{
 "updated_at": "2026-09-19T05:48+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.\n\nWidened 15 Sep: this is the case that breaks 'a storefront belongs to one place', and so it constrains the location model. Orly does not exist in our data and CDG's 7,036 listings are really Paris's. See .logs/planning/places-and-shops-2026-09-15.md section 2.3.\n\nCatalogue decisions, 15 Sep (.logs/planning/catalogue-model-decisions-2026-09-15.md section 2.11, section 7): the place model that answers this is decided in direction and built after Cannes, before any non-airport shop is collected. The delivery walkthrough does not need it: Adam hears at P6 that Extime's prices are Paris's (the W6 naming task, where the Extime issue resolves) and that terminals and specialty shops within one airport are not yet distinguished, coming after Cannes.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/catalogue-model-decisions-2026-09-15.md",
    ".logs/planning/places-and-shops-2026-09-15.md"
   ],
   "due": null,
   "created": "2026-09-03",
   "by": "Stream A",
   "updated": "2026-09-15",
   "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",
   "resolved": {
    "by": "rian (in chat, 13 Sep)",
    "on": "2026-09-13",
    "note": "Retry at a slower pace and find the rate they accept. Run 13 Sep on bwlive: collect --source ishopchangi-sin --limit 20 --delay 30. If it holds, the pace becomes the source's own (Source.delay_seconds / Changi.render_floor_seconds); a second refusal ends Singapore for good and it joins the Cannes ask list. Rian's direction beyond this run: crawl continuously rather than fast; keep the pace per collector as something a person adjusts, with notes; record when and at what pace a collector is refused; measure how often prices change per collector so the cadence is chosen from evidence."
   }
  },
  {
   "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.\n\nWidened 15 Sep: this is one face of the location model, documented in full in .logs/planning/places-and-shops-2026-09-15.md (section 2.2). The schema permits a second shop row at one airport - the unique constraint is (retailer_id, code) - so what is missing is not the row but what it means, and the sixteen queries that count shop rows as airports would silently overcount the day one appears.\n\nCatalogue decisions, 15 Sep (.logs/planning/catalogue-model-decisions-2026-09-15.md section 2.11, section 7): the place model that answers this is decided in direction and built after Cannes, before any non-airport shop is collected. The delivery walkthrough does not need it: Adam hears at P6 that Extime's prices are Paris's (the W6 naming task, where the Extime issue resolves) and that terminals and specialty shops within one airport are not yet distinguished, coming after Cannes.",
   "owner": "A",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/catalogue-model-decisions-2026-09-15.md",
    ".logs/planning/places-and-shops-2026-09-15.md"
   ],
   "due": null,
   "blocks": [
    "P6"
   ],
   "weight": "costly",
   "updated": "2026-09-15"
  },
  {
   "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",
   "resolved": {
    "by": "rian (in chat, 13 Sep)",
    "on": "2026-09-13",
    "note": "Navy stays for now; rian gets Adam's and Mark's final word this week. A base-colour switch beside the accent switch on /settings is filed so the lighter header can be compared on the live pages rather than from a description."
   }
  },
  {
   "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",
   "resolved": {
    "by": "Stream R2",
    "on": "2026-09-11",
    "note": "R2 ran 2026-09-12 on branch claude/stream-r2-fca074 (T7, T1 to T6, T8 to T10 done); merge and deploy are rian's"
   }
  },
  {
   "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 Adam's and Mark's pre-launch material without signing in. Prices and every database read stay behind the login. Two honest options: leave it (the copy is a proposal, not the data; the chunk names are hashed and unlisted) or, before the domain is shared more widely, serve /assets behind the session while members-only, keeping only the main bundle and the sign-in chunk public. The second is an afternoon in the access policy plus a test.\n\nAway plan, 11 Sep: the assumption flips to gating them, as Stream W's W2 (an anonymous request for a lazy chunk answers 401 in members mode and 200 in public mode; the entry bundle and the sign-in chunk stay public; a test pins both). To overturn: leave them public; reverting is deleting the rule and its test.",
   "owner": null,
   "priority": null,
   "options": [
    "gate the lazy chunks behind the session while members-only (assumed)",
    "leave them public until launch"
   ],
   "assumption": "gated behind the session while members-only; the entry bundle and the sign-in chunk stay public",
   "links": [
    ".logs/planning/streams/AWAY-PLAN.md",
    ".logs/planning/streams/W-readiness.md"
   ],
   "due": null,
   "blocks": [
    "W2"
   ],
   "weight": "costly",
   "updated": "2026-09-11"
  },
  {
   "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",
   "resolved": {
    "by": "rian",
    "on": "2026-09-16",
    "note": "Decided 16 Sep, generalised beyond fragrance: /products/ shows the line for every family. Section 2.7 of catalogue-model-decisions-2026-09-15.md."
   }
  },
  {
   "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"
  },
  {
   "id": "decide-opening-hours-are-a-new-source-class-collected-from-airp",
   "kind": "decide",
   "created": "2026-09-11",
   "by": "Away plan (finalising session)",
   "title": "Opening hours are a new source class: collected from airport operator sites where their robots allow, hand-entered where they do not, provenance shown",
   "detail": "Your call of 11 Sep, written into Stream G as the assumption it proceeds under: G1 reads each airport operator domain's robots.txt before the domain is added (permission never transfers between domains, exactly as retailer domains work), builds one collector per operator platform for the domains that allow it, and leaves the rest for hand population; G2 gives the hand path the same shape with the date and who entered it; G3 shows, per airport, collected or hand-entered and the date, and nothing at all where neither exists, so a stale hand-entered set is visible rather than silent. The page wording is a date, never a rate.\n\nTo overturn: say no hours collection at all, and the collectors are switched off by deleting their registry entries; the table, the hand path and the provenance stay. Say hand only, and G1 becomes the robots verdict list with no collector built.",
   "owner": null,
   "priority": null,
   "options": [
    "collect where the operator's robots allow, hand-enter elsewhere, show which (assumed)",
    "hand-enter everywhere; no operator site is read",
    "no opening hours on the page"
   ],
   "assumption": "collect where the operator's robots allow, hand-enter elsewhere, show which; one robots read per operator domain before it is added",
   "links": [
    ".logs/planning/streams/AWAY-PLAN.md",
    ".logs/planning/streams/G-airport.md"
   ],
   "due": null,
   "blocks": [
    "G1",
    "G3"
   ],
   "weight": "costly",
   "updated": "2026-09-11"
  },
  {
   "id": "do-review-the-decisions-taken-while-you-were-away",
   "kind": "do",
   "created": "2026-09-11",
   "by": "Away plan (finalising session)",
   "title": "Review the decisions taken while you were away",
   "detail": "Every call in .logs/planning/streams/AWAY-PLAN.md section 3 is a working assumption a lane proceeded under, not your decision; each states what it costs to overturn. Read section 3 top to bottom and overturn any in one sentence: the Singapore hold, the six page types on 18 Sep, the review area's order, opening hours as a source class, the four standing rules with the Avolta pages-2-and-later observations quarantined rather than purged, the login wall coming off with E7, the standing assumptions, the lazy chunks gated, and the dark header left alone on purpose. Then retire the plan file.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/streams/AWAY-PLAN.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-11"
  },
  {
   "id": "issue-the-mail-lines-mail-provider-mail-from-resend-api-key-ne",
   "kind": "issue",
   "created": "2026-09-11",
   "by": "Stream R2",
   "title": "The mail lines (MAIL_PROVIDER, MAIL_FROM, RESEND_API_KEY) need the app-only env file before they are set: the compose split, with this review",
   "detail": "T6 wired mail to the end (the Resend provider, the daily caps in email_sends, the notification digest, the mail preference) and set nothing: every path is inert until the three MAIL_* lines exist, and they must not go into .app.env, which docker-compose.yml hands to the database container as well (env_file on both services). W3's split is the compose change; the security review Stream R2 owes it is here so W3 can act on it without a second reading.\n\nReview. Port binding: unchanged (the app stays on 172.17.0.1:PORT, the database publishes nothing). Authentication: unchanged. Network exposure: the app makes one new outbound HTTPS call per message to api.resend.com with the key in the Authorization header only; no inbound path. Credential storage: RESEND_API_KEY is a bearer credential able to send mail as the site; it belongs in a file only the app container and rian read (0600, the srv-gateway ACL as for .app.env), referenced as a second env_file on the app service only, never on db or browser; the browser sidecar keeps no env_file at all. Container isolation: no new capability, no mount. Automated execution: notify digest runs from host cron, reads the database, sends at most 40 mails per person per day and 25 invites per host, and cannot be triggered from a request; the caps are the bound if the key leaks. Blast radius of a leak: mail sent in the site's name until the key is rotated at Resend; rotation is one line and a container recreate. After the change: srv-gw security-audit.\n\nProposed compose shape for W3 (not applied by R2): under services.app add a second env_file entry, .app-only.env, holding only the MAIL_* lines (and any later app-only secret); .app.env keeps the database lines both containers need. Stage .app-only.env with REPLACE_WITH_ placeholders, chmod 600 plus the gateway ACL, and add it to .gitignore beside .app.env. Nothing else in the compose file moves.",
   "owner": "W",
   "priority": "P2",
   "options": [
    "W3 splits the env file as proposed (assumed)",
    "keep one env file and accept that the database container reads the mail key"
   ],
   "assumption": "the app-only env file lands with W3; until then no MAIL_* line is set anywhere",
   "links": [
    "main/docs/RUNBOOK.md",
    "main/app/cli_notify.py"
   ],
   "due": null,
   "blocks": [
    "W3"
   ],
   "weight": "costly",
   "updated": "2026-09-11"
  },
  {
   "id": "issue-a-base-colour-switch-on-settings-beside-the-accent-switc",
   "kind": "issue",
   "created": "2026-09-13",
   "by": "rian (in chat, 13 Sep)",
   "title": "A base-colour switch on /settings beside the accent switch, so a lighter header can be judged live",
   "detail": "Rian is happy with navy for now and will get Adam's and Mark's final view this week; Mark had asked to avoid dark backgrounds. Rather than decide from a description, add a second brand-review switch on /settings: the base colour behind the header and hero. Same mechanism as the accent (a data attribute on the root, per browser, instant, the shipped look for everyone else): candidates declared in tokens.css as overrides of the navy family, at least 'navy (shipped)' and one light option with AA-checked text and label pairs. The server-rendered header mirror must not change; this is a per-browser preview only.",
   "owner": "C",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "C5"
   ],
   "weight": "costly",
   "updated": "2026-09-13"
  },
  {
   "id": "decide-makeup-shades-one-product-with-shade-variations-decided",
   "kind": "decide",
   "created": "2026-09-13",
   "by": "rian (in chat, 13 Sep)",
   "title": "Makeup shades: does the shade leave the line key?",
   "detail": "Deferred on 13 Sep with rian's leaning recorded, and the Makeup line key keeps the shade tail until he settles it, the kind held as metadata beside it.\n\nReopened 15 Sep: this now BLOCKS the line page. Because the shade is in the line key, every shade is its own line, so 'Blush Subtil 03 Sorbet De Corail' and 'Blush Subtil 041 Figue Espiegle' are separate product_lines rows. A cosmetics line page therefore has nothing to group, and the question of what a comparison grid shows for a line with many shades cannot even be tested. See .logs/planning/variations-and-line-pages-2026-09-15.md section 4b.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/variations-and-line-pages-2026-09-15.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15",
   "resolved": {
    "by": "Claude (catalogue decisions session, 15 Sep)",
    "on": "2026-09-15",
    "note": "Decided (§2.3 of .logs/planning/catalogue-model-decisions-2026-09-15.md): the shade leaves the Makeup line key; identity rules v5 on the branch, rehearsed (761 Makeup lines to 453, 0 products folded)"
   }
  },
  {
   "id": "decide-category-at-airport-pages-ship-them-with-the-structure-o",
   "kind": "decide",
   "created": "2026-09-13",
   "by": "planning session",
   "title": "Category-at-airport pages: ship them with the structure, or hold them back and price them later?",
   "detail": "The facts. The accepted quote has no line for them: it promises airport pages ('a page per airport carrying its pricing, its exclusives and your editorial, with the comparison tool preset to that airport'), brand pages, a mega menu, and two further product types. Category-at-airport pages came from the structure proposal we wrote for Mark, which calls them 'the commercially strongest page type on the site' and counts 'around a hundred'; Mark endorsed them in his review and Adam read the page without objecting, but nothing in writing commits you to them. Stream G has now built them (96 pages at the 15-product bar), unpaid.\n\nHiding them is one number: the coverage bar constant set impossibly high makes every one a 404 with no redirect and nothing renamed, so they can be switched on the day they are paid for. The cost of hiding: Mark will notice the strongest search page is missing, and the proposal page he read lists it as planned. The cost of shipping: work delivered outside the quote with nothing invoiced, which the next statement of work can still name as delivered value.",
   "owner": null,
   "priority": null,
   "options": [
    "ship them as part of the structure, and name them as delivered value in the next statement of work",
    "hold them behind the bar, offer them as a priced item",
    "ship a handful (say the top ten pairings) and hold the rest"
   ],
   "assumption": "they ship, since the bar constant can hide them in a minute if you decide otherwise",
   "links": [],
   "due": null,
   "blocks": [
    "G7"
   ],
   "weight": "costly",
   "updated": "2026-09-13",
   "resolved": {
    "by": "rian (in chat, 13 Sep)",
    "on": "2026-09-13",
    "note": "Hide them for now. Rian: we are already overdelivering; save them for the next quote rather than turn built work into a conversation about charging. Kept behind a feature flag (off) so they switch on the day they are quoted; the sitemap and the airport page's rail must not advertise them while hidden."
   }
  },
  {
   "id": "issue-pace-per-collector-is-something-a-person-adjusts-on-coll",
   "kind": "issue",
   "created": "2026-09-13",
   "by": "rian (in chat, 13 Sep)",
   "title": "Pace per collector is something a person adjusts on /collectors, with notes",
   "detail": "The columns already exist and are honoured: sources.delay_seconds (read by ingest and verify), sources.max_concurrency, sources.notes, and each rendered collector's own floor. Nobody can set them from a page. On the Collectors view, per collector: the pace between requests, the render floor where there is one, and a notes field for what a person learned about that host (when it refused us, at what pace, what the operator said), each change recorded against the account that made it. Rian's stance: crawl continuously rather than fast; the pace is a knob, not a constant.",
   "owner": "M",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "M6"
   ],
   "weight": "costly",
   "updated": "2026-09-13"
  },
  {
   "id": "issue-record-refusals-with-the-pace-at-the-time-and-measure-ho",
   "kind": "issue",
   "created": "2026-09-13",
   "by": "rian (in chat, 13 Sep)",
   "title": "Record refusals with the pace at the time, and measure how often prices change per collector, so the crawl cadence is chosen from evidence",
   "detail": "Two ledgers. First, every refusal a collector meets (403, a challenge page, a block mid-run) recorded with the source, the pace it was running at, how many pages it had read, and the time, so the rate a host tolerates can be found rather than guessed; the Singapore retry of 13 Sep is the first entry. Second, per collector, how often prices actually move between reads (the observations already carry every reading and its time), so the cadence per collector follows the shop: a shop whose prices move weekly is read weekly, at whatever pace it tolerates. Both shown on /collectors beside the run history.",
   "owner": "Q",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-13"
  },
  {
   "id": "issue-the-fetch-port-s-block-markers-miss-two-challenge-pages",
   "kind": "issue",
   "created": "2026-09-11",
   "by": "Stream G",
   "title": "The fetch port's block markers miss two challenge pages served behind HTTP 200 (Incapsula, Radware)",
   "detail": "main/app/services/collectors/fetch.py, _looks_blocked: on 11 Sep www.parisaeroport.fr answered an Incapsula frame ('Request unsuccessful. Incapsula incident ID') and www.torontopearson.com a 'Radware Captcha Page' (H1 'We apologize for the inconvenience'), both HTTP 200, and fetch() returned them as pages. Add 'request unsuccessful', 'incapsula', 'captcha' and 'we apologize for the inconvenience' to the markers so every collector treats them as SourceBlocked. The hours collectors judge these on content themselves meanwhile (services/hours/base.py looks_challenged), which is a second copy of a rule that belongs in the port.",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-11"
  },
  {
   "id": "issue-mount-the-opening-hours-provenance-as-a-per-airport-row",
   "kind": "issue",
   "created": "2026-09-11",
   "by": "Stream G",
   "title": "Mount the opening hours provenance as a per-airport row on /collectors",
   "detail": "The per-airport view of the review area (rian's stated order, third item) is where the hours provenance lands. Stream G built the read and the component; CollectorsPage.tsx is Stream M's file during wave one, so this is the exact mount rather than an edit. Import: import { hoursProvenanceLine } from \"../components/AirportHours\"; (main/web/src/components/AirportHours.tsx) and, per airport row, fetch GET /api/airports/{iata}/hours (shape {kind: collected|hand|none, text, observed_at, entered_by_username, source_url}; a hidden airport answers 404) then render hoursProvenanceLine(...) or nothing at all when kind is none; the AirportDetail's guide.hours_provenance carries the same fields (kind, observed_at, entered_by_username, source_url, source_host) if the page already holds the detail. Wording rule: a date, never 'verified', never a rate. Per airport it should read: 'Hours collected 11 Sep 2026 from www.heathrow.com' or 'Hours entered by rian 12 Sep 2026' or nothing.",
   "owner": "M",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-11"
  },
  {
   "id": "issue-v2-the-per-airport-view-carries-the-opening-hours-proven",
   "kind": "issue",
   "created": "2026-09-11",
   "by": "Stream G",
   "title": "V2 (the per-airport view) carries the opening hours provenance",
   "detail": "When Stream R3 builds V2, the per-airport view of the collection page, it shows per airport whether the hours were collected or hand-entered and the date, and nothing at all where neither exists (rian's decision of 11 Sep, AWAY-PLAN.md section 3). The read is GET /api/airports/{iata}/hours and the line is hoursProvenanceLine() in main/web/src/components/AirportHours.tsx; the shape is written in main/docs/COLLECTORS.md under Opening hours. The same issue is filed for Stream M's /collectors mount during wave one; whichever page survives the consolidation carries it.",
   "owner": "R",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-11"
  },
  {
   "id": "decide-the-coverage-bar-for-a-category-at-airport-page-fifteen",
   "kind": "decide",
   "created": "2026-09-11",
   "by": "Stream G",
   "title": "The coverage bar for a category-at-airport page: fifteen published products",
   "detail": "The structure proposal sets the rule in words ('the pairing has enough published products to be a real list', 'around a hundred' such pages) and no number. Measured on the 11 Sep nightly copy: 15 products gives 96 pairs, 12 gives 112, 20 gives 81, 8 gives 129; 155 pairs exist at all. The bar is one constant, coverage.CATEGORY_AT_AIRPORT_MIN_PRODUCTS, read at request time by the page's 404, the airport page's rail of links and the sitemap, so moving it changes which pages exist with no other code change and no redirect (a page that drops under the bar becomes a 404; nothing is renamed).",
   "owner": null,
   "priority": null,
   "options": [
    "15 (the assumption): about a hundred pages, every one a full screen of cards",
    "12: 112 pages, the smallest list two thirds of a screen",
    "20: 81 pages, only the deep pairings"
   ],
   "assumption": "Proceeding on 15. Reversing it is a one-line change; a lower bar adds pages, a higher one removes some, and neither moves an address.",
   "links": [],
   "due": null,
   "blocks": [
    "G7"
   ],
   "weight": "costly",
   "updated": "2026-09-11"
  },
  {
   "id": "decide-the-site-wide-category-page-address-alcohol-slug-per-the",
   "kind": "decide",
   "created": "2026-09-11",
   "by": "Stream G",
   "title": "The site-wide category page address: /alcohol/<slug> per the structure proposal, not /categories/",
   "detail": "Stream G's brief allows a site-wide category page under /categories/<category> only if lib/structure.ts names that address. It does not: the proposal (settled 9 Sep with Mark) puts whisky at /alcohol/whisky under a family page (/alcohol, /perfume, /cosmetics), and says explicitly that whisky does not sit behind /categories/. So G7 landed the category-at-airport pages alone (/airports/<airport>/<category-slug>) and no site-wide category page. The family and category hub pages are Stream B's hub work; the category slug table (urls.CATEGORY_SLUGS, mirrored in lib/urls.ts) is written so /alcohol/<slug> can reuse the same words. Nothing waits on this; it names where the site-wide pages go when they are built.",
   "owner": null,
   "priority": null,
   "options": [
    "/alcohol/<slug>, /perfume/<slug>, /cosmetics/<slug> under family pages, as the proposal says (the assumption)",
    "/categories/<slug>, a flat list"
   ],
   "assumption": "The proposal's addresses stand; no site-wide category page was built in G7.",
   "links": [],
   "due": null,
   "blocks": [
    "G7"
   ],
   "weight": "info",
   "updated": "2026-09-11"
  },
  {
   "id": "issue-data-model-md-the-airport-hours-table-s-rationale-beside",
   "kind": "issue",
   "created": "2026-09-11",
   "by": "Stream G",
   "title": "DATA-MODEL.md: the airport_hours table's rationale, beside its generated row",
   "detail": "main/docs/DATA-MODEL.md is Stream M's file during wave one, so Stream G left its hand-written part alone; the generated Tables block already lists airport_hours. Paragraph to add under 'Why it is shaped this way': Opening hours are observations like prices: one row per reading (airport_hours), collected from the airport operator's site or entered by hand (source_kind), dated (observed_at), the hand row named to an account (entered_by_id, a who-column), nothing deleted; the reader shows the newest hand row over any collected one, so a human value is never overwritten by a machine and a later collected reading is stored unshown. location_id points at one of the airport's shop rows, the way every airport fact is keyed; text is the one line the page prints and detail the stores behind it (services/hours/store.py, COLLECTORS.md Opening hours).",
   "owner": "DOCS",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-11"
  },
  {
   "id": "issue-a-litre-pattern-reads-the-5-in-a-name-like-n-5-l-eau-as",
   "kind": "issue",
   "created": "2026-09-11",
   "by": "Stream M",
   "title": "A litre pattern reads the 5 in a name like N°5 L'EAU as five litres",
   "detail": "main/app/services/normalize.py parse_size and parse_size_ml: the litre pattern (\\d+)\\s*l\\b matches '5 L' inside \"N°5 L'EAU 100ml\" (the apostrophe is a word boundary), so the Bogota and Panama Shopify listings of CHANEL N°5 L'EAU 100ml (listing tiles say '100 ml') are stored as size 5.00 l, 5000 ml, and can never match the other shops' 100 ml row. Seen on the /collectors Listings view, 'Size differs' filter, 11 Sep staging copy. Suggested fix: try ml and cl before the litre patterns, or refuse a litre token followed by an apostrophe or a letter; then backfill implausible_sizes (5000 ml is over the beauty ceiling) and rederive. A test with the real name belongs in tests/test_normalize.py.",
   "owner": "A",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-11"
  },
  {
   "id": "issue-thirteen-extime-products-are-named-no-yes-or-nothing",
   "kind": "issue",
   "created": "2026-09-11",
   "by": "Stream M",
   "title": "Thirteen Extime products are named No, Yes or nothing",
   "detail": "On the 11 Sep staging copy, 13 alive products carry the name 'No', 'Yes', 'N/A' or an empty string; eleven are Plisson at CDG (ids 11374, 11402, 11405, 11507 and seven more; URLs like extime.com/en/paris/product/beard-oil-105585111, natural-shaving-cream-tube-105585117). The Extime collector reads a field that holds a yes/no flag for these pages instead of the product name (the URL slug carries the real name). Seen on the Listings view and as a line called 'No' with eleven products under Plisson. Suggested fix in collectors/extime.py: refuse a name that is a bare yes/no or empty and fall back to the JSON-LD name or the slug; a test with the real fragment; then rederive.",
   "owner": "A",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-11"
  },
  {
   "id": "issue-product-cards-and-pages-show-the-brand-as-one-shop-spelt",
   "kind": "issue",
   "created": "2026-09-11",
   "by": "Stream M",
   "title": "Product cards and pages show the brand as one shop spelt it, not the house's preferred name",
   "detail": "catalog_queries.list_products and get_product return products.brand (the spelling the first shop used: 'PACO RABANNE', 'Paco Rabanne', 'Rabanne') while the brand page already resolves the alias to the house. Once a brand alias is confirmed in the merge session (brands.canonical_id, decided_by), every card and product page should show the house's name (brand_row -> canonical -> name) and link to its page; the collected spelling stays on the row as the raw record. Small change in catalog_queries (join brands, follow canonical_id once) and the BrandOut/ProductOut shapes; the line page (the follow-on rian decides) will need the same resolution for product_lines.canonical_id.",
   "owner": "B",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-11"
  },
  {
   "id": "issue-product-markup-a-barcode-that-arrived-through-a-merge-is",
   "kind": "issue",
   "created": "2026-09-11",
   "by": "Stream M",
   "title": "Product markup: a barcode that arrived through a merge is marked, and the JSON-LD should say so or leave it out",
   "detail": "products.gtin_source is 'merge' when the survivor of a merge gained its barcode from the row folded into it (its own shops never published that barcode); NULL or 'collected' means a shop published it. seo.py writes gtin13 into the product page's JSON-LD; for a merge-sourced barcode either omit gtin13 or keep it only when the merged row's listing still stands at a shop that published it. Stream M's part (the marking, migration c6d7e8f9a0b1) is done; the markup decision is Stream B's.",
   "owner": "B",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-11"
  },
  {
   "id": "do-land-stream-m-merge-the-branch-deploy-run-the-after-depl",
   "kind": "do",
   "created": "2026-09-11",
   "by": "Stream M",
   "title": "Land Stream M: merge the branch, deploy, run the after-deploy commands, then confirm Paco Rabanne to Rabanne on /collectors",
   "detail": "Stream M ran in the desktop worktree on branch claude/stream-m-fdcfa4 (seven M: commits, check.sh green). To land it: merge the branch into master (expect a conflict only in import/items.json and import/progress.json if R2 or G also appended; keep both sides), take the dump (backups/dfp-<date>-pre-migration-6.dump), deploy, then inside the container in this order, each safe to repeat: python -m app.cli backfill lines; backfill variations; rederive; backfill merges; suggest. On the rehearsal copy that took under two minutes and folded 500 groups. Then open /collectors, the Merge view: the first pair waiting is Paco Rabanne to Rabanne; y confirms it as Rabanne, and the 1 Million pairs follow at product level (CDG's bare '1 Million 10cl' beside the 100 ml Eau de Toilette). Every decision is recorded against your account. The page has not been opened in a browser from the branch: the routes are tested and the SPA typechecks, so look at it on staging before plowing through.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-11"
  },
  {
   "id": "decide-makeup-shades-a-variation-the-vocabulary-does-not-know-s",
   "kind": "decide",
   "created": "2026-09-11",
   "by": "Stream M",
   "title": "Makeup shades: a variation the vocabulary does not know, so makeup lines are never suggested for merging",
   "detail": "A foundation comes in shades ('Teint Idole 230W', '310N'); the variation vocabulary knows concentrations and Elixir, not shades, so two shades key apart only by their code word and a line merge would fold them into one product. Stream M therefore offers no makeup line pairs and skips any pair where only a shade-like code differs. Options: (a) leave makeup out of the merge session until a shade attribute exists (assumed); (b) add attributes.shade parsed from the name as a fourth variation kind, keyed like the concentration; (c) hide makeup from the comparison. Reversing (a) later costs one rule and a rederive, nothing else.",
   "owner": null,
   "priority": null,
   "options": [
    "leave makeup out of the merge session (assumed)",
    "add a shade attribute keyed like the concentration",
    "hide makeup from the comparison"
   ],
   "assumption": "leave makeup out of the merge session",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-11"
  },
  {
   "id": "issue-three-platforms-keep-no-fragment-for-most-listings-so-th",
   "kind": "issue",
   "created": "2026-09-11",
   "by": "Stream M",
   "title": "Three platforms keep no fragment for most listings, so the Listings view cannot show their tile",
   "detail": "raw_records exist for 11,098 of 23,771 listings on the 11 Sep copy. Extime keeps a fragment for 200 of 7,036 listings (only the targeted re-reads), Dubai and the three Heinemann-platform shops keep none, so the /collectors Listings view shows an empty left side for them and a rederive cannot re-read what they said. Each collector already builds the RawListing from a fragment; passing it as raw=facts_only(...) at every yield (the way Avolta, Shopify and ARI do) closes the gap.",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-11"
  },
  {
   "id": "issue-singapore-s-pace-is-thirty-seconds-make-it-the-collector",
   "kind": "issue",
   "created": "2026-09-13",
   "by": "planning session",
   "title": "Singapore's pace is thirty seconds: make it the collector's code default as well as the source row",
   "detail": "The 13 Sep retry settled it: iShopChangi refused twice at about ten seconds near page eighteen and accepted twenty pages at thirty. sources.delay_seconds is now 30 on both hosts, which ingest and verify read when no --delay is given. Put the same figure on the collector (Changi.render_floor_seconds = 30.0) so a fresh database or a --delay below it can never run it faster than the shop tolerates, and say so in COLLECTORS.md's pace-per-source note.",
   "owner": "A2",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-13"
  },
  {
   "id": "issue-adam-s-mention-of-rian-never-rang-legacy-comments-were-l",
   "kind": "issue",
   "created": "2026-09-13",
   "by": "planning session",
   "title": "Adam's @mention of rian never rang: legacy comments were linked to threads but their mentions were not notified",
   "detail": "Comment 221 (13 Sep 21:20, before the 0.47.0 deploy) names @Rian. The threads backfill linked it to its thread but emits no notifications, so rian's inbox stayed empty; the mention regex and directory are fine (the same body notifies when posted through the new write path, as the tests show). Emitted by hand on staging on 13 Sep (notification 1, unread, deep link to #c-221). The rule belongs in the backfill so production's migration does the same: T14.",
   "owner": "R",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "T14"
   ],
   "weight": "costly",
   "updated": "2026-09-13",
   "resolved": {
    "by": "Stream R2b",
    "on": "2026-09-13",
    "note": "backfill legacy_mentions (also the last step of backfill threads) rings every legacy mention once under the write path's dedupe key, so the hand-emitted row on staging is not doubled; rehearsed both ways on a staging copy; commit R: T14 (6f9c49c)"
   }
  },
  {
   "id": "issue-request-to-the-caddie-ui-pack-a-mention-picker-in-compos",
   "kind": "issue",
   "created": "2026-09-13",
   "by": "rian (in chat, 13 Sep)",
   "title": "Request to the caddie-ui pack: a mention picker in Composer",
   "detail": "Typing @ in a comment box should offer the names the writer may reach, filtered as they type, with arrows and Enter choosing and the handle inserted. The pack's Composer has none, and the Interaction Standard forbids an app re-implementing a pack piece, so this is a pack change: a directory prop on Composer ([{handle, display_name}]) and the picker. DFP's side (T16) supplies the directory through a people route and wires the prop the day the pack ships it. Filed here so rian can carry it into a caddie session; caddie keeps no request file of its own.",
   "owner": "caddie",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "T16"
   ],
   "weight": "costly",
   "updated": "2026-09-13"
  },
  {
   "id": "do-merge-r2b-deploy-staging-run-the-after-deploy-commands-t",
   "kind": "do",
   "created": "2026-09-13",
   "by": "Stream R2b",
   "title": "Merge R2b, deploy staging, run the after-deploy commands, then look at the panel",
   "detail": "Branch claude/stream-r2b-a21dc7 (eight R: commits on master's head). After the deploy, in order, inside the container: backfill threads (rings the legacy mentions; the hand-emitted row is not doubled), discussion rethread --check (read the lines; edit import/rethread-2026-09-13.json first if a move is wrong), discussion rethread --apply once. Then in a browser: the speech-bubble control beside the bell, the tabs, Show resolved, the search, Start a topic, Resolve with a closing word on the threads the map lists as ready (structure q-single, q-thresholds, q-family, schema, q-urls, urls, the fifteen quote lines). No browser reached the stream session.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/handoff.md"
   ],
   "due": "2026-09-14",
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-13",
   "resolved": {
    "by": "Stream R2b",
    "on": "2026-09-14",
    "note": "merged, deployed and the commands run on 14 Sep; rian looked at it in the browser and iterated through the evening (the handoff of 14 Sep)"
   }
  },
  {
   "id": "issue-fold-the-discussion-workflow-into-the-interaction-standa",
   "kind": "issue",
   "created": "2026-09-14",
   "by": "Stream R2b (14 Sep)",
   "title": "Fold the discussion workflow into the Interaction Standard and the caddie-ui pack",
   "detail": "The model built on DFP on 14 Sep (.logs/planning/discussion-workflow-2026-09-14.md §2, §3): three states (Open, Resolved with an outcome Done or Later, Archived), one hand-off (an ask: for one person, a note, a turn notification, done by them or a curator), one acknowledgement (Got it on a comment, which completes the person's asks), the closing word as the record, and the counts rule (needs you leads, unread seeded once). For the standard: §4.1 states and outcome, the ask and the acknowledgement as the standard's shapes, §6 the tabs. For the pack: the resolved style without the strike-through, a status line under a resolved card, the ask chip, the Got it thumb, and a Composer mention picker (the earlier request) so DFP's MentionScope can go.",
   "owner": "caddie",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/discussion-workflow-2026-09-14.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-14"
  },
  {
   "id": "do-adopt-catalog-queries-live-listings-in-the-five-readers",
   "kind": "do",
   "created": "2026-09-15",
   "by": "Stream L",
   "title": "Adopt catalog_queries.live_listings() in the five readers that query Listing directly",
   "detail": "An ignored listing (listings.ignored_at, set from /collectors) is skipped by catalog_queries.py and the collectors page, but services/trip.py (the two Listing selects), routers/catalog.py (stats, the multi-airport filter), services/indexnow.py, services/coverage.py and services/audit.py query Listing directly and still see it: it reaches the trip comparison, the home page counts and IndexNow pings. Each adds .where(catalog_queries.live_listings()) to its Listing reads. Owner: the trip/SEO lane; not Stream L's files.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "do-recrawl-extime-paris-on-bwlive-for-the-fragments-in-an-a",
   "kind": "do",
   "created": "2026-09-15",
   "by": "Stream L",
   "title": "Recrawl Extime Paris on bwlive for the fragments, in an announced window, after the Stream L chain deploys",
   "detail": "The one stale shop that has not refused us: 6,836 of CDG's 7,036 listings have no fragment because run 115 (4 Sep) predates raw_records, so their listed columns are NULL on the Listings table. The window file has the commands in order (collect extime-paris with tee to .logs/runs/, verify --n 20 --mode after_collection, then the staging refresh, backfill listed and suggest); three to five hours at the 1.5 s floor; no deploy inside the window; nothing runs until rian opens it.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/runs/window-2026-09-15-extime-fragments.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "do-the-partnership-asks-for-the-five-shops-that-refused-us",
   "kind": "do",
   "created": "2026-09-15",
   "by": "Stream L",
   "title": "The partnership asks for the five shops that refused us (A16 and Decision 3)",
   "detail": "heinemann-global, iceland-duty-free, sydney-duty-free and bordershop-scandinavia refused by robots.txt on 23 Aug (runs 52 to 55: Disallow /en/global/search/results; A16 owns the ask); dubai-duty-free answers 403 to our declared identity (verification check 396, 11 Sep; Decision 3: no bypass, the ask at Cannes). Until an ask lands, their prices stay frozen at 21 to 22 Aug and the collectors page reads 'refused since <date>'.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "decide-the-four-heinemann-family-shops-and-dubai-stay-uncrawled",
   "kind": "decide",
   "created": "2026-09-15",
   "by": "Stream L",
   "title": "The four Heinemann-family shops and Dubai stay uncrawled, frozen at their 21 to 22 Aug prices",
   "detail": "No collector contacts a host that refused us (COLLECTORS.md posture; A16; Decision 3). Their listings keep their dated prices (hidden or shown with the date by the publication rules) and the collectors page says 'refused since <date>'. Options: (a) the partnership asks at Cannes, then a permission record and a run; (b) accept the frozen data and hide the five shops. Proceeding under (b) frozen: nothing here changes either way, and no stream runs them.",
   "owner": null,
   "priority": null,
   "options": [
    "the partnership asks at Cannes",
    "accept the frozen data"
   ],
   "assumption": "frozen",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "decide-the-database-structure-changes-you-want-after-the-listin",
   "kind": "decide",
   "created": "2026-09-15",
   "by": "Claude (table layout session)",
   "title": "The database structure changes you want, starting with places and shops",
   "detail": "The whole structure is on one page (47 tables, 465 columns, 83 keys between them): the private artifact named in .memory/database-map-artifact.md, and the Relationships block in main/docs/DATA-MODEL.md.\n\nThe first area you flagged is written up in detail, with no plan attached by your instruction: .logs/planning/places-and-shops-2026-09-15.md. It records six gaps - a place has no row of its own (only an IATA code on each shop); two shops at one airport cannot be told apart, and the comparison already counts shop rows in sixteen queries and airport codes in one; one storefront can serve several places (Extime is CDG and Orly); the chain is the free-text operator column for Heinemann's four rows but the retailer row for Avolta's ten; the airport code is in public addresses and the API, not just the code; and opening hours hang off 'the first shop row by id' for want of a place to attach to. It ends with the seven questions a refactor plan must answer and the five standing rules it must not break.\n\nNothing is scheduled. When you have been through your other issues, a session writes the refactor plan against all of them together.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    "https://claude.ai/artifact/3muVVp2ZJanzGcQWTtw7hf",
    ".logs/planning/places-and-shops-2026-09-15.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15",
   "resolved": {
    "by": "Claude (catalogue decisions session, 15 Sep)",
    "on": "2026-09-15",
    "note": "Decided for rian at his request: .logs/planning/catalogue-model-decisions-2026-09-15.md (vocabulary, one ledger, rules v5, review gate, AI as proposer, places direction, rename wave); his to overturn"
   }
  },
  {
   "id": "issue-places-that-are-not-airports-cannot-exist-and-one-is-alr",
   "kind": "issue",
   "created": "2026-09-15",
   "by": "Claude (database map session)",
   "title": "Places that are not airports cannot exist, and one is already collected",
   "detail": "A place IS an IATA code: locations.iata is String(4) and there is no table for the place itself, so hours, write-ups, addresses and the public page are all reached by matching that string. Anything without an IATA code has nowhere to live.\n\nThis already bites in the live data. The BorderShop at Puttgarden, Germany (code SCA, Gebr. Heinemann, DKK) is a real shop at a real place with 1,806 collected listings, and it is hidden - not because anyone decided it should be, but because there is nowhere to say where it is. Adam's cruise ports (St Maarten, St Thomas, Nassau, Cozumel, Mina Rashid, Gibraltar, Livorno, Kai Tak, Marina Bay, Jeju) land in the same hole, as would outlet malls and ferry terminals.\n\nFull analysis in .logs/planning/places-and-shops-2026-09-15.md section 2.1.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/places-and-shops-2026-09-15.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "issue-the-comparison-counts-shop-rows-not-places-so-a-second-s",
   "kind": "issue",
   "created": "2026-09-15",
   "by": "Claude (database map session)",
   "title": "The comparison counts shop rows, not places, so a second shop at one airport would make the savings table lie",
   "detail": "'How many airports stock this product' is asked seventeen times across the services, two different ways. Once by place: the brand-page floor counts distinct(Location.iata), and its docstring says why. Sixteen times by shop row: distinct(Listing.location_id) - the product list, the savings table, search, the /collectors min_airports filter, the image-sourcing rank.\n\nBoth give the same number today only because no airport has two shop rows. The day one does, they diverge silently: a product carried by two Heathrow shops counts as 'at 2 airports' and becomes eligible for the savings table and the compare-across-airports rails with no cross-airport comparison in it at all. Nothing errors; the site just starts making a claim that is not true.\n\nLatent, not live. It must be corrected in the same change that models places, or it ships the moment a second shop is added. See .logs/planning/places-and-shops-2026-09-15.md section 2.2.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/places-and-shops-2026-09-15.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "issue-pack-words-are-stripped-from-spirit-names-so-triple-cask",
   "kind": "issue",
   "created": "2026-09-15",
   "by": "Claude (data model session)",
   "title": "Pack words are stripped from spirit names, so Triple Cask keys as Cask",
   "detail": "lines.py strips a list of pack tokens (pack, twin, trio, duo, triple, tri, kit, giftset) from a name before the line key is computed, which is right for 'Ballantine's Triple Pack 3x1L' and wrong whenever the word is part of the expression. Demonstrated on the live catalogue: '1800 Anejo Triple Cask 40% 1L' keys as 'anejo cask'; 'Artisian Triple Oak VSOP Cognac 1L' as 'artisian oak vsop'; 'Bellevoye Prune - Whisky triple malt' as 'prune'; '10 Year Old Triple Distilled Single Pot Still Irish Whiskey 70cl' as '10 distilled pot still'. 272 live products carry one of these words.\n\nThe risk is a silent wrong merge, not just an ugly name. 'Macallan Triple Cask 12' keys as 'cask 12' while 'Macallan Double Cask 12' keys as 'double cask 12', so a plain 'Cask 12' from any shop would fold into the Triple Cask. Two expressions becoming one product is the failure the identity rules exist to prevent.\n\nLikely fix: strip a pack token only when it sits beside a pack signal (Pack, x3, 3x1L) rather than unconditionally. Needs a test per the standing rule that a bug that shipped becomes a test in main/tests/.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15",
   "resolved": {
    "by": "Claude (catalogue decisions session, 15 Sep)",
    "on": "2026-09-15",
    "note": "Fixed on the branch (identity rules v5, §2.5): a pack word in a liquor name leaves the line only when the name is a pack or a set or the next word is a category word; tests/test_lines.py TestDrinkLines pins eight expressions and four packs"
   }
  },
  {
   "id": "decide-the-line-page-and-the-variation-determinant-split",
   "kind": "decide",
   "created": "2026-09-15",
   "by": "Claude (data model session)",
   "title": "The line page as the primary page, and the variation versus determinant split",
   "detail": "Your proposal: the line page replaces the product page as the thing we optimise for; every determinant and every variation still creates its own product row, so one product keeps one barcode; and new metadata on the variation kind says whether it is a determinant (quantity, concentration, vintage: the shopper picks it and the comparison regenerates) or a variation (shade, flavour: not selectable at all, just listed as what the shop carries, because nobody is purchasing from us).\n\nEvidence gathered 15 Sep, in .logs/planning/variations-and-line-pages-2026-09-15.md. Your price test holds for shade (18 shades of one lipstick, two prices) and breaks for concentration (1 Million EDT and Elixir at the same size are 19 percent apart), so concentration is a determinant by your own rule though the code files it with shade. Keeping shades as separate products costs no comparability: shade-bearing products are the most comparable in the catalogue at 62 percent against 14 percent for products with no variation. The thin-content case for line pages does not hold (3,130 product pages become 2,638 line pages, and 90 percent of those hold one product), but the search-intent case does.\n\nReverses a settled decision: the /products/<name>-<id> address was settled 9 Sep with Mark and Adam, so the page shape goes back to them before anything is built. Six open questions in section 5 of the document, including what price a line page prints for a variation nobody selects.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/variations-and-line-pages-2026-09-15.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15",
   "resolved": {
    "by": "Claude (catalogue decisions session, 15 Sep)",
    "on": "2026-09-15",
    "note": "Decided (§2.4, §2.7 of .logs/planning/catalogue-model-decisions-2026-09-15.md): kinds carry a behaviour (determinant or descriptor) in code; the line page is a recommendation rian takes to Mark and Adam (new do item)"
   }
  },
  {
   "id": "issue-the-flavor-variation-kind-is-landing-on-confectionery-gi",
   "kind": "issue",
   "created": "2026-09-15",
   "by": "Claude (data model session)",
   "title": "The flavor variation kind is landing on confectionery gift boxes, not on flavours of a line",
   "detail": "Of 158 products carrying a flavor variation, exactly one is comparable across airports. The cause is misclassification rather than the model: the kind is firing almost entirely on Paris confectionery, where the flavour word is part of a one-off product's identity and not a variation of a line. '15 Fine Chocolates Almond Crispy' carries the flavour 'almond praline', has no parsed quantity and exists at CDG alone. The single genuine case works correctly: a Mint Chocolate cream at 1L compares across eight airports.\n\nWorth settling before the variation and determinant split is designed, since flavour is one of the two kinds rian names as presentational. See .logs/planning/variations-and-line-pages-2026-09-15.md section 3.4.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/variations-and-line-pages-2026-09-15.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15",
   "resolved": {
    "by": "Claude (catalogue decisions session, 15 Sep)",
    "on": "2026-09-15",
    "note": "Fixed on the branch (identity rules v5, §2.6): the flavour reader fires only on a marked ' / ' tail; 158 flavour kinds became 3 on the rehearsal, and the marked tail no longer reaches the classifier"
   }
  },
  {
   "id": "do-read-the-catalogue-model-decisions-and-say-which-if-any",
   "kind": "do",
   "created": "2026-09-15",
   "by": "Claude (catalogue decisions session, 15 Sep)",
   "title": "Read the catalogue model decisions and say which, if any, you overturn",
   "detail": "The session you asked for decided the open catalogue questions and built the safe part on claude/table-layout-db-structure-1d5bc2: the words (section 1 and main/docs/VOCABULARY.md), one ledger for every human decision (2.2), identity rules v5 with the shade and marked flavour out of the line key and the pack words kept (2.3, 2.5, 2.6), the behaviour registry (2.4), the review gate as a CLI (2.8), and the direction for AI (2.9), the listed-changed tripwire (2.10), places (2.11) and the rename wave (2.12). Section 5 has the numbers the rehearsal produced. Every decision is yours to overturn; the cost of each reversal is named. When you merge, run the v5 block in RUNBOOK.md before the first desk session.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/catalogue-model-decisions-2026-09-15.md"
   ],
   "due": "2026-09-17",
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-15"
  },
  {
   "id": "do-take-the-line-page-recommendation-to-mark-and-adam",
   "kind": "do",
   "created": "2026-09-15",
   "by": "Claude (catalogue decisions session, 15 Sep)",
   "title": "Take the line page recommendation to Mark and Adam",
   "detail": "Section 2.7 of the decisions: the line page becomes the primary public page at /products/<line-slug>; product pages stay, canonicalised to their line and later redirected with the product preselected by fragment; determinants selectable, descriptors listed; a line card prices its representative product and never a range; a line publishes when one of its products does. This reverses the 9 Sep address decision, so nothing is built until they agree.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/catalogue-model-decisions-2026-09-15.md"
   ],
   "due": "2026-09-24",
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15",
   "resolved": {
    "by": "rian",
    "on": "2026-09-16",
    "note": "Decided 16 Sep: /products/ shows the line. Rian is commenting to Mark that he is proceeding unless Mark says no. Section 2.7 of the decisions document revised."
   }
  },
  {
   "id": "issue-the-spanish-shelf-words-labios-rostro-ojos-unas-are-unkn",
   "kind": "issue",
   "created": "2026-09-15",
   "by": "Claude (catalogue decisions session, 15 Sep)",
   "title": "The Spanish shelf words (Labios, Rostro, Ojos, Unas) are unknown to the taxonomy, so 384 marked-shade beauty rows have no category and keep their shade in the line",
   "detail": "Section 5 of the decisions. Every CHANEL Rouge Allure row carries the marked shape (ROUGE ALLURE 3.5gr / 104 PASSION) but no category, because the shop's shelf is 'Labios' and the name has no English makeup word; the shade reader is gated on Makeup, so the 59 lines stayed 59. Labios and Unas are pure makeup shelves in the data; Rostro (230 of 533 names carry a skincare word) and Ojos mix creams with makeup, and classify() lets a shelf hint beat every name word, so mapping the shelves would file creams as makeup and read their skin-type tails as shades. The fix is a rule that lets a skincare name word beat a shelf hint, with those rows as its test; then backfill lines and rederive collapse the 384.",
   "owner": "A",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/catalogue-model-decisions-2026-09-15.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "issue-extime-and-avolta-mark-a-shade-in-the-name-without-a-sep",
   "kind": "issue",
   "created": "2026-09-15",
   "by": "Claude (catalogue decisions session, 15 Sep)",
   "title": "Extime and Avolta mark a shade in the name without a separator, so their Makeup lines stay split by shade",
   "detail": "Extime writes ' - 447 Mellow Shade' and Avolta a bare '01' before the size ('Lash Idole Mascara 01 Midi 5ml'); neither is read (a rule guessing a shade from a number would read N°5 and Brush 13 as shades). Extime's makeup has no raw records until the recrawl. Add a reader per shape once its fragments exist to test against; empty beats guessed until then (decisions 2.3).",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/catalogue-model-decisions-2026-09-15.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "issue-the-brand-page-floor-counts-ignored-listings-and-the-col",
   "kind": "issue",
   "created": "2026-09-15",
   "by": "Claude (catalogue decisions session, 15 Sep)",
   "title": "The brand-page floor counts ignored listings, and the collectors page labels the parsed concentration as the variation",
   "detail": "Two small readers found while surveying for the catalogue decisions: catalog_queries._house_counts joins listings without live_listings(), so an ignored listing still counts towards the three-products-at-two-airports floor; collector_view._ours_of emits attributes.concentration under the key 'variation' (collector_view.py near line 731), so the products table shows the raw parsed concentration where the standard variation belongs.",
   "owner": "B",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "issue-three-makeup-products-sit-in-the-liquor-vertical",
   "kind": "issue",
   "created": "2026-09-15",
   "by": "Claude (catalogue decisions session, 15 Sep)",
   "title": "Three Makeup products sit in the liquor vertical",
   "detail": "Products 11055 (Benefit GoGotint), 11321 (Chachatint) and 11729 (High Beam) carry category Makeup and vertical liquor; 7307 (an Eight Hour Cream set) is Skincare under liquor. resolve_vertical kept a collector's claimed vertical over the category. Found on the 15 Sep staging copy; a backfill vertical --check should list them.",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "issue-no-undo-for-a-confirmed-alias-or-merge-build-unalias-and",
   "kind": "issue",
   "created": "2026-09-15",
   "by": "Claude (catalogue decisions session, 15 Sep)",
   "title": "No undo for a confirmed alias or merge: build unalias and unmerge from the recorded moves before any 200-row desk batch",
   "detail": "A brand or line confirm re-lines, re-keys and folds by bulk UPDATE; product_merges.detail snapshots the two product rows but not the listing ids that moved, and nothing reverses an alias or a merge. A wrong confirm in a batch of 200 is reversible only by hand. Record the moved listing and award ids in the merge detail, build app.cli unalias brand|line <id> and unmerge <merge_id> that reverse from the records and re-key, and lower BATCH_MAX for brand confirms until they exist (decisions section 4, section 6).",
   "owner": "L",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/catalogue-model-decisions-2026-09-15.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "issue-the-staging-refresh-preserves-the-client-s-tables-and-no",
   "kind": "issue",
   "created": "2026-09-15",
   "by": "Claude (catalogue decisions session, 15 Sep)",
   "title": "The staging refresh preserves the client's tables and none of the catalogue's decisions",
   "detail": "CLIENT_WRITTEN_TABLES covers discussions, quotes, to-dos and notifications; overrides, brands.canonical_id, product_lines aliases, product_merges, merge_candidates decisions and listing pins are dropped with the schema and rebuilt only as far as the after-deploy chain re-derives them. Any desk session on staging before a refresh from production is lost. Either the ledger, the aliases and the pair decisions join the preserved set under natural keys, or the first real desk session waits for production to carry the chain (decisions section 4, section 6).",
   "owner": "E",
   "priority": "P2",
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/catalogue-model-decisions-2026-09-15.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "issue-the-proposal-files-under-import-proposals-carry-staging",
   "kind": "issue",
   "created": "2026-09-15",
   "by": "Claude (catalogue decisions session, 15 Sep)",
   "title": "The proposal files under import/proposals carry staging row ids and must never be filed on production",
   "detail": "import/proposals/2026-09-15-brands.json and -lines.json were written from a copy of staging; on production the same ids are other brands and lines, and the RUNBOOK's b3c4 block lists the propose commands as if they were portable. Ticking 'all proposed' on production would alias unrelated rows as rian's decision. The RUNBOOK now warns; the durable fix is a proposals file keyed by natural keys (brand slug, line key) or stamped with the database it was written from, which propose refuses to apply elsewhere.",
   "owner": "L",
   "priority": "P1",
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/catalogue-model-decisions-2026-09-15.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "issue-carry-the-shop-s-variant-field-structurally-from-the-col",
   "kind": "issue",
   "created": "2026-09-15",
   "by": "Claude (catalogue decisions session, 15 Sep)",
   "title": "Carry the shop's variant field structurally from the collector instead of reading back the ' / ' the Shopify collector wrote",
   "detail": "The shade rule reads the separator the Shopify collector itself inserts between the title and the variant option, so whether a shade leaves the line depends on the platform, not the product (Attenza's ' / 99 Pirate' leaves; Extime's ' - 99 Pirate' stays), and every new platform needs a shape rule. Add a variant field to the collector's output (Shopify option2 or title, Extime's label), write it to listed_variant, and have the line rule strip that; the tail regex stays only as the fallback for fragments already stored (decisions section 2.3).",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/catalogue-model-decisions-2026-09-15.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "issue-tailed-beauty-rows-outside-makeup-carry-a-variation-of-u",
   "kind": "issue",
   "created": "2026-09-15",
   "by": "Claude (catalogue decisions session, 15 Sep)",
   "title": "Tailed beauty rows outside Makeup carry a variation of unknown kind (eyewear colours and frame shapes among them)",
   "detail": "Under rules v5 a marked tail on any beauty row is a variation; the kind is color only where the category is Makeup. The 384 uncategorised tailed rows include Attenza's 'Lentes' shelf, whose tails are colours (plateado, dorado, negro) and frame shapes (rectangularsquared, pilotnavigator). A shape is not a colour; the kind stays unknown until the taxonomy knows the shelf or a person names it, and an unknown kind is shown, never picked. Resolve with the Spanish-shelves issue.",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "issue-confectionery-flavour-is-read-only-where-a-shop-marks-it",
   "kind": "issue",
   "created": "2026-09-15",
   "by": "Claude (catalogue decisions session, 15 Sep)",
   "title": "Confectionery flavour is read only where a shop marks it, so the same crisps on two platforms are two lines until the structural variant field lands",
   "detail": "The v5 flavour reader fires on the marked ' / ' tail only (the word rule tagged 156 one-offs). On Heinemann and Extime a flavour stays in the line ('Pringles Sour Cream & Onion' -> 'sour cream onion'); on a Shopify shop it leaves ('Pringles 165g / Sour Cream & Onion' -> 'pringles' + flavour). A rule-derived corroboration (a flavour word leaves the line only when two products of one brand share the remaining key and differ only in it) would fold them without guessing; it is testable on the Lindor rows and never fires on a one-off gift box. Decide with the first food shop.",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "issue-the-heinemann-collector-falls-back-to-manufacturername-a",
   "kind": "issue",
   "created": "2026-09-15",
   "by": "Claude (catalogue decisions session, 15 Sep)",
   "title": "The Heinemann collector falls back to manufacturerName as the brand, which mints maker rows the desk could offer as brands",
   "detail": "collected.py and heinemann_platform.py read manufacturerName when the feed's brand field is empty, so a distillery or a licensee can become a brand row (the glossary's brand test is the name on the front of the pack, and who makes it is a different fact we do not record). Empty beats guessed: keep the manufacturer as a listed fact only, and until then flag rows minted from it so a suggested match never folds a brand into its maker.",
   "owner": "A",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-15"
  },
  {
   "id": "issue-ai-help-to-tell-a-new-line-from-a-member-of-a-line-and-t",
   "kind": "issue",
   "created": "2026-09-16",
   "by": "Claude (decisions walk-through, 16 Sep)",
   "title": "AI help to tell a new line from a member of a line, and to catch spelling splits",
   "detail": "Rian, 16 Sep: 'There's going to be lots of cases where it's not clear whats a line vs variation, and lots of cases where there's different spellings. We need a system to merge them all.' He expects lines to collapse much further once this is worked, and the evidence supports him (the shade rule alone took CHANEL Rouge Allure from 59 lines to 17).\n\nThe Johnnie Walker search shows both classes in one screen. Spelling splits: 'Johnnie Walker Blue 1L' sits in a line 'Blue' apart from Blue Label; 'Joh. Walker Blue Umami' sits apart from 'Blue Label Elusive Umami' because 'Joh.' is not read as the brand. Line versus member: whether the Chinese New Year Lunar bottle (491 dollars) is a new line or Blue Label in collector packaging cannot be read from the listing; King George V (557 dollars) is clearly its own line.\n\nFits the decided AI shape (section 2.9: a model proposes, a person confirms, a model never writes). Two proposal types to design: a pair proposal (these two lines are one, landing in the existing suggestion queue with reason ai) and a membership proposal (this line belongs under that one as a member, or stays its own). Price, barcode and the listed words are its evidence; a person's decision is never touched. Rules still do the bulk; the model is for the ambiguous tail.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/catalogue-model-decisions-2026-09-15.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-16"
  },
  {
   "id": "issue-at-the-go-live-flip-individual-product-pages-start-being",
   "kind": "issue",
   "created": "2026-09-16",
   "by": "Claude (decisions walk-through, 16 Sep)",
   "title": "At the go-live flip, individual product pages start being indexed before line pages exist",
   "detail": "Superseded as the plan on 16 Sep: rian decided the line page replaces the product page BEFORE launch, so no product page is public at the flip. Kept only as the fallback if the build slips past Friday: keep product pages out of the sitemap and send noindex until the line page ships, so the decision does not harden first.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/catalogue-model-decisions-2026-09-15.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-16"
  },
  {
   "id": "issue-collected-brand-products-brand-is-mostly-redundant-and-t",
   "kind": "issue",
   "created": "2026-09-16",
   "by": "Claude (decisions walk-through, 16 Sep)",
   "title": "Collected brand (products.brand) is mostly redundant, and the public site still reads it",
   "detail": "Rian, 16 Sep: 'I dont know why we need collected brand... what is the purpose of collected brand if we already have alias?' Traced: products.brand is the brand text of whichever shop first created the variation, written once at ingest and never updated. It predates the listed layer (14 Sep), when it was the only raw brand text kept. Four listings where the shop said Rabanne sit on a variation whose collected brand says Paco Rabanne.\n\nIt still does two jobs. It is the only brand text for 12,673 of 23,771 listings, which have no stored shop words yet. And the public site reads it: the page's JSON-LD brand, the brand link on the page, the bottle mark, and search suggestions and search (seo.py, catalog_queries.py), so shoppers see one shop's spelling rather than the brand's chosen name (see issue-product-cards-and-pages-show-the-brand-as-one-shop-spelt).\n\nDirection: the product pages built before launch read the brand row's name, never this text; once every listing carries its shop words, drop or demote the column in the rename wave. The table label 'Collected brand' misleads meanwhile, since it is not this listing's collected text.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-16"
  },
  {
   "id": "decide-the-consistency-pass-one-term-per-concept-in-the-databas",
   "kind": "decide",
   "created": "2026-09-16",
   "by": "Claude (decisions walk-through, 16 Sep)",
   "title": "The consistency pass: one term per concept in the database, code, labels and speech",
   "detail": "Rian's rule, 16 Sep: the technical language in the database must match what he is told in labels and conversation; one term per concept, and which term matters less than there being only one. He asked that a deep-reasoning session choose the specific terms.\n\nSection 2.12 of the catalogue decisions lists the thirteen concepts that carry more than one word today (product line, product variation, option, alias versus canonical_id, the brand versus house, merge versus fold, suggestion versus merge_candidates, picked/shown versus determinant/descriptor, quantity versus size, shop versus location versus source, place, Collected brand, and collected_value holding the rules' value). The pass picks one word each, renames columns and identifiers to it in a rename-only migration, and shrinks the glossary's translation column to nothing.\n\nWorking terms until then: product line and product variation, never a bare 'product'. Timing: the column renames after Cannes; the labels and prose can move with the product-line page build before launch.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/catalogue-model-decisions-2026-09-15.md",
    "main/docs/VOCABULARY.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-16"
  },
  {
   "id": "issue-a-product-variant-can-carry-only-one-option-besides-its",
   "kind": "decide",
   "created": "2026-09-16",
   "by": "Claude (decisions walk-through, 16 Sep)",
   "title": "Options and properties as open lists of kinds, so the model scales to clothing, food and beyond",
   "detail": "Rian, 16 Sep, rejecting 'not needed for launch': 'I dont like that we're setting ourselves up for limitations in the future like clothing. I want to make the database scalable to other areas, that was one of my big reasons for doing this now.' So this is a requirement of the model decisions now, not a later issue.\n\nThe limit, traced: a product variant's identity has four slots (brand | product line | ONE option value | quantity) and the option carries one kind. Properties are fixed columns on the variant (abv, country_of_origin, category, is_exclusive), so every new category's facts (a garment's material, a food's allergens) would need a new column and migration. Both halves hard-code today's categories.\n\nDirection to decide in the model review: one open list of attributes per variant, each a (kind, value) pair, with a registry of kinds per vertical (the pattern already used for variation kinds and proposed for place kinds) saying for each kind whether it is an OPTION (tells siblings apart, part of identity, picked or shown on the page) or a PROPERTY (a fact about the variant, never identity). A new category is then a registry entry, not a migration. The products.attributes JSONB column already exists, so the storage need not be rebuilt. The product line page built before launch should read options as a list from the start, so it does not hard-code one slot.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-16"
  },
  {
   "id": "issue-abv-differs-between-variants-of-one-product-line-in-28-l",
   "kind": "issue",
   "created": "2026-09-16",
   "by": "Claude (decisions walk-through, 16 Sep)",
   "title": "ABV differs between variants of one product line in 28 lines, and ABV cannot keep two variants apart",
   "detail": "Rian asked whether ABV is an option or a property. Usually a property (Blue Label is 40 percent at every size), but 28 product lines hold variants at different strengths: 'Reposado' 38 percent 700 ml and 40 percent 1 L; 'Espe.Repos' 35 and 38 percent at 1 L; 'Danzka' 40 percent 4 L and 44 percent 1 L. Causes: the same spirit bottled at different strengths for different markets, a product line name too generic to separate two drinks ('3', 'Coffey'), or a shop's typo.\n\nThe risk: ABV is not in the identity key and not a veto (ingest only copies it onto a variant that lacks one), so two variants without barcodes that differ only in strength at the same size key the same and would merge silently. Belongs with the options-and-properties decision: a kind the registry marks as a property in one vertical may need to veto a match.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-16"
  },
  {
   "id": "issue-keep-separate-does-not-stop-the-automatic-merge-from-fol",
   "kind": "issue",
   "created": "2026-09-16",
   "by": "Claude (decisions walk-through, 16 Sep)",
   "title": "Keep separate does not stop the automatic merge from folding the pair later",
   "detail": "Found tracing rian's scenario (16 Sep): two product variants that look alike but are genuinely different, such as a spirit bottled at 40 percent for one market and 43 percent for another. Rian keeps them separate. merge_session records kept_apart on the suggestion row, and that only stops the pair being suggested again.\n\nThe automatic fold (merges.merge_duplicates, run by backfill merges, rederive and after every confirmed alias) never reads kept_apart. It refuses a group only on two different barcodes, set against single, an unstated quantity, or a disagreement inside products.attributes. ABV is a column outside attributes, so it cannot refuse. Two such variants with no barcodes and the same size fold on the next re-key, silently undoing a person's decision; that breaks the standing rule that a human value is never overwritten by a machine.\n\nFix belongs with the attribute decision: the fold must honour every kept_apart pair, and when a person keeps two variants separate because of an attribute, that attribute kind should decide sameness for that product line (a per-line exception a person sets). With different barcodes, as market versions usually have, the barcode rule already protects them.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-16"
  },
  {
   "id": "decide-grouping-and-attributes-by-an-ai-proposed-review-sheet-i",
   "kind": "decide",
   "created": "2026-09-16",
   "by": "Claude (decisions walk-through, 16 Sep)",
   "title": "Grouping and attributes by an AI-proposed review sheet, instead of rules at collection time",
   "detail": "DECIDED by rian, 16 Sep, as a principle: automate only what is certain (a clearly stated attribute); where a rule could be wrong, keep the raw text in the most relevant plain field (the variant name) and leave the structured field empty; an AI pass with reasoning proposes brands, product lines, variants and attributes; a person confirms or corrects; a confirmed listing is not re-reviewed unless its listed words change, never for a price change.\n\nHis plan: all of it before Friday's launch. Rebuild on scalable attributes, re-derive existing data certain-only, build the AI corrector and its dashboard, then review. Walk-through W11 lists the nine parts in dependency order, the facts that constrain them (no model provider is wired in; an API key would be readable by the database container until the app-only env file exists; staging decisions are wiped by a refresh; no undo; Mark has not answered), and three candidate cut lines for a reasoning session to weigh.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/catalogue-walkthrough-2026-09-16.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-16"
  },
  {
   "id": "issue-the-production-seed-step-swallows-restore-errors-and-ass",
   "kind": "issue",
   "created": "2026-09-16",
   "by": "Claude (decisions walk-through, 16 Sep)",
   "title": "The production seed step swallows restore errors and assumes an empty database",
   "detail": "Rian plans to push the whole staging database to production for the soft launch (walk-through W17). The documented path, deploy/production.sh --seed-db, runs pg_restore --clean --if-exists --no-owner and ends in '|| true', so any restore error is ignored, and RUNBOOK describes it as first deploy only into an empty database. Production is not empty and is on the 0.35.0 schema.\n\nNeeded for the push: drop and recreate the schema as the staging refresh does, stop on any error, deploy matching code first and check alembic current, compare row counts, take a production dump first as the rollback, revoke every session and unused account token after the restore (staging holds 14 live sessions that would otherwise be accepted on production), and confirm source settings.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-16"
  },
  {
   "id": "decide-every-page-reachable-on-discovery-noindex-by-default-ind",
   "kind": "decide",
   "created": "2026-09-16",
   "by": "Claude (decisions walk-through, 16 Sep)",
   "title": "Every page reachable on discovery, noindex by default, indexing approved by a person",
   "detail": "Rian, 16 Sep, for brand, product line and airport pages: every page gets an address the moment it is discovered, so every comparison is usable at once; pages are noindex by default; the quality review merges pages and a confirmed alias fixes a final slug with old addresses redirecting; candidate rules (the 9 Sep floor of 3+ variants at 2+ airports) suggest pages for indexing on a dashboard, and a person approves each.\n\nLargely additive: the page head already takes a noindex flag, merged-away variants already 301, and an alias row is already a forwarding address. Resolves the 9 Sep reason for holding single-price products, since noindex keeps thin pages out of search. Open for a reasoning session and Mark: noindex,follow rather than the existing noindex,nofollow; flat redirects; sitemap and IndexNow limited to indexed pages; de-indexing by suggestion only; whether hidden remains; category and article pages. Walk-through W18.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/planning/catalogue-walkthrough-2026-09-16.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-16"
  },
  {
   "id": "issue-pin-lines-py-s-concentration-table-to-review-process-md",
   "kind": "issue",
   "created": "2026-09-16",
   "by": "Stream K0",
   "title": "Pin lines.py's concentration table to REVIEW-PROCESS.md section 1 item 6 with a test",
   "detail": "File: main/tests/ (a new pure-logic test) reading main/app/services/lines.py _VARIATION_RULES, _CONCENTRATIONS, _QUALIFIERS and VARIATION_DISPLAY. Change: a test that fails when the code's synonym set differs from the list in main/docs/REVIEW-PROCESS.md section 1 item 6 (Eau de Parfum: eau de parfum, EDP; Eau de Toilette: eau de toilette, EDT; Eau de Cologne: eau de cologne, EDC, cologne; Parfum: parfum, perfume, extrait, extrait de parfum, essence de parfum; Elixir: elixir, élixir, elixir de parfum; Mist: mist, body mist, hair mist, brume, bruma; qualifiers Intense, Extreme, Absolu with their spellings). Why: the doc is the authority for the certain boundary and changing it is a rules version; without a test the code and the doc drift apart silently, which is the failure the boundary exists to stop. K0 wrote the doc's list from the code as it stands on 16 Sep, so today they agree.",
   "owner": "K3",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "K3.2"
   ],
   "weight": "costly",
   "updated": "2026-09-16",
   "resolved": {
    "by": "Stream K3",
    "on": "2026-09-16",
    "note": "tests/test_certain_boundary.py holds REVIEW-PROCESS.md section 1 item 6 and product_lines._ATTRIBUTE_RULES equal, both ways (K3.2, d34240d)"
   }
  },
  {
   "id": "decide-the-spot-check-thresholds-in-review-process-md-section-4",
   "kind": "decide",
   "created": "2026-09-16",
   "by": "Stream K0",
   "title": "The spot-check thresholds in REVIEW-PROCESS.md section 4 are K0's defaults",
   "detail": "Read by rian 17 Sep and kept for today's pass: the five lowest-confidence proposals per brand, every proposal below 0.7 confidence, a random five percent (at least three), and always any proposal that merges variants or changes a price comparison. On the CHANEL rehearsal that was 8 of 48 rows.\n\nBeside the sheet, rian wants to keep eyeballing raw rows himself in a table he can filter and scan, which the review area already has: /collectors carries Product variants and Listings (the wide table with the column picker, full screen and CSV). The sheet is for approvals; the tables are for looking. Any later review surface keeps both.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-17"
  },
  {
   "id": "issue-the-test-pinning-the-concentration-table-now-reads-produ",
   "kind": "issue",
   "created": "2026-09-16",
   "by": "Stream K1",
   "title": "The test pinning the concentration table now reads product_lines.py, not lines.py",
   "detail": "K1 renamed lines.py to product_lines.py and its functions (attribute_of, attribute_kind_of, canonical_attribute, display_attribute; ATTRIBUTE_KINDS, ATTRIBUTE_RULES, _ATTRIBUTE_RULES). The earlier K0 issue (issue-pin-lines-py-s-concentration-table...) names the old module; write the test against the new names. REVIEW-PROCESS.md's Sources line already says product_lines.py.",
   "owner": "K3",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [
    "K3.2"
   ],
   "weight": "info",
   "updated": "2026-09-16",
   "resolved": {
    "by": "Stream K3",
    "on": "2026-09-16",
    "note": "the pin test reads product_lines.py (tests/test_certain_boundary.py, d34240d)"
   }
  },
  {
   "id": "issue-a-decided-shade-attribute-color-loses-its-kind-at-the-ba",
   "kind": "issue",
   "created": "2026-09-16",
   "by": "Stream K4",
   "title": "A decided shade (attribute:color) loses its kind at the batch tail: decisions verify reports drift",
   "detail": "File: main/app/services/keying.py, product_key (HEAD lines ~277-288; K3 holds the file in flight). It reads decided['attribute'] (the shim's legacy name for any attribute:<kind>) for the VALUE but takes the KIND from maps.attribute_kind(name), the rule. When the rule reads the shade words but names no kind (CHANEL 'ROUGE ALLURE' / '3.5gr / 196 A DEMI-MOT' on attenza PTY), the batch tail's rekey writes {attribute: value} with no attribute_kind, so attributes.get(variant, 'color') is None and decisions verify reports 1 drift. Reproduced on a clean export of HEAD c295416 against dfp_k4 (the nightly of 16 Sep with K1 and K2 applied): one individual approval of attribute:color, verify 1 checked 1 drift.\n\nChange: when the value comes from a decided attribute:<kind> row, set attribute_kind to that kind (the row's field suffix), not the rule's. Needs the decided row's field on the legacy view. A test: decide attribute:color on a variant whose name the rule reads without a kind; rekey; attributes.get(v, 'color') equals the decision.",
   "owner": "K3",
   "priority": null,
   "options": [],
   "assumption": "K4 proceeds; the CHANEL rehearsal reports verify drift for any shade decision until this lands, and says so in the handoff.",
   "links": [],
   "due": null,
   "blocks": [
    "K4.5"
   ],
   "weight": "costly",
   "updated": "2026-09-16",
   "resolved": {
    "by": "K8",
    "on": "2026-09-17",
    "note": "product_key takes the kind from the decided row's field suffix, not the rule; the rule still answers where nothing was decided; test_decided_attribute_kind.py pins both (fc187f8)."
   }
  },
  {
   "id": "issue-replay-parks-a-name-decision-on-a-product-line-a-sheet-a",
   "kind": "issue",
   "created": "2026-09-16",
   "by": "Stream K4",
   "title": "Replay parks a name decision on a product line a sheet approval minted (LINE_MISSING)",
   "detail": "File: main/app/services/decisions/replay.py (K2). A sheet approval that mints a line records its name decision with natural_key line:<the new uid> (natural_keys.build of the row), detail {brand_slug, slug, key: decided:<slug>}. On the second host the uid is unknown and no row holds the slug, so natural_keys.resolve parks LINE_MISSING; replay only calls _mint_line when the key starts line:new:, which a recorded decision never does. Every membership naming that line then parks too (its ref detail cannot resolve).\n\nChange: in replay, when a product_line 'name' row parks LINE_MISSING and its detail key starts with 'decided:' and its brand resolves, mint the line with the SOURCE uid (parsed from natural_key), slug and key from the detail (the same body as _mint_line). Test: a sheet mint on copy A, exported, replayed on B: 0 parked, B's line has A's uid. Adopted lines (an existing row, same uid on both hosts) are unaffected.",
   "owner": "K2",
   "priority": null,
   "options": [],
   "assumption": "K4 proceeds; the CHANEL rehearsal adopts existing lines where it can and reports any parked replay rows this causes.",
   "links": [],
   "due": null,
   "blocks": [
    "K4.5"
   ],
   "weight": "costly",
   "updated": "2026-09-16"
  },
  {
   "id": "issue-a-sheet-line-s-decided-key-and-its-mint-happen-in-the-ap",
   "kind": "issue",
   "created": "2026-09-16",
   "by": "Stream K4",
   "title": "A sheet line's decided key and its mint happen in the approval, not the applier, so replay carries neither",
   "detail": "Files: main/app/services/decisions/appliers.py (product_line name) and replay.py (K2). Spec section 2: the first name decision on a line fixes key = 'decided:' || slug and a sheet line adopts before it mints. The writer's applier for (product_line, name) only sets the name, so K4's approval (services/proposals.py _adopt_or_mint) stamps the key and mints before calling record. Replay applies the recorded name through the applier alone: on the CHANEL rehearsal (dfp_k4 -> dfp_k4b, 74 decisions, 0 parked) every column agreed (929 facts: memberships alias-followed, line names, aliases, pairs, attributes) EXCEPT the 7 approved lines' keys, which stayed rule keys on B, so an arrival on production could still land on an approved line (W10).\n\nChange: move the adopt-or-mint and the decided: key stamp into the (product_line, name) applier's prepare/materialise (the record's detail carries adopted_from_key, slug_was, minted), with the source uid on a mint so replay regenerates the same row; K4's _adopt_or_mint then becomes a call to it. The mint case also covers the earlier issue (replay parks a name decision on a line a sheet minted). An undo of the name should restore the key from detail.adopted_from_key. Test: approve a sheet line on copy A (adopt and mint), export, replay on B: B's key is decided:<slug> and the minted line has A's uid.",
   "owner": "K2",
   "priority": null,
   "options": [],
   "assumption": "K4 ships the approval stamping the key itself; the rehearsal numbers are reported with this one gap named.",
   "links": [],
   "due": null,
   "blocks": [
    "K4.5"
   ],
   "weight": "costly",
   "updated": "2026-09-16"
  },
  {
   "id": "issue-review-process-md-now-reads-version-3-k4-s-section-6-on",
   "kind": "issue",
   "created": "2026-09-16",
   "by": "Stream K4",
   "title": "REVIEW-PROCESS.md now reads Version 3 (K4's section 6) on top of K3's uncommitted version 2",
   "detail": "K4 committed section 6 (the file) and the section 4 wording through the index against HEAD, and wrote the same hunks into the working copy on top of K3's uncommitted rules v6 edits, with one version line naming both: 'Version 3 ... Version 2 the same day: identity rules v6 ...'. When K3 commits the file, keep that line (or bump past it) rather than restoring 'Version 2'; the rehearsal file carries process_version 2 and a new pass should say 3.",
   "owner": "K3",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-16"
  },
  {
   "id": "decide-should-a-product-line-membership-be-a-spot-check",
   "kind": "decide",
   "created": "2026-09-16",
   "by": "Stream K4",
   "title": "Should a product line membership be a spot-check?",
   "detail": "REVIEW-PROCESS.md section 4 item 1 said 'every proposal that merges variants or changes a comparison'. K4 reads 'a comparison' as the price comparison of one variant across shops, so it marks a pair confirmed same, a merged_into, a listing pin and a quantity, and does NOT mark a variant moving from one product line to another (it changes which page shows the variant, not what its price is compared with). Version 3 of the file says so. On the CHANEL rehearsal that left 8 of 48 rows as spot-checks; marking every membership as well would have made 25 of 48.",
   "owner": null,
   "priority": null,
   "options": [
    "Keep it: memberships are approved in bulk unless low-confidence or sampled (as built)",
    "Mark every membership that moves a variant to a different existing product line (one line in proposals.spot_checks and the doc)"
   ],
   "assumption": "Kept as built; the Saturday pass runs under version 3. Changing it is one line of code plus a version bump; rows already approved stay approved.",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-16",
   "resolved": {
    "by": "rian",
    "on": "2026-09-17",
    "note": "Keep as built: a membership is not a spot-check. Marking them would have taken the CHANEL sheet from 8 spot-checks to 25."
   }
  },
  {
   "id": "decide-a-stated-strength-against-a-silent-name-key-apart-built",
   "kind": "decide",
   "created": "2026-09-16",
   "by": "Stream K3",
   "title": "A stated strength against a silent name: key apart (built) or join?",
   "detail": "Identity rules v6 put a percentage the NAME states into the key, as the plan's W9 note says ('the differing ABV is part of that key'). So 'Casamigos Mezcal 40% 1L' and another shop's 'Casamigos Mezcal 1l' no longer join by rule: on the staging copy 84 of the 699 listings that key apart under the chosen boundary do so for this reason alone. Nothing is lost at the next collection (a placed listing stays placed), but a NEW shop's silent listing will not join a variant whose name states the strength until a person confirms it. The alternative keeps the strength out of the key and only vetoes 40 against 43; it joins more, and it glues a silent 'Lindt Excellence 100g' to whichever of the 70% and 85% bars arrived first.",
   "owner": null,
   "priority": null,
   "options": [
    "key apart; a person confirms the pair (assumed, built)",
    "join a silent name to a stated one; veto only a stated difference"
   ],
   "assumption": "key apart. Switching is a rules version (v7): one function (product_lines.identity_slot), a rederive, and the merges read again.",
   "links": [],
   "due": null,
   "blocks": [
    "K7.1"
   ],
   "weight": "costly",
   "updated": "2026-09-16",
   "resolved": {
    "by": "rian",
    "on": "2026-09-17",
    "note": "Keep as built: a stated strength keys apart from a silent name. Rian, 17 Sep: a split a person can fix beats a wrong join nobody sees; the programmatic pass finds what could match, the AI pass judges, the human approves quickly."
   }
  },
  {
   "id": "decide-brand-trailers-still-fold-inside-the-brand-slug-leave-or",
   "kind": "decide",
   "created": "2026-09-16",
   "by": "Stream K3",
   "title": "Brand trailers still fold inside the brand slug: leave, or re-slug the brand rows?",
   "detail": "KEPT for launch by rian, 17 Sep, after asking where in the workflow it happens. The answer he was given, and the record of it: this is not a merge and no AI is involved. normalize.brand_key reduces a listing's brand text and drops a trailing word from a fixed list (Estate, Rum, Distillery, Gin) plus a leading 'The', so 'Appleton Estate' and 'Appleton Rum' compute the same key and resolve to the same brand ROW at collection time. Two rows were never created and nothing was joined.\n\nIt is therefore the last open word list that still ACTS rather than proposing, which is the deviation from the programmatic-then-AI-then-human workflow rian is holding the model to. Kept because stopping it today splits 124 spellings (743 variants, 35 brand rows) into new rows, slugs and addresses days before launch; the compensation is rule:brand_trailers:6, 91 proposals that show each folded spelling for him to confirm on the sheet. Revisit after launch: the fold becomes a proposal like every other list, with a backfill that mints the alias rows and K6's redirects.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-17"
  },
  {
   "id": "decide-two-ingest-rules-k3-added-beyond-its-brief-read-and-obje",
   "kind": "decide",
   "created": "2026-09-16",
   "by": "Stream K3",
   "title": "Two ingest rules K3 added beyond its brief: read and object if wrong",
   "detail": "(1) A person's decision on a variant (its line, an attribute, its quantity) never moves the variant's key. The brief said a decided variant's key is its effective key; built that way, the variant's own listing would key elsewhere at its next sighting and mint a duplicate beside every reviewed variant. The decision still changes what the page reads, and the automatic merge still refuses a group holding a decided variant. (2) A listing already placed stays on its variant while the shop's words for it are unchanged, unless a barcode, a quantity or a stated attribute contradicts. Without it the 699 listings the word lists once joined would each have split off at their next sighting, taking 114 comparisons, silently.",
   "owner": null,
   "priority": null,
   "options": [
    "keep both (assumed)",
    "drop (2): let the rules re-place every listing at every sighting, as before"
   ],
   "assumption": "both kept; each is pinned by a test (tests/test_decided.py, tests/test_arrivals.py).",
   "links": [],
   "due": null,
   "blocks": [
    "K7.1"
   ],
   "weight": "info",
   "updated": "2026-09-16",
   "resolved": {
    "by": "rian",
    "on": "2026-09-17",
    "note": "Keep both. Without them every reviewed variant would spawn a duplicate at the next collection, and 699 listings would have split off silently."
   }
  },
  {
   "id": "issue-a-shop-s-shade-option-option-color-is-registered-picked",
   "kind": "issue",
   "created": "2026-09-17",
   "by": "Stream K5",
   "title": "A shop's shade option (option:color) is registered picked, so the product line page offers shades as a selector",
   "detail": "File: main/app/services/attributes.py register_option (K2's registry; K3 named the options). On dfp_k5 (K3's final copy) CHANEL Rouge Allure L'Extrait's 12 variants carry option:color, and register_option makes every option:* kind display=picked, so get_product_line draws a 12-way shade selector. VOCABULARY.md and REVIEW-PROCESS.md say a shade is shown, never selected. Change: map a shop option whose name means colour or shade (color, colour, shade, tono) to the registry's color kind or give it display=shown; a test on the CHANEL option.",
   "owner": "K3",
   "priority": null,
   "options": [],
   "assumption": "K5 follows the registry's display setting as it stands; the page will list shades as facts once the registry says shown, with no page change.",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-17"
  },
  {
   "id": "decide-should-the-browse-grids-show-one-card-per-product-line-i",
   "kind": "decide",
   "created": "2026-09-17",
   "by": "Stream K5",
   "title": "Should the browse grids show one card per product line instead of one per variant?",
   "detail": "Plan W20 says the representative variant survives only on cards; the brief's K5.4 applies it to cards. K5 kept every grid (browse, airport and brand shelves, home strips, related rail) one card per product variant, each card opening its own comparison on the line page with that variant chosen and its airports, and applied the representative rule where one entry stands for a line (search suggestions, now one per line). One card per line would change pagination, the featured-savings selection and every count the grids print (a line with a 1 L and a 70 cl at different airports has no single comparison to show on a card).",
   "owner": null,
   "priority": null,
   "options": [
    "Keep one card per variant, deep-linked (as built)",
    "One card per product line showing its representative variant's comparison (a grid query change and a count change)"
   ],
   "assumption": "Kept per variant; a card still names its bottle, and the line page shows the rest.",
   "links": [],
   "due": null,
   "blocks": [
    "K5.4"
   ],
   "weight": "costly",
   "updated": "2026-09-17",
   "resolved": {
    "by": "rian",
    "on": "2026-09-17",
    "note": "Keep as built: one card per product variant. Rian also wants a product line CARD later, linking to the line with no price or comparison on it; filed as its own issue."
   }
  },
  {
   "id": "issue-the-sitemap-the-feed-and-indexnow-still-list-variant-add",
   "kind": "issue",
   "created": "2026-09-17",
   "by": "Stream K5",
   "title": "The sitemap, the feed and IndexNow still list variant addresses, which now 301 to product lines",
   "detail": "Files: main/app/services/seo.py sitemap_entries/_product_sitemap_rows (K6's filter), feeds.py product_item, indexnow.py (K6). With LINE_PAGES on, /products/<name>-<id> answers 301 to /products/<line>?variant=<id> (K5.2), so every product URL these three emit is a redirect. Change: list product lines by their bare address (urls.line_path(slug)), only lines K6 has indexed (publish.is_indexed), one URL per line, lastmod the line's newest observation (ProductLineDetail.last_observed_at, or a query over its variants); the feed may keep naming a variant but should link its line path.",
   "owner": "K6",
   "priority": null,
   "options": [],
   "assumption": "K5 leaves the three emitters to K6's sitemap filter task; while every line is noindex the sitemap's product URLs are redirects to noindex pages, harmless before launch.",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-17",
   "resolved": {
    "by": "Stream K6",
    "on": "2026-09-17",
    "note": "K6.4: the sitemap and IndexNow list indexed product lines by their bare address; the feed links the card's line path and keeps the variant guid"
   }
  },
  {
   "id": "decide-the-floor-that-makes-an-airport-or-place-a-candidate-for",
   "kind": "decide",
   "created": "2026-09-17",
   "by": "Stream K6",
   "title": "The floor that makes an airport or place a candidate for indexing: fifteen published variants?",
   "detail": "publish.PLACE_MIN_VARIANTS. A rule only SUGGESTS indexing on /review; you approve each page. Fifteen is the category-at-airport bar already in use. A brand's rule is the 9 Sep floor (3 variants across 2 places); a product line's is one variant compared across 2 places. Changing the number is one constant and a re-run of 'index suggest'; nothing is de-indexed by it.",
   "owner": null,
   "priority": null,
   "options": [
    "fifteen published variants (assumed)",
    "another number",
    "every airport with a visible shop"
   ],
   "assumption": "fifteen",
   "links": [],
   "due": null,
   "blocks": [
    "K6.5"
   ],
   "weight": "costly",
   "updated": "2026-09-17",
   "resolved": {
    "by": "rian",
    "on": "2026-09-17",
    "note": "Keep fifteen published variants; a rule only suggests, rian approves each page."
   }
  },
  {
   "id": "decide-a-brand-s-address-cannot-follow-the-chosen-name-yet-keep",
   "kind": "decide",
   "created": "2026-09-17",
   "by": "Stream K6",
   "title": "A brand's address cannot follow the chosen name yet: keep the fold slug, or add an address column?",
   "detail": "brands.slug is three things at once: the page address, the fold key ingest finds the row by (ingest.resolve_brand), and the ledger's natural key (brand:<slug>, resolved with no redirect). Renaming it at confirm would make the next collection mint the brand again under the old slug and park every replayed brand decision. K6 therefore fixes the final slug at confirm for PRODUCT LINES only (found by key, never by slug); a brand keeps its slug and its alias rows forward to it (301, chain followed). To let /brands/macallan become /brands/the-macallan needs a separate address column (K2 schema) and the brand resolver reading redirects (K2 natural_keys), then publish.slug_at_confirm gains four lines.",
   "owner": null,
   "priority": null,
   "options": [
    "keep the fold slug as the address (assumed)",
    "add brands.address after launch"
   ],
   "assumption": "keep; brand aliases forward through the alias row",
   "links": [],
   "due": null,
   "blocks": [
    "K6.3"
   ],
   "weight": "costly",
   "updated": "2026-09-17",
   "resolved": {
    "by": "rian",
    "on": "2026-09-17",
    "note": "Keep the fold slug for launch. Rian accepts it, and separately asks for a manual slug-rename mechanism with a redirect, for the day a brand renames itself; filed as its own issue."
   }
  },
  {
   "id": "issue-no-cli-expires-unused-welcome-and-reset-links-the-launch",
   "kind": "issue",
   "created": "2026-09-17",
   "by": "Stream K7",
   "title": "No CLI expires unused welcome and reset links; the launch's replace does it with one SQL statement",
   "detail": "The K7 brief names 'accounts tokens purge'; it does not exist (app/cli_accounts.py has sessions revoke and sessions prune, which deletes used and old tokens only). deploy/production.sh --replace-db therefore runs: update account_tokens set expires_at = now() where used_at is null and expires_at > now(), and prints the count. Change: an 'accounts tokens expire --all' command that does the same through the accounts service with an audit_log row, and the script calls it; rehearsed statement: 1 inserted unused token expired, 0 unused after.",
   "owner": "R",
   "priority": null,
   "options": [],
   "assumption": "The SQL statement stands in for the launch; it expires, never deletes, so the audit of who was invited survives.",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-17"
  },
  {
   "id": "decide-after-the-launch-which-machine-collects-production-only",
   "kind": "decide",
   "created": "2026-09-17",
   "by": "Stream K7",
   "title": "After the launch, which machine collects: production only, with staging refreshed from it?",
   "detail": "DECIDED by rian 17 Sep, with a correction to when it starts: production collects ONLY AFTER he declares the launch. Until then the work stays on staging and the next collection run is expected there too; when he is ready the whole staging database is pushed to production and that is the launch.\n\nAfter that, the direction is one-way in practice: production's catalogue is the record and may be synced DOWN to staging, but staging must never overwrite production's catalogue again. The one thing that may want to keep living on staging is the development discussion; how that survives a refresh is left open, and the staging-refresh preserved set is where it would be handled.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-17"
  },
  {
   "id": "decide-the-brands-index-still-lists-only-brands-over-the-floor",
   "kind": "decide",
   "created": "2026-09-17",
   "by": "Stream K6",
   "title": "The brands index still lists only brands over the floor (364, not 1,498): right?",
   "detail": "Every brand now has a page, linked from its product variants. The /brands index could list every brand with a priced variant (1,498 on the 17 Sep staging copy) or stay at the floor (364). Most of the extra 1,134 are single-variant brands and spellings nobody has reviewed yet. One default in catalog_queries.list_brands.",
   "owner": null,
   "priority": null,
   "options": [
    "stay at the floor (assumed)",
    "list every brand"
   ],
   "assumption": "the floor",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-17",
   "resolved": {
    "by": "rian",
    "on": "2026-09-17",
    "note": "Keep the floor for launch: it is what Adam and Mark approved. Rian reserves the argument that every brand could be listed, since all pages are noindex until approved; the cost is noise before the review, not risk."
   }
  },
  {
   "id": "issue-the-slug-hook-is-on-a-path-nothing-calls-the-desk-the-sh",
   "kind": "issue",
   "created": "2026-09-17",
   "by": "Stream K6",
   "title": "The slug hook is on a path nothing calls: the desk, the sheet and replay record an alias without it",
   "detail": "File: main/app/services/decisions/appliers.py (the product_line alias_of applier; K2's file, K3's confirm path, so K6 did not touch it). K3.6 put publish.slug_at_confirm in merges.apply_brand_alias / apply_line_alias, which only tests call. The live paths record alias_of through writer.record directly: the desk's Confirm same (appliers._pair_consequences), the sheet's absorb (proposals._absorb) and replay. So on production no confirmed alias fixes a slug. Change: call the hook from the ('product_line','alias_of') applier's consequences, where every path passes: publish.slug_at_confirm(target_line, (row.detail or {}).get('preferred_name') or target_line.name, decision=row); pass decision=row so the redirect row carries its decision id, and drop the two calls in merges.py. The slug is a pure function of the brand slug and the chosen name, so replay gives the same address on both hosts. K6's function is live and rehearsed through merges.apply_line_alias (dfp_k6: two aliases, old and middle slugs each one 301 to the final). An undo of the alias leaves the new slug and its redirect in place (additive; the address a person chose stays).",
   "owner": "K3",
   "priority": "P1",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-17",
   "resolved": {
    "by": "K8",
    "on": "2026-09-17",
    "note": "The hook moved into the (product_line, alias_of) applier's consequences with decision=row; the two calls in merges.py dropped; the test drives the desk's Confirm same and asserts the redirect row (3fa4678)."
   }
  },
  {
   "id": "issue-redirects-is-keyed-by-from-slug-alone-so-a-brand-slug-an",
   "kind": "issue",
   "created": "2026-09-17",
   "by": "Stream K6",
   "title": "redirects is keyed by from_slug alone, so a brand slug and a product line slug cannot both forward",
   "detail": "File: main/app/models/decisions.py Redirect (primary key from_slug; kind is a plain column). A product line whose words are empty has its brand's slug. publish.redirect_write keeps the older row and logs redirect_kept rather than overwrite another kind's. Change when a migration is next open: primary key (kind, from_slug).",
   "owner": "K2",
   "priority": "P3",
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-17"
  },
  {
   "id": "issue-no-way-to-rename-a-page-s-address-by-hand-with-a-redirec",
   "kind": "issue",
   "created": "2026-09-17",
   "by": "Claude (step D, 17 Sep)",
   "title": "No way to rename a page's address by hand, with a redirect",
   "detail": "Rian, 17 Sep, on the brand-slug decision: 'what if we make a mistake in the slug when we publish it initially? We need a mechanism to be able to change slugs after the fact. EG: Paco Rabanne of the future renames itself, we update the display name, but we also want the new name to have an accurate slug.'\n\nToday there is no path. publish.slug_at_confirm fixes a PRODUCT LINE's slug only when a name is confirmed, and it refuses a page that is already indexed unless a rename_indexed flag is set, which nothing in the app ever sets. A BRAND's slug cannot move at all: brands.slug is the page address, the fold key ingest finds the row by, and the ledger's natural key, so renaming it would make the next collection mint the brand again under the old slug and park every replayed decision (the decision rian kept the same day).\n\nWhat it needs: a CLI and a review-area action that renames a page's address deliberately, writes the redirect (K6's flattening already handles chains), records it as a decision with who and why, and for a brand adds the separate address column and a resolver that reads redirects, which the brand-slug decision already costs at about four lines in publish plus K2's natural keys.",
   "owner": "rian",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-17"
  },
  {
   "id": "issue-a-product-line-card-links-to-the-line-with-no-price-or-c",
   "kind": "issue",
   "created": "2026-09-17",
   "by": "Claude (step D, 17 Sep)",
   "title": "A product line card: links to the line, with no price or comparison on it",
   "detail": "Rian, 17 Sep, accepting that browse grids keep one card per product variant: 'it might make sense to build a product line card as well that simply links to the product line without a price or comparison.' That sidesteps the reason per-line cards were refused (a line with a 1 L at one airport and a 70 cl at another has no single comparison to print), while giving the line a card shape for places where the line, not the bottle, is the thing being shown.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-17"
  },
  {
   "id": "issue-the-brand-trailer-list-is-global-and-there-is-no-way-to",
   "kind": "issue",
   "created": "2026-09-17",
   "by": "Claude (step D, 17 Sep)",
   "title": "The brand-trailer list is global and there is no way to split a brand it folded wrongly",
   "detail": "Rian, 17 Sep: 'are we still using those stop words like estate? in future crawl on other categories like clothing, will it strip words like estate?' and 'maybe the solution is to make sure that when AI is proposing, we're also considering the possibility of mistaken folds and scanning our stop words to see if there are brands or product lines we should be breaking apart instead of folding together.'\n\nThe facts. normalize._BRAND_TRAILERS is 45 words applied to EVERY vertical, written for drinks and beauty; it strips a trailing word at ingest when a listing's brand is resolved, so two spellings never become two rows and no decision records it. Every fold in today's data is right; the exposure is the first category beyond drinks and beauty. Rejecting one of the 91 rule:brand_trailers proposals records a rejection but moves no data, and undo.unmerge works on variants only, so a wrong fold has no way back.\n\nBEING BUILT by stream K9 (brief .logs/planning/streams/K9-brand-split.md, kickoff /stream-k9, tasks K9.1 to K9.7 on /plan), started 17 Sep in its own worktree and branch while rian reviews on staging: the split as one recorded decision with an undo, the rejected proposal offering that split, the list scoped per vertical with nothing for an unlisted one, and the lists and their words shown in the review area.",
   "owner": "K9",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-17"
  },
  {
   "id": "decide-k9-and-k10-are-not-merged-into-master-only-their-briefs",
   "kind": "decide",
   "created": "2026-09-17",
   "by": "Stream K11",
   "title": "K9 and K10 are not merged into master: only their briefs are, so K11 is building on a base that is missing both",
   "detail": "K11's command says to stop if master is missing K9 or K10. The check as written (git log includes 'Stream K9' / 'Stream K10') passes, but those two commits are brief-authoring commits only, exactly like cc0ed23 was for K11: b37be4c changed .claude/commands/stream-k9.md, the K9 brief, issues.md, items.json and progress.json, and 302f2a8 changed the K10 command, the K10 brief and progress.json. Neither carries a line of implementation.\n\nThe implementations sit unmerged on their branches. claude/brand-split (8 commits, tip 4510a57) has main/app/services/brands.py, cli_brands.py, the appliers/undo/writer changes, merges.py, ingest.py, keying.py, test_brand_split.py, AND 32 lines of main/app/services/normalize.py. claude/review-area (6 commits, tip 2b308ec) has components/review/ReviewTabs.tsx, DecisionPanels.tsx, DecisionRules.tsx, FoldingWork.tsx and the ReviewPage.tsx changes. Both branches say 'green and ready' in their own handoffs.\n\nWhat it costs K11. K11.3 edits normalize.brand_key, the same function K9 edits for its per-vertical trailer scoping, so whichever merges second resolves a conflict there by hand. K11.6 is told to mount into K10's tab shell and not to touch it; that shell does not exist in this base, so there is nothing to mount into.",
   "owner": null,
   "priority": null,
   "options": [
    "Merge claude/brand-split and claude/review-area into master, then rian tells K11 to rebase onto the merged master and K11.6 mounts into the real tab shell (what the brief assumes)",
    "Leave them unmerged: K11 lands its own review components standing alone against master's ReviewPage, and whoever merges K9/K10 afterwards resolves normalize.py and ReviewPage.tsx by hand",
    "Pause K11.6 only, land K11.1 to K11.5 and K11.7, and pick the UI up in a follow-up once K9 and K10 are in"
   ],
   "assumption": "Proceeding on option 2 for now: K11.3 changes only the trailer fold in brand_key and leaves K9's list alone so the conflict stays small and mechanical, and K11.6 builds the product line table as its own component mounted into master's ReviewPage rather than inventing a competing tab shell. If K9/K10 are merged first, K11.6's mount point moves, which is a few lines, not a rewrite.",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "blocking",
   "updated": "2026-09-17"
  },
  {
   "id": "decide-61-of-live-variants-carry-no-listed-text-so-no-pass-can",
   "kind": "decide",
   "created": "2026-09-17",
   "by": "Review pass",
   "title": "61% of live variants carry no listed text, so no pass can cite a span for them",
   "detail": "REVIEW-PROCESS section 3 requires every proposed value to cite the listed field and the exact words, and section 1 says a value the raw text does not contain is never proposed. Measured on staging 18 Sep: 10,276 of 16,756 live variants have NO listing with a listed_name, so a pass can propose nothing for them without inventing a citation. By collector: extime 200 of 7,036 listings carry a name (2.8%), bordershop 0 of 1,806, dubai-duty-free 0 of 1,489, heinemann 0 of 1,035, heinemann-iceland 0 of 963, heinemann-sydney 0 of 444. attenza, avolta, ari, the-loop, shilla and ishopchangi are all at 97-100%, so this is per-collector, not a parser fault. The variants DO have names (a Heinemann variant reads 'Glenfid V3 15y 50.2% 0.7L Tube'), so the collector read the text and did not persist it into the listed layer. Effect on this slice: rabanne 65 of 65 variants citable, glenfiddich 31 of 59, paco-rabanne 3 of 54. Until the six collectors populate listed_name (or the pass is allowed to cite the variant name as its source), a review pass can only cover the 39% of the catalogue that has text, and a brand served mainly by those collectors cannot be reviewed at all.",
   "owner": null,
   "priority": null,
   "options": [
    "backfill listed_name for the six collectors from what they already store, then re-run",
    "let a pass cite the variant name as a source where no listing carries one (weaker provenance)",
    "review only the 39% that has text before launch"
   ],
   "assumption": "proceeding on the brands that do have text; paco-rabanne skipped",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "blocking",
   "updated": "2026-09-17"
  },
  {
   "id": "decide-is-a-range-the-brand-markets-as-its-own-glenfiddich-perp",
   "kind": "decide",
   "created": "2026-09-17",
   "by": "Review pass",
   "title": "Is a range the brand markets as its own (Glenfiddich Perpetual Collection) still a member of its age line?",
   "detail": "REVIEW-PROCESS section 2 lists this as the open half of the finish/cask default: 'whether a finish that the brand markets as its own range is still a member'. Glenfiddich is the case. Perpetual Collection Vat 03 states 15 years and Vat 04 states 18, so the aged-spirits default puts them in Glenfiddich 15 and Glenfiddich 18. But Vat 01 and Vat 02 state no age at all, so they would have no home and become their own lines, splitting a range the brand sells as one thing across the catalogue. Either the Perpetual Collection is one product line with the vat as an attribute and the age where stated, or the default wins and Vat 01/02 sit alone. The same question covers Solera (Glenfiddich 15 Solera, sold as bare 'Solera' by two shops).",
   "owner": null,
   "priority": null,
   "options": [
    "Perpetual Collection is its own product line, vat as the attribute",
    "the aged-spirits default wins; Vat 01 and 02 stand alone"
   ],
   "assumption": "no file written for glenfiddich until this is answered",
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "costly",
   "updated": "2026-09-17"
  },
  {
   "id": "issue-the-listings-table-s-brand-differs-flag-compares-the-uns",
   "kind": "issue",
   "created": "2026-09-17",
   "by": "Stream K9",
   "title": "The listings table's 'brand differs' flag compares the unscoped fold against the brand slug",
   "detail": "listings.listed_brand_key is written by collected.listed_fields with normalize.brand_key and NO vertical (a display comparison, deliberately), and listings_table compares it to the brand row's slug. Two cases now show a false 'brand differs': a brand row a person SPLIT (its slug is 'appleton-rum', the unscoped fold of 'Appleton Rum' is 'appleton'), and any row whose spelling folds differently under the scoped list. Nothing is wrong with the data; the flag is comparing two things that stopped meaning the same thing in K9.5. Fix: write listed_brand_key through the listing's vertical, or have listings_table resolve the spelling the way ingest does (claim, then placed, then the scoped fold) instead of comparing keys.",
   "owner": "L",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-17"
  },
  {
   "id": "issue-277-empty-product-lines-hold-only-merge-tombstones-prune",
   "kind": "issue",
   "created": "2026-09-18",
   "by": "Stream K12",
   "title": "277 empty product lines hold only merge tombstones; prune keeps them as records, so they stay reachable",
   "detail": "On the 18 Sep copy every empty product line (alias_of null, no live variant) is referenced by merge tombstones, so backfill prune_lines deletes 0 of them by design. The simulation called them reachable rows that mean nothing. The fix is a reader rule (a line with no live variant is not listed or reachable) or moving a tombstone's line pointer to the survivor's line at merge; K12 changed neither. Undo now prunes what a fold minted, so the count no longer grows from fold-and-undo.",
   "owner": null,
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    ".logs/runs/rehearsal-2026-09-18-k12.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-18"
  },
  {
   "id": "issue-dubai-duty-free-is-listed-as-reachable-by-sweep-plan-bec",
   "kind": "issue",
   "created": "2026-09-18",
   "by": "Stream K12",
   "title": "Dubai Duty Free is listed as reachable by sweep plan because its refusal was never recorded as a run",
   "detail": "sweep plan reads the last collection run per source; dubai-duty-free's last run is ok (22 Aug) and the 403 to our declared identity lives only in a verification check, so a sweep would contact it. Before the sweep either record the refusal on the source (a blocked run, or sources.enabled=false with the reason in notes) or let the collector refuse on the first page; COLLECTORS.md says no run since 22 Aug and no bypass.",
   "owner": "A",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [
    "main/docs/RUNBOOK.md"
   ],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-18"
  },
  {
   "id": "decide-approve-the-airport-template-on-heathrow",
   "kind": "decide",
   "created": "2026-09-18",
   "by": "Stream G2",
   "title": "Approve the airport template on Heathrow",
   "detail": "The template is on staging at /airports/heathrow-lhr-london. Read it with the placeholder view both on and off (Settings). What it does now: the standing facts and four counts in one panel; what decides how you shop here (key facts, special offerings) as two cards; where to shop as one row per terminal that opens in place, each carrying its shops and what is worth buying; best value as one card per family with a link to all of them; then the products; then the allowances and the tips under the author's own titles. A contents line under the hero names the sections. Every part we mean to have and do not have draws a placeholder the size and shape of the real thing, with what to gather on hover.\n\nMeasured on staging: desktop 4,938px with the view off, against 996 text lines of body before this stream and 840 now, while carrying more of Adam's material than the page has ever held. On a phone, 4.6 screens before the products, down from 6.7.",
   "owner": null,
   "priority": null,
   "options": [
    "Approve as shown, and the other eighteen airports are converted and imported against it",
    "Change something first; any change applies to all nineteen by construction, so it costs nothing to ask now"
   ],
   "assumption": "Proceeding as approved: the conversion of the eighteen (G13) is built against this shape, and a later change still applies to all nineteen because they are data, not code",
   "links": [],
   "due": null,
   "blocks": [
    "G14"
   ],
   "weight": "blocking",
   "updated": "2026-09-18"
  },
  {
   "id": "issue-an-airport-s-page-title-carries-a-shop-s-name-seoul-inch",
   "kind": "issue",
   "created": "2026-09-18",
   "by": "Stream G2",
   "title": "An airport's page title carries a shop's name: Seoul Incheon (Shilla online store)",
   "detail": "/airports/incheon-icn-seoul renders as 'Duty free at Seoul Incheon (Shilla online store)' and the breadcrumb says the same. The shop row's name has leaked into the place's name, so the page reads as a storefront rather than an airport. The place row is named 'Seoul Incheon'; the page takes its name from the shop. Worth settling before the nineteen go public.",
   "owner": "A",
   "priority": null,
   "options": [],
   "assumption": null,
   "links": [],
   "due": null,
   "blocks": [],
   "weight": "info",
   "updated": "2026-09-18"
  }
 ]
}
