# The review process

**Version 3, 2026-09-17** (Stream K4: section 6, the file, added; section 4 names exactly what the loader
marks. Version 2 the same day: identity rules v6, section 1 chosen from the three-way rehearsal; version 1
was 2026-09-16). Bump this line whenever a default in 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 one file the Claude review pass reads before it proposes anything, and the file every
later automated pass starts from. It is configuration a person can read and change: the boundary
between what a rule may do alone and what must be proposed, the grouping default for each vertical,
what a proposal must carry, what a person looks at before approving in bulk, and the order brands
are reviewed in. 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.

## 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`).
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`. A fold never removes a word.
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 a proposal.** Every open word list that used to act is a proposal generator
in `services/proposal_rules.py`, one pass per list (`rule:<list>:6`, withdrawn alone): 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 appears on
the sheet as a rule-based proposal with its reason ("removed 'blended', 'scotch', 'whisky' as
drink category or describing words"), beside the pass's own, and is never applied by itself (plan
W14, option D). The classifier's category is likewise a proposed attribute; the shop's own shelf
is the certain input.

## 2. Grouping defaults the pass proposes, per vertical

A default 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 default 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.

| Vertical and case | Default the pass proposes | Where it came from |
|---|---|---|
| **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 |
| **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 |
| **A limited edition** (Gold Signature, a festival release, a year's design) | proposed as a member of its parent line with the attribute `edition`, and **confirmed per case**: the brand's own range structure decides, and the sheet shows the pair | plan W12; walk-through W12's "18 Gold Signature" against "Gold Signature 18" |
| **A set, coffret, kit or gift pack** | never a member of the bottle's line: its own product line, its contents as attributes where stated | plan W12; `form_of` in `product_lines.py`, written after a gift set folded into its bottle |
| **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 |
| **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 |
| **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 |
| **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" |
| **Electronics** | one product line per model family as the maker names it (RingConn Gen 2 Air), size and colour as attributes; whether a new generation is a new family is confirmed per case | walk-through, the brief's RingConn ring from Panama; plan W7 |
| **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 |
| **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 default is written here before its brands are approved in bulk | the standing rule: a new kind is configuration, never a one-off answer |

**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.

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. What the person spot-checks

The sheet marks these; the person reads the listed words beside each, not the proposal alone:

1. **Every proposal that merges variants or changes a comparison**: a pair proposed as Confirm same,
   a `merged_into`, a listing pin (a listing moving between variants), and a quantity (the comparison
   unit). A product line membership moves a variant between pages, not between comparisons, and is
   not marked by this item.
2. **The lowest-confidence proposals per brand**: the five lowest, and every one below 0.7.
3. **A random sample**: five percent of the brand's proposals, at least three, drawn from the rows
   items 1 and 2 did not mark, seeded by the pass name so a re-load draws the same sample. A mark is
   never cleared while its row waits.
4. **The rest may be approved in bulk**, one act with one batch id, undone as a unit (plan W15).

One strength of decision, with the mode recorded (`individual`, `bulk`; plan W16). 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.

## 5. Value order for the review

1. **Brands by comparison gain**: the number of barcode-less variants a confirmed grouping would
   bring into a cross-airport comparison, counted from the certain-only state by the pass, 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": "3",
          "rules_version": "6", "generator": "claude-session", "note": "..."},
 "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",
   "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`), `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>
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).
