# The review process

**Version 6, 2026-09-18** (**Stream K12**, from the plan `.logs/planning/review-process-golive-2026-09-18.md`:
the review we go live with. Every proposal names the kind of judgement it is and the precedent
register floors its attention (section 4, now a control rather than a label); the layer order gains
the cross-divide layer and the survey (the order of a review, above section 1); a listing whose fragment was never kept is cited by
its collected name (section 3); the terminal condition is measured by `pass status` (section 4);
the value order counts cross-divide candidates first (section 5); the file gains `precedent`,
`precedent_status`, `follows`, `survey`, `verdict`, `fingerprint` (section 6). Version 5, 2026-09-17:
two streams changed this file the same day and each called itself version 4; merged, it is version 5.
**Stream K11**: section 2 becomes a record of consensus rather than a set of blanket defaults decided
in advance, and gains the brand section 2.2 and the display-name policy 2.3; the set / coffret / kit /
refill row becomes a per-case question, section 2.1; the word lists become input to a pass, section 1.
**Stream K9**: the brand trailer list scoped per vertical, section 1 item 1a. Version 3, the same day:
section 6, the file. Version 2: identity rules v6, section 1 chosen from the three-way rehearsal;
version 1 was 2026-09-16.) Bump this line whenever section 2, the boundary in section 1 or a
threshold in section 4 changes, and say so in the handoff. A proposals file records the version it
followed; a pass that followed an older version is re-run or its proposals are shown as such.

Sources of truth: `.logs/planning/catalogue-refactor-plan-2026-09-16.md` (W10 to W16 and section 9),
`.logs/planning/catalogue-walkthrough-2026-09-16.md` (rian's words behind every default),
`main/app/services/product_lines.py` (its concentration table must equal section 1 item 6).

This is the file every review pass starts from; what a pass DOES, step by step, is `REVIEW-PASS.md`,
the specification any model executes from one packet. It is configuration a person can read and change: the boundary
between what a rule may do alone and what must be proposed, the consensus reached so far on each
grouping question, what a proposal must carry, what a person looks at before approving in bulk, and
the order brands are reviewed in.

**The division of labour, ruled by rian on 17 Sep, which everything below follows.** The
programmatic stage may act, **must err toward separate brands and separate product lines**, and is
**never reviewed by him**: many duplicate lines and brands is the correct output of that stage, not a
queue of questions. **The AI pass is the review.** It does the correction work a person would do in
an Airtable-style view of the raw data beside editable fields, which is real but impossible by hand
at this scale, and presents it for confirmation. A judgement call is raised **per case**, never
pre-decided as a blanket default; rian may approve it, **overturn** it, or **defer it with a note**,
and the note feeds the next pass, so the consensus accumulates into the reasoning rules in section 2.
Trust compounds until most answers are "approve all" (walk-through W16). Nothing here lives only in code or in a chat answer; a grouping judgement given in
conversation is written here as a default before it is applied anywhere (walk-through W12, rian:
*"a process issue, not a specific answer"*). The words are the ones in `VOCABULARY.md`: a product
line is the searched-for thing, a product variant is what a barcode names, an attribute is a fact
about a variant, a decision is a person's ruling, a proposal is what waits for one.

The process is written so a person could follow every step and check any of them. The expected
operation is that a Claude session follows it, a person spot-checks (section 4), and approves the
rest in bulk (walk-through W16). A machine never applies a proposal; a machine never undoes a
decision.

## The order of a review: five layers, and the survey before all of them

A review is passes with a person's answers between them, and each pass works ONE layer, the
outermost with an open question (`REVIEW-PASS.md` section 3):

1. **brands**: two rows that are one house (section 2.2).
2. **cross-divide product variants**: the retailers split into two populations that join on no key,
   one publishing barcodes and internal shorthand, the other clean names and no barcodes (the 18 Sep
   simulation: 15,035 of 16,719 product variants at one retailer). A barcoded row with no listed text
   and a name-bearing row with no barcode that read as one bottle at one size are a judgement a pass
   makes and a string comparison never will. It runs before the product line work because it changes
   what the lines contain. The candidates are a reading (`pass packet`, `hints --rule cross_divide`),
   never a queue row.
3. **product lines**, 4. **product variants within a line**, 5. **attributes**.

A decision in a layer may raise questions only in the layers after it, which is one of the bounds
that make the passes end (section 4). **Before any proposal a pass writes a survey**: every kind of
difference it can see between rows that would otherwise group, registered or new, with examples.
The sheet opens with it, new kinds first. The survey is a step, not a hope: the plan's sharpest
requirement is that a new kind of question is noticed once, loudly, before a bulk button exists to
hide it. The survey rules on nothing; it lists.

## 1. What a rule may do without review: the certain boundary

**This list is a rules version: identity rules v6, boundary (a).** Changing it is a deploy and a
`rederive` of every product variant's key, announced in the handoff and the changelog, never a
quiet edit. Every item is something the shop stated outright, or a fold that cannot change what
was stated. A rule removes from the listed name **only** what this list names; the residual name
keeps every other word, and the certain key is `brand | residual name | identity attributes |
quantity` (plan W14; `normalize.match_key`, `product_lines.product_line_key`,
`product_lines.identity_slot`). The key is a pure function of the listed words and the alias
maps: a person's decision on a variant (its line, an attribute, its quantity) changes what a page
reads and never the key, so the variant's own listing still lands on it at the next sighting.

**How it was chosen (K3.2, 17 Sep, on a copy of the 16 Sep staging dump; 16,760 live variants,
6,895 with no barcode, 3,737 comparisons).** Three boundaries were rederived and measured: **(a)**
this list as written; **(b)** (a) plus a closed list of drink category nouns (whisky, gin, vodka,
blended, "single malt"); **(c)** (b) plus the format words that never name a product (spray,
bottle, ml). Of the 10,008 listings that reach their variant only by the key, 699 key apart under
(a), 514 under (b), 508 under (c); comparisons that would lose a shop: 114, 85, 83. The groups the
automatic merge would fold, read by name: (a) 5 groups, 0 wrong; (b) and (c) 22 groups, **3 wrong**
(dropping "spirit" joined Montblanc Legend with Legend Spirit, and Habit Rouge with Habit Rouge
Spirit). A wrong join outweighs any number of splits, so **(a)**. The splits are not lost at the
next collection either: a listing already placed stays on its variant while the shop's words for
it are unchanged (`ingest`), and what the lists would have joined is on the sheet as proposals.

1. **The brand, matched to its row.** The whole word sequence of one of the brand's spellings at
   the head of the listed name (an article before it tolerated: "The Macallan 12"), or the whole
   sequence anywhere in the name ("Flower by Kenzo"); aliases followed to the brand. An
   abbreviation or a partial ("Joh." for Johnnie Walker, "Armani" for Giorgio Armani) is not a
   match: the words stay in the name and the match is a proposal (`rule:brand_partial`).

   1a. **Which spelling reaches which row is scoped to the vertical.** A trailing listed word is
   dropped before the spelling is resolved ("Tanqueray Gin" and "Tanqueray" are one row), and the
   list that may drop it belongs to the vertical: the drink and producer words in liquor, the house
   and city words in beauty, the corporate suffixes and the article in every vertical, and NOTHING
   else in a vertical nobody has written a list for. So two companies in an unlisted vertical whose
   names differ only by a listed word are two brands, and what the drinks or beauty list would have
   joined for them is a pair on the sheet (`rule:brand_trailers`), never an act. A vertical's words
   are written here before its brands are approved in bulk, as section 2's last row already
   requires for its grouping default. A fold that turns out to be wrong is undone by SPLITTING the
   row: a person names the spellings that do not belong and the brand they move to, which is one
   recorded decision with an undo (`app.cli brands split`, or the offer on a rejected
   `rule:brand_trailers` row). The fold itself records nothing, so the split is what there is to
   undo.
2. **A quantity the parser read as a stated number with its unit** (100 ml, 3.5 g, 1L, 6 pcs),
   from the name or from the shop's own size field or size option. A figure taken from a slug, a
   parent SKU or a product family's tile is not stated and is not read (a multi-size tile once
   priced the 1.5L at one airport and the 75cl at another). A product nothing placed (no shelf, no
   category) that states none keys its slot as `n/a`, which equals itself; a placed one as
   `unknown`, which equals nothing.
3. **An ABV percentage the name states** ("40%", "43% vol"): it leaves the residual name and sits
   in the key as `abv=40`. Numbers compare as numbers, so "40" equals "40.0". A stated value and a
   silent name are not certain agreement, so they key apart and the pair is for a person; two
   different stated values never merge automatically (plan W9), whichever field stated them. A
   person may still Confirm same across them and then set the value as a decision.
4. **A pack figure** ("3x1L", "2 x 50 ml"): the count and the member quantity, as a pack.
5. **A shop-published option field.** A value the shop published as its own field (a Shopify
   `option1`/`option2` with the product's option names), carried by the collector as a field and
   stored as `option:<the shop's name for it> = <value>` until the attribute registry maps the
   name; the option that is the size is the quantity (item 2) and is not repeated. A name stored
   before the collectors carried options keeps the ` / 99 Pirate` tail reader as its fallback
   (`backfill options` reads the stored fragments first). The shop's field is certain; the same
   words found loose in a name (Extime's ` - 447 Mellow Shade`, Avolta's bare `01`) are not, and
   become proposals (`rule:shade_shapes`).
6. **The closed concentration vocabulary**, each synonym folded to one canonical word, matched as
   whole words, longest phrase first, case and accents ignored:
   - **Eau de Parfum**: eau de parfum, EDP
   - **Eau de Toilette**: eau de toilette, EDT
   - **Eau de Cologne**: eau de cologne, EDC, cologne
   - **Parfum**: parfum, perfume, extrait, extrait de parfum, essence de parfum
   - **Elixir**: elixir, élixir, elixir de parfum (Elixir dominates when named with another)
   - **Mist**: mist, body mist, hair mist, brume, bruma
   - the qualifiers, kept beside the concentration in this order: **Intense** (intense);
     **Extreme** (extreme, extrême, extrème); **Absolu** (absolu, absolue, absolute, absolu de parfum)
   A word not on this list ("Sport", "Nuit", "Le Parfum" as a line name) is not a concentration and
   stays in the residual name.
7. **Case, accents, punctuation and glyph folding**: upper and lower case, diacritics, ß and
   ligatures, curly and straight quotes, fullwidth and ordinary digits, non-breaking and ordinary
   spaces, runs of punctuation, "V.S.O.P." as `vsop`, "N°5" as `no 5`, and the trademark and
   service-mark glyphs ("CÎROC™" and "Cîroc" are one brand). A fold never removes a word — and
   since version 4 a brand's key removes no trailing word either (section 2.2).
8. **The spellings of a stated age fold to one token**: "12 Years Old", "12 Year", "12 YO", "12y",
   "12 ans", "Aged 12 Years" are all `12yo`. A bare "12" or a numeral ("XV") states no age and
   stays as written; that the two meet is a proposal (`rule:age_words`).

**Read but not removed.** A stated age is also read as the certain attribute `age`, in years
(walk-through W12); its token stays in the residual name, because whether an age names its own
product line is a grouping default (section 2), not a rule. A vintage is not read by a rule yet
(a year in a name is as often the brand's: "1800", "Chanel 1957"); the pass proposes it.

**Everything else is left alone, and the word lists are input to the pass, not questions for a
person.** Every open word list that used to act is a generator in `services/proposal_rules.py`:
drink words, region words, the age's own words, format words, packaging noise, articles and
connectors, the audience fold, pack words, a partial brand, the lists acting together, unmarked
shade shapes, cask words, skin-type tails, brand trailers. **What a list would have removed or
grouped is a hint the pass may read, with its reason ("removed 'blended', 'scotch', 'whisky' as
drink category or describing words"), and never a row in rian's queue.** Version 3 put them on the
sheet (plan W14, option D) alongside the certain-only key (option B); the two together turned about
938 guesses into questions he was asked before the pass had ever run, which is the stage-1 output
rule 1 says he never sees. The knowledge in the lists is kept, and the pass is what reads it. The
classifier's category is likewise a hint; the shop's own shelf is the certain input.

## 2. Grouping defaults the pass proposes, per vertical: the consensus as it forms

**This section is a record, not a rule set.** Version 3 wrote it as blanket defaults decided in
advance, per vertical, before a single case had been looked at. Rian's ruling of 17 Sep is that a
judgement call is raised **per case and never pre-decided**, and that the consensus his answers form
is what becomes the reasoning here. So each row below says **what was decided**, **from which case**
it was decided, and **what is still open**. A row with an open column is not a default the pass may
apply quietly: it is a question the pass raises, with the reasoning written out, for him to approve,
overturn, or defer with a note.

A decision recorded here is what the pass proposes when the text gives no reason to do otherwise; a
person confirms or changes it per brand or per product line on the sheet, and the decision is
recorded (plan W15). **A decision that changes does not regroup what is approved**; it changes what
is proposed next, and the sheet shows the difference (plan W12). A product line's proposed name
reads with its brand ("Chivas Regal 12", never a line named "12"), and is generated from the words,
never typed.

**How a row gets here.** A case reaches rian on a sheet; he approves, overturns or defers it; when
the same judgement has been made enough times that the pass can make it without asking, it is
written here with the case it came from. Until then the pass asks. A deferred note is part of the
record too: it is what the next pass reads before it proposes the same thing again (section 2.3).

| Vertical and case | What was decided | From which case | Still open |
|---|---|---|---|
| **Aged spirits** | one product line per age: Chivas Regal 12, Chivas Regal 18, Chivas Regal 25 are three lines; the age is also the attribute `age` on every variant | walk-through W12, rian's answer; the Chivas Regal and Glenfiddich table there | settled |
| **A finish or cask edition** (Oloroso Sherry, Triple Cask, a Guatemalan rum cask) | a member of its parent line, told apart by the attribute `finish` (or `cask`): Glenfiddich 15 Oloroso Sherry sits in Glenfiddich 15 | plan W12; walk-through W12's Glenfiddich rows and W14's Triple Cask | whether a finish that the brand markets as its own range is still a member |
| **A limited edition** (Gold Signature, a festival release, a year's design) | proposed as a member of its parent line with the attribute `edition` | plan W12; walk-through W12's "18 Gold Signature" against "Gold Signature 18" | **raised per case**: the brand's own range structure decides, and the sheet shows the pair |
| **A set, coffret, kit or gift pack** | **nothing is decided; the pass raises it per case** (section 2.1) | plan W12 proposed "never a member of the bottle's line"; rian withdrew it as a blanket default on 17 Sep | the whole question: see section 2.1 for the two distinctions the pass must weigh |
| **Concentrations** (Eau de Toilette, Eau de Parfum, Parfum, Elixir, Intense) | members of one product line, told apart by the attribute `concentration`: 1 Million holds its EDT, Elixir and Parfum at every size | plan W2's example; the certain vocabulary in section 1 item 6 | settled |
| **Shades** (makeup, hair colour, nail) | members of one product line, told apart by the attribute `shade`: Rouge Allure is one line over every shade | walk-through W10, the Rouge Allure rehearsal (59 lines to 17); W18, rian: a shade compared across airports is good | settled |
| **Flavours** (confectionery, a spirit's flavoured range) | members of one product line, the attribute `flavour`: Lindor Truffles over Milk and Dark | identity rules v5 (plan section 1, "kept, changed, dropped"); walk-through W10 | whether a flavour the brand sells as its own named range is a line of its own |
| **Wine and champagne** | one product line per vintage, the vintage also the attribute `vintage`; a non-vintage cuvée is its own line | plan W12: "wine vintage follows the same default until the sheet says otherwise for a house" | per house, as W12 allows |
| **Electronics** | one product line per model family as the maker names it (RingConn Gen 2 Air), size and colour as attributes | walk-through, the brief's RingConn ring from Panama; plan W7 | **raised per case**: whether a new generation is a new family |
| **Clothing and accessories** | one product line per style; size and colour as attributes, where size is a label, never a quantity | plan W2 (size as a clothing attribute kind); walk-through W7 | settled |
| **A vertical not in this table** | the certain-only grouping (nothing beyond the certain key), and a `decide` item on the running list naming the vertical, so its first cases are reviewed before its brands are approved in bulk | the standing rule: a new kind is configuration, never a one-off answer | by definition |

### 2.1 A set, a coffret, a kit, a gift pack, a refill: the case rian has not settled

This was a default in version 3 ("never a member of the bottle's line: its own product line"). It is
not one now. Rian is **genuinely undecided**, and said so in the ruling that produced this version:
the cases are not alike, and one answer across all of them would be wrong somewhere. **The pass
raises each case, names which of the two distinctions it falls under, and proposes with its
reasoning shown.** It never applies an answer here quietly.

The two distinctions he drew, which are what the pass must weigh and say out loud:

1. **A refill serving several product lines is not the same case as one product line with one
   refill.** A refill that fits exactly one line is a member of it, told apart by an attribute. A
   refill sold for a range — one cartridge across four lines — belongs to none of them, and making
   it a member of any one is a false grouping. The pass must say which of these it is looking at,
   and cite the words it read that from.
2. **A gift set spanning several product lines is not the same case as three shades of one product
   line.** Three shades boxed together are that line at three attribute values; a coffret holding a
   lipstick, a mascara and a scent spans lines and is its own thing. Again the pass says which, and
   cites.

Where the words do not say which, the pass proposes nothing and asks, because this is exactly the
"empty beats guessed" case (walk-through W11 rule 2). **`FORMAT_WORDS` keeps set, gift, duo and
refill** — rian has ruled the opposite of the earlier proposal to drop them. They stay on the list so
the words are *recognised as a possible attribute*, never folded into the bottle's line by a rule.

### 2.2 Brands: when two brand rows are the same house

The document has never had this section, and brands are where the programmatic stage is now most
deliberately conservative: since version 4 the brand key folds nothing but case, accents and
punctuation (section 1 item 7), so **two spellings that are the same house stay two rows until a
person says otherwise**. That is rule 1 working — the stage errs toward separate — and it means the
pass, not a rule, does every brand join.

**The same house.** Two brand rows are proposed as one when the pass can show they are the same
company's name for the same thing: one spelling is the other with a corporate or category word
attached ("Tanqueray Gin" beside "Tanqueray"), a punctuation or glyph difference ("CIROC™" beside
"Ciroc"), a known rename, or a shop's abbreviation of a name another shop writes in full ("Joh.
Walker" beside "Johnnie Walker"). The proposal names which of these it is and cites the listings.

**A rename with no shared words is the case no rule can find, and the pass must.** Paco Rabanne
became Rabanne. No key, no fold and no string distance joins those two rows; only knowing the fact
does. This is the clearest argument for the pass existing at all, and it is the pass's job to bring
these: a brand the trade renamed, a house that sells under a founder's name and a short name both,
a brand whose travel-retail spelling differs from its domestic one. The evidence is the pass's
reasoning, not a span, and the proposal says so plainly rather than inventing a citation.

**A containment overlap is a candidate, not a conclusion.** "Armani" appearing inside "Giorgio
Armani" does not make them one row: Armani, Giorgio Armani, Emporio Armani and Armani Exchange are
a real hierarchy the shopper tells apart, and folding on containment would erase it. The pass may
raise a containment pair as a candidate; it must then give a reason beyond the containment itself,
and where it cannot, it leaves the rows separate and says why. The same caution applies to a short
brand name that is an ordinary word inside a longer one.

**Never joined by a rule.** None of this is a fold in `brand_key`. Every brand join is a decision
with a person behind it, recorded like any other, and `apply_brand_alias` cascades it to the
brand's lines and variants. A brand pair a person kept separate is never proposed as one again.

### 2.3 The display name of a brand

**A brand's display name is what the brand itself would use.** Not the spelling that happens to be
most common in what has been collected: that changes with every collection, and a display name that
moves when a shop adds stock is not a name, it is a tally. So the pass chooses it, as the judgement
it is, and says why.

- **Where nothing is set, the pass sets it.** A brand row with no decided display name takes the
  pass's proposed one on approval.
- **Where a person has set it, the pass never overwrites it.** A pass that disagrees with a decided
  display name proposes **against** that decision (`against_decision_id`), which is shown on the
  sheet as a disagreement and is never applied by itself. This is the standing rule that a machine
  never overwrites a person, applied to the one field most likely to tempt it.
- **The same holds for a product line's display name**, with the line's own brand read as part of
  it ("Chivas Regal 12", never "12").

**Ties and typos.** When two spellings of one product line differ only by word order or a typo
("Xv 15" and "15"), the pass proposes them as one line with the cleaner spelling and cites both
spans. A spread that looks like a retailer's entry error is a proposal to quarantine, not a merge.

## 3. What a proposal must carry

Every proposal in a proposals file (`app.cli proposals load --file <json>`, plan W13) carries:

- **The pass id**: a name and the version line of this file it followed.
- **Natural keys, never database ids**, so the file replays onto a database with different ids:
  a brand by its slug; a listing by shop code plus the shop's SKU; a product variant by its barcode,
  else by the listings it holds; a product line by its brand slug plus its proposed slug.
- **The text span every proposed value was read from**: the listed field (name, option, shelf) and
  the exact words, quoted. **A value the raw text does not contain is never proposed**; empty beats
  guessed. A shop's typo is cited as it stands and the correction proposed beside it.
- **A confidence**, 0 to 1: how surely the span means the value. Anything the pass would call a
  guess is left empty rather than given a low number.
- **One line of reasoning** a person can read on the sheet.
- **Its origin**: the pass, or a rule (`rule:<list name>`) when a word list generated it.
- **Its relation to any existing decision**: a proposal against a decided value is marked as
  disagreeing with that decision's id, is shown, and is never applied; a pair a person kept
  separate is never proposed as one.
- **The kind of judgement it is** (`precedent`, a register slug), whether it **sets** that
  precedent or **follows** it, and for a follower the lead it goes with (`follows`). Required from
  version 6: the register (section 4) cannot floor what nothing names, and the nameless row is the
  one bulk approval would hide. One lead sets a slug per file.

**A listing whose fragment was never kept is cited by its collected name.** Some sources were last
read before fragments were kept and are refused since; their listings have no listed column and
never will until permission changes. The only text a pass can cite for them is the name the
collector built from the shop's own fields (`product_variants.name`), and section 3's rule would
otherwise leave those product variants untouchable. So `source: collected_name` is accepted on a
listing with no fragment, refused on one that has a fragment (`EVIDENCE_HAS_FRAGMENT`), and shown
on the sheet as *"as collected, no fragment kept"*. A derived, normalised name on that side is not
kept: it would be a machine value in the standard layer looking like a fact.

A listing whose listed words have not changed since its last approved decision is not proposed
again; a price change alone is never a reason to review (walk-through W11, rule 4).

## 4. Attention is a control: the ladder, the floor, the group, the spot-checks, and when the review is done

**The levels gate the answer** (K12; the plan's B1). One word each, the code's (`critical / high /
medium / low / none`); the ladder proposal's other word for the second level is not used.

| level | bulk? | to answer it |
|---|---|---|
| **critical** | never | a click **and a note** (`NOTE_REQUIRED`): the note sets the precedent |
| **high** | never | a click; a note is optional, except "Suggest something else", where the note is the answer |
| **medium**, **low** | as a group | one confirmation for the precedent group, with an optional note written on every row it covered |
| **none** | with its lead | a follower: it names the lead it goes with and goes with that answer |

**The floor is mechanical and the pass cannot lower it** (`attention.floor_for`). A kind of
judgement not in the register is `critical` by construction. A row filed under a registered kind is
compared with the shapes decided under it (which hint lists fired on its cited text, which listed
words they found, the entity type, the field, the vertical); anything never seen under that kind
marks the row **unlike its precedent** and floors it at `high`, naming the element. A registered
kind stays `high` until it **leaves high after 3 consistent decisions** (`attention.LEAVE_HIGH_AFTER`;
a test holds the two equal); an overturn or a "something else" on a lead resets that count to zero.
An overturned kind is `high` until the next answers set it again. Past the count, the
confidence-derived level applies. Decay is counted from the ledger, never from a model's confidence.

**The bulk group is the precedent**, the set of rows one answer decides: the lead and its followers
and like rows (`scope: {"precedent": slug}`). The lead is answered first (`LEAD_NOT_ANSWERED`); a
lead answered otherwise refuses its followers (`LEAD_ANSWERED_OTHERWISE`). "The rest"
(`scope: "all"`) is refused while any critical or high row on the sheet is open
(`BULK_BLOCKED_BY_OPEN_LEAD`, naming them). A group's confirmation shows its **unseen words**: the
tokens in its cited text no decided row in the vertical has carried. Long on the first brand,
short by the fiftieth; blocks nothing.

**The spot-checks** still hold rows back from "the rest": every merge or comparison change, the
five lowest-confidence rows and every one below 0.7, and a five-percent seeded sample, marked at
load and never cleared while the row waits. A follower of an answered lead is not held back by the
mark: it was named on the lead the person just read.

**One strength of decision**, with the mode recorded (`individual`, `bulk`). A bulk decision does
not expire. When this file's version changes, the next pass may propose against decisions made
under the old version; those are shown as disagreements and never applied.

**The round cap.** A question deferred twice is parked to the running list (`PARKED_TO_RIAN`): no
pass asks it again until a person answers it there and runs `pass unpark <uid> --note "<the
answer>"`, which makes it a deferral carrying that answer. Defer loops cannot be infinite.

**Correcting a precedent later** is one act, `precedents overturn <slug> --note "<the new answer>"`
(the guide tab has the button): an undoable ledger decision that regroups nothing. The decisions made
under it stay in force; the next pass reads them from the packet and proposes against them; the
person confirms those as one group. Undo-batch is for a mistake noticed at the moment; overturn is
for a mind changed later.

**Done is measured.** `pass status` prints, per brand, `never | open | deferred | parked | settled |
unsettled`, and one line: `done: 0 open, 0 deferred, 0 wants, 0 parked; N brands settled`, or the
counts that remain. A pass that finds no question in any layer loads a file with zero proposals and
`verdict: settled`, stamped with this file's version, the identity rules version and the brand's
listed-words fingerprint; any of the three changing un-settles the brand. The bounds that make the
passes end: layers never re-open outward; a question has at most three rounds; unchanged words are
never re-asked; every pass shrinks the open set or declares settled; an arrival un-settles only the
brand it touches.

## 5. Value order for the review

1. **Brands by comparison gain**: the cross-divide candidates (the order of a review, layer 2) plus the number
   of barcode-less variants a confirmed grouping would bring into a cross-airport comparison,
   counted from the data by `sheets`, highest first.
2. **Then the 97 brands that hold 53 percent of listings** (measured 16 Sep: 2,365 brands, 23,770
   listings; the pass re-measures rather than trusting the figure).
3. **Then the rest.**

A partial review is useful at every point, because every unreviewed page shows the certain-only
grouping, is reachable, and is noindex until a person approves it (plan W18).

## 6. The file

One JSON file per brand per pass, validated by `main/app/services/proposals_schema.json` (an error
names the row and the field):

```json
{"pass": {"name": "claude/2026-09-18/chanel-1", "kind": "session", "process_version": "6",
          "rules_version": "6", "generator": "claude-session", "note": "...",
          "survey": [{"kind": "an audience word on some siblings", "precedent": "membership:audience-marked",
                      "registered": false, "examples": ["listing:attenza/BOG/<sku>"], "question": "..."}],
          "verdict": "proposals", "fingerprint": "<from pass packet>"},
 "brand": "chanel",
 "proposals": [
  {"sheet_line_ref": "line:new:chanel-rouge-allure", "position": 0,
   "entity": {"type": "product_line", "key": "line:new:chanel-rouge-allure",
              "detail": {"brand_slug": "chanel", "slug": "chanel-rouge-allure", "name": "Rouge Allure",
                         "absorbs": ["line:<uid>"]}},
   "field": "name", "value": "Rouge Allure", "confidence": 0.95, "reason": "one readable line",
   "precedent": "name-a-product-line:plain", "precedent_status": "follows", "attention": "medium",
   "evidence": [{"listing": "listing:attenza/BOG/<sku>", "source": "listed_name", "span": [0, 12],
                 "text": "ROUGE ALLURE"}]}]}
```

- **Natural keys only**, never an id: `brand:<slug>`, `line:<uid>`, `line:new:<slug>` (a product line
  the approval adopts or mints), `variant:<uid>` with its listings in `detail.listings`,
  `listing:<retailer>/<shop code>/<sku>`, `pair:<level>:<a>||<b>` (sides sorted), `wording:<vertical>|<raw>`.
- **Fields**: `name`, `product_line` (a line key), `attribute:<kind>` (the registry's kind name; a
  shade is `attribute:color`), `alias_of`, `merged_into`, a pair's `decision`
  (`{decision: same | separate, survivor, name, note}`), `pinned_to`, `ignored`.
- **Evidence**: at least one span per row; `source` is the listed column (`listed_name`,
  `listed_variant` for a shop's option field, `listed_quantity_text`, `listed_category`), or
  `collected_name` on a listing with no fragment (section 3); `span` the character offsets into it,
  `text` exactly what it holds there. A span that does not read `text`
  loads stale; the sheet re-reads every span on every view.
- **Absorbs**: a header names the existing lines it takes over, or the loader derives them (every
  line of the brand whose every live variant is proposed into it). An absorbed line is aliased to the
  approved one once no live variant is left on it.

The commands, in the order a review runs them (a Postgres copy first, then staging):

```
app.cli proposals load --file <json> --check --as <username>   # counts; every table unchanged
app.cli proposals load --file <json> --as <username>           # an identical file is a no-op
app.cli proposals sheet --brand <slug> [--rows]                # or /review?brand=<slug>
app.cli proposals approve --brand <slug> --pass <name> --line <line key> --by <username>
app.cli proposals approve --brand <slug> --pass <name> --all --by <username>   # spot-checks left out
app.cli proposals approve --brand <slug> --pass <name> --uids <uid>... --by <username> --note "<required on a critical row>"
app.cli pass packet --brand <slug> > packet.json                # what a pass is given (REVIEW-PASS.md)
app.cli pass status [--brand <slug>]                            # per brand, and the one done line
app.cli precedents list | show <slug> | overturn <slug> --note "<the new answer>" --by <username> | export
app.cli proposals approve --brand <slug> --pass <name> --reject <uid> --note "<one sentence>" --by <username>
app.cli decisions verify
app.cli decisions undo-batch <batch uid> --reason "<why>" --by <username>
app.cli proposals withdraw --pass <name> --reason "<why>" --by <username>
```

A header that is itself a spot-check holds its members back from `--all` (they report
`LINE_NOT_APPROVED`); approve the header by uid and its members follow. On another host the same
file loads unchanged: `decisions export --pass <name>` here, `decisions replay` there, then `proposals
load` of the file marks every replayed row approved (or rejected, for a Keep separate).
