# Decisions and actions for rian
<!-- GENERATED by main/scripts/items.py export on 2026-09-15 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-15.

## 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`
- **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
- **Makeup shades: one product with shade variations, decided after the first merge session** (raised 2026-09-13 by rian (in chat, 13 Sep); id `decide-makeup-shades-one-product-with-shade-variations-decided`)
  Deferred on 13 Sep: makeup stays out of the merge session for now (Stream M's assumption stands), which costs nothing on comparisons, because two shops listing the same shade still match automatically; only cross-shop naming variants of makeup go unmerged until this is settled. Rian's stated leaning, to be the default when it is decided: different shades of the same product are one product with a shade variation, especially where they share a price, the same way concentrations are variations of a fragrance line. That is option (b) on Stream M's item, a shade attribute keyed like the concentration, one rule plus a rederive. Decide after the first merge session has run on perfume and drinks. When Stream M merges, its makeup item resolves into this one.
  Gates: M6 (costly) — 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.
- **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

## 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
- **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`
- **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.
- **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.
- **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
- **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-11 by rian (in chat, 12 Sep):** Decided: one page per real product; sizes and variations chosen on the page; every listing standardised into brand | name | variation | size; automatic matching where all four agree; merges at every level made by a person, with the machine suggesting and a mass confirm session. Built as Stream M (.logs/planning/streams/M-merging.md, /stream-m). The line page itself and its address redirects are a separate follow-on decision after the first merge session shows what the catalogue collapses to.
- **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.
