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

Last swept 2026-09-15.

## 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.

## 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.
- **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.
- **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 privacy, terms and contact pages, and none exist yet** (P2; owner C; raised 2026-09-11 by Stream C; id `issue-before-launch-the-footer-needs-privacy-terms-and-contact`)
  The live-ready footer (0.44.0) links only pages that exist: the shop, the busiest airports, articles and the price data. A site that collects names and emails for its newsletter needs a privacy policy linked from every page before go-live, and a terms of use and a way to contact us are expected in a footer. Build the three pages and add a Legal column (or bottom-bar links) once Adam has approved the wording; the consent sentence decision on the subscribe form is the same conversation.
- **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.

## P3: should fix before Cannes

- **Extime is Paris (CDG and Orly together), not CDG alone** (P3; 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.
  Gates: P6 (costly) — blocks P6 (Delivery walkthrough with Adam), due 2026-09-18
- **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.

## 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
- **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.
  Gates: P6 (costly) — blocks P6 (Delivery walkthrough with Adam), due 2026-09-18
- **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
- **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.
- **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`

## 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 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 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).
- **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.
- **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)
