---
type: plan
version: 1
status: PROPOSED 2026-09-16 for rian's acceptance; nothing here is built. Written from `catalogue-walkthrough-2026-09-16.md` (W1 to W20, issue-first), the 15 Sep decisions it revises, the brief and its two companions, VOCABULARY.md and DATA-MODEL.md. Every walk-through outcome was treated as a hypothesis; where this plan disagrees it says so and why. No code, data or configuration was changed; no multi-agent workflow was run.
supersedes: the 15 Sep decisions where the two conflict (each conflict is named); the walk-through stays as the record of the issues
---
# The catalogue refactor: decisions, plan, streams, escalations

## 0. The one problem under the twenty items

Rian's instinct across the walk-through is that we keep fixing symptoms. He is right, and the
problem has a single shape. **Identity is derived from the listed name by hand-kept word lists at
collection time, and that derived identity silently drives automatic merges.** Every incident on
record is that one failure: the stopword list that deleted "reserve" and put a medal on the wrong
bottle; "triple" on a format list turning Triple Cask into Cask; shades left in product line names;
"Joh." unread as Johnnie Walker; product lines named "12". Each was found by a person after the
fact, fixed by editing a list, and the next one is already waiting in a category nobody has
collected yet.

So the refactor is not twenty fixes. It separates three things that are one function today:

1. **Record** what was collected, exactly (the listed layer; already right, 14 Sep).
2. **Extract only what is certain**, by rule, replayably: a barcode, a stated quantity, a declared
   currency, an option the shop published as its own field, a closed vocabulary such as
   Eau de Toilette = EDT. These become structured data with no review.
3. **Interpret** everything else (which listings are one product variant, which variants form a
   product line, what the ambiguous words in a name mean, which shelf a thing belongs on) as
   **proposals**, reviewed by a person or by an AI following a written process, recorded as
   **decisions** with provenance and undo, never applied silently, never undone by a machine.

Rian's four rules (W11) are exactly this split, and W12's process rule ("a grouping judgement is a
default the review applies, never a one-off answer in chat") is the operating principle of part 3.
Everything below is the consequence. Where the walk-through's provisional outcome already follows
this shape, the decision confirms it; where it does not, the decision changes it and says why.

**The most important single fact for the plan:** the automatic key-based merge is a small
contributor to comparisons. 9,865 of 16,761 variants carry a barcode and match by it; the
rule-based fold has produced 612 merges in the catalogue's life. So making the automatic key
"certain-only" (decision W14) costs little at launch and removes the whole class of silent wrong
merges. (Caveat: ingest also lands a new barcode-less listing on an existing variant by key; that
path also becomes certain-only, and the rehearsal in stream S2 measures the difference.)

## 1. The decisions, W1 to W20

Each: the decision, the reasoning, what it overturns, the reversal cost, and where I disagree with
the walk-through's provisional outcome.

### W1. `/products/` shows product lines, before launch

**Decision: as rian decided.** Product line pages at `/products/<product-line-slug>`; no separate
variant pages; a variant is addressed within its line as `?variant=<id>`, server-rendered with that
variant preselected, and every such address carries `rel=canonical` to the bare line address. The
old `/products/<name>-<id>` shape answers with a 301 to the line with the variant preselected.

Why the query string and not the fragment: a comparable card must open the exact comparison it
promised (rian's instruction), which means the server must render the chosen variant and the chosen
airports; a fragment never reaches the server. Duplicate-indexing risk is closed by the canonical
and by W18 (every product line is noindex until approved, so there is nothing to duplicate yet).

The **card deep link contract**: `/products/<line>?variant=<id>&airports=LHR,CDG,SIN` where the
airports are the ones the card's comparison used (from the shopper's chosen set, or the card's own
set when none is chosen). The page shows exactly that comparison; the canonical drops both
parameters.

**Overturns** 15 Sep §2.7 (a recommendation) and the 9 Sep address decision's meaning (the shape
`/products/` stays; what it shows changes). Mark has been told; proceed unless he says no.

**Reversal cost**: the old variant page code stays on the branch until Cannes; reverting is a route
change and a sitemap change, nothing in the data.

**Disagreement**: none with the outcome. On timing, see the cut line (section 3): the page is
above the line; the smallest honest version is defined there.

### W2. The words: one term per concept, chosen

**Decision.** The terms, chosen so that the database, the code, the labels and speech can all use
the same word, with the rule that a bare "product" is never written:

| Concept | The one word | Database and code | Notes |
|---|---|---|---|
| the searchable thing, one page | **product line** | `product_lines`, `ProductLine`, `product_line_id` | `line_id` and `line_key` are renamed to the full form in the consistency pass; new code writes the full form from day one |
| the barcoded thing | **product variant** | `product_variants`, `ProductVariant`, `variant_id` | today `products` / `Product`; renamed in the consistency pass |
| a value that tells two variants apart, and a fact about a variant | **attribute** | `attributes`, an attribute **kind** registry | one noun; each kind has two settings (W8) |
| a row that stands for another | **alias** | `alias_of_id` | replaces `canonical_id`; "canonical" is retired |
| the row every spelling resolves to | **the brand** / **the product line** | `brand_of()`, never `house` | |
| two variants becoming one | **merge** | `merged_into_id`, `merges` (today `product_merges`) | "fold" is retired in prose and code |
| a pair waiting for a person | **suggestion** | `suggestions` (today `merge_candidates`); the `/collectors` tab **Suggestions** | |
| accepting / declining a suggestion | **Confirm same** / **Keep separate** | `decision = same` / `separate`; the route `keep-separate` (today `reject`) | rian's words |
| a person's or an approved AI's ruling over a value | **decision** | `decisions` (today `overrides`); `rule_value` (today `collected_value`) | see W15 |
| a proposal awaiting a decision | **proposal** | `proposals` | new, W10 |
| how the page treats an attribute kind | **picked** / **shown** / **fact** | the registry's `display` setting | `determinant` / `descriptor` retired |
| how much is in the pack | **quantity** | `quantity_*` | `size_*` renamed in the pass; "size" survives only as a clothing attribute kind (a label, not a quantity) |
| a storefront we collect | **shop** | `shops` (today `locations`) | |
| where a shop is | **place** | `places` | new, W19; "location" retired in prose |
| a website's configuration | **source** | `sources` | a different concept from shop; both stay |
| the first shop's brand spelling | (retired) | `products.brand` dropped | W3 |

The URL `/products/` is not prose and keeps its settled form; it shows product lines, and the
glossary says so in one line.

**Timing, and where I disagree with "the correct way first".** Every word above is fixed now, in
labels, prose, the API's field names and all *new* tables and columns (the decisions, proposals,
attribute registry and places work in streams S1 to S5 are written in these words from the start).
The renames of *existing* stable tables and columns (`products`, `locations`, `size_*`, `line_id`,
`canonical_id`, `merge_candidates`, `overrides`) happen in one rename-only migration **after
Cannes**, with the glossary's "code says" column as its checklist and a test that fails on any
survivor. Reasoning: a rename-only migration is mechanical and safe whenever it runs; doing it in
the two days before launch competes with the line page for the same hours and touches every test,
router and the generated client at once; doing it on a fixed date after Cannes changes nothing a
shopper or the client sees. The one exception is `overrides`, which is rebuilt anyway (W15) and
becomes `decisions` now. Rian may overrule the timing; the cost of doing it before launch is
roughly one overnight stream and a full regression, and its benefit is that no session ever reads
`Product` as a variant again.

**Reversal cost**: words are cheap until the rename migration runs; after it, another migration.

### W3. Collected brand

**Decision: drop `products.brand` in the consistency pass; stop reading it publicly now.** The
public page, JSON-LD and search read the brand row's chosen name from the line page build onward.
For the 12,673 listings with no stored shop words yet, the brand row's name is still the right
thing to show (it is the brand); the shop's spelling is only ever a review-time fact, and it
arrives with the Extime recrawl. **Reversal cost**: none before the column drop; a backfill after.

### W4. How an alias works

**Decision: the alias row is a forwarding address, and it stays.** Variants keep pointing at the
alias row and readers follow the pointer; the column is `alias_of_id`. Rewiring variants to the
brand at confirm would make undo a data rewrite instead of a pointer clear (W15), and the alias row
is what catches the spelling at the next collection. A brand does not become "one row with a
spellings list": a spelling is a row because the collector needs a row to land on before anyone
has decided anything. **Overturns** nothing. **Reversal cost**: none.

### W5. Confirm same, keep separate, alias, merge

**Decision: the person sees two verbs everywhere; the record chooses alias or merge by level.** A
person never needs the alias/merge distinction to act. The desk says what the record will be in
one line under the button ("this will merge two variants; their listings join"). No confirm on
production before undo exists (W15). **Overturns** the button words only.

### W6. Variants, listings and prices

**Decision: as recorded; sound.** A merge gathers listings and never touches a price. A
merged-away variant row stays as a forwarding row (its old address and its history must keep
answering); it is never pruned.

### W7. More than one attribute on a variant; scaling to other categories

**Decision: attributes are an open list per variant, stored in the existing `attributes` JSONB
column, governed by a registry of kinds per vertical in code.** A new category is a registry
entry, never a migration.

The shape, chosen over the alternatives in the walk-through:

- **Storage: JSONB (option 1), not an attribute table (option 2).** Values are heterogeneous
  (a number with a unit, a text, a code), reads are per variant, and the one cross-variant query
  rian named ("every 12 year old whisky") is a GIN-indexed containment query. An attribute table
  buys constraints and indexes we would then have to make heterogeneous anyway, and a join on
  every read. The registry validates at write what a table would validate by constraint.
- **The registry entry per kind**: name, label, value type (`number` with a unit, `text`,
  `enum`), which verticals and categories it applies to, **identity** (does it decide sameness:
  `always`, `never`), **display** (`picked`, `shown`, `fact`), and where its value lives today
  (`attributes.<kind>` or a legacy column). That last field is the migration valve: `quantity`,
  `abv`, `country_of_origin` and `is_exclusive` stay in their typed columns for launch while the
  page, the sheet, the key and the ledger read every attribute through one accessor; moving a
  value into JSONB later changes one registry line, nothing that reads.
- **Quantity stays typed** (its seven columns, identity rules v4) and is registered as the one kind
  with structured storage. For a category with no quantity (a watch, a ring, a handbag) the
  registry marks quantity `not_applicable` for the category, and the key's quantity slot reads
  `n/a`, which equals itself; today's `unknown` (never equal) stays for a category that has a
  quantity the parser could not read.
- **The identity key from a variable list**: the identity attributes of the variant's category,
  sorted by kind name, each as `kind=value`, appended to the certain key (W14). Deterministic
  and stable under registry growth: adding a kind with `identity: never` changes no key; adding
  one with `identity: always` is a rules-version bump and a rederive, by design.
- **Shop-published options are attributes of a kind named by the shop until the registry maps
  it.** The RingConn ring at Panama ("... 6.0 / Plateado") arrives from a Shopify shop whose
  option names are `Talla` and `Color`: the collector carries them as `option:talla = 6.0` and
  `option:color = Plateado`, certain because the shop stated them as fields, identity because a
  shop's own option tells its variants apart. A registry mapping (`talla` is `size_label`, `color`
  is `color`) is a later registry line; nothing waits on it and nothing is guessed.

**Overturns** 15 Sep §2.4's fixed kinds and the four-slot key. **Reversal cost**: high after the
first review, because decisions will be recorded per attribute kind; that is why this lands first
(stream S1).

### W8. Options versus properties

**Decision: one noun, attribute; two settings on the kind, per category: identity and display.** As
recorded. On the grain of "decides sameness": per kind per category, in the registry, and **not per
product line** (see W9). A variant holds one value per attribute; two shops disagreeing about a
variant's ABV is a listed-layer disagreement queued for review, never two values.

### W9. The 40 percent versus 43 percent case

**Decision, and a disagreement with the recorded outcome.** Two parts:

1. **The automatic merge honours every Keep separate.** Necessary and cheap; a machine never
   undoes a person. Confirmed.
2. **Any certain attribute that disagrees vetoes a merge, category-wide.** ABV is a certain
   attribute when stated ("40%"). Two barcode-less variants stating 40 and 43 are different by
   evidence and never merge automatically; a typo (one shop's "43" on a 40 percent bottle) produces
   a conservative split that a person confirms the same on the desk, and the person then sets the
   ABV as a decision. This replaces the walk-through's **per-product-line identity exception**
   ("ABV decides sameness for this line, set when a person keeps a pair separate"). The exception
   is a second rule set that only a reader of the ledger can see, it scales badly (thousands of
   lines, each with its own identity rules), and it exists only because ABV sits outside the
   attribute list today. Once ABV is an attribute (W7), the existing veto ("two declared values
   that differ never join") covers it with no new mechanism, and the failure mode is the one the
   project's rules prefer: a split, never a wrong join. Tolerance: numbers compare as numbers
   ("40" equals "40.0"); a stated ABV against an absent one is not a disagreement.

Names for the two real versions come from the line plus the attributes that differ
("Blue Label 40% 1L", "Blue Label 43% 1L"), generated, never typed.

**Reversal cost**: none; the per-line exception can be added later if a category proves to need it.

### W10. Grouping and attributes by an AI-proposed review sheet

**Decision: rian's direction C, built as D.** Concretely:

- **Rules extract only certain facts** (W11 rule 1; the list in section 0). A shop-published option
  is certain; so the Attenza ` / 99 Pirate` shade leaves the product line name by rule, because it
  is the shop's own option field carried into the name by our collector. That one platform's shade
  collapse (rehearsed 15 Sep: Rouge Allure 59 lines to 17) therefore **ships**, reclassified from "a
  rule per shop format" to "a shop-published option". Extime's ` - 447 Mellow Shade` and Avolta's
  bare `01` are name text, uncertain, and become proposals. Better still, and the durable form: the
  Shopify collector passes `option1`/`option2` as fields instead of gluing them into the name, so
  the shade never touches the name rules at all (stream S2).
- **Grouping is a proposal.** The unit of proposal and review is **a brand**: one sheet per brand
  listing every proposed product line, under each every variant with its listings and its proposed
  attributes, each proposed value citing the text it was read from. A person (or an AI following
  the written process) corrects cells, then approves the sheet or parts of it; approval writes
  decisions (W15) in one batch with the sheet's id.
- **The existing word-list rules become proposal generators, not actions** (option D of W14). Their
  knowledge is kept: what the lists would have removed or grouped is shown on the sheet as a
  rule-based proposal beside the AI's, with its reason. Silent errors stop; nothing learned is
  thrown away.
- **New listings after an approved sheet** land by the certain key (barcode, else the certain
  residual). A new arrival whose certain key matches a decided variant joins it (that is not a
  guess). One that matches nothing becomes its own variant on its own new product line, reachable
  and noindex (W18), and is queued as a proposed addition to the nearest approved line; it never
  auto-joins an approved line and never re-opens the approved sheet.
- **Launch pages for unreviewed brands** show the certain-only grouping: more, thinner product
  lines, every one reachable and noindex, nothing on them guessed. The review, prioritised by value
  (section 3), improves them brand by brand; a page's quality never depends on a rule guessing.

**Overturns** 15 Sep §2.9's adoption order (auditor first, grouping by model last): grouping by
proposal is now central and first. Identity rules v5's shade, flavour and pack-word rules are
re-filed: the shop-published shade ships as certain; the pack-word and flavour readers become
proposal generators (their tests move to the proposal side, pinning what is proposed, not what is
applied).

**Reversal cost**: the sheet and the proposals store are new; the certain-only key is a rules
version; going back to rule-grouping is a rules version the other way. Decisions survive either.

### W11. The principle and the plan

**Decision: the four rules stand as the process.** The nine-part plan is re-cut in section 2 with
the dependencies made explicit and a cut line. Two corrections to the walk-through's reading of
the constraints: no model provider is needed for this phase (W13), and the app-only env file is
therefore not on the launch path; and the review's decisions do not need to survive a staging
refresh for the launch, because production becomes a copy of staging (W17), but the decisions
model is built with natural keys anyway (W15), because every review after launch faces that
problem.

### W12. Age

**Decision: as rian answered.** Age is always a structured attribute wherever it is clearly stated
(certain), whatever the grouping; the grouping default the review proposes for aged spirits is one
product line per age; the default lives in the written review instructions (W13), not in chat. Where
the defaults live: **a file in the workspace, `main/docs/REVIEW-PROCESS.md`, that the Claude pass
reads and every later automated pass starts from**, with one section per vertical and one line
per grouping judgement (age: one line per age; finishes: a member of the parent line; limited
editions: proposed as members, confirmed per case; sets: never a member of the bottle's line). A
default that changes does not regroup what is approved; it changes what is proposed next, and a
person sees the difference on the sheet. Wine vintage follows the same default (one line per
vintage) until the sheet says otherwise for a house.

### W13. The AI pass in this phase

**Decision: a Claude session runs the pass on staging, through a sanctioned command, in batches per
brand, writing to the proposals store; no API, no key, no local model.** The command is
`app.cli proposals load --file <json>`: a proposals file carries the pass id (a name and the
version of `REVIEW-PROCESS.md` it followed), and each proposal its natural keys (brand slug;
listing as shop code plus SKU; variant by barcode or by its listings), the proposed grouping and
attributes, the text spans read, and a confidence. Loading is idempotent on (pass, natural key,
field). A batch is withdrawn as a unit by pass id. The later local-model phase is a separate
security-reviewed decision, as the walk-through says; nothing here presumes it.

### W14. Certain-only keys, and word lists as proposals

**Decision: option B for the key, option D for the lists.** The certain key is:

`brand (the brand row, aliases followed) | residual name | identity attributes | quantity`

where the residual name is the listed name with **only** these removed: the brand as matched to its
row (head match, or the exact brand words anywhere); the quantity statement the parser read as a
number; an ABV percentage; a pack figure ("3x1L"); a shop-published option value; and the closed
concentration vocabulary folded to its canonical word (EDT, Eau de Toilette, edt are one). Case,
accents, punctuation and glyphs fold. **Nothing else is deleted.** Every open list (drink words,
region words, format words, cask words, pack words, skin-type tails, brand trailers, stopwords)
becomes a proposal generator: its output is a suggested grouping or attribute on the sheet, with
"removed 'blended scotch whisky' as a category word" as its reason.

The balance for launch, as rian asked: certain-only minimises what can be wrong at launch with
the least review, because barcodes carry most comparisons (section 0) and the review is
prioritised by comparison gain. The boundary between certain and uncertain is itself kept in one
place (`REVIEW-PROCESS.md`, section "What a rule may do without review") and changing it is a
rules version, so it cannot silently become the next list. This is the first escalation
(section 5), because the exact boundary and its measured effect on launch comparisons are the
one place a wrong call is expensive in both directions.

### W15. One place for every decision

**Decision: option C, append-only decisions, extended with D's provenance and natural keys.** One
table, `decisions`, replacing `overrides` (zero rows exist, so this is a rename plus columns):

- one row per act: entity type, entity id, the entity's **natural key**, field, the decided value,
  the rule's value at the time (`rule_value`), who, when, why, **origin** (`person`, `proposal`),
  the **proposal id** and **pass id** when it came from one, the **batch id** it was approved in,
  and `mode` (`individual`, `bulk`);
- never updated or deleted: a later decision on the same field supersedes the earlier by being
  newer; undo is a decision with `value = rule_value` and `reason = undo of <id>`; batch undo is
  that for every decision in the batch, in one transaction;
- the effective value is the newest decision per field, materialised on the entity's own column
  by the writer, exactly as today, so the key path and the pages stay one lookup;
- pair decisions (Confirm same, Keep separate) and merges are decisions too, with the pair as the
  entity (`suggestion`) and the merge record beside it, so "every decision" is literally one place
  and the automatic merge reads Keep separate from the same table it reads everything else;
- attribute decisions use the field `attribute:<kind>`, so a new kind needs no code.

Rian's picture holds exactly: tracked changes, each line saying what, who, when, why and which pass
suggested it; undo adds a reversing line; one click reverses every line of a pass or a batch.

On W16's two strengths: **one strength, with `mode` recorded.** A later pass may propose against
any decision; a proposal is never applied silently either way, so the distinction changes nothing
in what a machine may do and only helps a person filter the sheet ("show me what I approved in bulk
that a newer pass disagrees with"). Two strengths would give a machine permission to treat bulk
decisions as weaker, which is the door the standing rule keeps shut.

**Overturns** 15 Sep §2.2 (current-state table, hard delete). **Reversal cost**: none now; after
the first review, a migration of decisions.

### W16. The AI does the work; the process is one a human could follow

**Decision: as rian framed it, with three mechanics.** The process is written down
(`REVIEW-PROCESS.md`) so it is followable and testable. The sheet marks what to spot-check: every
proposal that merges variants or changes a comparison, the lowest-confidence proposals per brand,
and a random sample; bulk approval of the rest is one act with a batch id. A bulk decision does not
expire; when the process file changes version, the next pass may re-propose against decisions made
under the old version, shown, never applied.

### W17. Pushing staging to production for the soft launch

**Decision: sound; build the push as its own scripted step, not `--seed-db`.** `deploy/production.sh
--replace-db <dump>`: dump production first (the rollback); deploy the code and stop the app;
drop and recreate the `public` schema; `pg_restore` with errors fatal; `alembic current` must equal
staging's head; revoke every session and every unused welcome or reset token; compare row counts
per table with the source dump; confirm source settings (kill switches, delays, permission
records) against a printed list; start; health check. Nothing collects on production between the
staging collection and the push. After launch the direction reverses for good and every review
faces the natural-key problem, which W15 has already solved.

### W18. Reachable, quality, indexed

**Decision: rian's three facts, plus `hidden` as a fourth.** Every brand, product line, airport and
place page exists from discovery; every generated page is `noindex, follow` by default (the flag
changes from `nofollow`: crawlers should still follow links to indexed pages; Mark to confirm); the
review merges pages and a confirmed alias fixes the final slug from the chosen name, old addresses
301 to it with chains flattened at write; candidate rules (the 9 Sep floor for brands and lines,
an equivalent for airports) produce **suggestions to index** on the dashboard, and a person
approves each; the sitemap and IndexNow carry only approved pages; falling below the rule is a
suggestion to de-index, never automatic; `hidden` (unreachable) stays for a page that is wrong
rather than thin. Category and category-at-airport pages follow the same rule; a human-written
article indexes on publish. A single-shop product line page shows its one price and its shop
(W20 case 1), which is helpful, not thin.

**Overturns** 15 Sep §2.8's `checked` (subsumed: an approved sheet is the quality fact; indexing is
its own decision) and the 9 Sep publication rule's reason (thin pages are kept out of search by
noindex, not by non-existence). Mark should see this; it reduces the risk he reviewed for.

### W19. Places and shops

**Decision: option C, the schema before launch, the pages after.** `places (id, slug, kind, name,
parent_id, identifiers JSONB, attributes JSONB)` with a kind registry in code (comparable or not,
address prefix, page template); `shop_places (shop_id, place_id, role)` with one `primary` per
shop; one airport place per IATA code backfilled, every shop pointed at its primary place; the
comparison unit defined once as "shops counted once per primary place" and every counting query
routed through it, with a test that fails on any count of shop rows; `/airports/` pages unchanged.

Against the stress tests: a ship is a place of a kind that moves (`moves: true` in the registry; its
port today is an attribute, not a parent); in-flight is a kind with no place at all; a pop-up
carries `valid_from`/`valid_to` attributes; a border crossing spans two countries as an attribute
and currency stays on the shop; identifiers are an open list `(scheme, value)` (IATA, UN/LOCODE,
none); a kind whose pages should not exist has `pages: false` in the registry; a kind whose pages
carry no comparison has `comparable: false`. Every case is a registry line or an attribute, not a
migration; the one column named for a known kind, `locations.iata`, is kept for the pass and
mirrored into `identifiers`.

The comparison unit: the primary place, not the retailer within a place (a mall's sixty shops
compare within the mall on the mall's page; across places they count once), which the registry's
`comparable` flag per kind handles. A shop needs a primary place unless its kind says otherwise
(the online catalogue).

### W20. What a product line page shows as a price

**Decision: rian's five cases, with the missing ones answered.** A price is shown only when it is
unambiguous. The rule, in order: if the shopper chose airports, show the variant's prices at those
that stock it, and if none stock it, say so and offer the airports that do (cases 4, 5); if the
shopper chose no airports and the variant exists at one place, show that price and shop (case 1
generalised); if at several, ask for an airport (case 3); if the line has several variants and none
is chosen, ask for a variant (case 2). A variant at one place overrides nothing: if the shopper
chose other airports, they are told it is elsewhere and where. Catalogue-only listings never show a
price on a page. A price always shows its date. The **representative variant** survives only on
cards, chosen by most comparison units then lowest id, never by recency. A crawler and a first-time
shopper see case 3 or 2 as a page that asks, plus JSON-LD listing every offer with its shop and
date, which states facts without choosing one; Mark's view is asked.

### The 15 Sep build on this branch: kept, changed, dropped

| Built 15 Sep | Under this plan |
|---|---|
| the decisions ledger (`overrides` extended) | **changed**: rebuilt as append-only `decisions` (W15) |
| identity rules v5: shade leaves the line on the ` / ` tail | **kept as a certain rule**, reframed as a shop-published option; better still passed as a field by the collector (W10) |
| v5: flavour reader on the marked tail | **kept** (same reason) |
| v5: pack words as expressions | **changed**: becomes a proposal generator, no longer applied (W14) |
| v5: `rederive` re-lines rows, tombstones included; `prune_lines` | **kept** |
| the marked tail kept out of the classifier | **kept**; the classifier's category becomes a proposed attribute with the shop's shelf as the certain input |
| the review field `checked` / `hidden` and its CLI | **changed**: `hidden` stays; `checked` is subsumed by the approved sheet; `indexed` is added (W18) |
| the labels (Brand, Alias of, Suggested match, Confirm ticked) | **kept**, then Confirm same / Keep separate |
| merges recording moved listings; data moves refusing under a collection; the refresh tripwire | **kept** |
| line decisions following products through a rekey | **kept**, and less needed once approved lines are decided data |
| `VARIATION_BEHAVIOR`, determinant / descriptor | **changed**: the registry's `display` setting, picked / shown / fact |

## 2. The plan, in dependency order

Assumes the launch moves to **Monday 21 Sep**; the Friday fallback is the cut line in section 3.
Streams are summarised in section 4. Every stream rehearses on `dfp-devdb` first, from a fresh
dump, and reports measured numbers, never predictions.

| Order | Work | Depends on | Stream |
|---|---|---|---|
| 0 | Bring the branch onto master (three discussion-panel commits); write `REVIEW-PROCESS.md` v1 (the certain boundary, the grouping defaults, the spot-check rule) | nothing | S0 |
| 1 | The schema: `decisions` (append-only, provenance, natural keys, batches); `proposals`; the attribute kind registry and the one accessor over columns and JSONB; `places` and `shop_places`; `suggestions` decision values; the `indexed` decision. One migration, additive except the `overrides` rebuild | 0 | S1 |
| 2 | Identity: the certain-only key; the collectors carrying shop-published options as fields; attribute vetoes including ABV; Keep separate honoured; the word lists turned into proposal generators; rederive and merges over it; the counting unit defined once | 1 | S2 |
| 3 | Review: `proposals load`, the sheet per brand (read; correct cells; approve a sheet, a line, a row; batch undo), the natural-key replay, the indexing suggestions list | 1 | S3 |
| 4 | The product line page: `/products/<line>[?variant&airports]`, the W20 cases, cards deep-linking, JSON-LD, redirects from old addresses, `noindex, follow` by default, sitemap only indexed | 1 (reads attributes through the accessor); 2 for correct grouping on staging | S4 |
| 5 | Publish: slug fixed at confirm, flat redirects, `hidden`, the airport candidate rule, de-index by suggestion | 1, 3 | S5 |
| 6 | Launch path: `--replace-db` with its checklist; the after-deploy chain from 0.35.0 in one pass | nothing until run | S6 |
| 7 | The Claude review pass on staging, per brand in value order, then rian's spot-check and bulk approvals | 2, 3 | rian and a session |
| 8 | The push, the go-live flip, the checklist's second pass | 4, 5, 6, 7 | rian |
| after Cannes | the consistency pass (renames), place pages for new kinds, the local-model phase | 8 | later streams |

**Value order for the review (step 7)**: brands by comparison gain, which is the number of
barcode-less variants a confirmed grouping would bring into a cross-airport comparison, then the
97 brands that hold 53 percent of listings, then the rest. A partial review is useful at every
point because every unreviewed page is honest and noindex.

**The launch path, as a checklist** (S6 writes it fully): staging collection fresh; the review
pass and approvals on staging; `main/check.sh` green; production dump taken; code deployed; schema
dropped and restored from staging's dump, errors fatal; `alembic current` equals staging's;
sessions and tokens revoked; row counts compared; source settings confirmed; app started; health
shows the version; the go-live flip; the checklist's second pass; sitemap lists only approved pages.

## 3. The cut line

**Above the line (launch cannot be honest without it):**
- S1 the schema, S2 the certain-only identity, S4 the product line page in its smallest honest
  form (the five W20 cases, the deep link, redirects, noindex; no design polish beyond the existing
  card and price-table components), S6 the push.
- Of S3, only the mechanics: `proposals load`, a plain sheet that shows a brand's proposals and lets
  a person approve a sheet, a line or a row and undo a batch. No correction of cells (a correction
  is a rejection plus a hand decision through the existing override route until the cell editor
  exists).
- Of S5, only what the page needs: `noindex, follow`, `hidden`, redirects; the indexing approval as
  a CLI (`app.cli index approve <page>`) rather than a dashboard, since everything is noindex at
  launch and Mark has not yet seen the page.

**Below the line (follows in the days after):** the cell editor and the spot-check marks on the
sheet; the indexing dashboard with the candidate suggestions; places pages for new kinds (the
schema is above the line only because it is additive and the counting fix rides on it; if S1 runs
short, places is the first thing cut from S1 and the counting fix is done alone); the airport
candidate rule; the consistency pass.

**If launch stays Friday 18 Sep:** the same line, with the review pass covering only the brands the
savings tables lead with (the top 97 by listings), and the rest launching on certain-only grouping,
noindex. The order that loses least if time runs out: S6 and S4 are never cut (without them there
is no launch); S3's mechanics before S3's UI; S2's proposal generators after S2's certain key;
places last.

## 4. Proposed overnight streams (summary level)

Full briefs follow `OVERNIGHT-RULES.md` and `KICKOFFS.md` after acceptance. Each rehearses on a
fresh copy of the newest staging dump on `dfp-devdb`, runs `main/check.sh` before hand-back, and
writes its measured numbers to its handoff.

**S0. Ground** (short; first). Rebase the branch onto master; resolve nothing by hand that a test
does not cover. Write `main/docs/REVIEW-PROCESS.md` v1 from W11, W12 and W14. Owns: the branch
state, that one doc. Touches no app code. Acceptance: check.sh green on the rebased branch; the doc
names the certain boundary and every grouping default the walk-through recorded. Rollback: none.

**S1. Schema** (first of the parallel wave; the others wait for its migration). Owns: `main/alembic/`
(one new revision), `app/models/` (decisions, proposals, places, shop_places, the attribute
registry module `app/services/attributes.py` with the accessor), `app/services/overrides.py`
(rewritten as `decisions.py`, the old name kept as a thin shim until the pass), `tests/` for these.
Must not touch: routers, the SPA, `lines.py`, `keying.py`. Rehearsal: `alembic upgrade head`,
`downgrade -1`, `upgrade head` on the copy; the backfills (`places` from IATA; the shim's
readers unchanged). Acceptance: every existing test green through the shim; new tests for
append-only, undo, batch undo, natural keys, the accessor over both storages, a place per airport,
the counting helper. Rollback: `downgrade -1`; zero decision rows exist.

**S2. Identity.** Owns: `lines.py`, `keying.py`, `normalize.py`, `merges.py`, `suggest.py`,
`cli.py`'s rederive and backfills, the collectors' option fields (`shopify.py`, `extime.py`,
`base.py`, `collected.py`), their tests. Must not touch: the schema, the SPA, `seo.py`.
Rehearsal on the copy: rederive under the certain key with `--check` first; report the count of
barcode-less variants that split, the comparisons lost and gained against the 15 Sep rehearsal
(this number decides whether the boundary in `REVIEW-PROCESS.md` is right; see escalation 1);
every word-list rule now writes proposals, none applies. Acceptance: no test asserts an applied
list deletion; the Keep separate veto and the ABV veto pinned; the shade collapse reproduced as a
shop-published option. Rollback: the rules version.

**S3. Review.** Owns: `app/services/proposals.py`, `review_sheet.py`, the `proposals` and
`decisions` CLI, `routers/review.py`, the SPA's `/review` page and its components, their tests.
Must not touch: identity, the schema (asks S1 for a column), the storefront. Rehearsal: a proposals
file for one large brand (CHANEL) written by hand from the copy, loaded, shown, approved, undone,
replayed by natural key onto a second copy with different ids. Acceptance: load is idempotent;
approval writes one batch; undo reverses it exactly; the replay lands every decision on the second
copy. Rollback: the page is behind the owner permission; the tables empty.

**S4. The product line page.** Owns: `routers/catalog.py`'s line routes, `catalog_queries.py`'s
line reads, `seo.py`'s line body, JSON-LD and sitemap, `urls.py`, the SPA's `ProductPage` (as the
line page), `ProductCard`, `PriceTable`, search suggestions, their tests. Must not touch: identity,
the schema, the review. Rehearsal: the local API on the copy after S2's rederive; the five W20
cases each proven by a URL; a card's deep link opening the same comparison; an old variant address
answering 301. Acceptance: `test_site_routes`, `test_seo_*` and the route inventory green; a
crawler-side fetch of a line page carries `noindex, follow` and the offers in JSON-LD. Rollback:
the old routes stay on the branch until Cannes and re-enable by a flag.

**S5. Publish.** Owns: the slug-at-confirm logic in `merges.py` (shared with S2 by an agreed
function signature), the redirect table and its flattening, `robots_policy.py`, `indexnow.py`,
the `index` CLI, their tests. Must not touch: the page's body, identity. Rehearsal: confirm an
alias on the copy; the old slug 301s to the final one; a chain of two flattens; the sitemap lists
only approved pages. Rollback: redirects are additive.

**S6. Launch path.** Owns: `deploy/production.sh` (`--replace-db`), `RUNBOOK.md`'s launch
checklist, the consolidated after-deploy chain from 0.35.0. Must not touch: the app. Rehearsal:
the replace run against `dfp-devdb` from a staging dump into a database seeded with production's
last dump, errors fatal, row counts compared, sessions revoked. Acceptance: the rehearsal log.
Rollback: the production dump the script takes first.

Parallelism: S0, then S1 alone, then S2, S3, S4, S5, S6 together (S4 and S5 share one function
signature agreed in S1's brief; S2 and S5 share the confirm path, so S5's slug work lands as a
call S2 makes). Nothing runs while a collection runs.

## 5. Escalations: where a deeper think is worth its cost

1. **The certain/uncertain boundary of the identity key, and its measured effect at launch (W14,
   S2).** The risk: too narrow and barcode-less variants stop comparing across shops at launch
   (comparisons lost); too wide and a silent wrong merge survives into the review. The exact list
   of what a rule may remove from a name, the treatment of brand abbreviations ("Joh."), the
   concentration vocabulary's edges, and the count of comparisons gained or lost against the 15 Sep
   rehearsal deserve a multi-agent pass: several boundaries proposed, each rehearsed on the copy,
   judged on lost comparisons against wrong merges. This is the one place a mistake is expensive in
   both directions and the first thing every later category inherits.
2. **The decisions and proposals model (W15, S1).** The foundation every review writes into;
   getting the natural keys, the batch semantics or the append-only reads wrong means migrating
   decisions after the first review, the one migration this project cannot rehearse away. Worth an
   adversarial pass on the schema before S1 builds it: does every decision replay onto a database
   with different ids; does undo of a merge restore the listings exactly; does the newest-wins read
   stay one lookup under thousands of rows; does a pair decision fit the same row.
3. **The product line page's contract (W1, W20, S4)**, only if Mark cannot answer in time: the
   query-string variant with a canonical, the airports parameter, what a crawler sees on a page
   that asks, and `noindex, follow` are public and SEO-facing; a wrong shape is corrected after
   indexing at real cost. If Mark answers before S4 starts, no escalation is needed.

Not worth an escalation: the attribute storage choice (JSONB with a registry; reversible by one
registry line per kind), places (additive), the push script (rehearsable), the words (settled by
the rule that there is one).

## 6. Rian's three questions, answered directly

**Categories ahead, and problems not visible yet.** Watches, jewellery, sunglasses, electronics,
fashion, travel goods, tobacco and food each break one assumption the model makes today, and the
plan removes each assumption rather than adding a case: a category with no quantity (W7's
`not_applicable`); "size" as a label (an attribute kind `size_label`, not a quantity); units beyond
ml, g and pcs (the registry's value type carries the unit: mm, carat, GB); a variant with no option
at all (the certain key is brand plus residual name; a watch model is one variant); a product line
that is a model number (the residual name is the model number; the sheet's grouping default for
electronics is "one line per model family", a line in `REVIEW-PROCESS.md`); tobacco cartons (a
count with a pack form, already in the quantity model, plus the certain reading of "200" as
20 x 10 only when stated); food's flavour as a picked attribute where the shop marks it and a
proposal where it does not; and, underneath all of them, **the vertical as the unit a rule set is
registered under**, with a neutral default for a vertical nobody wrote rules for and `unknown`
instead of "liquor" for a product nothing placed. The classifier is the last hand-kept list
standing (category by regex over the name); under this plan a category is an attribute proposed
from the shop's shelf (certain) and the name (uncertain), decided on the sheet like everything
else. The RingConn ring is the test case for all of it and is answered in W7.

**Comparable cards open the product line page.** Decided in W1: `?variant=<id>&airports=...`,
server-rendered, canonical to the bare line, the same comparison the card promised. Part of S4.

**Where the review needs more depth.** Section 5.

## 7. What I would say to rian in one paragraph

The right refactor is one change of principle, not twenty fixes: rules record and extract only
what is certain; everything interpretive is a proposal with its evidence; a person or a process
decides, in one append-only place with undo; pages exist on discovery and index by approval. Build
the schema first, then identity, review, the page, publish and the push in parallel, launch on
Monday with certain-only grouping where the review has not reached, and let the review improve the
site brand by brand in value order. Escalate two things before building, the key boundary and the
decisions model, and ask Mark about the page.

---

## 8. Rian's response to this plan (16 Sep)

Recorded as he gave it; his answers to the four disagreements in section 1 follow when he gives them.

- **Mark has agreed, strongly, with the product line page shape.** Escalation 3 is therefore not
  needed.
- **Launch moves to Monday 21 Sep; Adam has agreed.** The cut line's Monday assumption holds.
- **The full refactor now, built overnight, rather than waiting.** Rian: *"I prefer to do the full
  refactor now with an overnight build than wait."* This overrules the W2 timing: the renames of
  existing tables and columns (`products`, `locations`, `size_*`, `line_id`, `canonical_id`,
  `merge_candidates`) move above the cut line, at the stated cost of one overnight stream and a full
  regression. Because a rename touches every file the other streams own, it must run on its own before
  the parallel wave, or the streams would conflict on every file.

- **Rian's question on W9, 16 Sep:** *"for the 40% vs 43% in the case of a typo, can I still correct the
  incorrect one or am I stuck with a split that I can't fix?"* Checked against today's code, and to be
  kept true by S1 and S2:
  1. **The veto binds machines, never a person.** A person's Confirm same on a variant pair is refused
     today only when one side is gone or already merged; differing barcodes require a note saying which
     wins; there is no attribute-conflict refusal (`merge_session.confirm`). The new veto must stay on
     the automatic merge only.
  2. **The person then sets the correct value as a decision** (ABV 40 on the surviving variant). The
     listing keeps the shop's own "43%" in its listed words, which is the record of what the shop showed.
  3. **The fix survives the next collection.** Ingest follows a human merge's forwarding row at the
     same key (`ingest._follow_tombstone`), so the shop's next "43%" sighting lands on the survivor. Under
     the certain key the differing ABV is part of that key, so S2 must pin this with a test: a person's
     merge of two variants whose keys differ by a certain attribute still captures the next sighting of
     either key.
  4. **With a shared barcode there is no split at all**: both listings are already one variant, and the
     person only sets the value.

- **Rian's answers to the four disagreements, 16 Sep: all four accepted.** W9: no per-product-line
  exceptions; a stated difference vetoes the automatic merge in every category (a person may still
  Confirm same, per the note above). W16: one strength of decision, with the mode recorded. W2: the
  timing overruled as above (the renames run now, as their own stream before the wave). W10: the
  Attenza shade ships as a shop-published option; the pack-word and flavour rules become proposal
  generators.
- **Escalations.** Escalation 2 (the decisions and proposals model) runs as a deeper multi-agent
  think before the schema stream, and its specification is the schema stream's brief. Escalation 1
  is not a separate think: the identity stream rehearses two or three versions of the certain
  boundary on the staging copy, reports comparisons lost against wrong merges avoided, and picks
  from the numbers. Escalation 3 is not needed (Mark agreed).
- **The cell editor stays below the line.** The first review is a Claude session writing a proposals
  file per brand; corrections in that pass happen in the file before it is loaded, and rian's
  spot-check needs approve and reject per row, not editing. If the first sheets show many values
  worth correcting by hand, the editor moves up; it is the first task after launch otherwise.

## 9. The accepted plan: final order and the streams as named

Stream keys `K0` to `K7` (the letter K was free on `/plan`); one brief each under
`.logs/planning/streams/`, one command each under `.claude/commands/`. Three sessions at once is
the ceiling (build plan Decision 13).

| Order | Stream | Brief | Runs | Depends on |
|---|---|---|---|---|
| 1 | **K0 Ground** | `K0-ground.md` | Wed 16 Sep, short, alone | nothing |
| 2 | **K1 Names** (the consistency pass: every rename, one migration, all files) | `K1-names.md` | Wed 16 night, alone | K0 |
| 3 | **K2 Schema** (decisions, proposals, attributes, places; from the escalation's spec) | `K2-schema.md` | Thu 17 day, alone | K1 |
| 4 | **K3 Identity**, **K4 Review**, **K5 Line page** | `K3-identity.md`, `K4-review.md`, `K5-line-page.md` | Thu 17 night to Fri 18, three at once | K2 |
| 5 | **K6 Publish**, **K7 Launch path** | `K6-publish.md`, `K7-launch.md` | Fri 18 to Sat 19 | K3 and K4 (K6); K1 (K7) |
| 6 | The review pass on staging (a Claude session, per brand in value order), rian's spot-check and bulk approvals | `main/docs/REVIEW-PROCESS.md` | Sat 19 to Sun 20 | K3, K4 |
| 7 | The push and the go-live flip | `K7-launch.md`'s checklist | Sun 20 night or Mon 21 | everything |

The 15 Sep build on this branch is the base every stream starts from; the table in section 1
("kept, changed, dropped") says what each stream keeps.

## 10. Escalation 2 delivered (16 Sep, evening)

The decisions and proposals model was thought through by a multi-agent pass (three designs from
three angles, each attacked through two lenses, one synthesis) and written as
`.logs/planning/decisions-model-spec-2026-09-16.md`. It is K2's design; `K2-schema.md` is the work
order on it and takes eight decisions on the questions the synthesis left open (newest wins by
`(decided_at, id)`; undo restores the previous decision; quantity as `attribute:quantity`; one
transaction per brand; arrival proposals are K3's; the `decided_member` veto ships; a `redirects`
table replaces `line_slugs`; `place` joins the ledger's entity types). Each is rian's to overturn on
the running list. K4's brief is the load, sheet, approval and undo half of the same spec, with the
cell editor below the line as §8 records. Every stream now has a brief, a kickoff command and its
tasks on `/plan`.
