---
type: plan
version: 1
status: DECIDED 2026-09-15 by the deep-reasoning session rian asked for, from `catalogue-model-brief-2026-09-15.md`, then put through an adversarial review (five critics, one lens each; every objection at medium or high confidence checked by a skeptic) and amended where an objection held; section 8 lists what the review changed. Rian asked that decisions be made for him and acted on; every decision here is his to overturn, and section 5 says what was built and what a reversal costs. Sections 1 to 3 are the decisions; section 4 is the sequencing.
supersedes: the brief's open questions (its evidence stands); the two companion docs (`places-and-shops-2026-09-15.md`, `variations-and-line-pages-2026-09-15.md`) stay as the evidence behind sections 2.7 and 2.11
---
# The catalogue model: the decisions

> **Revised 16 Sep by rian, walking the decisions one at a time; the issues behind every revision, with the options and reasoning, are in `catalogue-walkthrough-2026-09-16.md`. A reviewing session should read that first and challenge the outcomes, not implement them blindly.** Section 2.7 is no longer a
> recommendation: rian decided the line page is what `/products/` shows, before launch, and told
> Mark he is proceeding unless Mark says no. **Section 1's words are replaced**: the searchable
> thing is a **product**, the barcoded thing a **variation**, the value between them an **option**
> (`main/docs/VOCABULARY.md` is current; this document keeps the 15 Sep words where it was written
> in them, so read "line" as product, "product" as variation and "variation" as option).

## 0. How to read this

Rian asked for three things in this order: a model he can understand, a model that grows past
airports and past drinks and beauty, and a model a person can check, correct and merge at scale.
The brief added a fourth the evidence forced: everything must stay re-derivable from what was
collected, because the rules will keep changing.

Every decision below is written against those four. Where two of them pull apart, the text says
which won and why. **The single most useful thing to take from this document is section 1**, the
words. Most of the confusion in the brief was two things sharing a word or one thing having two.

## 1. The words

Fourteen nouns, each with a one-line test. The test is the definition; when a sentence fails the
test, the sentence is using the wrong word. The full glossary with examples is
`main/docs/VOCABULARY.md`; this is the short form.

| Word | Test | Example | Table today |
|---|---|---|---|
| **brand** | the name on the front of the pack, as a shopper says it | Rabanne, Chanel, Toblerone; Coke, not Coca-Cola | `brands` (one row per spelling family; an **alias** row points at the brand it stands for) |
| **line** | the name with the brand, the quantity and the variation words taken off; what every product in it shares; what you would type into a search box | 1 Million (over its EDT, Elixir and Parfum at every size), Rouge Allure, Macallan 18, Coke Zero | `product_lines` |
| **variation** | a word the readers take out of a name to tell one member of a line from its sibling; a word the rules leave in the line is never a variation | Elixir (concentration), 99 Pirate (color), Milk (flavor, from "Lindor Truffles 200 g / Milk") | `products.attributes.variation` with its **kind** |
| **quantity** | how much is in the pack, with its unit; "size" is the spoken word for a millilitre quantity | 100 ml, 3.5 g, 6 pcs | `products.quantity_*` |
| **product** | one line at one variation and one quantity; what a barcode names; always written with its quantity | 1 Million Elixir 100 ml | `products` |
| **listing** | one product at one shop | the 1 Million Elixir 100 ml at Heathrow's World Duty Free | `listings` |
| **price** | what one listing showed at one moment | 130 USD on 12 Sep | `price_observations` |
| **shop** | one storefront we collect, with its own catalogue, prices and currency | World Duty Free Heathrow; the Heinemann online catalogue | `locations` (renamed in the wave, section 2.12) |
| **place** | where a shop is, of any kind | Heathrow airport; Nassau cruise port; the Puttgarden border shop | new, section 2.11 |
| **retailer** | the company that runs shops | Avolta; Gebr. Heinemann | `retailers` |
| **vertical** | the family a rule set is written for; a product's vertical is its category's family, else the tree the collector walked | liquor, beauty, confectionery, tobacco | `products.vertical` |
| **source** | one website we read, with its own kill switch, crawl delay and identity mode; today one shop each | Iceland Duty Free; the Heinemann online catalogue | `sources` |
| **collector** | the code that reads one platform's websites; never a row | the Shopify collector (three Attenza shops); the Heinemann platform collector (three sources) | `collectors/<module>.py` |

**Product versus line, settled.** Rian says "product" for both (his own words: *"product
lines: these are what I tend to be referring to as 'products'"*), and he will keep doing so. The
`Product` model, the tests on it, the public address `/products/<name>-<id>`, Mark's structure
review and schema.org say "product" and mean the barcoded thing; the places that *defined* a
line called it "the real product", which is the muddle, and that phrase is retired now. The word
keeps its meaning: **a product is what a barcode names, written with its quantity; a line is
what a shopper searches for.** Two reasons, in order: the public address hardens the day it is
indexed, and the obvious replacement for the barcoded thing, "variant", sits one letter from
"variation", a word rian approved on 14 Sep and uses constantly. The burden is not on rian: a
session reads his sentence by the two tests, never by the word ("if you cannot say the quantity,
you mean the line"), the review area labels the line "Line" everywhere, and shoppers and the
client will call the line page "the product page" because it answers under `/products/`; in
labels, docs and sessions it is **the line page**. "Line" is short for his own "product line".

**The three layers stay exactly as rian approved them on 14 Sep:** **listed** (the shop's words),
**standard** (what the rules made of them), **decided** (what a person set). They apply to every
value at every level, and section 2.2 makes that literally true.

**"House" is retired, and so is "standard brand".** The brand row reached by following the
alias chain is simply **the brand** (the row the brand page is served from); the row that points
at it is **an alias of** it. The brief recommended "standard brand", and the review refuted it on
the 14 Sep vocabulary's own terms: an alias is a person's decision, so the row it reaches is a
decided outcome, and "standard" is the layer the rules make. "Standard" stays a layer word only.
"Parent brand" and "owner" are reserved for corporate ownership (Puig, Coty, Mondelez, a
distillery), a different fact we do not record; a row named for a maker is not a brand to alias,
it is products whose brand needs re-reading. **"Location"**, rian's word for a place, is retired
in prose too: say shop or place; the `locations` table holds shops until the wave. The words are
gone from every label, message and doc a person reads; the code identifiers (`house=` parameters,
`Maps.house()`) follow in the rename wave (section 2.12) as `brand_of()`, because a session
translating one word is cheap and a half-renamed codebase is not.

**Two operations, two words, one act.** The person's words are the same at every level: the
desk offers a **suggested match**, a person **confirms** or **keeps apart**. What the record
says depends on the level: an **alias** points one row at the row it stands for and both survive
(a brand alias, a line alias, a variation-wording alias); a **merge** folds two product rows into
one and the loser becomes a forwarding row; only products merge. The interface never says
"merge" for an alias and never says "approve" for a confirm (the desk's button said Approve; it
says Confirm now, so a review state could not collide with it).

**Two behaviours of a variation: picked or shown.** Every variation a reader produces has a
**kind** (concentration, color, flavor) and every such kind has a behaviour: **picked** (the
shopper selects it and the comparison regenerates; quantity, concentration) or **shown**
(information, never selected; shade). The identifiers are `determinant` and `descriptor`
(`lines.VARIATION_BEHAVIOR`), rian's word for the first kept in code; in every sentence a person
reads the words are picked and shown. The question is asked only of a word that is already a
variation; it never decides whether a word is a variation at all (that is the line rule: what no
reader lifts is the line). Section 2.4 has the rule for deciding which is which.

## 2. The decisions

Each carries what, why, what it costs, and when. "Now" means built by this session on staging;
"launch" is 18 Sep; "Cannes" is 27 Sep.

### 2.1 Vocabulary (P1 and question 7)

Decided as section 1. Built now: the glossary (`main/docs/VOCABULARY.md`), one rule in
`agents.md`, every label and message in the review area, the prose in the tier-2 docs. Not built:
code identifiers, which change in the rename wave. Cost of reversal: labels and prose only.

### 2.2 One decided mechanism (P6, question 5)

**Every human decision over a value is a row in `overrides`, whatever it is about.** Brands,
lines and variation wordings follow the pattern products already use: the decision is recorded in
`overrides` (entity type, key, field, the decided value, the value the rules had, who, when, why)
and the entity's own column carries the effective value for fast reads. Listing pins and ignores
follow the same pattern. A backfill or a rule may rewrite any standard column that has no override
row guarding it, and must never touch one that has. Two kinds of decision are not values and keep
their own record: a decision about a **pair** (confirm, keep apart) lives on the suggestion row
(`merge_candidates.decision`, `decided_by`, the note), and a **product merge** is a
`product_merges` row with both sides as they were. Three records; one audit query joins them.
`overrides` holds the current decision per field (a second decision replaces the first; a clear
deletes the row); the history of changes is `audit_log`, which every decision route writes.

Why: the layer is built twice and used zero times (0 overrides, 0 aliases, 0 pins, 0 ignores on
15 Sep). One mechanism is one thing for rian to understand and it keeps what the rule said
(`collected_value`) so a later rule change can be judged against what people overrode. The
brief's worry, a join on every page read, does not arise: the effective value stays on the row;
`overrides` is the ledger, read by backfills, the desk and the audit, not by the storefront.

A decision on a line is keyed by the line row's id, and a line row is derived state (section
2.3). So `backfill lines` moves a line's decisions to the row its products went to when a rule
change empties the row and every live product landed on one new row, and reports the ones it had
to leave because the products scattered; the prune never deletes a row that still carries one.

What changes in behaviour: today the standard layer for brand and line *names* is frozen at
creation (the backfills never touch an existing row, because they cannot tell a human name from
a rule's). With the ledger they can: a name with no override row is the rules' to improve. That
recompute is not switched on in this session (it would rename thousands of staging lines the day
before a launch), but the guard is in place and the backfills say so.

Cost now: near zero. Cost after rian's first desk session: a backfill to reconstruct the ledger
from `decided_by` columns, and every decision taken before it without a `collected_value`. That
is why it is built before he sits down at the desk. The `decided_by` / `decided_at` columns on
`brands`, `product_lines` and `variation_aliases` keep being written for one release and are
dropped in the rename wave.

### 2.3 Shades leave the line key; identity rules v5 (P4, question 1)

> **Questioned 16 Sep by rian:** rules per shop format may treat the symptom; he proposes an AI-proposed, human-approved review sheet per product line instead. Issue, evidence and options: walk-through W10.

**A descriptor never belongs in the line key.** The line is the name minus the brand, the
measures, the form words and every variation the readers found. Concentration already left the
beauty line key; the shade now leaves the Makeup line key the same way, and the flavour a
confectionery shop marks leaves the confectionery line key.

The rule reads only what the shop marked. Today that is the `<size> / <shade>` shape one Shopify
platform publishes as a second product option (783 live beauty rows, every one of them from that
platform's three shops). **The rule is the vertical's, never the category's**: it fires on any
beauty name with a marked tail, a skin-type tail excepted (every Skincare tail on the copy was a
Spanish skin type: grasa, seca, mixta, todo tipo de piel), and the category names only the kind
(Makeup: color; elsewhere the kind is unknown until something names it). The first cut gated it
on the category Makeup and the review caught what that missed: 384 of the tailed rows have no
category at all, CHANEL Rouge Allure among them, and a classifier's choice must never move a
shade between the line and the variation slot. Extime writes ` - 447 Mellow Shade` and Avolta
writes a bare `01` before the size; neither is read yet, because a rule that guesses a shade from a
number would also read "N°5" and "Brush 13" as shades (the reader's own docstring records that it
once did). Those shapes are added when their fragments exist to test against (Extime's makeup
has no raw records until the recrawl). **Empty beats guessed** applies to the shade as to
everything else. The durable form of this rule, for the next platform, is to carry the shop's
variant field structurally (the Shopify option, Extime's label) into the collector's output and
strip *that* from the name, instead of reading back a separator the collector itself wrote; that
is the first collector change after launch and it needs no rule change here.

What happens to the data: products do not fold (the shade stays in the key's variation slot), the
line rows they hang from change, and the old rows are left with nothing on them. A line row is
derived state, a pure function of the name; a derived row nothing references is pruned by the
backfill that derives it (`backfill prune_lines`, which refuses to touch a row that has a product,
a tombstone, an alias pointing at it, a suggestion, a decision, or a name a person set). That is
not a deletion of data in the sense the constitution forbids; a merge tombstone is data, an empty
rule-made line row is a cache.

`rederive` re-derives every row's **line** as well as its key, tombstones included, through the
same call ingest uses, so the after-deploy order is a convenience for the display names and never
load-bearing for identity: a product keyed under v5 always hangs from the v5 line, and a
confirmed merge's tombstone stays findable after any later rule change. The data moves (`lines`,
`prune_lines`, `rederive`, `merges` and the rest) refuse while a collection is running. The line
rules are two rule sets today, beauty and liquor, and every vertical without its own rides the
liquor set (drinks and region words stripped, ages folded; confectionery does so now); a
per-vertical rule table with a neutral default lands with the first non-drink shop (section 6).
Rehearsed on a copy of staging before it touched staging; the numbers are in section 5. The
identity rules version becomes 5.

Cost: a rule change, a rederive, a prune. Nothing public reads the line layer (measured: no
shopper page, API schema, sitemap or JSON-LD touches `product_lines`), so this is invisible at
launch.

### 2.4 Variation kinds carry a behaviour (P3, questions 8 and 9)

> **Widened 16 Sep by rian: scalability is a requirement now, not later.** The model as built allows
> one option besides size and keeps properties as fixed columns (abv, country_of_origin). Rian: *"I
> dont like that we're setting ourselves up for limitations in the future like clothing... that was
> one of my big reasons for doing this now."* The direction to decide: one open list of (kind,
> value) attributes per variant, a per-vertical registry marking each kind as an **option** (tells
> siblings apart; identity; picked or shown) or a **property** (a fact; never identity), stored in
> the existing `attributes` column. Running list:
> `issue-a-product-variant-can-carry-only-one-option-besides-its` (now a decide) and the ABV item.

**Per kind, in code, with category exceptions allowed, and only for a kind a reader produces.**
`lines.VARIATION_BEHAVIOR` maps concentration, color and flavor to `determinant` or `descriptor`;
a `(vertical, category, kind)` entry overrides the kind's default when evidence says a kind
behaves differently somewhere (a flavour or a colour may well be picked in a category food or
eyewear brings; the demotion of shade to shown is the one the data supports today). Not a table:
it is a rule, it changes with a deploy, and rian's own read is right that *"it's more of a
presentation thing."* The behaviour question is asked only of a word that is already a
variation; whether a word is a variation at all is the line rule's (section 2.3).

The test for which side a kind falls on is neither the price test alone (it misfiled
concentration) nor the search test alone (a judgement per word). It is both: **a kind is a
determinant if changing it changes the price or changes what a shopper would search for.**
Quantity and concentration are determinants on both counts. Shade is a descriptor on both. Flavour
of a confectionery line is a descriptor where a shop marks it as one; flavour that is part of a
one-off product's name is not a variation at all (section 2.6).

**Age and vintage stay in the line.** Rian filed them as determinants, and they pass both tests
(a 12 and an 18 differ in price and are what people search). They are line words all the same,
because no reader lifts them: a drink strips nothing of its expression (the catalogue's own
stopword list once deleted exactly those words and put a medal on the wrong bottle), so "Macallan
18" is a line and the determinant question never arises. The catalogue holds 579 drinks with an
age statement in 207 numbered families; a reader that lifted the age would be the largest rederive
yet, three days before launch, for a page that does not exist, and a page titled "Macallan
Sherry Oak" with an age selector ranks for none of those searches. Recorded as reversible and
revisited with search data after launch.

### 2.5 Pack words in drinks names (question 13)

**"Triple", "tri" and "twin" leave a line only when the name is a pack or a set, in every
vertical.** "Triple" in "1800 Añejo Triple Cask", "Grant's Triple Wood", "Jameson Triple Triple"
and Lancôme's "Rénergie H.C.F. Triple Serum" is the expression; "Triple" in "Chivas Regal 12
Triple Pack 3x1L" is packaging. The quantity parser already tells them apart (`form` is `single`
for the first four and `pack` for the last); the line rule now asks it, and a pack word followed
by a drinks category word ("triple malt") still describes the category. "Pack", "duo", "trio" and
"kit" stay format words everywhere: a name that carries one and parses as a single item is a pack
the parser missed ("Sol 12 Bottle Pack"), not an expression. Measured: 34 live drinks and seven
beauty rows carry "triple" or "tri" as a single item, and every one sampled is an expression. The
first cut kept every pack word and only in liquor; the review narrowed it to the three words the
measurement covered and widened it to every vertical.

Fixed by a rule, not deferred to a model (section 2.9): it is a two-line rule with a clear
signal, and the test that pins it is written as the real rows that were wrong.

### 2.6 The flavour reader (question 13, the confectionery issue)

**The confectionery flavour reader fires only on a marked variant tail**, the same ` / ` shape
the shade reader uses, never on a flavour word anywhere in the name. "15 Fine Chocolates Almond
Crispy" is a product whose name has a nut in it; "Lindor Truffles / Milk" is a line with a
flavour. Of 158 products the old reader tagged, one carried the marked shape, and that one is a
Clarins lipstick the taxonomy filed under confectionery because its shade is called "praline
nude" (filed as a taxonomy issue). The 157 keep their flavour words in the line, where they were
anyway, and lose a variation that never grouped anything.

### 2.7 The line page (P2, questions 2 to 4)

**DECIDED by rian, 16 Sep: `/products/` shows the line, not the product, and it ships BEFORE the
18 Sep launch. There are no product pages at launch.** Proceeding unless Mark says no. Corrected
the same day: the first revision recorded "built after launch", which rian refuted: *"When I launch
I dont want product pages as they are now, I want product line pages."* Rian, on why now rather than later: *"one of the major ones I wanted to decide on now
because the cost of changing is growing especially if we start indexing product pages."* He first
raised it on 11 Sep from the 1 Million rows (`decide-one-page-per-fragrance-line-with-its-sizes-and-variants`),
and the 16 Sep walk-through added the Johnnie Walker evidence below. The 15 Sep draft treated it
as a recommendation because the 9 Sep address decision was agreed with Mark and Adam; rian has
now put it to Mark directly with a default of proceeding.

**Why the timing matters, measured 16 Sep.** Nothing is indexed: staging answers an anonymous
`/products` with a redirect to the sign-in page and `/sitemap.xml` with 404, and the go-live flip to
public is still an open item. The cost of this change jumps at that flip, when individual product
addresses start being crawled. **Because nothing is indexed, no product address needs preserving
for search engines**: the old `/products/<name>-<id>` shape only needs to land on its line for
internal links and bookmarks, which simplifies point 2 below.

**What building it before launch depends on, stated once (16 Sep), rian having decided to proceed:**
the v5 line collapse (section 2.3) must be deployed first, or the pages show CHANEL Rouge Allure as
59 lines; the representative-product rule (point 4) is decided but unbuilt; the product page's
server-rendered body, JSON-LD, sitemap entry, search suggestions, cards and savings links all
address products today (22 files name the product address); production must take the whole
migration chain on launch day; and Mark has not yet answered.

**The Johnnie Walker case, from the search box on 16 Sep.** One search for "johnnie walker blue"
returns products from at least six lines, and shows both reasons the line page needs a person's
help as well as a rule:

| Listed name | Line today | Price seen | What it really is |
|---|---|---|---|
| Blue Label Blended Scotch Whisky 1L / 750 ml / 200ml | Blue Label | $60 to $395 | one line, three products |
| Johnnie Walker Blue 1L (no barcode) | Blue | $287 | almost certainly Blue Label 1L, "Label" dropped |
| Joh. Walker Blue Umami 43% 1L | Joh Blue Umami | $426 to $488 | Blue Label Elusive Umami; "Joh." not read as the brand |
| Joh. Walker Blue Xord 40% 1L | Joh Blue Xord | $409 to $453 | related to Xordinaire; a different barcode, so a person decides |
| Blue Label Xordinaire, Elusive Umami, The Casks Edition, King George V | their own lines | $240 to $562 | different whiskies carrying the Blue Label name: separate lines |
| Joh.Walker BlueCNYLunar40%1LGP | its own line | $491 | a limited-edition bottle; possibly Blue Label in collector packaging; a judgement call |

Spelling splits (the second to fourth rows) are what the desk's confirm is for. The last row is the
genuinely hard class: whether a special edition is a new line or a member of its parent line
cannot be read from a listing, which is why an AI-assisted proposal for it is filed (section 2.9,
and the running list).

The shape, as decided:

1. **The line page becomes the primary public page.** One page for 1 Million carrying every
   concentration and quantity with the cross-airport comparison for whichever the shopper picks.
   The case is search intent, not page count: nobody searches "1 Million EDT 100ml duty free".
   On the corrected data the page count also falls (section 5 has the measured collapse), but
   that is a consequence, not the argument.
2. **Product pages stay, canonicalised to their line.** `/products/<name>-<id>` keeps answering
   (indexed addresses are never broken), each carrying `rel=canonical` to its line page and, once
   the line page is live, a 301 with the product preselected. The line page's address is
   `/products/<line-slug>`, resolved exact-slug first, trailing-id second, so the two shapes share
   one prefix without ambiguity. **Rian's form for one product is `/products/<line-slug>?product=<id>`**
   (16 Sep). The 15 Sep draft proposed a fragment (`#product=<id>`) instead; the difference is
   small but real and is settled at build: a query-string address can be linked and shared as that
   product, and it must carry `rel=canonical` to the bare line address or every product becomes a
   separately indexed duplicate of its line; a fragment is invisible to crawlers and needs no
   canonical, but a server-rendered body cannot preselect from it.
3. **Determinants are selectable; descriptors are listed.** Rian's insight carried whole: a shade
   picker would imply knowing what a shop has in stock today, which we never do.
4. **A comparison is always between exact products.** A line card names one product, the
   **representative**: among the line's publishable products, the one carried at the most
   comparison units (today distinct airport shops with the catalogue excluded, the same count the
   comparable rule uses; after 2.11, distinct primary places), ties broken by the lowest product
   id and by nothing that moves between collections (no recency term: the newest observation is
   feed position at sub-second granularity). Computed at read from the same counts every other
   reader uses, so it changes only when coverage does; a person may decide it (`representative`
   in the ledger, added to the fields when the page is built). The card says which product it is
   priced as ("priced as 99 Pirate 3.5 g"). The first line-page build tests it against a
   publishable tied line (the Rabanne 1 Million EDT and Elixir at 100 ml, tied at four airports),
   not only the 18-shade line, which does not publish. Where a shop lacks the representative, its cell is empty, never
   substituted. A shade range is shown as information ("18 shades seen at Panama, two prices"),
   never as the card's price. "From $34" is refused outright; it is the multi-size-tile failure
   in reverse, as the brief said.
5. **The publication rule applies per product, and a line page publishes when one of its
   products does.** A line with one comparable product and nine single-shop products is a
   publishable page whose comparison table has one row and whose "also seen" list has nine; that
   is honest and useful.

### 2.8 The human publish gate (rian's new ask)

**Brand pages and line pages carry a review state a person sets; product pages stay
mechanical.** Two states named by their effect, recorded in the decided layer (`overrides`,
entity `brand` or `line`, field `review`): **`hidden`** (a person took an eligible page off the
site) and **`checked`** (a person looked: one brand or one line, a clean name, members that
belong), or no row. Mechanical eligibility (the brand floor; for a line, one publishable product)
stays the first gate; `hidden` is applied inside the one definition of "this brand has a page"
(`_house_counts`), so the index, the page, the sitemap and the link on every product agree.
`checked` is a stamp: it shows on the desk and counts towards coverage. Live = eligible and not
hidden; nothing at launch depends on a person having checked anything. The first cut named the
states `approved` and `withheld`; the review showed that the desk's own confirm button said
Approve, so one word would have meant "these two are one" and "this page may publish" on the
same row.

Three consequences the review made explicit. A stamp is a judgement about a row's membership
when it was made: a stamp on a line row with no live product, or on a row that has since become
an alias, is void and counts towards nothing (the desk surfaces it, `review_orphaned`); when a
rule change moves a line's products to one new row, its decisions move with them (section 2.2).
A stamp goes stale: every collection adds members, so the stamp records the member count at the
time and the desk shows "changed since checked" when it differs, with a `reverifications` row
(`members_changed`) for the checker; that is the desk stream's work. And an opt-in mode, if rian
ever wants one, is enforced in the same one definition as `hidden`, is switched on only when the
desk's coverage reads every eligible brand reviewed (so no page that was ever indexed dies at the
flip), and answers a refused address with a redirect to the brand's products rather than a 404,
the fallback every card already has. The desk's review control is a per-row Check / Hide, never
a batch button in the bar with Confirm ticked, so a tick can only ever mean the suggestion.

Why this shape: rian's words were *"candidate product pages and brand pages and a human would go
in and check it ... then hit publish"*, and he also said no single human can digest it all. An
opt-in gate on 2,365 brands the day before launch would publish nothing; an opt-out gate with a
stamp lets him start where the value is (the brands and lines the savings tables lead with) and
measure how far he has got. The desk is where he does it: the review column sits beside the
suggestion column, because the same look answers both questions.

Built now: the field in the decided layer, the CLI (`app.cli review brand|line <id>
checked|hidden|clear --reason`), and the brand floor honouring `hidden` (a hidden brand has no
page, no sitemap entry and no link from its products). Not built: the desk column, the
staleness mark and any opt-in mode, which are the first desk stream after launch. Product
publication stays as settled on 9 Sep.

### 2.9 AI in the pipeline (P5, question 12)

> **Reframed 16 Sep by rian:** the unit of review becomes a whole proposed product line with every variant's attributes on one sheet, and grouping by model moves from last to central. Walk-through W10.

**A model proposes; a rule or a person commits. A model never writes the standard or the decided
layer.** In order of adoption:

1. **Auditor, first.** A batch command (`app.cli ai-audit`) over `raw_records` changed since its
   last run, asking a model to read each fragment and say what the standard layer got wrong: the
   line, the variation and its kind, the quantity, a wrong brand fold, two rows that are one. A
   disagreement about a **pair** (two rows that are one) lands as a suggestion in the existing
   queue, reason `ai`. A disagreement about a **field** of one entity (its line, its variation,
   its quantity, its brand) has no pair to land on; it lands as a ledger-shaped proposal (entity,
   field, the proposed value, the value the rules had, the exact text span read, the model's one
   line of `why`), unique per entity, field and span, in `reverifications` or a `proposals` table
   the auditor stream decides, and the desk applies a confirmed one through the same `decide`
   call a person's own correction uses. A confirmed proposal is recorded with its origin
   (`ai_confirmed`, the model and prompt version), so a class of proposals later found wrong can
   be found and withdrawn, and a rule change may re-propose against it where a person's own
   decision is never touched. No determinism risk (the key stays rule-computed), no new runtime
   (an HTTPS call from the CLI; the key in `.app.env`), and it produces the precision data that
   decides step 2.
2. **Cleaner, later, and only where the listed words changed.** Rian's observation is exactly
   right and becomes a rule (section 2.10): once a listing is standardised, the next collection
   changes its price and nothing else, so re-reading it is waste. A model reads a listing again
   only when its listed fields moved. Whether its reading may then land as *standard* (never as
   *decided*) for presentational fields such as the display name, and never for anything in the
   identity key, is decided on the auditor's precision after a month of data.
3. **Never at collection time.** The collector gathers; identity is derived after, replayably,
   from `raw_records`. That is the architecture rian hoped for, and it exists.
4. **Constraints, enforced at ingest of the proposals, not hoped for in the prompt:** a proposal
   must cite the span it read or it is dropped; a value the raw text does not contain is dropped
   (empty beats guessed); a barcode is never proposed; units and kinds must be in the vocabulary;
   a proposal on a row a person decided is filed for the person, never applied.
5. **No local model sidecar.** The stack rule (one Python process; Node at build time only)
   stands; a sidecar is a deliberate architectural change to propose separately if the API cost
   ever argues for it.

### 2.10 The listed-changed tripwire (from rian's note on re-standardising)

**A listing whose standard changes between sightings is flagged for review; one whose price
alone changes is not.** At ingest, after the listed columns are rewritten from the new fragment,
the key, line, variation and quantity are recomputed from it; when any differs from the product's
current standard, or `listed_gtin` changed (the one field whose change means the SKU now names a
different bottle), a `reverifications` row is queued (`listed_changed`) with the old and new
values. It fires only when the previous sighting had a fragment (12,673 listings have none yet;
their first fragment-bearing sighting queues nothing) and compares the named columns, never the
writer's "changed" flag, which is true on every sighting because the record id changes.
Comparing the raw words alone would also fire on recasing, so the trigger is the derived
standard, not the text. Two more triggers belong beside it: `rederive` and `backfill lines` queue
`rules_changed` for a product whose line or key moved while it carried a decision or sat in a
checked line, and ingest queues `listing_moved` when a sighting re-resolves a listing to a
different product. The desk shows the queue; a person (or, later, the AI auditor) looks at those
and only those. Built after launch; `reverifications` was built for exactly this and never used.

### 2.11 Places and shops (question 11)

**Direction decided; built after launch, before any non-airport shop is collected.**

- A **place** is a row: `places (id, slug, kind, name, city, country, iata NULL, parent_id NULL)`.
  `kind` is a string validated by a code registry (`PLACE_KINDS`, like `VARIATION_KINDS`), each
  entry carrying whether the kind is **comparable** and its address prefix, so a new kind is a
  deploy, not a migration: `airport`, `cruise_port`, `border_crossing`, `mall`, `ferry_terminal`
  (comparable; `/airports/`, `/cruise-ports/`, ...) and `terminal`, `city` (not comparable; for
  nesting and the "where to buy" line). There is no `online` kind: a catalogue-only shop is a
  shop with no place. The IATA code becomes an attribute of an airport-kind place; our slug is
  the identity.
- A **shop** (today's `locations`) serves one **primary** place, always of a comparable kind, and
  may serve others (`shop_places (shop_id, place_id, primary)`); Extime's primary is CDG and it
  also serves Orly, so an Orly page exists and shows Extime's prices. The Heinemann online
  catalogue serves no place.
- **The comparison unit is the shop, counted once per primary place**, and because a primary
  place is always of a comparable kind, two Heathrow shops pinned to terminals T2 and T5 count as
  one place (the terminal is a child row they serve, never their primary). "At two or more
  airports" becomes "at two or more places", defined once in `catalog_queries` as the expression
  every counting query calls; a test fails on any surviving count of shop rows, so the level
  cannot drift per site. The acceptance test beside the BorderShop: two shop rows under one
  airport yield a place count of one and appear in no savings table. A within-place comparison (a
  mall's sixty shops, a terminal's three) is a page question the place model leaves open.
- **Places nest** through `parent_id` (Paris over CDG and Orly; an airport over its terminals).
  Whether the site shows a parent is a page question.
- **The address shape is per kind**: `/airports/...` stays exactly as settled with Mark; each
  kind's prefix is in the registry beside it. One page type, one template.
- **`retailers` becomes the company.** Heinemann's four rows become one retailer with four shops
  through an alias (`retailers.canonical_id`, like brands; never a delete of a row runs and
  sources point at), driven by an explicit slug map in code rather than the free-text `operator`,
  with a `--check` that reports shop-code collisions under the shared retailer before anything
  moves. Each website stays one shop and one source; the Heinemann platform collector is one piece
  of code reading three of them. `sources` does not rename.
- **`articles.airport_code` and `subscribers.home_airport`** gain a nullable `place_id` in the
  same additive migration, filled by the same backfill, so a port write-up and a home port can
  exist; the IATA strings stay until the wave.
- **Currency stays on the shop**, never on the place (BOG, EZE, ICN and MEX price in USD because
  those shops chose to).
- Sequencing: the migration is additive (create `places`, `shop_places`, backfill from `iata`,
  add `shops.place_id`), then the counting queries, then the rename of `locations` to `shops` in
  the wave. The BorderShop at Puttgarden (1,806 listings, hidden for want of a place; kind
  `border_crossing`) is the first non-airport place and the acceptance test.

### 2.12 The rename wave (after Cannes), now the consistency pass

**Rian's rule, 16 Sep, and the purpose of this pass:** one term per concept, identical in the
database, the code, every label and every sentence said to him; which term is secondary, that
there is only one is not. He left the specific terms to a deep-reasoning session. The conflicts it
must resolve, each today a different word for one thing:

| Concept | Words in use today |
|---|---|
| the searchable thing, one page | product line (rian), line, product (15 Sep), `product_lines`, `ProductLine`, `line` |
| the barcoded thing | product variant (rian, working term), product variation, variation, product (15 Sep), `products`, `Product` |
| what tells two barcoded things apart | option, variation (15 Sep), `attributes.variation`, `variation_kind`, `variation_aliases` |
| a row pointing at the row it stands for | alias, alias of (Listings label), `canonical_id`, `variation_aliases.canonical`, `resolve_alias` |
| the row every spelling resolves to | the brand, house (`house=`, `Maps.house()`), standard brand (retired), canonical |
| two variation rows becoming one | merge, `merged_into_id`, `product_merges`, fold (backfill `merges`) |
| a pair waiting for a person | suggestion, suggested match, `merge_candidates` (holding aliases too), the **Merge** tab on `/collectors` (which mostly confirms aliases) |
| accepting a suggestion | **Confirm same** (rian's word, 16 Sep), confirm, `confirm` |
| declining a suggestion | **Keep separate** (rian's word, 16 Sep), keep apart, `reject` (the route and the action in code), `kept_apart` (the stored decision) |
| what tells two variants apart, and the facts about a variant | **attribute** (agreed 16 Sep; one noun, two settings per kind), option, property, variation, `attributes`, `abv`, `country_of_origin`, `VARIATION_BEHAVIOR` |
| the list of barcoded things on `/collectors` | the **Products** tab (lists product variations) |
| picked versus shown | picked / shown, `determinant` / `descriptor`, `VARIATION_BEHAVIOR` |
| how much is in the pack | quantity, size, `size_ml`, `size_value`, `quantity_value` |
| a storefront we collect | shop, location, `locations`, source (one per shop today) |
| where a shop is | place, location (rian's earlier word), `iata` |
| the first shop's brand spelling | Collected brand (label), `products.brand` |
| the three layers | listed / standard / decided; `listed_*`, `overrides`, `collected_value` (the rules' value, not the collected one) |

**Working terms until the pass: product line and product variant** (never a bare "product").


**Added 16 Sep, from rian's reading of the alias:** *"we're calling it alias, but then in the
database we're calling it canonical... the language we're using to humanize it doesn't match the
language we're using in the database."* He is right, and the column is worse than a mismatch: it
lives on the alias row but is named for the row it points at, so `canonical_id` on "Paco Rabanne"
reads as if Paco Rabanne were canonical. **Proposed for the wave: `brands.canonical_id` and
`product_lines.canonical_id` become `alias_of_id`**, which reads as the sentence a person says and
matches the label the Listings table already shows ("Alias of"). The standing principle it
implies, put to rian: a column is named by the words a person uses for it, so the glossary's
"code says" column shrinks to nothing.

One deliberate pass, one migration that only renames, one weekend of regression, no data moved:
`locations` to `shops`; every `house` identifier to `brand_of()` / the resolved brand;
`merge_candidates` to `suggestions`; the `decided_by`/`decided_at` columns on `brands`,
`product_lines` and `variation_aliases` dropped, and only those (`merge_candidates.decision`,
`decided_by`, `decided_at` and `product_merges.merged_by` stay: they are the only record of a
keep-apart and of who confirmed); the redundant legacy `product_id`/`candidate_id` pair on
suggestions dropped; the v3 `size_value`/`size_unit` pair decided (kept as the record or
dropped). `sources` does not rename. Nothing public changes. The glossary carries a "code says /
we say" column until then, and that column is the wave's checklist.

## 3. What did not change, and why

- **One product, one barcode.** Untouched; it is why shades stay separate products.
- **The 9 Sep decisions with Mark and Adam.** The address shape and the comparable-or-exclusive
  rule stand until they revisit them; section 2.7 is the case to take to them.
- **Products publish mechanically.** The human gate is on brands and lines only.
- **Drinks keep age and expression in the line.** Section 2.4.
- **The runtime.** One Python process; no model sidecar; no queue.
- **Everything re-derives from `raw_records`.** Every rule change here was replayed with
  `rederive`, no fetch.

## 4. Sequencing

| When | What | Status |
|---|---|---|
| Now, on this branch | vocabulary in labels, messages and docs; the glossary; the decided-layer ledger for brands, lines, wordings, pins and ignores; identity rules v5 (shades, pack words, flavour); the prune; the variation behaviour registry; the review field and CLI; tests for all of it | section 5 |
| Staging, when rian merges | the after-deploy block in `RUNBOOK.md` (variations, lines, rederive, merges --check, merges, suggest, prune_lines); a look at the desk, confirming nothing: every confirm, review stamp and alias taken on staging is discarded by the next refresh from production (section 6) | rian |
| Launch day, production | the whole chain from 0.35.0 in one pass (every migration since, then the b3c4 block, then the v5 block); **no `propose --file` with a staging file**, its ids are staging's; the first real desk session is here, after this chain, because a staging refresh from production wipes every decision taken on staging | rian |
| Before launch | nothing else in the catalogue model; the readiness lane owns the week | |
| After launch (week of 21 Sep) | undo for a confirmed alias or merge (`unalias`, `unmerge` from the recorded moves) before any 200-row batch, and a lower batch cap for brand confirms until it exists; the desk's review column and the staleness mark; the listed-changed tripwire; the collector's structural variant field; the AI auditor's first run in `--check` | a stream brief |
| Decided 16 Sep, before launch, pending only Mark saying no | the line page replaces the product page at `/products/` (section 2.7); depends on the v5 block reaching staging first | rian's comment to Mark; the build is a stream starting now |
| After Cannes | places and shops (2.11); the rename wave (2.12); the age-in-line question revisited with search data; the Extime and Avolta shade shapes once their fragments exist | stream briefs |

## 5. What this session built and measured

Every number below was read from `dfp_cat` on the dev Postgres, a fresh read-only dump of staging
taken at 18:25 on 15 Sep and restored there twice (once for the first cut, once after the review's
amendments; these are the second run's numbers); the chain run on it was exactly the after-deploy
block in `RUNBOOK.md` (variations, lines, rederive, merges --check, merges, suggest, prune_lines),
each backfill twice and `rederive` twice to prove the second run writes zero. Staging itself was
not touched; the numbers are the ones rian's own run will produce on the same data.

| | before | after |
|---|---|---|
| line rows | 13,772 | 13,071 |
| line rows with no product at all | 66 | 0 |
| lines holding exactly one product | 12,129 | 11,358 |
| lines holding four or more | 330 | 412 |
| beauty lines | 5,449 | 4,821 |
| Makeup lines | 761 | 453 |
| CHANEL Rouge Allure lines | 59 | 17 |
| Lancôme Teint Idole lines | 45 | 8 |
| Shiseido Synchro Skin lines | 43 | 11 |
| Clarins Joli Rouge lines | 38 | 7 |
| products carrying a color kind | 399 | 435 |
| products carrying a flavour kind | 158 | 3 |
| match keys changed by `rederive` | | 1,044 |
| products folded by `backfill merges` | | 1 |
| suggestions closed as superseded | | 52 |
| empty line rows pruned | | 921 |
| liquor lines | 7,915 | 7,920 |
| lines with a publishable product | 3,183 | 2,848 |

**One product folded**, and it is right: "Bulldog Gin 1L" (with its barcode) and "BULLDOG London
Dry Gin 1000 ml" (without) now share a key and one barcode, so the rule folded them. Nothing else
met: the shade left the line key and stayed in the key's variation slot, so two shades never
agree. The 1,044 rekeyed rows are the shade-bearing beauty products (their line part shortened),
the 157 confectionery one-offs (their variation slot emptied) and the drinks and beauty rows whose
"triple", "tri" or "twin" is now the expression ("anejo triple cask", "triple wood", "black
label triple cask", "triple cask 12", "10 triple distilled pot still", "tri peptide").

**CHANEL Rouge Allure's 17 lines are its real sub-lines** (Rouge Allure, Rouge Allure Laque, Rouge
Allure Velvet, Rouge Allure Ink and their variants), not shades. The first cut left it at 59
because it gated the shade rule on the category Makeup and every Rouge Allure row has none: the
shop's shelf is `Labios`, a Spanish word the taxonomy does not know, and "Rouge Allure" carries no
English makeup word. The review caught it; the rule is the beauty vertical's now, and the category
names only the kind, which is why 435 products carry `color` (the Makeup-categorised ones) while
the rest of the tailed rows carry a variation of unknown kind until something names it.

**Three lipsticks still carry a flavour**, because they sit in the confectionery vertical: their
shades are called "praline nude", "Chocolate" and "Toffee Talk", and the classifier filed them by
those words. The marked tail no longer reaches the classifier (pinned by a test), so no new row
will be filed that way; the three keep their category until a classification backfill reaches
categorised rows, which none does today. Filed.

**The Spanish shelves stay filed, not done.** `Labios` and `Uñas` are pure makeup shelves in the
data, but `Rostro` and `Ojos` mix foundations with face and eye creams (230 of 533 `Rostro` names
carry a skincare word), and the classifier lets a shelf hint beat every name word, so mapping the
shelves would file creams as makeup. The fix is a classifier rule that lets a skincare name word
beat a shelf hint, with those rows as its test; it is on the running list. It no longer blocks
the shade collapse, only the kind.

**Also built**, each with its test: the ledger for brand, line and wording aliases and for
listing pins and ignores (section 2.2; `tests/test_aliases.py`, `tests/test_decided.py`); line
decisions following their products through a rekey (`tests/test_line_backfills.py`); the
behaviour registry (2.4; `tests/test_lines.py`); the pack-word rule (2.5, eight expressions, six
packs and three beauty rows pinned); the flavour reader (2.6); `backfill prune_lines` (2.3;
`tests/test_line_backfills.py`); `rederive` re-deriving every row's line, tombstones included; the
review field, its CLI and the brand floor honouring `hidden` (2.8; `tests/test_decided.py`, a
hidden brand has no slug for its products to link to); the data moves refusing under a running
collection; suggestions on a line the rules emptied closing as superseded; the marked tail kept
out of the classifier (`tests/test_taxonomy.py`); and every user-visible "house" and "approve"
replaced (the Listings table's column group, the desk's headers, button and message, the
refusals, the suggestion reasons). The full suite is 1,533 tests; `main/check.sh` passes.

## 6. Still open, honestly

- **Whether the flavour of a drink is a variation at all.** "Baileys Mint Chocolate" is its own
  line today and compares across nine shops; the model has no evidence yet that a shopper wants a
  Baileys page with a flavour selector. Left in the line.
- **Whether `kind` should ever be a key slot.** It is metadata beside the canonical string; two
  wordings that canonicalise alike but carry different kinds would need a person. None exist.
- **The shade shapes of Extime and Avolta**, and any shop that puts a shade in the name with no
  separator. Read when the fragments exist; until then those lines stay split, which is the
  conservative failure.
- **The Spanish shelf words** (section 5): a classifier rule that lets a skincare name word beat
  a shelf hint, so `Labios`, `Rostro`, `Ojos` and `Uñas` can classify without filing creams as
  makeup. The 384 uncategorised marked-tail rows collapse the day it lands, with no rule change
  here and one `backfill lines` plus `rederive`.
- **`PAGE_REVIEW=required`**: when, is rian's call, with the coverage figure on the desk.
- **The representative product rule** for a line card is decided but unbuilt; the first line-page
  build tests it against the 18-shade case.
- **Undo.** A confirmed alias re-lines, re-keys and folds by bulk update, and no `unalias` or
  `unmerge` exists; a wrong confirm in a batch is reversible only by hand. Built before the first
  large batch (section 4); until then a brand confirm is a small, deliberate act.
- **The next verticals.** The line rules have two branches, beauty and everything else, and the
  "everything else" branch strips drinks words (swiss, premium, blend, old) from any name it is
  given; a product the classifier cannot place is stored as a drink; a quantity the parser cannot
  read keys `unknown`, which never agrees with itself. Food, fashion and tobacco each need their
  own rule set registered by vertical, an `unknown` vertical instead of a default, and a
  `not_applicable` quantity state that equals itself; decided in direction, built with the first
  such shop, and the reason the shade rule was made vertical-wide rather than category-wide.
- **The staging refresh** preserves the client's tables and nothing of the catalogue's decisions;
  the ledger, the aliases and the suggestions' decisions must join its preserved set under
  natural keys before staging is ever used for a real desk session.

## 7. Where this is tracked

Running list: the three catalogue `decide` items are resolved with this document as the decision
(`decide-makeup-shades-one-product-with-shade-variations-decided`,
`decide-the-line-page-and-the-variation-determinant-split`,
`decide-the-database-structure-changes-you-want-after-the-listin`); rian's two `do` items are to
read this document and to take section 2.7 to Mark and Adam; the pack-word and flavour issues
resolve with their tests; new issues name the Spanish shelves, the two unread shade shapes, the
brand floor counting ignored listings, the concentration field the collectors page mislabels as
the variation, the missing undo, the staging refresh, the proposals files' ids, the structural
variant field, the unknown-kind tails and the platform-bound flavour. The two place issues that
carry a P6 note (`issue-extime-paris-not-cdg`, `issue-shops-and-terminals-within-one-airport-are-not-modelled`)
are not blocked by this document: the delivery walkthrough does not need the place model; Adam
hears at P6 that Extime's prices are Paris's (the W6 naming task) and that terminals and specialty
shops within one airport are not yet distinguished, coming after Cannes; the Extime issue
resolves at W6 with its note pointing at section 2.11.

## 8. What the adversarial review changed

Five critics read the first draft, one lens each (rian's understanding, scaling beyond airports
and drinks, a person checking at scale, the invariants, the deadline and the code); 51 objections
came back, and every one at medium or high confidence went to a skeptic who checked it against
the document and the code. The ones that held, and what they changed:

- **The shade rule was gated on the category Makeup**, which the taxonomy never assigned to most
  tailed rows (384 of 783, CHANEL Rouge Allure among them). Now the vertical's rule, skin-type
  tails excepted; the category names only the kind. Section 2.3, and the code.
- **`rederive` did not re-derive line membership**, so the after-deploy order was load-bearing
  and a tombstone kept its old line forever. Now it does, tombstones included. Section 2.3.
- **"Standard brand" contradicted the layer it borrowed its word from** (an alias is decided, not
  standard). Dropped for "the brand". Section 1, the labels, the glossary.
- **The line test and the determinant test were the same test** and put "1 Million Elixir" on
  both sides. The line has its own test now; behaviour is asked only of a variation. Section 1, 2.4.
- **Age, cask and edition had a behaviour without a reader.** Behaviour is registered only for
  kinds a reader produces. Section 2.4, the code.
- **The pack-word rule kept eight words in one vertical on a sample of twelve.** Narrowed to the
  three words measured, widened to every vertical (Lancôme's Triple Serum). Section 2.5.
- **Two identifiers used British spelling** against the 14 Sep rule; renamed, and the house-style
  test now covers the files this branch touched.
- **The desk's button said Approve** while the review state said `approved`; the button says
  Confirm, the states are `checked` and `hidden`, and `hidden` is applied in the one definition of
  a brand page, not in one read. Section 2.8, the code.
- **"Every human decision in one table" was not true** (keep-aparts and product merges have their
  own records); section 2.2 says what lives where. **Line decisions were keyed on derived rows**;
  they follow their products now. Section 2.2, the code.
- **The prune counted algorithm-closed suggestions as references** and ran before `suggest`;
  fixed and reordered. **The data moves ran under a live collection**; they refuse now.
- **Collector and source were one word for two grains**; **"location" had no glossary entry**;
  the brand test answered with the maker; the variation examples named a word the rules leave in
  the line. Section 1 and the glossary.
- **The AI auditor's field proposals had no row to land in**; **the tripwire fired on text
  noise and missed the barcode**; **the representative product moved with every collection**;
  **the place kinds mixed shops with places and re-created the two-shop defect through
  terminals**; **the proposals files carry staging ids**; **the staging refresh wipes decisions**;
  **there is no undo**. Each is now stated as decided or as open, in 2.7, 2.9, 2.10, 2.11, 4 and 6.

- **A second decision on a field replaced what the rules had**; the ledger is keep-first now.
  **A merge recorded nothing of what it moved**; it records the listing ids and the awards. **A
  brand alias left the alias line's decisions behind**; they follow its products. **A review from
  the CLI left no history**; it writes an audit row. **The staging refresh would silently discard
  decisions**; it counts them and refuses without an explicit flag.

Refuted or immaterial before the wave, and left as written: keeping "product" for the barcoded
thing (the reasoning was corrected, the decision stands); the alias/merge split (the person never
chooses between them); "shop" for the collected storefront; the flat-kind registry (a per-vertical
exception is one line); the double write of column and ledger (the effective column is the read
path by design, and a consistency check is the desk stream's first item).

