# Decisions and actions for rian
<!-- GENERATED by main/scripts/items.py export on 2026-09-18 from the running app and import/items.json. Do not edit: add or resolve items with the script; rian acts on them on /plan (Issues and to-dos). -->

Last swept 2026-09-18.

## Open: decide or answer

- **Which page types ship on 18 Sep** (due 2026-09-10; raised 2026-09-05 by plan review; id `decide-page-types-18-sep`)
  Either all six page types where the coverage thresholds are met, with beauty categories switching on as their coverage crosses the bar, or category and category-at-airport pages move to the buffer week. One sentence, which also goes on the structure page and into the note to Mark. Mark's comments are due Thu 10, so decide after reading them.
  Options: all six where thresholds are met; beauty switches on as it qualifies · category pages in the buffer week
  Gates: B6 (blocking) — blocks B6 (Category and category-at-airport pages (per §10 #10)), due 2026-09-15
- **The built JavaScript is public while the site is members-only, and it carries the client pages' static copy** (raised 2026-09-10 by Stream R; id `decide-the-built-javascript-is-public-while-the-site-is-members`)
  The hashed build assets under /assets are public in every mode, by the accounts plan (no data). But the lazily loaded chunks for the quote, structure and review pages embed those pages' static copy (the proposal lines, the structure prose, the feature board), so a determined anonymous visitor who reads the main bundle for chunk names could read Adam's and Mark's pre-launch material without signing in. Prices and every database read stay behind the login. Two honest options: leave it (the copy is a proposal, not the data; the chunk names are hashed and unlisted) or, before the domain is shared more widely, serve /assets behind the session while members-only, keeping only the main bundle and the sign-in chunk public. The second is an afternoon in the access policy plus a test.
  Away plan, 11 Sep: the assumption flips to gating them, as Stream W's W2 (an anonymous request for a lazy chunk answers 401 in members mode and 200 in public mode; the entry bundle and the sign-in chunk stay public; a test pins both). To overturn: leave them public; reverting is deleting the rule and its test.
  Options: gate the lazy chunks behind the session while members-only (assumed) · leave them public until launch
  *Proceeding under:* gated behind the session while members-only; the entry bundle and the sign-in chunk stay public
  Gates: W2 (costly) — blocks W2 (The lazy chunks gated behind the session while members-only; the main bundle and the sign-in chunk stay public; a test pins both), due 2026-09-16
  See `.logs/planning/streams/AWAY-PLAN.md`
  See `.logs/planning/streams/W-readiness.md`
- **Approve the airport template on Heathrow** (raised 2026-09-18 by Stream G2; id `decide-approve-the-airport-template-on-heathrow`)
  The template is on staging at /airports/heathrow-lhr-london. Read it with the placeholder view both on and off (Settings). What it does now: the standing facts and four counts in one panel; what decides how you shop here (key facts, special offerings) as two cards; where to shop as one row per terminal that opens in place, each carrying its shops and what is worth buying; best value as one card per family with a link to all of them; then the products; then the allowances and the tips under the author's own titles. A contents line under the hero names the sections. Every part we mean to have and do not have draws a placeholder the size and shape of the real thing, with what to gather on hover.
  Measured on staging: desktop 4,938px with the view off, against 996 text lines of body before this stream and 840 now, while carrying more of Adam's material than the page has ever held. On a phone, 4.6 screens before the products, down from 6.7.
  Options: Approve as shown, and the other eighteen airports are converted and imported against it · Change something first; any change applies to all nineteen by construction, so it costs nothing to ask now
  *Proceeding under:* Proceeding as approved: the conversion of the eighteen (G13) is built against this shape, and a later change still applies to all nineteen because they are data, not code
  Gates: G14 (blocking) — blocks G14 (The eighteen imported on a staging copy, every page rendered and measured, thin airports named, the after-deploy import list, Adam's verification list as a do), due 2026-09-21
- **Ratify four standing rules the sessions are already working under** (raised 2026-09-05 by plan review; id `decide-ratify-standing-rules`)
  1. The BLOCKED rule: a shop that refuses us stays public on its last-seen prices with the date shown and a flag, rather than disappearing (Dubai is the big case).
  2. The robots policy: a robots file that answers 404 means open; 401 or 403 means refused; a server error means stop and retry later.
  3. Host cron for the nightly audit and the verify runs is owned by Stream E, and the collectors doc states plainly that this is not automated collection; collection stays hand-run, as told to Adam.
  4. The pages-2-and-later observations at the four Avolta stores that forbid query pagination were collected against their robots before the fix: keep them, dated, or purge them.
  Write exceptions if any; otherwise "all four".
  Options: all four as stated · exceptions (write them)
  *Proceeding under:* all four
  Gates: A13 (costly) — the work it gated has gone ahead under the assumption; settling it now avoids a costlier change later
- **The owner sign-in stopgap: one password and one secret in the environment, until Stream R's accounts** (raised 2026-09-09 by Stream N; id `decide-the-owner-sign-in-stopgap-one-password-and-one-secret-in`)
  Build plan section 4 item 9 asked for a default-deny middleware first, and Stream R's accounts (migration #4, argon2, login screens) may run after 18 Sep. Landed tonight inside the overnight constraints (no migration, no new dependency, .app.env untouched): every write route is refused unless main/app/services/mutations.py declares it public (the client surfaces, unchanged for Adam and Mark) or owner with the owner session held. The session is a signed cookie (HMAC over an expiry, stdlib), Secure on https, HttpOnly, SameSite=Lax, twelve hours; the login compares in constant time and locks out after five attempts per address per quarter hour; every write checks its Origin against the site's own host.
  What it costs you: owner actions (the kill switch, decide/done/dismiss/reopen on the running list, adding or resolving decision cards in curator mode) are refused on every host until OWNER_PASSWORD (twelve characters or more) and SESSION_SECRET (thirty-two or more, random) are in .app.env and the container is recreated. Placeholders are staged in .app.env.example; the steps are in main/docs/RUNBOOK.md under Owner sign-in. Nothing is printed and nothing is asked for in chat: you set both in your own shell. Production gets its own pair.
  What Stream R changes: the credential becomes an account with a hashed password and the middleware stays. Reversal cost if you would rather not ship the stopgap: one line in main.py removes the middleware, but then the eighteen write routes are open again on a public host.
  Options: ship the stopgap; set the two lines before the deploy (assumed) · hold the middleware until Stream R
  *Proceeding under:* the stopgap ships and R replaces the credential
  Gates: E4b (costly) — the work it gated has gone ahead under the assumption; settling it now avoids a costlier change later
- **The date the login wall comes off the storefront (SITE_ACCESS=public)** (raised 2026-09-10 by planning 10 Sep; id `decide-the-date-the-login-wall-comes-off-the-storefront-site-ac`)
  Login required to see the site is a pre-launch state. Adam: 'search is always our driver'; Mark: 'I would not index this until as absolutely late as possible'. The flip is one environment line and a container recreate, owned by E7 together with the IndexNow key, the real robots and sitemap and the uptime checks. Name the date with Adam and Mark; the client pages, /plan and the admin area stay behind the login whatever the date. Plan section 4.7.
  Options: with E7 on the go-live day · a later date after the human review
  Gates: E7 (costly) — the work it gated has gone ahead under the assumption; settling it now avoids a costlier change later
- **Opening hours are a new source class: collected from airport operator sites where their robots allow, hand-entered where they do not, provenance shown** (raised 2026-09-11 by Away plan (finalising session); id `decide-opening-hours-are-a-new-source-class-collected-from-airp`)
  Your call of 11 Sep, written into Stream G as the assumption it proceeds under: G1 reads each airport operator domain's robots.txt before the domain is added (permission never transfers between domains, exactly as retailer domains work), builds one collector per operator platform for the domains that allow it, and leaves the rest for hand population; G2 gives the hand path the same shape with the date and who entered it; G3 shows, per airport, collected or hand-entered and the date, and nothing at all where neither exists, so a stale hand-entered set is visible rather than silent. The page wording is a date, never a rate.
  To overturn: say no hours collection at all, and the collectors are switched off by deleting their registry entries; the table, the hand path and the provenance stay. Say hand only, and G1 becomes the robots verdict list with no collector built.
  Options: collect where the operator's robots allow, hand-enter elsewhere, show which (assumed) · hand-enter everywhere; no operator site is read · no opening hours on the page
  *Proceeding under:* collect where the operator's robots allow, hand-enter elsewhere, show which; one robots read per operator domain before it is added
  Gates: G1, G3 (costly) — the work it gated has gone ahead under the assumption; settling it now avoids a costlier change later
  See `.logs/planning/streams/AWAY-PLAN.md`
  See `.logs/planning/streams/G-airport.md`
- **The coverage bar for a category-at-airport page: fifteen published products** (raised 2026-09-11 by Stream G; id `decide-the-coverage-bar-for-a-category-at-airport-page-fifteen`)
  The structure proposal sets the rule in words ('the pairing has enough published products to be a real list', 'around a hundred' such pages) and no number. Measured on the 11 Sep nightly copy: 15 products gives 96 pairs, 12 gives 112, 20 gives 81, 8 gives 129; 155 pairs exist at all. The bar is one constant, coverage.CATEGORY_AT_AIRPORT_MIN_PRODUCTS, read at request time by the page's 404, the airport page's rail of links and the sitemap, so moving it changes which pages exist with no other code change and no redirect (a page that drops under the bar becomes a 404; nothing is renamed).
  Options: 15 (the assumption): about a hundred pages, every one a full screen of cards · 12: 112 pages, the smallest list two thirds of a screen · 20: 81 pages, only the deep pairings
  *Proceeding under:* Proceeding on 15. Reversing it is a one-line change; a lower bar adds pages, a higher one removes some, and neither moves an address.
  Gates: G7 (costly) — the work it gated has gone ahead under the assumption; settling it now avoids a costlier change later
- **The site-wide category page address: /alcohol/<slug> per the structure proposal, not /categories/** (raised 2026-09-11 by Stream G; id `decide-the-site-wide-category-page-address-alcohol-slug-per-the`)
  Stream G's brief allows a site-wide category page under /categories/<category> only if lib/structure.ts names that address. It does not: the proposal (settled 9 Sep with Mark) puts whisky at /alcohol/whisky under a family page (/alcohol, /perfume, /cosmetics), and says explicitly that whisky does not sit behind /categories/. So G7 landed the category-at-airport pages alone (/airports/<airport>/<category-slug>) and no site-wide category page. The family and category hub pages are Stream B's hub work; the category slug table (urls.CATEGORY_SLUGS, mirrored in lib/urls.ts) is written so /alcohol/<slug> can reuse the same words. Nothing waits on this; it names where the site-wide pages go when they are built.
  Options: /alcohol/<slug>, /perfume/<slug>, /cosmetics/<slug> under family pages, as the proposal says (the assumption) · /categories/<slug>, a flat list
  *Proceeding under:* The proposal's addresses stand; no site-wide category page was built in G7.
  Gates: G7 (info) — the work it gated has gone ahead under the assumption; settling it now avoids a costlier change later
- **Shopify shops: all beauty rows are collected, not only the targeted forty** (raised 2026-09-05 by Stream A; id `decide-shopify-shops-all-beauty-rows-are-collected-not-only-the`)
  Decision 4 says targeted pages first, full crawls last. The four Shopify feeds arrive whole in the same requests whatever we keep, so keeping every beauty row (about 3,500 variants at YUL, PTY, BOG, SAL; no barcodes) costs no extra request and gives Paris its widest beauty comparison set. Avolta and Dublin stay targeted (page 1 and the forty), because there every product page is a fetch.
  Options: keep all Shopify beauty rows (assumed) · restrict Shopify to the targeted forty too: a filter on shelf_vertical plus targets.matches, and a backfill to hide the rest
  *Proceeding under:* keep all Shopify beauty rows
- **Confirm the nineteen airports' web addresses with Adam (JFK the only code-first one)** (raised 2026-09-09 by Stream S; id `decide-confirm-the-nineteen-airports-web-addresses-with-adam-jf`)
  The airport address now leads with the airport's common name, then its code, then its city unless the name already says it, as Mark asked. The words are a declared table (main/app/services/urls.py AIRPORT_SLUGS, mirrored in the SPA); changing an entry is one line on each side and every earlier address redirects, so a correction costs nothing before launch and a redirect after it. The nineteen as built: ATH /airports/athens-ath · BCN /airports/barcelona-el-prat-bcn · BOG /airports/bogota-el-dorado-bog · CDG /airports/paris-cdg (CDG and Orly together, named for Paris) · DUB /airports/dublin-dub · DXB /airports/dubai-dxb · EZE /airports/buenos-aires-ezeiza-eze · HKG /airports/hong-kong-hkg · ICN /airports/incheon-icn-seoul · JFK /airports/jfk-new-york · LHR /airports/heathrow-lhr-london · MAD /airports/madrid-barajas-mad · MEX /airports/mexico-city-mex · PTY /airports/tocumen-pty-panama-city · SAL /airports/san-salvador-sal · SIN /airports/changi-sin-singapore · YUL /airports/montreal-trudeau-yul · YYZ /airports/toronto-pearson-yyz · ZRH /airports/zurich-zrh. Also declared, collected but not public: KEF /airports/keflavik-kef-reykjavik, SYD /airports/sydney-syd.
  Two judgment calls to check with Adam: where the airport's own name is what people search (Heathrow, Changi, Incheon, Tocumen, Barajas, El Prat, El Dorado, Ezeiza, Pearson, Trudeau) it is in the address; where the city is the search term (Dubai, Zurich, Madrid is both, Mexico City, Sydney, Athens) the city alone leads and Benito Juarez and Kingsford Smith are left out. Mark deferred the code-first list to Adam and could think only of JFK.
  Riding on the same answer: the brand page floor is read as three or more published products, priced between them at two or more distinct airports (Adam, 10 Sep: 'three or more where stocked at two or more airports'). If Adam meant three products at each of two airports, say so and it is one line in catalog_queries.
  Options: the table as built (assumed) · corrections, one line per airport
  *Proceeding under:* JFK is the only code-first airport; the table as built stands
- **What the review area needs next, now that you can see the data** (raised 2026-09-11 by planning session; id `decide-what-the-review-area-needs-next-now-that-you-can-see-the`)
  The first version of /collectors is on staging: collectors by platform, catalogue counts, every product in one filterable table, and the problems list. It only reads. The obvious next steps, in the order I would take them, but you should look first and say what is actually missing: (1) act on a duplicate pair from the page rather than the command line, which is the merge queue's whole point and the columns already exist; (2) clear a publication block from the page, with the note recorded against your name; (3) a per-airport view, since today you filter by airport but there is no page for one; (4) saved views, so 'products missing an image at Heathrow' is one click; (5) a history of the catalogue counts over time, from the audit snapshots already stored nightly.
  Deciding anything from the page is a write, and every write here is a recorded human decision, so it needs the same care as the merge and clear commands already take.
- **Is a range the brand markets as its own (Glenfiddich Perpetual Collection) still a member of its age line?** (raised 2026-09-17 by Review pass; id `decide-is-a-range-the-brand-markets-as-its-own-glenfiddich-perp`)
  REVIEW-PROCESS section 2 lists this as the open half of the finish/cask default: 'whether a finish that the brand markets as its own range is still a member'. Glenfiddich is the case. Perpetual Collection Vat 03 states 15 years and Vat 04 states 18, so the aged-spirits default puts them in Glenfiddich 15 and Glenfiddich 18. But Vat 01 and Vat 02 state no age at all, so they would have no home and become their own lines, splitting a range the brand sells as one thing across the catalogue. Either the Perpetual Collection is one product line with the vat as an attribute and the age where stated, or the default wins and Vat 01/02 sit alone. The same question covers Solera (Glenfiddich 15 Solera, sold as bare 'Solera' by two shops).
  Options: Perpetual Collection is its own product line, vat as the attribute · the aged-spirits default wins; Vat 01 and 02 stand alone
  *Proceeding under:* no file written for glenfiddich until this is answered
- **AI training stance in robots.txt: no for now (search and AI answers yes)** (raised 2026-09-05 by Stream B; id `decide-ai-training-stance-in-robots-txt-no-for-now-search-and-a`)
  robots.txt now carries Content Signals and per-bot groups from one switch table (main/app/services/robots_policy.py CONTENT_SIGNALS). Assumed: search=yes, ai-input=yes (AI assistants may read and cite pages, which is the AEO goal), ai-train=no (GPTBot, ClaudeBot, CCBot, Bytespider, Applebot-Extended, cohere-ai are disallowed; Google-Extended and meta-externalagent stay open because they also govern grounding). Flipping ai-train is one word in that dict and takes effect at the next deploy; nothing waits on it. Adam may have a view: training gives nothing back, but some publishers open it for reach.
  Options: keep: search yes, ai-input yes, ai-train no (assumed) · open training too
  *Proceeding under:* ai-train=no
- **A licence and a bulk download for the public dataset page?** (raised 2026-09-05 by Stream B; id `decide-a-licence-and-a-bulk-download-for-the-public-dataset-pag`)
  /data describes the price dataset with schema.org Dataset markup. It states no licence and offers no download, because neither is decided: an invented licence is a promise, and a bulk file of retailers' prices changes the legal posture (facts are fine to show per page with their date and a link back; a downloadable corpus is a different product). Assumed for launch: no licence line, no distribution; people link to pages. If you want a licence stated (for example CC BY 4.0 for the observations) or a CSV export, say so and it is a small addition to seo.py head_for_dataset plus one route.
  Options: no licence, no download (assumed) · state a licence · state a licence and offer a CSV
  *Proceeding under:* no licence, no download
- **Makeup joins the category vocabulary (beauty vertical)** (raised 2026-09-05 by Stream A; id `decide-makeup-joins-the-category-vocabulary-beauty-vertical`)
  The category list came from the client's wireframe (Fragrance and Skincare for beauty). Paris and the Shopify shops carry hundreds of makeup rows (M.A.C, Huda Beauty, Charlotte Tilbury) that were sitting uncategorised with vertical liquor. 'backfill categories' now files them as Makeup (241 rows on the dump). It is a category in the data, not a page: pages switch on per section 10 #10.
  Options: keep Makeup (assumed) · drop it: null those categories with a one-line backfill and leave them uncategorised
  *Proceeding under:* keep Makeup
- **The consent sentence and privacy line on the subscribe form (drafted wording in place)** (raised 2026-09-05 by Stream D; id `decide-the-consent-sentence-and-privacy-line-on-the-subscribe-f`)
  The form stores, verbatim and per subscriber, the sentence the person ticked, so the wording can change later without touching old rows. Drafted: consent 'Yes, email me Duty Free Professor updates. I can unsubscribe at any time.' and, under the button, 'We store your details only to send the newsletter, and never share them.' Both live in one place (web/src/api/editorial.ts CONSENT_TEXT; the small print in SubscribeForm.tsx). Adam may want his own words, or a link to a privacy page once one exists.
  Options: Keep the drafted wording for the soft launch · Adam supplies the sentences (a comment on /todo is enough)
  *Proceeding under:* The drafted wording ships; changing it is a one-line edit and already-stored rows keep the sentence they agreed to
- **Sizes on the page: one unit shown, the shop's own wording kept in the record** (raised 2026-09-09 by Stream S; id `decide-sizes-on-the-page-one-unit-shown-the-shop-s-own-wording`)
  Mark (7 Sep): 700ml against 70cl differs by country. Rian's reply (comment 181): sizes are stored as a value and a unit with millilitres derived, so 70cl and 700ml already match behind the scenes; the inclination is one consistent unit on the page with the shop's wording kept in the record. Neither reviewer has answered.
  How it stands: the product page's meta line and every card already show one unit from size_ml (700ml, 1L, 1.5L) on both the server-rendered body and the SPA; the price table carries no size, since a product is one size. The product name, which is the H1 and the record, keeps the shop's wording (Diplomatico Ambassador 70cl), so a reader can see 70cl in the name and 700ml in the meta line. Normalising names would be an identity-rules change (Stream A's issue-size-nomenclature-differs-by-country-700ml-against-70cl stays open for it) and is not done here.
  Options: one unit shown in the meta line and cards, the name unchanged (assumed; this is the page today) · also rewrite the size in the product name to the one unit (an identity change for Stream A) · show each shop's wording as printed
  *Proceeding under:* one unit shown, the shop's wording kept in the record; the name is the record
- **Souvenir goods in the Paris catalogue: collect and filter, or skip at the collector** (raised 2026-09-09 by Stream N; id `decide-souvenir-goods-in-the-paris-catalogue-collect-and-filter`)
  Open since the Paris collector's first run (4 Sep handoff) and never filed: Extime's shopping catalogue includes souvenir goods (macarons, plush baguettes, travel pillows) alongside drinks and beauty. They are collected today and sit uncategorised, so no page shows them. The recommendation then was to keep collecting and let the site filter by category, which is what happens by default; the alternative is a skip rule in the collector's walk.
  Options: keep collecting; uncategorised rows never publish (assumed) · skip souvenir sections at the collector
  *Proceeding under:* keep collecting; the category rule hides them
- **Three small deviations from the accounts plan, taken as built** (raised 2026-09-10 by Stream R; id `decide-three-small-deviations-from-the-accounts-plan-taken-as-b`)
  The directory's userinfo resolves a disabled account (with its status) instead of raising, so the kit's add path refuses it with DISABLED as the plan says; raising would have sent the kit down the new-account path and answered EMAIL_REQUIRED or EXISTS for an account that only needs accounts enable. The test suite runs argon2 at a fraction of the production cost (the production parameters are pinned by their own test), because at full cost the suite went from three seconds to thirteen. The SPA's account menu is an absolutely positioned pill inside the header rather than a nav item, so the server-rendered header mirror (seo.py, Stream B's file) is untouched and nothing moves when React mounts.
  Options: keep as built (assumed) · make userinfo raise for disabled and accept EXISTS on re-add · put the account menu in the nav and change the header mirror too
  *Proceeding under:* keep as built; the plan's stated outcomes hold
- **Makeup shades: a variation the vocabulary does not know, so makeup lines are never suggested for merging** (raised 2026-09-11 by Stream M; id `decide-makeup-shades-a-variation-the-vocabulary-does-not-know-s`)
  A foundation comes in shades ('Teint Idole 230W', '310N'); the variation vocabulary knows concentrations and Elixir, not shades, so two shades key apart only by their code word and a line merge would fold them into one product. Stream M therefore offers no makeup line pairs and skips any pair where only a shade-like code differs. Options: (a) leave makeup out of the merge session until a shade attribute exists (assumed); (b) add attributes.shade parsed from the name as a fourth variation kind, keyed like the concentration; (c) hide makeup from the comparison. Reversing (a) later costs one rule and a rederive, nothing else.
  Options: leave makeup out of the merge session (assumed) · add a shade attribute keyed like the concentration · hide makeup from the comparison
  *Proceeding under:* leave makeup out of the merge session
- **The four Heinemann-family shops and Dubai stay uncrawled, frozen at their 21 to 22 Aug prices** (raised 2026-09-15 by Stream L; id `decide-the-four-heinemann-family-shops-and-dubai-stay-uncrawled`)
  No collector contacts a host that refused us (COLLECTORS.md posture; A16; Decision 3). Their listings keep their dated prices (hidden or shown with the date by the publication rules) and the collectors page says 'refused since <date>'. Options: (a) the partnership asks at Cannes, then a permission record and a run; (b) accept the frozen data and hide the five shops. Proceeding under (b) frozen: nothing here changes either way, and no stream runs them.
  Options: the partnership asks at Cannes · accept the frozen data
  *Proceeding under:* frozen
- **The consistency pass: one term per concept in the database, code, labels and speech** (owner rian; raised 2026-09-16 by Claude (decisions walk-through, 16 Sep); id `decide-the-consistency-pass-one-term-per-concept-in-the-databas`)
  Rian's rule, 16 Sep: the technical language in the database must match what he is told in labels and conversation; one term per concept, and which term matters less than there being only one. He asked that a deep-reasoning session choose the specific terms.
  Section 2.12 of the catalogue decisions lists the thirteen concepts that carry more than one word today (product line, product variation, option, alias versus canonical_id, the brand versus house, merge versus fold, suggestion versus merge_candidates, picked/shown versus determinant/descriptor, quantity versus size, shop versus location versus source, place, Collected brand, and collected_value holding the rules' value). The pass picks one word each, renames columns and identifiers to it in a rename-only migration, and shrinks the glossary's translation column to nothing.
  Working terms until then: product line and product variation, never a bare 'product'. Timing: the column renames after Cannes; the labels and prose can move with the product-line page build before launch.
  See `.logs/planning/catalogue-model-decisions-2026-09-15.md`
  See `main/docs/VOCABULARY.md`
- **Options and properties as open lists of kinds, so the model scales to clothing, food and beyond** (owner rian; raised 2026-09-16 by Claude (decisions walk-through, 16 Sep); id `issue-a-product-variant-can-carry-only-one-option-besides-its`)
  Rian, 16 Sep, rejecting '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.' So this is a requirement of the model decisions now, not a later issue.
  The limit, traced: a product variant's identity has four slots (brand | product line | ONE option value | quantity) and the option carries one kind. Properties are fixed columns on the variant (abv, country_of_origin, category, is_exclusive), so every new category's facts (a garment's material, a food's allergens) would need a new column and migration. Both halves hard-code today's categories.
  Direction to decide in the model review: one open list of attributes per variant, each a (kind, value) pair, with a registry of kinds per vertical (the pattern already used for variation kinds and proposed for place kinds) saying for each kind whether it is an OPTION (tells siblings apart, part of identity, picked or shown on the page) or a PROPERTY (a fact about the variant, never identity). A new category is then a registry entry, not a migration. The products.attributes JSONB column already exists, so the storage need not be rebuilt. The product line page built before launch should read options as a list from the start, so it does not hard-code one slot.
- **Grouping and attributes by an AI-proposed review sheet, instead of rules at collection time** (owner rian; raised 2026-09-16 by Claude (decisions walk-through, 16 Sep); id `decide-grouping-and-attributes-by-an-ai-proposed-review-sheet-i`)
  DECIDED by rian, 16 Sep, as a principle: automate only what is certain (a clearly stated attribute); where a rule could be wrong, keep the raw text in the most relevant plain field (the variant name) and leave the structured field empty; an AI pass with reasoning proposes brands, product lines, variants and attributes; a person confirms or corrects; a confirmed listing is not re-reviewed unless its listed words change, never for a price change.
  His plan: all of it before Friday's launch. Rebuild on scalable attributes, re-derive existing data certain-only, build the AI corrector and its dashboard, then review. Walk-through W11 lists the nine parts in dependency order, the facts that constrain them (no model provider is wired in; an API key would be readable by the database container until the app-only env file exists; staging decisions are wiped by a refresh; no undo; Mark has not answered), and three candidate cut lines for a reasoning session to weigh.
  See `.logs/planning/catalogue-walkthrough-2026-09-16.md`
- **Every page reachable on discovery, noindex by default, indexing approved by a person** (owner rian; raised 2026-09-16 by Claude (decisions walk-through, 16 Sep); id `decide-every-page-reachable-on-discovery-noindex-by-default-ind`)
  Rian, 16 Sep, for brand, product line and airport pages: every page gets an address the moment it is discovered, so every comparison is usable at once; pages are noindex by default; the quality review merges pages and a confirmed alias fixes a final slug with old addresses redirecting; candidate rules (the 9 Sep floor of 3+ variants at 2+ airports) suggest pages for indexing on a dashboard, and a person approves each.
  Largely additive: the page head already takes a noindex flag, merged-away variants already 301, and an alias row is already a forwarding address. Resolves the 9 Sep reason for holding single-price products, since noindex keeps thin pages out of search. Open for a reasoning session and Mark: noindex,follow rather than the existing noindex,nofollow; flat redirects; sitemap and IndexNow limited to indexed pages; de-indexing by suggestion only; whether hidden remains; category and article pages. Walk-through W18.
  See `.logs/planning/catalogue-walkthrough-2026-09-16.md`
- **The spot-check thresholds in REVIEW-PROCESS.md section 4 are K0's defaults** (owner rian; raised 2026-09-16 by Stream K0; id `decide-the-spot-check-thresholds-in-review-process-md-section-4`)
  Read by rian 17 Sep and kept for today's pass: the five lowest-confidence proposals per brand, every proposal below 0.7 confidence, a random five percent (at least three), and always any proposal that merges variants or changes a price comparison. On the CHANEL rehearsal that was 8 of 48 rows.
  Beside the sheet, rian wants to keep eyeballing raw rows himself in a table he can filter and scan, which the review area already has: /collectors carries Product variants and Listings (the wide table with the column picker, full screen and CSV). The sheet is for approvals; the tables are for looking. Any later review surface keeps both.
- **Brand trailers still fold inside the brand slug: leave, or re-slug the brand rows?** (owner rian; raised 2026-09-16 by Stream K3; id `decide-brand-trailers-still-fold-inside-the-brand-slug-leave-or`)
  KEPT for launch by rian, 17 Sep, after asking where in the workflow it happens. The answer he was given, and the record of it: this is not a merge and no AI is involved. normalize.brand_key reduces a listing's brand text and drops a trailing word from a fixed list (Estate, Rum, Distillery, Gin) plus a leading 'The', so 'Appleton Estate' and 'Appleton Rum' compute the same key and resolve to the same brand ROW at collection time. Two rows were never created and nothing was joined.
  It is therefore the last open word list that still ACTS rather than proposing, which is the deviation from the programmatic-then-AI-then-human workflow rian is holding the model to. Kept because stopping it today splits 124 spellings (743 variants, 35 brand rows) into new rows, slugs and addresses days before launch; the compensation is rule:brand_trailers:6, 91 proposals that show each folded spelling for him to confirm on the sheet. Revisit after launch: the fold becomes a proposal like every other list, with a backfill that mints the alias rows and K6's redirects.
- **After the launch, which machine collects: production only, with staging refreshed from it?** (owner rian; raised 2026-09-17 by Stream K7; id `decide-after-the-launch-which-machine-collects-production-only`)
  DECIDED by rian 17 Sep, with a correction to when it starts: production collects ONLY AFTER he declares the launch. Until then the work stays on staging and the next collection run is expected there too; when he is ready the whole staging database is pushed to production and that is the launch.
  After that, the direction is one-way in practice: production's catalogue is the record and may be synced DOWN to staging, but staging must never overwrite production's catalogue again. The one thing that may want to keep living on staging is the development discussion; how that survives a refresh is left open, and the staging-refresh preserved set is where it would be handled.
- **K9 and K10 are not merged into master: only their briefs are, so K11 is building on a base that is missing both** (raised 2026-09-17 by Stream K11; id `decide-k9-and-k10-are-not-merged-into-master-only-their-briefs`)
  K11's command says to stop if master is missing K9 or K10. The check as written (git log includes 'Stream K9' / 'Stream K10') passes, but those two commits are brief-authoring commits only, exactly like cc0ed23 was for K11: b37be4c changed .claude/commands/stream-k9.md, the K9 brief, issues.md, items.json and progress.json, and 302f2a8 changed the K10 command, the K10 brief and progress.json. Neither carries a line of implementation.
  The implementations sit unmerged on their branches. claude/brand-split (8 commits, tip 4510a57) has main/app/services/brands.py, cli_brands.py, the appliers/undo/writer changes, merges.py, ingest.py, keying.py, test_brand_split.py, AND 32 lines of main/app/services/normalize.py. claude/review-area (6 commits, tip 2b308ec) has components/review/ReviewTabs.tsx, DecisionPanels.tsx, DecisionRules.tsx, FoldingWork.tsx and the ReviewPage.tsx changes. Both branches say 'green and ready' in their own handoffs.
  What it costs K11. K11.3 edits normalize.brand_key, the same function K9 edits for its per-vertical trailer scoping, so whichever merges second resolves a conflict there by hand. K11.6 is told to mount into K10's tab shell and not to touch it; that shell does not exist in this base, so there is nothing to mount into.
  Options: Merge claude/brand-split and claude/review-area into master, then rian tells K11 to rebase onto the merged master and K11.6 mounts into the real tab shell (what the brief assumes) · Leave them unmerged: K11 lands its own review components standing alone against master's ReviewPage, and whoever merges K9/K10 afterwards resolves normalize.py and ReviewPage.tsx by hand · Pause K11.6 only, land K11.1 to K11.5 and K11.7, and pick the UI up in a follow-up once K9 and K10 are in
  *Proceeding under:* Proceeding on option 2 for now: K11.3 changes only the trailer fold in brand_key and leaves K9's list alone so the conflict stays small and mechanical, and K11.6 builds the product line table as its own component mounted into master's ReviewPage rather than inventing a competing tab shell. If K9/K10 are merged first, K11.6's mount point moves, which is a few lines, not a rewrite.
- **61% of live variants carry no listed text, so no pass can cite a span for them** (raised 2026-09-17 by Review pass; id `decide-61-of-live-variants-carry-no-listed-text-so-no-pass-can`)
  REVIEW-PROCESS section 3 requires every proposed value to cite the listed field and the exact words, and section 1 says a value the raw text does not contain is never proposed. Measured on staging 18 Sep: 10,276 of 16,756 live variants have NO listing with a listed_name, so a pass can propose nothing for them without inventing a citation. By collector: extime 200 of 7,036 listings carry a name (2.8%), bordershop 0 of 1,806, dubai-duty-free 0 of 1,489, heinemann 0 of 1,035, heinemann-iceland 0 of 963, heinemann-sydney 0 of 444. attenza, avolta, ari, the-loop, shilla and ishopchangi are all at 97-100%, so this is per-collector, not a parser fault. The variants DO have names (a Heinemann variant reads 'Glenfid V3 15y 50.2% 0.7L Tube'), so the collector read the text and did not persist it into the listed layer. Effect on this slice: rabanne 65 of 65 variants citable, glenfiddich 31 of 59, paco-rabanne 3 of 54. Until the six collectors populate listed_name (or the pass is allowed to cite the variant name as its source), a review pass can only cover the 39% of the catalogue that has text, and a brand served mainly by those collectors cannot be reviewed at all.
  Options: backfill listed_name for the six collectors from what they already store, then re-run · let a pass cite the variant name as a source where no listing carries one (weaker provenance) · review only the 39% that has text before launch
  *Proceeding under:* proceeding on the brands that do have text; paco-rabanne skipped

## Open: do, check, review

- **Make bot@dutyfreeprofessor.com deliver to you** (owner rian; due 2026-09-08; raised 2026-09-05 by planning session; id `do-make-bot-dutyfreeprofessor-com-deliver-to-you`)
  The identity page names bot@dutyfreeprofessor.com as the contact for retailers (to ask for a slower pace, an exclusion, or a direct feed). It needs to reach a real inbox. Easiest: Cloudflare Email Routing on the dutyfreeprofessor.com zone (free): enable it, add a custom address bot@ that forwards to your inbox, and let Cloudflare add the MX and TXT records it asks for. Mail to that address is rare but every message is a retailer relationship.
  If you would rather publish a different address, say which and the page changes in a minute.
  Gates: A13 (costly) — due 2026-09-08
- **Send Adam the list of the nineteen launch airports** (owner rian; due 2026-09-10; raised 2026-09-09 by Adam on /structure; id `do-send-adam-the-list-of-the-nineteen-launch-airports`)
  Adam asked for it again on the structure page: 'I am trying to find the list again of all the airports, please share the 19.' The sixteen collected today plus Paris, Seoul and Singapore.
- **The human check of thirty product pages before the nineteen switch on** (owner rian; due 2026-09-11; raised 2026-09-05 by planning session; id `do-thirty-page-check`)
  Part of the Fri 11 bar you chose: after the drinks refresh, open thirty product pages from the audit's list and confirm price, size and shop against the retailer page. Stream Q's checklist generator produces the list; note anything wrong here and it becomes an issue.
  Gates: A13 (blocking) — due 2026-09-11
  See `main/scripts/review-checklist.py`
- **Keep the scratch database until Stream R's rehearsal is done** (owner rian; due 2026-09-12; raised 2026-09-05 by planning session; id `do-dfp-devdb`)
  Stream Q left a copy of a dump in a scratch container (dfp-devdb) for migration rehearsals; the security audit lists the container as orphaned. Keep it up through Stream R's R1 night: O2 rehearses migration #4 on it as dfp_r (up, backfills twice, down, up) and L10 runs the local server against dfp_r; the session drops only dfp_r. Remove the container (docker rm -f dfp-devdb) only after R1 has handed over green and ready; the earlier scratch databases (dfp_q, dfp_a, dfp_b, dfp_c, dfp_d) can go with it.
  Gates: O2, L10 (costly) — due 2026-09-12
- **Get Adam's answer on the awards rules and the aging switch (/discuss card 5)** (owner rian; due 2026-09-11; raised 2026-09-05 by Stream F; id `do-adam-awards-strategy`)
  The one-page strategy is published on the review page as decision card 5 (item 40), full text in its thread, and kept at notes/awards-strategy-for-adam-2026-09-05.md. Three answers wanted: a yes to the six rules (latest result per competition shown, highest level leads, ties by year then the traveller's airports then judges' score, pin by competition+year+medal, doubtful matches show nothing); aging on or off at launch; and whether he would rather the best medal ever won stayed on the page instead of the latest per competition.
  Cost of reversal: the picker is built to the rules as written with aging off. Switching aging on is a constant; switching rule 2 to best-ever is one ordering function and a re-check of every product page's medal list.
  Options: rules as written, aging off (assumed) · rules as written, aging on · best-ever medal per competition instead of latest
  *Proceeding under:* rules as written, aging off
  Gates: F2 (costly) — blocks F2 (Picker (recency, level, airport affinity); --rebuild upserts by natural key; pin via overrides), due 2026-09-15
  See `notes/awards-strategy-for-adam-2026-09-05.md`
- **Give Adam feedback on his Heathrow airport profile and his whiskey category example** (owner rian; due 2026-09-11; raised 2026-09-09 by Adam on /structure; id `do-give-adam-feedback-on-his-heathrow-airport-profile-and-h`)
  Adam has drafted two documents and wants a view before he writes the rest: an airport profile for Heathrow, and a category example for whiskey. Both are Google Docs linked from the structure page comments (p-airport and general).
  Mark has already given his view on the airport one and it is a direction change worth passing on: no generic airport information, nothing about lounges or terminals except where it genuinely bears on duty free shopping, and no filler written for search engines. He wants the duty free situation itself: where the shops are, any specialty shops, perhaps a map.
  Gates: D2 (blocking) — blocks D2 (Adam's articles published; airport write-ups; category intros), due 2026-09-15
- **Mint INDEXNOW_KEY in .app.env before the gate comes off production** (owner rian; due 2026-09-16; raised 2026-09-09 by Stream N; id `do-mint-indexnow-key-in-app-env-before-the-gate-comes-off-p`)
  0.33.0's after-deploy note asked for it and nothing tracked it: the running container has no INDEXNOW_KEY (checked by length only, never by value). Without it python -m app.cli_pages indexnow lists and exits 2, and no key file is served. It is any alphanumeric string of 8 to 128 characters we mint once (for example python3 -c "import secrets; print(secrets.token_hex(16))"), set in .app.env in your own shell, then the container recreated; check by behaviour: /<key>.txt answers 200.
  Timing matters more than the value: while production sits behind the domain password IndexNow must not run at all (issue-production-goes-live-on-10-sep-behind-a-domain-password), so mint it as part of the go-live checklist (E7), not before. Nothing waits on it until then.
  Gates: E7 (costly) — due 2026-09-16
- **Read the catalogue model decisions and say which, if any, you overturn** (owner rian; due 2026-09-17; raised 2026-09-15 by Claude (catalogue decisions session, 15 Sep); id `do-read-the-catalogue-model-decisions-and-say-which-if-any`)
  The session you asked for decided the open catalogue questions and built the safe part on claude/table-layout-db-structure-1d5bc2: the words (section 1 and main/docs/VOCABULARY.md), one ledger for every human decision (2.2), identity rules v5 with the shade and marked flavour out of the line key and the pack words kept (2.3, 2.5, 2.6), the behaviour registry (2.4), the review gate as a CLI (2.8), and the direction for AI (2.9), the listed-changed tripwire (2.10), places (2.11) and the rename wave (2.12). Section 5 has the numbers the rehearsal produced. Every decision is yours to overturn; the cost of each reversal is named. When you merge, run the v5 block in RUNBOOK.md before the first desk session.
  See `.logs/planning/catalogue-model-decisions-2026-09-15.md`
- **Go-live day: flip production to public and run the checklist's second pass** (owner rian; due 2026-09-18; raised 2026-09-10 by Stream E; id `do-go-live-day-flip-production-to-public-and-run-the-checkl`)
  On the day the site opens: mint INDEXNOW_KEY (do-mint-indexnow-key...), set SITE_ACCESS=public in the droplet's .app.env in your own shell, recreate the app container (cd /srv/apps/dutyfreeprofessor && docker compose -f docker-compose.yml -f docker-compose.production.yml up -d app), then a session curls every route and API read anonymously and confirms the internal set is still closed, robots allows crawling, the sitemap lists the public pages, and no-store holds on HTML and JSON. Members keep their sign-in in front of the owner and client surfaces. Reversal: SITE_ACCESS=members and recreate.
  Gates: P6 (blocking) — blocks P6 (Delivery walkthrough with Adam), due 2026-09-18
  See `main/docs/RUNBOOK.md`
- **Apply the five kit upstream proposals and add a vendor drift check to the scaffolder, then let R re-vendor** (owner rian; due 2026-09-20; raised 2026-09-10 by planning 10 Sep; id `do-apply-the-five-kit-upstream-proposals-and-add-a-vendor-d`)
  Stream R writes the unified diffs to .logs/planning/kit-upstream-proposals.md: the owner default in bw_accounts.init made required; an accountLabel prop on AccountMenu; a strings prop on AddPerson; showStatus on AdminApp; openInPopup on ProfilePanel; and new-bw-app.sh gaining a --check-vendor that byte-compares an app's vendored copies (the research found caddie's vendored bw_admin_api.py already differs from the kit). Until applied, DFP works around each without editing a vendored byte.
- **Start Stream R3 (collection oversight): /stream-r3 once R1 is deployed** (owner rian; due 2026-09-24; raised 2026-09-10 by planning 10 Sep; id `do-start-stream-r3-collection-oversight-stream-r3-once-r1-i`)
  After R2 or beside it: /stream-r3 from /srv/apps/dutyfreeprofessor. Tasks V1 to V4 on /plan; V4 (the scheduler trial) stays blocked until decide-where-collectors-run is answered.
  Gates: V1 (blocking) — before V1 (R3: the /api/ops/* reads (sources, runs, rejections, verify, audit, merges, freshness, impersonations) gated by sources.manage), due 2026-09-25
- **Review the decisions taken while you were away** (owner rian; raised 2026-09-11 by Away plan (finalising session); id `do-review-the-decisions-taken-while-you-were-away`)
  Every call in .logs/planning/streams/AWAY-PLAN.md section 3 is a working assumption a lane proceeded under, not your decision; each states what it costs to overturn. Read section 3 top to bottom and overturn any in one sentence: the Singapore hold, the six page types on 18 Sep, the review area's order, opening hours as a source class, the four standing rules with the Avolta pages-2-and-later observations quarantined rather than purged, the login wall coming off with E7, the standing assumptions, the lazy chunks gated, and the dark header left alone on purpose. Then retire the plan file.
  See `.logs/planning/streams/AWAY-PLAN.md`
- **Land Stream M: merge the branch, deploy, run the after-deploy commands, then confirm Paco Rabanne to Rabanne on /collectors** (owner rian; raised 2026-09-11 by Stream M; id `do-land-stream-m-merge-the-branch-deploy-run-the-after-depl`)
  Stream M ran in the desktop worktree on branch claude/stream-m-fdcfa4 (seven M: commits, check.sh green). To land it: merge the branch into master (expect a conflict only in import/items.json and import/progress.json if R2 or G also appended; keep both sides), take the dump (backups/dfp-<date>-pre-migration-6.dump), deploy, then inside the container in this order, each safe to repeat: python -m app.cli backfill lines; backfill variations; rederive; backfill merges; suggest. On the rehearsal copy that took under two minutes and folded 500 groups. Then open /collectors, the Merge view: the first pair waiting is Paco Rabanne to Rabanne; y confirms it as Rabanne, and the 1 Million pairs follow at product level (CDG's bare '1 Million 10cl' beside the 100 ml Eau de Toilette). Every decision is recorded against your account. The page has not been opened in a browser from the branch: the routes are tested and the SPA typechecks, so look at it on staging before plowing through.
- **Set SOCIAL_INSTAGRAM_URL and SOCIAL_YOUTUBE_URL in .app.env when Adam and Mark hand the profile addresses over** (owner rian; raised 2026-09-05 by Stream B; id `do-set-social-instagram-url-and-social-youtube-url-in-app-e`)
  Two lines in .app.env (https URLs; a REPLACE_WITH_ placeholder or an http:// value is ignored), then recreate the app container. They light up the header and footer links (Stream C's C4), the shell's window.__DFP_SOCIAL__ and the Organization markup's sameAs at once; until then nothing shows. Nothing to paste in chat: edit the file yourself.
- **Check Heathrow's gate numbers and terminal detail before the guide goes public** (owner rian; raised 2026-09-11 by Stream C; id `do-check-heathrow-s-gate-numbers-and-terminal-detail-before`)
  Adam's Heathrow duty free guide (draft v2, September 2026) is now the LHR airport page's guide section (main/app/services/airport_guides.py). His own note on the draft: verify the exact gate numbers before publishing, because retailers reshuffle gates. The page carries a dated line saying the locations were checked in September 2026; confirm T2's A20 and B35, T3's Flight Connection Centre store and T5's Harrods and Prada before the site goes public.
- **Adopt catalog_queries.live_listings() in the five readers that query Listing directly** (owner rian; raised 2026-09-15 by Stream L; id `do-adopt-catalog-queries-live-listings-in-the-five-readers`)
  An ignored listing (listings.ignored_at, set from /collectors) is skipped by catalog_queries.py and the collectors page, but services/trip.py (the two Listing selects), routers/catalog.py (stats, the multi-airport filter), services/indexnow.py, services/coverage.py and services/audit.py query Listing directly and still see it: it reaches the trip comparison, the home page counts and IndexNow pings. Each adds .where(catalog_queries.live_listings()) to its Listing reads. Owner: the trip/SEO lane; not Stream L's files.
- **Recrawl Extime Paris on bwlive for the fragments, in an announced window, after the Stream L chain deploys** (owner rian; raised 2026-09-15 by Stream L; id `do-recrawl-extime-paris-on-bwlive-for-the-fragments-in-an-a`)
  The one stale shop that has not refused us: 6,836 of CDG's 7,036 listings have no fragment because run 115 (4 Sep) predates raw_records, so their listed columns are NULL on the Listings table. The window file has the commands in order (collect extime-paris with tee to .logs/runs/, verify --n 20 --mode after_collection, then the staging refresh, backfill listed and suggest); three to five hours at the 1.5 s floor; no deploy inside the window; nothing runs until rian opens it.
  See `.logs/runs/window-2026-09-15-extime-fragments.md`
- **The partnership asks for the five shops that refused us (A16 and Decision 3)** (owner rian; raised 2026-09-15 by Stream L; id `do-the-partnership-asks-for-the-five-shops-that-refused-us`)
  heinemann-global, iceland-duty-free, sydney-duty-free and bordershop-scandinavia refused by robots.txt on 23 Aug (runs 52 to 55: Disallow /en/global/search/results; A16 owns the ask); dubai-duty-free answers 403 to our declared identity (verification check 396, 11 Sep; Decision 3: no bypass, the ask at Cannes). Until an ask lands, their prices stay frozen at 21 to 22 Aug and the collectors page reads 'refused since <date>'.

## Decided or done

- **Add the DNS record for bot.dutyfreeprofessor.com in Cloudflare** (owner rian; due 2026-09-05; raised 2026-09-05 by planning session; id `do-add-the-dns-record-for-bot-dutyfreeprofessor-com-in-clou`)
  The zone sits in Adam's Cloudflare account (you have access, the gateway's token does not), so this one record is by hand. In the dutyfreeprofessor.com zone add: Type CNAME, Name bot, Target mosiah.riverway.ca, Proxy status DNS only (grey cloud), TTL Auto. Caddy issues the certificate on the first visit within a minute or two. Check: open https://bot.dutyfreeprofessor.com and see the identity page.
  Until this resolves, every request the reader sends carries a link that does not load, which is what the identity page exists to prevent. Tonight's drinks refresh should go out after it.
  Gates: A10 (blocking)
  See `https://bot.dutyfreeprofessor.com`
  **DONE 2026-09-05T20:16:24.441342+00:00 by rian:** (no text)
- **Set PUBLIC_BASE_URL in the app's environment file** (owner rian; due 2026-09-06; raised 2026-09-04 by Stream B; id `do-public-base-url`)
  Since the crawlable-pages work, canonical, sitemap and structured-data URLs are built from PUBLIC_BASE_URL instead of trusting the request. It is not set yet, so they are relative and the startup log warns. Add one line to .app.env in your own shell (the value for this host is https://dutyfreeprofessor.demoing.info), then redeploy at the next checkpoint. Production gets its own value in E4b.
  Gates: B4, A13 (blocking)
  **DONE 2026-09-05T19:54:13.732782+00:00 by Claude (planning session):** Line added by rian; container recreated; absolute canonical and sitemap URLs verified.
- **Hand-check the Singapore departure price before the first Singapore run** (owner rian; due 2026-09-06; raised 2026-09-05 by Stream A2; id `do-changi-hand-check`)
  Open the Johnnie Walker Blue Label 1000ml page on ishopchangi.com in your own browser, choose Traveller mode with At Departure, and confirm the price reads S$271.10 (non-traveller delivery shows S$333.87). The collector publishes the departure tier. Say "confirmed" here, or the price you saw.
  Gates: R4 (blocking)
  See `https://www.ishopchangi.com/en/product/johnnie-walker-blue-label-1000ml-40--mp00089136`
  See `.logs/runs/changi-targets-2026-09-05.md`
  **DONE 2026-09-05T19:54:13.745157+00:00 by Claude (planning session):** rian confirmed both tiers in the thread.
- **The Monday reply pass to Adam's open threads** (owner rian; due 2026-09-07; raised 2026-09-05 by plan review; id `do-reply-pass-adam`)
  About thirty minutes, one reply per thread. The wording is drafted in the build plan, section 10 item 15: Seoul confirmed possible; the browser reader; Dubai's refusal and the dated prices at launch; images and the list by Tue 8; the two new content asks; the hosting figure or the explicit hold; awards overrides via rian until the admin area. Plan task P3 is marked done when this is.
  Gates: P3 (blocking)
  See `.logs/planning/build-plan-2026-09.md`
  **DONE 2026-09-05T20:16:20.277801+00:00 by rian:** I ran through, some questions do not need answer. everything is cleared.
- **The hosting figure and the message to Adam** (due 2026-09-08; raised 2026-09-05 by plan review; id `decide-hosting-figure`)
  $250 a month was the 1 Sep anchor; the quote page says $50 and promises real numbers before production. Stream E gives you a real figure by Tue 8, two ways: this app alone, and with headroom for a shared live-hosting box. Then decide whether production runs from Fri 11 at the real figure, or the $50 hold stands through soft launch and steps up when scheduled collection starts. Say it on the quote thread Adam opened on the environment line.
  Options: production from Fri 11 at the real figure · $50 holds through soft launch, then steps up
  Gates: E4 (blocking)
  **DONE 2026-09-05T18:32:15.778633+00:00 by rian:** I want to hold the 50$ and see what the real cost is and discuss with Adam after launch. I'll mention this in the comment replys on the quote page
- **Who controls the dutyfreeprofessor.com zone at cutover** (due 2026-09-08; raised 2026-09-05 by plan review; id `decide-zone-control`)
  Production needs the domain's DNS under our control: either the zone moves into your Cloudflare account, or Adam issues a scoped API token that is staged into a permissions-restricted file, never pasted in chat. Confirm which it is, and that the old WP Engine placeholder stops resolving at cutover.
  Options: zone is already in my Cloudflare account · Adam issues a scoped token · zone transfer needed
  Gates: E4c (blocking)
  **DONE 2026-09-05T18:32:40.131431+00:00 by rian:** zone is in cloudflare and I have full access, but the domain is owned by adam
- **Belt-and-braces firewall rule for the browser sidecar before production** (owner rian; due 2026-09-09; raised 2026-09-05 by planning session; id `do-sidecar-firewall-rule`)
  The sidecar refuses requests to numeric and private addresses by name, but a permitted retailer hostname that resolved to a private address would pass (DNS rebinding). Cheap fix that does not depend on code: a DOCKER-USER iptables rule on whichever host runs the sidecar, dropping traffic from the dutyfreeprofessor-render network to the host's own addresses and to private ranges. A firewall change, so it is yours; on this server it lands in /etc/iptables/rules.v4. A session drafts the exact rule when you ask.
  Gates: E4b (costly)
  **RESOLVED 2026-09-10 by rian (in chat, 10 Sep):** Applied on bwlive 10 Sep: deploy/sidecar-firewall.sh as /usr/local/sbin/sidecar-firewall, systemd oneshot after docker.service, 13 DOCKER-USER rules; proof that the sidecar cannot reach 169.254.169.254 while retailers still render goes on the E7 go-live checklist.
- **Read Mark's comments on the structure proposal and settle the structure** (owner rian; due 2026-09-10; raised 2026-09-05 by planning session; id `do-read-marks-comments`)
  Mark has the structure page and the to-do page as of today; his comments are due Thu 10. Read them, decide what changes, and answer the page-types question above. If nothing arrives by Fri 11, the sessions build to the proposal as written.
  Gates: B6 (blocking)
  **RESOLVED 2026-09-09 by rian:** Read 9 Sep, replied on every thread; Mark and Adam have both replied back on the page.
- **The launch domain is serving an indexable Chocolate Professor clone** (owner rian; due 2026-09-10; raised 2026-09-09 by Mark on /structure; id `do-noindex-the-placeholder-site-on-the-launch-domain`)
  Mark spotted this on 9 Sep and he is right. dutyfreeprofessor.com answers 200 today with a WordPress site titled 'Home - Duty Free Professor' that is still built on the Chocolate Professor template: its logo files are Chocolate-Professor-Logo.png and its banner is ChocolateProfessorBanner.webp. Its robots.txt is wide open (Yoast block, 'Disallow:' with nothing after it) and it publishes a sitemap, so search engines are free to index all of it.
  Why it matters: Google is forming its picture of the launch domain right now, from a placeholder that looks like a copy of another site. We inherit that history the day we launch on it. Mark's words: 'I would no-index this immediately.'
  Not our server. It sits on WP Engine and Adam owns the domain, so the fix is Adam's hosting: set the site to discourage search engines (the Yoast or WordPress reading setting), or replace it with a holding page that carries a noindex tag. The zone is in your Cloudflare account if a faster block is needed. Our own demo host is unaffected: it answers 302 to the login gate and is not indexable.
  Gates: A13 (blocking)
  **RESOLVED 2026-09-09 by rian (in chat, 9 Sep):** Overtaken by rian's decision of 9 Sep: production goes live on 10 Sep on the launch domain behind a domain password, replacing the WP Engine placeholder, so the placeholder is never noindexed. Assumes the launch domain is dutyfreeprofessor.com as the plan states (rian wrote dutyfreeprofessors.com in chat); if production lands on any other host, reopen this item, because the placeholder would stay indexable.
- **Merge R2b, deploy staging, run the after-deploy commands, then look at the panel** (owner rian; due 2026-09-14; raised 2026-09-13 by Stream R2b; id `do-merge-r2b-deploy-staging-run-the-after-deploy-commands-t`)
  Branch claude/stream-r2b-a21dc7 (eight R: commits on master's head). After the deploy, in order, inside the container: backfill threads (rings the legacy mentions; the hand-emitted row is not doubled), discussion rethread --check (read the lines; edit import/rethread-2026-09-13.json first if a move is wrong), discussion rethread --apply once. Then in a browser: the speech-bubble control beside the bell, the tabs, Show resolved, the search, Start a topic, Resolve with a closing word on the threads the map lists as ready (structure q-single, q-thresholds, q-family, schema, q-urls, urls, the fifteen quote lines). No browser reached the stream session.
  See `.logs/handoff.md`
  **RESOLVED 2026-09-14 by Stream R2b:** merged, deployed and the commands run on 14 Sep; rian looked at it in the browser and iterated through the evening (the handoff of 14 Sep)
- **Start Stream R2 (discussions): /stream-r2 once R1 is deployed on this host** (owner rian; due 2026-09-22; raised 2026-09-10 by planning 10 Sep; id `do-start-stream-r2-discussions-stream-r2-once-r1-is-deploye`)
  After 18 Sep, in a terminal: cd /srv/apps/dutyfreeprofessor && claude, then /stream-r2 (the command checks that R1 is deployed before it starts). Tasks T1 to T6 on /plan. Before it runs, add MAIL_FROM and MAIL_PROVIDER=resend to .app.env when the stream stages them.
  Gates: T1 (blocking)
  **RESOLVED 2026-09-11 by Stream R2:** R2 ran 2026-09-12 on branch claude/stream-r2-fca074 (T7, T1 to T6, T8 to T10 done); merge and deploy are rian's
- **Take the line page recommendation to Mark and Adam** (owner rian; due 2026-09-24; raised 2026-09-15 by Claude (catalogue decisions session, 15 Sep); id `do-take-the-line-page-recommendation-to-mark-and-adam`)
  Section 2.7 of the decisions: the line page becomes the primary public page at /products/<line-slug>; product pages stay, canonicalised to their line and later redirected with the product preselected by fragment; determinants selectable, descriptors listed; a line card prices its representative product and never a range; a line publishes when one of its products does. This reverses the 9 Sep address decision, so nothing is built until they agree.
  See `.logs/planning/catalogue-model-decisions-2026-09-15.md`
  **RESOLVED 2026-09-16 by rian:** Decided 16 Sep: /products/ shows the line. Rian is commenting to Mark that he is proceeding unless Mark says no. Section 2.7 of the decisions document revised.
- **Scope versus capacity: what gives way if time runs short** (raised 2026-09-04 by plan review; id `decide-scope-vs-capacity`)
  The plan holds the sixteen promises plus unpaid back-office work, about twice what fits before 18 Sep.
  **RESOLVED 2026-09-05 by rian:** Everything stays in the plan. Unpaid work is ranked last, sits in the buffer week, and is pulled forward at the Fri 11 checkpoint if every wave-2 quote task is green. If time runs short, unpaid work drops before any quote line. The migration columns and the auth middleware stay regardless.
- **What the Fri 11 milestone means** (raised 2026-09-04 by plan review; id `decide-fri-11-milestone`)
  Adam approved production live, sixteen public, crawlable pages for Fri 11.
  **DONE 2026-09-05T18:21:44.2943+00:00 by rian (recorded by the planning session):** Option (a): hold Fri 11 with the quote's own bar; verify tripwire becomes the 18 Sep gate.
- **Three small additions to the verify rules (Q proceeds unless you object)** (raised 2026-09-05 by Stream Q; id `decide-verify-additions`)
  Stream Q built verify with three things Decision 10 did not spell out.
  1. A tenth verdict, FETCH_ERROR, for a timeout or a server error: reported, never a failure, never a block. Without it a retailer's bad hour would either block a shop or vanish from the record.
  2. When verify finds a moved price it writes a new observation marked as coming from verify, so the live price is kept, not just noted.
  3. A block is not a flag on the shop; it is a failing check that a human clears, so clearing is itself a recorded decision.
  My recommendation: accept all three.
  Options: accept all three (recommended) · accept 1 and 3, no new observations from verify · other
  *Proceeding under:* proceeding as if accepted
  **DONE 2026-09-05T18:55:32.669508+00:00 by rian:** yes, but note that I don't fully grasp this one yet so we may need to discuss and change our mind later
- **Seoul is readable after all: keep it in the nineteen and tell Adam?** (raised 2026-09-05 by Stream A2; id `decide-seoul-stays`)
  The Shilla probe passed: a browser with our declared identity gets the full server-rendered product page. Two conduct points to know: the page's own scripts live under a robots-disallowed path, so we read the document without them (it is complete without them); and the second render allowed the Cloudflare challenge host as an asset host so the browser could load what the page asked for. No stealth, no identity change.
  Limits: their sitemap is empty and the category grids are drawn by disallowed scripts, so a compliant reader discovers through the home page and related-product rails, about 200 renders a run. That reaches part of the catalogue per run, not all of it. A full catalogue needs the partnership ask. Their robots file now names many AI and rendering crawlers; we are not named.
  If you prefer a replacement airport instead, name two candidates on new platforms and a session probes them.
  Options: keep Seoul on the rendered fetch; tell Adam Monday it is confirmed (assumed) · swap it for a replacement airport
  *Proceeding under:* keep Seoul
  Gates: P3 (blocking)
  See `.logs/runs/shilla-probe-2026-09-05.log`
  **DONE 2026-09-05T18:52:38.010332+00:00 by rian:** yes keep it
- **Which Shilla price is the price?** (raised 2026-09-05 by Stream A2; id `decide-shilla-price-tier`)
  The Shilla page shows a list price ($43), a discount price ($25.37) and a price for online members ($25.37). The collector records the discount price as what a walk-up traveller pays and the list price as the crossed-out price; the member tier is not published. One line to change if you want otherwise.
  Options: discount price (assumed) · member price
  *Proceeding under:* discount
  Gates: R5 (costly)
  **DONE 2026-09-05T18:53:56.94266+00:00 by rian:** yes, sounds like a good case to show the list price crossed out with discount as long as we're displaying the date when we checked it so public knows it may be out of date
- **Singapore only renders with its tag manager loaded. Acceptable?** (raised 2026-09-05 by Stream A2; id `decide-changi-tag-manager`)
  The iShopChangi storefront draws nothing until one Adobe Launch script has loaded, because the page's own code depends on it. With that one host allowed, the page draws and fetches its own inventory file, which we read as received. The sixteen analytics beacons it then tries to call (Adobe, Google, Facebook, Criteo, Bing, Clarity, New Relic and others) stay blocked, so nothing is reported to anyone. The reason is written in the collector.
  If you disagree, Singapore has no honest path and joins the Cannes ask list.
  Options: acceptable: it is loading what the page needs to function, with our declared identity (assumed) · not acceptable: Singapore goes to the ask list
  *Proceeding under:* acceptable
  Gates: R4 (blocking)
  **DONE 2026-09-05T20:16:30.1868+00:00 by rian:** yes
- **The four Shopify shops refuse our identity at robots.txt. Accept dated prices, check the feed, or ask the operators?** (raised 2026-09-05 by Stream A; id `decide-shopify-refusal`)
  Montreal, Panama, Bogota and San Salvador all answer 403 to robots.txt for our declared reader and 200 to a plain request. Under the ratified policy (401/403 is a refusal) they record as blocked and launch on their last-seen prices from 22 Aug, with the date shown.
  Options: accept and add them to the Cannes ask list; check whether the product feed itself is also refused (one request); or ask the operators (ARI for Montreal, Motta for the others) for permission, which becomes a recorded consent on the source. Nothing is redone whichever you choose; a consent record unblocks them.
  Options: accept: dated prices at launch, on the ask list (assumed) · check the feed first · ask the operators for permission
  *Proceeding under:* accept
  Gates: A13 (costly)
  **RESOLVED 2026-09-05 by planning session:** Overtaken by events: on the 5 Sep afternoon run all four Shopify shops served robots.txt and their feeds to our (repointed) identity and collected fresh prices (YUL 466, PTY 188, BOG 257, SAL 245). No decision needed; verify watches them. Dubai still answers 403 to a single robots read with the new identity.
- **The permanent hostname for our reader's identity page** (raised 2026-09-05 by plan review; id `decide-bot-identity-hostname`)
  Every request we make carries a link to a page that says who we are and how to reach us. That hostname is baked into every retailer's logs forever, so it should sit on a zone you control and never move. The page itself is a separate static project (Decision 3), not part of the app. Name the host and a session builds it and repoints the reader.
  Options: a host on bowden.works · a host on dutyfreeprofessor.com (only if the zone is yours) · other
  Gates: A10 (costly)
  **DONE 2026-09-05T20:11:24.300068+00:00 by rian (in chat, 5 Sep):** bot.dutyfreeprofessor.com, hosted on mosiah for now as its own static project; the reader was repointed the same day. The apex /bot path redirects to it once production exists.
- **On production, do the client pages stay reachable?** (raised 2026-09-05 by plan review; id `decide-client-surfaces-on-production`)
  The quote, discussion, structure, to-do, sources and settings pages, and their API reads, exist for Adam and Mark. On production they can be switched off behind a flag, or kept behind the login with a client role so Adam and Mark keep commenting after the move. Sources and settings go behind the owner login either way.
  Options: off in production until the login exists · behind the login with a client role
  Gates: E4b (blocking)
  **RESOLVED 2026-09-10 by planning 10 Sep:** rian's 10 Sep instruction: the client pages (/discuss, /structure, /quote, /todo, /settings) stay reachable behind the app's own login for Adam and Mark on the admin level (accounts plan .logs/planning/accounts-2026-09.md section 4.6); /sources and the kill switch stay the owner's (sources.manage), as build plan section 10 item 4 said
- **Where the collectors run once production exists** (raised 2026-09-05 by plan review; id `decide-where-collectors-run`)
  Three options, settled by the egress test: on the production host; on this server writing to production Postgres over Tailscale; or a separate runner with a reserved IP. Whichever it is also fixes which machine hosts the cron for the nightly audit and the verify runs.
  Evidence, 10 Sep (Stream E): from the droplet's address, text fetches at all 23 retailer hosts answered exactly as from the dev server (22 pages, Dubai refused on both), and a bounded real collection through the browser sidecar rendered Changi 5 of 5 and Seoul 5 of 5 with a Heathrow text-fetch control 10 of 10. Nothing in the collection path treats DigitalOcean's address differently today. Rian's stated preference: production should not depend on his home server. The remaining cost of the droplet option is memory: app 1 GB + sidecar 2 GB + Postgres 512 MB on a 4 GB box with 2 GB swap, one collection at a time.
  Options: on the droplet (assumed): collectors and cron live with the app; the sidecar fence applies there · on the dev server writing to production over Tailscale: keeps the residential address, ties the live site to the home server · a separate runner: a second box to operate, only if the droplet's address is ever refused
  *Proceeding under:* on the droplet
  Gates: E5 (costly)
  See `.logs/runs/egress-2026-09-10.md`
  **RESOLVED 2026-09-10 by rian (in chat, 10 Sep):** Collectors run on the droplet. Evidence: .logs/runs/egress-2026-09-10.md (text fetches identical at 23 hosts; rendered Changi 5/5, Seoul 5/5 from the droplet). Cron for audit and verify lands on bwlive with E5; the sidecar fence applies there.
- **Singapore answered 403 on the 19th page of its first run. Retry slower once, or treat as refused?** (raised 2026-09-05 by planning session; id `decide-singapore-answered-403-on-the-19th-page-of-its-first-run`)
  The first Singapore run rendered 18 product pages cleanly at about ten seconds each (17 prices stored, departure tier), then the 19th page took 49 seconds and came back 403 with a 512-byte body and none of the page's own data calls. That shape (short body, after a steady run of requests) looks like the shop's edge protection reacting to the pace, not a rule about that product. Under our policy a 403 is a refusal, so the collector stopped and recorded the source as blocked. Seoul's first run in the same window was clean: 18 prices from 20 pages.
  Two honest options. Retry once, later, at a slower pace (for example thirty seconds between pages, which is still our declared identity with the same rules) and see whether it holds; if it refuses again, Singapore joins the ask list. Or treat this first 403 as the answer and stop now. Either way the 17 prices already stored are real and dated. My recommendation: one slower retry, tomorrow morning, twenty pages, and stop for good on a second 403.
  Options: one slower retry (thirty-second pace), then stop for good on a second 403 (recommended) · treat it as refused now; Singapore goes to the Cannes ask list
  *Proceeding under:* no retry until you say
  Gates: R4 (blocking)
  See `.logs/runs/changi-first-2026-09-05.log`
  **RESOLVED 2026-09-13 by rian (in chat, 13 Sep):** Retry at a slower pace and find the rate they accept. Run 13 Sep on bwlive: collect --source ishopchangi-sin --limit 20 --delay 30. If it holds, the pace becomes the source's own (Source.delay_seconds / Changi.render_floor_seconds); a second refusal ends Singapore for good and it joins the Cannes ask list. Rian's direction beyond this run: crawl continuously rather than fast; keep the pace per collector as something a person adjusts, with notes; record when and at what pace a collector is refused; measure how often prices change per collector so the cadence is chosen from evidence.
- **Land the overrides table before Stream R, so award pins (and the other human overrides) can exist before Cannes?** (raised 2026-09-05 by Stream F; id `decide-overrides-before-r`)
  Build plan section 4 #1 puts overrides (entity_type, entity_key, field, value, collected_value, set_by, set_at, reason, collector_disagrees_since; UNIQUE on the first three) in R's migration #4, and section 10 #1 may move R after 18 Sep. F2's pin is built to read it (award_picker.pin_from_override, then pick(pin=...)) and to queue a reverifications row from the importer when a rebuild makes a pin stale (that table exists since A's migration #3). Without overrides there is no pin, only the picker; the picker and the fallback are done and tested.
  If yes: a schema-only migration with exactly those columns, next in the migration order after f3a4b5c6d7e8 (whoever holds the token; F owns no migration), then F wires the two reads and the one write in a short follow-up. If no: the pin ships with R and nothing built today changes.
  Options: yes, schema-only overrides migration this wave (F wires the pin after) · no, the pin waits for R (assumed)
  *Proceeding under:* the pin waits for R; the picker ships without pins
  Gates: F2 (costly)
  **RESOLVED 2026-09-10 by rian:** yes: the table lands with Stream R's migration #4 (decided 10 Sep); F wires the pin after
- **How many products must a brand have before it gets its own page?** (raised 2026-09-07 by Mark's SEO review; id `decide-how-many-products-must-a-brand-have-before-it-gets-its-o`)
  The proposal said two. Mark: 'more than just two products should be required, otherwise the pages are just too thin. The criterion should probably be either a larger number of products or a substantial number of airports carrying those products.' Adam agrees, and expects most brands to clear it easily except one-item fragrances. Brand pages shipped this weekend with the floor of two, so this is a number to change, not a build.
  My suggestion: publish a brand page when it has four or more published products, OR three or more products stocked at two or more airports. That keeps the thin one-bottle pages out while letting a genuinely multi-airport brand through. Say a number and it is a one-line change.
  Options: four products, or three across two or more airports (recommended) · a different number (say which)
  *Proceeding under:* the floor stays at two until you answer
  Gates: B5 (blocking)
  **RESOLVED 2026-09-09 by Adam and Mark on /structure:** Adam, 9 Sep: start with three or more products stocked at two or more airports, which drops the one-offs and regional items. Mark adds that brand pages carry high SEO value (searches like 'paco rabanne duty free') and are worth enriching rather than trimming hard.
- **Currency: what price do we show, and do we keep the aggregate-offer markup?** (raised 2026-09-07 by Mark's SEO review; id `decide-currency-what-price-do-we-show-and-do-we-keep-the-aggreg`)
  Mark warns that markup must match the page exactly or Google can tank rankings, and is wary of our aggregate-offer markup if currencies and translation are involved. Adam asks the practical half: do we show only the shop's local currency as published, and add a converter to the visitor's currency, or show two or three fixed conversions such as US dollars, Euro and Yen?
  Today every price is stored in the shop's own currency with the rate and minute it was converted, and the site also shows a US dollar figure for comparison. My recommendation: the published local price is the price on the page and in the markup, with the converted figure shown as clearly secondary and never inside the markup. That satisfies Mark's accuracy rule and Adam's question at once, and a visitor currency picker can come later.
  Options: local currency is the price; conversion shown as secondary and kept out of the markup (recommended) · add a visitor-chosen converter now · show three fixed conversions (USD, EUR, JPY)
  *Proceeding under:* local price in the markup, converted figure secondary
  Gates: A13 (blocking)
  **RESOLVED 2026-09-09 by Adam and Mark on /structure:** Settled 9 Sep. Mark endorsed the answer: the shop's own price in the shop's own currency is the real price and the only one in the markup; our conversion is shown as clearly secondary with the date of its rate. No fixed three-currency display; a visitor currency picker is a later addition.
- **Call the family Perfume rather than Fragrance?** (raised 2026-09-07 by Mark's SEO review; id `decide-call-the-family-perfume-rather-than-fragrance`)
  Mark: 'duty free perfume' has nineteen times the global search volume of 'duty free fragrance', so his inclination is to use Perfume wherever possible even where it is not technically accurate, with cologne and eau de toilette sitting under it. Adam liked the point. This changes a family name, its URL, the navigation and the category vocabulary, and the beauty collection now running is filling that family, so it is cheapest to settle before those pages exist.
  Options: rename the family Perfume, keep cologne and eau de toilette beneath it (recommended) · keep Fragrance
  *Proceeding under:* Fragrance until you say
  Gates: A11 (costly)
  **RESOLVED 2026-09-09 by Adam and Mark on /structure:** Adam, 9 Sep: 'Search is always our driver, we go with Perfume.' Mark proposed it. Family, navigation, URL and category vocabulary all become Perfume.
- **How much of the catalogue do we publish when a product is sold at only one airport?** (raised 2026-09-07 by Mark's SEO review; id `decide-how-much-of-the-catalogue-do-we-publish-when-a-product-i`)
  The proposal was to publish what can be compared plus travel exclusives, and hold the rest. Mark disagrees with the principle: the site's purpose is not only comparison, and one-in-five being multi-airport does not mean only those belong in the database. He wants Adam to judge how many single-airport products to include, with the aim of the site becoming the source of truth for duty free pricing, which he expects to pay off in search and publicity. Adam agrees that is the goal.
  This is a launch-scope decision with a real cost: publishing everything means thousands of pages with one price each and no comparison, which is the thin-content risk Mark warns about elsewhere. My recommendation: publish every comparable product and every travel exclusive as planned, plus single-airport products that carry a size and a brand we already publish, and hold only the unidentified remainder. That grows the database substantially without creating bare pages.
  Options: comparable, exclusives, plus identified single-airport products (recommended) · publish everything we hold · keep the original proposal
  *Proceeding under:* the original proposal until you answer
  Gates: A13 (blocking)
  **RESOLVED 2026-09-09 by Adam and Mark on /structure:** Both agreed to rian's proposal. Mark, 9 Sep: 'See no reason not to go with Rian's proposed pace for the prototype. In the long run the database if comprehensive enough could be a goldmine.' Adam: show as much as the timeline allows and frame the rest as a next release. So: launch on the comparable set plus exclusives, keep collecting everything, open up as price history accrues.
- **The sponsor banner sizes and where the slots sit, before Adam is asked for creative** (raised 2026-09-09 by Stream N; id `decide-the-sponsor-banner-sizes-and-where-the-slots-sit-before`)
  C5 (banners designed into the pages) has been blocked since 5 Sep on 'needs rian for sizes', and D4 built the SponsorSlot component for the nine IAB sizes so any of them can be placed. Nothing on the running list carried the ask until now. Decide with Stream C in the session: which slots exist (home, product page, airport page, article), which IAB size each takes, and whether a slot with no creative shows nothing (the component's default). Then the /todo row for Adam's creative ('Sponsor creative', waiting) gets its sizes.
  Options: one leaderboard on the home page and one medium rectangle on product and airport pages (a starting point) · your own list
  *Proceeding under:* nothing placed until decided; the component shows nothing without creative
  Gates: C5 (blocking)
  **RESOLVED 2026-09-10 by rian (in chat, 11 Sep):** Sparse, per Adam: one slot per page type, never in a hero or header. Home between savings and exclusives 728x90; airport page above products 728x90; article after the body 728x90; product page under the price table 300x250; leaderboards fold to 320x50 on phones. Empty slots show nothing. Sizes on Adam's /todo row.
- **Mark's note against dark backgrounds: the header and hero are still dark** (raised 2026-09-09 by Stream N; id `decide-mark-s-note-against-dark-backgrounds-the-header-and-hero`)
  From the brand alignment pass one (3 Sep handoff), left as a taste call for you: Mark asked to avoid dark backgrounds; the header and the home hero are still navy. A lighter header would let the navy logo read as the sign it is and sit closer to the Professor sites. Stream C's second pass shipped the mega menu and prominence but not this. Decide before the design pass that places the sponsor banners, since both touch the same surfaces.
  Options: keep the dark header and hero (assumed) · lighten the header; keep the hero · lighten both
  *Proceeding under:* unchanged
  **RESOLVED 2026-09-13 by rian (in chat, 13 Sep):** Navy stays for now; rian gets Adam's and Mark's final word this week. A base-colour switch beside the accent switch on /settings is filed so the lighter header can be compared on the live pages rather than from a description.
- **Stream R runs the night of 10 Sep as unpaid work ahead of the 18 Sep lines** (raised 2026-09-10 by planning 10 Sep; id `decide-stream-r-runs-the-night-of-10-sep-as-unpaid-work-ahead-o`)
  The accepted quote excludes accounts and logins from this phase and the plan's own rule (section 10 item 1) drops unpaid infrastructure before any quote line. Your 10 Sep ask puts the login system first, as the enabler that replaces the shared password, closes the write routes on a public host and keeps the launch domain out of the index. R1 (the login) is about one autonomous night; R2 (discussions) and R3 (collection oversight) are placed in the buffer after 18 Sep. Confirm the order, or move R1 after B6 and the nineteen.
  Options: R1 tonight, R2 and R3 in the buffer (assumed) · R1 after the 18 Sep lines
  *Proceeding under:* R1 runs the night of 10 Sep; R2 and R3 in the buffer
  Gates: L1 (costly)
  **RESOLVED 2026-09-10 by rian:** R1 tonight; R2 and R3 in the buffer after 18 Sep
- **Level names at launch: Adam and Mark on admin (DFP's meaning), member empty, no kit template seeded** (raised 2026-09-10 by planning 10 Sep; id `decide-level-names-at-launch-adam-and-mark-on-client-the-kit-s`)
  You said admin for Adam and Mark. The plan seeds admin with DFP's meaning (see the client pages and participate: comments, priorities, quote lines, to-dos) and member (nothing), and seeds none of the kit's templates; the kit's admin template (invite, level changes, View As) stays available from the Levels editor under a non-colliding name for the day you delegate account administration. Nothing in the kit or its conformance suite constrains production's level names (the suite runs against the two templates its own test store seeds). The one constraint: the kit cannot rename a level, so the names are fixed at the first seed. Plan section 4.6.
  Options: admin (DFP's meaning) and member (assumed) · client and member, admin reserved for a future staff tier
  *Proceeding under:* admin and member as described
  Gates: O2 (costly)
  **RESOLVED 2026-09-10 by rian:** admin with DFP's meaning (see and participate) for Adam and Mark; member empty; no kit template seeded
- **Typed comment authors link by an alias map; no guest accounts are minted** (raised 2026-09-10 by planning 10 Sep; id `decide-typed-comment-authors-link-by-an-alias-map-no-guest-acco`)
  Build plan section 2 said typed names become guest:<name> identities when R lands. The plan deviates: the seven who-columns gain nullable FK columns, and app.cli backfill authors links only the folded names Adam, Mark and rian to their accounts; every other spelling stays text with a NULL link (empty beats guessed; a guest row is a principal every later flow must remember to exclude, and the consumer namespace should not hold rows that cannot sign in). The feature priorities and quote selections tables carry a silent 'Adam' fallback the SPA wrote for anonymous actors, so they link only when you pass --include-defaulted. Plan section 4.8.
  Options: alias map, no guests (assumed) · mint guest rows as the build plan said
  *Proceeding under:* alias map; the two defaulted tables only on rian's flag
  Gates: O2 (costly)
  **RESOLVED 2026-09-10 by rian:** alias map for Adam, Mark and rian only; no guest accounts; the two Adam-defaulted tables stay unlinked
- **Mail for invites, resets and notifications: a Resend account owned by the project** (raised 2026-09-10 by planning 10 Sep; id `decide-mail-for-invites-resets-and-notifications-a-resend-accou`)
  The app needs its own sender for invite and reset links and, in R2, discussion notifications. The plan assumes Resend on an account owned by the project (not the server's), a verified sending domain on dutyfreeprofessor.com, the key in an app-only env file the database container never reads, and the same daily caps the server's sender uses. Until it exists, invites are a CLI-printed one-time link or a generated password handed over once. Plan sections 4.5 and 8.
  Options: Resend, project-owned account (assumed) · another provider · no mail until R2
  *Proceeding under:* Resend on a project-owned account, configured before R2
  Gates: T6 (costly)
  **RESOLVED 2026-09-10 by rian:** Resend on a project-owned account; RESEND_API_KEY already in .app.env and dutyfreeprofessor.com verified (10 Sep); MAIL_FROM and MAIL_PROVIDER=resend still to add when the stream stages them; the app-only env file split stays E's issue
- **Scheduled collection: a watched trial after launch, or hand-run until the priced line is bought** (raised 2026-09-10 by planning 10 Sep; id `decide-scheduled-collection-a-watched-trial-after-launch-or-han`)
  Adam was told collection stays hand-run and scheduled collection is a priced add-on. The plan builds the oversight reads and the kill switch page in R3 and keeps the scheduler behind a flag, to run as a watched trial on one source after launch, on exactly one machine (which waits on decide-where-collectors-run). Say whether the trial is told to Adam as a trial, held until the line is bought, or not built. Plan section 9.
  Options: watched trial after launch, told to Adam (assumed) · hold until the scheduled-collection line is bought · do not build
  *Proceeding under:* trial after launch behind FEATURE_SCHEDULER; nothing runs unattended before the decide
  Gates: V4 (blocking)
  **RESOLVED 2026-09-10 by rian:** watched trial after launch on one source behind FEATURE_SCHEDULER, told to Adam as a trial; still waits on decide-where-collectors-run
- **The overrides table rides in migration #4 as the build plan names it** (raised 2026-09-10 by planning 10 Sep; id `decide-the-overrides-table-rides-in-migration-4-as-the-build-pl`)
  decide-overrides-before-r assumed the pin waits for R. R is now running, so the table lands with migration #4 exactly as build plan section 4 item 1 sketches it, with a backfill that reports zero; Stream F wires the award pin after (F2). Nothing else reads it yet. Say no and it moves to a later migration beside its first writer. Plan section 6.
  Options: rides in #4 (assumed) · later, with its first writer
  *Proceeding under:* rides in #4; F wires the pin after
  Gates: O2 (costly)
  **RESOLVED 2026-09-10 by rian:** rides in migration #4, schema only; F wires the pin after
- **Skincare and confectionery are out of the launch scope: collected, not shown** (raised 2026-09-10 by rian (in chat, 11 Sep); id `decide-skincare-and-confectionery-are-out-of-the-launch-scope-c`)
  Rian, 11 Sep: keep collecting both, but Adam must not read them as a promise, so they exist nowhere on the storefront: taxonomy.HIDDEN_CATEGORIES applied by catalog_queries.shown_category to every list, count, brand page and the sitemap. Perfume and makeup stay in scope. A product page in a hidden category still opens from a direct link.
  *Proceeding under:* hidden everywhere on the storefront; product pages still open by link
  **RESOLVED 2026-09-10 by rian (in chat, 11 Sep):** Decided and shipped 11 Sep: taxonomy.HIDDEN_CATEGORIES, commit 9b98ab3.
- **One page per fragrance line, with its sizes and variants on it, instead of one page per bottle** (raised 2026-09-11 by planning session; id `decide-one-page-per-fragrance-line-with-its-sizes-and-variants`)
  Rian's idea, looking at the 1 Million rows: someone searching '1 Million' should land on one page for the line, with the sizes and variants listed and prices under each, rather than five thin pages. It changes what a product address means, so it is cheaper to decide before launch than after.
  What it takes: a line above the product (the brand plus the name with size, concentration and edition stripped), a column on products pointing at it, the product page becoming a line page where the variant and size are chosen, and every existing product address redirecting to its line with the right variant preselected. The comparison itself stays exactly where it is, at one variant in one size, because a 50 ml against a 100 ml is not a saving.
  What it is worth, measured today: drinks barely move, 9,089 products across 9,047 lines, because a bottle is usually one size. Beauty is where it pays, 987 products sitting in 362 lines, so those become 362 stronger pages instead of 987 thin ones. It also answers Mark's thin-content worry, because a line page with several sizes is a real page even when no single size is stocked at two airports.
  Options: after the soft launch, as a considered change · before Cannes, for the demo · not doing it; one page per bottle stands
  *Proceeding under:* one page per bottle stands until you say otherwise
  **RESOLVED 2026-09-16 by rian:** Decided 16 Sep, generalised beyond fragrance: /products/ shows the line for every family. Section 2.7 of catalogue-model-decisions-2026-09-15.md.
- **Makeup shades: does the shade leave the line key?** (owner rian; raised 2026-09-13 by rian (in chat, 13 Sep); id `decide-makeup-shades-one-product-with-shade-variations-decided`)
  Deferred on 13 Sep with rian's leaning recorded, and the Makeup line key keeps the shade tail until he settles it, the kind held as metadata beside it.
  Reopened 15 Sep: this now BLOCKS the line page. Because the shade is in the line key, every shade is its own line, so 'Blush Subtil 03 Sorbet De Corail' and 'Blush Subtil 041 Figue Espiegle' are separate product_lines rows. A cosmetics line page therefore has nothing to group, and the question of what a comparison grid shows for a line with many shades cannot even be tested. See .logs/planning/variations-and-line-pages-2026-09-15.md section 4b.
  See `.logs/planning/variations-and-line-pages-2026-09-15.md`
  **RESOLVED 2026-09-15 by Claude (catalogue decisions session, 15 Sep):** Decided (§2.3 of .logs/planning/catalogue-model-decisions-2026-09-15.md): the shade leaves the Makeup line key; identity rules v5 on the branch, rehearsed (761 Makeup lines to 453, 0 products folded)
- **Category-at-airport pages: ship them with the structure, or hold them back and price them later?** (raised 2026-09-13 by planning session; id `decide-category-at-airport-pages-ship-them-with-the-structure-o`)
  The facts. The accepted quote has no line for them: it promises airport pages ('a page per airport carrying its pricing, its exclusives and your editorial, with the comparison tool preset to that airport'), brand pages, a mega menu, and two further product types. Category-at-airport pages came from the structure proposal we wrote for Mark, which calls them 'the commercially strongest page type on the site' and counts 'around a hundred'; Mark endorsed them in his review and Adam read the page without objecting, but nothing in writing commits you to them. Stream G has now built them (96 pages at the 15-product bar), unpaid.
  Hiding them is one number: the coverage bar constant set impossibly high makes every one a 404 with no redirect and nothing renamed, so they can be switched on the day they are paid for. The cost of hiding: Mark will notice the strongest search page is missing, and the proposal page he read lists it as planned. The cost of shipping: work delivered outside the quote with nothing invoiced, which the next statement of work can still name as delivered value.
  Options: ship them as part of the structure, and name them as delivered value in the next statement of work · hold them behind the bar, offer them as a priced item · ship a handful (say the top ten pairings) and hold the rest
  *Proceeding under:* they ship, since the bar constant can hide them in a minute if you decide otherwise
  Gates: G7 (costly)
  **RESOLVED 2026-09-13 by rian (in chat, 13 Sep):** Hide them for now. Rian: we are already overdelivering; save them for the next quote rather than turn built work into a conversation about charging. Kept behind a feature flag (off) so they switch on the day they are quoted; the sitemap and the airport page's rail must not advertise them while hidden.
- **The database structure changes you want, starting with places and shops** (owner rian; raised 2026-09-15 by Claude (table layout session); id `decide-the-database-structure-changes-you-want-after-the-listin`)
  The whole structure is on one page (47 tables, 465 columns, 83 keys between them): the private artifact named in .memory/database-map-artifact.md, and the Relationships block in main/docs/DATA-MODEL.md.
  The first area you flagged is written up in detail, with no plan attached by your instruction: .logs/planning/places-and-shops-2026-09-15.md. It records six gaps - a place has no row of its own (only an IATA code on each shop); two shops at one airport cannot be told apart, and the comparison already counts shop rows in sixteen queries and airport codes in one; one storefront can serve several places (Extime is CDG and Orly); the chain is the free-text operator column for Heinemann's four rows but the retailer row for Avolta's ten; the airport code is in public addresses and the API, not just the code; and opening hours hang off 'the first shop row by id' for want of a place to attach to. It ends with the seven questions a refactor plan must answer and the five standing rules it must not break.
  Nothing is scheduled. When you have been through your other issues, a session writes the refactor plan against all of them together.
  See `https://claude.ai/artifact/3muVVp2ZJanzGcQWTtw7hf`
  See `.logs/planning/places-and-shops-2026-09-15.md`
  **RESOLVED 2026-09-15 by Claude (catalogue decisions session, 15 Sep):** Decided for rian at his request: .logs/planning/catalogue-model-decisions-2026-09-15.md (vocabulary, one ledger, rules v5, review gate, AI as proposer, places direction, rename wave); his to overturn
- **The line page as the primary page, and the variation versus determinant split** (owner rian; raised 2026-09-15 by Claude (data model session); id `decide-the-line-page-and-the-variation-determinant-split`)
  Your proposal: the line page replaces the product page as the thing we optimise for; every determinant and every variation still creates its own product row, so one product keeps one barcode; and new metadata on the variation kind says whether it is a determinant (quantity, concentration, vintage: the shopper picks it and the comparison regenerates) or a variation (shade, flavour: not selectable at all, just listed as what the shop carries, because nobody is purchasing from us).
  Evidence gathered 15 Sep, in .logs/planning/variations-and-line-pages-2026-09-15.md. Your price test holds for shade (18 shades of one lipstick, two prices) and breaks for concentration (1 Million EDT and Elixir at the same size are 19 percent apart), so concentration is a determinant by your own rule though the code files it with shade. Keeping shades as separate products costs no comparability: shade-bearing products are the most comparable in the catalogue at 62 percent against 14 percent for products with no variation. The thin-content case for line pages does not hold (3,130 product pages become 2,638 line pages, and 90 percent of those hold one product), but the search-intent case does.
  Reverses a settled decision: the /products/<name>-<id> address was settled 9 Sep with Mark and Adam, so the page shape goes back to them before anything is built. Six open questions in section 5 of the document, including what price a line page prints for a variation nobody selects.
  See `.logs/planning/variations-and-line-pages-2026-09-15.md`
  **RESOLVED 2026-09-15 by Claude (catalogue decisions session, 15 Sep):** Decided (§2.4, §2.7 of .logs/planning/catalogue-model-decisions-2026-09-15.md): kinds carry a behaviour (determinant or descriptor) in code; the line page is a recommendation rian takes to Mark and Adam (new do item)
- **Should a product line membership be a spot-check?** (raised 2026-09-16 by Stream K4; id `decide-should-a-product-line-membership-be-a-spot-check`)
  REVIEW-PROCESS.md section 4 item 1 said 'every proposal that merges variants or changes a comparison'. K4 reads 'a comparison' as the price comparison of one variant across shops, so it marks a pair confirmed same, a merged_into, a listing pin and a quantity, and does NOT mark a variant moving from one product line to another (it changes which page shows the variant, not what its price is compared with). Version 3 of the file says so. On the CHANEL rehearsal that left 8 of 48 rows as spot-checks; marking every membership as well would have made 25 of 48.
  Options: Keep it: memberships are approved in bulk unless low-confidence or sampled (as built) · Mark every membership that moves a variant to a different existing product line (one line in proposals.spot_checks and the doc)
  *Proceeding under:* Kept as built; the Saturday pass runs under version 3. Changing it is one line of code plus a version bump; rows already approved stay approved.
  **RESOLVED 2026-09-17 by rian:** Keep as built: a membership is not a spot-check. Marking them would have taken the CHANEL sheet from 8 spot-checks to 25.
- **A stated strength against a silent name: key apart (built) or join?** (raised 2026-09-16 by Stream K3; id `decide-a-stated-strength-against-a-silent-name-key-apart-built`)
  Identity rules v6 put a percentage the NAME states into the key, as the plan's W9 note says ('the differing ABV is part of that key'). So 'Casamigos Mezcal 40% 1L' and another shop's 'Casamigos Mezcal 1l' no longer join by rule: on the staging copy 84 of the 699 listings that key apart under the chosen boundary do so for this reason alone. Nothing is lost at the next collection (a placed listing stays placed), but a NEW shop's silent listing will not join a variant whose name states the strength until a person confirms it. The alternative keeps the strength out of the key and only vetoes 40 against 43; it joins more, and it glues a silent 'Lindt Excellence 100g' to whichever of the 70% and 85% bars arrived first.
  Options: key apart; a person confirms the pair (assumed, built) · join a silent name to a stated one; veto only a stated difference
  *Proceeding under:* key apart. Switching is a rules version (v7): one function (product_lines.identity_slot), a rederive, and the merges read again.
  Gates: K7.1 (costly)
  **RESOLVED 2026-09-17 by rian:** Keep as built: a stated strength keys apart from a silent name. Rian, 17 Sep: a split a person can fix beats a wrong join nobody sees; the programmatic pass finds what could match, the AI pass judges, the human approves quickly.
- **Two ingest rules K3 added beyond its brief: read and object if wrong** (raised 2026-09-16 by Stream K3; id `decide-two-ingest-rules-k3-added-beyond-its-brief-read-and-obje`)
  (1) A person's decision on a variant (its line, an attribute, its quantity) never moves the variant's key. The brief said a decided variant's key is its effective key; built that way, the variant's own listing would key elsewhere at its next sighting and mint a duplicate beside every reviewed variant. The decision still changes what the page reads, and the automatic merge still refuses a group holding a decided variant. (2) A listing already placed stays on its variant while the shop's words for it are unchanged, unless a barcode, a quantity or a stated attribute contradicts. Without it the 699 listings the word lists once joined would each have split off at their next sighting, taking 114 comparisons, silently.
  Options: keep both (assumed) · drop (2): let the rules re-place every listing at every sighting, as before
  *Proceeding under:* both kept; each is pinned by a test (tests/test_decided.py, tests/test_arrivals.py).
  Gates: K7.1 (info)
  **RESOLVED 2026-09-17 by rian:** Keep both. Without them every reviewed variant would spawn a duplicate at the next collection, and 699 listings would have split off silently.
- **Should the browse grids show one card per product line instead of one per variant?** (raised 2026-09-17 by Stream K5; id `decide-should-the-browse-grids-show-one-card-per-product-line-i`)
  Plan W20 says the representative variant survives only on cards; the brief's K5.4 applies it to cards. K5 kept every grid (browse, airport and brand shelves, home strips, related rail) one card per product variant, each card opening its own comparison on the line page with that variant chosen and its airports, and applied the representative rule where one entry stands for a line (search suggestions, now one per line). One card per line would change pagination, the featured-savings selection and every count the grids print (a line with a 1 L and a 70 cl at different airports has no single comparison to show on a card).
  Options: Keep one card per variant, deep-linked (as built) · One card per product line showing its representative variant's comparison (a grid query change and a count change)
  *Proceeding under:* Kept per variant; a card still names its bottle, and the line page shows the rest.
  Gates: K5.4 (costly)
  **RESOLVED 2026-09-17 by rian:** Keep as built: one card per product variant. Rian also wants a product line CARD later, linking to the line with no price or comparison on it; filed as its own issue.
- **The floor that makes an airport or place a candidate for indexing: fifteen published variants?** (raised 2026-09-17 by Stream K6; id `decide-the-floor-that-makes-an-airport-or-place-a-candidate-for`)
  publish.PLACE_MIN_VARIANTS. A rule only SUGGESTS indexing on /review; you approve each page. Fifteen is the category-at-airport bar already in use. A brand's rule is the 9 Sep floor (3 variants across 2 places); a product line's is one variant compared across 2 places. Changing the number is one constant and a re-run of 'index suggest'; nothing is de-indexed by it.
  Options: fifteen published variants (assumed) · another number · every airport with a visible shop
  *Proceeding under:* fifteen
  Gates: K6.5 (costly)
  **RESOLVED 2026-09-17 by rian:** Keep fifteen published variants; a rule only suggests, rian approves each page.
- **A brand's address cannot follow the chosen name yet: keep the fold slug, or add an address column?** (raised 2026-09-17 by Stream K6; id `decide-a-brand-s-address-cannot-follow-the-chosen-name-yet-keep`)
  brands.slug is three things at once: the page address, the fold key ingest finds the row by (ingest.resolve_brand), and the ledger's natural key (brand:<slug>, resolved with no redirect). Renaming it at confirm would make the next collection mint the brand again under the old slug and park every replayed brand decision. K6 therefore fixes the final slug at confirm for PRODUCT LINES only (found by key, never by slug); a brand keeps its slug and its alias rows forward to it (301, chain followed). To let /brands/macallan become /brands/the-macallan needs a separate address column (K2 schema) and the brand resolver reading redirects (K2 natural_keys), then publish.slug_at_confirm gains four lines.
  Options: keep the fold slug as the address (assumed) · add brands.address after launch
  *Proceeding under:* keep; brand aliases forward through the alias row
  Gates: K6.3 (costly)
  **RESOLVED 2026-09-17 by rian:** Keep the fold slug for launch. Rian accepts it, and separately asks for a manual slug-rename mechanism with a redirect, for the day a brand renames itself; filed as its own issue.
- **The brands index still lists only brands over the floor (364, not 1,498): right?** (raised 2026-09-17 by Stream K6; id `decide-the-brands-index-still-lists-only-brands-over-the-floor`)
  Every brand now has a page, linked from its product variants. The /brands index could list every brand with a priced variant (1,498 on the 17 Sep staging copy) or stay at the floor (364). Most of the extra 1,134 are single-variant brands and spellings nobody has reviewed yet. One default in catalog_queries.list_brands.
  Options: stay at the floor (assumed) · list every brand
  *Proceeding under:* the floor
  **RESOLVED 2026-09-17 by rian:** Keep the floor for launch: it is what Adam and Mark approved. Rian reserves the argument that every brand could be listed, since all pages are noindex until approved; the cost is noise before the review, not risk.
