# Build log — Operator Console prototype

Newest first. One entry per wave.

---

## Wave 7 — voucher codes, rebuilt under the reward · 2026-09-08

Wave 4 built a Voucher codes page. This wave replaced most of it, on the owner's direction, in four
moves — and found three data faults doing it.

### The vendor is not on screen, and "block" was jargon

*"Do not say 'Jollibee endorses…' because they're already Jollibee."* Correct: the operator **is**
Jollibee, and a console that narrates its own employer reads as written for somebody else. Codes are
now **loaded**, in **batches** — the owner's word, replacing the "block" this had invented.

### Request became upload

A request that recorded an intention and moved nothing was the honest model for asking a supplier.
It was also not what anybody wanted to do here. `codes.upload` replaces it and **actually adds a
batch**: a real file read with `FileReader`, parsed, deduplicated against what is already loaded,
with the counts shown *before* approval — `3 of 5 will load · 1 repeated · 1 not a code`. Uploading
the same file twice is refused.

The file is the control and pasting is a closed disclosure, because *"that's hundreds of codes per
batch"* and a textarea holding two thousand lines implies otherwise.

### The page is gone; the reward expands instead

Asked whether codes and rewards should be one page, the recommendation was to keep them apart and
cross-link. The owner's answer was better than that and better than merging: **expand the reward and
the codes appear there.** It keeps the catalog table narrow — a merged table needs eleven columns and
would scroll sideways — and it fixes the split-brain that a cross-link only papers over, where a
reward reads `Available` on one page and `Out of codes` on another.

The catalog row now carries `Codes left` and `Cover`; expanding gives the stock tiles, the batches in
draw order, the codes themselves, and Upload.

### Cover, not a threshold per reward

*"Do we need a configuration per reward?"* No — and the argument is arithmetic rather than taste. An
absolute low-stock count cannot serve a Chickenjoy at 500 claims a week and a Champ at 18: the
threshold that gives Choco Sundae three weeks' warning is **1,410 codes**, which would falsely flag
**five** rewards holding over ninety days of cover. So the warning is expressed in **days of cover**,
measured against each reward's own rate, from **one** programme setting. It keeps working at 200
rewards.

The screen shows the consequence plainly: Choco Sundae has 260 codes and is flagged at **3 days**;
Champ has 620 and is not, at **241 days**.

### Disabling a code

Per-code `Disable` / `Put back`, with a struck-through code and a red `Disabled` status. The
proposal card separates the two cases because they are not the same event: an unissued code *"is
never issued to anyone"*, while an issued one *"stops working at the counter — a member is already
holding this one, so they will be turned away."* A lapsed code's button is disabled with its reason
rather than absent.

**Not built:** bulk disable of a batch. The realistic trigger is a supplier reporting a batch leaked,
and that is not 2,000 clicks — raised, deliberately deferred, recorded in the manifest.

### Search

Over the reward's whole inventory, not the twelve rows on screen — a member ringing up with a code
does not know which batch it came from. It types live; 6,000 codes are generated to be walked rather
than stored, so the cost is a scan and not a table.

### Three data faults, all found by looking at the numbers

**Lapsed codes in batches that had not expired.** `voided` was a stored per-reward number and
contradicted the batches behind it. It is now derived: a code has lapsed only if its batch's date has
passed. Choco Sundae gained a genuinely expired batch so the figure has a visible cause.

**Issuance divided across batches.** With the reward-level total allocated greedily in draw order,
the expired batch filled to 1,000/1,000 and reported nothing lapsed. Issuance is now recorded **per
batch**, which is the only shape that can say a batch stopped part-issued.

**A `NaN` that took a whole roll-up with it.** The first uploaded batch had no `issued` field, so
`codes - issued` was `NaN` and the reward's balance rendered as null. Set explicitly on upload, and
defended in `allocate()`.

Plus one stale navigation: both the tool and the Store action still returned `view: 'codes'` after
the page had been retired, so a successful upload navigated nowhere.

### Verified by driving it

56 assertions through real clicks: the totals reconcile against their batches; the expired batch is
where the lapsed codes are and nothing lapses in a valid one; draw order holds and a far-future batch
queues behind an unspent nearer one; search finds an exact code among 6,000 and a fragment matches
many; disabling leaves the count alone; the issued case gets the heavier warning; the upload adds a
batch and refuses the same file twice; the assistant reaches disable by the same registry and still
refuses analysis. Campaigns (29) and the member case (26) still pass, all 13 views render, and all
26 tools preview cleanly.

**Files:** `js/simulations.js` (per-batch issuance, an expired batch, `perWeek`, the cover setting) ·
`js/store.js` (`allocate`, `codePage`, `codeAt`, `uploadCodes`, `setCodeDisabled`, VERSION → 11) ·
`js/tools.js` (`codes.upload`, `codes.disable`) · `js/app.js` (codes folded into `renderCatalog`,
`codesRow`, the `codes` form field, search, four handlers) · `js/agent.js` · `js/icons.js` ·
`css/console.css` · `app.html` · `AI-GUIDE.md` · `README.md` · `screen-manifest.json`.

---

## Wave 6 — the console uses the width it has · 2026-09-08

**Owner:** *"Utilize the full width of all pages when the operator is collapsed. There's a huge
whitespace on the right."*

Correct, and the cause was one line: `.view { max-width: 1120px }`. The shell grid was already
right — `.oc` gives main `minmax(0, 1fr)` and reflows when the rail or the dock moves — but every
view then refused the space the grid handed it. With the dock closed at 1680px that left about
**300px of dead canvas** beside every table, which is exactly what the owner screenshotted.

`view--wide` (1600px, applied only to Settlement) was the same mistake with a bigger number: a
per-page escape hatch instead of a fix. Both are gone, and the class with them.

**What still caps, and why it is not an oversight.** Prose — `.view__sub` at 78ch, `.help--sec` at
84ch — because a 1400px line of explanation is unreadable. And the **settings list at 900px**, which
this wave deliberately did not change: uncapped at 1680px the Change button ends up roughly **700px
from the value it changes**, which is the ragged-void failure the density conventions already record
as reverted twice. Configuration is a form, and a form is the one surface here that should not be
edge to edge. Tested at 1180px as a middle setting; the void was still ~500px, so 900 stands.

**A second defect, found in the screenshot of the first.** With the rail collapsed to 64px the group
labels were clipping mid-word — `PROGRAMM`, `OPERATION`, `GOVERNANC` — because the collapsed rule
only shrank them to 9px, and 9px of `PROGRAMME` still does not fit in ~40px of usable width. The
label is now a **rule** when collapsed: the grouping still reads, the broken word is gone, and the
text stays in the DOM so a screen reader still announces it.

### Verified by rendering it

Real Chrome at 1680×1000, driven into each state before the shot: dock closed, dock open, rail
collapsed. Campaigns rows that previously wrapped onto three lines now sit on one; Settlement's four
tiles span properly; Configuration keeps its form width; with the dock open everything shrinks back
without clipping. The 72 behavioural assertions across the three console suites still pass.

**Files:** `css/console.css` (the `.view` cap, `.view--wide`, the collapsed rail-group rule) ·
`app.html` (drop `view--wide` from Settlement) · `AI-GUIDE.md` · `screen-manifest.json`.

---

## Wave 5 — campaigns, and the member case · 2026-09-08

For **RFI evidence item 4** (`MK-1`, answer-doc row 113): *"campaign / offer tool screens: bonus
event, targeted offer, approval before publish"* — with the note that it *"also covers PI-6
back-office view."* Two builds, because those are two different surfaces.

### Campaigns, and the boundary it had to respect

PRD-0007 lists campaign authoring as a *future enhancement* and this console has kept segments off
every view because analysis is Prism's. The answer doc says the tool exists at Phase 1. Both can be
true if the split is drawn where `MK-2` already draws it: **the audience is chosen in Prism and
picked here.** So the offer builder has an audience *field*, not an audience *builder* — a list with
a count beside each name — and the assistant's segment guard is left exactly as it was.

Also absent, deliberately: **A/B and offer-level holdout.** `MK-3` says Phase 1 does not have them.
An inert control on the builder would have read as a shipped capability, which is the failure this
console has a rule about.

### Approval before publish, without a role switch in the frame

The screenshot asks for *"approval before publish"* and does not say who approves. So the shot is a
campaign sitting in **Awaiting approval** with a disabled Publish beside it — no role switching
needed to produce it.

The gate underneath is real. `Store.approveCampaign` refuses when the actor is the person who
submitted it, checked on the **actor** rather than the role, because two people can share a role and
the point of the gate is a second pair of eyes. The fixture is seeded so `c02` was submitted by the
person the console signs in as — so the gate is visible on first load, the same arrangement that
makes the fraud-band escalation visible without hunting for it.

Because the check is in the Store action rather than the button, asking the assistant to publish it
produces the same sentence the disabled button carries. That is the structural claim in the README,
demonstrated on a second path.

### A multi-field dialog

`askValue` takes one field, which suits a setting. A campaign is six, and proposing them one card at
a time would ask an operator to approve five things that mean nothing alone. `askForm` collects them
into one dialog and one proposal card whose diffs are the whole campaign — and whose effect line
says **"saved as a draft, nothing changes for members"**, because a card listing a multiplier and a
date otherwise reads like a launch.

### The member case (PI-6)

`PI-6` describes a member view with the evidence behind each claim, related accounts linked, and a
proportionate ladder: hold, freeze redemption, suspend, claw back, cancel vouchers, reinstate. All
of that already existed here **except** the gathering and the two middle steps.

So: no new rail entry. Members grew a **case** that opens below the table — held scans with the same
signal chips the held queue draws, accounts sharing a device or sign-up address, vouchers, and what
has already been done to the account. Plus `member.freeze` and `member.suspend`, with `Active`,
`Redemption frozen` and `Suspended` as three states rather than two.

**A freeze is the point of the ladder.** It stops redemption and leaves the balance, the earning and
already-issued vouchers alone; the proposal card says so, because what a freeze does *not* do is the
only reason to reach for it before suspension.

**What was not built, and why.** `PI-6` says suspensions need supervisor approval above routine
adjustment limits. PRD-0007 lists *"account suspension — thresholds and who may authorise"* as an
open question, still unowned. Inventing a tier here would answer a question the programme has not
answered, so suspension sits on the `members` permission with a mandatory reason, and the gap stays
visible.

### Two defects found on the way, neither of them new

**Proposal cards reused fixed field ids.** Every card rendered `id="pr-reason"`, and cards accumulate
in the dock thread — so from the second card on, every `<label for>` pointed at the first card's
input and clicking a label focused another proposal's reason field. Ids are now per-card and the
handler reads by class. Found because a driver could not fill the second card's reason.

**The analytics guard refused "lift the freeze on Aileen."** `/\b(lift|incrementality|causal)\b/`
caught *lift* in both senses — the causal-uplift noun the spec owns, and the ordinary verb. Exactly
the Wave 3b failure, one word later. The pattern now excludes a following determiner, so *"what lift
did the campaign produce"* is still Prism's and *"lift the freeze"* reaches the tool.

### Verified by driving it

29 assertions on campaigns, 26 on the case, all through real clicks: the gate holds at the button,
at the write path and through the assistant; a spent card higher up the thread cannot be re-applied;
the builder offers six fields, a picked audience and no A/B control; freezing leaves the balance and
status alone; reinstating lifts a freeze with it; a franchise partner sees no campaigns and cannot
open a case. The 17 code-stock assertions still pass, all 14 views render, and all 25 tools preview
cleanly.

**Files:** `js/simulations.js` (`audiences`, `campaigns`, device/sign-up on four members) ·
`js/store.js` (campaign state machine, `memberCase`, `linkedTo`, `setFrozen`, `setSuspended`,
VERSION → 7) · `js/tools.js` (five campaign tools, two member tools) · `js/app.js` (`renderCampaigns`,
`memberCase`, `askForm`, per-card ids, nine handlers) · `js/agent.js` (context, prompt, intents, the
`lift` fix) · `js/icons.js` · `css/console.css` (`.dlg--form`) · `app.html` · `screen-manifest.json`.

---

## Wave 4 — voucher-code stock · 2026-09-08

For **RFI evidence item 6** (`C-5`, answer-doc row 16): *"voucher inventory / stock view for the
programme team."* The member half already existed — an issued voucher and the list it sits in — so
this wave is the console half only.

**The model, as the owner stated it.** Jollibee endorses a block of voucher codes to the programme,
each block tied to one reward in the catalog. A redemption draws the next code from that reward's
block. The vendor's name is not on screen: it is Jollibee's supplier, not a system an operator
interacts with, and naming it would put a third party into a console that has no other mention of
one.

### Six columns, and where the other two went

The first cut had eight: endorsed, issued, **redeemed**, **expired**, remaining, low-stock, validity,
last endorsement. Redeemed and expired came out. Neither changes how many codes are left to issue —
which is the only question this view exists to answer — and both are *voucher lifecycle*, which
Reconciliation already matches daily against the store's own record. Two views answering the same
question with separately-derived numbers is how they end up disagreeing.

Last endorsement moved into the reward cell as a helper line, the way Members carries a phone number
under a name. That leaves six columns, which is what fits at console width without `tbl--wide`.

### Three things that are computed, not seeded

- **`remaining` is not `endorsed − issued`.** A code that reaches its validity date before anyone
  claims it is voided, and it has to come out of what is left or the view overstates cover. Choco
  Sundae, Coke Float and Palabok each carry some.
- **The validity date shown is the *earliest* across a reward's blocks, not the newest batch's.**
  The block that lapses first is the one that decides when codes are needed. Showing the newest reads
  reassuringly and is wrong — Yumburger has a block good to Jun 2027 and still shows Dec 2026.
- **The stock state.** Out at zero, Low at or below the reward's threshold. Jolly Crispy Fries is
  seeded fully issued so the `C-5` claim is demonstrable rather than asserted: a reward with no codes
  stops being claimable in the app instead of failing at the counter.

### A request is recorded, not fulfilled

`codes.request` does **not** move the stock. The codes do not exist until Jollibee endorses the
block, so a request that topped up the count would demonstrate a capability nobody has. The proposal
card says so on its face, because an operator who expects the number to jump will read an unchanged
one as a failure and ask again.

It goes through the same path as every other mutation — `OC.propose` → `Tools.preview` → approve with
a reason → `Store` action → audit entry — so it needed no new write path, and the assistant reaches
it through the same registry. Where a request is already open the button stays and says so in its
title rather than disappearing, per the action-consistency rule.

### Scope

Programme-wide stock is not a franchise partner's subject. `stockRows()` returns nothing without
catalog permission and the view is absent from their rail, matching how the programme report is
already handled.

### The assistant

Stock is a count of our own codes, which the boundary already puts on the answerable side alongside
counting vouchers — so `voucherCodes` joined the context and the prompt's answerable list. Without
it *"how many Chickenjoy codes are left"* is refused as though it were analysis, which is the same
failure as Wave 3b.

### Verified by driving it

No headless driver existed here, so one was written (`jsdom`, kept outside the repo): it loads the
real `app.html`, runs the real scripts, and clicks real buttons. What it asserts, through the UI
rather than against the functions: the rail entry opens the view; the blocks disclosure opens below
the table and **prints no code**; the request dialog carries the reward's name; approving without a
reason writes nothing; approving with one reaches the audit log carrying the operator's own words;
and the stock is unchanged either way. All twelve other views still render, and all 18 tools still
preview cleanly.

**Files:** `js/simulations.js` (`codeStock`) · `js/store.js` (`stockRows`, `stockRow`,
`stockSummary`, `requestCodes`, VERSION → 5) · `js/tools.js` (`codes.request`) · `js/app.js`
(`renderCodes`, `blocksSection`, two handlers) · `app.html` · `js/agent.js` · `js/dev-toolbar.js` ·
`screen-manifest.json`.

---

## Wave 3b — it was refusing questions it held the answer to · 2026-08-24

**Owner:** *"These questions should be answered by the AI right?"* — after asking *"how much is the
current conversion rate"* and being told *"I cannot answer analytical questions about programme
performance."*

Correct, and the refusal was wrong twice over. **"Conversion rate of our rewards points" is the earn
rate** — `₱10 = 1 point`, a setting sitting in the assistant's own context. And the refusal did not
come from our guard: the guard's wording is *"That's a Prism question…"*, so that message was the
**model's own**. The prompt had told it what it must not answer and never once what it should.

### Three causes, none of them the model

**1. The prompt was all prohibition.** Given only a list of forbidden topics, a model generalises
from it and refuses anything adjacent. The prompt now **leads with what to answer** — settings,
catalog, stores, the held queue, bands, reconciliation, a member in scope, recent audit entries, and
its own capabilities — with a glossary of what operators actually say (*"conversion rate"*,
*"breakage"*, *"liability"*, *"what we owe"*) and the setting each maps to. The prohibition stays,
below it, and now ends with: **when you refuse, name the nearest thing you can do.**

**2. The guard blocked figures this console had since acquired.** `/what's our (breakage|redemption
rate)/` was still in the analytics list — written before reconciliation existed. Reconciliation
**measures** breakage, and [views-reconciliation](../../ux/0007-operator-console/views-reconciliation.md)
says plainly that counting your own vouchers is bookkeeping. The spec had moved; the guard had not.
Removed, along with the over-broad `how many/much … redeemed`. Added: `why is/are/did…` — stating a
figure is ours, explaining it is Prism's.

**3. The context could not support the answers.** This was the real fault, and it was ours:
- `settings` sent `value` but not `display`, `unit`, `allowedRange` or `shipsAs` — so the model said
  *"the earn rate is 10"* instead of *"₱10 = 1 point"*, and *"the allowed range is not specified"*.
- `heldScans` sent a composite `score` but not the five `signals` — so *"why was this held"* could
  only answer *"because its score was 0.78"*.
- there was **no reconciliation block at all**, so no money question was answerable.

All three added. The pattern from Wave 3a repeated exactly: **when the model gets it wrong, suspect
the context first.**

### Scripted parity

The offline path answered none of these either. Added intents for the earn rate and its synonyms,
breakage, liability, unset settings, and a capability list — plus a **generic setting lookup** that
matches a question against all 27 labels and helper strings, so the scripted path can answer any
setting rather than only the hand-written ones. It sits last in the intent list and returns `null` on
a weak match so a later intent can try, and `scripted()` now treats a null answer as no-match instead
of emitting an empty reply.

### Verification

**Both paths, same 16 questions.** Ten that must be answered — the two conversion-rate phrasings from
the screenshot, breakage, liability, voucher expiry, unset settings, a held scan's reasons, an allowed
range, and *"what can you answer?"* — and six that must still refuse: segment, ranking, trend,
forecast, basket, role assignment. **All pass on live and scripted.**

Then the deterministic regression, **23 assertions**, including a new one that the generic answer
intent does not shadow the five proposal intents, and that a franchise partner asking for programme
breakage is told it is not theirs.

---

## Wave 3a — the assistant runs on a real model · 2026-08-24

**Owner:** *"I authorized to hardcode the OpenRouter API key for the receipt scanning. Let's also do
it for the Ops Assistant."*

A **built-in OpenRouter key** now ships in `js/agent.js`, mirroring the same owner-approved
arrangement already in the member prototype's `js/ocr.js`. The assistant is a **live model by
default** — scripted is now what you get when the harness forces it or a call fails, never because
nothing was configured. Model defaults to `google/gemini-2.5-flash`, the one the receipt OCR already
proves works on this account, and it is swappable in the harness.

> **The key ships in the bundle.** Anyone who opens the page can read it, and it bills the account it
> belongs to. Deliberate for a controlled prototype; the proxy at `netlify/functions/agent.js` is
> still the right path for anything real. Rotate it at openrouter.ai/keys when the demo is done.

**The guards did not move.** The analytics boundary and the role-assignment exclusion run in
`js/agent.js` *before* any transport — verified by counting `fetch` calls: both refuse with **zero
network requests**. That was the point of putting them there.

### Three defects the live model found that the scripted path never could

**1. The context could not answer the question it was being asked.**
`context()` sent held scans with a store but **no date and no memberId**. So for *"everyone whose
scan was held at JB0142 on 22 August"* the model returned everything at that store — including
**Ramon Tolentino**, whose scan was held on the 20th, and who is *suspended for identity linkage*.
Awarding him points is precisely the wrong outcome.

The first fix was a prompt instruction not to widen a population. That turned the wrong answer into
an honest refusal — *"I cannot filter held scans by date"* — which was correct, and pointed at the
real problem. **`date` and `memberId` are now in the context.** The model has matched the
deterministic set exactly on every run since.

**2. A member could be awarded three times in one bulk operation.**
With dates supplied, the model returned one id per *held scan*, and Aileen Bautista has three on 22
August. She would have received **150 points instead of 50**. Deduplicated in
`Tools.preview('member.award')` **and** in `Store.award()` — the invariant has to hold at the write
path regardless of who assembled the list.

**3. A suspended account could be swept into a bulk award unnoticed.**
The set is listed explicitly (7.5.2) so a human *would* catch it, but the card should not make them
squint. A non-active member is now marked **⚠ suspended** on its own diff row and named in the
effect line, and the card carries the warning treatment.

None of these are model failures, and that is the point: **two were our data and one was our
arithmetic.** A described population resolved by any engine — scripted, model, or a person typing
ids — could have hit all three.

### Verification

**Live smoke test, real API calls:** 14 assertions covering six intents end to end (config, catalog,
member award, store holdout, a below-zero refusal, an authority refusal), the two local guards with a
`fetch` counter proving no call is made, bulk-by-description matched against the deterministic answer
three runs in a row, and the harness force-scripted toggle.

**Regression stays on the scripted path**, deliberately — deterministic, offline, free. 21
assertions: all ten views, both write paths, the four refusals, the reconciliation chain, scoping,
and no product notes.

---

## Wave 3 — Reconciliation and the programme report · 2026-08-24

**Owner:** *"we need a reconciliation page/feature… You're just missing the overall report like how
the performance of the whole program is and not just the store."*

Spec first: [views-reconciliation.md](../../ux/0007-operator-console/views-reconciliation.md), plus
a staged [PRD-0006 delta](../../ux/0007-operator-console/prd-0006-delta.md). Then built as the
console's **tenth view**.

### What this closes

[PRD-0006 story 6.4.3](../../product/PRDs/0006-redemption-crew-burn.md) commits to per-store daily
reconciliation reports and then says **"Report contents and delivery are unspecified."** And
[PRD-0000 § User Roles](../../product/PRDs/0000-jollibee-rewards.md#user-roles) defines the Franchise
partner as seeing *"their own store's reconciliation reports and nothing else"*, naming the surface
**"Reconciliation reports"** — a surface that did not exist. That role signed in and got Stores,
Audit and Access. Nothing it exists for.

### The programme report — the half a per-store report cannot give you

6.4.3 is per-store *by design*, because it is the franchise agreement. Nobody had written down that
corporate also needs the total. It now shows liability, volume, exception rate by type, a per-partner
rollup — and the figure that matters most:

> **Measured breakage, against the assumption.**
>
> The Reward catalog projects cost from configured point costs × an **assumed 88%** breakage, which
> `A5` records as nowhere defended. Reconciliation holds the **actuals**, so the console now shows
> **91.5% measured** beside that 88%, and **0.43% actual reward cost** beside the **0.61%
> projected**. Members redeem *less* than assumed, so real cost is lower than modelled.
>
> This **refines** the boundary in [agent-surface §1](../../ux/0007-operator-console/agent-surface.md),
> which said catalog economics must never use measured redemption. That rule was right for a
> forward-looking what-if and it stays — editing a point cost still models against the assumption.
> What changed is that the assumption is no longer the only figure available. **Counting your own
> vouchers is bookkeeping, not analysis.**

### Design decisions

**Two registers, because conflating them produces nonsense.** The cycles are recent detail (30 lines,
₱2,686); `*ToDate` is the programme's cumulative position. Reading 30 lines against 4.15M issued
points would have put realised breakage at 99.9%. Both are labelled for what they are.

**A partner's view is the corporate view filtered**, never a separately derived report — because
[PRD-0004 §4.3.2](../../product/PRDs/0004-transaction-validation.md) holds that *"a discrepancy is a
defect on our side, not a reconciliation exercise"*, and two derivations of one number is how you
manufacture that defect.

**Programme-to-date is withheld from a scoped role entirely.** A franchise partner has no business
seeing the whole programme's breakage. `reconSummary()` returns `toDate: null` when scoped.

**A line is never edited.** A wrong line is disputed and resolved — the same append-only rule the
points ledger follows. There is no tool to change a line's points or value, at any role.

**Money moves through the same single write path** as everything else: proposal card, reason, audit
entry. No exceptions for finance.

**Corporate-only actions are gated on scope, not just permission.** A partner can *agree* and
*dispute*; close, reopen and settle are corporate. An out-of-scope cycle returns `notfound`, not a
permission error — consistent with 7.4.2.

### It also fixed the dead-tile finding

The Overview's tiles were a fixed set, so a franchise partner saw programme-wide held-scan and
settings counts — data outside their scope — every one linking to a view that silently redirected.
Tiles are now built from what the role can **reach** and from **scoped** reads, and reconciliation
gives that role real tiles: *Owed to you*, *Awaiting your agreement*.

**And a leak the tests caught that the tile fix did not.** `Store.watchers()` was never scoped, so a
partner's Overview still announced "Detection bands are not set" and "3 settings never set". Tiles
and watchers are built in different places; fixing one did not fix the other. Both are scoped now.

### Verification

**40 assertions**, all passing: the full close → agree → settle chain, agreement refused while a line
is disputed, dispute → uphold → money coming out of what is owed, the audit entry for each, and for
the franchise role — no Close/Settle/Reopen buttons, no programme report, no partner rollup, no other
partner's store codes anywhere in the DOM, no dead tiles, and no programme counts.

### Open, and recorded in the spec

R1 is the one to settle before this is built for real: **does reconciliation match against the JFC
transaction feed, or a store-level tender report?** The prototype does store-level, the harder case,
which degrades to the feed-based one. Also open: what happens to a line past grace, who may dispute
and accept, and delivery to finance contacts the client has not yet supplied.

---

## Wave 2c — action consistency, categorisation, headers, dock scroll · 2026-08-24

**Owner:** *"Why do some members have award, deduct, revoke, and some only have award and deduct?
… the items that need attention are not categorized. Also the headers are hastily made. Please fix
them across pages. The Ops Assistant pane is also not scrollable fully."*

### Why Revoke was missing — and why that was the wrong answer

`Revoke` was rendered only when the member held an outstanding voucher. The rule was right (there is
nothing to revoke otherwise) but it was **invisible**: an operator could not tell whether they were
looking at a permission, a state, or a bug. Inconsistent action sets across rows of one table read as
carelessness.

Now **every row carries the same three actions**, and Revoke is *disabled* with the reason in its
title where there is nothing outstanding. It visibly tracks the Vouchers column, so the table
explains itself.

### Attention items are categorised

The Overview was one flat list mixing three things that demand different responses. Split into three
sections, each with its own count and helper:

| Section | What it holds | What it asks of an operator |
|---|---|---|
| **Programme setup** | Unset detection bands, settings never set | Configure once |
| **Programme economics** | Reward cost against target | Keep an eye on a number |
| **Decisions waiting** | Held scans | Work a queue, item by item |

Empty categories are omitted rather than shown empty.

### One header standard, applied across every page

The headers had been written ad hoc: `10 item(s)`, `Five modules` beside `12 rewards`, `The record`,
`Queue`, `8 of 8`. Now: **a plain-noun title, plus a count as `<numeral> <noun>`, correctly
pluralised**, plus one helper line.

`Store.plural()` is the single implementation, shared with `js/tools.js` — so a proposal card header
follows the same rule as a section header. "Proposed change · 5 modules", never "5 module(s)".

| Was | Now |
|---|---|
| `Needs attention · 10 item(s)` | three categorised sections, each with its own count |
| `Listings · 12 rewards` | `Rewards · 12 listings` |
| `Detection modules · Five modules` | `Detection modules · 5 modules` |
| `Queue · 8 waiting` | `Waiting on a decision · 8 scans` |
| `The record · 8 of 8` | `Activity · 8 records` |
| `Result` / `All members · 12 shown` | `Search result · 1 match` / `Members · 12 members` |
| `Effect of a change` | `Impact of a change` |

### The dock was not scrollable — and the reason was not the obvious one

Two bugs stacked. `.dock__thread` was missing **`min-height: 0`**, the classic column-flex problem
where a `flex:1 1 auto` child grows past its parent instead of scrolling. Fixing that alone was not
enough, and measuring rather than guessing found the real one: **the thread is a flex column, so its
children were shrinking to fit** — the proposal cards measured **5px tall each**, `scrollHeight`
equalled `clientHeight`, and `overflow-y: auto` therefore never engaged. `.prop` has
`overflow: hidden`, which is exactly why the five-row band card in the owner's screenshot showed a
diff row cut in half.

`.dock__thread > * { flex: 0 0 auto; }` fixed it: cards went from 5px to 334px, thread `scrollHeight`
from 532 to 2505.

Two more, found alongside:
- The harness offset only `#main`, so the fixed bar covered the dock's composer and its hint text.
  It now offsets the dock as well, measured from the bar rather than hard-coded.
- `.prop__diffs` capped at `220px` created a nested scroller that clipped a five-diff card. Raised to
  `50vh`, which still catches the genuinely long case (a bulk award over dozens of members).

Verified: 13 assertions, including card heights, `scrollHeight` vs `clientHeight`, that the last
card's Apply button and reason field are inside the visible thread after scrolling, that the composer
clears the harness bar, and that a five-diff band card is not clipped.

---

## Wave 2b — two defects, and the settings layout · 2026-08-24

**Owner:** *"This layout is really bad. Also, the accordion causes the whole page to reload."*

### The reason the layout looked nothing like Wave 2

**`VERSION` in `js/store.js` was never bumped.** Wave 2 rewrote every setting's label and added
helper text to all 27, but the store only discards a persisted payload when its `version` changes —
so a browser that had opened Wave 1 kept replaying **Wave 1 config under Wave 2 chrome**: old labels
("Presentation refresh interval"), the PM notes that were supposed to be gone ("Rotation defended a
bearer instrument", "Floored, on TOTAL DUE"), and no helper text at all. Every review note about
missing helper text was correct, and none of it was a layout problem.

Bumped to `VERSION = 3`, which fixes it once. **`reconcile()` fixes it for good:** on load, only the
values an operator has actually chosen are taken from `localStorage` — everything that merely
*describes* a thing (labels, helper text, permitted ranges, thumbnails, partner names) is re-read
from `OPS`. Copy can never be frozen into a browser again. Covers config, catalog, members and
stores.

### "The accordion causes the whole page to reload"

Not the accordion — the **ⓘ disclosure**, which called `render()` and replaced the whole view's
`innerHTML` for something that is not a state change. Now toggled in place: the row node survives,
scroll position holds, and `specMarkup()` is the single source of the markup so the rendered and the
toggled-in path cannot drift.

### The layout

Diagnosed as six things, all downstream of one decision — label pinned left, value pinned right
across an unbounded row:

| Problem | Fix |
|---|---|
| ~400px void between a setting's name and its value | Label column **fixed at 300px**, value starts immediately after |
| Right-aligned values wrapped to a ragged left edge | Values **left-aligned**; all 27 now share one left edge (verified geometrically) |
| Monospace on prose — "Code 39 barcode + printed digits" read as code | Text face by default; **monospace only for figures** (`isFigure()` — contains a digit, ≤22 chars) |
| Notes were full-width tinted bars, so a group read as a warnings list | Quiet muted line under the value |
| The disclosure was a third horizontal band inside one row | Quiet inline line, in the value's own stack |
| Nothing to scan down | A real value column, capped at **900px** so it never stretches |

Value, note and reference detail are one stack in column 2 (`.set__col`) — before that the detail
dropped to a second grid row whose top was set by the taller label column, leaving it visually
detached from the value it describes. Gap went from ~30px to 4px.

Verified: 15 assertions including geometric ones — a single shared left edge for all 27 values,
label/value/action sharing a top edge, computed font families per value type, the list capped at
900px in a 1700px viewport, and the disclosure opening in place without a rebuild or a scroll jump.

---

## Wave 2a — setting rows back to one line · 2026-08-24

**Owner:** *"The multi-line doesn't work. Revert back to single line."*

Wave 2 let a long setting value drop to its own full-width line below the label. It read as broken:
the value lost contact with the label it belonged to, and the action button was left orphaned at the
top right of an otherwise empty row.

**Reverted.** Every setting row is one grid row again — label and helper on the left, value in its
own column, action pinned right. A long value now **wraps inside its own column** rather than
spanning the row, which is ordinary row behaviour rather than a special case.

Two things came out of the revert:
- The value column is bounded (`minmax(150px, 300px)`) so a long value wraps instead of eating the
  label's width. That was the actual problem the full-width row had been papering over.
- Moving the value back onto the label line left the **allowed-values icon orphaned** on the help
  line when the helper text nearly filled the row. It now sits **beside the label**, where lines are
  short, which also reads better — the affordance is about the setting, so it belongs with its name.

**Unchanged:** the audit log and held queue still use record rows. Those are not long *table* rows —
their content is sentences, and putting a reason in a column meant scrolling sideways to read it.
Flagged for the owner in case the note was meant to cover those too.

Verified: 17 assertions, including geometric checks that label, value and action share a top edge
in all 27 rows, that no value exceeds 60% of its row, and that no disclosure icon is orphaned.

---

## Wave 2 — density pass · 2026-08-24

**Owner's review of Wave 1:** *"Remove all product notes (notes for me), but add helper texts (for
the user) for fields and sections... Also the texts are so small. The organization of the information
and the rows can be smarter too."*

### What changed

**1. Product notes are gone from the UI, and now stay gone.**
Wave 1 put the specification on screen — PRD citations, story numbers, `[confirm]` tags, `A5`,
"takes a side on an open question", the no-charts defence. Useful to a PM reading a prototype;
noise to an operator, and it makes the copy around it read as unfinished. All of it removed from
`app.js` and from the fixtures.

**The rule is now enforced rather than trusted.** The verification driver sweeps every view — with
every accordion and disclosure opened — for that vocabulary and fails the build if any of it
reaches a screen. Story numbers still exist, in `screen-manifest.json` and the specs, which is what
those files are for.

**2. Helper text everywhere it is read.**
One line under every view title, one under every field label, one per configuration group. All 27
settings got a written helper answering *what is this for* — not *how it was specified*. The
distinction is the whole point: "How long a claimed reward stays valid" replaces
"Jollibee bounds a capability the product leaves open."

**3. Type scale up.**
Body and table text 13px → 14.5px, helper text 11px → 13px, view titles 22px → 26px, tile figures
24px → 30px, headers 10.5px → 11.5px. The first pass was dense to the point of unreadable — density
was doing the work legibility should have.

**4. Reorganised, three ways.**
- **Accordions.** Configuration is five collapsible groups (`Earn` open, the rest closed) instead of
  27 rows in a wall. Catalog economics is an accordion, closed. Open-state lives in `accOpen`, so a
  Store-driven re-render restores it.
- **Per-row disclosure.** A setting's permitted range and shipped default moved off the row and
  behind a quiet info affordance. They had been competing with the value for width and losing.
- **Record rows instead of wide tables.** The audit log and the held queue are `.rec` rows that wrap
  downward. Both had cells containing *sentences*, which is the wrong thing to put in a column — the
  audit table needed horizontal scrolling to reach the reason. A long free-text setting value also
  drops to its own full-width line rather than squeezing its own label.

### Two problems the pass surfaced

**Enum settings were showing storage keys.** `code39`, `apporder`, `receipt · apporder · memberqr` —
keys an operator has no reason to know. Added `Store.label()`, used by the settings list *and* the
proposal card, so a diff never shows a key either.

**Two settings were unreachable.** `multi` and `text` types had no editor and rendered a dead
"Set elsewhere" label. Both are now editable and route through the same proposal card, with the
allowed-values check running in `Tools.preview()` so a bad key refuses before approval rather than
after.

### Verification

Driver rewritten for the new copy and layout: **38 assertions, all passing**, including four new
classes of check — no product vocabulary in any view, helper text present on every view and all 27
settings, accordion defaults and open-state persistence, and computed font sizes above the floor.

Four failures in the first run were the driver's own fault: the copy sweep opens every accordion, so
it polluted the state the accordion-default assertions were about. Reordered to run the sweep last.
A fifth was real — table monospace cells were still 13px. A sixth was a wrong assertion: a franchise
partner cannot reach Members *at all* (the view is absent from the rail and a deep link redirects),
which is better than the banner I had asserted on.

---

## Wave 1 — the whole console · 2026-08-24

**Built in one pass** against the approved [UX-0007](../../ux/0007-operator-console/) sample spec.
Nine views covering all twelve PRD-0007 stories, plus the Ops Assistant dock.

### Decisions made while building

**1. Every mutating control routes through the proposal card.**
The spec only required the *assistant* to propose before acting. Making the **buttons** do it too was
not in the plan, and it is the best decision in this build: it collapses two write paths into one, so
7.4.3's *"the identical record whether the operator clicked or asked"* stops being a promise and
becomes the only thing the code can do. One card, one reason field, one apply path, one audit entry —
differing only in the `instrument` column.

Cost: a button click now takes two steps. For consequential actions that is correct, and every action
in this console is consequential.

**2. The guards moved out of the prompt and into the code.**
The spec said the analytics boundary and role-assignment exclusion were rules the assistant must
respect. Asking a model to respect them would make PRD-0008 §8.2.3 compliance a matter of luck. They
now run in `js/agent.js` **before any transport**, so they hold identically in model mode and scripted
mode. `Agent.isAnalytics()` and `Agent.isRoleAssign()` are exported so they are testable.

**3. The proxy is a dumb pass-through — a deliberate divergence from `ocr.js`.**
The member app's OCR function rebuilds its own request, so the extraction prompt exists twice and has
to stay byte-identical. That is a standing hazard. Here the prompt, tool schema and context are built
once in `js/agent.js` and travel in the body; `netlify/functions/agent.js` only injects the key. Same
secret handling, one copy of the thinking.

**4. The default role has no fraud-tuning authority.**
Signing in as Loyalty Ops rather than Platform Admin means the 7.3.2 escalation is visible on first
load instead of needing a role change to find. The authority notice deliberately **names no role**,
because who holds it is `[confirm]`.

**5. The Overview summarises rather than reprints.**
First pass rendered all eight held-scan watchers, which buried the two structural findings (bands
unset, A5 economics) below a scroll. Now: the structural findings first, three held scans, then a link
to the queue. The Held scans view *is* the queue; the Overview is not a second copy of it.

**6. Config is 27 rows against the PRD's 25.**
PRD-0000's single *"reconciled, on a daily cycle"* row is two settings here (`acceptanceMode`,
`reconCycle`) because a console edits them separately — 26 from the PRD, plus the proposed `agentOps`.
Recorded in [AI-GUIDE](AI-GUIDE.md) so it doesn't read as drift.

### Findings the build surfaced

**The two reward-cost benchmarks disagree, and the PRD only documents one.**
Implementing the `A5` arithmetic made this unavoidable. The member prototype's catalog prices each
reward at about **2× the item's peso value** — gross 5.08%, needing **72–80% breakage** to land the
1.0–1.4% target. PRD-0007's `A5` note reasons instead from a **100-points-per-item** benchmark —
gross 10.00%, needing **86–90%**, which is exactly where the note's figure comes from. Our
implementation reproduces 86–90% from that benchmark, which is how we know the model is right, and
that is also how we know **the two catalogs are not the same catalog.** Neither breakage figure is
written down or defended anywhere. Both are now shown side by side on the Catalog view rather than
silently resolved.

**PRD-0003 has three cross-references that a new sub-feature 7.5 would break.**
Lines 195, 202 and 290 read `[7.5](0007-operator-surface.md)`, meaning PRD-0000's *story* 7.5 (fraud
tuning) but resolving into PRD-0007, where fraud tuning is sub-feature **7.3**. Loose today, wrong the
moment a 7.5 exists. Retargeting them is a prerequisite in the
staged delta §3, not a follow-up. *(Both landed: the cross-references on 2026-08-27, and the delta itself on 2026-09-02 as [PRD-0007 §7.5](../../product/PRDs/0007-operator-surface.md#sub-feature-75-agentic-operations-future), tagged `[future]`.)*

**The member app's voice inverts here, and nothing said so.**
`VOICE.md` bans *pending*, *settle*, *validation*, *dedup* — which are an operator's correct
vocabulary. Written up as [CONSOLE-VOICE.md](CONSOLE-VOICE.md) before any string was committed,
rather than after a copy pass found jargon in eleven of them.

### Verification

A driver loaded `app.html` in headless Chrome and drove the **real DOM** — clicking real buttons,
typing into the composer, approving real proposal cards — rather than calling functions directly.
**53 assertions, all passing.** Covered in `screen-manifest.json` under `verification`.

Three bugs it caught that reading would not have:
- the reason clause `"...because her voucher failed to scan"` was being swallowed into the captured
  member name, so *"Award 100 points to Marie because…"* resolved to no member;
- the bulk-award fixture had only one distinct member held on any one date, so M3 looked like it
  worked while proving nothing — three held scans were re-dated to give it four;
- refusals echoed the subject lower-cased (`"No member found for marie de guzman."`), because intents
  matched a lower-cased copy of the text.

Two design problems the screenshots caught:
- the audit table compressed every column instead of scrolling with the dock open — `tbl--wide` now
  makes wide tables scroll inside `.tbl-wrap`;
- the Overview problem in decision 5 above.

### Not built

- **No bridge to the member app.** An award here does not move the balance in
  [`../prototypes/member/`](../prototypes/member/). Deliberate — see README, *Relationship to the member app*.
- **Who may use the assistant** is not modelled (UX-0007 open question A1) — every console role can.
- **The `A3` question** — whether a member is told *how* a correction was decided — is policy, not UX,
  and is untouched.

---

## Wave — RFI-first conformance · 2026-09-21

**Not a feature wave. A removal pass**, asked for in these words: *"We need to remove what's not
agreed in the RFI and SOW only. There are more features than what we defined."*

### The decision that shaped it

The v29 SOW and business case sell **more** than the RFI describes, not less. Asked which boundary
wins, the owner chose **the RFI**. That is the opposite of the workplan's standing rule — which
conforms the SOW to the RFI — applied deliberately, knowing it narrows sold scope.

Where v29 is wider, and the RFI now governs:

| v29 sells | The RFI says |
|---|---|
| *"Adaptation of NMBLR's receipt OCR and **fraud-detection models**"* (SOW WS2); *"AI fraud detection"* and *"anomaly detection on receipts and voucher redemptions"* (BC §3) | `PI-8`: *"**Rules-based today**… we are not proposing [model-based] for the pilot and it should not be read as an existing capability"* |
| *"one-time codes, short TTL"* (BC §3) | `PI-5`: *"**We cannot** detect a member giving a voucher to a friend, **and we would not claim otherwise**"* |

### What changed

**1 · Fraud tuning → Fraud rules.** The five weighted module scores, the banded outcomes and the
band replay are gone. In their place, `PI-8`'s actual model: six rules, each with a **threshold, an
action on breach, a scope and an effective-from date**. `OPS.modules` and `OPS.bands` became
`OPS.rules`, `OPS.ruleActions` and `OPS.ruleScopes`; `Store.setBand` became `Store.setRule`;
`bands.set` became `rules.set`; `Store.bandEffect` became `Store.ruleEffect`.

Two things fell out of it that reading the spec alone would not have produced:

- **A rule with a threshold and no action is inert.** `Tools.preview` now says so in the effect line
  rather than letting a half-set rule be approved. All four fields are required by the form.
- **Only two of the six rules can be checked against the fixtures.** Rather than invent a number for
  the other four, `ruleEffect` returns `checkable: false` and the preview says the console does not
  hold what that rule reads. The old band replay quietly averaged across everything.

**2 · Ops Assistant removed.** `js/agent.js` and `netlify/functions/agent.js` deleted, with the
composer, the ⌘K binding, the mode chip, the engine panel and the sixteen demo asks.

The dock **stayed** — as the review panel. Preview-then-approve is PRD-0007 v2.2's programme rule
across all 17 mutation paths, established before the assistant and load-bearing without it. Removing
the panel would have removed a control the RFI does commit (`CS-7`'s *"under what controls, and with
what audit trail"*) in order to remove one it does not.

Three things the removal exposed:
- the boot greeting was assistant copy (*"Ask me to change a setting…"*) and had to go;
- two refusal strings spoke in the first person — *"before **I** can apply that"* — which is the
  assistant's register, not the console's;
- with one instrument left, the audit log's **Via** filter filters nothing. The chips went; the
  `instrument` field stayed on the record, because an audit trail that drops a column cannot answer
  a question asked of its old rows.

**3 · Settlement carries v1 and v2.** Not removed — the owner asked to see both first.

v1 is as built: store charging at a **₱0.10 `[placeholder]`**, agree / mark paid / overdue. v2 is
`FR-5`'s monthly NET settlement report and nothing else. Three RFI answers say v1's machinery is not
at pilot (`SC-1`, `FR-5`, `FR-9`) and `P-1`'s committed pilot scope carries no settlement item — but
`FR-5` **does** commit the NET report today, and draws the line itself between what the platform
does and what is *"design, not built"*. That line is the whole difference between the two versions.

v1 now states on screen that its rate is a placeholder with no RFI source.

**4 · Franchise Partner → Store Scope.** The scoped store view is **committed** — `ST-6`: *"access
is scoped to the user's own store, and to their role within it"*, delivered for the pilot. So the
mechanism stays and only the label changes. The word does not survive: `franchise` appears once in
140 RFI answers, inside JFC's own question, never in one of ours, and the pilot is 5 company-owned
<!-- SUPERSEDED 2026-10-19: the pilot is six or more branches INCLUDING a franchise-owned one and a
     second-city branch. The "5 company-owned branches" premise below is dead. -->
branches.

**Also dropped:** invoice-sequence observations on held scans. `PI-8`'s configurable set has no rule
that reads invoice sequence, and *"new rule types beyond the configurable set require a release."*
An observation no rule can read is not evidence an operator can act on — showing it under a
neighbouring rule's label, which the first cut did, is worse than not showing it.

### Verification

Behaviour in **jsdom**, driving the real event path — 38 assertions, all passing. Layout in
**headless Chrome** at 1440px, read rather than assumed.

Four defects the two passes caught that review had not:
- `setBand` was still exported after being renamed — the store failed to load at all;
- `ask` was still in the `OC` return object after being deleted — same, for `app.js`;
- with the review panel open, the rules table **clipped its last two columns**. The panel now starts
  closed and opens when there is something to approve, which is also the honest behaviour for a
  panel that holds one pending change;
- the Settlement page subtitle described v1's machinery while v2 was on screen. It is now
  version-aware — the only view whose subject changes with what is shown.

One test was wrong rather than the code: it asserted v2 contained no *"Mark paid"* string, but v2
lists "Agree / Mark paid" in its table of **what v1 has and this does not**. Re-pointed at the
buttons.

### Still open

- **v1 or v2 of Settlement.** The decision this wave exists to inform.
- **Churn and segment discovery** (PRD-0008, `[committed]`). The RFI is silent; SOW WS4 and BC §3
  both sell them and price them into the $850K. Silence is not contradiction, so RFI-first does not
  settle this one — it needs an owner decision on its own.

---

## Wave — configuration gap, page 1 · 2026-09-23

The owner answered all 57 questions in `console-config-gap.md`. This is the Configuration page,
which had to run first and alone because every other page reads values from it.

### A third state appeared, and it is the interesting part

The worksheet offered *keep* or *remove*. Fifteen answers came back **display only** — a row that
shows a value nobody can change from the dashboard. Defined on the owner's own terms: *"a fixed
value that is configured on the backend and is just displayed for reference."*

Two consequences that are not obvious:

- **A display-only row carries no control at all, not a disabled one.** A disabled button says
  *someone with more rights could change this here*, which is false. The row says *Set outside the
  console* instead.
- **The write path refuses it too.** `Tools.preview` and `Store.setConfig` both reject a
  display-only key. Without that the lock is decoration — the button is not the write path, and
  this console's whole credibility rests on there being exactly one.

### What changed

**Nine removed.** Fraud detection bands and Agent-assisted operations were stale after the RFI pass
— they configured a model and an assistant that no longer exist. Reward recommendation and Transfer
and forwarding contradict `MK-1` and `PI-5`. Membership tiering, Brands in the programme, Receipt
reading confidence floor, Promo and discount codes and Show code before ordering went on the
owner's call.

*Show code before ordering* is worth recording: it existed to stop a member screenshotting their
code and passing it on. The owner's answer — *"screenshots are okay because of gifting"* — matches
`PI-5` exactly: *"We cannot detect a member giving a voucher to a friend, and we would not claim
otherwise."* A setting defending something we had already disclaimed.

**Fifteen display-only**, including two corrections the lookup forced:

- **Audit retention** had no value at all. `FR-13` says records are kept for the statutory period,
  so it could not simply be printed. Owner set a 2-year default.
  **SUPERSEDED 2026-10-19 — 10 years**, as an accounting record under BIR Revenue Regulations
  17-2013. The 2-year figure had no legal basis, and the fixture was in fact setting `value` twice
  in one object literal (`null`, then `730`), so the console printed "2 years" against a
  specification that said unset.
- **Reconciliation cycle** read *monthly*. `FR-1`, `FR-2` and `FR-3` all say the three-way match
  runs **daily**; monthly is the Finance report, which is a different thing the console already has
  a separate setting for. It would have printed the wrong number for ever.

**Twelve added**, each traceable to an RFI answer rather than to the parameter list alone: earn caps
daily and monthly (`C-1` names both), what the earn rate applies to (`C-1`), excluded tender types,
earn reversal on a void (`FR-1`), claim messages (`MK-8`), points-expiry reminders (`C-8`, `MK-7`),
adjustment reason codes (`CS-7`), message triggers (`MK-8` names the event set), self-service
actions (`CS-1`, display-only) and the transaction data connection (`T-3`, `FR-14`, display-only).

Three new groups: **Registration & consent**, **Messaging**, **Data**.

### The below-zero rule is gone

> **SUPERSEDED 2026-10-19 — the floor is restored.** Everything in this section is withdrawn.
> See the entry at the end of this log.

7.2.1 refused any deduction that took a balance under zero, and the console demonstrated that
refusal as a real rule. It is withdrawn on the owner's decision.

The case that broke it came out of the worksheet: a purchase is voided **after** the member has
already spent the points it earned. The points owed back no longer exist, so the refusal did not
protect anything — it made a correction impossible and left the liability wrong. `PI-6` allows
clawback; `C-8` treats reversal as a ledger entry; neither says what happens when the points are
gone. The owner chose a negative balance recovered from future earning.

It is not silent: the preview states the resulting balance, says the member can earn but not redeem
until it clears, and flags itself as a warning.

### Not in this pass

Two answers are pages, not settings, and get their own:

- **1.25** — the owner asked for a page where the operator manages T&Cs, the Privacy Notice and
  marketing consent with their acceptance points. `C-7` and `D-6` commit that these gate
  registration; nothing in the console touches them.
- **1.37** — message *content*. A copy surface, not a settings row.

**1.41** (return and reversal via Avtica Rewards QR) was never answered build-or-skip. `C-6` puts
that rail at Phase 2 and 1.42 was answered *No*, so it is treated as skip — recorded rather than
assumed silently.

### Verification

60 assertions, all passing. Sixteen are new: the removals, the counts, both corrected values, the
display-only refusal on **both** preview and apply, and the negative-balance path end to end.
Layout read in headless Chrome at 1440px with the new groups expanded.

`VERSION` bumped to 13. The schema changed shape again — a stale payload would have carried the
nine removed settings and none of the twelve new ones.

### Reworked the same day, on review

Three corrections from the owner once it was on screen.

**"Set outside the console" was wrong**, and so was the shape it sat in. A lock chip in the action
column of a third of the page reads as *you are not allowed*, when the truth is *this is not that
kind of row*. The reference values came out of the groups into their own table — a thing to look up,
scanned down a column, rather than a control that refuses you.

**The groups went too.** Eight accordions were a filing system for 30 settings; there are 16
configurable ones now, and a reader looking for the earn cap had to know it filed under *Earn*
before they could find it. One table with a **Category** column answers *what can I change* in a
screen and still says where each thing belongs. `settingRow` and `GROUP_HELP` are deleted rather
than left as dead code describing a layout that no longer exists.

**Programme values is collapsed by default.** Open, fifteen rows of reference buried the sixteen
controls under them.

**`earnReversal` removed.** The owner: *"we already answered this — let the balance go to −100 and
recover it from future earning."* Correct, and there is a second reason: `FR-1` says *"every voided
or refunded transaction **must** have its earn reversed"*. A toggle offering *off* was a choice we
had committed away in writing. The dependency it carried is still real — reversal needs void and
refund flags on the transaction feed, which Q9 leaves open — but that belongs in the record, not on
a settings row.

31 settings: 16 configurable, 15 reference. 66 assertions passing.
<!-- CORRECTED 2026-10-19: the fixture held 37 rows — 22 changeable, 15 read-only — and holds 36
     (21 changeable, 15 read-only) after the two credit-limit rows were merged into one the same
     day. The 29 and 31 figures carried in this log, AI-GUIDE.md and screen-manifest.json were all
     wrong. Counted by reading window.OPS.config, then Store.configAll() in a booted console. -->

### Recategorised · same day

The categories were the old product groups with three invented ones bolted on. Three faults:

- **`Wallet` contradicted the RFI.** `SC-1`: *"we agree points-as-cash, stored value and wallet
  functionality are out of scope, and we confirm our product does not bundle them."* A category by
  that name in the operator console says otherwise. It is gone, and so is the word from the one
  helper text that still carried it.
- **Messages were spread across four categories** — claim messages under *Earn*, notification
  channels and points-expiry reminders under *Wallet*, expiry warning under *Redemption*, trigger
  events under *Messaging*. Five settings, one subject. `MK-8` treats them as one thing:
  *"triggers, channel, timing and content are configured per message type."*
- **Singletons.** *Trust* and *Messaging* held one configurable setting each; the reference table
  had four categories of one row.

Now one vocabulary across both tables — **Earning · Points & rewards · Messages · Operations ·
Settlement · Member app · Platform** — so a category means the same thing whichever you are reading.

**`Settlement window` renamed to `Validation window`.** It is the pending-to-validated wait, which
`CS-3` calls the *expected validation date*; it has nothing to do with settlement, and leaving that
name beside a new *Settlement* category invited exactly the confusion the pass was removing.

**Two defects the screenshot caught that the tests had not.** The settings table was sorted by
category and the reference table was not, so *Earning* appeared at the top and again at the bottom.
And "wallet" survived in a helper text after the category was removed for saying it. Both now have
assertions — grouping is checked on **both** tables, and no label or help may contain the word.

### Uncollapsed · same day

Programme values was an accordion so fifteen reference rows did not bury the sixteen controls above
them. Reversed on the owner's call: a lookup you have to open first is a lookup people stop using,
and the two tables already read as two different things without a disclosure doing that work.

Both tables now render open, grouped by the same seven categories. 73 assertions.

### A table per category · same day

Reversed again, and the owner was right both times. *Combine into one table with a Category column*
was correct against eight accordions; it stopped being correct once the column existed, because it
was the same word typed sixteen times and the reader still had to carry it down the rows.

Now a table per category inside two blocks — **Settings** and **Programme values** — with the
heading doing once what the column repeated. Nothing is collapsed.

The two blocks are not a visual convention: `Tools.preview` and `Store.setConfig` both refuse a
Programme value, so the second block is a statement about what can be changed.

70 assertions. The layout ones were rewritten rather than patched — six of them asserted a
one-table shape that no longer exists, and a test that describes the wrong layout passes for the
wrong reason next time.

---

## 2026-10-19 · Defect pass against the four-source reconciliation rulings

Ten verified defects where this build contradicted the settled specification. Each was
confirmed by reading the cited line before it was changed. Nothing here is a design
change — it is the build catching up with rulings already taken.

### 1 · Audit retention printed "2 years" from a duplicate key

`js/simulations.js` — the `auditRetention` row set `value:` **twice in one object literal**
(`value: null`, then `value: 730`). The later wins silently, so the console rendered
*2 years* against a specification that said unset. The duplicate is gone and the value is
**3650 days / 10 years**.

Its note said *"Data-privacy rules set a minimum this cannot go below."* That is backwards.
The floor is **tax** law — BIR Revenue Regulations 17-2013 (amended by 5-2014), books of
accounts and other accounting records, 10 years from the day after the filing deadline for
the year of the last entry. The Data Privacy Act's implementing rules (Rule IV §19(d))
impose a *"no longer than necessary"* **ceiling**. The note now says so.

### 2 · The below-zero floor is restored, and an invented rule is struck

`js/store.js` `deduct()` — the floor was removed citing an *"owner's decision, 2026-09-23"*
recorded nowhere else, on the premise of a purchase voided after its points were spent.
**That premise is dead, not merely overruled:** JFC cannot associate a refund with the
original purchase, so the case cannot arise in the form described. Nor is there any
retrospective clawback — fraud detection is forward-acting.

Two other places in this same build never stopped enforcing the floor (`js/app.js`'s deduct
dialog `max: m.balance`, and its helper copy), so the build contradicted itself. `deduct()`
now refuses, and `js/tools.js`'s `member.deduct` preview refuses before it, so the proposal
card never offers a deduction that the write path will reject.

**Struck:** the rule this build invented — that a member with a negative balance *"can earn
but not redeem until it clears"*. It appears in no document and was never adopted.

### 3 · Two credit limits became one threshold

`js/simulations.js` — `agentCreditLimit` (0–10,000 points) and `supervisorThreshold`
(0–50,000 points) are replaced by a single `creditApprovalThreshold` at **10,000 points**,
covering a single credit **or** that agent's running total for the day.

**NOT BUILT, and deliberately not faked: the daily aggregate.** Nothing in this console
accumulates an agent's credits across a day, so only the single-credit half of the setting
is enforceable here. No document has a running per-agent daily total; it needs building and
has no owner. Recorded as an open gap.

### 4 · `graceCycles` misdescribed what it held

`js/store.js` multiplied `graceCycles` (2) by `settlementCycleDays` (30) for a 60-day grace
window, while the field read as *cycles* beside a **daily** reconciliation cadence. Renamed
`graceSettlementCycles`, and `reconSummary()` now also returns `graceDays`. The two
operator-visible strings that said *"Unresolved over 2 cycles"* now say **60 days**.

### 5 · No refusal was ever audited

`js/store.js` `deny()` returned a refusal without calling `record()`. Twenty-five `record()`
calls existed and **not one sat on a refusal path**, so the audit log could say what was
allowed but never what was attempted and stopped — which is the question an audit trail
exists for. `deny()` now records `refused.<code>` with the refusal message and emits.

### 6 · The settings count was wrong in three places

The fixture held **37 rows — 22 changeable, 15 read-only** — before this pass, and holds **36
(21 changeable, 15 read-only)** after the two credit-limit rows above were merged into one.
The evidence files claimed 29 (`AI-GUIDE.md` twice, `screen-manifest.json`) and 31 (this log,
`screen-manifest.json`). All corrected. Counted by reading `window.OPS.config`, then
`Store.configAll()` in a booted console — not by eye.

### 7 · Voucher expiry — verified, not changed

30 days, as ruled. `voucherExpiry` holds `value: 30` and `js/tools.js`'s
`effectForConfig()` reads `c.value` rather than printing a literal. No change needed; the
shipped default of *never* is correctly shown as the product default beside Jollibee's choice.

### 8 · Specification references removed from operator-visible copy

Four specification sites say no product notes appear on screen. These were on screen:

| Where | Was | Now |
|---|---|---|
| `js/app.js` settlement tab | `v2 · RFI at CPV` | `v2 · report only` |
| `js/app.js` settlement banner | *"what the RFI commits at CPV"* | *"the narrower report-only version"* |
| `js/app.js` v2 banner | *"FR-5, verbatim: …"* | plain statement of what the report is |
| `js/app.js` v2 footnote | *"which is what LS-2 says"* | plain statement |
| `js/app.js` "Gone with v2" table | `SC-1`, `FR-5`, `[placeholder]` | plain reasons |
| `js/app.js` funding banner | *"a placeholder with no RFI source"* | *"a provisional rate. Finance sets the real one"* |
| `js/app.js` brand row | *"The only brand at CPV"* | *"The only brand at the pilot"* |
| `js/app.js` audience help | *"built in Prism"* | *"built in the analytics tool"* |
| `js/store.js` rules caveat | *"(PI-8)"* | removed |
| `js/store.js` economics caveat | *"which is Prism's"* | *"which belongs to the analytics tool"* |

`screen-manifest.json`'s `copyPolicy` carried an explicit exception allowing *Prism* on
screen. That exception is **withdrawn**.

### 9 · The pilot is not five company-owned branches

`js/simulations.js` — the `storeScope` role comment asserted *"the CPV is 5 company-owned
branches"* as the reason the word *franchise* was dropped. The pilot is **six or more,
including a franchise-owned branch** and a second-city branch, so franchise scoping is real
pilot scope. Corrected here, in `README.md` and in `BUILD-LOG.md`.

### 10 · The manifest described an assistant that does not exist

`screen-manifest.json` claimed the audit log was *"appended by both paths"*, that the
publish gate was proven because *"the assistant hits the same refusal the button does"*, and
that *"every console role can use it today"*. The assistant is removed and marked `[future]`
everywhere; there is no second path and no role can use it. All three corrected.

**Also recorded there, honestly rather than silently:** named roles are retired in the
specification in favour of **five independent permission areas**. This build still models
three named roles (Loyalty Ops, Platform Admin, Store Scope) and has **not** been
re-expressed. That is a known divergence carried forward, not a design position.

### Open gaps recorded, not built

| Gap | Why it is not built |
|---|---|
| Per-agent daily credit aggregate | No document carries a running daily total; no owner. Faking it would be worse than naming it |
| Five permission areas replacing named roles | A structural change to the access model, out of scope for a defect pass |
| Settlement rate per point | Finance sets it; the console now says so instead of calling it a placeholder |

### Verification — what was actually run

- `node --check` over all six `operator/js/*.js` — **all pass**.
- `screen-manifest.json` parses as JSON (`python3 -c "import json; json.load(...)"`).
- The console was served with `python3 -m http.server 8713` from the prototypes directory
  and booted in **headless Chrome over the DevTools Protocol**. Result: **no console
  errors and no uncaught exceptions**, and the following read back from a live `Store`:

  | Probe | Value |
  |---|---|
  | `Store.configAll().length` | 36 (21 changeable, 15 read-only) |
  | `Store.config('auditRetention').display` | `10 years` |
  | `Store.config('creditApprovalThreshold').value` | `10000` |
  | `Store.config('agentCreditLimit')` / `supervisorThreshold` | `null` / `null` — both gone |
  | `Store.config('voucherExpiry').value` | `30` |
  | `reconSummary().graceDays` / `.graceSettlementCycles` | `60` / `2`; `.graceCycles` undefined |
  | `Store.deduct(points > balance)` | refused: *"A deduction cannot take a balance below zero. Marie De Guzman holds 1950 points."* |
  | audit length across that refusal | 8 → **9**, top row `refused.range` |
  | `Store.deduct(points = 10)` | still applies, balance 1950 → 1940 |

- **Not executed:** the prototype's own jsdom behaviour suite and the headless-Chrome
  layout pass. They were already marked stale in `screen-manifest.json` before this
  work and are staler now — several of their assertions describe the below-zero
  behaviour that this pass reversed.
