{
 "updated_at": "2026-09-11T23:14+00:00",
 "items": [
  {
   "id": "decide-verify-additions",
   "kind": "decide",
   "title": "Three small additions to the verify rules (Q proceeds unless you object)",
   "detail": "Stream Q built verify with three things Decision 10 did not spell out.\n\n1. A tenth verdict, FETCH_ERROR, for a timeout or a server error: reported, never a failure, never a block. Without it a retailer's bad hour would either block a shop or vanish from the record.\n\n2. When verify finds a moved price it writes a new observation marked as coming from verify, so the live price is kept, not just noted.\n\n3. A block is not a flag on the shop; it is a failing check that a human clears, so clearing is itself a recorded decision.\n\nMy recommendation: accept all three.",
   "owner": null,
   "priority": null,
   "options": [
    "accept all three (recommended)",
    "accept 1 and 3, no new observations from verify",
    "other"
   ],
   "assumption": "proceeding as if accepted",
   "links": [],
   "due": null,
   "created": "2026-09-05",
   "by": "Stream Q",
   "updated": "2026-09-05",
   "blocks": [],
   "weight": "info"
  },
  {
   "id": "decide-seoul-stays",
   "kind": "decide",
   "title": "Seoul is readable after all: keep it in the nineteen and tell Adam?",
   "detail": "The Shilla probe passed: a browser with our declared identity gets the full server-rendered product page. Two conduct points to know: the page's own scripts live under a robots-disallowed path, so we read the document without them (it is complete without them); and the second render allowed the Cloudflare challenge host as an asset host so the browser could load what the page asked for. No stealth, no identity change.\n\nLimits: their sitemap is empty and the category grids are drawn by disallowed scripts, so a compliant reader discovers through the home page and related-product rails, about 200 renders a run. That reaches part of the catalogue per run, not all of it. A full catalogue needs the partnership ask. Their robots file now names many AI and rendering crawlers; we are not named.\n\nIf you prefer a replacement airport instead, name two candidates on new platforms and a session probes them.",
   "owner": null,
   "priority": null,
   "options": [
    "keep Seoul on the rendered fetch; tell Adam Monday it is confirmed (assumed)",
    "swap it for a replacement airport"
   ],
   "assumption": "keep Seoul",
   "links": [
    ".logs/runs/shilla-probe-2026-09-05.log"
   ],
   "due": null,
   "created": "2026-09-05",
   "by": "Stream A2",
   "updated": "2026-09-05",
   "blocks": [
    "P3"
   ],
   "weight": "blocking"
  },
  {
   "id": "decide-shilla-price-tier",
   "kind": "decide",
   "title": "Which Shilla price is the price?",
   "detail": "The Shilla page shows a list price ($43), a discount price ($25.37) and a price for online members ($25.37). The collector records the discount price as what a walk-up traveller pays and the list price as the crossed-out price; the member tier is not published. One line to change if you want otherwise.",
   "owner": null,
   "priority": null,
   "options": [
    "discount price (assumed)",
    "member price"
   ],
   "assumption": "discount",
   "links": [],
   "due": null,
   "created": "2026-09-05",
   "by": "Stream A2",
   "updated": "2026-09-05",
   "blocks": [
    "R5"
   ],
   "weight": "costly"
  },
  {
   "id": "decide-changi-tag-manager",
   "kind": "decide",
   "title": "Singapore only renders with its tag manager loaded. Acceptable?",
   "detail": "The iShopChangi storefront draws nothing until one Adobe Launch script has loaded, because the page's own code depends on it. With that one host allowed, the page draws and fetches its own inventory file, which we read as received. The sixteen analytics beacons it then tries to call (Adobe, Google, Facebook, Criteo, Bing, Clarity, New Relic and others) stay blocked, so nothing is reported to anyone. The reason is written in the collector.\n\nIf you disagree, Singapore has no honest path and joins the Cannes ask list.",
   "owner": null,
   "priority": null,
   "options": [
    "acceptable: it is loading what the page needs to function, with our declared identity (assumed)",
    "not acceptable: Singapore goes to the ask list"
   ],
   "assumption": "acceptable",
   "links": [],
   "due": null,
   "created": "2026-09-05",
   "by": "Stream A2",
   "updated": "2026-09-05",
   "blocks": [
    "R4"
   ],
   "weight": "blocking"
  },
  {
   "id": "decide-shopify-refusal",
   "kind": "decide",
   "title": "The four Shopify shops refuse our identity at robots.txt. Accept dated prices, check the feed, or ask the operators?",
   "detail": "Montreal, Panama, Bogota and San Salvador all answer 403 to robots.txt for our declared reader and 200 to a plain request. Under the ratified policy (401/403 is a refusal) they record as blocked and launch on their last-seen prices from 22 Aug, with the date shown.\n\nOptions: accept and add them to the Cannes ask list; check whether the product feed itself is also refused (one request); or ask the operators (ARI for Montreal, Motta for the others) for permission, which becomes a recorded consent on the source. Nothing is redone whichever you choose; a consent record unblocks them.",
   "owner": null,
   "priority": null,
   "options": [
    "accept: dated prices at launch, on the ask list (assumed)",
    "check the feed first",
    "ask the operators for permission"
   ],
   "assumption": "accept",
   "links": [],
   "due": null,
   "created": "2026-09-05",
   "by": "Stream A",
   "updated": "2026-09-05",
   "blocks": [
    "A13"
   ],
   "weight": "costly",
   "resolved": {
    "by": "planning session",
    "on": "2026-09-05",
    "note": "Overtaken by events: on the 5 Sep afternoon run all four Shopify shops served robots.txt and their feeds to our (repointed) identity and collected fresh prices (YUL 466, PTY 188, BOG 257, SAL 245). No decision needed; verify watches them. Dubai still answers 403 to a single robots read with the new identity."
   }
  },
  {
   "id": "decide-bot-identity-hostname",
   "kind": "decide",
   "title": "The permanent hostname for our reader's identity page",
   "detail": "Every request we make carries a link to a page that says who we are and how to reach us. That hostname is baked into every retailer's logs forever, so it should sit on a zone you control and never move. The page itself is a separate static project (Decision 3), not part of the app. Name the host and a session builds it and repoints the reader.",
   "owner": null,
   "priority": null,
   "options": [
    "a host on bowden.works",
    "a host on dutyfreeprofessor.com (only if the zone is yours)",
    "other"
   ],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-05",
   "by": "plan review",
   "updated": "2026-09-05",
   "blocks": [
    "A10"
   ],
   "weight": "costly"
  },
  {
   "id": "decide-client-surfaces-on-production",
   "kind": "decide",
   "title": "On production, do the client pages stay reachable?",
   "detail": "The quote, discussion, structure, to-do, sources and settings pages, and their API reads, exist for Adam and Mark. On production they can be switched off behind a flag, or kept behind the login with a client role so Adam and Mark keep commenting after the move. Sources and settings go behind the owner login either way.",
   "owner": null,
   "priority": null,
   "options": [
    "off in production until the login exists",
    "behind the login with a client role"
   ],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-05",
   "by": "plan review",
   "updated": "2026-09-05",
   "blocks": [
    "E4b"
   ],
   "weight": "blocking",
   "resolved": {
    "by": "planning 10 Sep",
    "on": "2026-09-10",
    "note": "rian's 10 Sep instruction: the client pages (/discuss, /structure, /quote, /todo, /settings) stay reachable behind the app's own login for Adam and Mark on the admin level (accounts plan .logs/planning/accounts-2026-09.md section 4.6); /sources and the kill switch stay the owner's (sources.manage), as build plan section 10 item 4 said"
   }
  },
  {
   "id": "decide-hosting-figure",
   "kind": "decide",
   "title": "The hosting figure and the message to Adam",
   "detail": "$250 a month was the 1 Sep anchor; the quote page says $50 and promises real numbers before production. Stream E gives you a real figure by Tue 8, two ways: this app alone, and with headroom for a shared live-hosting box. Then decide whether production runs from Fri 11 at the real figure, or the $50 hold stands through soft launch and steps up when scheduled collection starts. Say it on the quote thread Adam opened on the environment line.",
   "owner": null,
   "priority": null,
   "options": [
    "production from Fri 11 at the real figure",
    "$50 holds through soft launch, then steps up"
   ],
   "assumption": null,
   "links": [],
   "due": "2026-09-08",
   "created": "2026-09-05",
   "by": "plan review",
   "updated": "2026-09-05",
   "blocks": [
    "E4"
   ],
   "weight": "blocking"
  },
  {
   "id": "decide-where-collectors-run",
   "kind": "decide",
   "title": "Where the collectors run once production exists",
   "detail": "Three options, settled by the egress test: on the production host; on this server writing to production Postgres over Tailscale; or a separate runner with a reserved IP. Whichever it is also fixes which machine hosts the cron for the nightly audit and the verify runs.\n\nEvidence, 10 Sep (Stream E): from the droplet's address, text fetches at all 23 retailer hosts answered exactly as from the dev server (22 pages, Dubai refused on both), and a bounded real collection through the browser sidecar rendered Changi 5 of 5 and Seoul 5 of 5 with a Heathrow text-fetch control 10 of 10. Nothing in the collection path treats DigitalOcean's address differently today. Rian's stated preference: production should not depend on his home server. The remaining cost of the droplet option is memory: app 1 GB + sidecar 2 GB + Postgres 512 MB on a 4 GB box with 2 GB swap, one collection at a time.",
   "owner": null,
   "priority": null,
   "options": [
    "on the droplet (assumed): collectors and cron live with the app; the sidecar fence applies there",
    "on the dev server writing to production over Tailscale: keeps the residential address, ties the live site to the home server",
    "a separate runner: a second box to operate, only if the droplet's address is ever refused"
   ],
   "assumption": "on the droplet",
   "links": [
    ".logs/runs/egress-2026-09-10.md"
   ],
   "due": null,
   "created": "2026-09-05",
   "by": "plan review",
   "updated": "2026-09-10",
   "blocks": [
    "E5"
   ],
   "weight": "costly",
   "resolved": {
    "by": "rian (in chat, 10 Sep)",
    "on": "2026-09-10",
    "note": "Collectors run on the droplet. Evidence: .logs/runs/egress-2026-09-10.md (text fetches identical at 23 hosts; rendered Changi 5/5, Seoul 5/5 from the droplet). Cron for audit and verify lands on bwlive with E5; the sidecar fence applies there."
   }
  },
  {
   "id": "decide-zone-control",
   "kind": "decide",
   "title": "Who controls the dutyfreeprofessor.com zone at cutover",
   "detail": "Production needs the domain's DNS under our control: either the zone moves into your Cloudflare account, or Adam issues a scoped API token that is staged into a permissions-restricted file, never pasted in chat. Confirm which it is, and that the old WP Engine placeholder stops resolving at cutover.",
   "owner": null,
   "priority": null,
   "options": [
    "zone is already in my Cloudflare account",
    "Adam issues a scoped token",
    "zone transfer needed"
   ],
   "assumption": null,
   "links": [],
   "due": "2026-09-08",
   "created": "2026-09-05",
   "by": "plan review",
   "updated": "2026-09-05",
   "blocks": [
    "E4c"
   ],
   "weight": "blocking"
  },
  {
   "id": "decide-page-types-18-sep",
   "kind": "decide",
   "title": "Which page types ship on 18 Sep",
   "detail": "Either all six page types where the coverage thresholds are met, with beauty categories switching on as their coverage crosses the bar, or category and category-at-airport pages move to the buffer week. One sentence, which also goes on the structure page and into the note to Mark. Mark's comments are due Thu 10, so decide after reading them.",
   "owner": null,
   "priority": null,
   "options": [
    "all six where thresholds are met; beauty switches on as it qualifies",
    "category pages in the buffer week"
   ],
   "assumption": null,
   "links": [],
   "due": "2026-09-10",
   "created": "2026-09-05",
   "by": "plan review",
   "updated": "2026-09-05",
   "blocks": [
    "B6"
   ],
   "weight": "blocking"
  },
  {
   "id": "decide-ratify-standing-rules",
   "kind": "decide",
   "title": "Ratify four standing rules the sessions are already working under",
   "detail": "1. The BLOCKED rule: a shop that refuses us stays public on its last-seen prices with the date shown and a flag, rather than disappearing (Dubai is the big case).\n\n2. The robots policy: a robots file that answers 404 means open; 401 or 403 means refused; a server error means stop and retry later.\n\n3. Host cron for the nightly audit and the verify runs is owned by Stream E, and the collectors doc states plainly that this is not automated collection; collection stays hand-run, as told to Adam.\n\n4. The pages-2-and-later observations at the four Avolta stores that forbid query pagination were collected against their robots before the fix: keep them, dated, or purge them.\n\nWrite exceptions if any; otherwise \"all four\".",
   "owner": null,
   "priority": null,
   "options": [
    "all four as stated",
    "exceptions (write them)"
   ],
   "assumption": "all four",
   "links": [],
   "due": null,
   "created": "2026-09-05",
   "by": "plan review",
   "updated": "2026-09-05",
   "blocks": [
    "A13"
   ],
   "weight": "costly"
  },
  {
   "id": "decide-scope-vs-capacity",
   "kind": "decide",
   "title": "Scope versus capacity: what gives way if time runs short",
   "detail": "The plan holds the sixteen promises plus unpaid back-office work, about twice what fits before 18 Sep.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-04",
   "by": "plan review",
   "updated": "2026-09-05",
   "resolved": {
    "by": "rian",
    "on": "2026-09-05",
    "note": "Everything stays in the plan. Unpaid work is ranked last, sits in the buffer week, and is pulled forward at the Fri 11 checkpoint if every wave-2 quote task is green. If time runs short, unpaid work drops before any quote line. The migration columns and the auth middleware stay regardless."
   },
   "blocks": [],
   "weight": "info"
  },
  {
   "id": "decide-fri-11-milestone",
   "kind": "decide",
   "title": "What the Fri 11 milestone means",
   "detail": "Adam approved production live, sixteen public, crawlable pages for Fri 11.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-04",
   "by": "plan review",
   "updated": "2026-09-05",
   "resolved": {
    "by": "rian",
    "on": "2026-09-05",
    "note": "Option (a): hold the date with the quote's own bar. Fresh drinks recollection minus the refusing shops, a human check of thirty pages, sixteen public on the demo host with the five refusing shops on dated prices, production live if E lands it. Verify's tripwire becomes the 18 Sep gate. Adam hears it in the Mon 7 reply pass."
   },
   "blocks": [],
   "weight": "info"
  },
  {
   "id": "do-changi-hand-check",
   "kind": "do",
   "title": "Hand-check the Singapore departure price before the first Singapore run",
   "detail": "Open the Johnnie Walker Blue Label 1000ml page on ishopchangi.com in your own browser, choose Traveller mode with At Departure, and confirm the price reads S$271.10 (non-traveller delivery shows S$333.87). The collector publishes the departure tier. Say \"confirmed\" here, or the price you saw.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    "https://www.ishopchangi.com/en/product/johnnie-walker-blue-label-1000ml-40--mp00089136",
    ".logs/runs/changi-targets-2026-09-05.md"
   ],
   "due": "2026-09-06",
   "created": "2026-09-05",
   "by": "Stream A2",
   "updated": "2026-09-05",
   "blocks": [
    "R4"
   ],
   "weight": "blocking"
  },
  {
   "id": "do-reply-pass-adam",
   "kind": "do",
   "title": "The Monday reply pass to Adam's open threads",
   "detail": "About thirty minutes, one reply per thread. The wording is drafted in the build plan, section 10 item 15: Seoul confirmed possible; the browser reader; Dubai's refusal and the dated prices at launch; images and the list by Tue 8; the two new content asks; the hosting figure or the explicit hold; awards overrides via rian until the admin area. Plan task P3 is marked done when this is.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/build-plan-2026-09.md"
   ],
   "due": "2026-09-07",
   "created": "2026-09-05",
   "by": "plan review",
   "updated": "2026-09-05",
   "blocks": [
    "P3"
   ],
   "weight": "blocking"
  },
  {
   "id": "do-public-base-url",
   "kind": "do",
   "title": "Set PUBLIC_BASE_URL in the app's environment file",
   "detail": "Since the crawlable-pages work, canonical, sitemap and structured-data URLs are built from PUBLIC_BASE_URL instead of trusting the request. It is not set yet, so they are relative and the startup log warns. Add one line to .app.env in your own shell (the value for this host is https://dutyfreeprofessor.demoing.info), then redeploy at the next checkpoint. Production gets its own value in E4b.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": "2026-09-06",
   "created": "2026-09-04",
   "by": "Stream B",
   "updated": "2026-09-05",
   "blocks": [
    "B4",
    "A13"
   ],
   "weight": "blocking"
  },
  {
   "id": "do-sidecar-firewall-rule",
   "kind": "do",
   "title": "Belt-and-braces firewall rule for the browser sidecar before production",
   "detail": "The sidecar refuses requests to numeric and private addresses by name, but a permitted retailer hostname that resolved to a private address would pass (DNS rebinding). Cheap fix that does not depend on code: a DOCKER-USER iptables rule on whichever host runs the sidecar, dropping traffic from the dutyfreeprofessor-render network to the host's own addresses and to private ranges. A firewall change, so it is yours; on this server it lands in /etc/iptables/rules.v4. A session drafts the exact rule when you ask.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": "2026-09-09",
   "created": "2026-09-05",
   "by": "planning session",
   "updated": "2026-09-05",
   "blocks": [
    "E4b"
   ],
   "weight": "costly",
   "resolved": {
    "by": "rian (in chat, 10 Sep)",
    "on": "2026-09-10",
    "note": "Applied on bwlive 10 Sep: deploy/sidecar-firewall.sh as /usr/local/sbin/sidecar-firewall, systemd oneshot after docker.service, 13 DOCKER-USER rules; proof that the sidecar cannot reach 169.254.169.254 while retailers still render goes on the E7 go-live checklist."
   }
  },
  {
   "id": "do-thirty-page-check",
   "kind": "do",
   "title": "The human check of thirty product pages before the nineteen switch on",
   "detail": "Part of the Fri 11 bar you chose: after the drinks refresh, open thirty product pages from the audit's list and confirm price, size and shop against the retailer page. Stream Q's checklist generator produces the list; note anything wrong here and it becomes an issue.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    "main/scripts/review-checklist.py"
   ],
   "due": "2026-09-11",
   "created": "2026-09-05",
   "by": "planning session",
   "updated": "2026-09-05",
   "blocks": [
    "A13"
   ],
   "weight": "blocking"
  },
  {
   "id": "do-read-marks-comments",
   "kind": "do",
   "title": "Read Mark's comments on the structure proposal and settle the structure",
   "detail": "Mark has the structure page and the to-do page as of today; his comments are due Thu 10. Read them, decide what changes, and answer the page-types question above. If nothing arrives by Fri 11, the sessions build to the proposal as written.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": "2026-09-10",
   "created": "2026-09-05",
   "by": "planning session",
   "updated": "2026-09-05",
   "blocks": [
    "B6"
   ],
   "weight": "blocking",
   "resolved": {
    "by": "rian",
    "on": "2026-09-09",
    "note": "Read 9 Sep, replied on every thread; Mark and Adam have both replied back on the page."
   }
  },
  {
   "id": "do-dfp-devdb",
   "kind": "do",
   "title": "Keep the scratch database until Stream R's rehearsal is done",
   "detail": "Stream Q left a copy of a dump in a scratch container (dfp-devdb) for migration rehearsals; the security audit lists the container as orphaned. Keep it up through Stream R's R1 night: O2 rehearses migration #4 on it as dfp_r (up, backfills twice, down, up) and L10 runs the local server against dfp_r; the session drops only dfp_r. Remove the container (docker rm -f dfp-devdb) only after R1 has handed over green and ready; the earlier scratch databases (dfp_q, dfp_a, dfp_b, dfp_c, dfp_d) can go with it.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": "2026-09-12",
   "created": "2026-09-05",
   "by": "planning session",
   "updated": "2026-09-10",
   "blocks": [
    "O2",
    "L10"
   ],
   "weight": "costly"
  },
  {
   "id": "issue-dubai-refuses-by-name",
   "kind": "issue",
   "title": "Dubai refuses our reader by name at the Akamai edge",
   "detail": "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.",
   "owner": null,
   "priority": "P1",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-03",
   "by": "Stream A",
   "updated": "2026-09-05",
   "blocks": [],
   "weight": "costly"
  },
  {
   "id": "issue-launch-prices-stale",
   "kind": "issue",
   "title": "Every launch price is stale until the drinks refresh runs",
   "detail": "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.",
   "owner": "A",
   "priority": "P1",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-03",
   "by": "Stream A",
   "updated": "2026-09-05",
   "blocks": [
    "A13"
   ],
   "weight": "blocking",
   "resolved": {
    "by": "planning session",
    "on": "2026-09-05",
    "note": "5 Sep refresh: every source except Dubai (refuses) has prices dated 5 Sep; deployed in 0.33.0"
   }
  },
  {
   "id": "issue-sin-icn-first-runs",
   "kind": "issue",
   "title": "Singapore and Seoul: first real runs and their discovery limits",
   "detail": "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.",
   "owner": "A2",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-05",
   "by": "Stream A2",
   "updated": "2026-09-05",
   "blocks": [
    "R4",
    "R5"
   ],
   "weight": "costly",
   "resolved": {
    "by": "planning session",
    "on": "2026-09-05",
    "note": "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"
   }
  },
  {
   "id": "issue-beauty-name-size-misread",
   "kind": "issue",
   "title": "A number in a beauty product's name is read as its size",
   "detail": "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.",
   "owner": "A",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-05",
   "by": "Stream Q",
   "updated": "2026-09-05",
   "blocks": [
    "B5"
   ],
   "weight": "costly",
   "resolved": {
    "by": "Stream A",
    "on": "2026-09-05",
    "note": "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"
   }
  },
  {
   "id": "issue-shopify-drinks-filter-english",
   "kind": "issue",
   "title": "The Shopify drinks filter only knows English shelf names",
   "detail": "\"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).",
   "owner": "A",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-03",
   "by": "Stream A",
   "updated": "2026-09-05",
   "blocks": [
    "A6"
   ],
   "weight": "costly",
   "resolved": {
    "by": "Stream A",
    "on": "2026-09-05",
    "note": "3d5f3f1: shelf_vertical() matches the shop's own shelves in English and Spanish (Licores, Vinos, Rones, Champanas, Cordiales...); tests in test_beauty_widening.py"
   }
  },
  {
   "id": "issue-duplicate-products",
   "kind": "issue",
   "title": "110 product rows are duplicates of another row",
   "detail": "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.",
   "owner": "A",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-03",
   "by": "Stream A",
   "updated": "2026-09-05",
   "blocks": [
    "B5"
   ],
   "weight": "blocking",
   "resolved": {
    "by": "Stream A",
    "on": "2026-09-05",
    "note": "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"
   }
  },
  {
   "id": "issue-brand-names-unnormalised",
   "kind": "issue",
   "title": "Brand names are not normalised, and brand pages depend on it",
   "detail": "\"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.",
   "owner": "A",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-03",
   "by": "Stream A",
   "updated": "2026-09-05",
   "blocks": [
    "B5"
   ],
   "weight": "blocking",
   "resolved": {
    "by": "Stream A",
    "on": "2026-09-05",
    "note": "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"
   }
  },
  {
   "id": "issue-uncategorised-products",
   "kind": "issue",
   "title": "About 260 launch products have no category and fall out of the structure",
   "detail": "Measured 3 Sep; some are the beauty products already collected at Athens. An idempotent backfill assigns them (A9).",
   "owner": "A",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-03",
   "by": "Stream A",
   "updated": "2026-09-05",
   "blocks": [
    "B6"
   ],
   "weight": "costly",
   "resolved": {
    "by": "Stream A",
    "on": "2026-09-05",
    "note": "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"
   }
  },
  {
   "id": "issue-scheduler-freshness-alarm",
   "kind": "issue",
   "title": "No scheduler and no freshness alarm yet",
   "detail": "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.",
   "owner": "E",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-08-27",
   "by": "issues register",
   "updated": "2026-09-05",
   "blocks": [
    "E5"
   ],
   "weight": "blocking"
  },
  {
   "id": "issue-header-mirror-rule",
   "kind": "issue",
   "title": "When the header or announcement bar changes shape, update its server-side mirror in the same deploy",
   "detail": "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.",
   "owner": "C",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-04",
   "by": "Stream B",
   "updated": "2026-09-05",
   "blocks": [
    "C2"
   ],
   "weight": "costly",
   "resolved": {
    "by": "Stream C",
    "on": "2026-09-05",
    "note": "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"
   }
  },
  {
   "id": "issue-hide-blocked-shop-rows",
   "kind": "issue",
   "title": "Hide or date the rows of a publication-blocked shop on the public site",
   "detail": "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).",
   "owner": "B",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-05",
   "by": "Stream Q",
   "updated": "2026-09-05",
   "blocks": [
    "A13"
   ],
   "weight": "blocking",
   "resolved": {
    "by": "Stream B",
    "on": "2026-09-05",
    "note": "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."
   }
  },
  {
   "id": "issue-q-touches-in-a-files",
   "kind": "issue",
   "title": "Review Stream Q's three small edits in Stream A's files",
   "detail": "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.",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-05",
   "by": "Stream Q",
   "updated": "2026-09-05",
   "blocks": [],
   "weight": "info",
   "resolved": {
    "by": "Stream A",
    "on": "2026-09-05",
    "note": "reviewed 5 Sep: register_quality in cli.py, skip_counts on CollectionRun, count_skip in ingest are as they should be; kept"
   }
  },
  {
   "id": "issue-robots-pattern-regex",
   "kind": "issue",
   "title": "Expose the robots pattern-to-regex step so the fetcher stops mirroring it",
   "detail": "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.",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-05",
   "by": "Stream A2",
   "updated": "2026-09-05",
   "blocks": [],
   "weight": "info",
   "resolved": {
    "by": "Stream A",
    "on": "2026-09-05",
    "note": "9c57e5e: robots.pattern_regex() is public; fetch.py's import swap is A2's (issue filed)"
   }
  },
  {
   "id": "issue-agents-robots-pointer",
   "kind": "issue",
   "title": "agents.md still points the robots rule at the Heinemann collector",
   "detail": "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.",
   "owner": "DOCS",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-04",
   "by": "Stream A",
   "updated": "2026-09-05",
   "blocks": [],
   "weight": "info",
   "resolved": {
    "by": "Stream N",
    "on": "2026-09-09",
    "note": "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."
   }
  },
  {
   "id": "issue-extime-paris-not-cdg",
   "kind": "issue",
   "title": "Extime is Paris (CDG and Orly together), not CDG alone",
   "detail": "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.",
   "owner": null,
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-03",
   "by": "Stream A",
   "updated": "2026-09-05",
   "blocks": [
    "P6"
   ],
   "weight": "costly"
  },
  {
   "id": "issue-extime-untested-paths",
   "kind": "issue",
   "title": "Extime: a few paths handled defensively but never seen for real",
   "detail": "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.",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-03",
   "by": "Stream A",
   "updated": "2026-09-05",
   "blocks": [],
   "weight": "info"
  },
  {
   "id": "issue-gratien-meyer-jfk",
   "kind": "issue",
   "title": "Watch: Gratien & Meyer at JFK, $77 against about $21 in Europe",
   "detail": "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?",
   "owner": null,
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-08-25",
   "by": "rian",
   "updated": "2026-09-05",
   "blocks": [],
   "weight": "info"
  },
  {
   "id": "issue-variant-listings-no-barcode",
   "kind": "issue",
   "title": "Per-size listings from configurable tiles carry no barcode",
   "detail": "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.",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-08-25",
   "by": "rian",
   "updated": "2026-09-05",
   "blocks": [],
   "weight": "info"
  },
  {
   "id": "issue-ghost-listings",
   "kind": "issue",
   "title": "Ghost listings at stores that could not be re-read",
   "detail": "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.",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-08-25",
   "by": "rian",
   "updated": "2026-09-05",
   "blocks": [],
   "weight": "info"
  },
  {
   "id": "issue-medal-artwork-missing",
   "kind": "issue",
   "title": "Silver and bronze medal artwork missing for four wine competitions",
   "detail": "Cards fall back to the text tag, which works; sourcing the artwork finishes the set.",
   "owner": "F",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-08-25",
   "by": "rian",
   "updated": "2026-09-05",
   "blocks": [],
   "weight": "info",
   "resolved": {
    "by": "Stream N",
    "on": "2026-09-09",
    "note": "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."
   }
  },
  {
   "id": "issue-award-aging",
   "kind": "issue",
   "title": "Award aging: demote or hide awards older than a year",
   "detail": "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).",
   "owner": "F",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-08-27",
   "by": "rian",
   "updated": "2026-09-05",
   "blocks": [
    "F2"
   ],
   "weight": "costly",
   "resolved": {
    "by": "Stream F",
    "on": "2026-09-05",
    "note": "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"
   }
  },
  {
   "id": "issue-back-office-parked",
   "kind": "issue",
   "title": "The back office: six mechanisms parked with their columns already in place",
   "detail": "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).",
   "owner": null,
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "created": "2026-09-04",
   "by": "rian",
   "updated": "2026-09-05",
   "blocks": [],
   "weight": "info"
  },
  {
   "id": "do-add-the-dns-record-for-bot-dutyfreeprofessor-com-in-clou",
   "kind": "do",
   "created": "2026-09-05",
   "by": "planning session",
   "title": "Add the DNS record for bot.dutyfreeprofessor.com in Cloudflare",
   "detail": "The zone sits in Adam's Cloudflare account (you have access, the gateway's token does not), so this one record is by hand. In the dutyfreeprofessor.com zone add: Type CNAME, Name bot, Target mosiah.riverway.ca, Proxy status DNS only (grey cloud), TTL Auto. Caddy issues the certificate on the first visit within a minute or two. Check: open https://bot.dutyfreeprofessor.com and see the identity page.\n\nUntil this resolves, every request the reader sends carries a link that does not load, which is what the identity page exists to prevent. Tonight's drinks refresh should go out after it.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    "https://bot.dutyfreeprofessor.com"
   ],
   "due": "2026-09-05",
   "blocks": [
    "A10"
   ],
   "weight": "blocking",
   "updated": "2026-09-05"
  },
  {
   "id": "do-make-bot-dutyfreeprofessor-com-deliver-to-you",
   "kind": "do",
   "created": "2026-09-05",
   "by": "planning session",
   "title": "Make bot@dutyfreeprofessor.com deliver to you",
   "detail": "The identity page names bot@dutyfreeprofessor.com as the contact for retailers (to ask for a slower pace, an exclusion, or a direct feed). It needs to reach a real inbox. Easiest: Cloudflare Email Routing on the dutyfreeprofessor.com zone (free): enable it, add a custom address bot@ that forwards to your inbox, and let Cloudflare add the MX and TXT records it asks for. Mail to that address is rare but every message is a retailer relationship.\n\nIf you would rather publish a different address, say which and the page changes in a minute.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": "2026-09-08",
   "blocks": [
    "A13"
   ],
   "weight": "costly",
   "updated": "2026-09-05"
  },
  {
   "id": "issue-watch-the-four-shopify-shops-refused-us-on-4-sep-and-acc",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "planning session",
   "title": "Watch: the four Shopify shops refused us on 4 Sep and accepted us on 5 Sep",
   "detail": "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.",
   "owner": "Q",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-05"
  },
  {
   "id": "decide-singapore-answered-403-on-the-19th-page-of-its-first-run",
   "kind": "decide",
   "created": "2026-09-05",
   "by": "planning session",
   "title": "Singapore answered 403 on the 19th page of its first run. Retry slower once, or treat as refused?",
   "detail": "The first Singapore run rendered 18 product pages cleanly at about ten seconds each (17 prices stored, departure tier), then the 19th page took 49 seconds and came back 403 with a 512-byte body and none of the page's own data calls. That shape (short body, after a steady run of requests) looks like the shop's edge protection reacting to the pace, not a rule about that product. Under our policy a 403 is a refusal, so the collector stopped and recorded the source as blocked. Seoul's first run in the same window was clean: 18 prices from 20 pages.\n\nTwo honest options. Retry once, later, at a slower pace (for example thirty seconds between pages, which is still our declared identity with the same rules) and see whether it holds; if it refuses again, Singapore joins the ask list. Or treat this first 403 as the answer and stop now. Either way the 17 prices already stored are real and dated. My recommendation: one slower retry, tomorrow morning, twenty pages, and stop for good on a second 403.",
   "owner": null,
   "priority": null,
   "options": [
    "one slower retry (thirty-second pace), then stop for good on a second 403 (recommended)",
    "treat it as refused now; Singapore goes to the Cannes ask list"
   ],
   "assumption": "no retry until you say",
   "links": [
    ".logs/runs/changi-first-2026-09-05.log"
   ],
   "due": null,
   "blocks": [
    "R4"
   ],
   "weight": "blocking",
   "updated": "2026-09-05"
  },
  {
   "id": "issue-singapore-the-run-pace-may-need-to-slow-the-block-and-st",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "planning session",
   "title": "Singapore: the run pace may need to slow; the block-and-stop worked as designed",
   "detail": "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.",
   "owner": "A2",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "R5"
   ],
   "weight": "costly",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream A2",
    "on": "2026-09-05",
    "note": "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"
   }
  },
  {
   "id": "do-adam-awards-strategy",
   "kind": "do",
   "created": "2026-09-05",
   "by": "Stream F",
   "title": "Get Adam's answer on the awards rules and the aging switch (/discuss card 5)",
   "detail": "The one-page strategy is published on the review page as decision card 5 (item 40), full text in its thread, and kept at notes/awards-strategy-for-adam-2026-09-05.md. Three answers wanted: a yes to the six rules (latest result per competition shown, highest level leads, ties by year then the traveller's airports then judges' score, pin by competition+year+medal, doubtful matches show nothing); aging on or off at launch; and whether he would rather the best medal ever won stayed on the page instead of the latest per competition.\n\nCost of reversal: the picker is built to the rules as written with aging off. Switching aging on is a constant; switching rule 2 to best-ever is one ordering function and a re-check of every product page's medal list.",
   "owner": "rian",
   "priority": null,
   "options": [
    "rules as written, aging off (assumed)",
    "rules as written, aging on",
    "best-ever medal per competition instead of latest"
   ],
   "assumption": "rules as written, aging off",
   "links": [
    "notes/awards-strategy-for-adam-2026-09-05.md"
   ],
   "due": "2026-09-11",
   "blocks": [
    "F2"
   ],
   "weight": "costly",
   "updated": "2026-09-05"
  },
  {
   "id": "issue-article-pages-need-seo-py-and-main-py-to-know-articles-p",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream D",
   "title": "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",
   "detail": "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:\n\n1. 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} />.",
   "owner": "B",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [
    "main/app/services/editorial.py",
    "main/app/routers/articles.py"
   ],
   "due": null,
   "blocks": [
    "D2"
   ],
   "weight": "blocking",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream B",
    "on": "2026-09-05",
    "note": "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."
   }
  },
  {
   "id": "issue-register-the-indexnow-subcommand-in-app-cli",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream B",
   "title": "Register the indexnow subcommand in app.cli",
   "detail": "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.",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream A",
    "on": "2026-09-05",
    "note": "73362e1: register_pages(sub) in app.cli; 'python -m app.cli indexnow' works"
   }
  },
  {
   "id": "decide-ai-training-stance-in-robots-txt-no-for-now-search-and-a",
   "kind": "decide",
   "created": "2026-09-05",
   "by": "Stream B",
   "title": "AI training stance in robots.txt: no for now (search and AI answers yes)",
   "detail": "robots.txt now carries Content Signals and per-bot groups from one switch table (main/app/services/robots_policy.py CONTENT_SIGNALS). Assumed: search=yes, ai-input=yes (AI assistants may read and cite pages, which is the AEO goal), ai-train=no (GPTBot, ClaudeBot, CCBot, Bytespider, Applebot-Extended, cohere-ai are disallowed; Google-Extended and meta-externalagent stay open because they also govern grounding). Flipping ai-train is one word in that dict and takes effect at the next deploy; nothing waits on it. Adam may have a view: training gives nothing back, but some publishers open it for reach.",
   "owner": null,
   "priority": null,
   "options": [
    "keep: search yes, ai-input yes, ai-train no (assumed)",
    "open training too"
   ],
   "assumption": "ai-train=no",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-05"
  },
  {
   "id": "decide-a-licence-and-a-bulk-download-for-the-public-dataset-pag",
   "kind": "decide",
   "created": "2026-09-05",
   "by": "Stream B",
   "title": "A licence and a bulk download for the public dataset page?",
   "detail": "/data describes the price dataset with schema.org Dataset markup. It states no licence and offers no download, because neither is decided: an invented licence is a promise, and a bulk file of retailers' prices changes the legal posture (facts are fine to show per page with their date and a link back; a downloadable corpus is a different product). Assumed for launch: no licence line, no distribution; people link to pages. If you want a licence stated (for example CC BY 4.0 for the observations) or a CSV export, say so and it is a small addition to seo.py head_for_dataset plus one route.",
   "owner": null,
   "priority": null,
   "options": [
    "no licence, no download (assumed)",
    "state a licence",
    "state a licence and offer a CSV"
   ],
   "assumption": "no licence, no download",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-05"
  },
  {
   "id": "issue-architecture-md-request-path-server-bodies-are-no-longer",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream B",
   "title": "ARCHITECTURE.md request path: server bodies are no longer product-only",
   "detail": "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.",
   "owner": "DOCS",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream B",
    "on": "2026-09-05",
    "note": "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."
   }
  },
  {
   "id": "issue-fetch-disallow-regexes-should-import-robots-pattern-rege",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream A",
   "title": "fetch.disallow_regexes should import robots.pattern_regex instead of mirroring it",
   "detail": "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.",
   "owner": "A2",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream A2",
    "on": "2026-09-05",
    "note": "6a1fc91"
   }
  },
  {
   "id": "issue-audit-must-skip-merged-products-and-re-pin-two-threshold",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream A",
   "title": "Audit must skip merged products and re-pin two thresholds after migration #3",
   "detail": "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.",
   "owner": "Q",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "Q5"
   ],
   "weight": "costly",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream Q",
    "on": "2026-09-05",
    "note": "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"
   }
  },
  {
   "id": "issue-brand-pages-the-brands-table-is-ready-slug-name-canonica",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream A",
   "title": "Brand pages: the brands table is ready (slug, name, canonical_id); Makeup is a new category",
   "detail": "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).",
   "owner": "B",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "B5"
   ],
   "weight": "info",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream B",
    "on": "2026-09-05",
    "note": "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."
   }
  },
  {
   "id": "decide-makeup-joins-the-category-vocabulary-beauty-vertical",
   "kind": "decide",
   "created": "2026-09-05",
   "by": "Stream A",
   "title": "Makeup joins the category vocabulary (beauty vertical)",
   "detail": "The category list came from the client's wireframe (Fragrance and Skincare for beauty). Paris and the Shopify shops carry hundreds of makeup rows (M.A.C, Huda Beauty, Charlotte Tilbury) that were sitting uncategorised with vertical liquor. 'backfill categories' now files them as Makeup (241 rows on the dump). It is a category in the data, not a page: pages switch on per section 10 #10.",
   "owner": null,
   "priority": null,
   "options": [
    "keep Makeup (assumed)",
    "drop it: null those categories with a one-line backfill and leave them uncategorised"
   ],
   "assumption": "keep Makeup",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-05"
  },
  {
   "id": "decide-shopify-shops-all-beauty-rows-are-collected-not-only-the",
   "kind": "decide",
   "created": "2026-09-05",
   "by": "Stream A",
   "title": "Shopify shops: all beauty rows are collected, not only the targeted forty",
   "detail": "Decision 4 says targeted pages first, full crawls last. The four Shopify feeds arrive whole in the same requests whatever we keep, so keeping every beauty row (about 3,500 variants at YUL, PTY, BOG, SAL; no barcodes) costs no extra request and gives Paris its widest beauty comparison set. Avolta and Dublin stay targeted (page 1 and the forty), because there every product page is a fetch.",
   "owner": null,
   "priority": null,
   "options": [
    "keep all Shopify beauty rows (assumed)",
    "restrict Shopify to the targeted forty too: a filter on shelf_vertical plus targets.matches, and a backfill to hide the rest"
   ],
   "assumption": "keep all Shopify beauty rows",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-05"
  },
  {
   "id": "issue-wire-award-picker",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream F",
   "title": "Wire award_picker into catalog_queries: card corner, product page list, and the detail route's airports",
   "detail": "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.\n\nWhy: 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.",
   "owner": "B",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [
    "main/app/services/award_picker.py",
    "main/tests/test_award_picker.py"
   ],
   "due": null,
   "blocks": [
    "F2"
   ],
   "weight": "blocking",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream B",
    "on": "2026-09-05",
    "note": "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)."
   }
  },
  {
   "id": "issue-award-shorter-entry-on-variant",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream F",
   "title": "A shorter competition entry lands on the longer retail variant (Bacardi Ocho on the Rye Cask Finish)",
   "detail": "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.",
   "owner": "F",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [
    ".logs/verification/awards-matching-review-2026-09-05.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream N",
    "on": "2026-09-09",
    "note": "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."
   }
  },
  {
   "id": "issue-cli-awards-stats",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream F",
   "title": "app.cli awards: print the reconcile counters (updated, corrected, removed, ambiguous_same_year)",
   "detail": "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.",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream A",
    "on": "2026-09-05",
    "note": "73362e1: second print line with updated/corrected/removed/unchanged/ambiguous_same_year"
   }
  },
  {
   "id": "decide-overrides-before-r",
   "kind": "decide",
   "created": "2026-09-05",
   "by": "Stream F",
   "title": "Land the overrides table before Stream R, so award pins (and the other human overrides) can exist before Cannes?",
   "detail": "Build plan section 4 #1 puts overrides (entity_type, entity_key, field, value, collected_value, set_by, set_at, reason, collector_disagrees_since; UNIQUE on the first three) in R's migration #4, and section 10 #1 may move R after 18 Sep. F2's pin is built to read it (award_picker.pin_from_override, then pick(pin=...)) and to queue a reverifications row from the importer when a rebuild makes a pin stale (that table exists since A's migration #3). Without overrides there is no pin, only the picker; the picker and the fallback are done and tested.\n\nIf yes: a schema-only migration with exactly those columns, next in the migration order after f3a4b5c6d7e8 (whoever holds the token; F owns no migration), then F wires the two reads and the one write in a short follow-up. If no: the pin ships with R and nothing built today changes.",
   "owner": null,
   "priority": null,
   "options": [
    "yes, schema-only overrides migration this wave (F wires the pin after)",
    "no, the pin waits for R (assumed)"
   ],
   "assumption": "the pin waits for R; the picker ships without pins",
   "links": [],
   "due": null,
   "blocks": [
    "F2"
   ],
   "weight": "costly",
   "updated": "2026-09-05",
   "resolved": {
    "by": "rian",
    "on": "2026-09-10",
    "note": "yes: the table lands with Stream R's migration #4 (decided 10 Sep); F wires the pin after"
   }
  },
  {
   "id": "issue-place-stream-d-s-components-latest-articles-and-the-subs",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream D",
   "title": "Place Stream D's components: latest articles and the subscribe form on the home page, sponsor slots at the C5 positions",
   "detail": "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.\n\n1. 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.",
   "owner": "C",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [
    "main/web/src/components/LatestArticles.tsx",
    "main/web/src/components/SubscribeForm.tsx",
    "main/web/src/components/SponsorSlot.tsx"
   ],
   "due": null,
   "blocks": [
    "D2",
    "D4"
   ],
   "weight": "costly",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream C",
    "on": "2026-09-05",
    "note": "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"
   }
  },
  {
   "id": "issue-add-brand-slug-to-productsummary-so-product-pages-link-t",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream B",
   "title": "Add brand_slug to ProductSummary so product pages link to brand pages",
   "detail": "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.",
   "owner": "A",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "B5"
   ],
   "weight": "costly",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream A",
    "on": "2026-09-05",
    "note": "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"
   }
  },
  {
   "id": "decide-the-consent-sentence-and-privacy-line-on-the-subscribe-f",
   "kind": "decide",
   "created": "2026-09-05",
   "by": "Stream D",
   "title": "The consent sentence and privacy line on the subscribe form (drafted wording in place)",
   "detail": "The form stores, verbatim and per subscriber, the sentence the person ticked, so the wording can change later without touching old rows. Drafted: consent 'Yes, email me Duty Free Professor updates. I can unsubscribe at any time.' and, under the button, 'We store your details only to send the newsletter, and never share them.' Both live in one place (web/src/api/editorial.ts CONSENT_TEXT; the small print in SubscribeForm.tsx). Adam may want his own words, or a link to a privacy page once one exists.",
   "owner": null,
   "priority": null,
   "options": [
    "Keep the drafted wording for the soft launch",
    "Adam supplies the sentences (a comment on /todo is enough)"
   ],
   "assumption": "The drafted wording ships; changing it is a one-line edit and already-stored rows keep the sentence they agreed to",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-05"
  },
  {
   "id": "issue-the-trip-comparison-still-shows-a-publication-blocked-sh",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream B",
   "title": "The /trip comparison still shows a publication-blocked shop: two visibility clauses in services/trip.py",
   "detail": "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.",
   "owner": null,
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [
    "main/app/services/trip.py",
    "main/app/services/catalog_queries.py"
   ],
   "due": null,
   "blocks": [
    "A13"
   ],
   "weight": "costly",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream A",
    "on": "2026-09-05",
    "note": "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"
   }
  },
  {
   "id": "issue-quality-md-the-publication-block-sentence-still-says-the",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream B",
   "title": "QUALITY.md: the publication-block sentence still says the public-site query has not landed",
   "detail": "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.",
   "owner": "Q",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [
    "main/docs/QUALITY.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream Q",
    "on": "2026-09-05",
    "note": "670b7d0: sentence replaced, points at SEO.md 'Which shops the site shows'"
   }
  },
  {
   "id": "issue-update-header-html-mirror-for-the-mega-menu",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream C",
   "title": "Update header_html() mirror for the mega menu",
   "detail": "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:\n\n<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>\n\n(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:\n\n<div id=\"site-mega-menu\" class=\"mega\" hidden role=\"region\" aria-label=\"Browse by category, brand and airport\"></div>\n\nOptional, 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.",
   "owner": "B",
   "priority": "P1",
   "options": [],
   "assumption": null,
   "links": [
    "main/web/src/components/SiteHeader.tsx",
    "main/web/src/components/MegaMenu.tsx"
   ],
   "due": null,
   "blocks": [
    "C2"
   ],
   "weight": "blocking",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream B",
    "on": "2026-09-05",
    "note": "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."
   }
  },
  {
   "id": "issue-send-the-category-family-with-api-stats-so-the-mega-menu",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream C",
   "title": "Send the category family with /api/stats so the mega menu can group its shelves",
   "detail": "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.",
   "owner": "B",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [
    "main/web/src/components/MegaMenu.tsx"
   ],
   "due": null,
   "blocks": [
    "C2"
   ],
   "weight": "info",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream B",
    "on": "2026-09-05",
    "note": "5690633: category_counts fills family/family_label from taxonomy; tests/test_category_family.py; rehearsed on dfp_b, /api/stats JSON carries both"
   }
  },
  {
   "id": "issue-product-page-send-the-visitor-s-airports-on-the-detail-r",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream B",
   "title": "Product page: send the visitor's airports on the detail request so the medals reorder for them",
   "detail": "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.",
   "owner": "B",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [
    "main/web/src/api/queries.ts"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream B",
    "on": "2026-09-05",
    "note": "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"
   }
  },
  {
   "id": "issue-product-page-the-exclusive-badge-takes-the-new-excl-tone",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream C",
   "title": "Product page: the exclusive badge takes the new excl tone (one word in ProductPage.tsx and its seo.py mirror)",
   "detail": "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.",
   "owner": "B",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [
    "main/web/src/components/Badge.css"
   ],
   "due": null,
   "blocks": [
    "C3"
   ],
   "weight": "info",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream B",
    "on": "2026-09-05",
    "note": "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."
   }
  },
  {
   "id": "issue-inject-the-social-profile-urls-into-the-shell-window-dfp",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream C",
   "title": "Inject the social profile URLs into the shell (window.__DFP_SOCIAL__) from the environment, and put them in Organization sameAs",
   "detail": "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.",
   "owner": "B",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [
    "main/web/src/lib/social.ts",
    "main/web/src/components/SocialLinks.tsx"
   ],
   "due": null,
   "blocks": [
    "C4"
   ],
   "weight": "costly",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream B",
    "on": "2026-09-05",
    "note": "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."
   }
  },
  {
   "id": "issue-categorycount-family-and-family-label-the-two-a-side-lin",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream B",
   "title": "CategoryCount.family and family_label: the two A-side lines the mega menu's grouping needs",
   "detail": "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.",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [
    "main/app/models/schemas.py",
    "main/app/services/taxonomy.py"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream A",
    "on": "2026-09-05",
    "note": "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"
   }
  },
  {
   "id": "issue-the-hero-s-airport-picker-shifts-the-card-when-its-list",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "Stream C",
   "title": "The hero's airport picker shifts the card when its list arrives (home CLS 0.025 on a phone)",
   "detail": "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.",
   "owner": null,
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [
    "main/web/src/components/AirportPicker.tsx"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream N",
    "on": "2026-09-09",
    "note": "Landed: loading placeholders at the rows' final height; Lighthouse mobile CLS 0.0166 to 0.0007 on the home page, two runs each side."
   }
  },
  {
   "id": "do-set-social-instagram-url-and-social-youtube-url-in-app-e",
   "kind": "do",
   "created": "2026-09-05",
   "by": "Stream B",
   "title": "Set SOCIAL_INSTAGRAM_URL and SOCIAL_YOUTUBE_URL in .app.env when Adam and Mark hand the profile addresses over",
   "detail": "Two lines in .app.env (https URLs; a REPLACE_WITH_ placeholder or an http:// value is ignored), then recreate the app container. They light up the header and footer links (Stream C's C4), the shell's window.__DFP_SOCIAL__ and the Organization markup's sameAs at once; until then nothing shows. Nothing to paste in chat: edit the file yourself.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-05"
  },
  {
   "id": "issue-product-page-link-the-brand-name-to-its-brand-page-now-t",
   "kind": "issue",
   "created": "2026-09-05",
   "by": "sweeper",
   "title": "Product page: link the brand name to its brand page now that brand_slug is on the API",
   "detail": "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.",
   "owner": "B",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [
    "main/app/services/seo.py",
    "main/web/src/pages/ProductPage.tsx"
   ],
   "due": null,
   "blocks": [
    "B5"
   ],
   "weight": "info",
   "updated": "2026-09-05",
   "resolved": {
    "by": "Stream B",
    "on": "2026-09-05",
    "note": "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"
   }
  },
  {
   "id": "issue-url-changes-from-mark-s-review-airport-name-in-the-path",
   "kind": "issue",
   "created": "2026-09-07",
   "by": "Mark's SEO review",
   "title": "URL changes from Mark's review: the airport name in the path",
   "detail": "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.\n\nSettled 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').\n\nOne 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.",
   "owner": "B",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "A13"
   ],
   "weight": "blocking",
   "updated": "2026-09-09",
   "resolved": {
    "by": "Stream S",
    "on": "2026-09-09",
    "note": "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."
   }
  },
  {
   "id": "decide-how-many-products-must-a-brand-have-before-it-gets-its-o",
   "kind": "decide",
   "created": "2026-09-07",
   "by": "Mark's SEO review",
   "title": "How many products must a brand have before it gets its own page?",
   "detail": "The proposal said two. Mark: 'more than just two products should be required, otherwise the pages are just too thin. The criterion should probably be either a larger number of products or a substantial number of airports carrying those products.' Adam agrees, and expects most brands to clear it easily except one-item fragrances. Brand pages shipped this weekend with the floor of two, so this is a number to change, not a build.\n\nMy suggestion: publish a brand page when it has four or more published products, OR three or more products stocked at two or more airports. That keeps the thin one-bottle pages out while letting a genuinely multi-airport brand through. Say a number and it is a one-line change.",
   "owner": null,
   "priority": null,
   "options": [
    "four products, or three across two or more airports (recommended)",
    "a different number (say which)"
   ],
   "assumption": "the floor stays at two until you answer",
   "links": [],
   "due": null,
   "blocks": [
    "B5"
   ],
   "weight": "blocking",
   "updated": "2026-09-07",
   "resolved": {
    "by": "Adam and Mark on /structure",
    "on": "2026-09-09",
    "note": "Adam, 9 Sep: start with three or more products stocked at two or more airports, which drops the one-offs and regional items. Mark adds that brand pages carry high SEO value (searches like 'paco rabanne duty free') and are worth enriching rather than trimming hard."
   }
  },
  {
   "id": "decide-currency-what-price-do-we-show-and-do-we-keep-the-aggreg",
   "kind": "decide",
   "created": "2026-09-07",
   "by": "Mark's SEO review",
   "title": "Currency: what price do we show, and do we keep the aggregate-offer markup?",
   "detail": "Mark warns that markup must match the page exactly or Google can tank rankings, and is wary of our aggregate-offer markup if currencies and translation are involved. Adam asks the practical half: do we show only the shop's local currency as published, and add a converter to the visitor's currency, or show two or three fixed conversions such as US dollars, Euro and Yen?\n\nToday every price is stored in the shop's own currency with the rate and minute it was converted, and the site also shows a US dollar figure for comparison. My recommendation: the published local price is the price on the page and in the markup, with the converted figure shown as clearly secondary and never inside the markup. That satisfies Mark's accuracy rule and Adam's question at once, and a visitor currency picker can come later.",
   "owner": null,
   "priority": null,
   "options": [
    "local currency is the price; conversion shown as secondary and kept out of the markup (recommended)",
    "add a visitor-chosen converter now",
    "show three fixed conversions (USD, EUR, JPY)"
   ],
   "assumption": "local price in the markup, converted figure secondary",
   "links": [],
   "due": null,
   "blocks": [
    "A13"
   ],
   "weight": "blocking",
   "updated": "2026-09-07",
   "resolved": {
    "by": "Adam and Mark on /structure",
    "on": "2026-09-09",
    "note": "Settled 9 Sep. Mark endorsed the answer: the shop's own price in the shop's own currency is the real price and the only one in the markup; our conversion is shown as clearly secondary with the date of its rate. No fixed three-currency display; a visitor currency picker is a later addition."
   }
  },
  {
   "id": "decide-call-the-family-perfume-rather-than-fragrance",
   "kind": "decide",
   "created": "2026-09-07",
   "by": "Mark's SEO review",
   "title": "Call the family Perfume rather than Fragrance?",
   "detail": "Mark: 'duty free perfume' has nineteen times the global search volume of 'duty free fragrance', so his inclination is to use Perfume wherever possible even where it is not technically accurate, with cologne and eau de toilette sitting under it. Adam liked the point. This changes a family name, its URL, the navigation and the category vocabulary, and the beauty collection now running is filling that family, so it is cheapest to settle before those pages exist.",
   "owner": null,
   "priority": null,
   "options": [
    "rename the family Perfume, keep cologne and eau de toilette beneath it (recommended)",
    "keep Fragrance"
   ],
   "assumption": "Fragrance until you say",
   "links": [],
   "due": null,
   "blocks": [
    "A11"
   ],
   "weight": "costly",
   "updated": "2026-09-07",
   "resolved": {
    "by": "Adam and Mark on /structure",
    "on": "2026-09-09",
    "note": "Adam, 9 Sep: 'Search is always our driver, we go with Perfume.' Mark proposed it. Family, navigation, URL and category vocabulary all become Perfume."
   }
  },
  {
   "id": "decide-how-much-of-the-catalogue-do-we-publish-when-a-product-i",
   "kind": "decide",
   "created": "2026-09-07",
   "by": "Mark's SEO review",
   "title": "How much of the catalogue do we publish when a product is sold at only one airport?",
   "detail": "The proposal was to publish what can be compared plus travel exclusives, and hold the rest. Mark disagrees with the principle: the site's purpose is not only comparison, and one-in-five being multi-airport does not mean only those belong in the database. He wants Adam to judge how many single-airport products to include, with the aim of the site becoming the source of truth for duty free pricing, which he expects to pay off in search and publicity. Adam agrees that is the goal.\n\nThis is a launch-scope decision with a real cost: publishing everything means thousands of pages with one price each and no comparison, which is the thin-content risk Mark warns about elsewhere. My recommendation: publish every comparable product and every travel exclusive as planned, plus single-airport products that carry a size and a brand we already publish, and hold only the unidentified remainder. That grows the database substantially without creating bare pages.",
   "owner": null,
   "priority": null,
   "options": [
    "comparable, exclusives, plus identified single-airport products (recommended)",
    "publish everything we hold",
    "keep the original proposal"
   ],
   "assumption": "the original proposal until you answer",
   "links": [],
   "due": null,
   "blocks": [
    "A13"
   ],
   "weight": "blocking",
   "updated": "2026-09-07",
   "resolved": {
    "by": "Adam and Mark on /structure",
    "on": "2026-09-09",
    "note": "Both agreed to rian's proposal. Mark, 9 Sep: 'See no reason not to go with Rian's proposed pace for the prototype. In the long run the database if comprehensive enough could be a goldmine.' Adam: show as much as the timeline allows and frame the rest as a next release. So: launch on the comparable set plus exclusives, keep collecting everything, open up as price history accrues."
   }
  },
  {
   "id": "issue-brand-at-airport-pages-a-page-type-the-structure-proposa",
   "kind": "issue",
   "created": "2026-09-07",
   "by": "Mark's SEO review",
   "title": "Brand-at-airport pages: a page type the structure proposal does not have yet",
   "detail": "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.",
   "owner": "B",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "P6"
   ],
   "weight": "costly",
   "updated": "2026-09-07"
  },
  {
   "id": "issue-shops-and-terminals-within-one-airport-are-not-modelled",
   "kind": "issue",
   "created": "2026-09-07",
   "by": "Mark's SEO review",
   "title": "Shops and terminals within one airport are not modelled",
   "detail": "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.",
   "owner": "A",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "P6"
   ],
   "weight": "costly",
   "updated": "2026-09-07"
  },
  {
   "id": "issue-price-charts-over-time-for-products-and-categories",
   "kind": "issue",
   "created": "2026-09-07",
   "by": "Mark's SEO review",
   "title": "Price charts over time, for products and categories",
   "detail": "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.",
   "owner": "D",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-07"
  },
  {
   "id": "issue-size-nomenclature-differs-by-country-700ml-against-70cl",
   "kind": "issue",
   "created": "2026-09-07",
   "by": "Mark's SEO review",
   "title": "Size nomenclature differs by country: 700ml against 70cl",
   "detail": "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.",
   "owner": "A",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-07"
  },
  {
   "id": "do-noindex-the-placeholder-site-on-the-launch-domain",
   "kind": "do",
   "created": "2026-09-09",
   "by": "Mark on /structure",
   "title": "The launch domain is serving an indexable Chocolate Professor clone",
   "detail": "Mark spotted this on 9 Sep and he is right. dutyfreeprofessor.com answers 200 today with a WordPress site titled 'Home - Duty Free Professor' that is still built on the Chocolate Professor template: its logo files are Chocolate-Professor-Logo.png and its banner is ChocolateProfessorBanner.webp. Its robots.txt is wide open (Yoast block, 'Disallow:' with nothing after it) and it publishes a sitemap, so search engines are free to index all of it.\n\nWhy it matters: Google is forming its picture of the launch domain right now, from a placeholder that looks like a copy of another site. We inherit that history the day we launch on it. Mark's words: 'I would no-index this immediately.'\n\nNot our server. It sits on WP Engine and Adam owns the domain, so the fix is Adam's hosting: set the site to discourage search engines (the Yoast or WordPress reading setting), or replace it with a holding page that carries a noindex tag. The zone is in your Cloudflare account if a faster block is needed. Our own demo host is unaffected: it answers 302 to the login gate and is not indexable.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": "2026-09-10",
   "blocks": [
    "A13"
   ],
   "weight": "blocking",
   "updated": "2026-09-09",
   "resolved": {
    "by": "rian (in chat, 9 Sep)",
    "on": "2026-09-09",
    "note": "Overtaken by rian's decision of 9 Sep: production goes live on 10 Sep on the launch domain behind a domain password, replacing the WP Engine placeholder, so the placeholder is never noindexed. Assumes the launch domain is dutyfreeprofessor.com as the plan states (rian wrote dutyfreeprofessors.com in chat); if production lands on any other host, reopen this item, because the placeholder would stay indexable."
   }
  },
  {
   "id": "do-send-adam-the-list-of-the-nineteen-launch-airports",
   "kind": "do",
   "created": "2026-09-09",
   "by": "Adam on /structure",
   "title": "Send Adam the list of the nineteen launch airports",
   "detail": "Adam asked for it again on the structure page: 'I am trying to find the list again of all the airports, please share the 19.' The sixteen collected today plus Paris, Seoul and Singapore.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": "2026-09-10",
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-09"
  },
  {
   "id": "do-give-adam-feedback-on-his-heathrow-airport-profile-and-h",
   "kind": "do",
   "created": "2026-09-09",
   "by": "Adam on /structure",
   "title": "Give Adam feedback on his Heathrow airport profile and his whiskey category example",
   "detail": "Adam has drafted two documents and wants a view before he writes the rest: an airport profile for Heathrow, and a category example for whiskey. Both are Google Docs linked from the structure page comments (p-airport and general).\n\nMark has already given his view on the airport one and it is a direction change worth passing on: no generic airport information, nothing about lounges or terminals except where it genuinely bears on duty free shopping, and no filler written for search engines. He wants the duty free situation itself: where the shops are, any specialty shops, perhaps a map.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": "2026-09-11",
   "blocks": [
    "D2"
   ],
   "weight": "blocking",
   "updated": "2026-09-09"
  },
  {
   "id": "issue-airport-pages-mark-wants-duty-free-content-only-not-gene",
   "kind": "issue",
   "created": "2026-09-09",
   "by": "Mark on /structure",
   "title": "Airport pages: Mark wants duty free content only, not general airport writing",
   "detail": "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.'\n\nTwo 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.",
   "owner": "B",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "B4"
   ],
   "weight": "blocking",
   "updated": "2026-09-09",
   "resolved": {
    "by": "Stream S",
    "on": "2026-09-09",
    "note": "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."
   }
  },
  {
   "id": "issue-fix-the-offer-markup-so-its-currency-matches-the-page",
   "kind": "issue",
   "created": "2026-09-09",
   "by": "Mark on /structure",
   "title": "Fix the offer markup so its currency matches the page",
   "detail": "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.",
   "owner": "B",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "A13"
   ],
   "weight": "blocking",
   "updated": "2026-09-09",
   "resolved": {
    "by": "Stream S",
    "on": "2026-09-09",
    "note": "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)."
   }
  },
  {
   "id": "issue-brand-pages-deserve-more-fields-than-we-planned-mark-rat",
   "kind": "issue",
   "created": "2026-09-09",
   "by": "Mark on /structure",
   "title": "Brand pages deserve more fields than we planned: Mark rates them high value",
   "detail": "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.",
   "owner": "B",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "B5"
   ],
   "weight": "costly",
   "updated": "2026-09-09",
   "resolved": {
    "by": "Stream S",
    "on": "2026-09-09",
    "note": "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."
   }
  },
  {
   "id": "issue-notable-price-trend-charts-on-airport-pages-chosen-algor",
   "kind": "issue",
   "created": "2026-09-09",
   "by": "Mark on /structure",
   "title": "Notable price-trend charts on airport pages, chosen algorithmically",
   "detail": "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.",
   "owner": "D",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-09"
  },
  {
   "id": "issue-production-goes-live-on-10-sep-behind-a-domain-password",
   "kind": "issue",
   "created": "2026-09-09",
   "by": "Stream S",
   "title": "Production goes live on 10 Sep behind a domain password: consequences for E",
   "detail": "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.\n\nWhile 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).\n\nThe 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.",
   "owner": "E",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "E4c"
   ],
   "weight": "costly",
   "updated": "2026-09-09",
   "resolved": {
    "by": "Stream E",
    "on": "2026-09-10",
    "note": "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."
   }
  },
  {
   "id": "decide-confirm-the-nineteen-airports-web-addresses-with-adam-jf",
   "kind": "decide",
   "created": "2026-09-09",
   "by": "Stream S",
   "title": "Confirm the nineteen airports' web addresses with Adam (JFK the only code-first one)",
   "detail": "The airport address now leads with the airport's common name, then its code, then its city unless the name already says it, as Mark asked. The words are a declared table (main/app/services/urls.py AIRPORT_SLUGS, mirrored in the SPA); changing an entry is one line on each side and every earlier address redirects, so a correction costs nothing before launch and a redirect after it. The nineteen as built: ATH /airports/athens-ath · BCN /airports/barcelona-el-prat-bcn · BOG /airports/bogota-el-dorado-bog · CDG /airports/paris-cdg (CDG and Orly together, named for Paris) · DUB /airports/dublin-dub · DXB /airports/dubai-dxb · EZE /airports/buenos-aires-ezeiza-eze · HKG /airports/hong-kong-hkg · ICN /airports/incheon-icn-seoul · JFK /airports/jfk-new-york · LHR /airports/heathrow-lhr-london · MAD /airports/madrid-barajas-mad · MEX /airports/mexico-city-mex · PTY /airports/tocumen-pty-panama-city · SAL /airports/san-salvador-sal · SIN /airports/changi-sin-singapore · YUL /airports/montreal-trudeau-yul · YYZ /airports/toronto-pearson-yyz · ZRH /airports/zurich-zrh. Also declared, collected but not public: KEF /airports/keflavik-kef-reykjavik, SYD /airports/sydney-syd.\n\nTwo judgment calls to check with Adam: where the airport's own name is what people search (Heathrow, Changi, Incheon, Tocumen, Barajas, El Prat, El Dorado, Ezeiza, Pearson, Trudeau) it is in the address; where the city is the search term (Dubai, Zurich, Madrid is both, Mexico City, Sydney, Athens) the city alone leads and Benito Juarez and Kingsford Smith are left out. Mark deferred the code-first list to Adam and could think only of JFK.\n\nRiding on the same answer: the brand page floor is read as three or more published products, priced between them at two or more distinct airports (Adam, 10 Sep: 'three or more where stocked at two or more airports'). If Adam meant three products at each of two airports, say so and it is one line in catalog_queries.",
   "owner": null,
   "priority": null,
   "options": [
    "the table as built (assumed)",
    "corrections, one line per airport"
   ],
   "assumption": "JFK is the only code-first airport; the table as built stands",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-09"
  },
  {
   "id": "decide-sizes-on-the-page-one-unit-shown-the-shop-s-own-wording",
   "kind": "decide",
   "created": "2026-09-09",
   "by": "Stream S",
   "title": "Sizes on the page: one unit shown, the shop's own wording kept in the record",
   "detail": "Mark (7 Sep): 700ml against 70cl differs by country. Rian's reply (comment 181): sizes are stored as a value and a unit with millilitres derived, so 70cl and 700ml already match behind the scenes; the inclination is one consistent unit on the page with the shop's wording kept in the record. Neither reviewer has answered.\n\nHow it stands: the product page's meta line and every card already show one unit from size_ml (700ml, 1L, 1.5L) on both the server-rendered body and the SPA; the price table carries no size, since a product is one size. The product name, which is the H1 and the record, keeps the shop's wording (Diplomatico Ambassador 70cl), so a reader can see 70cl in the name and 700ml in the meta line. Normalising names would be an identity-rules change (Stream A's issue-size-nomenclature-differs-by-country-700ml-against-70cl stays open for it) and is not done here.",
   "owner": null,
   "priority": null,
   "options": [
    "one unit shown in the meta line and cards, the name unchanged (assumed; this is the page today)",
    "also rewrite the size in the product name to the one unit (an identity change for Stream A)",
    "show each shop's wording as printed"
   ],
   "assumption": "one unit shown, the shop's wording kept in the record; the name is the record",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-09"
  },
  {
   "id": "decide-the-owner-sign-in-stopgap-one-password-and-one-secret-in",
   "kind": "decide",
   "created": "2026-09-09",
   "by": "Stream N",
   "title": "The owner sign-in stopgap: one password and one secret in the environment, until Stream R's accounts",
   "detail": "Build plan section 4 item 9 asked for a default-deny middleware first, and Stream R's accounts (migration #4, argon2, login screens) may run after 18 Sep. Landed tonight inside the overnight constraints (no migration, no new dependency, .app.env untouched): every write route is refused unless main/app/services/mutations.py declares it public (the client surfaces, unchanged for Adam and Mark) or owner with the owner session held. The session is a signed cookie (HMAC over an expiry, stdlib), Secure on https, HttpOnly, SameSite=Lax, twelve hours; the login compares in constant time and locks out after five attempts per address per quarter hour; every write checks its Origin against the site's own host.\n\nWhat it costs you: owner actions (the kill switch, decide/done/dismiss/reopen on the running list, adding or resolving decision cards in curator mode) are refused on every host until OWNER_PASSWORD (twelve characters or more) and SESSION_SECRET (thirty-two or more, random) are in .app.env and the container is recreated. Placeholders are staged in .app.env.example; the steps are in main/docs/RUNBOOK.md under Owner sign-in. Nothing is printed and nothing is asked for in chat: you set both in your own shell. Production gets its own pair.\n\nWhat Stream R changes: the credential becomes an account with a hashed password and the middleware stays. Reversal cost if you would rather not ship the stopgap: one line in main.py removes the middleware, but then the eighteen write routes are open again on a public host.",
   "owner": null,
   "priority": null,
   "options": [
    "ship the stopgap; set the two lines before the deploy (assumed)",
    "hold the middleware until Stream R"
   ],
   "assumption": "the stopgap ships and R replaces the credential",
   "links": [],
   "due": null,
   "blocks": [
    "E4b"
   ],
   "weight": "costly",
   "updated": "2026-09-09"
  },
  {
   "id": "do-mint-indexnow-key-in-app-env-before-the-gate-comes-off-p",
   "kind": "do",
   "created": "2026-09-09",
   "by": "Stream N",
   "title": "Mint INDEXNOW_KEY in .app.env before the gate comes off production",
   "detail": "0.33.0's after-deploy note asked for it and nothing tracked it: the running container has no INDEXNOW_KEY (checked by length only, never by value). Without it python -m app.cli_pages indexnow lists and exits 2, and no key file is served. It is any alphanumeric string of 8 to 128 characters we mint once (for example python3 -c \"import secrets; print(secrets.token_hex(16))\"), set in .app.env in your own shell, then the container recreated; check by behaviour: /<key>.txt answers 200.\n\nTiming matters more than the value: while production sits behind the domain password IndexNow must not run at all (issue-production-goes-live-on-10-sep-behind-a-domain-password), so mint it as part of the go-live checklist (E7), not before. Nothing waits on it until then.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": "2026-09-16",
   "blocks": [
    "E7"
   ],
   "weight": "costly",
   "updated": "2026-09-09"
  },
  {
   "id": "issue-identity-mode-is-a-column-nothing-enforces-the-consent-r",
   "kind": "issue",
   "created": "2026-09-09",
   "by": "Stream N",
   "title": "identity_mode is a column; nothing enforces the consent rule",
   "detail": "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.",
   "owner": "A",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-09"
  },
  {
   "id": "issue-per-platform-recipes-for-the-six-text-fetch-collectors",
   "kind": "issue",
   "created": "2026-09-09",
   "by": "Stream N",
   "title": "Per-platform recipes for the six text-fetch collectors",
   "detail": "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.",
   "owner": "DOCS",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-09"
  },
  {
   "id": "decide-the-sponsor-banner-sizes-and-where-the-slots-sit-before",
   "kind": "decide",
   "created": "2026-09-09",
   "by": "Stream N",
   "title": "The sponsor banner sizes and where the slots sit, before Adam is asked for creative",
   "detail": "C5 (banners designed into the pages) has been blocked since 5 Sep on 'needs rian for sizes', and D4 built the SponsorSlot component for the nine IAB sizes so any of them can be placed. Nothing on the running list carried the ask until now. Decide with Stream C in the session: which slots exist (home, product page, airport page, article), which IAB size each takes, and whether a slot with no creative shows nothing (the component's default). Then the /todo row for Adam's creative ('Sponsor creative', waiting) gets its sizes.",
   "owner": null,
   "priority": null,
   "options": [
    "one leaderboard on the home page and one medium rectangle on product and airport pages (a starting point)",
    "your own list"
   ],
   "assumption": "nothing placed until decided; the component shows nothing without creative",
   "links": [],
   "due": null,
   "blocks": [
    "C5"
   ],
   "weight": "blocking",
   "updated": "2026-09-09",
   "resolved": {
    "by": "rian (in chat, 11 Sep)",
    "on": "2026-09-10",
    "note": "Sparse, per Adam: one slot per page type, never in a hero or header. Home between savings and exclusives 728x90; airport page above products 728x90; article after the body 728x90; product page under the price table 300x250; leaderboards fold to 320x50 on phones. Empty slots show nothing. Sizes on Adam's /todo row."
   }
  },
  {
   "id": "issue-uploads-land-root-owned-on-the-host-because-the-app-cont",
   "kind": "issue",
   "created": "2026-09-09",
   "by": "Stream N",
   "title": "Uploads land root-owned on the host because the app container runs as root",
   "detail": "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.",
   "owner": "E",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-09"
  },
  {
   "id": "issue-no-command-imports-adam-s-brand-images-from-a-folder",
   "kind": "issue",
   "created": "2026-09-09",
   "by": "Stream N",
   "title": "No command imports Adam's brand images from a folder",
   "detail": "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.",
   "owner": "A",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-09"
  },
  {
   "id": "issue-main-tsx-imports-app-css-after-the-components-so-a-share",
   "kind": "issue",
   "created": "2026-09-09",
   "by": "Stream N",
   "title": "main.tsx imports app.css after the components, so a shared rule beats a component override",
   "detail": "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.",
   "owner": "C",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-09"
  },
  {
   "id": "issue-mammoth-s-markdown-writer-is-deprecated-upstream",
   "kind": "issue",
   "created": "2026-09-09",
   "by": "Stream N",
   "title": "mammoth's Markdown writer is deprecated upstream",
   "detail": "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.",
   "owner": "D",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-09"
  },
  {
   "id": "issue-docs-check-should-warn-when-items-json-is-newer-than-its",
   "kind": "issue",
   "created": "2026-09-09",
   "by": "Stream N",
   "title": "docs-check should warn when items.json is newer than its generated mirrors",
   "detail": "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.",
   "owner": "DOCS",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-09"
  },
  {
   "id": "issue-favicon-ico-answers-404-on-every-page",
   "kind": "issue",
   "created": "2026-09-09",
   "by": "Stream N",
   "title": "/favicon.ico answers 404 on every page",
   "detail": "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.",
   "owner": "C",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-09",
   "resolved": {
    "by": "Stream C",
    "on": "2026-09-10",
    "note": "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."
   }
  },
  {
   "id": "issue-settings-still-lists-the-editorial-sample-key-which-gate",
   "kind": "issue",
   "created": "2026-09-09",
   "by": "Stream N",
   "title": "Settings still lists the editorial sample key, which gates nothing",
   "detail": "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.",
   "owner": "C",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-09"
  },
  {
   "id": "decide-souvenir-goods-in-the-paris-catalogue-collect-and-filter",
   "kind": "decide",
   "created": "2026-09-09",
   "by": "Stream N",
   "title": "Souvenir goods in the Paris catalogue: collect and filter, or skip at the collector",
   "detail": "Open since the Paris collector's first run (4 Sep handoff) and never filed: Extime's shopping catalogue includes souvenir goods (macarons, plush baguettes, travel pillows) alongside drinks and beauty. They are collected today and sit uncategorised, so no page shows them. The recommendation then was to keep collecting and let the site filter by category, which is what happens by default; the alternative is a skip rule in the collector's walk.",
   "owner": null,
   "priority": null,
   "options": [
    "keep collecting; uncategorised rows never publish (assumed)",
    "skip souvenir sections at the collector"
   ],
   "assumption": "keep collecting; the category rule hides them",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-09"
  },
  {
   "id": "decide-mark-s-note-against-dark-backgrounds-the-header-and-hero",
   "kind": "decide",
   "created": "2026-09-09",
   "by": "Stream N",
   "title": "Mark's note against dark backgrounds: the header and hero are still dark",
   "detail": "From the brand alignment pass one (3 Sep handoff), left as a taste call for you: Mark asked to avoid dark backgrounds; the header and the home hero are still navy. A lighter header would let the navy logo read as the sign it is and sit closer to the Professor sites. Stream C's second pass shipped the mega menu and prominence but not this. Decide before the design pass that places the sponsor banners, since both touch the same surfaces.",
   "owner": null,
   "priority": null,
   "options": [
    "keep the dark header and hero (assumed)",
    "lighten the header; keep the hero",
    "lighten both"
   ],
   "assumption": "unchanged",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-09"
  },
  {
   "id": "issue-docs-check-strict-trips-client-surfaces-md-at-every-chec",
   "kind": "issue",
   "created": "2026-09-09",
   "by": "Stream N",
   "title": "docs-check --strict trips CLIENT-SURFACES.md at every checkpoint because import/ is one of its sources",
   "detail": "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.",
   "owner": "DOCS",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-09"
  },
  {
   "id": "decide-stream-r-runs-the-night-of-10-sep-as-unpaid-work-ahead-o",
   "kind": "decide",
   "created": "2026-09-10",
   "by": "planning 10 Sep",
   "title": "Stream R runs the night of 10 Sep as unpaid work ahead of the 18 Sep lines",
   "detail": "The accepted quote excludes accounts and logins from this phase and the plan's own rule (section 10 item 1) drops unpaid infrastructure before any quote line. Your 10 Sep ask puts the login system first, as the enabler that replaces the shared password, closes the write routes on a public host and keeps the launch domain out of the index. R1 (the login) is about one autonomous night; R2 (discussions) and R3 (collection oversight) are placed in the buffer after 18 Sep. Confirm the order, or move R1 after B6 and the nineteen.",
   "owner": null,
   "priority": null,
   "options": [
    "R1 tonight, R2 and R3 in the buffer (assumed)",
    "R1 after the 18 Sep lines"
   ],
   "assumption": "R1 runs the night of 10 Sep; R2 and R3 in the buffer",
   "links": [],
   "due": null,
   "blocks": [
    "L1"
   ],
   "weight": "costly",
   "updated": "2026-09-10",
   "resolved": {
    "by": "rian",
    "on": "2026-09-10",
    "note": "R1 tonight; R2 and R3 in the buffer after 18 Sep"
   }
  },
  {
   "id": "decide-level-names-at-launch-adam-and-mark-on-client-the-kit-s",
   "kind": "decide",
   "created": "2026-09-10",
   "by": "planning 10 Sep",
   "title": "Level names at launch: Adam and Mark on admin (DFP's meaning), member empty, no kit template seeded",
   "detail": "You said admin for Adam and Mark. The plan seeds admin with DFP's meaning (see the client pages and participate: comments, priorities, quote lines, to-dos) and member (nothing), and seeds none of the kit's templates; the kit's admin template (invite, level changes, View As) stays available from the Levels editor under a non-colliding name for the day you delegate account administration. Nothing in the kit or its conformance suite constrains production's level names (the suite runs against the two templates its own test store seeds). The one constraint: the kit cannot rename a level, so the names are fixed at the first seed. Plan section 4.6.",
   "owner": null,
   "priority": null,
   "options": [
    "admin (DFP's meaning) and member (assumed)",
    "client and member, admin reserved for a future staff tier"
   ],
   "assumption": "admin and member as described",
   "links": [],
   "due": null,
   "blocks": [
    "O2"
   ],
   "weight": "costly",
   "updated": "2026-09-10",
   "resolved": {
    "by": "rian",
    "on": "2026-09-10",
    "note": "admin with DFP's meaning (see and participate) for Adam and Mark; member empty; no kit template seeded"
   }
  },
  {
   "id": "decide-typed-comment-authors-link-by-an-alias-map-no-guest-acco",
   "kind": "decide",
   "created": "2026-09-10",
   "by": "planning 10 Sep",
   "title": "Typed comment authors link by an alias map; no guest accounts are minted",
   "detail": "Build plan section 2 said typed names become guest:<name> identities when R lands. The plan deviates: the seven who-columns gain nullable FK columns, and app.cli backfill authors links only the folded names Adam, Mark and rian to their accounts; every other spelling stays text with a NULL link (empty beats guessed; a guest row is a principal every later flow must remember to exclude, and the consumer namespace should not hold rows that cannot sign in). The feature priorities and quote selections tables carry a silent 'Adam' fallback the SPA wrote for anonymous actors, so they link only when you pass --include-defaulted. Plan section 4.8.",
   "owner": null,
   "priority": null,
   "options": [
    "alias map, no guests (assumed)",
    "mint guest rows as the build plan said"
   ],
   "assumption": "alias map; the two defaulted tables only on rian's flag",
   "links": [],
   "due": null,
   "blocks": [
    "O2"
   ],
   "weight": "costly",
   "updated": "2026-09-10",
   "resolved": {
    "by": "rian",
    "on": "2026-09-10",
    "note": "alias map for Adam, Mark and rian only; no guest accounts; the two Adam-defaulted tables stay unlinked"
   }
  },
  {
   "id": "decide-the-date-the-login-wall-comes-off-the-storefront-site-ac",
   "kind": "decide",
   "created": "2026-09-10",
   "by": "planning 10 Sep",
   "title": "The date the login wall comes off the storefront (SITE_ACCESS=public)",
   "detail": "Login required to see the site is a pre-launch state. Adam: 'search is always our driver'; Mark: 'I would not index this until as absolutely late as possible'. The flip is one environment line and a container recreate, owned by E7 together with the IndexNow key, the real robots and sitemap and the uptime checks. Name the date with Adam and Mark; the client pages, /plan and the admin area stay behind the login whatever the date. Plan section 4.7.",
   "owner": null,
   "priority": null,
   "options": [
    "with E7 on the go-live day",
    "a later date after the human review"
   ],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "E7"
   ],
   "weight": "costly",
   "updated": "2026-09-10"
  },
  {
   "id": "decide-mail-for-invites-resets-and-notifications-a-resend-accou",
   "kind": "decide",
   "created": "2026-09-10",
   "by": "planning 10 Sep",
   "title": "Mail for invites, resets and notifications: a Resend account owned by the project",
   "detail": "The app needs its own sender for invite and reset links and, in R2, discussion notifications. The plan assumes Resend on an account owned by the project (not the server's), a verified sending domain on dutyfreeprofessor.com, the key in an app-only env file the database container never reads, and the same daily caps the server's sender uses. Until it exists, invites are a CLI-printed one-time link or a generated password handed over once. Plan sections 4.5 and 8.",
   "owner": null,
   "priority": null,
   "options": [
    "Resend, project-owned account (assumed)",
    "another provider",
    "no mail until R2"
   ],
   "assumption": "Resend on a project-owned account, configured before R2",
   "links": [],
   "due": null,
   "blocks": [
    "T6"
   ],
   "weight": "costly",
   "updated": "2026-09-10",
   "resolved": {
    "by": "rian",
    "on": "2026-09-10",
    "note": "Resend on a project-owned account; RESEND_API_KEY already in .app.env and dutyfreeprofessor.com verified (10 Sep); MAIL_FROM and MAIL_PROVIDER=resend still to add when the stream stages them; the app-only env file split stays E's issue"
   }
  },
  {
   "id": "decide-scheduled-collection-a-watched-trial-after-launch-or-han",
   "kind": "decide",
   "created": "2026-09-10",
   "by": "planning 10 Sep",
   "title": "Scheduled collection: a watched trial after launch, or hand-run until the priced line is bought",
   "detail": "Adam was told collection stays hand-run and scheduled collection is a priced add-on. The plan builds the oversight reads and the kill switch page in R3 and keeps the scheduler behind a flag, to run as a watched trial on one source after launch, on exactly one machine (which waits on decide-where-collectors-run). Say whether the trial is told to Adam as a trial, held until the line is bought, or not built. Plan section 9.",
   "owner": null,
   "priority": null,
   "options": [
    "watched trial after launch, told to Adam (assumed)",
    "hold until the scheduled-collection line is bought",
    "do not build"
   ],
   "assumption": "trial after launch behind FEATURE_SCHEDULER; nothing runs unattended before the decide",
   "links": [],
   "due": null,
   "blocks": [
    "V4"
   ],
   "weight": "blocking",
   "updated": "2026-09-10",
   "resolved": {
    "by": "rian",
    "on": "2026-09-10",
    "note": "watched trial after launch on one source behind FEATURE_SCHEDULER, told to Adam as a trial; still waits on decide-where-collectors-run"
   }
  },
  {
   "id": "decide-the-overrides-table-rides-in-migration-4-as-the-build-pl",
   "kind": "decide",
   "created": "2026-09-10",
   "by": "planning 10 Sep",
   "title": "The overrides table rides in migration #4 as the build plan names it",
   "detail": "decide-overrides-before-r assumed the pin waits for R. R is now running, so the table lands with migration #4 exactly as build plan section 4 item 1 sketches it, with a backfill that reports zero; Stream F wires the award pin after (F2). Nothing else reads it yet. Say no and it moves to a later migration beside its first writer. Plan section 6.",
   "owner": null,
   "priority": null,
   "options": [
    "rides in #4 (assumed)",
    "later, with its first writer"
   ],
   "assumption": "rides in #4; F wires the pin after",
   "links": [],
   "due": null,
   "blocks": [
    "O2"
   ],
   "weight": "costly",
   "updated": "2026-09-10",
   "resolved": {
    "by": "rian",
    "on": "2026-09-10",
    "note": "rides in migration #4, schema only; F wires the pin after"
   }
  },
  {
   "id": "do-apply-the-five-kit-upstream-proposals-and-add-a-vendor-d",
   "kind": "do",
   "created": "2026-09-10",
   "by": "planning 10 Sep",
   "title": "Apply the five kit upstream proposals and add a vendor drift check to the scaffolder, then let R re-vendor",
   "detail": "Stream R writes the unified diffs to .logs/planning/kit-upstream-proposals.md: the owner default in bw_accounts.init made required; an accountLabel prop on AccountMenu; a strings prop on AddPerson; showStatus on AdminApp; openInPopup on ProfilePanel; and new-bw-app.sh gaining a --check-vendor that byte-compares an app's vendored copies (the research found caddie's vendored bw_admin_api.py already differs from the kit). Until applied, DFP works around each without editing a vendored byte.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": "2026-09-20",
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-10"
  },
  {
   "id": "issue-the-database-container-reads-every-line-of-app-env-split",
   "kind": "issue",
   "created": "2026-09-10",
   "by": "planning 10 Sep",
   "title": "The database container reads every line of .app.env; split an app-only env file before any mail or Google secret lands",
   "detail": "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.",
   "owner": "E",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "E7"
   ],
   "weight": "costly",
   "updated": "2026-09-10"
  },
  {
   "id": "issue-production-edge-for-the-login-caddy-trusted-proxies-on-t",
   "kind": "issue",
   "created": "2026-09-10",
   "by": "planning 10 Sep",
   "title": "Production edge for the login: Caddy trusted_proxies on the droplet, Cloudflare cache bypass for the auth paths, Bot Fight Mode off",
   "detail": "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.",
   "owner": "E",
   "priority": "P1",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "E7"
   ],
   "weight": "blocking",
   "updated": "2026-09-10"
  },
  {
   "id": "do-start-stream-r2-discussions-stream-r2-once-r1-is-deploye",
   "kind": "do",
   "created": "2026-09-10",
   "by": "planning 10 Sep",
   "title": "Start Stream R2 (discussions): /stream-r2 once R1 is deployed on this host",
   "detail": "After 18 Sep, in a terminal: cd /srv/apps/dutyfreeprofessor && claude, then /stream-r2 (the command checks that R1 is deployed before it starts). Tasks T1 to T6 on /plan. Before it runs, add MAIL_FROM and MAIL_PROVIDER=resend to .app.env when the stream stages them.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": "2026-09-22",
   "blocks": [
    "T1"
   ],
   "weight": "blocking",
   "updated": "2026-09-10"
  },
  {
   "id": "do-start-stream-r3-collection-oversight-stream-r3-once-r1-i",
   "kind": "do",
   "created": "2026-09-10",
   "by": "planning 10 Sep",
   "title": "Start Stream R3 (collection oversight): /stream-r3 once R1 is deployed",
   "detail": "After R2 or beside it: /stream-r3 from /srv/apps/dutyfreeprofessor. Tasks V1 to V4 on /plan; V4 (the scheduler trial) stays blocked until decide-where-collectors-run is answered.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": "2026-09-24",
   "blocks": [
    "V1"
   ],
   "weight": "blocking",
   "updated": "2026-09-10"
  },
  {
   "id": "decide-three-small-deviations-from-the-accounts-plan-taken-as-b",
   "kind": "decide",
   "created": "2026-09-10",
   "by": "Stream R",
   "title": "Three small deviations from the accounts plan, taken as built",
   "detail": "The directory's userinfo resolves a disabled account (with its status) instead of raising, so the kit's add path refuses it with DISABLED as the plan says; raising would have sent the kit down the new-account path and answered EMAIL_REQUIRED or EXISTS for an account that only needs accounts enable. The test suite runs argon2 at a fraction of the production cost (the production parameters are pinned by their own test), because at full cost the suite went from three seconds to thirteen. The SPA's account menu is an absolutely positioned pill inside the header rather than a nav item, so the server-rendered header mirror (seo.py, Stream B's file) is untouched and nothing moves when React mounts.",
   "owner": null,
   "priority": null,
   "options": [
    "keep as built (assumed)",
    "make userinfo raise for disabled and accept EXISTS on re-add",
    "put the account menu in the nav and change the header mirror too"
   ],
   "assumption": "keep as built; the plan's stated outcomes hold",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-10"
  },
  {
   "id": "issue-before-a-second-uvicorn-worker-a-login-attempts-table-an",
   "kind": "issue",
   "created": "2026-09-10",
   "by": "Stream R",
   "title": "Before a second uvicorn worker: a login_attempts table, and a levels memo",
   "detail": "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.",
   "owner": "R",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-10"
  },
  {
   "id": "decide-the-built-javascript-is-public-while-the-site-is-members",
   "kind": "decide",
   "created": "2026-09-10",
   "by": "Stream R",
   "title": "The built JavaScript is public while the site is members-only, and it carries the client pages' static copy",
   "detail": "The hashed build assets under /assets are public in every mode, by the accounts plan (no data). But the lazily loaded chunks for the quote, structure and review pages embed those pages' static copy (the proposal lines, the structure prose, the feature board), so a determined anonymous visitor who reads the main bundle for chunk names could read that text without signing in. Prices and every database read stay behind the login. Two honest options: leave it (the copy is a proposal, not the data; the chunk names are hashed and unlisted) or, before the domain is shared more widely, serve /assets behind the session while members-only, keeping only the main bundle and the sign-in chunk public. The second is an afternoon in the access policy plus a test.",
   "owner": null,
   "priority": null,
   "options": [
    "leave it until launch (assumed)",
    "gate the lazy chunks behind the session while members-only"
   ],
   "assumption": "left as built; the client pages' copy is a proposal, not the data",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-10"
  },
  {
   "id": "issue-no-external-uptime-check-on-production-yet",
   "kind": "issue",
   "created": "2026-09-10",
   "by": "Stream E",
   "title": "No external uptime alert on production yet: the check runs and logs, nothing pages rian",
   "detail": "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.",
   "owner": "E",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [
    "deploy/uptime-check.sh"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-10",
   "resolved": {
    "by": "rian (in chat, 11 Sep)",
    "on": "2026-09-10",
    "note": "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."
   }
  },
  {
   "id": "issue-let-s-encrypt-cannot-validate-the-zone-production-runs-o",
   "kind": "issue",
   "created": "2026-09-10",
   "by": "Stream E",
   "title": "Let's Encrypt cannot validate the zone; production runs on a Cloudflare Origin CA certificate",
   "detail": "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/.",
   "owner": "E",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [
    ".logs/runs/production-first-deploy-2026-09-10.log"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-10"
  },
  {
   "id": "issue-items-py-cannot-read-rian-s-page-actions-since-members-o",
   "kind": "issue",
   "created": "2026-09-10",
   "by": "Stream E",
   "title": "items.py cannot read rian's page actions since members-only: /api/items answers 401 to the local script",
   "detail": "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.",
   "owner": "R",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [
    "main/scripts/items.py"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-10",
   "resolved": {
    "by": "Stream E",
    "on": "2026-09-10",
    "note": "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)."
   }
  },
  {
   "id": "do-go-live-day-flip-production-to-public-and-run-the-checkl",
   "kind": "do",
   "created": "2026-09-10",
   "by": "Stream E",
   "title": "Go-live day: flip production to public and run the checklist's second pass",
   "detail": "On the day the site opens: mint INDEXNOW_KEY (do-mint-indexnow-key...), set SITE_ACCESS=public in the droplet's .app.env in your own shell, recreate the app container (cd /srv/apps/dutyfreeprofessor && docker compose -f docker-compose.yml -f docker-compose.production.yml up -d app), then a session curls every route and API read anonymously and confirms the internal set is still closed, robots allows crawling, the sitemap lists the public pages, and no-store holds on HTML and JSON. Members keep their sign-in in front of the owner and client surfaces. Reversal: SITE_ACCESS=members and recreate.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    "main/docs/RUNBOOK.md"
   ],
   "due": "2026-09-18",
   "blocks": [
    "P6"
   ],
   "weight": "blocking",
   "updated": "2026-09-10"
  },
  {
   "id": "decide-skincare-and-confectionery-are-out-of-the-launch-scope-c",
   "kind": "decide",
   "created": "2026-09-10",
   "by": "rian (in chat, 11 Sep)",
   "title": "Skincare and confectionery are out of the launch scope: collected, not shown",
   "detail": "Rian, 11 Sep: keep collecting both, but Adam must not read them as a promise, so they exist nowhere on the storefront: taxonomy.HIDDEN_CATEGORIES applied by catalog_queries.shown_category to every list, count, brand page and the sitemap. Perfume and makeup stay in scope. A product page in a hidden category still opens from a direct link.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": "hidden everywhere on the storefront; product pages still open by link",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-10",
   "resolved": {
    "by": "rian (in chat, 11 Sep)",
    "on": "2026-09-10",
    "note": "Decided and shipped 11 Sep: taxonomy.HIDDEN_CATEGORIES, commit 9b98ab3."
   }
  },
  {
   "id": "issue-2-422-liquor-products-carry-no-category-so-they-sit-on-n",
   "kind": "issue",
   "created": "2026-09-10",
   "by": "Stream E",
   "title": "2,422 liquor products carry no category, so they sit on no shelf",
   "detail": "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.",
   "owner": "A",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-10"
  },
  {
   "id": "issue-when-sponsor-creative-arrives-the-server-rendered-articl",
   "kind": "issue",
   "created": "2026-09-11",
   "by": "Stream C",
   "title": "When sponsor creative arrives, the server-rendered article and airport pages must follow the browser's layout",
   "detail": "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.\n\nFix 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.",
   "owner": "B",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [
    "main/docs/CLIENT-SURFACES.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-11"
  },
  {
   "id": "issue-before-launch-the-footer-needs-privacy-terms-and-contact",
   "kind": "issue",
   "created": "2026-09-11",
   "by": "Stream C",
   "title": "Before launch the footer needs privacy, terms and contact pages, and none exist yet",
   "detail": "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.",
   "owner": "C",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-11"
  },
  {
   "id": "decide-what-the-review-area-needs-next-now-that-you-can-see-the",
   "kind": "decide",
   "created": "2026-09-11",
   "by": "planning session",
   "title": "What the review area needs next, now that you can see the data",
   "detail": "The first version of /collectors is on staging: collectors by platform, catalogue counts, every product in one filterable table, and the problems list. It only reads. The obvious next steps, in the order I would take them, but you should look first and say what is actually missing: (1) act on a duplicate pair from the page rather than the command line, which is the merge queue's whole point and the columns already exist; (2) clear a publication block from the page, with the note recorded against your name; (3) a per-airport view, since today you filter by airport but there is no page for one; (4) saved views, so 'products missing an image at Heathrow' is one click; (5) a history of the catalogue counts over time, from the audit snapshots already stored nightly.\n\nDeciding anything from the page is a write, and every write here is a recorded human decision, so it needs the same care as the merge and clear commands already take.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-11"
  },
  {
   "id": "do-check-heathrow-s-gate-numbers-and-terminal-detail-before",
   "kind": "do",
   "created": "2026-09-11",
   "by": "Stream C",
   "title": "Check Heathrow's gate numbers and terminal detail before the guide goes public",
   "detail": "Adam's Heathrow duty free guide (draft v2, September 2026) is now the LHR airport page's guide section (main/app/services/airport_guides.py). His own note on the draft: verify the exact gate numbers before publishing, because retailers reshuffle gates. The page carries a dated line saying the locations were checked in September 2026; confirm T2's A20 and B35, T3's Flight Connection Centre store and T5's Harrods and Prada before the site goes public.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-11"
  },
  {
   "id": "issue-paco-rabanne-and-rabanne-are-two-brands-in-our-data-whic",
   "kind": "issue",
   "created": "2026-09-11",
   "by": "planning session",
   "title": "Paco Rabanne and Rabanne are two brands in our data, which splits a whole fragrance line",
   "detail": "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.\n\nWhat 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.\n\nThree 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.",
   "owner": "A",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "A11"
   ],
   "weight": "costly",
   "updated": "2026-09-11"
  },
  {
   "id": "decide-one-page-per-fragrance-line-with-its-sizes-and-variants",
   "kind": "decide",
   "created": "2026-09-11",
   "by": "planning session",
   "title": "One page per fragrance line, with its sizes and variants on it, instead of one page per bottle",
   "detail": "Rian's idea, looking at the 1 Million rows: someone searching '1 Million' should land on one page for the line, with the sizes and variants listed and prices under each, rather than five thin pages. It changes what a product address means, so it is cheaper to decide before launch than after.\n\nWhat it takes: a line above the product (the brand plus the name with size, concentration and edition stripped), a column on products pointing at it, the product page becoming a line page where the variant and size are chosen, and every existing product address redirecting to its line with the right variant preselected. The comparison itself stays exactly where it is, at one variant in one size, because a 50 ml against a 100 ml is not a saving.\n\nWhat it is worth, measured today: drinks barely move, 9,089 products across 9,047 lines, because a bottle is usually one size. Beauty is where it pays, 987 products sitting in 362 lines, so those become 362 stronger pages instead of 987 thin ones. It also answers Mark's thin-content worry, because a line page with several sizes is a real page even when no single size is stocked at two airports.",
   "owner": null,
   "priority": null,
   "options": [
    "after the soft launch, as a considered change",
    "before Cannes, for the demo",
    "not doing it; one page per bottle stands"
   ],
   "assumption": "one page per bottle stands until you say otherwise",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-11"
  },
  {
   "id": "issue-a-merge-conflict-was-committed-into-the-running-list-and",
   "kind": "issue",
   "created": "2026-09-11",
   "by": "planning session",
   "title": "A merge conflict was committed into the running list, and nothing noticed",
   "detail": "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.\n\nIt 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.",
   "owner": "DOCS",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-11"
  }
 ]
}
