# The review process

**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 it never had; the set / coffret / kit / refill row leaves the table and
becomes a per-case question, section 2.1; the word lists stop being rows in a person's queue and
become input to a pass, section 1. **Stream K9**: the brand trailer list scoped per vertical,
section 1 item 1a, and what a person does about a fold that was wrong. Note that K11 then removed
that fold from the key entirely, so the scoping now governs what the list PROPOSES, not what it
does; no brand row re-slugs from the scoping itself. Version 3, the same day: section 6, the file.
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 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 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.

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

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