# AI Guide — Jollibee Rewards Operator Console prototype

> **Scope:** for anyone (or any agent) building or editing this folder
> (`docs/design/prototypes/operator/`).
>
> **What this is:** a front-end-only, clickable **web app** simulator of the Jollibee Rewards
> **operator console** — HTML/CSS/JS, no build step. Demo and review only.
>
> **What this is NOT:** not the design system (that's `nmblr.foundry-codex`), not the member app
> (that's [`../prototypes/member/`](../prototypes/member/)), and not production code.
>
> **Operator-facing copy has its own reference: [`CONSOLE-VOICE.md`](CONSOLE-VOICE.md).** Read it
> before writing any string. The member app's [`VOICE.md`](../prototypes/member/VOICE.md) **does not apply
> here and its word list inverts** — that is the easiest mistake on this whole prototype.

Grounded in **[PRD-0007 Operator Surface](../../product/PRDs/0007-operator-surface.md)** v2.0 and
specified by **[UX-0007](../../ux/0007-operator-console/)**.

---

## Precedence: UX-0007 > PRD-0007 > the member prototype

- **UX-0007 § agent-surface is superseded, not authoritative.** It specifies an Ops Assistant this
  console no longer has. Divergence from it is intentional as of 2026-09-21 — see the note under
  *The six rules that matter*. Do not close the gap by rebuilding the assistant.
- **The RFI outranks the v29 SOW where they disagree.** Owner's decision, 2026-09-21. v29 sells
  more than the RFI describes — AI fraud detection, churn models, an economics agent — and the
  console is built to the narrower of the two.
- **PRD-0007 is authoritative on what the console can do.** Twelve stories, four sub-features. The
  fifth sub-feature (agentic operations) is **proposed, not approved** — staged at
  [PRD-0007 §7.5](../../product/PRDs/0007-operator-surface.md#sub-feature-75-agentic-operations-future), applied `[future]`.
- **The member prototype is authoritative on look.** Tokens and components come from it verbatim.
  Do not fork them; if a token needs to change, change it there and re-copy.

---

## The six rules that matter

> **The assistant is gone (2026-09-21).** The RFI sells no operator agent, and v29 puts agents at
> Phase 2. `js/agent.js` and the Netlify proxy are deleted. What survived is the **approval flow**
> it used to share with the buttons — PRD-0007 v2.2 makes preview-then-approve a programme rule
> across all 17 mutation paths, so it was never the assistant's to take away.
>
> [`agent-surface.md`](../../ux/0007-operator-console/agent-surface.md) in the UX spec still
> describes it. **Do not build to it** without a decision that reverses this one.

### 1. One write path. No exceptions.

`js/store.js` is the only thing that mutates state. `js/tools.js` wraps it. Every button goes
through `OC.propose()` → `Tools.preview()` → the proposal card → `Tools.apply()`.

**Never** call a `Store` action directly from a view renderer, and never add a second path for
convenience. The whole credibility of this prototype rests on there being one — it is what makes
7.4.1's *"checked on every action, not only when a surface is drawn"* structurally true.

If you add a capability: add a `Store` action, wrap it as a tool, and route the button through it.

### 2. Nothing is written without a preview and a reason.

The operator approves, and the operator is the recorded **actor**. The review panel is not a
convenience — it is the mutation path. A change applied without passing through it is a bug, not a
shortcut.

### 3. Fraud detection is RULES, not scores.

`PI-8` is the constraint: *"Rules-based today… **Thresholds, the action taken on breach, and the
scope a rule applies to** are all configuration… versioned with an **effective-from date**."*

Four configurables per rule, and all four move together — a threshold with no action is inert, and
neither is auditable without the date it took effect. `Tools.preview` says so rather than letting a
half-set rule be approved.

- **Do not reintroduce a composite score.** The weighted five-module model with banded outcomes was
  removed because it is not what a reader of `PI-8` takes "rules-based" to mean.
- **Do not add a rule TYPE.** `PI-8`: *"new rule types beyond the configurable set require a
  release."* The six in `OPS.rules` are the set. Their thresholds, actions and scopes are
  configuration; their existence is not.
- **Held scans carry observations, not scores** — a count, an interval, a multiple. If no rule can
  read an observation, it does not belong on the card: it is not evidence an operator can act on.

### 4. A refusal states its actual reason — with exactly one exception.

The exception is **out-of-scope subjects** (7.4.2). The message for *"this member does not exist"*
and *"this member is outside your scope"* must be **identical**, and must never say *"you don't have
access"* — that phrase confirms the subject exists, which is the disclosure the rule exists to
prevent. `Store.findMember()` returns `null` for both cases on purpose; keep it that way.

### 5. Say what is real.

`screen-manifest.json` and the harness both carry a real/mocked/absent split. If you make something
real, move it. If you fake something new, list it. The held-scan **observations** are seeded because
PRD-0003 marks the detection modules `[not yet built]` — do not present them as computed. The rule
check is real for the two rules the fixtures carry an observable for, and says so for the other
four rather than inventing a number.

### 6. No product notes on screen.

**The console never cites the documents that specified it.** No PRD numbers, no story numbers, no
`[confirm]`, no open-question references, no `A5`, no "net-new", no "Prism". An operator gains
nothing from them and trusts the surrounding copy less.

Where the fact matters, state the fact. Where it does not, cut it. Full word list and worked
examples in [`CONSOLE-VOICE.md`](CONSOLE-VOICE.md).

**Enforced by the verification driver**, which fails on that vocabulary in two places: every view
with accordions and disclosures opened, **and every tool's proposal card**, generated by previewing
all 17 tool paths rather than by clicking around. Story numbers live in `screen-manifest.json` and the
specs — that is what those files are for.

**And it bans spec *voice*, not only spec citations.** The first version of this check looked for
`PRD-`, `[confirm]`, story numbers and the like, and passed a proposal card reading *"the reason is
recorded — a member refused by a model is owed a reason a human can defend."* That quotes PRD-0003
without naming it. The ban list therefore also covers the register: *append-only*, *measurement
obligation*, *never silent*, *point of record*, *not a verdict*, *the ledger is*, *hold-don't-refuse*.

The tell is simple. **A spec sentence argues; an operator sentence informs.** If a line explains
*why the product was designed this way*, cut it. If it tells the operator *what will happen when they
press the button*, keep it.

| Argues — cut it | Informs — keep it |
|---|---|
| "the reason is recorded — a member refused by a model is owed a reason a human can defend" | "your reason goes on the record" |
| "the holdout flag is a measurement obligation, not an ordinary setting" | "this store stops taking part, so its results can be compared against the rest" |
| "recorded as a new offsetting entry, not an edit — the ledger is append-only" | "the balance is not edited — this goes on as a correction, so the original entry stays" |
| "configuration is never silently retroactive" | "nothing already settled changes" |

---

## Density conventions

The console carries 36 settings (21 an operator can change, 15 read-only), six fraud rules, eight held scans and a growing audit log. *(Corrected 2026-10-19: this said 29. The fixture held 37 until the agent credit limit and the supervisor approval threshold were merged into one row on the same day.)*
Three rules keep that legible:

- **One line of helper text per view and per field.** Under the title, under the label. Never two.
- **Reference detail hides behind a disclosure.** A setting's permitted range and shipped default
  are available on the row (`.set__more`) but not ambient — they competed with the value for width
  and lost, twice.
- **Groups that are long collapse.** Configuration is five accordions, `Earn` open and the rest
  closed. Open-state lives in `accOpen`, so a Store-driven re-render restores it rather than
  snapping everything shut.
- **Rows whose cells are sentences are not tables.** The audit log and the held queue use `.rec`
  record rows that wrap downward. A table that needs horizontal scroll to show a *reason* is the
  wrong component; one that shows values (`tbl--wide`) is fine.
- **A setting row is always one row**, in three parts: a **fixed 300px** label column (label +
  helper), a **left-aligned** value column, and the action pinned right. A long value wraps inside
  its column and must never span the row — that was tried and reverted twice.
- **A view has no width cap; the grid decides.** `.oc` gives main `minmax(0, 1fr)`, so a view takes
  whatever the rail and the dock are not using and reflows when either moves. A `max-width: 1120px`
  on `.view` used to cap every page — it left roughly 300px of dead canvas beside every table with
  the dock closed, and the `view--wide` escape hatch only moved the same problem out to 1600px. Both
  are gone. **What still caps is what reads rather than scans:** prose (`.view__sub` 78ch,
  `.help--sec` 84ch) and the settings list below.
- **Values left-align, and the list is capped at 900px.** Every value shares one left edge so the
  column can be scanned down, which is what an operator actually does. Right-aligning across an
  unbounded row produced a 400px void and a ragged wrap.
- **Monospace is for figures only** (`isFigure()`): "₱10 = 1 point", "0.35", "7 days". Prose values
  like "Code 39 barcode + printed digits" are option *names* and belong in the text face — in mono
  they read as code.
- **Value, note and reference detail are one stack** (`.set__col`). Placing them as separate grid
  children put the detail on a row whose top was set by the taller label column, detaching it from
  the value it describes.
- **Type floor:** body and table text ≥ 14px, helper text ≥ 12.5px. The first pass shipped 11–13px
  throughout and was rejected as unreadable.
- **One header standard, everywhere.** A section header is a **plain-noun title** + a count as
  **`<numeral> <noun>`, correctly pluralised** + one helper line. Use `Store.plural()` — never build
  a count by hand, and never write `item(s)` or spell a number out. It is shared with `js/tools.js`
  so proposal-card headers obey the same rule.
- **Attention lists are categorised by what they ask of an operator** — something to configure once,
  a number to watch, a queue to work. A flat list makes those look interchangeable. Omit an empty
  category rather than rendering it empty.
- **An action set is identical on every row of a table.** If an action does not apply to a row,
  render it **disabled with the reason in its title** — never omit it. A row with fewer buttons than
  its neighbour reads as a bug, and the operator cannot tell a permission from a state.
- **A scrolling flex column needs two things**, and missing either breaks it silently:
  `min-height: 0` on the scroller, and `flex: 0 0 auto` on its children. Without the second the
  children *shrink to fit* instead of overflowing, `scrollHeight` equals `clientHeight`, and
  `overflow-y: auto` never engages — content just gets crushed and clipped. This cost a review
  cycle; if something "won't scroll", measure `scrollHeight` against `clientHeight` first.

---

## Design system — Foundry + the Jollibee overlay

- **Tokens** — `css/tokens.css`, copied verbatim from the member prototype. **Do not edit it here.**
  Change it there and re-copy, or the two prototypes drift.
- **Components** — `css/components.css`, likewise verbatim. Compose `fds-*`; don't reinvent.
- **`css/console.css` is the only net-new layer** — the rail/content/dock shell, dense tables, the
  setting row, the proposal card, the signal chips. It replaces the member app's mobile
  `prototype.css`.

### Hard constraints

- **Tokens only for color:** `var(--c-*)`. No raw hex in CSS or markup, **except
  `js/dev-toolbar.js`**, which is fixed tooling chrome and must always read as separate from the
  product. That is the one documented exception, same as the member app.
- **Type:** `--font-brand` (Jellee) for the brand block only; `--font-display` (Baloo 2) for headings
  and chrome; `--font-text` (Nunito Sans) for body; `--font-mono` for codes, values and diffs.
- **Icons:** `<span data-icon="lucide-slug" data-size="N">` or `FIcon('slug')`. Lucide plus
  `brand:jollibee` only; add new Lucide paths to `js/icons.js`.
- **Status never rides on colour alone.** Icon plus words, every time — the `.st` atoms enforce it.
- **Density is the point.** Tables over cards for anything list-shaped. A table with many columns
  gets `tbl--wide` so it **scrolls inside `.tbl-wrap`** rather than compressing every column to
  unreadable width.

### Accessibility floor

- The dock is a `complementary` landmark, labelled.
- A new proposal focuses **the card, not the Apply button**, and `Apply change` is never a keyboard
  default. You should not be able to award points by pressing Enter.
- The thread is `aria-live="polite"`.
- Highlight-on-navigate respects `prefers-reduced-motion` (becomes a static outline).

---

## Persistence — the trap this hit twice

`Store` persists to `localStorage`, and **a bumped `VERSION` is not enough on its own.** Wave 2
rewrote every setting's label and added helper text; returning browsers kept serving the old copy,
and it read as a layout bug for a whole review cycle.

`reconcile()` is the rule now: **only values persist.** Anything that describes a thing — labels,
helper text, permitted ranges, shipped defaults, thumbnails, member names, partner names — is
re-read from `OPS` on every load. When you add a field to a fixture, decide which side of that line
it falls on and extend `reconcile()` if it is descriptive. Bump `VERSION` as well when the *shape*
changes.

## Data

- **`OPS` is the seed, `Store` is the truth.** `js/simulations.js` holds fixtures; the store loads
  them into `localStorage` once and then owns live state. **Render from `Store`, never read `OPS`
  directly** in a view renderer — except `OPS.ruleActions`, `OPS.ruleScopes` and `OPS.categories`,
  which are static reference data rather than state. `OPS.rules` seeds `Store.rules()`, which IS
  state — read it from the store.
- **`OPS.config` is [PRD-0000 § Programme Configuration](../../product/PRDs/0000-jollibee-rewards.md#programme-configuration)**,
  all of it. One divergence, deliberate: the PRD's single *"reconciled, on a daily cycle"* row is
  **two settings here** (`acceptanceMode`, `reconCycle`), because a console edits them separately.
  That makes **36 rows — 21 changeable and 15 read-only** *(corrected 2026-10-19; this said 29, and the fixture held 37 before the two credit-limit rows were merged into one that day)*. The PRD now carries the split (Acceptance mode / Reconciliation cycle) and the settlement rows; what this file adds beyond it is `agentOps`, landed `[future]` as PRD-0007 §7.5. **Labels are NOT verbatim** — eight were rewritten in plain language and PRD-0000 v2.3 adopted them.
- **Reads are scoped.** `Store.members()`, `.stores()`, `.held()`, `.audit()` all filter by role.
  Never bypass them with `Store.get().members`.

---

## Adding a view

1. Read the PRD-0007 story it realizes, and the matching section of UX-0007.
2. Add it to `VIEWS` in `js/app.js` (id, icon, group, title, story, sub) and add a
   `<section class="view" id="v-yourid">` to `app.html`.
3. Gate it in `allowedViews()` by permission. A view a role cannot use is **absent from the rail**,
   not present and refused.
4. Write the renderer. Read from `Store`. Compose `fds-*` and the `console.css` patterns.
5. Any mutating control calls `OC.propose(tool, args, { echo: '…' })` — **never** a `Store` action.
6. Register the view in `screen-manifest.json` and append a `BUILD-LOG.md` entry.

## Adding a tool

1. Add the `Store` action first, with its permission check, its reason requirement, and its audit
   `record()` call. Return `{ ok:false, code, message }` for every refusal — do not throw.
2. Wrap it in `js/tools.js` with a `preview()` that produces **`from → to` diffs** and an **effect
   line stating what it affects and from when**, plus what explicitly keeps its old value.
3. `preview()` must run the same checks as `apply()`, so a refusal surfaces **before** the operator
   is asked to approve.
4. Wire a button to it through `OC.propose()`. There is no other way in.
5. If the tool would let the console do something a human should have to do by hand — **don't add
   it.**

---

## Voucher codes live under the reward

There is no codes page. A reward in the catalog **expands** to its batches and its codes, because
"what does this cost" and "can anyone still claim it" are one question about one reward — and while
they were two pages, a reward could read `Available` in one and `Out of codes` in the other.

Three rules the data depends on:

- **Issuance is recorded per batch, never divided across them.** A reward-level total allocated in
  order cannot express a batch that expired part-issued, and produced a batch claiming 1,000 issued
  while 120 had in fact lapsed.
- **Status is derived in one pass** (`allocate()`), so the tiles, the batch table and the code list
  cannot disagree. A code has LAPSED only if its batch's date has passed; otherwise the leftover is
  AVAILABLE. Statuses used to come from two global pools, which put lapsed codes in valid batches.
- **Codes are generated from the batch reference, not stored.** Stable across renders, searchable
  without a table, and the sample never has to be seeded.

**Cover, not a floor.** Low stock is a number of days at the reward's own claim rate, against one
programme setting — because an absolute threshold cannot serve a Chickenjoy at 500 claims a week and
a Champ at 18. The equivalent count threshold for Choco Sundae is 1,410 codes, which would falsely
flag five rewards holding months of cover.

**The codes are listed on screen.** That was argued the other way first — an unissued code on screen
is one that can be copied off it — and the owner's call is that a voucher inventory shows its stock.
Access to the view is the control. Do not quietly re-hide them.

## What NOT to do

- Don't add charts, KPIs, trends, or segments. Analysis is Prism's; their absence is the boundary.
- Don't add a tool for role assignment, at any role.
- Don't reintroduce a composite fraud score, or a rule type beyond the six. See rule 3.
- Don't compute a metric about members, baskets or programme performance — even when the fixtures
  could support it. A number invented in a demo is read as a shipped capability.
- Don't make a refusal for an out-of-scope subject distinguishable from a not-found one.
- Don't prefill or auto-generate the reason on a change. An invented justification in a compliance
  record is worse than a blank one.
- Don't edit `css/tokens.css` or `css/components.css` here — they belong to the member prototype.
- Don't add raw hex outside `js/dev-toolbar.js`, or non-Lucide icons.
- Don't add a product backend, or any server code. There is none now, and nothing calls out.
- Don't reuse the member app's voice. See [`CONSOLE-VOICE.md`](CONSOLE-VOICE.md).
- Don't put a PRD number, story number or spec tag on a screen. See rule 6.
- Don't put reference detail (ranges, defaults, provenance) in ambient helper text — it belongs
  behind a disclosure.
- Don't trust a population an engine resolved. Deduplicate it, bound it, and surface anything that
  makes a subject's inclusion questionable — a suspended account, for one.
- Don't write a regression that calls the live model.
