# Stream K4: the review (the proposals file, the sheet, approval, undo)
(written 2026-09-16 Wed, from the accepted plan W10, W13, W15, W16 and §8 (the cell editor stays below the line), and the escalation-2 specification §6 to §8; part of the Thursday-night wave with K3 and K5)

**Runs:** Thu 17 Sep night into Fri 18, in the wave, after K2's hand-back, on the refactor
branch in the worktree. Never alongside a collection. Reads K2's tables and writer through the
signatures K2's brief fixes; writes proposals and decisions through them and nowhere else.

## Goal
A Claude session's proposals file for one brand loads idempotently under a named pass; the sheet
at `/review` shows the brand's proposals grouped by proposed product line with evidence, rule
value, current value, confidence, the spot-check marks first and the effective decision on every
field computed now; a person approves per row, per line, or all except spot-checks, rejects per
row (a rejected pair is a Keep separate), and every approval is one batch a person can undo as one;
the same file loads on production unchanged; the whole loop is rehearsed end to end on a CHANEL
file written from the copy, replayed by natural key onto a second copy.

## What already exists, so you build on it
- K2: `proposal_passes`, `proposals`, `decisions`, `decision_batches` (spec §1); `proposals.write`
  (the thin store), `decisions.batch`/`record`/`undo`/`undo_batch`, `decisions.effective`,
  `natural_keys.resolve` (with `line:new:<slug>` as `creatable`), `replay.export`/`replay`, the
  `Refused` codes; `cli._collection_running`.
- K0: `main/docs/REVIEW-PROCESS.md` §3 (what a proposal carries), §4 (the spot-check rule: every
  proposal that merges variants or changes a comparison; the five lowest-confidence per brand and
  below 0.7; a random five percent, at least three), §5 (the value order), the version line.
- The merge desk on `/collectors` (`merge_desk.py`, `merge_session.py`, `MergeDesk.tsx`): the
  keyboard flow and the "Suggested match" column stay as they are (pair suggestions from rules);
  the review sheet is a second surface, not a replacement.
- `routers/collectors.py` (the `review` state route, the ledger listing), `services/access.py`
  (every route in exactly one class; the review routes are owner-class), `listings_table.py`.
- The spec §6.1 file shape; §6.2 the load; §6.3 the sheet read; §6.4 the approval; §7 undo.

## Contract
- **One file per brand per pass**, JSON, exactly the spec §6.1 shape; `pass.name` like
  `claude/2026-09-18/chanel-1`; `process_version` the REVIEW-PROCESS.md version followed;
  `rules_version` the identity rules the file was computed against; natural keys only, never an
  id (`brand:<slug>`, `line:<uid>`, `line:new:<slug>`, `variant:<uid>` with the listings in
  `detail`, `listing:<retailer>/<shop code>/<sku>`, `pair:<level>:<a>||<b>`, `wording:<vertical>|<raw>`);
  every value cites the spans it was read from (`evidence`); a value the raw text does not contain
  is never proposed (empty beats guessed).
- **Load is the pass's whole statement**: open rows missing from a re-load go `stale` unless
  `--partial`; an identical file is a no-op by sha256; a resolved row is never changed by a re-load.
- **The sheet reads; nothing on a GET writes.** Effective decisions are computed at read, never
  from the load snapshot; drift is flagged when the column changed since load; staleness is a
  change in what was read (a cited listing gone or its text at the span changed), never a rule's
  rederive.
- **Approval is one batch per brand per call**, `mode = bulk` for all or a line, `individual` for
  explicit uids; the writer's human-first rule refuses `DECISION_HELD` per row and the rest
  proceed; the response and the sheet say "N approved, M held, R refused" loudly.
- **Reject writes no decision**, except a pair proposal, which writes Keep separate (`origin =
  person`, `proposal_id` kept).
- **Undo is per batch** (the sheet's history lists the brand's batches with an undo per row) and
  per decision (the ledger page); both are K2's service behind K4's routes.
- **The cell editor stays below the line** (rian, 16 Sep): the API and CLI accept `corrections`
  (spec §6.4 step 5, `origin = person`, `detail.corrected`), the UI offers approve and reject per
  row and nothing to type; a corrected value in this phase is edited in the file before loading.
  If the CHANEL rehearsal shows many values worth correcting by hand, say so in the handoff; the
  editor is the first task after launch otherwise.
- **Access**: every review route is in the owner class of `services/access.py`; the page is
  reachable from the account menu for the owner only; nothing public changes.

## Tasks, in order (ids on /plan; commit prefix `K4:`)
1. **K4.1 The load.** `services/proposals.py`: `load(db, file, *, check=False, as_user=None,
   partial=False)` per spec §6.2 (pass upserted by name with `file_sha256`; rows upserted on the
   unique key; `entity_id` and `resolution` from `natural_keys.resolve` with `mint=False`;
   `parked` on an unresolvable key, visible, never approvable; `rule_value` and `current_value`
   captured; `against_decision_id`; `spot_check` by REVIEW-PROCESS.md §4 seeded by the pass name;
   `detail.previously_rejected`; a field already holding an effective decision from the same pass
   marked `approved` on load); `withdraw(db, pass_name, reason, by)` (open rows to `withdrawn`, the
   pass stamped, the approved batch uids printed with their undo commands; the ledger untouched);
   CLI `app.cli proposals load --file <json> [--check] [--as <username>] [--partial]` and
   `proposals withdraw --pass <name> --reason <text>`. `--check` leaves every table's count
   unchanged (a test asserts it). The file is validated against a JSON schema
   (`main/app/services/proposals_schema.json`) with the error naming the row and field.
2. **K4.2 The sheet read.** `GET /api/review/sheets` (brands with open proposals, in the value
   order of REVIEW-PROCESS.md §5, with counts open, spot-check, held, parked, stale, and the newest
   pass name); `GET /api/review/sheets/<brand>` per spec §6.3 (the alias chain resolved; grouped by
   `sheet_line_ref` in `position` order; brand rows first; the line header with the lines it
   absorbs; each member's listings from `natural_key_detail`; one cell per field with proposed,
   corrected, rule, current (re-read now, `drift`), confidence, evidence spans, the spot-check mark,
   the effective decision now with who, when, mode, origin, pass, process version, and the flag "a
   newer pass disagrees with a bulk decision"; the arrival rows and previously-rejected rows
   collapsed; the brand's batches with by, at, mode, undone or not); `GET /api/review/batches/<uid>`
   and `GET /api/review/decisions?entity=<type>:<id>` (K2's `decisions list` as JSON).
   `schemas.py` shapes for all of it; the typed client regenerates.
3. **K4.3 The approval and the reject.** `POST /api/review/sheets/<brand>/approve {pass, scope,
   corrections, reject, note}` and `app.cli proposals approve --brand <slug> --pass <name>
   [--line <ref>] [--uids ...] [--all]` per spec §6.4: the `COLLECTION_RUNNING` gate (`--force`);
   one batch, one transaction; the selection rule (`all` excludes spot-checks, parked, stale and
   held rows; explicit uids take exactly those); the order within the batch (brand rows, line
   headers adopting before minting, memberships re-resolved from their natural keys, attribute and
   name cells, `alias_of` on absorbed lines, `merged_into` and pair rows last, pins and ignores); per
   row `record(origin = proposal, ...)` or with a correction `record(origin = person, mode =
   individual, ...)`; `Refused` per row leaves it open and names it with its code; reject per row
   (a pair proposal writes Keep separate); the response with the batch uid, the counts, the
   refusals and the undo command. `POST /api/review/batches/<uid>/undo {reason}` and `POST
   /api/review/decisions/<id>/undo {reason}` calling K2's service; both gated the same way.
4. **K4.4 The page.** `web/src/pages/ReviewPage.tsx` at `/review` (owner only): the sheet list in
   value order with counts; the sheet: spot-check rows first, then by line; a row shows the proposed
   value, the rule value, the current value with a drift mark, confidence, the evidence spans
   rendered as the listed text with the span highlighted, the effective decision if any (who, when,
   mode, pass) and the disagreement flag; per row **Approve** and **Reject** (a reject asks for one
   sentence); per line **Approve line**; at the top **Approve all except spot-checks** and, once every
   spot-check row on the sheet has been acted on, **Approve all**; after any approval the banner
   "N approved, M held, R refused" with the refusals listed by code and row; the brand's batches
   below with **Undo** per batch (one confirmation sentence); a link to the ledger view for an
   entity. Keyboard: `j`/`k` move, `a` approve, `r` reject, as the desk. Reuse the desk's tokens and
   table styles; a component's stylesheet imported in the component. A named mount point
   `<ReviewPanels />` at the bottom of the page where K6 mounts its index-candidates panel (K4
   ships the empty slot; K6 fills it). Route in `App.tsx`; the account menu link for the owner.
5. **K4.5 The rehearsal, end to end (the first dry run of the review pass).** On `dfp_k4`, a fresh
   copy of the newest dump with K1's and K2's migrations and backfills applied: write, as the
   session, following REVIEW-PROCESS.md, a proposals file for CHANEL (the largest beauty brand on
   the copy; product lines, memberships, shades as `attribute:shade` where a shop published them,
   one or two variant pairs, one brand alias if the copy has a candidate) under pass
   `claude/2026-09-17/chanel-rehearsal`; `proposals load --check`, then load; the sheet read; approve
   a line, approve all except spot-checks, approve the spot-checks by uid, reject one row; `decisions
   verify` zero drift; `undo-batch` of the line batch; approve it again; `decisions export --pass`
   and `replay` onto `dfp_k4b` (a second copy with shifted ids, made by K2's recipe) with 0 parked;
   `proposals load` of the same file on `dfp_k4b` marking the replayed rows approved. Every number
   (rows loaded, resolved, parked, spot-checked, approved, held, refused, undone, replayed) read from
   the databases into the handoff. Record what the file took to write (time, and what was hard),
   because that is what the Saturday review pass repeats per brand. `REVIEW-PROCESS.md` gains §6
   "The file" (the JSON shape, the natural keys, the load and approve commands) and bumps its
   version to 2; the spot-check rule's wording checked against what K4.1 implements.
6. **K4.6 Tests.** The spec's tests 15 (load idempotent; `--check` leaves counts unchanged;
   `spot_check` deterministic and never cleared; a replayed decision marks its proposal approved on
   load), 16 (staleness by evidence, never by rederive), 17 (approval order and re-resolution; a
   merged variant's decision lands on the survivor; a rejected pair writes Keep separate with
   `pass_id` kept), 22 (the gates); the sheet GET writes nothing (row counts before and after); the
   `all` selection excludes spot-checks and held rows; the response counts; the schema validation
   error names the row; the route inventory (every review route owner-class, `test_access`).

## Owns
`app/services/proposals.py` (new), `proposals_schema.json`, `app/routers/review.py` (new),
`app/models/schemas.py` (the review shapes only), `services/access.py` (the review routes' class
lines only), `cli.py`'s `proposals *` commands, `web/src/pages/ReviewPage.tsx` and `.css`,
`web/src/components/review/` (new), `App.tsx` (the route line), the account menu link, their
tests, `REVIEW-PROCESS.md` §6 and its version line.

## Must not touch
The schema and `services/decisions/` (K2; a column or a signature K4 lacks is an issue on the
running list owned by K2, and K4 proceeds on the nearest thing that exists), identity (K3;
`proposal_rules.py` writes into the same store and K4 reads its rows like any other pass),
`ProductLinePage` and the storefront (K5), `publish.py`, the sitemap and the index CLI (K6; K4
ships the panel slot only), the merge desk's own flow (`MergeDesk.tsx`, `merge_desk.py`,
`merge_session.py`: the "Suggested match" column stays), `.app.env`, the live databases.

## Rehearsal and acceptance
K4.5's numbers in the handoff; `../.venv-dev/bin/pytest tests -q` green; `main/check.sh` green
(the typed client regenerated); `docs-check.sh --strict` no FAIL; every review route in the owner
class and absent from the unauthenticated sweep.

## Rollback
The page is behind the owner permission; the tables are empty until a file is loaded;
`proposals withdraw --pass` and `decisions undo-batch` reverse a rehearsal on any host.

## Hand-back
Handoff (≤25 lines) with the rehearsal numbers, what writing the CHANEL file cost and what was
hard (the Saturday pass reads this), the REVIEW-PROCESS.md version, and "K4 green and ready; the
review pass may start after K3". Anything only rian can decide (the editor above the line, if the
rehearsal argued for it) on the running list with the assumption proceeded under.
