---
type: plan
version: 1
status: a live record of rian walking the 15 Sep catalogue decisions one item at a time on 16 Sep. ISSUE-FIRST BY HIS INSTRUCTION. Every "provisional outcome" below is where the conversation landed, not a decision to implement.
supersedes: nothing; revises sections of catalogue-model-decisions-2026-09-15.md, and each revision there points back here
---
# The catalogue walk-through, 16 Sep

## For the session that reviews this

### Your brief (rian, 16 Sep)

**Do not change code, data or configuration.** Produce a written plan that rian accepts before
anything is built. The sequence he set: *"a writeup of the decisions and plan and proposed streams that
can get it done over night. After that plan, we'll accept it and write up the streams. Then we will
start the streams."*

**Deliverables, in one document** (`.logs/planning/catalogue-refactor-plan-2026-09-16.md`):
1. **The decisions**, one per walk-through item W1 to W20, each with the reasoning, what it overturns in
   the 15 Sep decisions, and its reversal cost. Where you disagree with a provisional outcome here, say
   so and why; rian wants the argument.
2. **The plan**, in dependency order, with the launch path (the staging-to-production push of W17 and
   its checklist) and a cut line if the overnight work runs short.
3. **Proposed streams** runnable overnight in parallel where they can be, each with scope, the files and
   tables it owns, what it must not touch, its dependencies, how it is rehearsed, its acceptance checks,
   and its rollback. Summary level only: full stream briefs are written after rian accepts the plan,
   following `.logs/planning/streams/OVERNIGHT-RULES.md` and `KICKOFFS.md`.
4. **Escalations**: the issues where a mistake would be costly enough to justify a deeper
   multi-agent think before building, each with the specific risk. If there are none, say so.

**Budget.** Rian: *"I'm going to run this on fabel high, but I want fabel to see if there are any issues
we need to think about more deeply. If none, great, I dont want to overdo it because I have very limited
credits. But if there's a significant risk of making a mistake, lets pull those issues out and do an
ultracode think on them."* Do the work at normal depth; do not start a multi-agent workflow yourself;
list the escalations for rian to run.

**Three things rian asked you to think about specifically.**
- **Categories we will likely add, and problems not visible yet.** *"think ahead to other categories we
  will likely add. Are there problems we're not seeing now that we really need to think about before we
  do this major refactor?"* Airport and cruise shops also sell watches, jewellery, sunglasses,
  electronics, fashion accessories, travel goods, tobacco cartons and food. One hint is already in the
  data: a RingConn smart ring collected at Panama as "Anillo Inteligente RingConn Gen 2 Air para
  Monitoreo de Salud 6.0 / Plateado", with a ring size and a colour, neither of them a quantity, and no
  category. Apply the W19 principle: an unforeseen kind should be configuration, not a migration.
- **Comparable cards open the product line page.** *"Now when you click a comparable card, it doesn't go
  to a unique page for that product variant, it deep links into a product line page and preselects the
  variant. I still want to see the comparable when I click it, but it's now within a product line page.
  Building this UI is part of the refactor."*
- **Where the review needs more depth** (the escalations above).

**State of the code you are planning against.**
- This branch, `claude/table-layout-db-structure-1d5bc2`, carries the 15 Sep build (identity rules v5,
  the decisions ledger, the review field and CLI, the pack-word and flavour fixes), tested and **not
  deployed**; staging and production have none of it. The plan must say which of it is kept, changed or
  dropped under the new decisions.
- `master` has moved on since the branch last merged (three discussion-panel commits), so the first
  stream starts by bringing the branch up to date.
- Staging is at 0.49.0; production at 0.35.0; launch may move from Friday 18 Sep to Monday 21 Sep.

### How to read this record

Rian, 16 Sep: *"Remember to record the issue as we're doing this not just the conclusion. I want to
run this through Fable 5.1 and I want Fable 5.1 to think about the issue in case there is a better
solution not just blindly implement our recorded decisions."*

So for each item: **the question as rian raised it, why it is hard, the evidence, the options that
were on the table, where the conversation landed, and what is still worth challenging.** Treat
every provisional outcome as a hypothesis with its reasoning attached. Where you find a better
answer, say why the recorded one is worse; rian wants the argument, not deference to this document
or to the 15 Sep one.

Rian's standing goals, from the whole day: a model he can understand; one that scales past
airports, past drinks and beauty, into clothing and food; one where humans confirm and correct at
scale; everything re-derivable from what was collected. And a new standing rule from this session:
**one term per concept, identical in the database, code, labels and conversation.**

**Designing for kinds nobody has thought of (rian, 16 Sep, W19).** *"I dont want to lock us in to a
structure that is only relevant to the things we can think of right now."* This applies to every part
of the model, not only places: kinds of place, categories and their attributes (W7), page types (W18),
and sources of data. The test for a design is not whether it handles airports, cruise ports, drinks and
beauty, but whether an unforeseen kind is configuration rather than a migration. Prefer open lists with
a registry over columns named for today's cases (`iata`, `abv`), and state explicitly any place where a
design still assumes a known kind.

How rian works through this, worth knowing: he thinks aloud, revises his own vocabulary as it
clarifies (three sets of words for products in one afternoon), and is often right by instinct
before the data confirms it (he predicted product lines were over-split; they were).

---

## W1. What `/products/` shows: product lines or product variants

**The question.** The 15 Sep decisions kept per-barcode pages at `/products/` and left the product
line page as "a recommendation for Mark and Adam". Rian: *"My intention is to change what is
displayed on /products/ to display the product LINE, not the product... 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."* Then, correcting a first recording that said "built after launch": *"When I launch
I dont want product pages as they are now, I want product line pages."*

**Why it is hard.**
- The `/products/<name>-<id>` address was settled on 9 Sep with the client's reviewers, Mark and
  Adam. Rian has told Mark he proceeds unless Mark says no.
- Launch is 18 Sep. The page depends on the shade collapse (W10) reaching staging first, on a
  representative-price rule that is decided but unbuilt, and on 22 files that address variant
  pages today (page, server-rendered body, JSON-LD, sitemap, search suggestions, cards).
- A product line card must show a price, and a line holds variants at different prices.

**Evidence.**
- Nothing is indexed today: staging answers an anonymous `/products` with a redirect to sign-in and
  `/sitemap.xml` with 404; the flip to public is still open. So old variant addresses need no
  search-engine preservation, only a landing on their line.
- A search for "johnnie walker blue" returned variants from at least six product lines (Blue Label;
  Xordinaire; Elusive Umami; Casks Edition; King George V; a Chinese New Year edition) plus two
  spelling splits ("Johnnie Walker Blue 1L" as its own line; "Joh. Walker Blue Umami" apart from
  "Blue Label Elusive Umami"). Rian: *"once we figure this out we will be able to collapse the
  product pages further."*
- Rian raised the same idea on 11 Sep from the 1 Million rows; he has been consistent.

**Options considered.**
1. Keep variant pages (the 15 Sep position). Rejected by rian.
2. Line pages after launch, variant pages kept out of the index meanwhile (my first recording).
   Rejected by rian; kept only as the fallback if the build slips.
3. Line pages replace variant pages before launch. **Chosen.**

For the variant within a line page: rian's `/products/<line>?product=<id>` versus the 15 Sep
draft's `#product=<id>`. A query string can be shared and server-rendered but must carry a
canonical to the bare line address or every variant becomes an indexed duplicate; a fragment is
invisible to crawlers and needs no canonical but cannot preselect in a server-rendered body. Not
settled.

**Provisional outcome.** Product line pages at `/products/`, before launch, no variant pages.

**Worth challenging.** Whether two days is enough, and what the smallest honest version is; the
query-versus-fragment choice; what a line card prints as its price (the 15 Sep answer: price one
named "representative" variant, never "from $X"); whether a line should publish when only one of its
variants is comparable.

---

## W2. The words, and the rule that came out of them

**The question.** Rian kept saying "product" for both the searchable thing and the barcoded thing,
and found the 15 Sep vocabulary (line / product / variation) did not match how he thinks. Then,
reading an 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."*

**Why it is hard.** The database and code were written in one vocabulary, the review labels in a
second, and conversation in a third. The public address says `/products/`. Renaming code and
columns two days before launch risks bugs; not renaming leaves a permanent translation layer.

**Evidence.** Thirteen concepts carry more than one word today; the full table is in section 2.12
of the decisions document. Worst case: `brands.canonical_id` sits on the alias row but is named for
the row it points at, so it reads as if the alias were canonical. The Listings table already labels
it "Alias of".

**Options considered, in the order they came up.**
- line / product / variation (15 Sep). Rejected: "product" meant the barcoded thing while the page
  called `/products/` was to show the searchable thing.
- product / variation / option. Accepted, then superseded within the hour: rian still said
  "product" for both.
- **product line / product variant**, never a bare "product". Accepted as working terms. "Variant"
  was avoided on 15 Sep for sitting one letter from "variation"; that objection fell away once
  "variation" stopped being a separate word. "Product item" was floated and not pursued.
- alias versus canonical: rian is *"happy with one or the other, but not both."*

**Provisional outcome.** A standing rule: **one term per concept, identical in the database, the
code, the labels and speech; which term matters less than there being only one.** Rian asked that a
deep-reasoning session choose the specific terms. Working terms meanwhile: product line, product
variant, attribute (W8), alias, Confirm same, Keep separate.

**Worth challenging.** Every specific word. Whether code and columns should follow the words now,
before launch, or in one rename pass after Cannes; the cost of a half-renamed codebase against the
cost of a translation table.

---

## W3. Why "Collected brand" exists

**The question.** Rian, on the Listings table: *"I'm actually not sure I understand why we need
collected brand... what is the purpose of collected brand if we already have alias?"*

**Evidence, traced.** `products.brand` is the brand text of whichever shop first created the
variant, written once at ingest, never updated. It predates the per-listing shop words
(`listings.listed_brand`, 14 Sep). Four listings where the shop said "Rabanne" sit on a variant
whose Collected brand says "Paco Rabanne". It is still the only brand text for 12,673 of 23,771
listings (no stored shop words yet), and the public page, JSON-LD and search still read it, so
shoppers see one shop's spelling rather than the brand's chosen name.

**Provisional outcome.** An alias and Collected brand do different jobs (a decision linking brand
rows versus raw text), but once every listing carries its own words and the site reads the brand
row, Collected brand has no job. The product line page build should read the brand row; the column
is dropped or demoted later.

**Worth challenging.** Whether the variant should carry any brand text at all, or only the brand
row; what the page shows for the 12,673 listings until their words arrive.

---

## W4. How an alias actually works

**The question.** Rian's scenario: a collector finds "Paco Rabanne" at one shop and "Rabanne" at
another for the same bottle; *"Where do I create the alias? What table?"* and then *"the only useful
info from then on is the canonical_id? is that right?"*

**Evidence, traced.**
- With a shared barcode, the second listing lands on the same variant; there are not two brands.
  Without one, matching falls back to a key that begins with the brand (`paco-rabanne|1-million|
  edt|100ml` against `rabanne|...`), so two variants and two brands appear. That is the case aliases
  fix.
- Confirming on the desk writes the alias pointer on the Paco Rabanne row in `brands` and a
  decision row in `overrides`, then re-keys and folds.
- The alias row is not dead afterwards: it catches that spelling in every later collection (a new
  "Paco Rabanne" folds to its slug, finds the row, follows the pointer), and variants keep pointing
  at it; readers follow the pointer rather than being rewired.

**Provisional outcome.** The alias row is a forwarding address. The column should carry the
person's word (W2).

**Worth challenging.** Whether variants should be rewired to the brand at confirm rather than
following a pointer forever; whether a brand needs a row per spelling at all, or a spellings list on
one row.

---

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

**The question.** What the desk's two buttons do at each level.

**Evidence.** Brands, product lines and attribute wordings are **aliased** (both rows survive, one
points at the other). Product variants are **merged** (one row survives; the other's listings move
onto it and it becomes a forwarding row), because a variant carries prices and a split variant
splits a price history. Waiting on the desk: 83 brand, 2,062 product line, 1,069 product variant
suggestions. No undo exists for a confirm. The `/collectors` tab called **Merge** mostly confirms
aliases, and the button **Keep apart** is the action `reject` in code.

**Provisional outcome.** Rian's button words: **Confirm same** and **Keep separate**. Alias and merge
*"I guess make sense"*.

**Worth challenging.** Whether a person needs to know the difference between alias and merge at all;
whether undo must exist before any confirm on production.

---

## W6. Variants, listings and prices

**The question.** Rian: *"product variations are the things we're comparing... So there's the
product variant information (name, brand, size, etc) which should be kept consistent across all
discoveries... But we need to keep the prices of the different listings so we can compare them...
so how would that look in the listing table?"*

**Evidence.** Variant 60 (Johnnie Walker Blue Label 1L, barcode 5000267114293) has 15 listings, each
with its own shop's words and prices, from 271.10 SGD (USD 214) at Singapore to 47,900 ISK (USD 395)
at Keflavik. Variant 2883, "Johnnie Walker Blue 1L" at Montreal (396.95 CAD, no barcode), is almost
certainly the same bottle. Confirming them the same moves listing 3037 to variant 60; its price is
untouched; variant 60 then compares across 16 shops; variant 2883 remains as a forwarding row.

**Provisional outcome.** Rian's model is exactly how it works. A merge gathers listings; it never
combines or averages prices.

**Worth challenging.** Little; this one is sound. Whether a merged-away variant row needs to remain
forever or can be pruned once nothing references it.

---

## W7. More than one option on a variant, and scaling to other categories

**The question.** Rian: *"what if a variant has more than one option?... there are ecommerce products
that have more than one option. does it still hold?"* Then, when told it was not needed for launch:
*"I dont like that we're setting ourselves up for limitations in the future like clothing. I want to
make the database scalable to other areas, that was one of my big reasons for doing this now."*

**Evidence, traced.**
- A variant's identity key has four slots: brand | product line | ONE option value | quantity. Size
  plus one other option works (1 Million Elixir 100 ml; a shade at 3.5 g).
- No current row breaks it (0 variants carry both a shade and a concentration), but a second option
  is handled lossily today: either left in the product line ("Rouge Allure Velvet" is its own line,
  so its finish is never selectable) or glued into one string ("edp intense absolu"), which keeps
  matching correct but stops a page treating the two separately.
- Properties are fixed columns on the variant (`abv`, `country_of_origin`, `category`,
  `is_exclusive`). Clothing's material or food's allergens would each need a column and a migration.

**Options on the table.**
1. One JSON list of (kind, value) attributes per variant in the existing `products.attributes` JSONB
   column, with a per-vertical registry of kinds in code. No migration; weak on database
   constraints and indexing; querying "every variant in 43 percent" needs JSON operators.
2. A separate `variant_attributes (variant_id, kind, value)` table. Relational, indexable,
   constrainable; a join on every read; the classic entity-attribute-value trade-offs.
3. Typed columns per kind, added per category. Fastest to query, and exactly the scaling problem
   rian wants gone.
4. A hybrid: typed columns for the few kinds every category shares (quantity, and possibly
   brand-level facts), a list for the rest.

Option 1 was the one described to rian; the others were not discussed with him and belong to this
review.

**Provisional outcome.** Requirement, not deferral: options and properties become open lists of
kinds so a new category is configuration, not a migration. The product line page built before launch
should read attributes as a list from day one.

**Worth challenging.** Storage shape (above); how an identity key is built from a variable list in a
stable order; what "size" means for clothing (a label, not a quantity) and whether quantity stays
special.

---

## W8. Options versus properties

**The question.** Rian: *"are options different from properties? eg. is abv an option?"* and then
the real point: *"Does it really matter if they are typically different between variants? they all
just seem like attributes of a variant. a variant itself would never have two different values for
an attribute I dont think? I'm just wondering if the option vs property is something that even
really matters at this point."*

**Evidence.**
- ABV is usually shared across a line's variants (Blue Label is 40 percent at every size), but 28
  product lines hold variants at different strengths: "Reposado" 38 percent 700 ml and 40 percent 1 L;
  "Danzka" 40 percent 4 L and 44 percent 1 L; "Coffey" 45 and 47 percent at 700 ml. Causes: market
  strengths, a line name too generic to separate two drinks, typos.
- So a fixed option-or-property label per kind is wrong in both directions.

**Options considered.**
1. Two nouns, option and property. Rejected: storage is identical and the label is unstable.
2. **One noun, attribute, with two settings per kind: does it decide sameness; how does the product
   line page show it (picked, listed, just a fact).** Chosen.

**Provisional outcome.** Attribute, one value per attribute on a variant. The settings live on the
kind, per category.

**Worth challenging.** The grain of "decides sameness": per kind per category, per product line, or
per pair of variants a person looked at. Rian's point that a variant holds one value per attribute is
true of the variant; shops can still disagree (40 versus 43 for one bottle), which is a listed-layer
disagreement, not two values on the variant.

---

## W9. The 40 percent versus 43 percent case

**The question.** Rian: *"lets say we saw the 40 vs 43 case, during the merge we confirm which is
correct? But what if we look into it and there are indeed two different versions with one having 40
and the other 43. do we keep separate and assign a good name to each variant like Johnnie Walker Blue
40% and Johnnie Walker Blue 43%?"*

**Evidence, traced in the code.**
- A typo on one bottle: with a shared barcode it is already one variant and the person sets its ABV
  (a decided value that keeps what the rules had); without one, Confirm same merges and the person
  sets the value.
- Two real versions: Keep separate records `kept_apart` on the suggestion row, which only stops the
  pair being suggested again. **The automatic fold (`merges.merge_duplicates`, run by `backfill
  merges`, `rederive` and after every confirmed alias) never reads it.** It refuses only on two
  barcodes, set versus single, an unstated quantity, or a disagreement inside `products.attributes`.
  ABV is a column outside `attributes`, so it cannot refuse. Two such variants without barcodes and
  at the same size are silently re-merged on the next re-key, overwriting a person's decision.
- A name a person types does not protect them either: matching never reads the display name.
- Different barcodes, which market versions usually have, do protect them.

**Options considered.**
1. Hand-name each variant. Insufficient (above).
2. Make the fold honour every `kept_apart` pair. Necessary regardless of anything else; cheap.
3. When a person keeps two variants separate *because of* an attribute, set that attribute to decide
   sameness for that product line (a per-line exception). Then they can never merge, the line page
   offers strength as a choice, and names generate from line plus attributes ("Blue Label 40% 1L",
   "Blue Label 43% 1L"). Proposed; rian said *"ok, record it."*

**Provisional outcome.** 2 and 3 together.

**Worth challenging.** Whether per-line exceptions scale to a person checking thousands of lines, or
become a second rule set nobody can read; whether ABV should simply decide sameness for all spirits,
accepting that a typo splits a variant until someone confirms it the same (the conservative failure
the project's rules usually prefer); tolerance ("40" against "40.0" is not a disagreement).

---

## W10. Shades leave the product line, and whether rules are the right tool at all

**What was on the table.** Section 2.3 of the 15 Sep decisions, built but not deployed: identity
rules v5 lift a shade out of the product line name only when the shop marked it with a ` / ` tail
(one Shopify platform, the three Attenza shops, 783 rows); skin-type tails excepted. Rehearsed on a
copy of staging: all product lines 13,772 to 13,071; Makeup 761 to 453; CHANEL Rouge Allure 59 to 17
(its real sub-lines: Rouge Allure, Laque, Velvet, Ink); Clarins Joli Rouge 38 to 7; one variant
auto-merged, correctly. Deliberately not read: Extime's ` - 447 Mellow Shade` and Avolta's bare
`01`, because a rule that treats a number as a shade also reads "N°5" and "Brush 13" (it once did).
Those product lines stay split.

**The question rian raised.** *"my gut still tells me that there are many more cases like this that
we just aren't finding yet. I am worried that we are creating rules that treat the symptom and not
the problem. Also I'm starting to realize this isn't just a simple problem where a human needs to
merge all of these into one line, but we need to make sure the properties are all correctly set in
the variants."*

**Rian's proposal, in his words.** *"we live with the problem of many different lines at first, so
there's not really a rule that takes place at collection time if there's a good chance we'll get it
wrong. instead, we do an AI pass through to look for recommended corrections. So the AI pass would
present a recommendation that we have one product line called Rouge Allure. then below that it lists
all the listings in a table with proposed attribute population. The human scans through it, makes
any corrections, and approves."*

**Evidence that his gut is right.**
- The rule captures a fraction of the problem. A crude estimate on 15 Sep (collapsing beauty product
  lines onto the first two words of their name, per brand) suggested beauty would fall by about
  1,850 lines; the v5 rule, rehearsed, removed about 630. Roughly a third. The estimate over-collapses
  (it would merge "Skin Illusion Foundation" with "Skin Illusion Tinted Moisturizer"), so the true gap
  is smaller than two thirds, but it is not small.
- Every shop format needs its own rule: Attenza's ` / `, Extime's ` - `, Avolta's bare number, the
  "Joh." abbreviation (W1), pack words mistaken for expressions (the "Triple Cask" defect), ABV
  differing within a line (W8). Each rule was written after a person noticed a failure.
- The rules decide grouping and attributes together from one name string, so an error in one is an
  error in both, which is rian's second point: grouping is not the whole job; every variant's
  attributes must be right too.

**Why it is hard.**
- **Friday.** Rian wants product line pages at launch (W1). If grouping waits for an AI pass and a
  human review, launch pages show split lines except where a review is done.
- **New listings after approval.** A reviewed Rouge Allure sheet is correct on the day. The next
  collection brings "Rouge Allure 3.5 gr / 105". Something must place it, and must not silently
  undo the review (compare W9, where the automatic fold overwrites a keep-separate).
- **Review load.** 2,365 brands. 97 brands have 50 or more listings and hold 12,526 of 23,770
  listings (53 percent); the largest brand has 384 product lines. A per-brand sheet is a natural unit
  for the AI (it can only see that 59 lines are one if it sees all 59 at once) and a natural unit
  for the person, and a value order (brands and lines the site leads with first) makes a partial
  review useful.
- **Determinism and re-derivation.** Rules are replayable; model output is not. An approved sheet
  must become decided data (the `overrides` ledger), which survives every rederive; an unapproved
  proposal must never change the standard layer.
- **Accuracy and cost** of a model over 23,770 listings, and what the sheet shows where the model
  is unsure.

**Options.**
- **A. Rules per shop format** (the v5 approach). Deterministic, cheap, testable; a new rule per
  format, written after each failure; covers a fraction.
- **B. Structural variant fields from collectors.** Where a platform publishes its own options
  (Shopify does, per variant), pass them through instead of reading them back from the name. Durable
  and exact where available; useless for shops that publish only a name.
- **C. Rian's: an AI pass proposes, per brand, a product line grouping with every listing's proposed
  attributes, shown as one sheet; a person corrects and approves; approval writes decisions.**
  Collection-time rules stay only for facts that are not interpretive (barcode, a parsed quantity,
  the declared currency).
- **D. A hybrid.** B wherever the shop publishes structure; rules only for deterministic facts;
  C for everything interpretive; an approved sheet placing new arrivals by proposing them as
  additions to the approved line, queued for a person, never auto-joined.

**How C relates to the 15 Sep AI decision (section 2.9).** Compatible in principle: a model proposes,
a person commits, a model never writes the standard or decided layer. C changes the unit of review,
from one pair at a time (the desk's 2,062 product line suggestions, one by one) to a whole
product line with all its variants and attributes on one sheet. That is a better fit for rian's
point that grouping and attributes are one job. It also reorders the 15 Sep adoption plan, which put
an auditor first and grouping by model last.

**Provisional outcome.** Rian's direction C, which in practice probably means D. Not settled: whether
v5 still deploys for launch as the best available grouping, or is set aside.

**Worth challenging.**
- Whether "live with many lines at first" is compatible with product line pages at launch, and what
  the launch pages look like for unreviewed brands.
- Whether any rule should group at all, or rules should only extract facts and leave all grouping to
  proposals.
- How new listings join an approved line without re-creating the W9 problem.
- The sheet's shape: per brand, per proposed product line, or per shop format; what a person corrects
  cell by cell versus accepts wholesale; how one approval is undone.
- Where the model's confidence shows, and whether low-confidence rows are held back from the sheet.

---

## W11. The principle, and rian's plan to rebuild before Friday

**Rian's principle, in his words.** *"I think we should automate stuff we can be confident on, like
attributes that are clearly spelled out, but if there's a potential for making a mistake with an
algorithmic analysis, opt to just record the raw data in the most relevant field (like variant
name) rather than risk populating it wrongly. I think an AI pass with reasoning can do a better job
of suggesting the correct brands, product lines, product variants, and attributes, then a human can
just spend time going through and confirming or correcting. Once a listing is corrected, we dont
need to do it again unless something unexpected (price being the main thing we expect to change)
changes in the next collection."*

Restated as four rules for the reviewer to test:
1. **Rules fill a structured field only when the fact is certain** (a barcode, a quantity the shop
   stated as a number, a currency the page declared, an option the shop published as its own field).
2. **When a rule could be wrong, the raw text goes into the most relevant plain field** (the variant
   name) and the structured field stays empty. This is the project's existing "empty beats guessed"
   applied to grouping and attributes, not only to values.
3. **An AI pass with reasoning proposes** brand, product line, variant grouping and attributes; a
   person confirms or corrects; the confirmation is decided data.
4. **A confirmed listing is not re-reviewed** unless its listed words change; a price change alone
   never triggers a review. (The 15 Sep decisions' "listed-changed tripwire", section 2.10, is this
   rule, and was scheduled for after launch.)

**Rian's plan, in his words.** *"My plan is to do all this before friday. it will be long nights. So
I want to do the database refactor the 'correct' way first so I can do the final ai assisted human
review by friday. So the answer is we should rebuild with proper scalable attributes now, shade being
one of those, then refactor the database in the new way as if they had been collected and ended up
with many product lines. We also build the AI assisted data corrector dashboard in the new way that
helps suggest what the product lines, brands, variants, and attributes should be, and then I'll go
through all do them and do the confirmations."*

The plan's parts, in dependency order as understood here:
1. The scalable attribute model (W7, W8): an open list of attributes per variant, per-category
   settings for "decides sameness" and "how the page shows it".
2. The consistency pass's words in the schema (W2), since rian wants the refactor done "the correct
   way first".
3. Re-derive the existing data under certain-only rules (W11 rule 1 and 2), accepting many product
   lines, with raw text in the variant name wherever a rule would have guessed.
4. The AI-assisted corrector: a model proposes brand, product line, variant grouping and attributes
   per brand; proposals are stored, never written into the standard or decided layer.
5. The corrector dashboard: a sheet per proposed product line with every variant and its proposed
   attributes; a person corrects cells and approves; approval writes decisions.
6. The re-review trigger (rule 4).
7. Product line pages at `/products/` (W1).
8. Rian's review.
9. The production launch chain (production is at 0.35.0; every migration since, in one pass).

**Facts found while recording this that constrain the plan.**
- **No model provider is wired in.** `pyproject.toml` and `app/config.py` carry no model client and
  no key setting. The corrector starts from nothing.
- **An API key has nowhere safe to live yet.** Both the app and the Postgres container read
  `.app.env` (`env_file: .app.env` on both services in `docker-compose.yml`), so a model API key put
  there would be readable by the database container. This is the open item
  `issue-the-mail-lines-...` (blocks W3, the compose split into an app-only env file, due 17 Sep),
  which the server's security rules require to be reviewed before any new secret lands. The
  corrector depends on it.
- **The shade tail Attenza publishes is the shop's own option field**, carried into the name as
  ` / 99 Pirate` by the collector. Under rule 1 it counts as certain, so the v5 shade rule for that
  one platform is arguably on the automated side of rian's line; Extime's and Avolta's shapes are on
  the proposal side.
- **Staging decisions are wiped by a refresh from production** (15 Sep decisions, section 6), and a
  confirm has no undo. If the review happens on staging, it must survive the move to production, or
  the review must happen on production after the launch chain.
- **The review's size.** 2,365 brands; 97 of them hold 53 percent of the 23,770 listings; the largest
  brand has 384 product lines.
- **Mark has not answered** on product line pages at `/products/`.

**The concern, stated once, as rian has decided to proceed.** Each of the nine parts is a stream of
its own on this project's usual pace (a single stream such as the 15 Sep identity rules took an
overnight session plus a day of review). Friday is two days away and also carries the readiness lane
and the production launch. The reviewer should produce a cut line: what must be true at launch for
the site to be honest and the review to be possible, what can follow in the days after, and what
order loses least if time runs out. Candidate cut lines the reviewer should weigh:
- **Launch on the new schema and certain-only rules, with product line pages, and review after.**
  Pages show more, thinner product lines at launch; nothing on them is guessed.
- **Launch with the review done for the top 97 brands only.** Most listings reviewed; a long tail
  unreviewed but honest.
- **Keep the current schema for launch, run the corrector and review against it, migrate after.**
  Faster to launch; the review's decisions must then survive a migration.

**Provisional outcome.** The four rules above are rian's decision. The plan is his; its sequencing
and cut line are for the reviewer.

**Worth challenging.** The cut line. Whether "the correct way first" should include renaming columns
two days before launch or only the new attribute structure. Whether the corrector proposes per brand,
per product line or per listing, and how it is given enough context to group (it must see all 59
Rouge Allure names at once). How approval is undone. How the review's decisions survive staging's
refresh. What the model is allowed to propose where the raw text does not state the value (the 15 Sep
rule: a proposal must cite the text it read, or it is dropped).

---

## The remaining 15 Sep items, in light of W10 and W11

Not walked through with rian one by one; summarised so the reviewer sees how the new principle
touches each. Each is still open to challenge.

- **Age stays in the product line** (15 Sep §2.4; rian had filed age as a determinant). Under W8 age
  is an attribute like any other; whether it decides sameness is a per-category setting. Macallan 12
  and Macallan 18 as one product line with age as a picked attribute, or two product lines, is a
  search-intent question the review sheet can answer per brand.
- **Pack words ("Triple Cask")** (§2.5). A rule fixed after a failure; under W11 rule 2 a name that
  might be a pack stays raw and the corrector proposes. The fix built on 15 Sep is still correct as a
  rule for the certain case (the quantity parser already states `form: pack`).
- **The flavour reader** (§2.6). Built to fire only on the marked tail; consistent with W11 rule 1.
- **One ledger for human decisions** (§2.2). Unchanged and more important: an approved review sheet
  writes decisions, and decisions must survive rederive and staging refreshes.
- **The publish gate on brand and product line pages** (§2.8: `checked` and `hidden`). Maps directly
  onto the review: an approved sheet is a `checked` product line. Whether unreviewed pages publish at
  launch is part of the cut line.
- **AI proposes, never writes** (§2.9). Kept; W10 changed the unit of review and moved grouping by
  model from last to central.
- **Re-checking only when a listing's words change** (§2.10). Now rian's rule 4 and part of the plan,
  not post-launch.
- **Places and shops** (§2.11, after Cannes). Untouched by W11; the "correct way first" instinct may
  argue for doing the place model in the same refactor, which the reviewer should weigh against the
  deadline.
- **The consistency pass** (§2.12). W2's rule; rian wants the refactor done correctly first, which
  pulls some of it before launch.
- **Still open** (§6): the missing undo (now urgent, since the plan is a large review); the Spanish
  shelf words (the corrector may make them moot); staging refresh wiping decisions (urgent for the
  same reason).
- **What rian does next** (§4, and the two traps): never run `propose --file` on production with
  staging proposal files; decisions taken on staging are wiped by a refresh. Both matter more now.

---

## Note: launch date

Rian, after W11, 16 Sep: *"yes, I know this is a lot to change. i'm worried about it too. but I need
to get this right. i'll probably tell adam we need to wait for monday."* Not decided; if the launch
moves from Friday 18 Sep to Monday 21 Sep, the W11 cut line gains a weekend, and "get it right" is
his stated priority over the date.

---

## W12. Age: an attribute of one product line, or separate product lines

**What was on the table.** 15 Sep §2.4: age and vintage stay in the product line name. Rian had
filed age as a determinant. The reasons given: no rule lifts it out (drinks keep every expression
word, after a stopword list once deleted ages and put a medal on the wrong bottle); 579 drinks with
an age statement in 207 families; lifting it would be the largest rederive yet; and search intent,
since a page for "Macallan Sherry Oak" with an age selector would rank for none of "Macallan 12",
"Macallan 18". Marked reversible, to revisit with search data after launch.

**How W8 and W11 change the question.** Under W8, age is simply an attribute; there is no separate
category of "determinant". Under W11 rule 1, "12 Years Old" is clearly spelled out, so extracting the
age is certain. So extraction is no longer the issue. The remaining question is purely **grouping**:
is Chivas Regal 12 and Chivas Regal 18 one product line with age as a picked attribute, or two
product lines?

**Evidence from the data, 16 Sep.**

| Brand | Product line today | Variants | Price seen (USD) |
|---|---|---|---|
| Chivas Regal | 12 | 8 | 16 to 99 |
| Chivas Regal | 15 | 2 | 57 to 107 |
| Chivas Regal | Xv 15 | 1 | 77 to 94 |
| Chivas Regal | 18 | 2 | 22 to 137 |
| Chivas Regal | 18 Gold Signature | 1 | 175 |
| Chivas Regal | Gold Signature 18 | 2 | 30 to 139 |
| Chivas Regal | 25 | 1 | 288 to 444 |
| Glenfiddich | 12 | 2 | 51 to 74 |
| Glenfiddich | 12 Guatemalan | 1 | 82 to 83 |
| Glenfiddich | 15 Oloroso Sherry | 1 | 95 to 96 |

What it shows:
- **The product line name is often just the age** ("12", "25"), because the brand is stripped and the
  age is all that remains. A page titled "Chivas Regal 12" reads fine; a product line named "12" in
  the review area does not.
- **Price differs sharply by age** (Chivas 25 is several times Chivas 12), and shoppers search by age.
- **Spelling and word-order splits recur here too**: "18 Gold Signature" and "Gold Signature 18" are
  two product lines; "15" and "Xv 15" may be one. More evidence for W10.
- The low minimums (16, 22, 30 USD) are almost certainly miniatures or mislabelled sizes; a size
  attribute problem, not an age problem.

**Options.**
1. **Age stays in the product line** (15 Sep). One page per age: "Chivas Regal 12", "Chivas Regal
   18". Matches how people search and how brands market; many product lines.
2. **Age is a picked attribute of one product line** ("Chivas Regal" with 12, 15, 18, 25). Fewer,
   richer pages; a single page competes for every age search; the whisky world treats a 12 and an
   18 as different whiskies, not sizes of one.
3. **Per brand, decided on the review sheet.** Some houses market one range with ages (Glenfiddich
   12, 15, 18), others distinct expressions that happen to carry an age (Chivas Regal 18 Gold
   Signature). The corrector proposes; a person decides per brand.

**Rian's answer, 16 Sep, and the larger point in it.** *"this is something that should be decided in
the review and AI would suggest it. My answer right now would be lets do these ones as different
product lines, but I want that built into the review process not just a one off decision I'm making
via chats whenever we happen to be chatting about it. This is a process issue, not a specific answer.
But either way, I think age should be an attribute. Even if we have a Chivas Regal 12, there is only
benefit to having the age as structured data. We could then potentially look at all the 12 year old
whiskeys from different brands or product lines."*

**Provisional outcome.**
1. **Age is always a structured attribute**, extracted wherever it is clearly stated, whatever the
   grouping. Structured data and the product line's name are independent: "Chivas Regal 12" can be
   the product line's name *and* every variant under it can carry `age: 12`. The name is for people;
   the attribute is for queries ("every 12 year old whisky", across brands).
2. **Grouping is decided in the review process, not in conversation.** The AI proposes a grouping; a
   person confirms or changes it per brand or per product line. The current default the AI should
   propose for aged spirits: **one product line per age**.
3. **A standing process rule (rian's): a grouping judgement like this is encoded as a default the
   review process applies and a person overrides, never as a one-off answer given in chat.** This
   generalises past age: shades, finishes, limited editions, gift sets and every future category's
   grouping questions belong to the same mechanism.

**Worth challenging.**
- **Where the grouping defaults live** so they are inspectable and editable by rian rather than buried:
  prompt text in code; a per-category guidance file or table the corrector reads; or learned from the
  person's past confirmations (the corrector shown earlier approved sheets as examples). Each trades
  transparency against effort.
- **How a default changes over time** without silently regrouping what a person already approved
  (the W9 lesson: a machine must never undo a person's decision).
- Whether grouping should follow search demand, price, or the brand's own range structure, and
  whether wine vintage behaves like whisky age.

---

## W13. How the AI pass actually happens in this phase

**Rian's clarification, 16 Sep.** *"in the short term I dont plan to have an AI accessible via API.
I'm just going to do a pass here on staging with claude so I can get my first batch reviewed and
approved by me. Then I'll worry about creating the AI assisted QA process with written rules/prompts
etc as part of the next phase. That will replace the need to do it here on mosiah with probably a
smaller local LLM with highly optimized prompts accessible via API."*

**What this removes from W11.** No model provider in the app and no API key for this phase, so the
dependency on the app-only env file (W11) does not block the first review.

**What it adds, for the reviewer to design.**
- **A sanctioned way in for proposals.** A Claude session's proposals must enter through a command
  that writes to a proposals store the dashboard reads (never hand-written SQL), so each proposal is
  recorded with its source text, reasoning and origin, and can be withdrawn as a class if a batch
  proves wrong.
- **Carrying approvals from staging to production.** Two known traps make this load-bearing: a staging
  refresh from production wipes every decision taken on staging (15 Sep §6), and staging ids are not
  production ids (the `propose --file` trap, §4). Approvals must be keyed by natural keys (brand by its
  slug; product line by brand and name; listing by shop code and the shop's own SKU) and replayed on
  production, or the review happens after the launch chain on production.
- **Writing the pass's instructions down now.** The rules and grouping defaults the Claude pass uses
  (W12: one product line per age; W11: raw text where unsure) should be written to a file in the
  workspace as they are used, so the next phase's automated process starts from rian's approved
  guidance rather than re-deriving it. This is the concrete answer to W12's "where do the defaults
  live" for this phase.
- **Batching for a session.** 23,770 listings; per brand is the unit that lets a grouping be seen;
  97 brands hold 53 percent.

**The later phase, noted for its own review.** A local model on mosiah serving an API is a network
service: under the server's security rules it needs a port bound to the Docker bridge, authentication
and a security audit; and it reverses the 15 Sep decision of no model sidecar (§2.9 point 5) and the
stack rule of one Python process per app. Not for now; flagged so it is decided deliberately.

---

## W14. "Triple Cask" is a symptom: product line names are built by deleting words from a list

**The question.** Rian, on the fix that only strips "triple" when the name reads as a pack: *"The
triple cask issue is actually what made me feel squeamish when I saw it. It feels like another case
of treating symptoms instead of fixing the process. The same thing may go for double, quadruple, or
many other cases that I can't even think of right now. That's why I'm trying to focus on trying to
fix the process so that mistakes are less likely to get baked in."*

**The mechanism, traced.** A product line's key is the listed name with words deleted: the brand,
the quantity, and every word on a set of hand-maintained lists. Counted on 16 Sep:

| Module | Lists | Words |
|---|---|---|
| `services/lines.py` | `_DRINK_WORDS` (72), `_FORMAT_WORDS` (66), `_SKIN_TYPE_TAILS` (19), `_ARTICLES` (14), `_CASK_WORDS` (8), `_CONCENTRATIONS` (5), `VARIATION_KINDS` (6) | 190 |
| `services/normalize.py` | `_BRAND_TRAILERS` (45), `_STOPWORDS` (35), `_LEADING_FILLER` (6) | 86 |

About 276 words, each a guess that the word never distinguishes one product line from another.

**Every incident so far is the same failure.**
- The catalogue's stopwords deleted "original", "reserve" and ages, and a medal landed on the wrong
  bottle (recorded as a rule in `agents.md`).
- "Triple" on the format list turned "1800 Anejo Triple Cask" into `anejo cask` and "Macallan Triple
  Cask 12" into `cask 12`, where a plain "Cask 12" would have joined it.
- With the brand and drink words deleted, product lines named just "12", "18", "25" (W12).
- "Joh." not matched as Johnnie Walker, so "Joh. Walker Blue Umami" became its own product line (W1).

Each was fixed by editing a list after someone noticed. Rian's point stands: the lists can only ever
be corrected after a failure, and the failures are silent. There is a second consequence the reviewer
must weigh: the same key drives the **automatic merge of variants without barcodes**, so a wrong
deletion can join two different products without anyone seeing it.

**Options.**
- **A. Keep the lists; add each missed word** (today). Symptom-fixing, by definition.
- **B. Certain-only keys.** The key removes only what is certain: the brand as matched to the brand
  row, a quantity the parser read as a stated number, an option the shop published as its own field.
  Every other word stays. More and longer product lines; grouping proposed by the AI and confirmed by
  a person. The direct application of W11 rule 2.
- **C. No derived grouping at all.** Product line membership is only ever a decision: each listing
  starts as its own raw product line (or grouped only by barcode), and the AI proposes every grouping.
  Most honest; most review load; automatic merging of variants without barcodes stops entirely.
- **D. Keep the lists as proposals, not actions.** A deletion from an ambiguous list (drink, format,
  cask words) is recorded as a suggested removal for review, never applied silently; certain removals
  apply. Keeps the knowledge in the lists while stopping silent errors.

**Provisional outcome.** Rian rejects A as a process and leans to **B or D**: *"let fable help find
the right balance of human review requirement vs automation. Especially for launch, after launch we
will have more time to do human reviews."* So the reviewer is asked for the balance, with one
constraint on it: **the launch configuration should minimise the human review needed before launch**,
and the balance may shift toward more review afterwards. C (no automatic grouping at all) is not
favoured.

**Worth challenging.** What happens to automatic merging of barcode-less variants under B or C, and
how much review load that adds (6,896 of 16,761 variants have no barcode, per 15 Sep); whether items
7 and 8 (the "Triple Cask" and flavour fixes, built 15 Sep) should ship at all if the process changes,
or ship as harmless interim fixes; how the certain/uncertain boundary is itself reviewed so it does
not become the next list.

---

## W15. One place for every human decision

**What was on the table.** 15 Sep §2.2, built on this branch: every human decision over a *value* is
one row in `overrides` (which thing, which field, the decided value, the value the rules had, who,
when, why), and the thing's own column carries the effective value for fast reads. Two kinds of
decision keep their own record: a decision about a **pair** (confirm same, keep separate) lives on the
suggestion row, and a **variant merge** is a `product_merges` row. The history of changes is
`audit_log`. One row per field holds the current decision; a second decision replaces the first. Built
before any decision exists: staging has 0 rows in `overrides` on 16 Sep.

Fields the ledger accepts today:

| Thing | Fields |
|---|---|
| product variant (`product`) | name, product line, one variation, quantity |
| brand | name, alias pointer, review |
| product line (`line`) | name, alias pointer, review |
| option wording (`variation_alias`) | canonical, display |
| listing | pin, ignore |

**How the walk-through strains it.**
1. **Attributes (W7, W8).** The fields are fixed names ("one variation", "quantity"). An open list of
   attributes needs a decision per attribute kind (age, shade, ABV, material) without a code change per
   kind: a field such as `attribute:age`, or a structured value.
2. **Row ids (W13).** Decisions are keyed by row id, and product line rows are derived and change when
   rules change. Carrying approvals from staging to production needs natural keys, which the ledger
   does not store.
3. **Three records, and one of them is ignored (W9).** A keep-separate on the suggestion row is never
   read by the automatic merge, so a machine undoes it.
4. **Review sheets (W10, W11).** Approving one sheet writes many decisions at once (a product line,
   each variant's membership, each variant's attributes). There is no identity for "this sheet's
   approval", so it cannot be reviewed, withdrawn or undone as a unit, and no undo exists at all.
5. **Where a decision came from.** A value typed by a person and a value a person confirmed from an AI
   proposal are different evidence; §2.9 said a confirmed proposal records its origin, so a bad batch
   can be found. Not built.
6. **Naming.** The column `collected_value` holds the value the *rules* had, not the collected one
   (consistency list, W2).

**Options.**
- **A. As built.** One current-state row per field; pairs and merges separate.
- **B. One decisions record for everything**, typed (field value, pair, merge, sheet approval), so
  "every human decision" is literally one place.
- **C. Append-only decisions**, with the current value derived from the latest and undo written as a
  reversing decision. History and undo come free; reads need the effective column kept up to date.
- **D. As built, extended**: a batch or sheet id on every decision, natural-key columns alongside ids,
  an origin (person, confirmed proposal), attribute kinds as fields, and the automatic merge reading
  pair decisions.

**Rian's answer, 16 Sep.** *"This one I dont really comprehend completely, but I like the idea of
being able to keep track of all decisions and undo them. And I like the idea of tracking which AI pass
made the suggestions."*

**Provisional outcome.** Requirements, not a shape: **every decision is kept (nothing overwritten
without a trace), any decision can be undone, a whole batch can be undone together, and every decision
records which AI pass proposed it** (with W16, that last one is essential rather than nice to have).
Options C and D both satisfy it; the choice is the reviewer's. A plain-language picture that rian can
hold: decisions work like tracked changes in a document, each change a line saying what, who, when,
why and which AI pass suggested it; undo adds a line reversing a change; one click can reverse every
line a given AI pass produced.

**Worth challenging.** Whether replacing a decision in place loses history the review process needs;
whether undo should be a first-class operation before rian's first review; whether decisions should be
keyed by natural keys only.


---

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

**Rian's reframing, 16 Sep.** *"The reality is that I'll probably trust a good AI like Fable to make
the right calls, so it's not necessarily going to actually be a human sitting there approving
everything. A human may review the first several dozen, then start to say 'AI is getting them all
right, I'm just going to approve it all'. The idea though is that the PROCESS is there to do these
reviews. We dont want to risk the mistakes programmatically during collection, but rather just focus on
the collection and building the process for grouping and correcting... So essentially the AI is doing
the work and making the decisions and following the process that a human could also follow. The human
is likely going to just eyeball it at first and then just say 'approve all'."*

**What changes.** The review process is designed so a person *could* follow every step and check any
of them, but the expected operation is: the AI proposes and reasons through the process; a person
spot-checks early output, gains trust, and approves in bulk. Collection only collects; grouping and
correction are the process, never collection-time rules that might be wrong (W11, W14).

**Consequences for the reviewer.**
- **Batch undo and pass provenance become essential** (W15). If most decisions are bulk approvals of
  one AI pass, the only safe recovery from a bad pass is withdrawing that pass's decisions as a unit.
- **A bulk approval is weaker evidence than an individual review.** The standing rule is that a
  person's decision is never overwritten by a machine. If thousands of decisions are "approve all" of
  AI output, locking every one against a later, better AI pass may freeze early mistakes in place. The
  process may need two strengths: *reviewed* (a person looked; never overwritten) and *approved in bulk*
  (a later pass may propose a change, shown to a person, never applied silently).
- **What the person checks.** "The first several dozen" is a sample. A process that picks what to
  spot-check (the lowest-confidence proposals, a random sample per brand, anything that merges
  variants or changes a price comparison) makes bulk approval safer than the first dozens alone.
- **The process must be written down to be followable** (W13: the pass's instructions as a file), or
  the claim that a human could follow it is untestable.

**Provisional outcome.** Rian's model of operation, as stated.

**Worth challenging.** The two strengths of decision; the sampling rule; whether a bulk approval
should expire or be re-checked when the process's instructions change.

---

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

**Rian's plan, 16 Sep.** *"my plan for soft launch is just to push the entire thing to production,
database and all. Is there a reason that is the wrong thinking? No one has seen the site yet on
production, the only reason we have it live was to test the collectors. I'll run a fresh collection and
do all the approvals on staging, then push to production, and only after we launch do we need to worry
about the two falling out of sync."*

**Assessment: sound.** It removes W13's hardest problem (carrying approvals from staging to production
by natural keys) for the launch, because production simply becomes a copy. Facts found while checking:
- **A documented version already exists**: `deploy/production.sh --seed-db <dump>`, described in
  `RUNBOOK.md` as "first deploy only: restores the dev server's nightly dump into the empty production
  database."
- **Collecting on staging is equivalent to collecting on production.** The 10 Sep egress test
  (`.logs/runs/egress-2026-09-10.md`) fetched every retailer from both machines; every host answered
  the same from each (Dubai refuses both).
- **The client's comments live on staging** (192 comments), so copying staging carries them, which
  is the right direction.

**Risks, each with its handling.**
1. **The seed step swallows errors and assumes an empty database.** It runs `pg_restore --clean
   --if-exists --no-owner ... || true`: any failure is ignored. Production is not empty (collections
   ran there) and is on an older schema (0.35.0). The staging refresh learned that a table-by-table
   `--clean` can fail on tables one database has and the other lacks, and drops and recreates the
   whole schema instead. The push should do the same, stop on any error, and compare row counts after.
2. **Order.** Production must be running code of the same version as the staging database, or the old
   app meets a new schema. Deploy the code, then restore, then start, then check `alembic current`.
3. **Live sessions come along.** Staging has 14 unexpired sign-in sessions. After the copy, those
   tokens would be accepted on production. Revoke every session (and any unused welcome or reset
   token) immediately after the restore; people sign in again.
4. **Production's own collected data is discarded.** Acceptable by rian's plan (a fresh collection on
   staging replaces it), provided nothing collects on production between the staging collection and
   the push, and a production dump is taken first as the rollback.
5. **Source settings are staging's.** Per-source kill switches, crawl delays and permission records
   come from staging; confirm they match what production should run (for example Singapore's 30
   second pace was set on both).
6. **After launch the direction reverses for good.** Production becomes the source of truth; staging
   refreshes from production; and every later review faces W13's natural-key problem again, so the
   review process should be built with that in mind even if the launch avoids it.

**Provisional outcome.** Rian's plan, with the six handlings above as its checklist.

**Worth challenging.** Whether to build the push as a proper script now (schema drop and recreate, stop
on error, row-count comparison, session revocation) rather than reuse `--seed-db`.

---

## W18. The publish gate on brand and product line pages

**What was on the table.** 15 Sep §2.8, partly built: brand pages and product line pages carry a
review state a person sets, **`checked`** (a person looked: one brand or one product line, a clean
name, members that belong) or **`hidden`** (a person took an eligible page off the site), recorded in
the decisions ledger. A page's eligibility stays mechanical first (a brand needs three or more variants
priced at two or more airports, the 9 Sep floor; a product line needs one publishable variant). **Live
means eligible and not hidden**: the gate is opt-out, and `checked` is a stamp that changes nothing on
the site. A stamp goes stale as collections add members ("changed since checked"). An opt-in mode
(`PAGE_REVIEW=required`, only checked pages publish) was left for later. Built: the ledger field, the
CLI (`app.cli review brand|line <id> checked|hidden|clear`), the brand floor honouring `hidden`. Not
built: the review column on the desk, the staleness mark, opt-in.

**Why opt-out was chosen on 15 Sep.** *"An opt-in gate on 2,365 brands the day before launch would
publish nothing."* Rian's words then: *"candidate product pages and brand pages and a human would go in
and check it ... then hit publish"*, and that no single human can digest it all.

**How the walk-through changes the inputs.**
- **W1:** product variants no longer have pages; product line pages and brand pages are the pages.
- **W10, W11, W16:** a full AI-proposed review is planned before launch, approved in bulk, so the
  "nothing would publish" objection to opt-in depends on whether that review finishes, not on a person's
  capacity.
- **Launch may move to Monday** (note after W11).
- **Scale, 16 Sep, on staging:** about 413 brands meet the brand floor today (brand rows with aliases
  followed; three or more variants priced at two or more visible airport shops). Product lines with a
  publishable variant: 3,183 before the shade rule, 2,848 after it, rehearsed 15 Sep; the certain-only
  rules of W14 would raise that number again.
- **An approved review sheet is, in effect, a `checked` product line.** The two mechanisms are one.

**Options.**
- **A. Opt-out (as built).** Every eligible page is live unless hidden; `checked` records coverage.
  Launch never waits on review; unreviewed pages go live with whatever the rules made of them.
- **B. Opt-in.** Only checked pages publish. Nothing unreviewed is public; launch shows exactly what
  was approved; an unfinished review means a smaller site.
- **C. Opt-in for product line pages, opt-out for brand pages** (or the reverse). Brand pages change
  little with grouping; product line pages are where W10's errors show.
- **D. Opt-in at launch, opt-out after**, or opt-in only for brands above a value threshold (the 97
  brands with 53 percent of listings).

**Questions the reviewer must answer whichever option.**
- With W16's two strengths of decision, does a bulk approval count as `checked`?
- When a checked page gains a new member at the next collection, does it stay live (opt-out
  thinking) or leave the site until the new member is approved (opt-in thinking)?
- What a shopper sees at an unpublished page's address (the 15 Sep answer for a brand: a redirect to
  the brand's variants rather than a 404).
- Words: `checked` against "approve", "Confirm same" and "hidden" against a listing's "ignored"
  (consistency list, W2).

**Rian's answer, 16 Sep: three steps, separating "exists" from "indexed".**

> *"1) the product line gets a url immediately on collection. this is so that we get the most
> comparables possible. comparing a shade in 13 airports is good, even if they are all on their own
> line. HOWEVER, by default product lines are no-index. Same goes for brands. give them all a home
> whenever they are discovered, but noindex them by default.*
> *2) the quality check starts merging these pages. When we set an alias for a brand or product line,
> a final slug is selected (based on the brand name or product line name) and the old ones redirect to
> the new. BUT, we're still in no-index mode.*
> *3) we have rules for candidate SEO pages. That's where the 3+ variants priced at 2+ airports come
> in. Those do not get their no-index removed by default, but rather become suggested candidates in a
> dashboard.*
> *Same would go for airport pages. No-index by default when we create them. There's a final approval
> by a human when we are ready to remove their no-index."*

**Provisional outcome.** Replaces all four options above. Every generated page has three independent
facts, not one gate:
1. **Reachable**: a brand, product line or airport page exists from the moment it is discovered, so
   every comparison is usable at once, even on an unmerged product line.
2. **Quality**: the review process (W10, W16) merges pages; confirming an alias fixes a final slug
   from the chosen name and the old addresses redirect to it.
3. **Indexed**: default **noindex**. Candidate rules (the 9 Sep floor of three or more variants priced
   at two or more airports, and equivalents for airports) produce *suggestions* on a dashboard; a person
   approves each page's indexing.

**Facts that make it largely additive, checked 16 Sep.**
- The page head builder already takes a `noindex` flag (`seo.py`), used today for owner surfaces; the
  robots policy already keeps such pages crawlable but noindexed rather than disallowed, which is
  correct (a disallowed address can still be indexed by its URL; a crawled noindex cannot).
- A merged-away variant already answers with a 301 to its survivor (`main.py`, through
  `resolve_product_id`). An alias row is already a forwarding address (W4); under this model it is also
  the source of the redirect.
- The 9 Sep brand floor is already one definition in `catalog_queries`; it becomes a candidate rule
  instead of a publication gate.

**What it resolves.** The 9 Sep decision to hold single-price products out of publication existed to
avoid thin pages in search. Noindex answers that concern directly, so every product line can be
reachable (rian's "most comparables possible") without thin pages reaching search engines. The opt-in
versus opt-out argument disappears: reachability is always on, indexing is always opt-in.

**Worth challenging.**
- **`noindex, nofollow` versus `noindex, follow`.** The existing flag writes `noindex, nofollow`. For
  reachable-but-unindexed catalogue pages, `nofollow` would stop crawlers following links from them to
  indexed pages; `noindex, follow` is likely right. Mark's call.
- **Slug stability.** A slug is final at alias confirmation, but a page may be renamed after it is
  indexed; redirects must stay flat (A to B then B to C must send A straight to C), and renaming an
  indexed page should itself be a reviewed act.
- **The sitemap and IndexNow** must list only indexed pages and ping only on an indexing approval.
- **De-indexing.** When an indexed page falls below its candidate rule (a shop drops a variant), is it
  suggested for removal, never removed automatically (W16's principle)?
- **Hidden.** Whether a truly wrong page also needs to be unreachable, not only noindexed (the 15 Sep
  `hidden` state), as a fourth fact.
- **Which page types it covers.** Brands, product lines and airports by rian's word; category pages,
  category-at-airport pages and articles need the same answer. A human-written article may reasonably
  index on publish.
- **Shoppers on a single-shop page.** Reachable is not the same as helpful: what a product line page
  with one price and nothing to compare should show.
- **Mark.** This changes the SEO structure he reviewed on 9 Sep, in a direction that reduces risk; he
  should see it.

---

## W19. Places and shops

**The question, from rian on 15 Sep** (the evidence is in `places-and-shops-2026-09-15.md`): *"there
can be multiple shops per airport... Adam is talking about cruise ports and maybe from there we go
further into things like outlet malls... calling it airport is boxing us in, especially with things
like airport hours. I think we need a location and a shop, where shops belong to a location. Also, I
wonder if shops could belong to multiple locations... are things like Avolta actually a chain of shops
that appear in multiple airports?"*

**What the 15 Sep decisions settled (§2.11), scheduled after Cannes.**
- A **place** is a row with a **kind** from a code registry (airport, cruise port, border crossing,
  mall, ferry terminal as comparable kinds; terminal and city for nesting only), a slug of our own as
  identity, and the IATA code as an optional attribute. A new kind is a deploy, not a migration.
- A **shop** (today's `locations` table) serves one **primary** place, always of a comparable kind, and
  may serve others (Extime: primary Paris CDG, also Orly). The Heinemann online catalogue serves none.
- **The comparison unit is a shop counted once per primary place**, defined once and called by every
  counting query, with a test that fails on any surviving count of shop rows. Two Heathrow shops
  pinned to terminals count as one place.
- Places nest (Paris over CDG and Orly; an airport over its terminals).
- Addresses per kind: `/airports/` stays as settled with Mark; ports get their own prefix.
- `retailers` becomes the company: Heinemann's four rows become one retailer through an alias.
- Articles and subscribers gain a place reference beside their IATA strings.
- Currency stays on the shop. The BorderShop at Puttgarden (1,806 listings, hidden for want of a place)
  is the first non-airport place and the acceptance test.

**Answers already given to rian's own questions (15 Sep).** Yes, Avolta is a chain: one retailer row
with ten shops; Motta has three. A physical shop is at one place, but what we collect is a storefront,
which can serve several places (Extime) or none (the online catalogue), so the storefront-to-place link
is many-to-many.

**How the walk-through bears on it.**
- **Timing against W11.** Rian wants the refactor done "the correct way first" before launch, and
  scaling past airports was one of his reasons for doing it now. §2.11 put places after Cannes. Doing
  it in the same refactor avoids migrating a second time; doing it later keeps the pre-launch work
  smaller.
- **W18.** Places are pages under the same three steps: reachable on discovery, noindex by default,
  indexing approved by a person. The review process (W10) also applies: whether two shops are at one
  place, or a shop's primary place, is a grouping judgement proposed and confirmed like any other.
- **W2.** The table `locations` holds shops; "location" is retired as a word; the rename belongs to
  the consistency pass.
- **A live defect it fixes.** Sixteen queries count shop rows where one counts airport codes, so a
  second shop at one airport would silently make the savings table claim cross-airport comparisons
  that are one airport (15 Sep issue). The place model's "defined once" rule is the fix; if places are
  deferred, that defect should still be fixed before any second shop is added at an airport.

**Options.**
- **A. Places after Cannes, as decided.** Smaller launch; one more migration later; the counting
  defect fixed separately.
- **B. Places in the pre-launch refactor.** One migration; the model is scalable from day one; larger
  pre-launch work.
- **C. The schema before launch, the pages after.** Create places and the shop-to-place link, backfill
  one place per airport code, and route every count through the one definition; keep `/airports/` pages
  as they are; add port pages and nesting later.

**Rian's answer, 16 Sep.** *"C, let fable decide the details. That said, I want to stress that I'm not
trying to plan for cruises here, I'm trying to get to a broad structure that will be most flexible. Yes,
cruises are next, but who knows where we're going after that... I dont want to lock us in to a
structure that is only relevant to the things we can think of right now (airports and cruise
ports)."*

**Provisional outcome.** Option C: the place structure in the pre-launch refactor, pages for new kinds
later, details for the reviewer. And a design principle that reaches beyond places (see "Designing for
kinds nobody has thought of" below).

**Stress tests for the place model, derived from that principle** (not plans; cases a flexible
structure should hold without a schema change):
- a shop with no fixed place (online only; a catalogue);
- a shop in a place that moves (a ship, an aircraft's in-flight duty free);
- a place inside a place inside a place (a terminal in an airport in a city);
- a temporary place (a pop-up with dates);
- a place that spans two countries (a border crossing), or has two currencies;
- a place identified by a scheme we do not know yet (IATA for airports, UN/LOCODE for ports, none for a
  mall): identifiers as an open list of (scheme, value), not an `iata` column;
- a kind whose pages should not exist at all, or should exist without comparisons.

**Worth challenging.** Whether the comparison unit should be the primary place or the retailer within a
place (a mall with sixty shops); whether a shop needs a primary place at all; what a cruise port's
"hours" mean (sailing days, not opening hours).

---

## W20. What price a product line page shows

**The question.** 15 Sep §2.7 said a product line card prices one named "representative" variant and
never shows "from $X". Put to rian with a second question: what a product line page shows when only one
shop carries it.

**Rian's answer, 16 Sep, by case.**
1. *"the simplest is that there's only one shop and only one variant and one price. so show that price
   and the shop it is at."*
2. *"there is the case where there are multiple variants. The user needs to select a variant to see a
   price."*
3. *"there is the case where there are multiple prices but the user hasn't selected any airports. the
   user can select the airport they want to see the price for."*
4. *"there is the case where there are multiple prices but none match the airports selected. A message
   says no listings on those airports but they can select an available airport to view the price."*
5. *"there's the case where there are multiple airports and one or more of them are selected by the
   user in their settings, so show the price or comparison based on the airports that stock them."*

And the principle: *"ideally we're trying to show the price at airports that the user will be at, but
we're not guessing unless there's only one place where the variant exists anyway."* He noted there are
probably more cases, and that case 1 answers the single-shop question.

**Provisional outcome.** A price is shown only when it is unambiguous: one variant at one place, or a
variant the shopper chose at a place the shopper chose (or the only place it exists). Otherwise the page
asks: choose a variant, choose an airport. The 15 Sep "representative variant" survives only for
**cards** in grids (a card must show something), not for the page.

**Worth challenging.**
- **Cases not listed:** one variant at several places with no airport chosen (is that case 3?); a
  variant at one place when the shopper chose other airports (does "only one place exists" override
  their choice?); a catalogue-only listing (collected, never compared); a stale price (the date must
  show); a variant that is not comparable anywhere.
- **What a search engine and a first-time shopper see.** The chosen airports live in the shopper's
  browser, so the server-rendered page and a crawler always see "nothing selected". An indexed product
  line page (W18) may then carry no price at all. Structured data can list every offer with its shop
  and date without choosing one, which is not guessing. Mark's view is needed.
- **Comparable cards (see the reviewing session's instructions).** A card now deep-links into a product
  line page with its variant preselected; which airports the card's comparison used should carry across
  so the page shows the same comparison the card promised.
- **"From $X" stays refused** (a family's cheapest presented as the family's price), including on cards.
