# Stream K12: the review we go live with

(written 2026-09-18 by Claude (Fable) from the plan `.logs/planning/review-process-golive-2026-09-18.md`,
which is the design; this brief is the work list. Where the two differ, the plan wins and the
difference is written here before the code.)

**Runs:** in its own worktree and branch off master, after rian approves the plan. Never deploy,
never write to staging's or production's database, never run a collection, never edit `.app.env`
or `.env`. Rehearse every data move on `dfp-devdb` (127.0.0.1:5433) from a fresh copy of the newest
dump in `backups/`. **This stream does not run a pass.** Passes start after the sweep, which
starts after rian deploys this.

**Sequence after this stream, fixed:** rian migrates and deploys staging → `backfill stuck_runs` →
`backfill prune_lines` → the sweep (the seven reachable sources; five are refused and are never
contacted) → passes until `app.cli pass status` prints done.

## The two rules that shape every task

1. **We are building the room, not doing the work in it.** No task here decides a catalogue
   question. A test fixture may hold a gendered name or a tube because the code has to carry such
   cases; the test asserts that the question is *raised, floored and grouped*, never what the
   answer is. If you find yourself writing a rule that says what a "for men" line is, stop.
2. **Loud on novelty beats right on average.** Every mechanism in K12.3 and K12.4 exists so that a
   new kind cannot be bulk-approved by accident. When a choice trades a little friction for a
   louder new kind, take the friction.

## What already exists (do not rebuild)

The ledger, the proposals store, load / sheet / approve / defer / counter / undo (K2, K4, K11); the
notes read with `wants` (18 Sep); `attention.py` and its five levels; `merges.prune_lines` and
`backfill prune_lines`; `backfill stuck_runs`; the dead-run gate; the evidence span check; the
sheet's product line table and cards; `proposal_rules` generators and `proposals hints`;
`plan-set.py --add` for registering your tasks on `/plan`.

## Tasks, in order (commit prefix `K12:`)

### K12.1 Before the sweep: empty lines, the parser version, the sweep runbook

- `_alias_release` (brand and product line) prunes the product lines the fold minted: the applier
  already knows them (`lines_created` in the batch notes; record their ids in the decision's
  `detail` at prepare time so the release can find them). Use `merges.prune_lines(candidates)`.
  Test: the simulation's shape, a fold of a brand with 39 unmatched lines, undone, leaves live
  product lines at exactly the starting count.
- A fingerprint test per collector: the set of keys the kept fragment carries (from each fixture,
  through `collected.listed_fields`) is hashed and pinned beside `PARSER_VERSION`; a changed
  payload shape with an unchanged version fails with the message "bump PARSER_VERSION in
  <module>". Bump avolta's now (the 18 Sep run kept a new shape under `avolta/2026-09-05`).
- `main/docs/RUNBOOK.md`, the sweep section: `backfill stuck_runs` first, then
  `backfill prune_lines`, then the run; the table of which sources a sweep reaches and which are
  refused (read from `sources` and the last run, not typed; a small `app.cli sweep plan` that
  prints it is fine and is what the runbook cites).

### K12.2 `collected_name` as an evidence source

- Schema: `source` accepts `collected_name`. The loader resolves it to the listing's variant's
  `name`, **only when the listing has no fragment** (`listed_record_id IS NULL`); a listing with a
  fragment cited this way is refused (`EVIDENCE_HAS_FRAGMENT`, naming the listed column to cite
  instead). Staleness: the span is re-read against the variant's current name.
- The sheet and the card label such a span *"as collected, no fragment kept"*.
- The packet (K12.5) carries `collected_name` on every fragment-less listing and the retailer's
  population (`has_listed_words`, `has_barcodes`, read from the retailer's live listings, not typed).
- Test: the simulation's pair, #4520 (barcode, no listed text) against #16598 (listed text, no
  barcode), loads with one `collected_name` span and one `listed_name` span and resolves both.

### K12.3 The precedent register, and the file that names its precedent

- Migration: `precedents` (`id, uid, slug UNIQUE, vertical, question, answer, note, first_case
  (natural key), set_by, set_at, decision_id, status active|overturned, overturned_by_id,
  overturned_at, overturn_note, shape JSONB, decisions_count, resets_count, created_at`). On
  `proposals`: `precedent` (slug), `precedent_status` (`sets | follows`), `follows` (a natural
  key), `shape` (JSONB, computed at load), `unlike` (JSONB, the elements never seen on the
  precedent, or NULL). On `proposal_passes`: `survey` (JSONB), `verdict` (`proposals | settled`),
  `fingerprint`. A decision's `detail` carries `precedent` when it came from a proposal that named
  one. Rehearse up, down, up.
- Schema: `precedent` required on every session proposal (a rule or arrival row may omit it and is
  floored as the register decides); `precedent_status`; `follows`; `sets` rows must be one per
  slug per file.
- Register semantics. A lead approved with a note **sets** the precedent when its slug is new
  (`sets` status; the note is required, K12.4). A follower's approval, or a later lead under the
  same slug, increments `decisions_count`. A defer with `wants` on a lead under an existing slug,
  or an overturn, increments `resets_count` and zeroes the streak the floor reads. Kept separate
  counts as a decision under the slug. The `shape` of a precedent is the union of the shapes of
  the rows decided under it.
- **Shape**: a pure function of the proposal and its cited listings: `{entity_type, field,
  vertical, hints: [list names that fired on the cited listed text], words: [the hint words
  found]}`. Computed by the loader from `proposal_rules`'s generators run on the cited text only,
  nothing else. `unlike` is the set difference against the register's shape for that slug.
- `app.cli precedents list | show <slug> | overturn <slug> --note --by | export` (export writes
  `main/docs/PRECEDENTS.md`, one `## slug` per precedent with question, answer, note, case, count,
  status; the doc gate treats it as generated). `GET /api/review/precedents`, `POST
  /api/review/precedents/<slug>/overturn`.
- Overturn: a decision on the precedent (entity type `precedent`, field `status`), undoable; marks
  nothing else in place; the packet lists, per overturned precedent, the decisions made under it
  so a pass can propose against them. Regroups nothing.
- Tests: a new slug loads at critical whatever the pass said; a follower under a slug with a
  streak of 3 lands at the confidence-derived level; a lead under a registered slug with a hint
  list never seen on it is `unlike` and floored high, with the element named; an overturn resets
  the streak and the next load floors that slug high again.

### K12.4 Attention becomes a control

- `attention.for_rows` becomes floor logic reading the register: unregistered slug → `critical`;
  registered with streak < `LEAVE_HIGH_AFTER` (3) → `high`; `unlike` non-empty → `high`; a merge or
  a pair across shops → `high` until the streak; then confidence. The pass's level is a floor the
  code may raise, never lower. Constants live in `attention.py` and REVIEW-PROCESS.md section 4
  with a test that the two agree.
- `approve`: a critical row needs a note (`NOTE_REQUIRED`, naming the row); `scope: "all"` is
  refused while any open row on the sheet is critical or high (`BULK_BLOCKED_BY_OPEN_LEAD`, naming
  up to five); new scope `{"precedent": slug}` approves the lead-answered group's open medium, low
  and follower rows as one batch; the batch `note` is shown on every row it covered (sheet and
  card). A follower whose lead was answered otherwise is refused
  (`_refuse_if_header_was_answered_otherwise` already does this for lines; extend to `follows`).
- The round cap: on the second defer (or counter) of the same natural key + field, the store parks
  the row (`parked`, `detail.parked_to_rian`) and adds a `decide` item to the running list with the
  question, both notes and the sheet link (`main/scripts/items.py add --kind decide --by "review"`
  via a subprocess, or write the JSON the script writes; one home). A parked row is never
  re-proposed until the item is resolved; the packet says so.
- Unseen words: for a precedent group, the words in the group's cited listed text that no decided
  row in the same vertical has cited; returned by the sheet on the group and shown on the bulk
  confirmation. Blocks nothing.
- Tests: each refusal with its code; the bulk note reaching every row; the cap parking on the
  second defer with the item written; a critical approved with a note sets the register row.

### K12.5 The packet, the cross-divide generator, the settled stamp, status

- `app.cli pass packet --brand <slug> [--layer]` emits the JSON the plan's C1 lists, and nothing
  a pass would need beyond it. Include the principles file's text and the register.
- `proposal_rules` gains `cross_divide`: candidate pairs, same brand family, same quantity (and
  ABV where both state one), one side barcoded with no listed text, the other with listed text and
  no barcode, token overlap after the certain folds above a low bar. It is a hint, never a queue
  row; it is what makes the second layer cheap. Count per brand feeds section 5's value order
  (`sheets` gains `cross_divide_candidates`).
- Fingerprint: `pass fingerprint --brand` hashes the brand family's live listings' listed columns
  (and `collected_name` where no fragment). A settled pass row stores it with both versions;
  `pass status` reports each brand as `settled | open | parked | unsettled (words changed |
  version changed)` and the one done line: `done: 0 open, 0 deferred, 0 wants, 0 parked` or the
  counts.
- Layer choice: `pass status --brand` names the outermost layer with open questions (brand pairs
  open → brands; cross-divide candidates unanswered → cross-divide; and so on), which the packet
  carries as `layer`.
- Tests: the packet on the simulation's brands contains what C1 lists; a changed listed word
  un-settles; cross-divide finds #4520 / #16598 and does not pair two barcoded rows.

### K12.6 The review itself

- The survey block at the top of the sheet: new kinds first in the alert colour, each with its
  two example listings and the lead question it produced; registered kinds below, collapsed.
- Groups by precedent: the lead, then followers by name, the group's level, the "Confirm group"
  action with an optional note; the unseen words list on the confirmation; "Approve the rest"
  disabled with the reason while a lead is open.
- The fold card states both decisions (which row survives, what it is called) as two labelled
  choices with per-direction counts: how many product variants move and how many product line
  addresses are kept each way. The product variant merge card says what the merge is for: the
  retailers it puts on one page.
- The collected-name label; the register on the guide tab with per-precedent counts and the
  overturn button (note required); the brand index sorted new-kinds-first, then value order.
- Keep the keyboard and everything K10/K11 built. `main/check.sh` regenerates the client.

### K12.7 The documents and the skill

- `main/docs/REVIEW-PASS.md`: install Part C of the plan as the executable specification, adjusted
  only where a command name changed in this stream. It is the one home for what a pass does.
- `main/docs/REVIEW-PROCESS.md` **version 6**: the layer order with the cross-divide layer and the
  survey; the ladder table (plan B1) in section 4 with the thresholds; the terminal condition;
  section 3's new fields; section 6's file example with `precedent`; the sweep note about the
  refused sources (a rule about populations, naming no shop by name is not required here, this is
  project doc). Bump the version line and say so in the handoff.
- `main/docs/AI-REVIEW-GUIDELINES.md`: remove "This list is rewritten from the answers"; add the
  survey principle ("List every kind you can see before you propose, and say which have no
  precedent"), the "never assume the unmarked member is the parent; ask" principle (a process
  rule, not an answer), and "a precedent is named on every proposal". Everything that reads as a
  case decision stays as the default the pass proposes when the text gives no reason otherwise.
- `.claude/commands/review-pass.md` rewritten thin: where to run, the packet command, "follow
  `main/docs/REVIEW-PASS.md`", the commit rule. A test asserts the skill names the spec and
  contains no step the spec does not.
- `docs-check.sh` knows `PRECEDENTS.md` is generated.

### K12.8 Rehearsal end to end, hand-back

On a fresh copy of the newest dump: load a two-brand pass written by hand from the packet (one
brand with a fold, one with a cross-divide pair, one new-kind lead, three followers); prove:
the new slug is critical; approving it without a note is refused; `all` is refused while it is
open; the note sets the register; the group confirms with a note that reaches every row; a
second defer parks and writes the decide item; an overturn floors the slug high on the next load;
undo of everything returns product lines, variants and listings to the starting counts **with
zero empty product lines added**; `pass status` moves from open to settled to unsettled when one
listed word is changed. `main/check.sh` prints "checks passed". Handoff ≤25 lines naming the
migration command, the two backfills, the sweep order and the first brands to pass.

## Owns

`main/app/services/attention.py`, `proposals.py`, `proposals_store.py`, `proposal_rules.py` (new
generator and the shape function), `decisions/appliers.py` (`_alias_release` and prepare only),
`review_detail.py`, `guidelines.py`, `cli_proposals.py` and a new `cli_pass.py` / `cli_precedents.py`,
`routers/review.py`, `proposals_schema.json`, the new migration, `main/docs/REVIEW-PROCESS.md`,
`AI-REVIEW-GUIDELINES.md`, `REVIEW-PASS.md`, `PRECEDENTS.md` (generated), `RUNBOOK.md` sweep
section, `.claude/commands/review-pass.md`, `main/web/src/pages/ReviewPage.tsx` and
`components/review/*`, `collectors/*` for `PARSER_VERSION` and the fingerprint test only, and tests
for all of it.

## Must not touch

Staging and production; `.app.env`, `.env`; `import/proposals/`; the certain boundary
(REVIEW-PROCESS.md section 1) and identity rules v6; `decisions/writer.py` and `undo.py` beyond
what K12.1 names; the collectors' parsing; `FORMAT_WORDS` membership; anything that would decide a
catalogue question.

## Acceptance

- A proposal with an unregistered precedent slug is critical whatever the file said, and cannot be
  bulk-approved; approving it needs a note; the note creates the register row.
- A row filed under a registered precedent whose shape the register has never seen is floored high
  with the differing element named.
- `scope: "all"` is refused while a critical or high row is open; a precedent group confirms as one
  batch with a note on every row.
- The second defer of one question parks it and writes a decide item.
- An overturn regroups nothing and the next pass can propose against the decisions under it.
- `collected_name` cites a fragment-less listing and is refused on one with a fragment.
- A brand fold undone leaves zero extra product lines; `backfill prune_lines` on the copy removes
  the 297.
- A changed payload shape without a version bump fails a test.
- `pass packet`, `pass status`, `pass fingerprint`, `precedents *` exist and are what
  `REVIEW-PASS.md` cites; `review-pass.md` is thin and cites the spec.
- REVIEW-PROCESS.md is version 6; `main/check.sh` prints "checks passed"; nothing deployed.
