# Issues register
<!-- GENERATED by main/scripts/items.py export on 2026-09-20 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-20.

## P1: blocks the launch

- **Production edge for the login: Caddy trusted_proxies on the droplet, Cloudflare cache bypass for the auth paths, Bot Fight Mode off** (P1; owner E; raised 2026-09-10 by planning 10 Sep; id `issue-production-edge-for-the-login-caddy-trusted-proxies-on-t`)
  The login throttle keys on CF-Connecting-IP through Caddy's trusted_proxies; without the same block on the droplet's Caddy every visitor shares one key and ten wrong attempts lock everyone out. The zone's cache rules must bypass /api/*, /login, /forgot, /welcome/*, /reset/*, /account*, /admin* on both zones, Bot Fight Mode and managed challenges must stay off so the login POST is never challenged, and the domain password comes off only after the curl list in the accounts plan section 7 holds. Add to the E7 checklist.
  Gates: E7 (blocking) — the work it gated has gone ahead under the assumption; settling it now avoids a costlier change later
- **Dubai refuses our reader by name at the Akamai edge** (P1; raised 2026-09-03 by Stream A; id `issue-dubai-refuses-by-name`)
  Since 3 Sep: our declared identity gets 403, a generic browser identity gets 200, robots.txt refused too, error page from Akamai. Last good run 22 Aug with the same identity, so their rules changed. Dubai is about 39 percent of launch products and 77 percent of barcodes. Deliberately not bypassed. Path decided: the identity page becomes its own static project, the reader is repointed once it serves, the ask goes to Dubai when you judge the relationship ready, and Dubai launches on dated 21 Aug prices. Open: the hostname decision and your Dubai conversation.
- **The proposal files under import/proposals carry staging row ids and must never be filed on production** (P1; owner L; raised 2026-09-15 by Claude (catalogue decisions session, 15 Sep); id `issue-the-proposal-files-under-import-proposals-carry-staging`)
  import/proposals/2026-09-15-brands.json and -lines.json were written from a copy of staging; on production the same ids are other brands and lines, and the RUNBOOK's b3c4 block lists the propose commands as if they were portable. Ticking 'all proposed' on production would alias unrelated rows as rian's decision. The RUNBOOK now warns; the durable fix is a proposals file keyed by natural keys (brand slug, line key) or stamped with the database it was written from, which propose refuses to apply elsewhere.
  See `.logs/planning/catalogue-model-decisions-2026-09-15.md`

## P2: silently corrupting data or blocking a page

- **The mail lines (MAIL_PROVIDER, MAIL_FROM, RESEND_API_KEY) need the app-only env file before they are set: the compose split, with this review** (P2; owner W; raised 2026-09-11 by Stream R2; id `issue-the-mail-lines-mail-provider-mail-from-resend-api-key-ne`)
  T6 wired mail to the end (the Resend provider, the daily caps in email_sends, the notification digest, the mail preference) and set nothing: every path is inert until the three MAIL_* lines exist, and they must not go into .app.env, which docker-compose.yml hands to the database container as well (env_file on both services). W3's split is the compose change; the security review Stream R2 owes it is here so W3 can act on it without a second reading.
  Review. Port binding: unchanged (the app stays on 172.17.0.1:PORT, the database publishes nothing). Authentication: unchanged. Network exposure: the app makes one new outbound HTTPS call per message to api.resend.com with the key in the Authorization header only; no inbound path. Credential storage: RESEND_API_KEY is a bearer credential able to send mail as the site; it belongs in a file only the app container and rian read (0600, the srv-gateway ACL as for .app.env), referenced as a second env_file on the app service only, never on db or browser; the browser sidecar keeps no env_file at all. Container isolation: no new capability, no mount. Automated execution: notify digest runs from host cron, reads the database, sends at most 40 mails per person per day and 25 invites per host, and cannot be triggered from a request; the caps are the bound if the key leaks. Blast radius of a leak: mail sent in the site's name until the key is rotated at Resend; rotation is one line and a container recreate. After the change: srv-gw security-audit.
  Proposed compose shape for W3 (not applied by R2): under services.app add a second env_file entry, .app-only.env, holding only the MAIL_* lines (and any later app-only secret); .app.env keeps the database lines both containers need. Stage .app-only.env with REPLACE_WITH_ placeholders, chmod 600 plus the gateway ACL, and add it to .gitignore beside .app.env. Nothing else in the compose file moves.
  Options: W3 splits the env file as proposed (assumed) · keep one env file and accept that the database container reads the mail key
  *Proceeding under:* the app-only env file lands with W3; until then no MAIL_* line is set anywhere
  Gates: W3 (costly) — blocks W3 (An app-only env file in compose so the database container reads no app secret: security review, the change staged and audited, the droplet twin as a checklist for rian), due 2026-09-17
  See `main/docs/RUNBOOK.md`
  See `main/app/cli_notify.py`
- **No scheduler and no freshness alarm yet** (P2; owner E; raised 2026-08-27 by issues register; id `issue-scheduler-freshness-alarm`)
  Prices must be current in late September. Collection stays hand-run, but the nightly audit, verify after any collection, the weekly verify, and an alert when a store returns far less than its last run (the Heathrow lesson) all need a cron home, decided by where the collectors run. Stream E task E5.
  Gates: E5 (blocking) — the work it gated has gone ahead under the assumption; settling it now avoids a costlier change later
- **The database container reads every line of .app.env; split an app-only env file before any mail or Google secret lands** (P2; owner E; raised 2026-09-10 by planning 10 Sep; id `issue-the-database-container-reads-every-line-of-app-env-split`)
  docker-compose.yml gives the db service env_file: .app.env, so ACCOUNT_OWNER and any future MAIL_API_KEY or Google secret land in the postgres container's environment. R1 adds no new secret by design. Before R2's mail key: a separate app-only env file (or a db-only one) in compose, a security review first, then srv-gw security-audit; the same shape on the droplet. Also tighten .app.env, but not with a plain chmod 600: the gateway's docker compose reads env_file as srv-gateway through the project group's ACL, and chmod 600 sets the ACL mask to nothing so srv-gw deploy fails at config load. Use setfacl -b .app.env && chmod 600 .app.env && setfacl -m u:srv-gateway:r .app.env, verify with sudo -u srv-gateway test -r .app.env, and note that srv-gw fix-permissions re-widens it (ideas.md 2026-08-04). The RUNBOOK's mode wording carries the same recipe.
  Gates: E7 (costly) — the work it gated has gone ahead under the assumption; settling it now avoids a costlier change later
- **identity_mode is a column; nothing enforces the consent rule** (P2; owner A; raised 2026-09-09 by Stream N; id `issue-identity-mode-is-a-column-nothing-enforces-the-consent-r`)
  The rule (agents.md, build plan section 4 item 11, COLLECTORS.md): a source runs in a browser-like identity only when its permission_record names who agreed, when and how, and without the record the collector refuses to switch. Migration #1 added sources.identity_mode and permission_record; no code reads either (grep across app/ and tests/ finds only the model). Today that is safe by absence, since every collector sends the declared identity and no path can switch. The refusal itself must exist before any switch does: fetch() (or the collector base) reads the source's identity_mode, sends the browser-like headers only when permission_record is non-empty, and raises otherwise, with a test that a source set to browser_like and no record is refused.
- **Uploads land root-owned on the host because the app container runs as root** (P2; owner E; raised 2026-09-09 by Stream N; id `issue-uploads-land-root-owned-on-the-host-because-the-app-cont`)
  Noted in the 5 Sep to-do page handoff and never filed: the container's process runs as root, so a file Adam hands in through /todo is written to the mounted uploads/ directory owned by root, and neither rian nor a session can move or delete it without sudo. Fix on the infrastructure side: run the app as a non-root user in the Dockerfile with the uploads directory chowned to it (or a matching uid on the host); the production droplet should start that way rather than inherit the habit. Check by behaviour: a test upload lands owned by the app user.
- **2,422 liquor products carry no category, so they sit on no shelf** (P2; owner A; raised 2026-09-10 by Stream E; id `issue-2-422-liquor-products-carry-no-category-so-they-sit-on-n`)
  Counted on staging 11 Sep: 2,422 products with vertical liquor and category NULL (881 of them at no visible airport). They are searchable and appear in airport and brand lists but on no category shelf and in no menu count. The categories backfill only fills rows its rules recognise; the rest need either wider rules (the retailer's own family label is stored in raw_records) or a rederive pass. Worth doing before B6's category pages, which would otherwise undercount every shelf.
- **When sponsor creative arrives, the server-rendered article and airport pages must follow the browser's layout** (P2; owner B; raised 2026-09-11 by Stream C; id `issue-when-sponsor-creative-arrives-the-server-rendered-articl`)
  The server copies of pages (seo.py article_body, airport_body) draw every page as it is with no sponsor creative. Once creative is added to web/src/lib/sponsors.ts (SPONSORS or NATIVE_SPONSORS), two layouts change in the browser only: an article with the skyscraper gets a right-hand rail (the text is no longer centred), and the airport shelf with its native tile lists 23 bottles per page instead of 24. If the mirrors are not updated in the same deploy, those pages shift visibly as they load and crawlers see 24 bottles per airport page (and different page boundaries) where shoppers see 23.
  Fix when the first creative lands: move the active-placement registry somewhere both sides can read (for example a JSON file the server loads and the SPA imports, or flags in the shell like window.__DFP_FLAGS__), then have article_body draw .article-page__layout--rail and airport_body page by 23 whenever the matching placement has creative. Previews switched on from /settings are per browser and never need the mirror.
  See `main/docs/CLIENT-SURFACES.md`
- **Before launch the footer needs a contact page; privacy and terms now exist** (P2; owner C; raised 2026-09-11 by Stream C; id `issue-before-launch-the-footer-needs-privacy-terms-and-contact`)
  Privacy and terms landed on 20 Sep as /privacy and /terms (main/docs/legal/*.md, read per request; the footer's bar links both). A contact page is still missing. Note for whoever builds it: the explainer rule /{page}.html is a development route since the environment line (access.DEVELOPMENT_ROUTES), so a contact page needs its own route, as the legal pages have (seo.LEGAL_PATHS).
- **A litre pattern reads the 5 in a name like N°5 L'EAU as five litres** (P2; owner A; raised 2026-09-11 by Stream M; id `issue-a-litre-pattern-reads-the-5-in-a-name-like-n-5-l-eau-as`)
  main/app/services/normalize.py parse_size and parse_size_ml: the litre pattern (\d+)\s*l\b matches '5 L' inside "N°5 L'EAU 100ml" (the apostrophe is a word boundary), so the Bogota and Panama Shopify listings of CHANEL N°5 L'EAU 100ml (listing tiles say '100 ml') are stored as size 5.00 l, 5000 ml, and can never match the other shops' 100 ml row. Seen on the /collectors Listings view, 'Size differs' filter, 11 Sep staging copy. Suggested fix: try ml and cl before the litre patterns, or refuse a litre token followed by an apostrophe or a letter; then backfill implausible_sizes (5000 ml is over the beauty ceiling) and rederive. A test with the real name belongs in tests/test_normalize.py.
- **Thirteen Extime products are named No, Yes or nothing** (P2; owner A; raised 2026-09-11 by Stream M; id `issue-thirteen-extime-products-are-named-no-yes-or-nothing`)
  On the 11 Sep staging copy, 13 alive products carry the name 'No', 'Yes', 'N/A' or an empty string; eleven are Plisson at CDG (ids 11374, 11402, 11405, 11507 and seven more; URLs like extime.com/en/paris/product/beard-oil-105585111, natural-shaving-cream-tube-105585117). The Extime collector reads a field that holds a yes/no flag for these pages instead of the product name (the URL slug carries the real name). Seen on the Listings view and as a line called 'No' with eleven products under Plisson. Suggested fix in collectors/extime.py: refuse a name that is a bare yes/no or empty and fall back to the JSON-LD name or the slug; a test with the real fragment; then rederive.
- **The Spanish shelf words (Labios, Rostro, Ojos, Unas) are unknown to the taxonomy, so 384 marked-shade beauty rows have no category and keep their shade in the line** (P2; owner A; raised 2026-09-15 by Claude (catalogue decisions session, 15 Sep); id `issue-the-spanish-shelf-words-labios-rostro-ojos-unas-are-unkn`)
  Section 5 of the decisions. Every CHANEL Rouge Allure row carries the marked shape (ROUGE ALLURE 3.5gr / 104 PASSION) but no category, because the shop's shelf is 'Labios' and the name has no English makeup word; the shade reader is gated on Makeup, so the 59 lines stayed 59. Labios and Unas are pure makeup shelves in the data; Rostro (230 of 533 names carry a skincare word) and Ojos mix creams with makeup, and classify() lets a shelf hint beat every name word, so mapping the shelves would file creams as makeup and read their skin-type tails as shades. The fix is a rule that lets a skincare name word beat a shelf hint, with those rows as its test; then backfill lines and rederive collapse the 384.
  See `.logs/planning/catalogue-model-decisions-2026-09-15.md`
- **No undo for a confirmed alias or merge: build unalias and unmerge from the recorded moves before any 200-row desk batch** (P2; owner L; raised 2026-09-15 by Claude (catalogue decisions session, 15 Sep); id `issue-no-undo-for-a-confirmed-alias-or-merge-build-unalias-and`)
  A brand or line confirm re-lines, re-keys and folds by bulk UPDATE; product_merges.detail snapshots the two product rows but not the listing ids that moved, and nothing reverses an alias or a merge. A wrong confirm in a batch of 200 is reversible only by hand. Record the moved listing and award ids in the merge detail, build app.cli unalias brand|line <id> and unmerge <merge_id> that reverse from the records and re-key, and lower BATCH_MAX for brand confirms until they exist (decisions section 4, section 6).
  See `.logs/planning/catalogue-model-decisions-2026-09-15.md`
- **The staging refresh preserves the client's tables and none of the catalogue's decisions** (P2; owner E; raised 2026-09-15 by Claude (catalogue decisions session, 15 Sep); id `issue-the-staging-refresh-preserves-the-client-s-tables-and-no`)
  CLIENT_WRITTEN_TABLES covers discussions, quotes, to-dos and notifications; overrides, brands.canonical_id, product_lines aliases, product_merges, merge_candidates decisions and listing pins are dropped with the schema and rebuilt only as far as the after-deploy chain re-derives them. Any desk session on staging before a refresh from production is lost. Either the ledger, the aliases and the pair decisions join the preserved set under natural keys, or the first real desk session waits for production to carry the chain (decisions section 4, section 6).
  See `.logs/planning/catalogue-model-decisions-2026-09-15.md`
- **The product variant list's first page costs half a second before a card is drawn** (P2; owner Stream AW2; raised 2026-09-20 by Stream AW7; id `issue-the-product-list-s-first-page-costs-half-a-second-before`)
  GET /api/products?limit=24&offset=0&sort=featured answers in 554 ms p50 (628 max) on a restored copy served locally after ANALYZE, and catalog_queries.list_product_variants with the same arguments takes 951 ms median (1,112 max) over five runs inside the app container on staging. AW7's own threshold for the pass is p50 under 500 ms.
  Where it goes, measured part by part in the container: the SQL count over _filtered_summary 279 ms, featured_records for the 2,376 comparisons 489 ms, featured_rules.order over them about 15 ms, the fetch of the 24 rows with their cards 29 ms. _featured_ids already short-circuits the whole list (ranking all 11,251 records costs 1,189 ms), so what is left is the count plus one whole-catalogue aggregate per request.
  Not an index: EXPLAIN (ANALYZE, BUFFERS) on the count executes in 153 ms with 205,700 shared buffer hits, all cache, and the only sequential scans are price_observations (77,370 rows, 4.5 ms), shops (16) and shop_places (24). The shape is the cost, so the candidates are caching the ranked ids per filter set, or materialising the comparison figures. Evidence: .logs/verification/quality-pass-2026-09-19.md, AW7.4.
- **The category page is the site's heaviest read: four whole-catalogue passes in one handler** (P2; owner Stream B; raised 2026-09-20 by Stream AW7; id `issue-the-category-page-is-the-site-s-heaviest-read-four-whole`)
  catalog_queries.category_detail for Whisky takes 1,236 ms median and 1,598 ms max over five runs inside the app container on staging, the slowest read measured in the pass. The served page /alcohol/whisky answers in 706 ms p50 (718 max) on the local copy after ANALYZE, and GET /api/categories/whisky in 717 ms p50 (747 max). AW7's threshold is p50 under 500 ms and max under 1 s.
  Where it goes, timed part by part: _category_rows 289 ms (486 max), then three separate list_product_variants calls, one for the exclusives sample 181 ms, one for the savings sample 179 ms and one for the page of 24 206 ms, then _category_brands 16 ms, then category_page_summaries for the sibling categories 306 ms (610 max). Each list call runs its own count over _filtered_summary, so the handler counts the catalogue three times to draw one page.
  Not an index: the statements behind it are the same shape as the product list's, whose plan is all cache hits with sequential scans only on price_observations (77,370 rows), shops (16) and shop_places (24). The candidates are one shared row set for the three blocks, or computing the siblings from the category summaries the page already holds. Evidence: .logs/verification/quality-pass-2026-09-19.md, AW7.4.
- **The airport page recomputes every card figure over the whole catalogue on each request** (P2; owner Stream B; raised 2026-09-20 by Stream AW7; id `issue-the-airport-page-recomputes-every-card-figure-over-the-w`)
  catalog_queries.airport_detail for LHR takes 902 ms median and 1,108 ms max over five runs inside the app container on staging. The served page /airports/heathrow-lhr-london answers in 513 ms p50 (533 max) on the local copy after ANALYZE, 105,212 bytes, and GET /api/airports/LHR in 527 ms p50 (626 max).
  Where it goes: _airport_rows 392 ms (484 max) for 459 rows, list_product_variants for the exclusives sample 119 ms, list_product_variants for the page of 24 229 ms; airport_shops 6.5 ms, _airport_shop_views 2.4 ms, _airport_summary 0.8 ms. The body render itself is not the cost: seo.airport_body over a detail already in hand is 0.6 ms.
  Not an index: EXPLAIN (ANALYZE, BUFFERS) on the _airport_rows statement executes in 170 ms on the copy with 272,113 shared buffer hits, all cache, sequential scans only on price_observations (77,370 rows, 4.3 ms), shops and shop_places. The row estimates are 75 times low (rows=6 against 459 actual) because the filters are correlated subqueries the planner cannot see through; at this size it still picks a workable plan. Evidence: .logs/verification/quality-pass-2026-09-19.md, AW7.4.
- **Sponsor creative is meant to be served under /docs-static/sponsors/, which answers 404 on live** (P2; owner C; raised 2026-09-20 by rian's session, 20 Sep; id `issue-sponsor-creative-is-meant-to-be-served-under-docs-static`)
  web/src/lib/sponsors.ts documents the convention: creative files live under public/sponsors/ and are served at /docs-static/sponsors/<name>-vN.png. The environment line (AW6.2) put MOUNT /docs-static in access.DEVELOPMENT_ROUTES, so on live (SITE_ROLE=live) every such file would answer 404 and a sponsored slot would draw a broken image. Nothing is broken yet: no creative is configured and public/sponsors/ does not exist. Before the first real creative lands, serve it from a live path (its own mount, classified in access.py, or the uploads mount) and update the convention in sponsors.ts.

## P3: should fix before Cannes

- **Watch: the four Shopify shops refused us on 4 Sep and accepted us on 5 Sep** (P3; owner Q; raised 2026-09-05 by planning session; id `issue-watch-the-four-shopify-shops-refused-us-on-4-sep-and-acc`)
  On 4 Sep all four answered 403 to robots.txt for our identity; on the 5 Sep afternoon refresh, run with the identity link repointed to bot.dutyfreeprofessor.com, all four served robots.txt and their product feeds and collected normally. Either the refusal was transient or the platform's protection scored the old, non-resolving identity link. Nothing to do unless it recurs; if verify sees BLOCKED on any of them again, note whether the identity page was reachable at the time.
- **Watch: Gratien & Meyer at JFK, $77 against about $21 in Europe** (P3; raised 2026-08-25 by rian; id `issue-gratien-meyer-jfk`)
  Both prices verified real on the retailer's pages. Across 304 shared JFK/Europe products the median ratio is 1.07 and this bottle is the maximum at 3.74, so almost certainly the retailer's own entry error. Kept on its page (it is the published price), excluded from featuring by the corroboration rule. Does it correct on the next JFK crawl?
- **Per-size listings from configurable tiles carry no barcode** (P3; owner A; raised 2026-08-25 by rian; id `issue-variant-listings-no-barcode`)
  A size row emitted from a configurable tile has no barcode, so image enrichment cannot attach a photo and cross-shop matching falls back to names, which is what splits. Possible fix: read per-variant barcodes from the tile's configuration data when present.
- **Ghost listings at stores that could not be re-read** (P3; owner A; raised 2026-08-25 by rian; id `issue-ghost-listings`)
  The 25 Aug purge removed superseded plain listings only where a per-size sibling exists at the same store; stores whose tiles could not be re-read keep old listings, so a few barcode-less ghosts survive at hidden locations. Self-heals when those stores next crawl clean; the same purge pattern covers the reverse case if a tile ever goes from multi-size to single-size.
- **Extime: a few paths handled defensively but never seen for real** (P3; owner A; raised 2026-09-03 by Stream A; id `issue-extime-untested-paths`)
  An out-of-stock product's rendering, a genuine multi-variant product, and the French tree; also the services sitemap returns a server error, so sitemap coverage is not catalogue coverage there. The collector skips rather than guesses in each case.
- **The back office: six mechanisms parked with their columns already in place** (P3; raised 2026-09-04 by rian; id `issue-back-office-parked`)
  AI-assisted QA (a local model proposes brand merges, draft descriptions and likely inconsistencies; humans confirm in batch); per-source identity mode with a consent record; human data-entry fallback with a likely-stale flag; the merge review queue with recorded merges; verified-field semantics through the single overrides record; the reverification queue with scoring and human-fed re-ranking. The columns land in migrations #1 to #4 so nothing is thrown away; the behaviour waits for the back office after Cannes (plan section 4).
- **Per-platform recipes for the six text-fetch collectors** (P3; owner DOCS; raised 2026-09-09 by Stream N; id `issue-per-platform-recipes-for-the-six-text-fetch-collectors`)
  COLLECTORS.md carries a recipe only for the two rendered sources (Changi, Shilla). Avolta, Shopify, ARI, Extime, Dubai and the two Heinemann collectors are documented by their module docstrings, the posture table and the read_one table. One recipe each in the same shape (how discovery works, what a listing page yields, the skip reasons it reports, its robots posture and crawl delay, the fixtures that pin it). Half a day; the buffer week's WARN-tier doc pass (X4) is the natural home.
- **main.tsx imports app.css after the components, so a shared rule beats a component override** (P3; owner C; raised 2026-09-09 by Stream N; id `issue-main-tsx-imports-app-css-after-the-components-so-a-share`)
  Stream C's 5 Sep handoff: two fixes that night were worked around because app.css is imported after the component stylesheets, so a single-class rule in app.css wins over a component's own override of the same property. Root fix is the import order in main.tsx (app.css first, components after) with a check that nothing visibly changes on the six main pages.
- **mammoth's Markdown writer is deprecated upstream** (P3; owner D; raised 2026-09-09 by Stream N; id `issue-mammoth-s-markdown-writer-is-deprecated-upstream`)
  The Word hand-in reader (_read_docx in the articles import) uses mammoth's Markdown output, which its maintainers have deprecated. Isolated in one function; the swap is to mammoth's HTML output plus the project's own HTML-to-Markdown step, or to keep HTML for Word hand-ins. Nothing breaks today; it breaks on a future mammoth upgrade.
- **docs-check should warn when items.json is newer than its generated mirrors** (P3; owner DOCS; raised 2026-09-09 by Stream N; id `issue-docs-check-should-warn-when-items-json-is-newer-than-its`)
  The two markdown registers are regenerated by every items.py add and resolve, but a hand edit to import/items.json (or a crash between write and export) leaves .logs/issues.md and .logs/decisions-for-rian.md behind with nothing to say so. One WARN-tier line in docs-check.sh comparing mtimes, in the X4 pass.
- **Settings still lists the editorial sample key, which gates nothing** (P3; owner C; raised 2026-09-09 by Stream N; id `issue-settings-still-lists-the-editorial-sample-key-which-gate`)
  CLIENT-SURFACES.md documents it as harmless: the home page's sample editorial block was replaced by the real latest-articles block, so the 'editorial' switch on /settings toggles nothing. Remove the key from FEATURES and the settings page, and drop the sentence from CLIENT-SURFACES.md in the same change.
- **docs-check --strict trips CLIENT-SURFACES.md at every checkpoint because import/ is one of its sources** (P3; owner DOCS; raised 2026-09-09 by Stream N; id `issue-docs-check-strict-trips-client-surfaces-md-at-every-chec`)
  The strict gate compares the commit date of a doc with the newest commit touching each of its named sources. CLIENT-SURFACES.md names import/ (the plan and running-list files the /plan page reads live), and every checkpoint commits import/progress.json and import/items.json, so the doc reads as stale the moment a session records its status, with nothing in the doc to update. Either the gate should ignore data files that are trackers by design (import/*.json, .logs/verification/), or the doc should name main/app/routers/plan.py and items.py as the sources instead of the data directory. Found on the night of 10 Sep, when the closing commit produced the only strict fail.
- **Before a second uvicorn worker: a login_attempts table, and a levels memo** (P3; owner R; raised 2026-09-10 by Stream R; id `issue-before-a-second-uvicorn-worker-a-login-attempts-table-an`)
  The per-address login throttle, the unknown-name throttle and the argon2 semaphore live in the process (routers/auth.py, services/passwords.py). One worker is the launch assumption written into ACCOUNTS.md and the RUNBOOK; a login_attempts table keyed on address and folded name, plus a short-TTL memo over the kit's levels reads, come before a second worker or a second host. Also: the account page offers sign out on this device and a password change (which signs every other device out); a separate sign-out-everywhere button is R2 polish.
- **Let's Encrypt cannot validate the zone; production runs on a Cloudflare Origin CA certificate** (P3; owner E; raised 2026-09-10 by Stream E; id `issue-let-s-encrypt-cannot-validate-the-zone-production-runs-o`)
  Fifteen attempts on 10 Sep failed at Let's Encrypt's secondary validation (networking error looking up TXT and CAA at the zone's nameservers) although the challenge record was published correctly and every public resolver saw it. Caddy serves a 15-year Origin CA certificate instead, which only Cloudflare trusts, so the zone must stay proxied (orange) and Full (strict). If the zone is ever un-proxied, revisit: the DNS module and its token are still configured in deploy/caddy/.
  See `.logs/runs/production-first-deploy-2026-09-10.log`
- **The fetch port's block markers miss two challenge pages served behind HTTP 200 (Incapsula, Radware)** (P3; owner A; raised 2026-09-11 by Stream G; id `issue-the-fetch-port-s-block-markers-miss-two-challenge-pages`)
  main/app/services/collectors/fetch.py, _looks_blocked: on 11 Sep www.parisaeroport.fr answered an Incapsula frame ('Request unsuccessful. Incapsula incident ID') and www.torontopearson.com a 'Radware Captcha Page' (H1 'We apologize for the inconvenience'), both HTTP 200, and fetch() returned them as pages. Add 'request unsuccessful', 'incapsula', 'captcha' and 'we apologize for the inconvenience' to the markers so every collector treats them as SourceBlocked. The hours collectors judge these on content themselves meanwhile (services/hours/base.py looks_challenged), which is a second copy of a rule that belongs in the port.
- **Mount the opening hours provenance as a per-airport row on /collectors** (P3; owner M; raised 2026-09-11 by Stream G; id `issue-mount-the-opening-hours-provenance-as-a-per-airport-row`)
  The per-airport view of the review area (rian's stated order, third item) is where the hours provenance lands. Stream G built the read and the component; CollectorsPage.tsx is Stream M's file during wave one, so this is the exact mount rather than an edit. Import: import { hoursProvenanceLine } from "../components/AirportHours"; (main/web/src/components/AirportHours.tsx) and, per airport row, fetch GET /api/airports/{iata}/hours (shape {kind: collected|hand|none, text, observed_at, entered_by_username, source_url}; a hidden airport answers 404) then render hoursProvenanceLine(...) or nothing at all when kind is none; the AirportDetail's guide.hours_provenance carries the same fields (kind, observed_at, entered_by_username, source_url, source_host) if the page already holds the detail. Wording rule: a date, never 'verified', never a rate. Per airport it should read: 'Hours collected 11 Sep 2026 from www.heathrow.com' or 'Hours entered by rian 12 Sep 2026' or nothing.
- **V2 (the per-airport view) carries the opening hours provenance** (P3; owner R; raised 2026-09-11 by Stream G; id `issue-v2-the-per-airport-view-carries-the-opening-hours-proven`)
  When Stream R3 builds V2, the per-airport view of the collection page, it shows per airport whether the hours were collected or hand-entered and the date, and nothing at all where neither exists (rian's decision of 11 Sep, AWAY-PLAN.md section 3). The read is GET /api/airports/{iata}/hours and the line is hoursProvenanceLine() in main/web/src/components/AirportHours.tsx; the shape is written in main/docs/COLLECTORS.md under Opening hours. The same issue is filed for Stream M's /collectors mount during wave one; whichever page survives the consolidation carries it.
- **DATA-MODEL.md: the airport_hours table's rationale, beside its generated row** (P3; owner DOCS; raised 2026-09-11 by Stream G; id `issue-data-model-md-the-airport-hours-table-s-rationale-beside`)
  main/docs/DATA-MODEL.md is Stream M's file during wave one, so Stream G left its hand-written part alone; the generated Tables block already lists airport_hours. Paragraph to add under 'Why it is shaped this way': Opening hours are observations like prices: one row per reading (airport_hours), collected from the airport operator's site or entered by hand (source_kind), dated (observed_at), the hand row named to an account (entered_by_id, a who-column), nothing deleted; the reader shows the newest hand row over any collected one, so a human value is never overwritten by a machine and a later collected reading is stored unshown. location_id points at one of the airport's shop rows, the way every airport fact is keyed; text is the one line the page prints and detail the stores behind it (services/hours/store.py, COLLECTORS.md Opening hours).
- **Product cards and pages show the brand as one shop spelt it, not the house's preferred name** (P3; owner B; raised 2026-09-11 by Stream M; id `issue-product-cards-and-pages-show-the-brand-as-one-shop-spelt`)
  catalog_queries.list_products and get_product return products.brand (the spelling the first shop used: 'PACO RABANNE', 'Paco Rabanne', 'Rabanne') while the brand page already resolves the alias to the house. Once a brand alias is confirmed in the merge session (brands.canonical_id, decided_by), every card and product page should show the house's name (brand_row -> canonical -> name) and link to its page; the collected spelling stays on the row as the raw record. Small change in catalog_queries (join brands, follow canonical_id once) and the BrandOut/ProductOut shapes; the line page (the follow-on rian decides) will need the same resolution for product_lines.canonical_id.
- **Product markup: a barcode that arrived through a merge is marked, and the JSON-LD should say so or leave it out** (P3; owner B; raised 2026-09-11 by Stream M; id `issue-product-markup-a-barcode-that-arrived-through-a-merge-is`)
  products.gtin_source is 'merge' when the survivor of a merge gained its barcode from the row folded into it (its own shops never published that barcode); NULL or 'collected' means a shop published it. seo.py writes gtin13 into the product page's JSON-LD; for a merge-sourced barcode either omit gtin13 or keep it only when the merged row's listing still stands at a shop that published it. Stream M's part (the marking, migration c6d7e8f9a0b1) is done; the markup decision is Stream B's.
- **Three platforms keep no fragment for most listings, so the Listings view cannot show their tile** (P3; owner A; raised 2026-09-11 by Stream M; id `issue-three-platforms-keep-no-fragment-for-most-listings-so-th`)
  raw_records exist for 11,098 of 23,771 listings on the 11 Sep copy. Extime keeps a fragment for 200 of 7,036 listings (only the targeted re-reads), Dubai and the three Heinemann-platform shops keep none, so the /collectors Listings view shows an empty left side for them and a rederive cannot re-read what they said. Each collector already builds the RawListing from a fragment; passing it as raw=facts_only(...) at every yield (the way Avolta, Shopify and ARI do) closes the gap.
- **Extime and Avolta mark a shade in the name without a separator, so their Makeup lines stay split by shade** (P3; owner A; raised 2026-09-15 by Claude (catalogue decisions session, 15 Sep); id `issue-extime-and-avolta-mark-a-shade-in-the-name-without-a-sep`)
  Extime writes ' - 447 Mellow Shade' and Avolta a bare '01' before the size ('Lash Idole Mascara 01 Midi 5ml'); neither is read (a rule guessing a shade from a number would read N°5 and Brush 13 as shades). Extime's makeup has no raw records until the recrawl. Add a reader per shape once its fragments exist to test against; empty beats guessed until then (decisions 2.3).
  See `.logs/planning/catalogue-model-decisions-2026-09-15.md`
- **The brand-page floor counts ignored listings, and the collectors page labels the parsed concentration as the variation** (P3; owner B; raised 2026-09-15 by Claude (catalogue decisions session, 15 Sep); id `issue-the-brand-page-floor-counts-ignored-listings-and-the-col`)
  Two small readers found while surveying for the catalogue decisions: catalog_queries._house_counts joins listings without live_listings(), so an ignored listing still counts towards the three-products-at-two-airports floor; collector_view._ours_of emits attributes.concentration under the key 'variation' (collector_view.py near line 731), so the products table shows the raw parsed concentration where the standard variation belongs.
- **Three Makeup products sit in the liquor vertical** (P3; owner A; raised 2026-09-15 by Claude (catalogue decisions session, 15 Sep); id `issue-three-makeup-products-sit-in-the-liquor-vertical`)
  Products 11055 (Benefit GoGotint), 11321 (Chachatint) and 11729 (High Beam) carry category Makeup and vertical liquor; 7307 (an Eight Hour Cream set) is Skincare under liquor. resolve_vertical kept a collector's claimed vertical over the category. Found on the 15 Sep staging copy; a backfill vertical --check should list them.
- **Carry the shop's variant field structurally from the collector instead of reading back the ' / ' the Shopify collector wrote** (P3; owner A; raised 2026-09-15 by Claude (catalogue decisions session, 15 Sep); id `issue-carry-the-shop-s-variant-field-structurally-from-the-col`)
  The shade rule reads the separator the Shopify collector itself inserts between the title and the variant option, so whether a shade leaves the line depends on the platform, not the product (Attenza's ' / 99 Pirate' leaves; Extime's ' - 99 Pirate' stays), and every new platform needs a shape rule. Add a variant field to the collector's output (Shopify option2 or title, Extime's label), write it to listed_variant, and have the line rule strip that; the tail regex stays only as the fallback for fragments already stored (decisions section 2.3).
  See `.logs/planning/catalogue-model-decisions-2026-09-15.md`
- **Tailed beauty rows outside Makeup carry a variation of unknown kind (eyewear colours and frame shapes among them)** (P3; owner A; raised 2026-09-15 by Claude (catalogue decisions session, 15 Sep); id `issue-tailed-beauty-rows-outside-makeup-carry-a-variation-of-u`)
  Under rules v5 a marked tail on any beauty row is a variation; the kind is color only where the category is Makeup. The 384 uncategorised tailed rows include Attenza's 'Lentes' shelf, whose tails are colours (plateado, dorado, negro) and frame shapes (rectangularsquared, pilotnavigator). A shape is not a colour; the kind stays unknown until the taxonomy knows the shelf or a person names it, and an unknown kind is shown, never picked. Resolve with the Spanish-shelves issue.
- **Confectionery flavour is read only where a shop marks it, so the same crisps on two platforms are two lines until the structural variant field lands** (P3; owner A; raised 2026-09-15 by Claude (catalogue decisions session, 15 Sep); id `issue-confectionery-flavour-is-read-only-where-a-shop-marks-it`)
  The v5 flavour reader fires on the marked ' / ' tail only (the word rule tagged 156 one-offs). On Heinemann and Extime a flavour stays in the line ('Pringles Sour Cream & Onion' -> 'sour cream onion'); on a Shopify shop it leaves ('Pringles 165g / Sour Cream & Onion' -> 'pringles' + flavour). A rule-derived corroboration (a flavour word leaves the line only when two products of one brand share the remaining key and differ only in it) would fold them without guessing; it is testable on the Lindor rows and never fires on a one-off gift box. Decide with the first food shop.
- **The Heinemann collector falls back to manufacturerName as the brand, which mints maker rows the desk could offer as brands** (P3; owner A; raised 2026-09-15 by Claude (catalogue decisions session, 15 Sep); id `issue-the-heinemann-collector-falls-back-to-manufacturername-a`)
  collected.py and heinemann_platform.py read manufacturerName when the feed's brand field is empty, so a distillery or a licensee can become a brand row (the glossary's brand test is the name on the front of the pack, and who makes it is a different fact we do not record). Empty beats guessed: keep the manufacturer as a listed fact only, and until then flag rows minted from it so a suggested match never folds a brand into its maker.
- **redirects is keyed by from_slug alone, so a brand slug and a product line slug cannot both forward** (P3; owner K2; raised 2026-09-17 by Stream K6; id `issue-redirects-is-keyed-by-from-slug-alone-so-a-brand-slug-an`)
  File: main/app/models/decisions.py Redirect (primary key from_slug; kind is a plain column). A product line whose words are empty has its brand's slug. publish.redirect_write keeps the older row and logs redirect_kept rather than overwrite another kind's. Change when a migration is next open: primary key (kind, from_slug).
- **Avolta grids declare the currency as currency_code, which declared_currency does not read** (P3; owner A; raised 2026-09-19 by Stream AW1; id `issue-avolta-grids-declare-the-currency-as-currency-code-which`)
  Scout window 2026-09-19c, eleven Avolta grids: none carries itemprop=priceCurrency; every tile carries a JSON 'currency_code': 'USD' (Las Vegas), 'EUR' (Helsinki, Milan), 'GBP' (the UK), 'CAD' (Vancouver). avolta.declared_currency() reads only priceCurrency, so a category page falls back to the price symbol map, which knows '$' and '€' but not 'CA$' (Vancouver renders CA$37.00), then to the store's configured currency. Harmless while the configuration is right (the scout reads currency_code and the configured currency is the page's own word); the fix that removes the fallback is one more alternative in _DECLARED_CURRENCY_RE. Fixtures: main/tests/fixtures/scout/<host>_listing.html.
  See `.logs/runs/window-2026-09-19c-scout.md`
- **Served pictures under /uploads/images/ answer application/octet-stream, not image/webp** (P3; owner AW7; raised 2026-09-19 by Away orchestrator; id `issue-served-pictures-under-uploads-images-answer-application`)
  After D2, curl of a staged brand picture (uploads/images/brand/*.webp, mounted at /srv/uploads) returned HTTP 200 with content-type application/octet-stream: the container's Python mimetypes table has no .webp entry, so StaticFiles falls back. Browsers sniff the bytes and render it, but caches and the og:image consumers may not. Fix: one line at app start, mimetypes.add_type('image/webp', '.webp'), beside the uploads mount in main/app/main.py, and a test that the mount answers image/webp for a .webp path. Small; for AW7's pass or any owner session.
- **The collapsed collector row's summary line shows the previous run's prices and checked while a run is live** (P3; owner AW7; raised 2026-09-19 by Away orchestrator; id `issue-the-collapsed-collector-row-s-summary-line-shows-the-pre`)
  On /collectors#live during run 189 (shopify-yul, recheck), the collapsed row read 'prices 0 · checked 0 · pace 3 s · 143 MiB' while the state detail beside it said '6 prices, 12 requests' and the bar '6 of 30 checked'. The two figures on that line appear to come from the last finished run rather than the live run. Small: CollectorRow's summary should read the live run's counters when one exists. Seen in window W2 part B.
- **The variant list counts the whole filtered set even when the caller wants a fixed sample** (P3; owner Stream AW2; raised 2026-09-20 by Stream AW7; id `issue-the-product-list-counts-the-whole-filtered-set-even-when`)
  catalog_queries.list_product_variants always runs a count over _filtered_summary before it selects anything, and returns it as total. Several callers ask for a fixed number of cards and throw the total away: the home page's exclusives and awarded rails (limit 6 each), the category page's exclusives and savings samples (limit 4 each), the airport page's exclusives sample (limit 8), and every only_ids fetch, where the ids are already known.
  What it costs: the unfiltered count alone is 279 ms median in the container; the two sample calls inside category_detail are 181 ms and 179 ms each, a large share of which is their own count. One keyword that skips the count when the caller does not read total would take a share off five blocks at once, which makes it the cheapest of the three performance findings to act on.
  Measured on staging inside the app container over one SessionLocal, reads only, five runs each. Evidence: .logs/verification/quality-pass-2026-09-19.md, AW7.4.

## Open: watch, parked, later

- **A base-colour switch on /settings beside the accent switch, so a lighter header can be judged live** (owner C; raised 2026-09-13 by rian (in chat, 13 Sep); id `issue-a-base-colour-switch-on-settings-beside-the-accent-switc`)
  Rian is happy with navy for now and will get Adam's and Mark's final view this week; Mark had asked to avoid dark backgrounds. Rather than decide from a description, add a second brand-review switch on /settings: the base colour behind the header and hero. Same mechanism as the accent (a data attribute on the root, per browser, instant, the shipped look for everyone else): candidates declared in tokens.css as overrides of the navy family, at least 'navy (shipped)' and one light option with AA-checked text and label pairs. The server-rendered header mirror must not change; this is a per-browser preview only.
  Gates: C5 (costly) — C5 is in progress now: Brand pass two: banners designed into the pages, sponsor sizes decided (then Adam gets the sizes)
- **Paco Rabanne and Rabanne are two brands in our data, which splits a whole fragrance line** (owner A; raised 2026-09-11 by planning session; id `issue-paco-rabanne-and-rabanne-are-two-brands-in-our-data-whic`)
  The company renamed itself from Paco Rabanne to Rabanne in 2023 and our shops have not all caught up. Paris says Paco Rabanne; the Shopify shops and Montreal say Rabanne. The fold joins spellings, not rebrands, so we hold two houses and nothing on one side can ever match the other.
  What it costs on one line: the 100 ml 1 Million Eau de Toilette exists three times, as '1 Million 10cl' at Paris, '1 Million Eau de Toilette 100 ml' at Bogota, Panama and San Salvador, and 'Rabanne 1 Million EDT 100ml' at Montreal. It should be one product compared across five airports; it is three products comparing nothing.
  Three parts to the fix: an alias row pointing one house at the other, which the brands table already supports; stripping the brand from the front of a product name before keying, so 'Rabanne 1 Million EDT 100ml' keys like the rest; and stripping concentration words from the name once the attribute carries them. Worth sweeping for other rebrands at the same time. A related case sits in the same line: 'Elixir Eau de Parfum Intense 200 ml' and 'Elixir Parfum Intense 200 ml' are the same bottle held apart because two shops named the concentration differently.
  Gates: A11 (costly) — blocks A11 (Two categories at twenty each, across airports), due 2026-09-15
- **Extime is Paris (CDG and Orly together), not CDG alone** (raised 2026-09-03 by Stream A; id `issue-extime-paris-not-cdg`)
  The storefront is one Paris tenant; the only terminal signal is the combined value and there is no per-airport sitemap, so the location is named for both with code CDG. If Adam needs CDG-only pricing that is an unmet requirement to raise with him, not a bug.
  Widened 15 Sep: this is the case that breaks 'a storefront belongs to one place', and so it constrains the location model. Orly does not exist in our data and CDG's 7,036 listings are really Paris's. See .logs/planning/places-and-shops-2026-09-15.md section 2.3.
  Catalogue decisions, 15 Sep (.logs/planning/catalogue-model-decisions-2026-09-15.md section 2.11, section 7): the place model that answers this is decided in direction and built after Cannes, before any non-airport shop is collected. The delivery walkthrough does not need it: Adam hears at P6 that Extime's prices are Paris's (the W6 naming task, where the Extime issue resolves) and that terminals and specialty shops within one airport are not yet distinguished, coming after Cannes.
  Gates: P6 (costly) — blocks P6 (Delivery walkthrough with Adam), due 2026-09-18
  See `.logs/planning/catalogue-model-decisions-2026-09-15.md`
  See `.logs/planning/places-and-shops-2026-09-15.md`
- **Brand-at-airport pages: a page type the structure proposal does not have yet** (owner B; raised 2026-09-07 by Mark's SEO review; id `issue-brand-at-airport-pages-a-page-type-the-structure-proposa`)
  Mark wants brand-and-airport pairing pages above some threshold, for two reasons. Commercially, a brand in a pay-to-play arrangement could get more than one general page. For search, a brand with enough products stocked at one airport deserves its own page there, for example Macallan at Heathrow or JFK. Adam agrees. This is a seventh page type, on the same shape as category-at-airport, and it needs a threshold and Adam's commercial rules before it is built.
  Gates: P6 (costly) — blocks P6 (Delivery walkthrough with Adam), due 2026-09-18
- **Shops and terminals within one airport are not modelled** (owner A; raised 2026-09-07 by Mark's SEO review; id `issue-shops-and-terminals-within-one-airport-are-not-modelled`)
  Mark: some system may be required to distinguish duty free shops in different terminals at the same airport, and specialty shops need accounting for, citing a particular shop on the JFK terminal map. Adam adds that Singapore Changi has specialty liquor locations on its second floor. Today a location is one airport and a shop is one retailer at that airport, so a terminal or a named specialty shop has nowhere to live. Affects prices that genuinely differ between terminals, and the 'where to buy' line on a product page.
  Widened 15 Sep: this is one face of the location model, documented in full in .logs/planning/places-and-shops-2026-09-15.md (section 2.2). The schema permits a second shop row at one airport - the unique constraint is (retailer_id, code) - so what is missing is not the row but what it means, and the sixteen queries that count shop rows as airports would silently overcount the day one appears.
  Catalogue decisions, 15 Sep (.logs/planning/catalogue-model-decisions-2026-09-15.md section 2.11, section 7): the place model that answers this is decided in direction and built after Cannes, before any non-airport shop is collected. The delivery walkthrough does not need it: Adam hears at P6 that Extime's prices are Paris's (the W6 naming task, where the Extime issue resolves) and that terminals and specialty shops within one airport are not yet distinguished, coming after Cannes.
  Gates: P6 (costly) — blocks P6 (Delivery walkthrough with Adam), due 2026-09-18
  See `.logs/planning/catalogue-model-decisions-2026-09-15.md`
  See `.logs/planning/places-and-shops-2026-09-15.md`
- **Pace per collector is something a person adjusts on /collectors, with notes** (owner M; raised 2026-09-13 by rian (in chat, 13 Sep); id `issue-pace-per-collector-is-something-a-person-adjusts-on-coll`)
  The columns already exist and are honoured: sources.delay_seconds (read by ingest and verify), sources.max_concurrency, sources.notes, and each rendered collector's own floor. Nobody can set them from a page. On the Collectors view, per collector: the pace between requests, the render floor where there is one, and a notes field for what a person learned about that host (when it refused us, at what pace, what the operator said), each change recorded against the account that made it. Rian's stance: crawl continuously rather than fast; the pace is a knob, not a constant.
  Gates: M6 (costly) — the work it gated has gone ahead under the assumption; settling it now avoids a costlier change later
- **Request to the caddie-ui pack: a mention picker in Composer** (owner caddie; raised 2026-09-13 by rian (in chat, 13 Sep); id `issue-request-to-the-caddie-ui-pack-a-mention-picker-in-compos`)
  Typing @ in a comment box should offer the names the writer may reach, filtered as they type, with arrows and Enter choosing and the handle inserted. The pack's Composer has none, and the Interaction Standard forbids an app re-implementing a pack piece, so this is a pack change: a directory prop on Composer ([{handle, display_name}]) and the picker. DFP's side (T16) supplies the directory through a people route and wires the prop the day the pack ships it. Filed here so rian can carry it into a caddie session; caddie keeps no request file of its own.
  Gates: T16 (costly) — the work it gated has gone ahead under the assumption; settling it now avoids a costlier change later
- **Replay parks a name decision on a product line a sheet approval minted (LINE_MISSING)** (owner K2; raised 2026-09-16 by Stream K4; id `issue-replay-parks-a-name-decision-on-a-product-line-a-sheet-a`)
  File: main/app/services/decisions/replay.py (K2). A sheet approval that mints a line records its name decision with natural_key line:<the new uid> (natural_keys.build of the row), detail {brand_slug, slug, key: decided:<slug>}. On the second host the uid is unknown and no row holds the slug, so natural_keys.resolve parks LINE_MISSING; replay only calls _mint_line when the key starts line:new:, which a recorded decision never does. Every membership naming that line then parks too (its ref detail cannot resolve).
  Change: in replay, when a product_line 'name' row parks LINE_MISSING and its detail key starts with 'decided:' and its brand resolves, mint the line with the SOURCE uid (parsed from natural_key), slug and key from the detail (the same body as _mint_line). Test: a sheet mint on copy A, exported, replayed on B: 0 parked, B's line has A's uid. Adopted lines (an existing row, same uid on both hosts) are unaffected.
  *Proceeding under:* K4 proceeds; the CHANEL rehearsal adopts existing lines where it can and reports any parked replay rows this causes.
  Gates: K4.5 (costly) — the work it gated has gone ahead under the assumption; settling it now avoids a costlier change later
- **A sheet line's decided key and its mint happen in the approval, not the applier, so replay carries neither** (owner K2; raised 2026-09-16 by Stream K4; id `issue-a-sheet-line-s-decided-key-and-its-mint-happen-in-the-ap`)
  Files: main/app/services/decisions/appliers.py (product_line name) and replay.py (K2). Spec section 2: the first name decision on a line fixes key = 'decided:' || slug and a sheet line adopts before it mints. The writer's applier for (product_line, name) only sets the name, so K4's approval (services/proposals.py _adopt_or_mint) stamps the key and mints before calling record. Replay applies the recorded name through the applier alone: on the CHANEL rehearsal (dfp_k4 -> dfp_k4b, 74 decisions, 0 parked) every column agreed (929 facts: memberships alias-followed, line names, aliases, pairs, attributes) EXCEPT the 7 approved lines' keys, which stayed rule keys on B, so an arrival on production could still land on an approved line (W10).
  Change: move the adopt-or-mint and the decided: key stamp into the (product_line, name) applier's prepare/materialise (the record's detail carries adopted_from_key, slug_was, minted), with the source uid on a mint so replay regenerates the same row; K4's _adopt_or_mint then becomes a call to it. The mint case also covers the earlier issue (replay parks a name decision on a line a sheet minted). An undo of the name should restore the key from detail.adopted_from_key. Test: approve a sheet line on copy A (adopt and mint), export, replay on B: B's key is decided:<slug> and the minted line has A's uid.
  *Proceeding under:* K4 ships the approval stamping the key itself; the rehearsal numbers are reported with this one gap named.
  Gates: K4.5 (costly) — the work it gated has gone ahead under the assumption; settling it now avoids a costlier change later
- **Size nomenclature differs by country: 700ml against 70cl** (owner A; raised 2026-09-07 by Mark's SEO review; id `issue-size-nomenclature-differs-by-country-700ml-against-70cl`)
  Mark flags that size variants are written differently in different countries and need managing. Migration #3 now stores a size value with its unit and derives millilitres, so the matching side is handled; what is not settled is display. A shop that publishes 70cl should probably still read 700ml on our page for comparability, with the shop's own wording kept in the record. Decide the display rule, then apply it in one place.
- **A merge conflict was committed into the running list, and nothing noticed** (owner DOCS; raised 2026-09-11 by planning session; id `issue-a-merge-conflict-was-committed-into-the-running-list-and`)
  import/items.json was committed on 11 Sep with git conflict markers still in it (commit 0ab8e6c, two sessions appending an item at the same moment). The file stopped being valid JSON, so items.py could neither read nor write and the /plan items tab had nothing to show. Found by hand two days later when a filing failed; resolved by rebuilding both sides and keeping both items.
  It should not be possible to commit that file broken. Cheapest guard: a line in check.sh that parses import/items.json and import/progress.json and fails on either a parse error or a conflict marker, which also covers the same accident in the plan file. Worth considering an append-safe write path too, since two sessions adding an item at once is now routine.
- **Record refusals with the pace at the time, and measure how often prices change per collector, so the crawl cadence is chosen from evidence** (owner Q; raised 2026-09-13 by rian (in chat, 13 Sep); id `issue-record-refusals-with-the-pace-at-the-time-and-measure-ho`)
  Two ledgers. First, every refusal a collector meets (403, a challenge page, a block mid-run) recorded with the source, the pace it was running at, how many pages it had read, and the time, so the rate a host tolerates can be found rather than guessed; the Singapore retry of 13 Sep is the first entry. Second, per collector, how often prices actually move between reads (the observations already carry every reading and its time), so the cadence per collector follows the shop: a shop whose prices move weekly is read weekly, at whatever pace it tolerates. Both shown on /collectors beside the run history.
- **Singapore's pace is thirty seconds: make it the collector's code default as well as the source row** (owner A2; raised 2026-09-13 by planning session; id `issue-singapore-s-pace-is-thirty-seconds-make-it-the-collector`)
  The 13 Sep retry settled it: iShopChangi refused twice at about ten seconds near page eighteen and accepted twenty pages at thirty. sources.delay_seconds is now 30 on both hosts, which ingest and verify read when no --delay is given. Put the same figure on the collector (Changi.render_floor_seconds = 30.0) so a fresh database or a --delay below it can never run it faster than the shop tolerates, and say so in COLLECTORS.md's pace-per-source note.
- **A shop's shade option (option:color) is registered picked, so the product line page offers shades as a selector** (owner K3; raised 2026-09-17 by Stream K5; id `issue-a-shop-s-shade-option-option-color-is-registered-picked`)
  File: main/app/services/attributes.py register_option (K2's registry; K3 named the options). On dfp_k5 (K3's final copy) CHANEL Rouge Allure L'Extrait's 12 variants carry option:color, and register_option makes every option:* kind display=picked, so get_product_line draws a 12-way shade selector. VOCABULARY.md and REVIEW-PROCESS.md say a shade is shown, never selected. Change: map a shop option whose name means colour or shade (color, colour, shade, tono) to the registry's color kind or give it display=shown; a test on the CHANEL option.
  *Proceeding under:* K5 follows the registry's display setting as it stands; the page will list shades as facts once the registry says shown, with no page change.
- **A staging refresh leaves id sequences behind the rows it replays** (raised 2026-09-19 by Stream AW5; id `issue-a-staging-refresh-leaves-id-sequences-behind-the-rows-it`)
  Staging on 19 Sep: articles held one row at id 1 while articles_id_seq had never been called, so the next insert collided on articles_pkey. The refresh replays client-written rows by natural key, ids included, and never setvals the owning sequence afterwards. AW5.2 put an idempotent lift in 'articles import' (cli_editorial._lift_article_sequence), which covers that one table only; every other table the refresh replays has the same exposure the first time something inserts into it. The durable fix belongs in the refresh path: after the replay, setval every owning sequence to max(id). Read-only check: select the sequences whose last_value is null in pg_sequences and whose table has rows.
- **Price charts over time, for products and categories** (owner D; raised 2026-09-07 by Mark's SEO review; id `issue-price-charts-over-time-for-products-and-categories`)
  Mark has discussed with Adam how useful price history charts would be for selected products and for categories, as part of the site becoming a source of truth for duty free pricing. We already store every observation with its date, so the data exists from now on; this is a display feature, not a collection change. Not for the soft launch.
- **Notable price-trend charts on airport pages, chosen algorithmically** (owner D; raised 2026-09-09 by Mark on /structure; id `issue-notable-price-trend-charts-on-airport-pages-chosen-algor`)
  Mark, 9 Sep: eventually an airport page could carry a 'notable charts' section for goods that have seen interesting price movements, picked by rule rather than by a person. Belongs with the price history work; the observations are already stored dated.
- **Fold the discussion workflow into the Interaction Standard and the caddie-ui pack** (owner caddie; raised 2026-09-14 by Stream R2b (14 Sep); id `issue-fold-the-discussion-workflow-into-the-interaction-standa`)
  The model built on DFP on 14 Sep (.logs/planning/discussion-workflow-2026-09-14.md §2, §3): three states (Open, Resolved with an outcome Done or Later, Archived), one hand-off (an ask: for one person, a note, a turn notification, done by them or a curator), one acknowledgement (Got it on a comment, which completes the person's asks), the closing word as the record, and the counts rule (needs you leads, unread seeded once). For the standard: §4.1 states and outcome, the ask and the acknowledgement as the standard's shapes, §6 the tabs. For the pack: the resolved style without the strike-through, a status line under a resolved card, the ask chip, the Got it thumb, and a Composer mention picker (the earlier request) so DFP's MentionScope can go.
  See `.logs/planning/discussion-workflow-2026-09-14.md`
- **Places that are not airports cannot exist, and one is already collected** (raised 2026-09-15 by Claude (database map session); id `issue-places-that-are-not-airports-cannot-exist-and-one-is-alr`)
  A place IS an IATA code: locations.iata is String(4) and there is no table for the place itself, so hours, write-ups, addresses and the public page are all reached by matching that string. Anything without an IATA code has nowhere to live.
  This already bites in the live data. The BorderShop at Puttgarden, Germany (code SCA, Gebr. Heinemann, DKK) is a real shop at a real place with 1,806 collected listings, and it is hidden - not because anyone decided it should be, but because there is nowhere to say where it is. Adam's cruise ports (St Maarten, St Thomas, Nassau, Cozumel, Mina Rashid, Gibraltar, Livorno, Kai Tak, Marina Bay, Jeju) land in the same hole, as would outlet malls and ferry terminals.
  Full analysis in .logs/planning/places-and-shops-2026-09-15.md section 2.1.
  See `.logs/planning/places-and-shops-2026-09-15.md`
- **The comparison counts shop rows, not places, so a second shop at one airport would make the savings table lie** (raised 2026-09-15 by Claude (database map session); id `issue-the-comparison-counts-shop-rows-not-places-so-a-second-s`)
  'How many airports stock this product' is asked seventeen times across the services, two different ways. Once by place: the brand-page floor counts distinct(Location.iata), and its docstring says why. Sixteen times by shop row: distinct(Listing.location_id) - the product list, the savings table, search, the /collectors min_airports filter, the image-sourcing rank.
  Both give the same number today only because no airport has two shop rows. The day one does, they diverge silently: a product carried by two Heathrow shops counts as 'at 2 airports' and becomes eligible for the savings table and the compare-across-airports rails with no cross-airport comparison in it at all. Nothing errors; the site just starts making a claim that is not true.
  Latent, not live. It must be corrected in the same change that models places, or it ships the moment a second shop is added. See .logs/planning/places-and-shops-2026-09-15.md section 2.2.
  See `.logs/planning/places-and-shops-2026-09-15.md`
- **AI help to tell a new line from a member of a line, and to catch spelling splits** (raised 2026-09-16 by Claude (decisions walk-through, 16 Sep); id `issue-ai-help-to-tell-a-new-line-from-a-member-of-a-line-and-t`)
  Rian, 16 Sep: 'There's going to be lots of cases where it's not clear whats a line vs variation, and lots of cases where there's different spellings. We need a system to merge them all.' He expects lines to collapse much further once this is worked, and the evidence supports him (the shade rule alone took CHANEL Rouge Allure from 59 lines to 17).
  The Johnnie Walker search shows both classes in one screen. Spelling splits: 'Johnnie Walker Blue 1L' sits in a line 'Blue' apart from Blue Label; 'Joh. Walker Blue Umami' sits apart from 'Blue Label Elusive Umami' because 'Joh.' is not read as the brand. Line versus member: whether the Chinese New Year Lunar bottle (491 dollars) is a new line or Blue Label in collector packaging cannot be read from the listing; King George V (557 dollars) is clearly its own line.
  Fits the decided AI shape (section 2.9: a model proposes, a person confirms, a model never writes). Two proposal types to design: a pair proposal (these two lines are one, landing in the existing suggestion queue with reason ai) and a membership proposal (this line belongs under that one as a member, or stays its own). Price, barcode and the listed words are its evidence; a person's decision is never touched. Rules still do the bulk; the model is for the ambiguous tail.
  See `.logs/planning/catalogue-model-decisions-2026-09-15.md`
- **At the go-live flip, individual product pages start being indexed before line pages exist** (raised 2026-09-16 by Claude (decisions walk-through, 16 Sep); id `issue-at-the-go-live-flip-individual-product-pages-start-being`)
  Superseded as the plan on 16 Sep: rian decided the line page replaces the product page BEFORE launch, so no product page is public at the flip. Kept only as the fallback if the build slips past Friday: keep product pages out of the sitemap and send noindex until the line page ships, so the decision does not harden first.
  See `.logs/planning/catalogue-model-decisions-2026-09-15.md`
- **Collected brand (products.brand) is mostly redundant, and the public site still reads it** (raised 2026-09-16 by Claude (decisions walk-through, 16 Sep); id `issue-collected-brand-products-brand-is-mostly-redundant-and-t`)
  Rian, 16 Sep: 'I dont know why we need collected brand... what is the purpose of collected brand if we already have alias?' Traced: products.brand is the brand text of whichever shop first created the variation, written once at ingest and never updated. It predates the listed layer (14 Sep), when it was the only raw brand text kept. Four listings where the shop said Rabanne sit on a variation whose collected brand says Paco Rabanne.
  It still does two jobs. It is the only brand text for 12,673 of 23,771 listings, which have no stored shop words yet. And the public site reads it: the page's JSON-LD brand, the brand link on the page, the bottle mark, and search suggestions and search (seo.py, catalog_queries.py), so shoppers see one shop's spelling rather than the brand's chosen name (see issue-product-cards-and-pages-show-the-brand-as-one-shop-spelt).
  Direction: the product pages built before launch read the brand row's name, never this text; once every listing carries its shop words, drop or demote the column in the rename wave. The table label 'Collected brand' misleads meanwhile, since it is not this listing's collected text.
- **ABV differs between variants of one product line in 28 lines, and ABV cannot keep two variants apart** (raised 2026-09-16 by Claude (decisions walk-through, 16 Sep); id `issue-abv-differs-between-variants-of-one-product-line-in-28-l`)
  Rian asked whether ABV is an option or a property. Usually a property (Blue Label is 40 percent at every size), but 28 product lines hold variants at different strengths: 'Reposado' 38 percent 700 ml and 40 percent 1 L; 'Espe.Repos' 35 and 38 percent at 1 L; 'Danzka' 40 percent 4 L and 44 percent 1 L. Causes: the same spirit bottled at different strengths for different markets, a product line name too generic to separate two drinks ('3', 'Coffey'), or a shop's typo.
  The risk: ABV is not in the identity key and not a veto (ingest only copies it onto a variant that lacks one), so two variants without barcodes that differ only in strength at the same size key the same and would merge silently. Belongs with the options-and-properties decision: a kind the registry marks as a property in one vertical may need to veto a match.
- **Keep separate does not stop the automatic merge from folding the pair later** (raised 2026-09-16 by Claude (decisions walk-through, 16 Sep); id `issue-keep-separate-does-not-stop-the-automatic-merge-from-fol`)
  Found tracing rian's scenario (16 Sep): two product variants that look alike but are genuinely different, such as a spirit bottled at 40 percent for one market and 43 percent for another. Rian keeps them separate. merge_session records kept_apart on the suggestion row, and that only stops the pair being suggested again.
  The automatic fold (merges.merge_duplicates, run by backfill merges, rederive and after every confirmed alias) never reads kept_apart. It refuses a group only on two different barcodes, set against single, an unstated quantity, or a disagreement inside products.attributes. ABV is a column outside attributes, so it cannot refuse. Two such variants with no barcodes and the same size fold on the next re-key, silently undoing a person's decision; that breaks the standing rule that a human value is never overwritten by a machine.
  Fix belongs with the attribute decision: the fold must honour every kept_apart pair, and when a person keeps two variants separate because of an attribute, that attribute kind should decide sameness for that product line (a per-line exception a person sets). With different barcodes, as market versions usually have, the barcode rule already protects them.
- **The production seed step swallows restore errors and assumes an empty database** (raised 2026-09-16 by Claude (decisions walk-through, 16 Sep); id `issue-the-production-seed-step-swallows-restore-errors-and-ass`)
  Rian plans to push the whole staging database to production for the soft launch (walk-through W17). The documented path, deploy/production.sh --seed-db, runs pg_restore --clean --if-exists --no-owner and ends in '|| true', so any restore error is ignored, and RUNBOOK describes it as first deploy only into an empty database. Production is not empty and is on the 0.35.0 schema.
  Needed for the push: drop and recreate the schema as the staging refresh does, stop on any error, deploy matching code first and check alembic current, compare row counts, take a production dump first as the rollback, revoke every session and unused account token after the restore (staging holds 14 live sessions that would otherwise be accepted on production), and confirm source settings.
- **REVIEW-PROCESS.md now reads Version 3 (K4's section 6) on top of K3's uncommitted version 2** (owner K3; raised 2026-09-16 by Stream K4; id `issue-review-process-md-now-reads-version-3-k4-s-section-6-on`)
  K4 committed section 6 (the file) and the section 4 wording through the index against HEAD, and wrote the same hunks into the working copy on top of K3's uncommitted rules v6 edits, with one version line naming both: 'Version 3 ... Version 2 the same day: identity rules v6 ...'. When K3 commits the file, keep that line (or bump past it) rather than restoring 'Version 2'; the rehearsal file carries process_version 2 and a new pass should say 3.
- **No CLI expires unused welcome and reset links; the launch's replace does it with one SQL statement** (owner R; raised 2026-09-17 by Stream K7; id `issue-no-cli-expires-unused-welcome-and-reset-links-the-launch`)
  The K7 brief names 'accounts tokens purge'; it does not exist (app/cli_accounts.py has sessions revoke and sessions prune, which deletes used and old tokens only). deploy/production.sh --replace-db therefore runs: update account_tokens set expires_at = now() where used_at is null and expires_at > now(), and prints the count. Change: an 'accounts tokens expire --all' command that does the same through the accounts service with an audit_log row, and the script calls it; rehearsed statement: 1 inserted unused token expired, 0 unused after.
  *Proceeding under:* The SQL statement stands in for the launch; it expires, never deletes, so the audit of who was invited survives.
- **No way to rename a page's address by hand, with a redirect** (owner rian; raised 2026-09-17 by Claude (step D, 17 Sep); id `issue-no-way-to-rename-a-page-s-address-by-hand-with-a-redirec`)
  Rian, 17 Sep, on the brand-slug decision: 'what if we make a mistake in the slug when we publish it initially? We need a mechanism to be able to change slugs after the fact. EG: Paco Rabanne of the future renames itself, we update the display name, but we also want the new name to have an accurate slug.'
  Today there is no path. publish.slug_at_confirm fixes a PRODUCT LINE's slug only when a name is confirmed, and it refuses a page that is already indexed unless a rename_indexed flag is set, which nothing in the app ever sets. A BRAND's slug cannot move at all: brands.slug is the page address, the fold key ingest finds the row by, and the ledger's natural key, so renaming it would make the next collection mint the brand again under the old slug and park every replayed decision (the decision rian kept the same day).
  What it needs: a CLI and a review-area action that renames a page's address deliberately, writes the redirect (K6's flattening already handles chains), records it as a decision with who and why, and for a brand adds the separate address column and a resolver that reads redirects, which the brand-slug decision already costs at about four lines in publish plus K2's natural keys.
- **A product line card: links to the line, with no price or comparison on it** (raised 2026-09-17 by Claude (step D, 17 Sep); id `issue-a-product-line-card-links-to-the-line-with-no-price-or-c`)
  Rian, 17 Sep, accepting that browse grids keep one card per product variant: 'it might make sense to build a product line card as well that simply links to the product line without a price or comparison.' That sidesteps the reason per-line cards were refused (a line with a 1 L at one airport and a 70 cl at another has no single comparison to print), while giving the line a card shape for places where the line, not the bottle, is the thing being shown.
- **The brand-trailer list is global and there is no way to split a brand it folded wrongly** (owner K9; raised 2026-09-17 by Claude (step D, 17 Sep); id `issue-the-brand-trailer-list-is-global-and-there-is-no-way-to`)
  Rian, 17 Sep: 'are we still using those stop words like estate? in future crawl on other categories like clothing, will it strip words like estate?' and 'maybe the solution is to make sure that when AI is proposing, we're also considering the possibility of mistaken folds and scanning our stop words to see if there are brands or product lines we should be breaking apart instead of folding together.'
  The facts. normalize._BRAND_TRAILERS is 45 words applied to EVERY vertical, written for drinks and beauty; it strips a trailing word at ingest when a listing's brand is resolved, so two spellings never become two rows and no decision records it. Every fold in today's data is right; the exposure is the first category beyond drinks and beauty. Rejecting one of the 91 rule:brand_trailers proposals records a rejection but moves no data, and undo.unmerge works on variants only, so a wrong fold has no way back.
  BEING BUILT by stream K9 (brief .logs/planning/streams/K9-brand-split.md, kickoff /stream-k9, tasks K9.1 to K9.7 on /plan), started 17 Sep in its own worktree and branch while rian reviews on staging: the split as one recorded decision with an undo, the rejected proposal offering that split, the list scoped per vertical with nothing for an unlisted one, and the lists and their words shown in the review area.
- **The listings table's 'brand differs' flag compares the unscoped fold against the brand slug** (owner L; raised 2026-09-17 by Stream K9; id `issue-the-listings-table-s-brand-differs-flag-compares-the-uns`)
  listings.listed_brand_key is written by collected.listed_fields with normalize.brand_key and NO vertical (a display comparison, deliberately), and listings_table compares it to the brand row's slug. Two cases now show a false 'brand differs': a brand row a person SPLIT (its slug is 'appleton-rum', the unscoped fold of 'Appleton Rum' is 'appleton'), and any row whose spelling folds differently under the scoped list. Nothing is wrong with the data; the flag is comparing two things that stopped meaning the same thing in K9.5. Fix: write listed_brand_key through the listing's vertical, or have listings_table resolve the spelling the way ingest does (claim, then placed, then the scoped fold) instead of comparing keys.
- **277 empty product lines hold only merge tombstones; prune keeps them as records, so they stay reachable** (raised 2026-09-18 by Stream K12; id `issue-277-empty-product-lines-hold-only-merge-tombstones-prune`)
  On the 18 Sep copy every empty product line (alias_of null, no live variant) is referenced by merge tombstones, so backfill prune_lines deletes 0 of them by design. The simulation called them reachable rows that mean nothing. The fix is a reader rule (a line with no live variant is not listed or reachable) or moving a tombstone's line pointer to the survivor's line at merge; K12 changed neither. Undo now prunes what a fold minted, so the count no longer grows from fold-and-undo.
  See `.logs/runs/rehearsal-2026-09-18-k12.md`
- **Dubai Duty Free is listed as reachable by sweep plan because its refusal was never recorded as a run** (owner A; raised 2026-09-18 by Stream K12; id `issue-dubai-duty-free-is-listed-as-reachable-by-sweep-plan-bec`)
  sweep plan reads the last collection run per source; dubai-duty-free's last run is ok (22 Aug) and the 403 to our declared identity lives only in a verification check, so a sweep would contact it. Before the sweep either record the refusal on the source (a blocked run, or sources.enabled=false with the reason in notes) or let the collector refuse on the first page; COLLECTORS.md says no run since 22 Aug and no bypass.
  See `main/docs/RUNBOOK.md`
- **An airport's page title carries a shop's name: Seoul Incheon (Shilla online store)** (owner A; raised 2026-09-18 by Stream G2; id `issue-an-airport-s-page-title-carries-a-shop-s-name-seoul-inch`)
  /airports/incheon-icn-seoul renders as 'Duty free at Seoul Incheon (Shilla online store)' and the breadcrumb says the same. The shop row's name has leaked into the place's name, so the page reads as a storefront rather than an airport. The place row is named 'Seoul Incheon'; the page takes its name from the shop. Worth settling before the nineteen go public.
- **Trip cards (SavingsPage, services/trip.py TripProduct) still read the product variant's own thumb_url, not the picture cascade** (owner Stream AW3; raised 2026-09-19 by Stream AW3; id `issue-trip-cards-savingspage-services-trip-py-tripproduct-stil`)
  AW3.3 put every catalog_queries reader on catalog_queries.pictures_of (variant, else line, else brand, with image_level and image_credit). services/trip.py was outside the stream's Owns list, so TripProduct.thumb_url is still the variant's own column: a bottle with only a line or brand picture shows a monogram on /savings trip cards. Fix: in trip.py call pictures_of(db, products) and fill thumb_url and a new image_level on TripProduct; pass level to Thumb in SavingsPage.tsx. One query each for lines and brands; no schema change.
- **writer._validate bool-checks hidden, indexed and ignored only; featured is checked by its caller** (owner decisions writer; raised 2026-09-19 by Stream AW2; id `issue-writer-validate-bool-checks-hidden-indexed-and-ignored-o`)
  services/decisions/writer.py _validate (the hidden/indexed/ignored branch) accepts any value for a product line's or product variant's featured field; services/featured_pins.set_featured checks isinstance(featured, bool) before any batch opens (VALUE_INVALID, tested in tests/test_featured_pins.py). One word to add to that branch so the writer refuses a non-bool featured itself; the writer is out of Stream AW2's bounds.
- **The saving columns still read a catalogue price** (owner AW2; raised 2026-09-19 by Stream AW2 review; id `issue-the-saving-columns-still-read-a-catalogue-price`)
  catalog_queries._summary_base: cheapest_usd and dearest_usd coalesce to the in-stock price over every joined shop, so a two-airport comparison sold out at both airports and in stock online collapses to the catalogue's price (saving 0, no_saving) and a catalogue price can be the cheapest or dearest a card shows. The review fix made the three gate columns airport-only (D10); the saving columns are v2's and were left alone because every card's displayed price reads them. Options: filter both by Shop.is_catalogue_only false (the card then never shows a catalogue price), or keep the catalogue in the displayed price and compute saving_usd from airport-only columns.
- **api/client.ts drops the server's summary on ApiError; the page can only show a code** (owner AW7; raised 2026-09-19 by Stream AW4; id `issue-api-client-ts-drops-the-server-s-summary-on-apierror-the`)
  main/web/src/api/client.ts: ApiError carries status and errorCode only. Every 409 from the collector control routes (POST /api/collectors/{slug}/start|pause|resume|stop|pace|mode, Stream AW4) answers {error_code, summary} where the summary is the one sentence a person should read (which floor a pace is under, why a Start is refused). Add readonly summary?: string to ApiError and fill it from detail.summary in the one place the error is built. AW4.4 works around it by computing start_blocked and the pace sentence server-side on the live read; once summary rides on ApiError the workaround can go.
  See `main/web/src/api/client.ts`
- **refuse_if_collecting recomputes the dead cutoff itself and will not learn the heartbeat rule** (owner AW7; raised 2026-09-19 by Stream AW4; id `issue-refuse-if-collecting-recomputes-the-dead-cutoff-itself-a`)
  main/app/services/decisions/writer.py refuse_if_collecting: it rebuilds the cutoff from STUCK_AFTER and process_boot on its own instead of asking ingest.is_stuck per running row, so the heartbeat rule AW4.3 adds to is_stuck (a running row whose heartbeat_at is older than STUCK_HEARTBEAT is dead) never reaches the approval gate: a shed collector would hold the review for up to 24 h again. Change: select the running rows and refuse only if any is not is_stuck(run, now). One query, same shape; the tests in test_writer.py already cover the timer and boot cases.
  See `main/app/services/decisions/writer.py`
- **The kill switch route writes on a bare query param and audits nothing** (owner AW7; raised 2026-09-19 by Stream AW4; id `issue-the-kill-switch-route-writes-on-a-bare-query-param-and-a`)
  main/app/routers/sources.py set_enabled (POST /api/sources/{slug}/enabled): the only legal control on a source is written from a bool query parameter with no identity.actor and no audit_log.record, so nobody can later say who switched a source off or on, or when. Every other write under sources.manage records the actor. Change: take the actor, move enabled into a JSON body, and record source.enabled {from, to}. The AW4 routes in routers/collectors.py show the shape (the actor first, the row locked, one audit row).
  See `main/app/routers/sources.py`
- **cmd_sweep_plan still prints paused for the kill switch; the word is off** (owner AW7; raised 2026-09-19 by Stream AW4; id `issue-cmd-sweep-plan-still-prints-paused-for-the-kill-switch-t`)
  main/app/cli.py cmd_sweep_plan: a source with enabled=false is listed as paused, but since AW4 paused is the cooperative page state (a run holding between requests) and the kill switch's one word is off (cmd_status already prints it). One string. Also the refused column there could name the run's stopped state now that stopped is a status.
  See `main/app/cli.py`
- **check.sh fails for anyone who has a built SPA at main/static** (owner AW7; raised 2026-09-19 by Stream AW5; id `issue-check-sh-fails-for-anyone-who-has-a-built-spa-at-main-st`)
  Reproduced in the worktree during the AW5.5 rehearsal: after 'npm run build' and 'cp -r web/dist static' (what a local uvicorn run needs, since app/main.py mounts the site only when main/static exists), test_access.py::TestMembersMode::test_anonymous_answers_are_the_same_under_any_accept fails three times on '/logo.png' returning {"detail":"No such file"} instead of the fixture's bytes. Cause: access.register_root_files(static_dir) is module-level state filled at import of app.main from the real static dir, while the test mounts a temporary shell over main.app and expects its own root files. Remove main/static and the same tree is green (2492 passed). Nothing in product code is wrong; the test is not isolated from an artifact a developer is told to create. Fix: have register_root_files be per-mount state, or have the fixture re-register the temporary shell's root files.
- **The awards pin comment still promises the retired overrides table** (owner F; raised 2026-09-19 by Stream AW6; id `issue-the-awards-pin-comment-still-promises-the-retired-overri`)
  main/app/services/catalog_queries.py:812-816 says a pin 'will arrive through award_picker.pin_from_override once the overrides table exists (R's migration #4)'. K2 retired overrides behind a shim and AW2 landed the featured pin as a ledger decision, so that is the shape an awards pin would take now. Found by AW6.3's proposal double-check; nothing is broken, the comment just points a reader at a table that is going away.
- **docs-check --strict calls backups/ a missing source when run in a worktree** (owner Stream Docs; raised 2026-09-19 by Stream AW7; id `issue-docs-check-strict-calls-backups-a-missing-source-when-ru`)
  main/docs/RUNBOOK.md names backups/ in its Sources of truth line. The folder is gitignored and exists only in the main checkout, so bash main/docs-check.sh --strict prints 'FAIL main/docs/RUNBOOK.md names a source that does not exist: backups/' in a worktree and not in the main checkout: 11 fails against 10 on 2026-09-19. Fix: skip a source that is gitignored, or name it as backups/ only where it exists. Evidence: .logs/verification/quality-pass-2026-09-19.md, AW7.1.
- **CLIENT-SURFACES.md is stale by construction: import/ is rewritten by every plan update** (owner Stream Docs; raised 2026-09-19 by Stream AW7; id `issue-client-surfaces-md-is-stale-by-construction-import-is-re`)
  main/docs/CLIENT-SURFACES.md names import/ as a source. Every plan-set.py and items.py call rewrites import/progress.json or import/items.json, so the source commit is newer than the doc within minutes of any session, and docs-check.sh --strict fails on it forever. It failed in both the worktree and the main checkout on 2026-09-19 with nothing about the doc wrong. Fix: name the files the prose actually depends on rather than the folder, or exclude the two churn files. Evidence: .logs/verification/quality-pass-2026-09-19.md, AW7.1.
- **On a phone the price table's cheapest marker starts off-screen** (owner Stream S; raised 2026-09-20 by Stream AW7; id `issue-on-a-phone-the-price-table-s-cheapest-marker-starts-off`)
  Page /products/<line>?variant=<id> at 390 CSS pixels. main/web/src/components/PriceTable.css sets .price-table { min-width: 560px } inside the shared .scroll-x wrapper, so at 390 the table is 560 wide and the reader swipes it sideways. That is the standard idiom and nothing is unreachable, but the third column (our conversion, with the CHEAPEST pill on the best row) begins past the right edge, so the one signal the page exists for is not visible until the shopper discovers the sideways scroll. Measured by main/scripts/page-probe.py: table.price-table 560 wide, 211 past the viewport, scroller div.scroll-x, verdict scrolls. Candidate answers: stack the row into two lines below 600px, or move the conversion under the shop price in the same cell. Not a one-rule fix, so AW7 left it.
- **Serving the built shell locally breaks three access tests** (owner Stream Docs; raised 2026-09-20 by Stream AW7; id `issue-serving-the-built-shell-locally-breaks-three-access-test`)
  main/app/main.py mounts the shell from main/static, which the Dockerfile fills with web/dist. A local run that wants the real served pages has to put something there, and the obvious symlink (ln -s web/dist main/static) makes tests/test_access.py::TestMembersMode::test_anonymous_answers_are_the_same_under_any_accept fail three times: the test stubs a static directory and the real mount wins, so /logo.png answers the app's No such file instead of the stub. It cost the AW7 browser pass a red check.sh and a diagnosis. The pass worked around it with a launcher that calls mount_site(app, web/dist) after import, the same trick scripts/route-sweep.py uses (kept in the pass's scratch folder). Candidate answers: RUNBOOK's dev loop names that launcher, or the app grows a STATIC_DIR environment override, or the test builds its own app rather than the imported one. Nothing here is urgent: the container is unaffected.
- **The served bodies carry the same three accessibility gaps the SPA just lost** (owner Stream B; raised 2026-09-20 by Stream AW7; id `issue-the-served-bodies-carry-the-same-three-accessibility-gap`)
  The SPA half is fixed and tested (main/tests/test_landmarks.py). seo.py renders its own copy of the shell and the article index, so the same three gaps are still in every server-rendered body, which is what a crawler and a reader with JavaScript off get. Measured on a restored copy served locally with SITE_ACCESS=public, JavaScript never run: (1) app/services/seo.py line 886 emits <nav class="site-nav"> with no aria-label, in all seven server-rendered bodies; (2) articles_body at line 2866 goes from the h1 straight to the cards' h3 at line 2842, one level skipped, the only served body that skips one; (3) no served body but the article page draws a <footer>, so the shell has a banner and a main and no contentinfo. The SPA now draws one banner, one main and one contentinfo on all nine pages, and no unnamed navigation at either width. seo.py is AW7's must-not-touch, so its owner decides; the SPA changes to mirror are aria-label="Main" on the header nav and a visually hidden h2 above the article grid.
- **The collectors page jumps from its h1 to the cards' h3** (owner Stream AW4; raised 2026-09-20 by Stream AW7; id `issue-the-collectors-page-jumps-from-its-h1-to-the-cards-h3`)
  On /collectors at 1440 and at 390 the heading ladder is h1 "Collectors and catalogue" then every qa-card title at h3, one level skipped; the tab strip between them is a role=tablist, not a heading. The same shape on /articles was fixed by naming the group in a heading the page does not draw (main/web/src/pages/ArticlesPage.tsx, .articles-page__group). The fix here is one heading per tab panel, which is seven call sites in CollectorsPage.tsx rather than one rule, so it was left. Internal page, owner and sources_manage only, so no shopper meets it. Evidence: the probe's outline, .logs/verification/quality-pass-2026-09-19.md, the accessibility table.
- **No way past the header: twenty-one Tab presses before the page's own content** (owner Stream C; raised 2026-09-20 by Stream AW7; id `issue-no-way-past-the-header-twenty-one-tab-presses-before-the`)
  Signed in at 1440, the first control the page's own content offers is the twenty-first Tab: three announcement links, the logo, the search box, the airports button, the account menu, the bell, then eight menu links and four more. Signed out it is nineteen. There is no skip link, so a keyboard reader without a screen reader walks the whole header on every page. WCAG 2.4.1 is met for a screen reader by the landmarks, which are now all named, but not for a sighted keyboard reader. The fix is a link, a rule and an id on the main element: three files, not one, so it was not taken here. Evidence: scripts/page-probe.py --tab 40 on /collectors at 1440, .logs/verification/quality-pass-2026-09-19.md.
- **The primary button's hover and focus colour is a literal, so it does not follow a scheme** (owner Stream C; raised 2026-09-20 by Stream AW7; id `issue-the-primary-button-s-hover-and-focus-colour-is-a-literal`)
  web/src/styles/app.css line 124: .btn--primary:hover, .btn--primary:focus-visible set background and border-color to #B0431F, a coral that exists nowhere in tokens.css. The resting state is var(--accent-strong), which every colour scheme redefines, so under ink, forest, burgundy, midnight and their light-header twins the button rests in the scheme's accent and turns coral the moment a reader tabs to it. White on #B0431F is 5.72:1, so nothing fails a contrast bar; what fails is the scheme. The same line holds color: #fff where --ink-inverse exists. .btn--light at line 135 does the same with #fff. The fix is a token (--accent-deep) added to the ten scheme blocks, which is ten edits in tokens.css rather than one rule, so AW7 left it. The article centre's pager and every primary action on the site draw it.

## Resolved

- **Every launch price is stale until the drinks refresh runs** (P1; owner A; raised 2026-09-03 by Stream A; id `issue-launch-prices-stale`)
  Newest observation anywhere in the sixteen is 25 Aug; Dubai 21 Aug. The drinks refresh of the sixteen minus the refusing shops is Stream A's first task tonight (A10), after which verify runs its first twenty-page check the next morning. The last-checked date on every price keeps this visible to visitors, which is the point.
  Gates: A13 (blocking)
  **RESOLVED 2026-09-05 by planning session:** 5 Sep refresh: every source except Dubai (refuses) has prices dated 5 Sep; deployed in 0.33.0
- **Update header_html() mirror for the mega menu** (P1; owner B; raised 2026-09-05 by Stream C; id `issue-update-header-html-mirror-for-the-mega-menu`)
  SiteHeader.tsx now wraps the Products item in a group with a toggle, and the nav carries the (closed) browse panel. Row height is unchanged (47.9px measured closed and open; header 126.2px), so the mirror change is markup only, in the same deploy as commit C2. In header_html() replace the first _NAV entry's <a> with exactly:
  <div class="site-nav__group"><a class="site-nav__link[ site-nav__link--active]" href="/products">Products</a><button type="button" class="site-nav__more" aria-expanded="false" aria-controls="site-mega-menu" aria-label="Browse by category, brand and airport"></button></div>
  (the button is empty; its chevron is CSS). The other links are unchanged. After the closing </div> of .site-nav__inner and before </nav>, add the closed panel shell so aria-controls resolves:
  <div id="site-mega-menu" class="mega" hidden role="region" aria-label="Browse by category, brand and airport"></div>
  Optional, worth it for crawlers: fill that hidden div with the panel's links server-side (categories via category_counts with count >= 8 to /products?category=..., brands via list_brands sorted by products desc top 12 to their path, airports via list_airports to their path, plus /products and /airports); the SPA never hydrates it, it replaces it, so contents need not match. Classes for the links are in web/src/components/MegaMenu.tsx if you do. Verify: tests/test_seo_body or a token diff of the served nav against headless Chrome's closed-state DOM (scratch check: main/web build, then compare <nav class=site-nav> outerHTML); Lighthouse CLS stayed 0.000 on the product page with the SPA change alone.
  Gates: C2 (blocking)
  See `main/web/src/components/SiteHeader.tsx`
  See `main/web/src/components/MegaMenu.tsx`
  **RESOLVED 2026-09-05 by Stream B:** 7b0e542: header_html emits the site-nav__group with the site-nav__more toggle and the hidden #site-mega-menu shell exactly as SiteHeader.tsx renders closed; verified token for token against headless Chrome's mounted DOM of a product, an airport and an article page (only the no-JS search form differs, as before). The optional server-side fill of the panel is declined: the SPA renders nothing inside while closed, and the served markup must be what the visitor gets. Later header changes need the same mirror; file again.
- **The slug hook is on a path nothing calls: the desk, the sheet and replay record an alias without it** (P1; owner K3; raised 2026-09-17 by Stream K6; id `issue-the-slug-hook-is-on-a-path-nothing-calls-the-desk-the-sh`)
  File: main/app/services/decisions/appliers.py (the product_line alias_of applier; K2's file, K3's confirm path, so K6 did not touch it). K3.6 put publish.slug_at_confirm in merges.apply_brand_alias / apply_line_alias, which only tests call. The live paths record alias_of through writer.record directly: the desk's Confirm same (appliers._pair_consequences), the sheet's absorb (proposals._absorb) and replay. So on production no confirmed alias fixes a slug. Change: call the hook from the ('product_line','alias_of') applier's consequences, where every path passes: publish.slug_at_confirm(target_line, (row.detail or {}).get('preferred_name') or target_line.name, decision=row); pass decision=row so the redirect row carries its decision id, and drop the two calls in merges.py. The slug is a pure function of the brand slug and the chosen name, so replay gives the same address on both hosts. K6's function is live and rehearsed through merges.apply_line_alias (dfp_k6: two aliases, old and middle slugs each one 301 to the final). An undo of the alias leaves the new slug and its redirect in place (additive; the address a person chose stays).
  **RESOLVED 2026-09-17 by K8:** The hook moved into the (product_line, alias_of) applier's consequences with decision=row; the two calls in merges.py dropped; the test drives the desk's Confirm same and asserts the redirect row (3fa4678).
- **The Shopify drinks filter only knows English shelf names** (P2; owner A; raised 2026-09-03 by Stream A; id `issue-shopify-drinks-filter-english`)
  "liquor" is a hint but "licor" is not, so Panama's own Licores shelf is invisible: only 181 of 395 items pass. Spanish shelf names (Vinos, Rones, Champanas, Cordiales) fail everywhere, so liquor is being dropped at Panama and Bogota. Part of the beauty widening task (A6).
  Gates: A6 (costly)
  **RESOLVED 2026-09-05 by Stream A:** 3d5f3f1: shelf_vertical() matches the shop's own shelves in English and Spanish (Licores, Vinos, Rones, Champanas, Cordiales...); tests in test_beauty_widening.py
- **110 product rows are duplicates of another row** (P2; owner A; raised 2026-09-03 by Stream A; id `issue-duplicate-products`)
  52 groups measured 3 Sep. A bottle appears as two products with two prices instead of one product with a comparison, which also corrupts the aggregate-offer markup the SEO plan relies on. Fixed as recorded merges with a forwarding id, never deletes (A9), before brand pages ship.
  Gates: B5 (blocking)
  **RESOLVED 2026-09-05 by Stream A:** 758233e: 'backfill merges' after 'rederive' folds every group sharing a key without a barcode, attribute or set conflict (40 groups, 44 rows on the 5 Sep dump); the rest are merge_candidates for the Mon 21 review; merged ids 301 to their survivor
- **Brand names are not normalised, and brand pages depend on it** (P2; owner A; raised 2026-09-03 by Stream A; id `issue-brand-names-unnormalised`)
  "Don Julio", "Don Julio Tequila" and "Don Julio®" are three brands; "Moet & Chandon" and "Moët & Chandon" are two; six pairs differ only in case or accent. Each would become a competing brand page. Same family: name matching splits when there is no barcode, which undercounts comparisons (safe direction, but thin and sloppy). Brands become a table with a fold rule in migration #3 (A8) and the fold backfill (A9); the first audit counted 122 folds.
  Gates: B5 (blocking)
  **RESOLVED 2026-09-05 by Stream A:** 758233e: brands table (migration f3a4b5c6d7e8) + 'backfill brands' (2,215 rows from the spellings; mixed case shown over caps); match_key v2 folds the brand, 'rederive' re-keys 2,512 rows
- **About 260 launch products have no category and fall out of the structure** (P2; owner A; raised 2026-09-03 by Stream A; id `issue-uncategorised-products`)
  Measured 3 Sep; some are the beauty products already collected at Athens. An idempotent backfill assigns them (A9).
  Gates: B6 (costly)
  **RESOLVED 2026-09-05 by Stream A:** 758233e: 'backfill categories' (widened rules, Spanish shelves, Makeup, single-category houses): launch uncategorised 260 -> 121 on the dump, 1,032 rows overall; the remaining 121 have nothing to go on and stay honest
- **Singapore and Seoul: first real runs and their discovery limits** (P2; owner A2; raised 2026-09-05 by Stream A2; id `issue-sin-icn-first-runs`)
  Both collectors are built and the sidecar is deployed. Still to do: the first twenty-page run of each in an announced window after your Changi hand check; reading the first raw records; then the discovery question. Shilla can only be walked through the home page and related-product rails (its grids are drawn by disallowed scripts), and Changi's full catalogue needs a category-page harvest that is unprobed. Singapore has no barcodes anywhere, so it matches on brand, name and size only.
  Gates: R4, R5 (costly)
  **RESOLVED 2026-09-05 by planning session:** First runs done 5 Sep: Seoul clean (18 prices), Singapore 17 prices then a 403 at page 19; the pace decision is decide-singapore-answered-403
- **A number in a beauty product's name is read as its size** (P2; owner A; raised 2026-09-05 by Stream Q; id `issue-beauty-name-size-misread`)
  Found by the first audit: Extime "DELICIA DRENCH 59" stored as 5,924 ml and "CHEIROSA 76" as 7,624 ml; two Extime drinks rows carry the old 7-litre misread. The audit's oversize metric lists them with the threshold pinned at today's six, so a new one shows red. Fix the size parser on beauty names and re-derive the four rows.
  Gates: B5 (costly)
  **RESOLVED 2026-09-05 by Stream A:** 3d5f3f1: the names were innocent; the page declares capacity 7624 ml and prices per 100 ml (implies 240). The cross-check now reads that live shape and refuses the size; 'backfill implausible_sizes' clears the four stored rows after the deploy
- **Hide or date the rows of a publication-blocked shop on the public site** (P2; owner B; raised 2026-09-05 by Stream Q; id `issue-hide-blocked-shop-rows`)
  Decision 10 says a correctness failure hides the affected rows or shop until a human clears it. Verify now knows which shops are blocked, but the latest-price queries do not yet exclude their observations or show them as dated. Until that lands the block is visible only in verify-status. Owner: whoever owns the catalogue queries (B or A).
  Gates: A13 (blocking)
  **RESOLVED 2026-09-05 by Stream B:** 5a5acba: catalog_queries.publishable(db) hides a blocked shop from every catalogue query, /api/stats, the sitemap and IndexNow; rehearsed on scratch copy dfp_b (JFK hidden while blocked, restored on clear); tests/test_publication_block.py. /trip keeps its own clause: separate issue.
- **Singapore: the run pace may need to slow; the block-and-stop worked as designed** (P2; owner A2; raised 2026-09-05 by planning session; id `issue-singapore-the-run-pace-may-need-to-slow-the-block-and-st`)
  First run: 18 pages clean at the ten-second floor, then a 403 at page 19 (49 s, 512-byte body, no data calls). If rian approves a slower retry, make the per-source pace configurable (render_wait floor per source, thirty seconds for Singapore) and record the outcome in the run log. Seoul: 18 prices from 20 renders, no refusals, rails-only discovery as designed.
  Gates: R5 (costly)
  **RESOLVED 2026-09-05 by Stream A2:** 83bb3a4: per-source floor built (Changi.render_floor_seconds, None until rian's decision; 30.0 on a yes), pace logged per render as wait=; behaviour unchanged tonight
- **Article pages need seo.py and main.py to know /articles: page route, head, body, sitemap; add the two App.tsx routes in the same commit** (P2; owner B; raised 2026-09-05 by Stream D; id `issue-article-pages-need-seo-py-and-main-py-to-know-articles-p`)
  Stream D has the articles table (commit 89f33a0), the API (GET /api/articles?kind=article|airport_writeup|category_intro&limit&offset, /api/articles/{slug}, /api/articles/airport/{iata}, /api/articles/category/{name}; published only, drafts 404) and the SPA pages web/src/pages/ArticlesPage.tsx and ArticlePage.tsx plus web/src/api/editorial.ts (hooks, reads window.__DFP_ARTICLE__ as a seed). The SPA catch-all in main.py 404s any path seo.is_known_route() does not know, and tests/test_seo.py requires a STATIC_HEADS path and its App.tsx <Route> to land TOGETHER, so the head and the two Route lines cannot come from two workers in two commits. Please land all of this in one B commit:
  1. web/src/App.tsx: lazy imports like the others, then <Route path="/articles" element={<ArticlesPage />} /> and <Route path="/articles/:slug" element={<ArticlePage />} />. 2. app/main.py: a page route /articles/{slug} (GET, HEAD) next to the product and airport routes calling seo.article_head(db, slug); not_found() when it returns None, so a draft or unknown slug is a real 404 rather than the shell. 3. app/services/seo.py: STATIC_HEADS['/articles'] (title, description, canonical /articles) and article_head(): row = app.services.editorial.article_by_slug(db, slug); out = editorial.article_out(row) (ArticleOut: title, description, path, body_html, standfirst, category, published_at, updated_at, hero_image); canonical = out.path; JSON-LD Article (headline, description, datePublished, dateModified, publisher {'@id': '/#organization'}, mainEntityOfPage) plus BreadcrumbList; body = the ArticlePage markup, class for class: <article class="page article-page"><header class="article-page__head"><span class="eyebrow">{category or 'Article'}</span><h1 class="article-page__title">..</h1>[<p class="article-page__standfirst">..</p>]<p class="article-page__meta">Published {date}</p></header>[<figure class="article-page__hero"><img ..></figure>]<div class="prose">{body_html}</div></article>; seed window.__DFP_ARTICLE__ = out; last_modified = updated_at. 4. sitemap_entries: editorial.sitemap_rows(db) gives [(path, lastmod)] for published articles; add /articles to the static paths. 5. RSS (B7 shipped in eb85201): editorial.feed_items(db, limit=20) gives ArticleOut rows newest first if you want an articles feed beside the products one. 6. tests/test_seo.py TestRouteInventory: add '/articles/' to the tuple of prefixes served by their own route. 7. Airport pages: editorial.airport_writeup(db, iata) -> Article|None; embed editorial.article_out(row) in AirportDetail or drop <EditorialBlock kind="airport" code={iata} /> (web/src/components/EditorialBlock.tsx; fetches /api/articles/airport/<iata>; renders nothing when null) into AirportPage.tsx. Category pages (B6): editorial.category_intro(db, name) / <EditorialBlock kind="category" name={category} />.
  Gates: D2 (blocking)
  See `main/app/services/editorial.py`
  See `main/app/routers/articles.py`
  **RESOLVED 2026-09-05 by Stream B:** 69108f5: /articles static head, /articles/{slug} route (GET/HEAD, real 404 for drafts), seo.article_head with Article + BreadcrumbList and the ArticlePage body class for class, __DFP_ARTICLE__ seed, sitemap rows and feed items from editorial, the two App.tsx routes (ArticlePage in the main bundle so the server body is redrawn, not blanked). Airport write-ups ride in AirportDetail.writeup and render on the airport page server- and client-side with EditorialBlock's classes; category intros wait for B6. Rehearsed on dfp_b at head. D2 can proceed: import Adam's hand-ins after the deploy.
- **Audit must skip merged products and re-pin two thresholds after migration #3** (P2; owner Q; raised 2026-09-05 by Stream A; id `issue-audit-must-skip-merged-products-and-re-pin-two-threshold`)
  After the evening deploy runs 'rederive' and 'backfill merges' (A9), products with merged_into_id set are tombstones: audit.py should exclude them from every product metric (duplicate_groups, brand_folds, oversize_singles, uncategorised) or it counts forwarded rows. Measured on a clean copy of the 5 Sep dump: oversize_singles 6 -> 2 after 'backfill implausible_sizes'; duplicate_groups (mergeable) -> 0 after 'backfill merges', with 223 gtin_differs/set groups left as merge_candidates; brand_folds -> 0 once brands rows exist. THRESHOLDS should be re-pinned to the post-backfill numbers; the Mon 21 checklist can read merge_candidates for its 'right product' list.
  Gates: Q5 (costly)
  **RESOLVED 2026-09-05 by Stream Q:** 4f712c5: tombstones excluded from every product metric; duplicates read through merges.duplicate_groups; brand_folds a pending-fold metric at 0; oversize 2, duplicates 0; merge_candidates list + checklist section 6; 16/16 green on dfp_b
- **Brand pages: the brands table is ready (slug, name, canonical_id); Makeup is a new category** (P2; owner B; raised 2026-09-05 by Stream A; id `issue-brand-pages-the-brands-table-is-ready-slug-name-canonica`)
  Migration f3a4b5c6d7e8 + 'backfill brands' give brands(id, slug UNIQUE, name, canonical_id NULL) and products.brand_id (2,215 rows on the dump; mixed-case display names). A brand page should key on brands.slug and join products.brand_id, never on the products.brand text. Products with merged_into_id set are tombstones: exclude them from lists (resolve_product_id already forwards the detail routes). 'backfill categories' adds the category value 'Makeup' (vertical beauty; 241 rows on the dump), so category lists and structure.ts should know it. test_site_routes.py::test_identity_resolver_is_a_pass_through_until_migration_3 is stale in name only (still true without a session).
  Gates: B5 (info)
  **RESOLVED 2026-09-05 by Stream B:** Overtaken by B5 (7094b48): brand pages key on brands.slug and join products.brand_id via brand_ids (house + aliases), never the brand text; list_products excludes nothing extra because tombstones hold no listings after backfill merges re-points them (resolve_product_id forwards the detail routes). Makeup: taxonomy carries it and structure.ts already describes make-up under Cosmetics without enumerating category values, so nothing to change there.
- **Wire award_picker into catalog_queries: card corner, product page list, and the detail route's airports** (P2; owner B; raised 2026-09-05 by Stream F; id `issue-wire-award-picker`)
  Stream F's app/services/award_picker.py (commit e2d50a3) is the strategy Adam was given; it is pure and unwired because the call sites are in B's files. Three edits, no schema change, seo.py untouched (it keeps reading detail.awards): (1) catalog_queries._top_awards(db, product_ids, at_codes=None): group the Award rows per product, then sel = award_picker.pick(rows, visitor_airports=at_codes or (), listing=True); the TopAward is built from sel.featured (None when nothing qualifies); list_products passes its at_codes through. (2) catalog_queries.get_product(db, product_id, at_codes=None): sel = award_picker.pick(all Award rows of the product, visitor_airports=at_codes or ()); ProductDetail.awards = [AwardOut.model_validate(a) for a in sel.retained] (featured first, this is what the page and seo.py's award property emit); top_award from sel.featured; award_count can stay the number of medals held. (3) routers/catalog.py get_product gains at: list[str] = Query(default=[], max_length=12) passed as at_codes; main.py's server-rendered product page passes none (deterministic without a visitor). The old _MEDAL_RANK and the best-tier-then-year loop in _top_awards go away.
  Why: today the card corner shows the best medal a bottle ever won, so a 2024 Double Gold outranks a 2025 Silver forever; the product page lists every medal from every year; nothing knows the visitor's airports. Adam's answer on the rules (do-adam-awards-strategy) does not gate this wiring: the picker is built to the rules as written and a change is a constant or one ordering function inside award_picker.py.
  Gates: F2 (blocking)
  See `main/app/services/award_picker.py`
  See `main/tests/test_award_picker.py`
  **RESOLVED 2026-09-05 by Stream B:** 00cf418: _top_awards and get_product go through award_picker.pick (listing=True for cards, the request's at codes); ProductDetail.awards = retained featured first, award_count = medals held, top_award = featured; GET /api/products/{id}?at= accepted, the server page passes none. _MEDAL_RANK gone; seo.py untouched. Rehearsed on dfp_b: every corner is its competition's latest result. Not done: the SPA's useProduct does not send the visitor's airports yet (queries.ts; separate B item).
- **A shorter competition entry lands on the longer retail variant (Bacardi Ocho on the Rye Cask Finish)** (P2; owner F; raised 2026-09-05 by Stream F; id `issue-award-shorter-entry-on-variant`)
  The contrastive-expression veto allows one-sided extras (retailers add category words), so an entry named 'BACARDI Ocho' or 'BACARDI Reserva Ocho' matches 'Bacardi Reserva Ocho Rye Cask Finish Rum 1L' when the plain Ocho is not in the catalogue, and 'Angel's Envy Kentucky Straight Bourbon ... Port Wine Barrels' matches the Cask Strength bottle. Today the same-year collision rule catches the years where both entries exist; the years with one entry still place the plain bottle's medal on the variant. Fix direction: treat product-only extras that are expression words (cask, strength, finish, rye, reserve, black, organic, edition) as a veto while still allowing category and size words; measure with .logs/verification/awards-matching-review-2026-09-05.md as the baseline. Precision first; never loosen.
  See `.logs/verification/awards-matching-review-2026-09-05.md`
  **RESOLVED 2026-09-09 by Stream N:** Landed: one-sided expression markers veto (cask, strength, finish, rye, sherry, port, peated, barrel, label, xo/vs/vsop, spiced, flavours), plus the curly-apostrophe fix. Rehearsed on dfp_n, every changed row read by hand (.logs/verification/awards-matching-review-2026-09-10.md). After deploy: awards --rebuild.
- **Place Stream D's components: latest articles and the subscribe form on the home page, sponsor slots at the C5 positions** (P2; owner C; raised 2026-09-05 by Stream D; id `issue-place-stream-d-s-components-latest-articles-and-the-subs`)
  Stream D ships drop-in components; their placement is in files Stream C owns (HomePage.tsx, SiteFooter.tsx, styles). Each is one line to place and renders nothing when there is nothing to show, so they are safe on the home page before Adam's text arrives.
  1. Home page article listing (D2): replace the sample EDITORIAL block in web/src/pages/HomePage.tsx with <LatestArticles /> from web/src/components/LatestArticles.tsx (returns null until at least one article is published; shows up to three ArticleCards and an 'All articles' link to /articles). The sample cards and web/src/lib/editorial.ts can go once it is placed. 2. Email capture (D3): the home page newsletter block (home-newsletter) becomes <SubscribeForm source="home" /> from web/src/components/SubscribeForm.tsx; a compact variant <SubscribeForm source="footer" compact /> fits the footer. The form posts to /api/subscribers (throttled) and shows the server's thank-you; style hooks are subscribe-form, subscribe-form__grid, subscribe-form__field, subscribe-form__consent, subscribe-form__done, subscribe-form__error. 3. Sponsor slots (D4): <SponsorSlot position="home-top" size="728x90" /> from web/src/components/SponsorSlot.tsx at each position C5 settles; sizes are the IAB set in web/src/lib/sponsors.ts (728x90, 970x250, 970x90, 300x250, 336x280, 300x600, 160x600, 320x50, 320x100). The slot reserves its box at the size's aspect ratio (no layout shift) and draws nothing until a creative is registered in SPONSORS (web/src/lib/sponsors.ts) pointing at a versioned file under public/sponsors/ (served at /docs-static/sponsors/...). It only ever shows a static image with rel=sponsored; there is no prop for a script or an iframe. Restyle by editing SponsorSlot.css or the tokens, not by wrapping.
  Gates: D2, D4 (costly)
  See `main/web/src/components/LatestArticles.tsx`
  See `main/web/src/components/SubscribeForm.tsx`
  See `main/web/src/components/SponsorSlot.tsx`
  **RESOLVED 2026-09-05 by Stream C:** LatestArticles and SubscribeForm (home band + compact footer form on every other page) placed in 6fa56d8; SponsorSlot positions wait for C5 (blocked: needs rian for sizes), tracked on /plan
- **Add brand_slug to ProductSummary so product pages link to brand pages** (P2; owner A; raised 2026-09-05 by Stream B; id `issue-add-brand-slug-to-productsummary-so-product-pages-link-t`)
  Brand pages (/brands/<slug>, B5) are keyed by brands.slug, which the product API does not carry, so the product page's brand link still goes to the catalogue search (urls.py brand_path / lib/urls.ts brandPath fall back when no slug is given). Ask: in app/models/schemas.py add 'brand_slug: str | None = None' to ProductSummary (ProductDetail inherits it), and in app/services/catalog_queries.py set it in list_products (join Brand on Product.brand_id, or select Brand.slug via the relationship) and get_product (product.brand_row.slug, resolving canonical_id to the house). catalog_queries is B's read layer: say the word and B does that half; only the schema line is A's. Then B switches the two call sites: seo.py product_body -> brand_path(detail.brand, detail.brand_slug) and web/src/pages/ProductPage.tsx -> brandPath(data.brand, data.brand_slug). Until then brand pages are reachable from the sitemap, llms.txt and each other, not from product pages.
  Gates: B5 (costly)
  **RESOLVED 2026-09-05 by Stream A:** c2f5a59: schema field + catalog_queries.brand_page_slugs fills list_products and get_product (house slug, only when the house has a page); B's two call sites filed as issue-product-page-link-the-brand-name
- **The /trip comparison still shows a publication-blocked shop: two visibility clauses in services/trip.py** (P2; raised 2026-09-05 by Stream B; id `issue-the-trip-comparison-still-shows-a-publication-blocked-sh`)
  5a5acba made catalog_queries.publishable(db) the one rule for which shops the public site shows (visible AND NOT IN catalog_queries.blocked_location_ids(db), fed by verify.blocked_sources). app/services/trip.py (no stream owns it; pre-baseline) keeps its own Location.visible.is_(True) clauses (the aliased a/b locations near lines 39-40 and 69-70, and lines 91 and 116), so while a shop is blocked its prices still appear on /trip and /api/trip. Fix: in each of those WHERE clauses add Location.id.not_in(blocked) / a.c.id.not_in(blocked) with blocked = catalog_queries.blocked_location_ids(db) (empty list = no clause; the helper memoises per request), or replace the plain clauses with catalog_queries.publishable(db) where the un-aliased Location table is joined. Same for services/coverage.py line 77 if the airport picker should stop offering a blocked airport (choosing it now yields an empty list, which is truthful, so this half is optional). Test: tests/test_publication_block.py shows the compile-and-assert pattern.
  Gates: A13 (costly)
  See `main/app/services/trip.py`
  See `main/app/services/catalog_queries.py`
  **RESOLVED 2026-09-05 by Stream A:** fixed in 445c25f: trip.py (both aliases, stops, compare) and coverage.py go through publishable/blocked_location_ids; tests/test_trip_publication_block.py; rehearsed on dfp_b
- **Send the category family with /api/stats so the mega menu can group its shelves** (P2; owner B; raised 2026-09-05 by Stream C; id `issue-send-the-category-family-with-api-stats-so-the-mega-menu`)
  The mega menu's categories column is meant to read as the families and their categories (build plan: taxonomy's verticals). No API carries the family today, so the column is one flat list. MegaMenu.tsx already groups by it the moment it arrives: it reads two optional fields on each CategoryCount, family (taxonomy.vertical_of(category), e.g. liquor / beauty / confectionery / tobacco) and family_label (the shopper's word for it, one dict in taxonomy.py next to VERTICAL_OF_CATEGORY, e.g. Drinks / Beauty / Confectionery / Tobacco; never typed in the SPA). Change: app/models/schemas.py CategoryCount gains family: str | None = None and family_label: str | None = None; catalog_queries.category_counts fills both from taxonomy; nothing else changes (the same object feeds /api/stats, /api/dataset, airport and brand detail). check.sh regenerates the TS types. Owner B because category_counts is in your file; the schema line is A's file and one line.
  Gates: C2 (info)
  See `main/web/src/components/MegaMenu.tsx`
  **RESOLVED 2026-09-05 by Stream B:** 5690633: category_counts fills family/family_label from taxonomy; tests/test_category_family.py; rehearsed on dfp_b, /api/stats JSON carries both
- **Inject the social profile URLs into the shell (window.__DFP_SOCIAL__) from the environment, and put them in Organization sameAs** (P2; owner B; raised 2026-09-05 by Stream C; id `issue-inject-the-social-profile-urls-into-the-shell-window-dfp`)
  C4 ships the Instagram and YouTube links in the header nav row and under the footer tagline (components/SocialLinks.tsx, lib/social.ts). The SPA renders nothing until the server hands it the URLs, so one home for them: config.py gains SOCIAL_INSTAGRAM_URL and SOCIAL_YOUTUBE_URL (empty by default, set in .app.env when Adam and Mark hand them over on /todo); main.py's shell injection writes window.__DFP_SOCIAL__ = {"instagram": ..., "youtube": ...} next to window.__DFP_FLAGS__ (omit or null an empty one; the SPA also refuses anything that is not https://); seo.py's ORGANIZATION gets sameAs from the same two settings (SEO.md already plans 'sameAs once the client confirms handles'). The header mirror (header_html) then renders, after the quiet Airports link, <div class="social social--nav"><a href=... class="social__link" target="_blank" rel="noopener" aria-label="Duty Free Professor on Instagram" title="Instagram"><svg ...></a>...</div>; copy the two inline SVGs from SocialLinks.tsx (or leave the mirror without them: the row height does not change, the glyphs simply appear when React mounts). Until the env lines exist nothing changes on the page.
  Gates: C4 (costly)
  See `main/web/src/lib/social.ts`
  See `main/web/src/components/SocialLinks.tsx`
  **RESOLVED 2026-09-05 by Stream B:** commit after d21a3e0: SOCIAL_INSTAGRAM_URL / SOCIAL_YOUTUBE_URL in config.py (settings.social_profiles: https only, no placeholders) -> window.__DFP_SOCIAL__ next to the flags, header_html mirror after the Airports link with the component's glyphs, Organization sameAs. Verified identical to Chrome's mounted DOM with both URLs set. Rian: add the two lines to .app.env when Adam and Mark hand the URLs over (the /todo item); until then nothing shows.
- **Product page: link the brand name to its brand page now that brand_slug is on the API** (P2; owner B; raised 2026-09-05 by sweeper; id `issue-product-page-link-the-brand-name-to-its-brand-page-now-t`)
  c2f5a59 added ProductSummary.brand_slug (ProductDetail inherits it): the brand page's slug when the brand has a page, None when the house is too thin for one (under BRAND_PAGE_MIN_PRODUCTS), so a link made through brand_path/brandPath can never 404. Two edits, both B's: app/services/seo.py product_body line ~678, brand_path(detail.brand) -> brand_path(detail.brand, detail.brand_slug); web/src/pages/ProductPage.tsx line ~129, brandPath(data.brand) -> brandPath(data.brand, data.brand_slug). The generated client already carries the field (schema.ts). The header mirror test (tests/test_seo_body.py) is unaffected; add one body assertion that a detail with brand_slug links to /brands/<slug> and one without links to the search.
  Gates: B5 (info)
  See `main/app/services/seo.py`
  See `main/web/src/pages/ProductPage.tsx`
  **RESOLVED 2026-09-05 by Stream B:** 9150552: seo.py product_body and ProductPage.tsx pass brand_slug through brand_path/brandPath; test in test_seo_body.py; rehearsed on dfp_b
- **No command imports Adam's brand images from a folder** (P2; owner A; raised 2026-09-09 by Stream N; id `issue-no-command-imports-adam-s-brand-images-from-a-folder`)
  Stream A's Wave 2 handoff listed 'app.cli images import <dir>' as not done, and it still does not exist: python -m app.cli images only enriches from the openly licensed source. Adam's official product images arrive in the shared Drive folder named by brand and product (/todo row 8). The command should take a directory, match each file to a product (barcode in the name first, then brand and name), store it as first-party imagery with provenance 'brand' and never overwrite a human-set image; a dry run lists the matches.
  **RESOLVED 2026-09-19 by Stream AW3:** built as app.cli images manifest (2d32358: the folder and the export classified into import/images/adam-2026-09-19.json, barcode first, then brand and line, a dry list), images stage and images import (e8a0fc9: provenance admin:<supplier>, set_image never replaces an admin picture)
- **No external uptime alert on production yet: the check runs and logs, nothing pages rian** (P2; owner E; raised 2026-09-10 by Stream E; id `issue-no-external-uptime-check-on-production-yet`)
  11 Sep: deploy/uptime-check.sh runs from the dev server every five minutes (cron), checks https://dutyfreeprofessor.com/api/health and the sign-in page from outside the droplet, and logs every check to .logs/runs/uptime.log with failures in uptime-incidents.log. The missing half is the alarm: the dev server has no mailer. Two ways to close it, rian's choice: (a) send through Resend's HTTP API with a verified sender (which domain is verified in Resend?), a ten-line addition to the script; or (b) add the domain to the works-suite hosting monitor, which already pages by Telegram. DO's own alert policies (memory, CPU, disk) are set and do email.
  See `deploy/uptime-check.sh`
  **RESOLVED 2026-09-10 by rian (in chat, 11 Sep):** Email through Resend from monitor@dutyfreeprofessor.com to rian, after two failed five-minute checks and once on recovery; test email delivered 11 Sep 03:5x UTC.
- **items.py cannot read rian's page actions since members-only: /api/items answers 401 to the local script** (P2; owner R; raised 2026-09-10 by Stream E; id `issue-items-py-cannot-read-rian-s-page-actions-since-members-o`)
  main/scripts/items.py merges import/items.json with the app's owner_item_states through GET /api/items on the container port. Since R1 (SITE_ACCESS=members) that read is refused for an anonymous caller, so every sweep and export prints 'app unreachable: rian's page actions not included' and the two generated registers no longer reflect what rian decided on /plan. The script needs a way in: a bridge-only read declared public in services/access.py for this one path, or a service token in the environment the script can present. Until then, read rian's decisions on the page itself.
  See `main/scripts/items.py`
  **RESOLVED 2026-09-10 by Stream E:** 11 Sep: items.py reads owner_item_states through the database container (db_states), API as the fallback; sweep/export merge rian's page decisions again (24 waiting became 14 once his page actions were seen).
- **The seven UK Avolta storefronts render tiles the parser cannot read** (P2; owner A; raised 2026-09-19 by Stream AW1; id `issue-the-seven-uk-avolta-storefronts-render-tiles-the-parser`)
  Scout window 2026-09-19c: london-gatwick, manchester, london-stansted, edinburgh, glasgow, birmingham and bristol .worlddutyfree.com (Crawl-delay 30, Disallow /*? so page-one-only, like LHR) render every tile as <a href=... class="product-item-link">, href BEFORE class, and avolta._URL_RE requires class first: 0 of 18 to 34 tiles per page parse as-is, all of them parse if the attribute order is accepted (scratch measurement, avolta.py untouched). Second shape difference: a hidden Club Avolta member block per tile whose finalPrice (15.59) precedes the shown regular price (14.03) on one tile per page, so the first typed finalPrice is a price the shopper does not pay. Fixtures: main/tests/fixtures/scout/<host>_listing.html and _robots.txt. Cost: seven UK airports on a platform we already parse are not configuration until _URL_RE accepts either order and the price rule skips display:none member blocks. Not chosen by AW1 (parsing functions are Stream A's).
  See `.logs/runs/window-2026-09-19c-scout.md`
  **RESOLVED 2026-09-19 by Stream AW1:** Wrong diagnosis: the href-first shape is the tree root's carousel only; the category grids on all seven UK hosts (second scout pass, 19 Sep 18:20 UTC) are class-first and parse with avolta.parse_product_variants as they stand (9 tiles, 22% barcodes, aperitifs-digestifs). The collector reads the root, finds no tile, and moves to the children, so nothing is lost. No parser change needed.
- **Silver and bronze medal artwork missing for four wine competitions** (P3; owner F; raised 2026-08-25 by rian; id `issue-medal-artwork-missing`)
  Cards fall back to the text tag, which works; sourcing the artwork finishes the set.
  **RESOLVED 2026-09-09 by Stream N:** Landed: nine badges from the competitions' own 2025 artwork (NYIWC, BIWC, MIWC silver and bronze; AIWC gold, silver, bronze), 160 px transparent like the set; test_medal_artwork.py pins every competition has all four tiers.
- **Award aging: demote or hide awards older than a year** (P3; owner F; raised 2026-08-27 by rian; id `issue-award-aging`)
  Your idea from the board comments: older awards fade on listings to encourage brands to re-enter each season. Business-model relevant; belongs in the awards picker work (Stream F).
  Gates: F2 (costly)
  **RESOLVED 2026-09-05 by Stream F:** Stated as a proposal with an on/off decision in the strategy for Adam (/discuss card 5, notes/awards-strategy-for-adam-2026-09-05.md); the switch exists as award_picker.AGING_ENABLED (off) with tests for the on state (e2d50a3); Adam's answer is do-adam-awards-strategy
- **When the header or announcement bar changes shape, update its server-side mirror in the same deploy** (P3; owner C; raised 2026-09-04 by Stream B; id `issue-header-mirror-rule`)
  The server-rendered product page draws a copy of the header so the page does not jump when the app takes over (layout shift measured at zero). The mega menu (C2) and any announcement-bar change alter that shape; the mirror lives in the SEO service and must change in the same deploy. A standing rule for Stream C and Stream B.
  Gates: C2 (costly)
  **RESOLVED 2026-09-05 by Stream C:** Superseded by issue-update-header-html-mirror-for-the-mega-menu, filed with the exact markup in the C2 commit; the standing rule lives in SiteHeader.css (comment at .site-nav__group) and SEO.md
- **agents.md still points the robots rule at the Heinemann collector** (P3; owner DOCS; raised 2026-09-04 by Stream A; id `issue-agents-robots-pointer`)
  The matcher now lives in collectors/robots.py and every collector calls it. One line in agents.md to repoint; the rule text is right.
  **RESOLVED 2026-09-09 by Stream N:** Already fixed: agents.md's robots rule names no collector (the 5 Sep tier-1 trim kept the lesson and dropped the name); found in the 10 Sep issue-log sweep.
- **Review Stream Q's three small edits in Stream A's files** (P3; owner A; raised 2026-09-05 by Stream Q; id `issue-q-touches-in-a-files`)
  Q registered its audit and verify subcommands in the CLI, added a per-reason skip-count column to collection runs, and made ingest count skips by reason. All tested; listed for A's review at its next session, not a bug.
  **RESOLVED 2026-09-05 by Stream A:** reviewed 5 Sep: register_quality in cli.py, skip_counts on CollectionRun, count_skip in ingest are as they should be; kept
- **Expose the robots pattern-to-regex step so the fetcher stops mirroring it** (P3; owner A; raised 2026-09-05 by Stream A2; id `issue-robots-pattern-regex`)
  The fetcher copies four lines of the robots matcher to apply a host's Disallow rules to a rendered page's own requests; a test pins the two equal. Exposing one function from robots.py removes the copy.
  **RESOLVED 2026-09-05 by Stream A:** 9c57e5e: robots.pattern_regex() is public; fetch.py's import swap is A2's (issue filed)
- **Register the indexnow subcommand in app.cli** (P3; owner A; raised 2026-09-05 by Stream B; id `issue-register-the-indexnow-subcommand-in-app-cli`)
  app/cli.py (Stream A's file): add 'from app.cli_pages import register as register_pages' and 'register_pages(sub)' next to register_quality(sub). Until then the command runs as 'python -m app.cli_pages indexnow' (documented in main/docs/SEO.md) and the RUNBOOK's generated CLI reference does not list it. Two lines; nothing else changes.
  **RESOLVED 2026-09-05 by Stream A:** 73362e1: register_pages(sub) in app.cli; 'python -m app.cli indexnow' works
- **ARCHITECTURE.md request path: server bodies are no longer product-only** (P3; owner DOCS; raised 2026-09-05 by Stream B; id `issue-architecture-md-request-path-server-bodies-are-no-longer`)
  main/docs/ARCHITECTURE.md step 3 says the body is rendered server-side 'for product pages'. Since B4/B7 the airport pages (/airports/<iata>-<city>) and the data page (/data) get their bodies the same way, and the process also serves the machine surfaces (/robots.txt with per-bot policy, /sitemap.xml, /feed.xml, /llms.txt, the IndexNow key file). One sentence each; the mechanism is documented in main/docs/SEO.md, so link rather than explain.
  **RESOLVED 2026-09-05 by Stream B:** done in the B commit after 00cf418 (structure rows to built): ARCHITECTURE.md step 3 now names the product, airport, brand, article and data bodies and the machine surfaces, linking SEO.md.
- **fetch.disallow_regexes should import robots.pattern_regex instead of mirroring it** (P3; owner A2; raised 2026-09-05 by Stream A; id `issue-fetch-disallow-regexes-should-import-robots-pattern-rege`)
  robots.pattern_regex() is public since 9c57e5e (Stream A). In app/services/collectors/fetch.py, disallow_regexes() can become [pattern_regex(p) for p in robots.disallows]; tests/test_robots.py::TestPatternRegex pins the two equal until then. One-line swap in A2's file.
  **RESOLVED 2026-09-05 by Stream A2:** 6a1fc91
- **app.cli awards: print the reconcile counters (updated, corrected, removed, ambiguous_same_year)** (P3; owner A; raised 2026-09-05 by Stream F; id `issue-cli-awards-stats`)
  cmd_awards in cli.py prints winners/matched/created/already_present/ambiguous. import_awards now also returns updated, corrected, removed, unchanged and ambiguous_same_year (commit e2d50a3); they are in the log line but not on the terminal. One print line in A's file; F did not touch it.
  **RESOLVED 2026-09-05 by Stream A:** 73362e1: second print line with updated/corrected/removed/unchanged/ambiguous_same_year
- **QUALITY.md: the publication-block sentence still says the public-site query has not landed** (P3; owner Q; raised 2026-09-05 by Stream B; id `issue-quality-md-the-publication-block-sentence-still-says-the`)
  main/docs/QUALITY.md, section 'The tripwire, not a rate', the paragraph beginning 'Publication blocked means': its parenthetical says the public-site query is a request to the stream that owns the catalogue queries and that verify-status is where the block is visible until it lands. It landed in 5a5acba: catalog_queries.publishable(db) hides a blocked shop from every catalogue query, /api/stats, the sitemap and the IndexNow list (mechanism and rehearsal in main/docs/SEO.md, 'Which shops the site shows'). Replace the parenthetical with one sentence pointing there; verify-status remains where the block and its checks are listed.
  See `main/docs/QUALITY.md`
  **RESOLVED 2026-09-05 by Stream Q:** 670b7d0: sentence replaced, points at SEO.md 'Which shops the site shows'
- **Product page: send the visitor's airports on the detail request so the medals reorder for them** (P3; owner B; raised 2026-09-05 by Stream B; id `issue-product-page-send-the-visitor-s-airports-on-the-detail-r`)
  00cf418 made GET /api/products/{id} accept at= (orders the medals by award_picker's affinity rule) and the server-rendered page passes none, so the seeded first paint is deterministic. web/src/api/queries.ts useProduct still fetches /api/products/{id} without the shopper's airports, so a JFK visitor sees the same order as everyone. Change: when useMyAirports() has codes, append ?at=<code>&at=<code> (as useSimilar does) and include the codes in the queryKey; keep the seed as initialData only when no airports are chosen (the seed is the no-airport order). Waited because Stream C had queries.ts open on 5 Sep.
  See `main/web/src/api/queries.ts`
  **RESOLVED 2026-09-05 by Stream B:** 70508d4: useProduct sends at= with the visitor's airports; seed stays initialData only without airports, placeholder with them; rehearsed in headless Chrome against dfp_b
- **Product page: the exclusive badge takes the new excl tone (one word in ProductPage.tsx and its seo.py mirror)** (P3; owner B; raised 2026-09-05 by Stream C; id `issue-product-page-the-exclusive-badge-takes-the-new-excl-tone`)
  C3 gives Badge an excl tone (Badge.css: gold on navy, the mark of the site's own shelf) and the ProductCard already marks exclusives. ProductPage.tsx line ~137 still renders <Badge tone="neutral">Travel-retail exclusive</Badge>; change tone to "excl", add "excl" to BadgeTone in components/Badge.tsx, and mirror the class (badge badge--excl) where seo.py renders that badge in the product body. Also in that file: the "Not stocked at ... — or at least" sentence carries an em dash (client-facing text rule); a comma or full stop reads the same.
  Gates: C3 (info)
  See `main/web/src/components/Badge.css`
  **RESOLVED 2026-09-05 by Stream B:** commit after 7b0e542: Badge.tsx excl tone, ProductPage.tsx tone=excl, seo.py product_body emits badge--excl (test pins it); the em dash in the 'Not stocked at' sentence is a comma now.
- **CategoryCount.family and family_label: the two A-side lines the mega menu's grouping needs** (P3; owner A; raised 2026-09-05 by Stream B; id `issue-categorycount-family-and-family-label-the-two-a-side-lin`)
  Stream C's issue-send-the-category-family-with-api-stats-so-the-mega-menu (owner B) needs two lines in A's files before B can fill them in catalog_queries.category_counts: (1) app/models/schemas.py CategoryCount gains family: str | None = None and family_label: str | None = None (server defaults keep every existing response identical); (2) app/services/taxonomy.py gains one dict next to VERTICAL_OF_CATEGORY mapping vertical -> the shopper's word (liquor: Drinks, beauty: Beauty, confectionery: Confectionery, tobacco: Tobacco) and a family_label(category) helper beside vertical_of. Once those land B fills both fields in category_counts (the same object feeds /api/stats, /api/dataset, airport and brand detail; check.sh regenerates the TS types) and MegaMenu.tsx groups by them with no further change. B did not edit A's files.
  See `main/app/models/schemas.py`
  See `main/app/services/taxonomy.py`
  **RESOLVED 2026-09-05 by Stream A:** d3f0590: CategoryCount.family/family_label + taxonomy.FAMILY_LABEL and family_label(); B's fill in category_counts stays open as issue-send-the-category-family-with-api-stats
- **The hero's airport picker shifts the card when its list arrives (home CLS 0.025 on a phone)** (P3; raised 2026-09-05 by Stream C; id `issue-the-hero-s-airport-picker-shifts-the-card-when-its-list`)
  Measured with a layout-shift observer at the Lighthouse phone viewport (412x823, 4x CPU, slow 4G): the AirportPicker in the home hero renders 'Loading airports...' and then the collapsed airport list, moving airport-picker__foot and home-hero__actions by ~740px (value 0.025). It was 0.009 before the header stopped wrapping to three rows on phones, when the same shift sat mostly below the fold. Fix in the component: render the collapsed list's rows as fixed-height placeholders while useTripStops is pending (the count is the collapsed limit, so the height is known), or give airport-picker__grid a min-height equal to the collapsed list's phone height. AirportPicker.tsx is nobody's stream file; its CSS is C's.
  See `main/web/src/components/AirportPicker.tsx`
  **RESOLVED 2026-09-09 by Stream N:** Landed: loading placeholders at the rows' final height; Lighthouse mobile CLS 0.0166 to 0.0007 on the home page, two runs each side.
- **/favicon.ico answers 404 on every page** (P3; owner C; raised 2026-09-09 by Stream N; id `issue-favicon-ico-answers-404-on-every-page`)
  Noted by Stream C on 5 Sep and in SEO.md's corrections table as open (favicon and manifest are Stream C assets). Browsers request /favicon.ico regardless of link tags; a 404 in every crawl log and a blank tab icon. Needs the icon files derived from the logo (ico, 32 and 180 px PNGs, a manifest) placed in web/public and linked from index.html; the server already serves web/public at the root.
  **RESOLVED 2026-09-10 by Stream C:** 11 Sep: favicon.ico (16/32/48), favicon-32.png, apple-touch-icon.png, icon-192/512.png and site.webmanifest derived from the logo's mortarboard on navy, linked from index.html; tests/test_tokens_contrast.py::TestIconSet pins the links to files that exist. Served as root files by the existing public_always class.
- **A run still running reads as failed in the collectors page story** (P3; owner AW4; raised 2026-09-19 by Stream AW1; id `issue-a-run-still-running-reads-as-failed-in-the-collectors-pa`)
  collector_view._story classifies every run that is neither ok nor blocked as failed, so a source with a run in progress reads 'worked before, refused now' (on the 19 Sep 03:15 dump: avolta-ath, avolta-bcn, avolta-hkg, avolta-mad and extime-paris, all mid-sweep). Cost: the live collectors page tells a person a host closed its door while the collector is happily walking it. Fix: treat status running as neither, or as the previous outcome, in _story; test in test_collector_view.py with a running last run.
  **RESOLVED 2026-09-19 by Stream AW4:** a running row is a neutral kind in _story; test_a_run_still_walking_is_not_a_refusal (AW4: handoff commit)
- **URL changes from Mark's review: the airport name in the path** (owner B; raised 2026-09-07 by Mark's SEO review; id `issue-url-changes-from-mark-s-review-airport-name-in-the-path`)
  Narrowed on 9 Sep by Mark's replies, so this is smaller than it was. What is left: an airport URL must lead with the airport's common name, /airports/heathrow-lhr-london rather than today's /airports/lhr-london, because 'heathrow' is what people search. Category at airport follows the same slug.
  Settled and dropped from this item: the plural stays ('the slug should match the main Airports page and the plural doesn't matter in the url'); the category path is /alcohol/whisky, nested under the family, which Mark confirmed; and the numeric product id STAYS, since Mark withdrew that point once he saw the uniqueness and redirect reasons ('Not against including product ID given the issues you raise here').
  One thing still open: which airports are known by their code rather than their name. Mark defers to Adam and can only think of JFK, so treat JFK as the sole exception unless Adam names others.
  Gates: A13 (blocking)
  **RESOLVED 2026-09-09 by Stream S:** Landed in e119380: /airports/<common-name>-<iata>-<city> from a declared table in urls.py mirrored in urls.ts (a test pins them equal); JFK the one code-first airport; every earlier shape (bare code, lhr-london, stale words) 301s keeping the query; the API sends every airport link ready-made. Verified by curl on a local server against the 9 Sep nightly restore. The slug table for the nineteen is a decide item for rian to confirm with Adam.
- **Airport pages: Mark wants duty free content only, not general airport writing** (owner B; raised 2026-09-09 by Mark on /structure; id `issue-airport-pages-mark-wants-duty-free-content-only-not-gene`)
  Mark, 9 Sep, reacting to Adam's Heathrow draft: 'I don't think you want all of that generic information about e.g. Heathrow that is unrelated to duty free. Keep the text succinct and duty free related, no one needs this site for lounge or terminal information except as it actually relates importantly to duty free. Instead keep to the actual duty free situation at Heathrow including where all the duty free shops are, any specialty shops, perhaps a map. No need to be verbose or have filler of any kind especially not for SEO purposes.'
  Two things follow. The airport page spec on /structure currently asks for 'what the airport is known for, food, lounges, terminal tips', and the to-do list asks Adam for exactly that; both need rewording to shop locations, specialty shops and a map. And the field list should gain the shops and their locations as a first-class thing rather than a footnote.
  Gates: B4 (blocking)
  **RESOLVED 2026-09-09 by Stream S:** Landed in the /structure rebuild: the airport page is specified as the duty free situation only (shops by terminal, specialty shops, perhaps a map, no filler), the lounges and tips field is gone, and /todo row 9's ask to Adam says the same (applied to the live list; the row kept its comment). The terminal and named-shop data model stays open under its own item.
- **Fix the offer markup so its currency matches the page** (owner B; raised 2026-09-09 by Mark on /structure; id `issue-fix-the-offer-markup-so-its-currency-matches-the-page`)
  The decision is settled (local price is the price, our conversion is secondary and stays out of the markup); this is the build. Today the combined offer declares US dollars using our conversion while the individual shop offers nested inside are each in their own currency, which is inconsistent on its face. Either the combined offer carries no price range, or it carries one per currency. Nothing goes in the markup that is not visible on the page in the same currency.
  Gates: A13 (blocking)
  **RESOLVED 2026-09-09 by Stream S:** Landed: the AggregateOffer states no USD range; each shop's offer is in its own currency and a range appears only when every shop quotes one currency. The table leads with the shop's price in bold and labels the USD figure as our conversion at the rate held on the date checked. Pinned on product 1156 (AED and GBP).
- **Brand pages deserve more fields than we planned: Mark rates them high value** (owner B; raised 2026-09-09 by Mark on /structure; id `issue-brand-pages-deserve-more-fields-than-we-planned-mark-rat`)
  Mark, 9 Sep: there are many searches shaped 'X brand duty free' (his example, 'paco rabanne duty free') with no airport in them, so brand pages matter more than their page count suggests. Unlike airport pages, he thinks some general brand writing is appropriate here so the pages are not thin, and he suggests templated fields and structured data: country of origin, founding date, worldwide sales a year and similar. He also sees pay-to-play potential on them. Not for launch, but it argues for building the brand page with room for those fields rather than adding them later.
  Gates: B5 (costly)
  **RESOLVED 2026-09-09 by Stream S:** Landed in the /structure rebuild: the brand page carries Mark's templated fields (country of origin, founding date, owner or house, worldwide sales a year) and room for general brand writing, all optional and rendered only when filled, entering the Brand markup only once visible on the page; the schema plan says so. Brand-and-airport pairing pages are named as a seventh page type and pinned under their own item.
- **Production goes live on 10 Sep behind a domain password: consequences for E** (owner E; raised 2026-09-09 by Stream S; id `issue-production-goes-live-on-10-sep-behind-a-domain-password`)
  Rian's decision of 9 Sep: production stands up on DigitalOcean on 10 Sep, on the launch domain, behind a domain password, replacing the WP Engine placeholder; the password keeps the new site out of the index, which is what Mark asked for ('I would not index this until as absolutely late as possible'). This 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 do-noindex-the-placeholder-site-on-the-launch-domain, because the placeholder would stay indexable.
  While the host is gated: IndexNow must not run and INDEXNOW_KEY stays unset (a submitted URL that answers a password challenge is a wasted signal at best); the sitemap and robots are moot until the gate comes off; the client surfaces (/structure, /quote, /todo, /discuss) stay reachable behind the domain password, which bears on decide-client-surfaces-on-production (rian's; not resolved here).
  The password mechanism (Caddy basic_auth on the droplet's Caddy, or a Cloudflare Access rule on the zone) is E's to choose with rian present, not a stream session's. No plan document contemplates a gated production host, so whichever is chosen is a recorded deviation from the plan and goes in E's stream doc. If the cutover slips, the noindex ask on the placeholder comes back.
  Gates: E4c (costly)
  **RESOLVED 2026-09-10 by Stream E:** Production went live 11 Sep 00:2x UTC on dutyfreeprofessor.com, kept out of the index by the app's own members-only door (SITE_ACCESS=members: sign-in redirect on every page, 401 on every API read, robots Disallow all, sitemap 404), not by a Caddy or Cloudflare password. INDEXNOW_KEY unset. The client surfaces sit behind the same sign-in. E7's second pass (anonymous curls after the flip to public) is the go-live day's work.
- **Adam's @mention of rian never rang: legacy comments were linked to threads but their mentions were not notified** (owner R; raised 2026-09-13 by planning session; id `issue-adam-s-mention-of-rian-never-rang-legacy-comments-were-l`)
  Comment 221 (13 Sep 21:20, before the 0.47.0 deploy) names @Rian. The threads backfill linked it to its thread but emits no notifications, so rian's inbox stayed empty; the mention regex and directory are fine (the same body notifies when posted through the new write path, as the tests show). Emitted by hand on staging on 13 Sep (notification 1, unread, deep link to #c-221). The rule belongs in the backfill so production's migration does the same: T14.
  Gates: T14 (costly)
  **RESOLVED 2026-09-13 by Stream R2b:** backfill legacy_mentions (also the last step of backfill threads) rings every legacy mention once under the write path's dedupe key, so the hand-emitted row on staging is not doubled; rehearsed both ways on a staging copy; commit R: T14 (6f9c49c)
- **Pack words are stripped from spirit names, so Triple Cask keys as Cask** (raised 2026-09-15 by Claude (data model session); id `issue-pack-words-are-stripped-from-spirit-names-so-triple-cask`)
  lines.py strips a list of pack tokens (pack, twin, trio, duo, triple, tri, kit, giftset) from a name before the line key is computed, which is right for 'Ballantine's Triple Pack 3x1L' and wrong whenever the word is part of the expression. Demonstrated on the live catalogue: '1800 Anejo Triple Cask 40% 1L' keys as 'anejo cask'; 'Artisian Triple Oak VSOP Cognac 1L' as 'artisian oak vsop'; 'Bellevoye Prune - Whisky triple malt' as 'prune'; '10 Year Old Triple Distilled Single Pot Still Irish Whiskey 70cl' as '10 distilled pot still'. 272 live products carry one of these words.
  The risk is a silent wrong merge, not just an ugly name. 'Macallan Triple Cask 12' keys as 'cask 12' while 'Macallan Double Cask 12' keys as 'double cask 12', so a plain 'Cask 12' from any shop would fold into the Triple Cask. Two expressions becoming one product is the failure the identity rules exist to prevent.
  Likely fix: strip a pack token only when it sits beside a pack signal (Pack, x3, 3x1L) rather than unconditionally. Needs a test per the standing rule that a bug that shipped becomes a test in main/tests/.
  **RESOLVED 2026-09-15 by Claude (catalogue decisions session, 15 Sep):** Fixed on the branch (identity rules v5, §2.5): a pack word in a liquor name leaves the line only when the name is a pack or a set or the next word is a category word; tests/test_lines.py TestDrinkLines pins eight expressions and four packs
- **The flavor variation kind is landing on confectionery gift boxes, not on flavours of a line** (raised 2026-09-15 by Claude (data model session); id `issue-the-flavor-variation-kind-is-landing-on-confectionery-gi`)
  Of 158 products carrying a flavor variation, exactly one is comparable across airports. The cause is misclassification rather than the model: the kind is firing almost entirely on Paris confectionery, where the flavour word is part of a one-off product's identity and not a variation of a line. '15 Fine Chocolates Almond Crispy' carries the flavour 'almond praline', has no parsed quantity and exists at CDG alone. The single genuine case works correctly: a Mint Chocolate cream at 1L compares across eight airports.
  Worth settling before the variation and determinant split is designed, since flavour is one of the two kinds rian names as presentational. See .logs/planning/variations-and-line-pages-2026-09-15.md section 3.4.
  See `.logs/planning/variations-and-line-pages-2026-09-15.md`
  **RESOLVED 2026-09-15 by Claude (catalogue decisions session, 15 Sep):** Fixed on the branch (identity rules v5, §2.6): the flavour reader fires only on a marked ' / ' tail; 158 flavour kinds became 3 on the rehearsal, and the marked tail no longer reaches the classifier
- **Pin lines.py's concentration table to REVIEW-PROCESS.md section 1 item 6 with a test** (owner K3; raised 2026-09-16 by Stream K0; id `issue-pin-lines-py-s-concentration-table-to-review-process-md`)
  File: main/tests/ (a new pure-logic test) reading main/app/services/lines.py _VARIATION_RULES, _CONCENTRATIONS, _QUALIFIERS and VARIATION_DISPLAY. Change: a test that fails when the code's synonym set differs from the list in main/docs/REVIEW-PROCESS.md section 1 item 6 (Eau de Parfum: eau de parfum, EDP; Eau de Toilette: eau de toilette, EDT; Eau de Cologne: eau de cologne, EDC, cologne; Parfum: parfum, perfume, extrait, extrait de parfum, essence de parfum; Elixir: elixir, élixir, elixir de parfum; Mist: mist, body mist, hair mist, brume, bruma; qualifiers Intense, Extreme, Absolu with their spellings). Why: the doc is the authority for the certain boundary and changing it is a rules version; without a test the code and the doc drift apart silently, which is the failure the boundary exists to stop. K0 wrote the doc's list from the code as it stands on 16 Sep, so today they agree.
  Gates: K3.2 (costly)
  **RESOLVED 2026-09-16 by Stream K3:** tests/test_certain_boundary.py holds REVIEW-PROCESS.md section 1 item 6 and product_lines._ATTRIBUTE_RULES equal, both ways (K3.2, d34240d)
- **The test pinning the concentration table now reads product_lines.py, not lines.py** (owner K3; raised 2026-09-16 by Stream K1; id `issue-the-test-pinning-the-concentration-table-now-reads-produ`)
  K1 renamed lines.py to product_lines.py and its functions (attribute_of, attribute_kind_of, canonical_attribute, display_attribute; ATTRIBUTE_KINDS, ATTRIBUTE_RULES, _ATTRIBUTE_RULES). The earlier K0 issue (issue-pin-lines-py-s-concentration-table...) names the old module; write the test against the new names. REVIEW-PROCESS.md's Sources line already says product_lines.py.
  Gates: K3.2 (info)
  **RESOLVED 2026-09-16 by Stream K3:** the pin test reads product_lines.py (tests/test_certain_boundary.py, d34240d)
- **A decided shade (attribute:color) loses its kind at the batch tail: decisions verify reports drift** (owner K3; raised 2026-09-16 by Stream K4; id `issue-a-decided-shade-attribute-color-loses-its-kind-at-the-ba`)
  File: main/app/services/keying.py, product_key (HEAD lines ~277-288; K3 holds the file in flight). It reads decided['attribute'] (the shim's legacy name for any attribute:<kind>) for the VALUE but takes the KIND from maps.attribute_kind(name), the rule. When the rule reads the shade words but names no kind (CHANEL 'ROUGE ALLURE' / '3.5gr / 196 A DEMI-MOT' on attenza PTY), the batch tail's rekey writes {attribute: value} with no attribute_kind, so attributes.get(variant, 'color') is None and decisions verify reports 1 drift. Reproduced on a clean export of HEAD c295416 against dfp_k4 (the nightly of 16 Sep with K1 and K2 applied): one individual approval of attribute:color, verify 1 checked 1 drift.
  Change: when the value comes from a decided attribute:<kind> row, set attribute_kind to that kind (the row's field suffix), not the rule's. Needs the decided row's field on the legacy view. A test: decide attribute:color on a variant whose name the rule reads without a kind; rekey; attributes.get(v, 'color') equals the decision.
  *Proceeding under:* K4 proceeds; the CHANEL rehearsal reports verify drift for any shade decision until this lands, and says so in the handoff.
  Gates: K4.5 (costly)
  **RESOLVED 2026-09-17 by K8:** product_key takes the kind from the decided row's field suffix, not the rule; the rule still answers where nothing was decided; test_decided_attribute_kind.py pins both (fc187f8).
- **The sitemap, the feed and IndexNow still list variant addresses, which now 301 to product lines** (owner K6; raised 2026-09-17 by Stream K5; id `issue-the-sitemap-the-feed-and-indexnow-still-list-variant-add`)
  Files: main/app/services/seo.py sitemap_entries/_product_sitemap_rows (K6's filter), feeds.py product_item, indexnow.py (K6). With LINE_PAGES on, /products/<name>-<id> answers 301 to /products/<line>?variant=<id> (K5.2), so every product URL these three emit is a redirect. Change: list product lines by their bare address (urls.line_path(slug)), only lines K6 has indexed (publish.is_indexed), one URL per line, lastmod the line's newest observation (ProductLineDetail.last_observed_at, or a query over its variants); the feed may keep naming a variant but should link its line path.
  *Proceeding under:* K5 leaves the three emitters to K6's sitemap filter task; while every line is noindex the sitemap's product URLs are redirects to noindex pages, harmless before launch.
  **RESOLVED 2026-09-17 by Stream K6:** K6.4: the sitemap and IndexNow list indexed product lines by their bare address; the feed links the card's line path and keeps the variant guid
