# Handoff

Newest first. What landed, what is in flight, what is next.

## 2026-09-11 (Fri, night) — Stream M complete on branch `claude/stream-m-fdcfa4`: lines, the v3 key, aliases, suggestions, the merge session

**Where:** the desktop worktree, thirteen `M:` commits; `/plan` and the running list read the root checkout, so M shows there once rian merges the branch. Nothing deployed.
**Landed (M1 to M7, all `done`):** the Listings view (the tile beside what we made of it, a differs
filter); `product_lines` and `variation_aliases` with the pure rules in `services/lines.py`; identity
rules v3, `house | line | variation | size` plus a form marker (`services/keying.py` resolves the house
and the variation first); aliases at every level applied so the catalogue follows (`merges.apply_*`);
suggestions at three levels (`services/suggest.py`, `app.cli suggest`); the Merge view with y / n / s,
the preferred name and `decided_by` from the session (`services/merge_session.py`, three POST routes
under `sources.manage`); the inheritance rules (`gtin_source`, the barcode note, the image with its
provenance). Mechanisms and rules: `DATA-MODEL.md`, `RUNBOOK.md` (Merge session).
**Verified:** `main/check.sh` green on the final tree (1,185 tests, 122 new). Rehearsed twice each on
`dfp_m` from `backups/dfp-2026-09-11-stream-m-rehearsal.dump` (a read-only pg_dump of staging after
the refresh; the nightly lacked nine fixture rows): migration up/down/up; `backfill lines` 13,717 lines
from 17,323 products; `variations` 41 wordings, 1,986 products; `rederive` 17,324 keys; `merges` 500
groups (555 rows forward), 321 left for a person, comparable 3,675 to 3,731; `suggest` 27 brand, 2,039
line, 1,054 product pairs, Paco Rabanne to Rabanne first at 0.95. With the alias simulated, the 100 ml
1 Million EDT is one product at BOG, PTY, SAL and YUL; CDG's bare "1 Million 10cl" waits as a pair.
**Migration `c6d7e8f9a0b1`; after deploy, in order, each safe to repeat:** `backfill lines`,
`backfill variations`, `rederive`, `backfill merges`, `suggest`; no collection running.
**Outside Owns, named:** `ingest.py` (the keying call returns the line; the veto reads the variation
first); the head pin in `tests/test_accounts_backfills.py`; JSON variants on JSONB columns in
`models/catalog.py` for the SQLite tests. **Not done:** the Merge page was not opened in a browser
from this branch (routes tested, SPA typechecked). No task blocked.
**Needs rian:** the do item (merge, deploy, the five commands, confirm Paco Rabanne to Rabanne); the makeup-shade
decide (assumed: no makeup line pairs); three issues for A, two for B. **Next:** rian's look at Merge on
staging, then the real session; Stream A (A14 to A18) may start now.

## 2026-09-11 (Fri, evening) — Away: the plan finalised into runnable lanes; nothing started

**Landed (this commit, `Away:` prefix):** the two missing briefs and commands, `G-airport.md` + `/stream-g` (G1 to G7) and `W-readiness.md` + `/stream-w` (W1 to W6); R2 widened in `R-accounts.md` (T7 staging refresh keeps Adam's comments, T8 collapse the pages with the proposal written first, T9 issues resolve and archive, T10 a place for rian to talk to Claude) with its own Owns, Shared and Must not touch, and `/stream-r2` reordered to T7, T1 to T4, T8 to T10, T5, T6; A's away tasks A14 to A18 in `A-data.md` with `/stream-a` re-gated to run only after M's final handoff. `KICKOFFS.md` carries the away table.
**Registered:** G1 to G7, W1 to W6, T7 to T10, A14 to A18 in `import/progress.json`, all through `plan-set.py`, which gained a locked `--add` mode for exactly this (it could only set status before; hand edits are what put a merge conflict into the running list).
**Running list:** `decide-opening-hours-are-a-new-source-class-...` (new, assumption set, blocks G1 and G3); `decide-the-built-javascript-is-public-...` updated in place rather than duplicated, its assumption flipped to gated and now blocking W2; `do-review-the-decisions-taken-while-you-were-away` for rian, linking `AWAY-PLAN.md`. No decide was resolved; the page-types and review-area decides stay open, their assumptions written into the G and R2 briefs.
**Owns proof (wave one):** the M, R2 and G Owns columns were parsed for backticked paths and intersected: M ∩ R2 none, M ∩ G none, R2 ∩ G none. One basename repeats, `catalog.py`, and it is two files (`models/catalog.py` is M's, `routers/catalog.py` airport routes are G's). Two files are shared by rule rather than owned: `services/access.py` and `App.tsx` (each lane appends its own entries; W2 is the one structural edit, in wave two), plus `cli.py` and `main.py` registration lines. One documented one-time touch: R2's first commit moves the discussion classes out of `models/catalog.py` into `models/discussion.py` before M reaches M2.
**Verified:** `main/check.sh` printed "checks passed" (1072 tests); docs-check 0 fail, the 12 warns predate this session.
**Not done, on purpose:** no stream started, nothing deployed, no collection, no migration; `KICKOFFS.md`'s older table still lists R2 and R3 as "after 18 Sep", superseded by the away table above it.
**Next:** rian starts the three wave-one terminals (the paste block at the end of `AWAY-PLAN.md` §5, printed by this session); on return, the review item.

## 2026-09-11 (Fri, late night) — the airport page compacted on staging, 0.46.0

**Landed (`c53e493`, staging only; 1069 tests, `check.sh` green; rebased onto the /collectors
work again):** rian's read of 0.45.0 was that the page was too long — too much prose up top, then
grid after grid. Reworked: `AirportFacts` is one panel (summary, standing facts, the four counts),
replacing the stat cards, the shops sentence and the guide's heading block; `AirportTerminals` is a
`<details>` row per terminal (name, airlines, first line, opening in place); the four product
sections became three shelves as tabs — Best value here / Travel exclusives / All products — with
the shelf in the URL (`?tab=`, `urls.airport_tab` mirrored in `lib/urls.ts`; a filter, a sort or a
page implies the full list, an empty shelf is not offered). `seo.airport_body` takes the tab and
draws the same shelf, chips and pager for the same address; `airport_facts_html` and
`airport_terminals_html` mirror the two components. The placeholder view is `TemplateGaps`, one
panel of rows. Heathrow measured 9,500px before, 3,850px after (3,400px with the placeholder view
off); the full list shelf is 6,400px because it is 24 cards.
**Watch:** the shelf tabs are the first place a query parameter selects content in the server
body; if a new shelf is added, `urls.airport_tab`, `lib/urls.ts` and `seo.airport_body` move
together, and `tests/test_seo_airport.py::TestAirportBody` pins the three.
**Next:** rian's look; then the parts the gaps panel keeps naming as ours to build (featured by
family, the category-at-airport pages, shopping-feature filters, a comparison preset to the
airport), and guides for JFK, Hong Kong, Buenos Aires and Paris when Adam sends them.

## 2026-09-12 (Sat) — staging refreshed from production; collector history on /collectors; the 1 Million diagnosis

**Staging now carries production's data.** Fresh dump taken on bwlive (the pulled nightly predates
Friday's three-lane run), restored here after a safety dump. Two things existed only on staging and
were carried back: Adam's three comments of 11 Sep on `todo:9`, `todo:11` and `todo:13`, and the one
article. **Adam is commenting on staging, not on the live host**, so every future refresh must
preserve `discussion_comments`, or point him at production instead: worth a decision.
**Fixed, running list was broken:** `import/items.json` was committed on 11 Sep with git conflict
markers in it (0ab8e6c), so it was not valid JSON and `items.py` could neither read nor write.
Rebuilt from both sides, keeping both items. Guard filed for `check.sh`.
**`/collectors` gained what rian asked to be able to assess:** a per-collector run history with a
story in words (clean every run · never once succeeded · worked before, refused now · working now,
after trouble · nothing attempted for N days) and a strip of the last twelve runs, marking a shop's
refusal apart from our own failure. That distinction is the overcomeable question: 12 collectors are
clean, Singapore has never once succeeded, four Heinemann-family shops worked and then refused,
six Avolta stores show only our own breakage, and Dubai reads honestly now as untouched for 20 days.
**Fixed:** the blank page after clicking a group (a grouped row has no price, and the money formatter
crashed on undefined, taking the page with it); groups now expand in place instead of jumping to an
unfiltered table, with exact `brand` and `size_ml` filters behind them.
**Measured after the refresh:** 17,324 products, 3,675 comparable (was 1,661), 15,983 without an
image, 300 duplicate pairs queued, 3 shops publication-blocked, audit clean.
**The 1 Million case:** diagnosed as a brand rebrand (Paco Rabanne against Rabanne) plus name
normalisation, not a display problem; the line-page idea is filed as its own decision with numbers.
**Next:** rian's call on the line model; the brand alias is a small, high-value fix for Stream A.

## 2026-09-11 (Fri, late night) — Heathrow's guide and the airport template on staging, 0.45.0

**Landed (`0ab8e6c`, staging only; 1059 tests, `check.sh` green; rebased onto the /collectors
work that landed on main meanwhile):** Adam's Heathrow duty free guide (draft v2) is the first
airport guide — `main/app/services/airport_guides.py`, in code because the data holds no
terminals, mirrored by `seo.airport_guide_html` for the crawler and `components/AirportGuide.tsx`
for the SPA, seeded in `AirportDetail.guide` like the write-up. New demo setting **Placeholder
view** (default on): `web/src/lib/airportTemplate.ts` turns the structure proposal's `p-airport`
fields into fourteen checkable parts, and every unfilled one draws a `MissingPart` card where it
will go, tagged by who fills it (write / build / collect / later). It is browser-only: the server
body never draws one, and `tests/test_seo_airport.py::TestAirportGuide` pins that. The airports
list marks which airports have a guide (`CoverageLocation.has_guide`). Removed: the home page's
"Tracking N products..." line, and the airports page's internal notes (catalogues, "Not yet
covered"), no setting.
**For rian:** running list has a to-do to verify Heathrow's gate numbers before the site is
public (Adam's own note on his draft), and the earlier issue about privacy/terms/contact pages.
**Next:** guides for JFK, Hong Kong, Buenos Aires and Paris (the structure proposal's list);
the parts the placeholder view keeps naming as ours to build are "Featured at this airport" by
family, the category-at-airport pages, the shopping-feature filters and a comparison preset to
the airport.

## 2026-09-12 (Sat) — the data review area: /collectors, first version on staging

**Landed (two commits, 1,065 tests, check.sh green, deployed to staging only):** a new
owner-only page at `/collectors` (`sources.manage`, the same permission as the kill switch),
and `app/services/collector_view.py` + `app/routers/collectors.py` behind it. Four views:
**Collectors** grouped by commerce platform, each expanding to the airports it powers, its last
run, its skip reasons and any check hiding its shops; **Catalogue**, the counts (products,
brands, comparable against single-airport, and how many carry an image, size, barcode, category,
brand) with the airport-spread, family, category and size distributions; **Products**, every
product in one table, searchable and filterable by collector, airport, category, what it is
missing and how many airports stock it, groupable by brand/category/family/size, each row
expanding to its price at every airport with the date read; **Problems**, blocked shops, the
merge-candidate pairs, audit metrics over threshold, rejected rows by reason, and the run history.
**Read-only by design**: deciding a merge or clearing a block stays with the commands that record
who decided. Registered in `access.py` (five API routes + the SPA path) and `seo.STATIC_HEADS`
(noindex); the route-inventory and gold-token guards both caught real mistakes first.
**Measured on staging while building:** 23 collectors over 8 platforms, 1,661 products at two or
more airports, 11,917 with no image, 300 duplicate pairs queued. Every query under 0.15s.
**Not verified visually:** the browser session here is signed in as a client account, which is
correctly refused; only `rian` holds `sources.manage`. Rian is the first person to see it render.
**Next:** rian looks and says what is missing (item `decide-what-the-review-area-needs-next`);
production deploy whenever he wants it there.

## 2026-09-11 (Fri, late night) — launch footer, scroll-to-top, loading outlines on staging, 0.44.0

**Landed (`e6262fb`, staging only; 1058 tests, `check.sh` green):** rian's "shoots me past the
footer" bug had two causes. (1) The router never scrolled on navigation, so a link low on one page
(footer, a /settings sample link) opened the next page at the old depth, a lazy page first as a
bare footer: `lib/useScrollOnNavigate.ts` scrolls to the top on a PUSH without a #fragment (POP
and REPLACE, i.e. back/forward and filter rewrites, untouched). (2) `SponsorSlot`'s hash scroll
re-fired when a keyed grid remounted the slot (home airport toggle after a sample link): now a
module-level once-per-history-entry guard. The settings switches themselves never moved the page
in Playwright. Footer rebuilt (logo straight on navy; Shop / Airports top-6 from `useAirports` /
Read; copyright + price and non-affiliation lines); "Coming soon" and "Behind the scenes" gone.
Skeletons in the real classes: `ProductCardSkeletons` (browse, departments, shelves, Exclusives,
Awards), savings rows, airports-list cards, `HubSkeleton` (airport and brand pages), article list
and article; `RefreshVeil` over a filter refetch (`refresh-host`). `SiteHeader` arms the menu on
`requestIdleCallback` (Safari: 1.5s timeout) and `MegaMenu` warms the flag SVGs. Newsletter: Tab
from the name goes to last name (no space) or email (space); fields reveal on email focus. Home
"What's coming" and the "Editorial sample articles" switch removed. Running list: new issue, the
footer needs privacy, terms and contact pages before launch (owner C, P2; wording is Adam's).
**Next:** rian's look on staging; the legal pages once the wording is agreed.

## 2026-09-11 (Fri, late night) — airports in two places, growing newsletter form, home reorder on staging, 0.43.0

**Landed (`f9d90f5`, staging only; 1058 tests, `check.sh` green):** airports are set only in the
home picker and the header dropdown (rian's rule): the dropdown's open state lives in the airports
context and `SetAirportsButton` opens it from every body button (home savings head and band, menu
card, product page, savings empty state, airports page, the picker's "Browse all airports" tile);
the savings page's own picker and suggested pairings, and the airports page's per-card toggles,
are gone. `SubscribeForm` grows: Name + Email, the name splits on blur, the email reveals airport
and interests. Home: awards (with the billboard) above the articles, medal artwork on each winner,
"By category" removed. Running list: the sponsor-mirror issue is filed (owner B, P2).
**Noticed, not changed:** the newsletter interests still offer "Skincare", which the storefront
hides; worth a word with rian before launch.
**Next:** rian's look on staging.

## 2026-09-11 (Fri, night) — sponsor placements reshaped on staging, 0.42.0

**Landed (`8481f77`, staging only; 1058 tests, `check.sh` green):** `article-magazine` replaced by
`article-skyscraper` (300x600, sticky at 140px in a right rail; off, the text centres; hidden
below 1100px). `product-related-tile` and `airport-shelf-tile` are native tiles taking a card's
place (related: 7+tile; airport: 23 per page + tile, pages contiguous via `pageSize` in
`useAirport`). New `exclusives-rectangle` and `awards-rectangle` (300x250) beside the collection
introduction. Verified locally: skyscraper holds at 140px through the scroll; grids keep 8 / 24
cells; airport page 2 starts at 24.
**Watch:** the server mirrors draw the no-creative layouts. When creative lands in `SPONSORS`,
the article (rail) and airport (23 per page) server bodies must follow, or those pages shift on
mount and crawlers see 24 where shoppers see 23.
**Next:** rian and Adam compare placements from `/settings` on staging.

## 2026-09-11 (Fri, evening) — the articles section and sponsor sample links on staging, 0.41.0

**Landed (`ebc1076`, staging only; 1058 tests, `check.sh` green):** articles restyled on the
Professor sites' cues (oliveoilprofessor.com: ruled section labels, serif heads, "date · author"
small caps) in DFP's palette: index with a lead story, article page with a centred head, 2:1 hero,
`prose--article` column, publisher note, sponsor slots, "more from the Professor". `ArticleSummary`
gains `author` (account display name; house byline "Duty Free Professor" when none) and
`reading_minutes`; `seo.article_body` mirrors the page (geometry identical). New public mount
`/media` (editorial images; classified in access.py). `/settings`: sponsor note rewritten for the
native tile; each placement has "See it on a sample page" (switches it on, opens home / busiest
airport / top featured product / newest article at `#sponsor-<id>`). Correction: Adam's
"Sponsor creative" to-do (id 14) never named 300x250; it is "waiting" and still accurate.
**Data on staging only:** the sample article `is-it-really-a-saving` (source
`import/articles/is-it-really-a-saving.md`, hero `/media/articles/price-check-board.svg`), imported
and published on staging with the CLI; not on production.
**Open for rian:** the article is a starter I wrote under the house byline; Adam should approve,
edit or replace it before it goes to production (`articles publish` there after a deploy).
**Next:** rian's look at `/articles`, the article, and `/settings` sample links on staging.

## 2026-09-11 (Fri, late afternoon) — savings page speed, native sponsor tile on staging, 0.40.0

**Landed (`b8756b0`, staging only; 1056 tests, `check.sh` green):** `/savings` (services/trip.py)
0.6-2.3 s -> 13-107 ms on staging (candidates fetched once; ranking scoped to them at the route's
shops; deterministic tie-breaks; identical output across 10 routes x 15 variants). Home savings
grid: eight cards when the tile is off (asks for `total=9`), seven plus the tile when on
(`sponsorActive`). `home-shelf-tile` is now variant `native` (`NativeCreative`, the responsive
display ad asset set, drawn with ProductCard's elements; `/settings` preview shows
`SAMPLE_NATIVE` at the limits). `home-billboard` moved below the award winners; wrapper renders
only when active (it left an empty band before).
**Open for rian:** (1) Adam's `/todo` "Sponsor creative" row told him 300x250 for the home tile;
it is now native assets (square image 1200x1200, headline 30, description 90, brand 25, CTA), so
that row wants rewording before he sends files. (2) Filling it from Google's network or another
ad network needs their script on the page; the slot is script-free by design (`SponsorSlot`), so
that is a separate decision (consent banner, CSP, speed). Direct-sold assets in the same shape
work today via `NATIVE_SPONSORS`.
**Next:** rian's look on staging with the tile switched on and off in `/settings`.

## 2026-09-11 (Fri, afternoon) — the trip savings hold still on staging, 0.39.0

**Landed (`d5a6526`, staging only; 1055 tests, `check.sh` green):** placeholders
(`ProductCardSkeleton`, `HeadlineSavingSkeleton`: the real elements under `Skeleton.css`'s
shimmer), a `RefreshVeil` over the last answer while a new route loads (keepPreviousData), the
grid keyed by its answer, fixed card rhythm (two-line names, four bar rows) site-wide;
`routeCodes()` keeps savings site-wide below two airports. CLS on a route change 0.05 -> 0.0005,
measured with a 1.5 s artificial delay locally. Featured savings 0.5-6 s -> 137-267 ms on staging
(`_latest_observation_subquery(product_ids)`, identical output). Headline rule featured v2.1,
tuned on 438 airport sets (simulation scripts were scratch, not kept): award winner with photo
leads unless two cards save more than twice its dollars; 5% -> 11% of sets.
**Open:** `services/trip.py` (the `/savings` page, "All my savings") still ranks every
observation per request; the same scoping would likely speed it. Headline frequency is capped by
data: 31 visible products are award winners with photos.
**Next:** rian's look on staging: first visit, then change airports.

## 2026-09-11 (Fri, midday) — search as you type on staging, 0.38.0

**Landed (`8f4bac2`, staging only; 1046 tests, `check.sh` green):** `SiteSearch.tsx`, an ARIA
combobox: airports and brands matched in the browser (`lib/search.ts`), products from the new
`GET /api/search/suggest` (`catalog_queries.suggest_products`: the browse page's match, ranked
for typing, no price aggregation; 30-120 ms against about 500 ms for `/api/products`). Classified
`PUBLIC_WHEN_OPEN`; `open_routes.json` updated; anonymous gets 401 on staging as it should.
Verified locally on a scratch copy of the nightly (dropped): products 370 ms after the last key,
arrows and Enter, Escape closes then clears, nothing-found note, phone width.
**Open:** suggestions show no prices by design (speed, and no undated price in the header); if
rian wants a price there, it needs a cheap per-product "from" query first.
**Next:** rian tries it signed in on staging.

## 2026-09-11 (Fri, late morning) — the header's second pass on staging, 0.37.0

**Landed (`37625b4`, staging only; 1042 tests, `check.sh` green):** rian's round two. Articles
department (Reviews & tasting notes, Buying guides, News, latest articles; those three left the
row); "Popular brands"; search placeholder "Search product, brand, or airport" with airport
routing (`lib/search.ts`: a code or whole city opens the airport; `/products?q=` lists named
airports); profile circle (`Avatar.tsx`, `AccountMenu.tsx`) with Build Discussion and its pages
from `lib/clientPages.ts` (the footer shares it; `/plan` is now linked there for plan_view, at
rian's ask); My savings at the end of the nav; the home picker full width, exactly two rows,
last tile "Browse all airports"; footer flush under the home band. Header mirror re-pinned;
server and mounted boxes identical. Verified locally with a throwaway account on a scratch copy
of the nightly (both gone).
**Open for rian:** the Articles panel is sparse until articles are published; the label
"Articles" is a guess at the department's name (one line in `MegaMenu.tsx` and `seo.py`).
**Next:** rian's look on staging, signed in: the profile menu, the home picker at phone width.

## 2026-09-11 (Fri, morning) — the department menu (option C) on staging, 0.36.0

**Landed (`674c5aa`, deployed to staging only; 1041 tests, `check.sh` green):** rian chose option C
from the mega menu proposal (artifact "Duty Free Professor Mega Menu"). The nav leads with
Airports, Drinks, Beauty, each a link plus its own panel toggle; Products and "Airports we price"
left the row. Drinks/Beauty panels: shelves with drawn marks (`LineIcon.tsx`), ten leading houses,
the Professor's picks, a featured saving (the home page's selection, first in that family) or the
set-your-airports card. Airports panel: all visible airports by region with flags
(`REGION_OF_COUNTRY` in `lib/countries.ts`). API: `family=` on `/api/products`, `family` on
`BrandSummary`; "All drinks" is `/products?family=liquor`. The seo.py header mirror moved in the
same commit; server and mounted nav geometry measured identical on an airport and a brand page.
**Not verified:** a signed-in browser pass on staging itself (members-only; rian's). Locally
verified on a restored copy of the 11 Sep nightly (dropped after).
**Open for rian:** (1) the nav order in Adam's wireframe began with Products, his prototype with
departments; C follows the prototype, so confirm with Adam. (2) Beauty has no featured saving
today (the featured selection is all drinks), so its fourth column shows the airports prompt; a
sponsor tile there is an option (a ninth placement). (3) Production still serves 0.35.0; this
rides the next production deploy, after the crawl window closes.
**Next:** rian signs in on staging and tries the three panels and the phone drawer.

## 2026-09-10 (Thu, afternoon into evening) — Stream R (R1): the account system, green and ready

**Landed (eleven `R:` commits; 1020 tests in about 8 s, `check.sh` green with the vendor check and the 45-test conformance pack inside it; `docs-check --strict` 0 fail; nothing deployed, pushed or crawled):** L1 the kit and React pack vendored byte-identical with a manifest; O2 migration #4 (`b5c6d7e8f9a0`) and the backfills, rehearsed up, backfills twice, down, up on `dfp_r`; L3 sessions, tokens in the link fragment, the local directory, the audit sink, `/api/auth/*`, the CLI; L5 the kit wired at `/api/bw` with the owner sentinel; L4 `services/access.py` (every route in one class, members-only mode, the stopgap retired); L6 authorship from the session; L7 the SPA pages and the account menu; L8 the conformance pack under `tests/kit/`; L9 `main/docs/ACCOUNTS.md` and the rest; L10 the rehearsal: 27 curls and a 23-step round trip on a local server against `dfp_r` (the browser step itself is yours on the public host); L11 the Resend provider, inert until its lines exist. Design followed as written; the three small deviations are one info decide on the running list.
**Blocked:** nothing. **In flight:** nothing; `dfp_r`, the server and `main/static` are gone. `dfp-devdb` can go once you have deployed.
**Rian, in order (`main/docs/RUNBOOK.md`, Accounts):**
1. Nothing collecting; `docker exec dutyfreeprofessor-db pg_dump -U dfp -Fc dfp > backups/dfp-$(date +%F)-accounts.dump`.
2. `.app.env`: `ACCOUNT_OWNER=rian` and `SITE_ACCESS=members`; delete `OWNER_PASSWORD`/`SESSION_SECRET` if present; then `setfacl -b .app.env && chmod 600 .app.env && setfacl -m u:srv-gateway:r .app.env`; `grep -c REPLACE_WITH_ .app.env` prints 0.
3. Bump `APP_VERSION`, the NOW block and the CHANGELOG heading; `main/check.sh`; commit; `srv-gw deploy --project dutyfreeprofessor --build` (the container migrates on start).
4. Inside the container, in order: `backfill accounts`, `backfill levels`, `accounts create --username adam --email <his> --display-name Adam --level admin`, the same for `mark`, `accounts set-password --username rian`, `accounts invite-link --username adam`, `accounts invite-link --username mark` (send them yourself), `backfill authors --map Adam=adam --map Mark=mark --map rian=rian`, `awards --rebuild`.
5. The curl list from the host against `http://172.17.0.1:<port>`, every POST with `-H 'Origin: https://dutyfreeprofessor.demoing.info'` (plan §7 step 5; a traversal probe answers 302 while members-only, 404 once public, never a file body).
6. Browser: sign in, `/plan`, View As adam read-only (a write is refused), act mode, sign out; Adam and Mark redeem their links, see `/discuss`, not `/plan`; an anonymous window sees `/login`. `srv-gw security-audit`.
7. Owner only: `srv-gw id-gate --site dutyfreeprofessor.demoing.info --mode public` (rollback `srv-gw id-gate --site dutyfreeprofessor.demoing.info`); the curls again through the public host; `srv-gw security-audit` again; resolve the stopgap decide as superseded.
**Never:** `SITE_ACCESS=public` before E7; `app-client-register`; a `Domain` on the cookie; `INDEXNOW_KEY`. Green and ready.

## 2026-09-10 (Thu, afternoon) — Stream R (R1): the plan before code

**Design:** `.logs/planning/accounts-2026-09.md` as written; no redesign. Modules to create: `app/vendor/{bw_accounts,bw_store_sqlalchemy,bw_admin_api,bw_view_as}.py` + `MANIFEST.sha256` and `web/src/vendor/bw-admin/` (byte-identical; `scripts/vendor-check.sh`, `vendor-refresh.sh`; `web/tsconfig.vendor.json`); `models/accounts.py` (credentials, levels, members, grants, sessions, tokens, audit_log, overrides); migration `b5c6d7e8f9a0` (down `a4b5c6d7e8f9`, schema-only); `cli_accounts.py`; `services/{passwords,sessions,directory,mail,audit_log,accounts,identity,view_as,access}.py`; `routers/{auth,ops}.py`; the SPA pages login, forgot, set-password, account, admin and the header account menu; `tests/conftest.py` (root contract), `tests/kit/` (five conformance files + `conftest.py` + `_env.py`), `tests/test_access.py`, `tests/test_accounts_*.py`, `tests/fixtures/{open_routes,next_cases}.json`; `docs/ACCOUNTS.md`; `.logs/planning/kit-upstream-proposals.md`.
**Security checklist (plan §5), answered:** ports: nothing new listens; the app stays on the bridge behind Caddy. Auth: the app's own login is the only barrier once the gate lifts; no bypass list, no trusted subnet, no header trust (`X-Auth-User` grep-gated out of `main/app` by `check.sh`). Exposure: every non-public path answers 302 or 401 anonymously; FastAPI docs off in production; machine files 404 while members-only. Credentials: argon2id in `account_credentials` (no listing query joins it); tokens hashed, single-use, in the link fragment and a JSON body only; the cookie is an opaque 256-bit token, hashed at rest, host-only, HttpOnly, Secure, SameSite=Lax, no `Domain`; no new secret enters `.app.env` in R1; the file tightening is rian's step. Isolation, VPN, AutoRun: unchanged, none. Brute force: silent per-account lock in the database plus a per-address throttle on `CF-Connecting-IP` (per worker; one worker is a launch assumption). Audit: `srv-gw security-audit` runs when rian deploys, not tonight.
**Rehearsal:** `dfp-devdb` is up; migration and backfills on `dfp_r` from `backups/dfp-nightly-2026-09-10.dump`, dropped at the end. Nothing deployed, nothing pushed, no crawl.
**Order:** L1, O2, L3, L5, L4, L6, L7, L8, L9, L10, then L11 if time remains; one `R:` commit per task.

## 2026-09-10 (Wed, afternoon) — the accounts and login plan for Stream R; a live traversal hole closed

**Landed:** the design record `.logs/planning/accounts-2026-09.md` (R1 login and access this week,
R2 discussions and R3 collection oversight after 18 Sep, R4 growth as shape); the brief
`streams/R-accounts.md`; the commands `/stream-r`, `/stream-r2`, `/stream-r3`; lane R on `/plan`
(L1 to L11, T1 to T6, V1 to V4; O2 retitled, O3 superseded); ten running-list items filed, three
updated, `decide-client-surfaces-on-production` resolved with rian's instruction; `KICKOFFS.md`
rows; one server-level note in `/srv/.logs/ideas.md`. Built from seven research readers, three
independent designers and four adversarial reviewers with refutation: 34 confirmed findings applied.
**Also landed, code:** the SPA catch-all served any file on the container filesystem through `..`
segments (found in the review; live behind the shared password until now): confined to the shell
root in `main/app/main.py`, pinned by `tests/test_catch_all_confinement.py`, CHANGELOG line. 899
tests, `check.sh` green, `docs-check` 0 fail 0 warn. Nothing deployed.
**In flight:** nothing of mine. Stream E's egress run (`.logs/runs/egress-2026-09-10-mosiah.*`) is
untracked and E's to commit.
**Next:** rian reads the eight decides the plan filed (`items.py list`; each carries the assumption
it proceeds under), then `cd /srv/apps/dutyfreeprofessor && claude`, `/stream-r`, and pastes its
goal line; the stream lands R1 and hands back "green and ready" with his ordered steps (plan §7).
**Rian must do:** deploy the traversal fix with the next deploy of this tree, production included
(it is in Unreleased); keep `dfp-devdb` up through the R1 night (item updated); if deploying
before R1 lands, the stopgap's two lines still apply as the previous entry says; after R1, they go.

## 2026-09-11 (Thu, late) — Everything on staging went live; the first collection on production is running

**Landed since the evening entry (all committed, checks green):** every sponsor placement built in
(eight, two per page type, framed to belong: tile, billboard, strip, magazine) with a switch each on
`/settings`, off and invisible without creative; small flags beside the code on the airport cards and
hero, the code on its flag in the home picker (the band is gone); skincare and confectionery hidden
everywhere (`taxonomy.HIDDEN_CATEGORIES`, `catalog_queries.shown_category`), still collected;
uptime check now emails rian through Resend after two failed checks and on recovery (test mail
delivered). **Deployed to production 03:5x UTC** (`production-deploy-2026-09-11.log`); the domain
serves the flags and the favicon; Adam's "Sponsor creative" row is back to waiting on both hosts.
**Running on bwlive since 03:55 UTC:** the drinks refresh (ten Avolta, Dublin, four Shopify, Changi and
Seoul at 20, then `verify --n 20 --mode after_collection`), window file
`window-2026-09-11-night-refresh.md`, log on the droplet, pulled here at 04:15 with the dumps.
**No deploy to production until it says finished.** The earlier verify run (three shops, 59 pass, 1
moved) was ended by the deploy. **Filed:** 2,422 uncategorised liquor rows (A, P2).
**Next:** read the refresh and verify results; hide again if the tripwire trips; A11/A12 beauty
widening; B6 after the page-types decide. **Rian must do:** placements keep-list or "Adam decides";
ratify the four standing rules; the bot mailbox; the thirty-page check.

## 2026-09-11 (Thu, evening) — Brand pass two on staging; the nineteen switched on; production tooling

**Landed (with rian present; five commits `C:`/`E:`/`Docs:`, 1,029 tests, `check.sh` green):**
(1) e1911a1 brand pass two: coral accent (`--accent*` in `tokens.css`, 28 files), gold only on
`.badge--medal`; flag band on the airport cards and the airport hero (`web/public/flags/`, MIT
flag-icons, `lib/countries.ts`, `/flags` mount added in `app/main.py` and `access.py`, three
lines, noted for B/R); four sparse sponsor positions (home-mid, airport-mid, article-foot 728x90
folding to 320x50; product-below-prices 300x250); Adam's `/todo` "Sponsor creative" flipped to
open with the sizes (applied on staging, id 14). (2) c74980b favicon set from the logo +
`test_tokens_contrast.py` (AA pairs, gold pinned to the medal badge, icon files exist).
(3) 7464633 `items.py` reads page decisions from the database (the API is 401 in members mode).
(4) 7c1a2a0 `deploy/uptime-check.sh`, five-minute cron on this server, log + incident file, no
alarm channel yet. (5) 7be18e6 docs. **Deployed:** staging only (rian's deploy, 20:5x local).
**A13 done:** `locations --only` the nineteen on production AND staging; the site is
members-only so nothing public changed. **Verify tripwire** `--n 20` (per source) is running on
production since 00:50 UTC, two shops in, 39 pass / 1 price moved; rian chose option (a): stop
it at bedtime, run the drinks refresh tonight, verify after.
**In flight:** nothing half-done in the tree. **Next:** tonight's refresh from the droplet
(window file first; no deploy during it); production deploy of the brand pass only BEFORE the
refresh starts, if rian wants it live. **Rian must do:** look at staging; say "start the
night"; ratify the four standing rules; the bot mailbox; the thirty-page check; choose the
uptime alarm channel (Resend sender or the hosting monitor). Doc-gate warns are mtime noise
on docs the account stream's sources moved (issue filed earlier for `import/`).

## 2026-09-11 (Thu, small hours) — Stream E: production is live on dutyfreeprofessor.com from the droplet

**Landed (six `E:` commits, nothing changed under `main/app` or `main/web`):** the DigitalOcean
droplet `bwlive` (Toronto, 2 vCPU / 4 GB, 137.184.166.194, Tailscale 100.106.255.15) bootstrapped
by `deploy/bootstrap-droplet.sh` (deploy user, keys-only sshd from the tailnet and this server,
root off, unattended upgrades, Docker, swap); DO cloud firewall 22-from-here-only, 80/443 open
(verified from outside). **Egress:** `.logs/runs/egress-2026-09-10.md`, 23 of 23 hosts identical
to this server's address, and a bounded rendered run (Changi 5/5, Seoul 5/5, Heathrow 10/10);
rian decided **collectors run on the droplet**. **First deploy** 23:51 UTC via
`deploy/production.sh --seed-db --caddy`: seeded from the staging dump, 0.35.0, head
b5c6d7e8f9a0, owner-check ok, awards rebuilt; **apex A record moved** from WP Engine to the
droplet (proxied); Caddy on a **Cloudflare Origin CA certificate** (Let's Encrypt's remote
validators failed the zone fifteen times, issue filed); zone set by rian (Full strict, bots off,
bypass rule). **Verified through the domain:** all 29 routes give the sign-in door except
`/login /welcome /forgot`; every API read 401/302 and `no-store`; robots Disallow all; rian
signed in. **Fence** (`deploy/sidecar-firewall.sh`, systemd oneshot) proven from inside the
browser container: metadata, host, tailnet, public address blocked; retailer reachable. **Crons
on bwlive:** dump 03:15 UTC (pulled here 04:15 into `backups/production/`), audit 04:00, verify
--n 200 weekly; **restore rehearsed** into a scratch database in 11 seconds. RUNBOOK "Production".
**Passwords carried over** from the seed dump (argon2, no host secret); sessions did not.
**Blocked:** nothing. **In flight:** nothing. **Next:** E7's second pass on go-live day (flip
`SITE_ACCESS=public`, anonymous curls, sitemap/robots, IndexNow key minted then); external
uptime check (`issue-no-external-uptime-check-on-production-yet`); WordPress waves onto the same
box after 60 to 90 clean days (server plan). **Rian must do:** give Adam and Mark the live URL
(their staging passwords work); keep the zone proxied and Full (strict) (the origin certificate
depends on it); Cloudflare's per-site "Always Online" stays off. Staging on demoing.info is now
the dev copy; deploy there with `srv-gw deploy`, to live with `deploy/production.sh`.

## 2026-09-10 (Wed, night) — Stream N: the write routes are closed, every document is current, the issue log is swept

**Landed (seven `N:` commits, 897 tests, `check.sh` green, `docs-check --strict` 0 fail, nothing
deployed; rian deployed 0.34.0 mid-session and ran `backfill perfume`):** (1) d5f89b0 default-deny
middleware: a write is admitted only when `services/mutations.py` declares it public (the client
surfaces, unchanged) or owner with the owner session; the session is a signed cookie from
`POST /api/owner/login`, fail closed until `OWNER_PASSWORD` and `SESSION_SECRET` are set
(`RUNBOOK.md`, Owner sign-in; `.app.env.example`); `OwnerGate.tsx` fronts the running list's actions
and curator mode. Curl-verified on `dfp_n`. (2) d50a352 medal matching vetoes one-sided expression
markers and reads curly apostrophes; rehearsed on `dfp_n`, every changed row read
(`.logs/verification/awards-matching-review-2026-09-10.md`). (3) 07bd258 the wine competitions'
silver, bronze and the Asia gold from their own artwork. (4) 858014b the hero picker draws loading
placeholders: home CLS 0.0166 to 0.0007. (5) c3e5f09 fifteen em dashes out of shopper-facing text,
`test_house_style.py`. (6) 950d2c5 the doc pass: seven docs corrected against their sources, three
"not yet built" pointers in `agents.md` retired, every stream brief stamped, three plan statuses
corrected, twelve historic handoff entries trimmed, fourteen items filed and one resolved.
**Review pass:** 159 routes, sitemap entries and API reads all 200 on `dfp_n`, no tracebacks, no
console errors; Lighthouse mobile home 97, product 93, airport 98, brand 97, accessibility 100, CLS 0.
**Blocked:** nothing. **In flight:** nothing; `dfp_n`, the local server and `main/static` are gone.
**Next:** B6 category pages at `/alcohol/<slug>` once the page-types decide is answered; Stream E.
**Rian must do, in order:** add the two owner sign-in lines to `.app.env` (until then the running
list's actions, curator mode and the kill switch answer 403); deploy ("green and ready"); then
`docker exec dutyfreeprofessor-app python -m app.cli awards --rebuild`. Read the decides on the
running list: the sign-in stopgap (blocks E4b), the sponsor banner sizes (blocks C5), souvenir goods,
the dark header. Adam's two Google Docs answer with a sign-in page and stay unread by any session.

## 2026-09-10 (Wed, overnight) — Stream S: the structure review landed in code and on /structure; drafts for rian

**Landed (six `S:` commits, 859 tests, `check.sh` green, nothing deployed):** (1) e119380 airport
addresses lead with the airport's name (`/airports/heathrow-lhr-london`, JFK code-first) from a
declared table in `urls.py` mirrored in `urls.ts`; every earlier shape 301s keeping the query; the
API sends every airport link (`PriceOut.location_path`, `CoverageLocation.path`); curl-verified on a
local server against the 9 Sep nightly restored as `dfp_s` (torn down). (2) d4567e6 the category is
Perfume; `backfill perfume` rehearsed on `dfp_s`: 2,219 rows, 0 on rerun. (3) fe5ffa9 a brand page
needs three products priced at two or more airports, one rule in `_house_counts`; 44 pages on `dfp_s`
at four airports (114 under the old rule). (4) ba64acd the shop's price is the price of record: no USD
range in the AggregateOffer, the table leads with the shop price and labels our conversion; pinned
on product 1156. (5) 2af072b `/structure` rebuilt (keys unchanged; settled lines, two new sections,
airport page as duty free only, brand fields, Perfume) and `/todo` row 9 reworded and applied live
(kept id 9, status, comment 148). (6) ca4b3d1 no em dash in served titles and awards.
**Running list:** four review issues and the noindex item resolved (rian's 9 Sep decision); one
issue for E on the password-gated production host; two decides (the slug table for the nineteen with
the brand-floor reading; size display). Cited, not resolved: shops-and-terminals, brand-at-airport,
the two price-chart items. Adam's two Google Docs answer with a sign-in page, so unread.
**Blocked:** nothing. **In flight:** nothing half-done. **Next:** deploy, then B6 at `/alcohol/<slug>`
once rian answers the page-types decide.
**Rian must do:** deploy ("green and ready"), then `docker exec dutyfreeprofessor-app python -m
app.cli backfill perfume`; send Adam the nineteen and the document feedback from
`notes/review-drafts-2026-09-10.md` (`do-send-adam-the-list-of-the-nineteen-launch-airports`,
`do-give-adam-feedback-on-his-heathrow-airport-profile-and-h`); confirm the slug table with Adam
(`decide-confirm-the-nineteen-airports-web-addresses-with-adam-jf`); read the E issue before the
10 Sep cutover. Mark's `/todo` row 1 (structure comments) is still open, his or rian's to close.

## 2026-09-09 (Tue) — Mark and Adam have both reviewed /structure; four decisions settled, one urgent find

**They have commented.** Mark's nine emailed points and Adam's replies were transcribed onto
`/structure` on 7 Sep, rian answered every thread on 9 Sep, and both have now replied on the page
itself (comments 191-208). Comments are editable in place since 0.33.x, and rian rewrote several of
his own replies in his own voice.
**Settled, resolved on the running list:** the family becomes **Perfume** (Adam: "Search is always
our driver"); a brand page needs **three or more products stocked at two or more airports** (Adam,
with Mark noting brand pages carry high SEO value and deserve enriching rather than trimming); the
**launch scope stands as proposed** (Mark: "no reason not to go with Rian's proposed pace", Adam:
show what the timeline allows and frame the rest as a next release); the **shop's own price in its
own currency is the price of record** and the only one in the markup.
**Mark withdrew two asks** once he had the reasons: the product URL keeps its numeric id, and the
plural `/airports/` stays. `/alcohol/whisky` is confirmed. What remains of the URL item is the
airport name in the slug, with JFK the only code-first exception unless Adam names more.
**Urgent, and rian's to act on:** `dutyfreeprofessor.com` answers 200 with a WordPress site still
built on the **Chocolate Professor** template (its logo and banner files say so), robots.txt wide
open with a sitemap. Mark: "I would no-index this immediately." It is Adam's WP Engine property,
not ours; our demo host is unaffected (302 to the gate). Item
`do-noindex-the-placeholder-site-on-the-launch-domain`, blocks A13.
**Direction change on airport pages:** Mark wants duty free content only, no lounges or terminal
writing and no filler for search; shop locations, specialty shops, perhaps a map. The `/structure`
airport spec and the `/todo` ask to Adam both still request the old shape and need rewording.
**Also new:** Adam has drafted a Heathrow airport profile and a whiskey category example (Google
Docs, linked in the threads) and wants feedback; he has asked again for the list of nineteen
airports. Six items filed, five resolved, one narrowed.
**Next:** noindex the placeholder, then the airport-name URL change while nothing is indexed.

## 2026-09-09 (Tue) — Mark's SEO review answered on /structure; comments are now editable in place

**Landed:** Mark's nine points and Adam's replies transcribed onto `/structure` (21 comments across
eleven threads, attributed "by email, 7 Sep"), then 14 replies as Rian: agreeing on the airport-name
URL, no generic word in a path, Perfume over Fragrance, a higher brand floor, size wording and
deferring terminals; pushing back on dropping the product id, on publishing the whole catalogue at
once (measured: 1,661 of 13,252 priced products are multi-airport, so one in eight, not one in five),
and on pay-to-play brand pages (doorway-page risk: stock decides existence, commerce decides
prominence). Nine items filed from the review; three gate A13.
**Editable comments (0.33.x, deployed):** `POST /api/discussion/feature-comments/{id}` rewrites a
body; **the author is never editable** and `updated_at` moves so the thread shows "edited". No
migration (TimestampMixin already carried the column). `FeatureThread` gained an inline editor, so
every surface using it (structure, quote, feature board, to-do, running list) is editable. 9 tests
(`test_feature_comment_edit.py`), route added to the mutating-route inventory, 847 pass.
**Deliberately open:** anyone past the shared password can edit any comment, per rian; the "edited"
marker is what keeps transcribed words honest. Closes when the auth middleware (O1) lands.
**Verified:** live round trip (body changed, author preserved, marker set, 404 on unknown, 422 on
empty) and a UI save through the browser.
**Next:** rian sends Mark and Adam the page; the URL changes are the cheapest thing to do before
anything is indexed (item `issue-url-changes-from-mark-s-review`).

## 2026-09-05 (Sat) evening — 0.33.0 deployed: the day's seven workers, two migrations, backfills, fresh prices

**Deployed 18:51** after dump `backups/dfp-2026-09-05-pre-0.33.0.dump`: migrations `f3a4b5c6d7e8` (A, #3)
and `a4b5c6d7e8f9` (D, #5), `alembic current` = D's head. Backfills in A's order: implausible_sizes 4,
brands 2,246 rows / 13,306 products, sizes 10,786, categories 1,043, rederive 2,546 re-keyed, merges
45 groups (49 forward) with 223 left for a human; `awards --rebuild` 413 matched, 62 created, 113
removed, 224 ambiguous held. **Audit (no network): 0 of 16 metrics over threshold.** Smoke: every new
route 200 (`/airports/lhr-london` 1.1 s, watch it). Health reports 0.33.0. Security audit: no DFP
finding; one CRITICAL is another project's 82 GB (`brentwood-legacy`), disk 83% full.
**Prices:** the 5 Sep refresh collected 2,829 prices across the ten Avolta stores, Dublin and the four
Shopify shops (all ok; Shopify's 4 Sep refusal did not recur); Dubai still 403 to a single robots
read; Paris fresh from 4 Sep. Seoul first run 18/20 clean; Singapore 17 then a 403 at page 19
(decision `decide-singapore-answered-403…` open; a retry is `collect --source ishopchangi-sin
--limit 20 --delay 30`, no deploy). Identity page live at bot.dutyfreeprofessor.com; UA repointed.
**Streams today:** A (A7, A6, A8, A9), B (B4, B7, B5 + three request passes), C (C2, C3, C4; C5 needs
rian), D (D1, D3, D4; D2 needs Adam's files), F (F1; F2 waits on `overrides` + B's wiring is done),
sweeper. 838 tests. Public site still shows the four airports (A13 is Fri 11 after the tripwire).
**Next:** morning verify `verify --n 20 --mode after_collection` per host (never right after a
collection); flip the `/structure` airport/brand rows from built to live (needs a build); C5 with
rian; E with rian; `overrides` decision; `INDEXNOW_KEY` and `SOCIAL_*` lines in `.app.env` (rian).

## 2026-09-05 (Sat, evening) — Stream B (3): the three product-page items other streams filed, all landed; nothing deployed

**Landed (commits 9150552 5690633 70508d4; `check.sh` green on the whole tree, 838 tests; no deploy, no restart,
no retailer contacted, the Avolta loop untouched):** (1) the product page's brand name links to `/brands/<brand_slug>`
when the API hands a slug, else a catalogue search, in `seo.py product_body` and `ProductPage.tsx` alike
(`tests/test_seo_body.py::test_brand_links_to_its_page_only_when_the_api_handed_over_a_slug`; on `dfp_b` #6740 ->
`/brands/glen-deveron`, #6154 -> `/products?q=Suntory`). (2) `category_counts` fills `CategoryCount.family/family_label`
from `taxonomy`, query unchanged, so the mega menu groups its shelves (`tests/test_category_family.py`; `/api/stats` on
`dfp_b`: ten rows, every one with a family). (3) `useProduct` sends `at=` with the visitor's airports; the seed stays
`initialData` only with no airports and stands in as placeholder with them (`main/docs/SEO.md` "What renders
server-side"). Rehearsed in headless Chrome over the built SPA against `dfp_b`: `GET /api/products/1156?at=JFK&at=LHR`
with airports, no product request without; a MutationObserver armed before the first script saw no skeleton either way.
**In flight:** nothing half-done. `dfp_b` read only; local uvicorn, Chrome profiles and `main/static` torn down.
**Next for B:** after the deploy flip the two `built` rows on `/structure` to `live`; B6 waits on §10 #10.
**Needs rian:** nothing new. ARCHITECTURE/CLIENT-SURFACES/DATA-MODEL warns predate me and nothing they say changed
(checked), so no edit. Three running-list items resolved, none added.

## 2026-09-05 (Sat, evening) — Sweeper (A/Q/A2): the seven small items other streams filed, all landed; nothing deployed

**Landed (commits 445c25f c2f5a59 d3f0590 4f712c5 670b7d0 37d904f 6a1fc91 83bb3a4; `check.sh` green on the whole tree,
834 tests; no deploy, no restart, no retailer contacted, the Avolta loop untouched):**
(1) `/trip` (stops, routes, comparison) and the coverage list hide a publication-blocked shop through
`publishable`/`blocked_location_ids` (`tests/test_trip_publication_block.py`; rehearsed on `dfp_b` with JFK's source
blocked in a rolled-back transaction: JFK left every list). Gates A13. (2) `ProductSummary.brand_slug`, filled by
`catalog_queries.brand_page_slugs` only when the brand has a page (a blind slug would 404 on about a quarter of visible
products: 622 of 2,380 on `dfp_b`); `list_brands` shares its statement (`tests/test_brand_slug.py`). (3) `CategoryCount.family`
+ `family_label`, `taxonomy.FAMILY_LABEL`/`family_label()` (`tests/test_taxonomy.py::TestFamilyLabel`). (4) Audit: tombstones
count nowhere, duplicates read through `merges.duplicate_groups`, `brand_folds` a pending-fold metric at 0, thresholds
re-pinned (duplicates 0, oversize 2); `merge_candidates` in the snapshot and checklist section 6; 16/16 green on `dfp_b`
(`main/docs/QUALITY.md`). (5) QUALITY.md's block sentence; SEO.md's `/trip` sentence. (6) `fetch.disallow_regexes` imports
`robots.pattern_regex`. (7) `render_floor_seconds` per rendering collector, both None; `wait=` in every `rendered` log line;
no pace changed (`tests/test_render.py::TestSourceFloor`; `main/docs/COLLECTORS.md` "Pace per source").
**In flight:** nothing half-done. `dfp_b` untouched apart from reads.
**Next (B, two issues on the list):** `issue-product-page-link-the-brand-name...` (two one-line call-site edits) and
`issue-send-the-category-family...` (two lines in `category_counts`); the menu and the brand links switch on with them.
**Needs rian:** `decide-singapore-answered-403` unchanged. Two facts for it: the render wait is measured from the START of the
previous render, so the "ten-second" first run left the host about a second idle between nine-second pages; a slower retry
needs no deploy (`collect --source ishopchangi-sin --limit 20 --delay 30`), and on a yes `Changi.render_floor_seconds = 30.0`
makes it permanent. Doc warns left: ARCHITECTURE/DATA-MODEL/CLIENT-SURFACES are older than sources I touched; nothing they
say changed (checked), so no edit.

## 2026-09-05 (Sat) — Stream B (2): the requests other streams filed against B's files, all landed

**Landed (commits 5a5acba 69108f5 00cf418 dbb5764 7b0e542 d21a3e0 edf3461; `check.sh` green on the whole
tree, 805 tests; nothing deployed, no retailer contacted, the crawl window untouched):**
(1) `catalog_queries.publishable(db)` hides a verify-blocked shop from every catalogue query, `/api/stats`,
the sitemap and IndexNow (mechanism + rehearsal: `main/docs/SEO.md` "Which shops the site shows";
`tests/test_publication_block.py`); unblocks A13. (2) D's article pages: `/articles` head, `/articles/<slug>`
route + body + Article markup + seed, sitemap rows, feed items, the two `App.tsx` routes, an airport's write-up
in `AirportDetail.writeup` drawn server- and client-side; unblocks D2. (3) F's picker wired: card corner and
product page through `award_picker.pick`, `at=` on `GET /api/products/{id}`; unblocks F2.
(4) `/structure` airport and brand rows "built, deploying this weekend" (third `PageStatus`); ARCHITECTURE.md
step 3. (5) C's requests: `header_html` mirrors the mega-menu header (group, toggle, closed panel shell);
the excl badge tone; `SOCIAL_*_URL` settings feeding the shell, the header mirror and Organization `sameAs`.
**How verified:** rehearsals on a scratch copy `dfp_b` (cloned from A's `dfp_a`, upgraded to D's head,
seeded with an article, an LHR write-up, a draft, a failing check on a JFK listing); the served header,
product body and article body diffed token for token against headless Chrome's mounted DOM over `web/dist`:
identical (article: identical up to the JS subscribe form, omitted on purpose). `main/CHANGELOG.md` has four lines.
**In flight:** nothing half-done. `dfp_b` stays on `dfp-devdb` for reuse; drop it when done.
**Next for B:** `issue-send-the-category-family-with-api-stats` once A adds the two lines
(`issue-categorycount-family-and-family-label`); `issue-product-page-send-the-visitor-s-airports`
(queries.ts); after the deploy flip the two `built` rows to `live`. Every later SPA header change needs
the `header_html` mirror the same day (`tests/test_seo_body.py::TestHeaderMirror`).
**Needs rian / others:** `/trip` still shows a blocked shop (unowned issue, gates A13, two-line fix
named); QUALITY.md's stale sentence (Q); `SOCIAL_*` env lines when the URLs arrive (to-do). Deploy order
unchanged: A's migrations and backfills, then D's, then this; `alembic upgrade head` runs on start.

## 2026-09-05 (Sat, late afternoon) — Stream C pass two: mega menu, headline saving and exclusives band, social links behind the server, D's components placed

**Landed (commits c06a766 C2, 1034869, 6fa56d8 C3, 62ffa7e C4, c780f20; `check.sh` green on the whole tree):**
C2 `MegaMenu.tsx` in the header: categories from `/api/stats` (count >= 8, the browse page's floor),
the twelve biggest brands from `/api/brands` (column absent while the table is empty, fills after the
deploy), the visible airports from `/api/airports`; requests start on intent, never on page load; hover
intent, toggle, Enter/Tab/Escape, click-outside and navigation all verified in headless Chrome on two
dev copies (brands present and empty); 38 panel links all 200; nav row 47.9px open and closed; B's
mirror (7b0e542) diffs identical to the SPA's closed state. C3: the featured list's first pick printed
large (`HeadlineSaving`, every figure the API's), exclusives moved under it on a band with the live
total, product-page saving enlarged, `badge--excl` (B flipped the word in d21a3e0). C4: `SocialLinks`
render only for https URLs the shell injects as `window.__DFP_SOCIAL__`; nothing today. D's
`LatestArticles` and `SubscribeForm` (home band; compact form atop the footer on every other page)
placed; sample editorial and `lib/editorial.ts` gone. Contrast: gold text on `--gold-ink`, new
`--eyebrow-ink` token (dark surfaces set gold-light); the phone header's hidden CTA rule now wins.
**Measured (Lighthouse mobile, local, gzip):** product page perf 93 / a11y 100 / CLS 0.000 (was 96 a11y);
brand page 98 / 100 / 0.000; home 97 / 100 / CLS 0.025 (0.009 before: the pre-existing airport-picker
shift moved into the first viewport when the phone header lost a row; issue filed, unowned).
**Blocked:** C5 on `/plan`, "needs rian for sizes"; the sponsor slots are placed then (D4 waits too).
**Needs from B (running list):** `CategoryCount.family`/`family_label` so the menu groups by family
(menu already handles it); `SOCIAL_*_URL` env → shell injection + Organization `sameAs` + mirror.
**Not done, noticed:** `/favicon.ico` 404s on every page (B's §6 list); `Settings` still lists the
`editorial` sample key, which gates nothing now; `main.tsx` imports `app.css` after the components,
so a shared single-class rule beats every component override (two fixes tonight worked around it).
**Next for C:** C5 with rian (positions and IAB sizes, then `SponsorSlot` at each); dev copy `dfp_c` on `dfp-devdb`.

## 2026-09-05 (Sat) — Stream D: articles table + import, email capture, article pages and sponsor slot as components

**Landed (commits 89f33a0 000765e 198d952 75395b2 3724f38 d0bce83; `check.sh` green on HEAD, 765 tests):** migration **`a4b5c6d7e8f9`**
(revises A's `f3a4b5c6d7e8`; `articles` + `subscribers`; rehearsed up/down/up on `dfp-devdb`,
copy `dfp_d`, never the live DB; no backfill). `python -m app.cli articles import <file | --upload
id | --todo id>` (Markdown/text as written, Word via `mammoth`, pinned `mammoth>=1.12,<2`; drafts;
idempotent on slug; `publish`/`unpublish`/`list`); `GET /api/articles[...]` published only, one
Markdown renderer for API and server body (`services/markdown.py`); `POST /api/subscribers`
(throttled, honeypot, consent sentence + timestamp, no address ever logged; in
`mutating_routes.json`); `app.cli subscribers export|stats|unsubscribe` (owner only, never a
route). Components: `ArticlesPage`, `ArticlePage`, `ArticleCard`, `LatestArticles`,
`EditorialBlock`, `SubscribeForm`, `SponsorSlot` + `lib/sponsors.ts` (tsc clean, scratch server render).
**How verified:** 45 D tests (renderer safety, Word conversion, idempotent import, reads,
throttle, log check), CLI and API rehearsed end to end on `dfp_d` (md, txt, docx, `--todo`
uploads, export mode 600), migration rehearsal above. No retailer contacted, nothing deployed.
**Blocked:** D2 (blocked on `/plan`): **B** must add the `/articles` head, page route, sitemap
rows and the two `App.tsx` routes in ONE commit (the route test needs both), and Adam has handed
in nothing yet; D4 is done as a component, **C** places it with `LatestArticles` and
`SubscribeForm` (positions from C5). Both issues are on the running list, gating D2/D4. Assumptions: subscriber fields = the five Adam was asked to confirm;
mammoth's Markdown writer is deprecated upstream (isolated in `_read_docx`, one-function swap).
**Post-deploy:** `alembic upgrade head` (runs on start); then RUNBOOK "Content" for imports.
**Next:** when B's wiring lands, import Adam's hand-ins from `/todo`, publish, check
`/articles/<slug>`; when C5 settles sizes, register creative in `lib/sponsors.ts`.

## 2026-09-05 (Sat) — Stream F (afternoon): awards strategy published; picker and rebuild by natural key landed; pin waits for `overrides`

**Landed:** F1 (a21766d): the one-page strategy for Adam on `/discuss` as decision card 5 (item 40,
full text in its thread) and at `notes/awards-strategy-for-adam-2026-09-05.md`; the ask is
`do-adam-awards-strategy` (rules yes/no, aging on/off, latest-vs-best-ever). F2 (e2d50a3 + the
checkpoint commit): `app/services/award_picker.py` (retained = latest result per competition; featured =
level, year, the visitor's airports, judges' score, competition name; pin by natural key with a stale
fallback; aging behind `AGING_ENABLED=False`, listings only); `awards_import.py` reconciles by
`(product_id, competition_slug, year, medal)` so `--rebuild` never renumbers (rehearsed on a throwaway
copy of the dev DB: 309 of 309 kept rows keep their id, 50 created, 2 corrected in place, 111 removed,
a second rebuild is a no-op); two entries on one bottle in one competition-year store nothing; the
brand gate needs every distinctive brand word and ignores category, producer-type and nationality
words; ages glued to yr/yo are numbers. 78 tests across `test_award_picker.py`, `test_awards_import.py`,
`test_awards_matching.py`; `check.sh` green. Review sample of what a rebuild changes:
`.logs/verification/awards-matching-review-2026-09-05.md` (two other producers' Double Golds become
the bottles' own medals; most removals were one shared brand word).
**Not wired:** the card corner and product page still run the old best-tier loop; the call sites are
B's, exact code in `issue-wire-award-picker` (three edits; `seo.py` needs nothing).
**Blocked:** the pin needs `overrides`, which is R's migration #4, not A's #3: `decide-overrides-before-r`.
Plug-in points: `catalog_queries.get_product` / `_top_awards` → `award_picker.pin_from_override` →
`pick(pin=)`; the stale-pin `reverifications` write belongs in `awards_import.import_awards` after
`reconcile_awards` (a GET never writes). F2 is `blocked` on /plan with this note.
**After deploy (rian):** `python -m app.cli awards --rebuild`, then read the review sample.
**Open for F:** `issue-award-shorter-entry-on-variant` (Bacardi Ocho medals on the Rye Cask Finish,
the one-sided extras rule) and the medal artwork gaps. `Medal.tsx` and `featured.py` untouched.
**Next:** B wires the picker; Adam answers card 5; the pin lands when `overrides` does.

## 2026-09-05 (Sat) — Stream A Wave 2: targeted forty, beauty widening, migration #3, brand fold and merges (A7, A6, A8, A9 done)

**Landed (commits fcdb0a7, 3d5f3f1, b46e3f8, 758233e, 9c57e5e, bd20d0d, 73362e1; 750 tests, doc gates 0 fail):**
A7 `.logs/runs/beauty-candidates-2026-09-05.md` + `import/beauty-targets.json` (20 fragrance / 20 skincare,
every fold at 3 to 6 stores; Paris GTIN rows, Athens, the four Shopify feeds read after their log said
finished, Changi's seeded slugs, Dublin's sitemap read after 13:37; Avolta page-1 tiles not read: its refresh
runs on). A6 Avolta walks drinks and beauty as two trees with logged caps, beauty page 1 only, product pages
only for targeted families, page-1-only stores re-read held URLs (`wants_held_listings`); Dublin `/beauty/`
targeted; Shopify Spanish and beauty shelves, shop-hidden rows skipped; Extime capacity cross-check on the live
shape (the 7,624 ml mist is the fixture). A8 migration `f3a4b5c6d7e8` (brands, brand_id, size_value/unit,
attributes, identity_rules_version, merged_into_id, merge_candidates, product_merges, reverifications),
rehearsed up/down/up; ingest folds the brand, vetoes on concentration, follows tombstones;
`resolve_product_id()` follows `merged_into_id`. A9 match_key v2 (folded brand), `rederive`, backfills
brands/sizes/categories/merges/implausible_sizes, rehearsed twice on a clean copy (`dfp_a` on dfp-devdb): 2,215
brand rows, 1,032 categorised (launch uncategorised 260 to 121), 2,512 re-keyed, 40 groups / 44 rows merged,
223 groups left as candidates; second pass a no-op. Docs: COLLECTORS.md, DATA-MODEL.md, RUNBOOK.md, CHANGELOG.
**Deploy (evening, after the crawl window; dump first):** `alembic upgrade head`, then in this order
`backfill implausible_sizes`, `backfill brands`, `backfill sizes`, `backfill categories`, `rederive`,
`backfill merges`, with no collection running (`rederive` re-keys, `merges` re-points listings).
**Measured:** Athens page-1-only wrote 154 prices today against 135 on 25 Aug; 73 of its 227 held listings
were not on page 1 and are what the new re-read fetches. LHR's number comes from tonight's run counters.
**In flight:** A10 (planning session's run). **Not done:** `app.cli images import <dir>` (Wave 2 list, not
today's); the first widened run needs the deploy and an announced window.
**Needs rian (running list):** Makeup as a category (assumed yes); all Shopify beauty rows kept, not only
the forty (assumed yes). Issues filed: Q (audit skips tombstones, re-pin thresholds), B (brands table ready,
Makeup), A2 (`pattern_regex` import).

## 2026-09-05 (Sat) — Stream B Wave 2, afternoon: airport pages, the crawl surface, brand pages (B4, B7, B5 done)

**Landed (commits `01f6001` B4, `eb85201` B7, `7094b48` B5; `check.sh` green on the whole tree,
765 tests):** `/airports/<iata>-<city>` and `/brands/<slug>` server-rendered like a product page
(CollectionPage about an Airport or Brand + ItemList + BreadcrumbList, sitemap entries, filters
and paging canonicalising to the clean page, alias/stale slugs 301, hidden or thin ones 404),
backed by `GET /api/airports[/{iata}]` and `GET /api/brands[/{slug}]`; `/robots.txt` with Content
Signals and per-bot groups from one switch table; `/feed.xml`; `/llms.txt`; `/data` with Dataset
markup; `app.cli indexnow` (A registered it). Mechanisms and how to verify each: `main/docs/SEO.md`.
URL shapes live in `app/services/urls.py` (mirrored by `lib/urls.ts`); hub shapes in
`app/models/hubs.py`; A's `schemas.py` untouched.
**How verified:** served roots of `/airports/lhr-london` (plain and filtered page 2), `/data` and
`/brands/johnnie-walker` diffed token for token against headless Chrome's DOM of the same URLs
on the dev copies (`dfp_q`, then A's `dfp_a` with brands folded): identical, and the SPA used the
seeded object (no API request). 301/404/HEAD/304, robots parsed by our own matcher, the IndexNow
payload and a dry run (no network) are all pinned by tests.
**In flight:** nothing half-done. B6 waits on rian's page-types answer (`decide-page-types-18-sep`).
**Next for B, in order:** (1) `issue-hide-blocked-shop-rows` (blocks A13); (2) Stream D's
request, article pages in `seo.py`/`main.py` (blocks D2; D's `editorial.py` and routers are
committed, so it can start); (3) product pages link to brand pages once A adds `brand_slug` to
`ProductSummary` (`issue-add-brand-slug-...`); (4) after the deploy, flip the airport and brand
rows on `/structure` from "build" to "live".
**Needs rian:** two info-weight items with the assumption taken: the AI-training stance in
robots.txt (assumed no; search and AI answers yes) and a licence or download for `/data`
(assumed neither). After deploy: mint `INDEXNOW_KEY` in `.app.env`; E's zone checklist must keep
Cloudflare's managed robots.txt off or the edge overrides ours.

## 2026-09-05 (Sat) — reader identity page live as its own project; running list ranks by the plan

**Landed:** `/srv/apps/dfp-bot` (static nginx, `public/index.html`, CSP no-scripts, autoindex off,
Caddy site marked PUBLIC via `srv-gw id-gate --mode public`); the reader's identity link is now
`+https://bot.dutyfreeprofessor.com` in `fetch.py` and `images.py` (deployed; the page's UA line
matches `fetch.USER_AGENT`). **DNS is rian's:** the zone is in Adam's Cloudflare account, so the
gateway could not add it; `bot` CNAME `mosiah.riverway.ca`, DNS only (to-do on the running
list, gates A10). `bot@dutyfreeprofessor.com` needs Email Routing (to-do, gates A13).
Running list: urgency derives from `blocks` + `weight` against live task status; "Before you go
further" holds rian's items only. Rian decided on the page: $50 hosting hold, zone in his access,
Seoul stays, Shilla discount tier, verify additions accepted (with a caveat), hostname; two
question-items were answered in their threads and reopened (Changi tag manager still open).
PUBLIC_BASE_URL set and verified (absolute canonicals + sitemap). Changi hand check confirmed.
**Rule for the identity page:** every sentence there must stay true of the reader; change it the
same day the reader changes (`/srv/apps/dfp-bot/agents.md`).
**Next:** rian adds the CNAME, then tonight's Stream A run; first SIN run after the tag-manager
decision; E session when rian has time (E brief carries his hosting and zone decisions).

## 2026-09-05 (Sat) — /plan gets the running list: Issues and to-dos tab; items.py replaces the hand-edited registers

**Landed:** `import/items.json` (43 items: 12 to decide, 7 to do, 22 issues, 2 closed) seeded from
today's decisions file and the issues register, both archived under `.archive/`; the two
markdown files are now GENERATED by `python3 main/scripts/items.py export` (never hand-edit).
`app/services/items.py` merges the file with rian's page actions (`owner_item_states`,
migration `e2f3a4b5c6d7`, schema only; **Stream A's migration #3 revises from it**). Routes:
`GET /api/items`, `POST /api/items/{id}/decide|done|dismiss|reopen` (in the mutating-routes
fixture). `/plan` has tabs (Progress · Issues and to-dos, deep link `#items`); every item has
a FeatureThread (`item:<id>`), decide items take a decision sentence. `/issues` redirects.
Deployed 5 Sep evening after a dump; verified: migration at head, API 200, 404/422 paths, one
decision recorded and read back with `items.py list --decisions`, page checked in a browser.
**Session workflow changed:** checkpoint step 2 is `items.py sweep` + `resolve`/`add`;
overnight rules and every stream command point at the script; `list --decisions` at orient.
**Next:** rian clears the decide list on the page; sessions read it at start. Docs task: a
`docs-check` warn when `items.json` is newer than the generated mirrors (not built).

## 2026-09-05 (Sat) — Stream Q: audit, migration #2, verify, QUALITY.md (Q1–Q4 done, green and ready)

**Landed (five `Q:` commits, `check.sh` green, 439 tests):** `app.cli audit` (fifteen metrics,
thresholds pinned to today's dump in `THRESHOLDS`, review lists, snapshot in `.logs/verification/`
and `audit_snapshots`); migration #2 `d1e2f3a4b5c6` (verification_runs/checks with
cleared_at/cleared_by, audit_snapshots, rejected_observations, `collection_runs.skip_counts`),
rehearsed up/down/up on a fresh dev copy `dfp_q` of this morning's dump (left in place on
`dfp-devdb` for your own rehearsal); `app.cli verify` / `verify-status` / `verify-clear`;
`main/docs/QUALITY.md`; the Mon 21 checklist generator (`main/scripts/review-checklist.py`).
**How verified:** pure tests (85 new) plus a no-network rehearsal of verify on `dfp_q` with a
stand-in reader: every verdict, the abort-at-first-BLOCKED, the block, the clear, spot-checked
flipping after a clean run. **No retailer was contacted.**
**What the first audit found (for A):** `parse_size_ml` reads a number in a beauty name as a
size (Extime "DELICIA DRENCH 59" → 5,924 ml, "CHEIROSA 76" → 7,624 ml) and two Extime rows
are the 7-litre misread ("15yo Sherry Cask", "18yo Port Cask"); three feeds never say sold out
(Bordershop, Dubai, Panama); 10 mergeable duplicate groups; 122 brand folds. All in the
checklist and the snapshot. **Deploy:** dump, deploy, `alembic upgrade head`, no backfill.
**Next:** first real `verify --n 20` the morning after the drinks refresh (rian's call);
the public-site block query and the E cron lines are Requests in `decisions-for-rian.md`;
Q5 is the 21 Sep review itself, regenerate its checklist from a fresh audit that day.
**Need from rian:** decisions #11 (FETCH_ERROR verdict, verify writes observations, block as an
uncleared check); review the three small touches in A's files (`cli.py`, `catalog.py`,
`ingest.py`, listed under Requests).

## 2026-09-05 (Sat) — Stream A2: browser sidecar, render(), Seoul confirmed possible, SIN and ICN collectors built

**Landed (R1–R3 done on /plan; commits b826e64, 40e839e, 5b60e3e, 14cc4de, bf608e1, bcde568):**
`main/browser/` sidecar (Playwright Chromium, own compose network `render`, no env_file, no
port, `cap_drop ALL`, `no-new-privileges`, renderer sandbox ON via `browser/seccomp.json`;
verified: sandboxed launch under those flags, a public page rendered, an IP literal refused);
`fetch.render()` (BOT_NAME identity, robots fresh or the run's `Robots`, 10 s floor, per-run
`render_budget`, content-not-status refusal, the host's Disallows applied to the page's own
requests, `wait_for`, console capture, `RenderRefused` carrying what arrived); `changi.py`
(SIN, 622 targeted URLs in `import/changi-targets.json`, Departure tier in SGD, no barcodes on
that source); `shilla.py` (ICN, server-rendered pages, discount tier in USD, walk = home page
+ rails, 200 renders/run). 461 tests pass; `check.sh` green; COLLECTORS.md has the rendered-
fetch section, two recipes and posture rows. HKD/MXN fallbacks added.
**Measured (`.logs/runs/shilla-probe-2026-09-05.log`, `changi-probe-2026-09-05.log`):** Shilla
product page HTTP 200 to the browser, full document; its grids need robots-disallowed scripts.
Changi draws only with `assets.adobedtm.com` declared; prices per channel; no barcode anywhere.
**Green and ready; not deployed.** `srv-gw deploy --build` adds the `browser` service and a
second network. R4/R5 stay `doing`: the first SIN/ICN runs need the deployed sidecar, an
announced window, and rian's tier check.
**Needs rian (`.logs/decisions-for-rian.md` #11–#14):** tell Adam Seoul is possible; Shilla
price tier (discount, assumed); the Changi tag-manager host (assumed acceptable); the JW Blue
Label hand check at S$271.10 before the first SIN write. Requests filed for A (`pattern_regex`).
**Next (A2):** after deploy, `collect --source shilla-icn --limit 20` then `ishopchangi-sin
--limit 20` in an announced window; read the first raw records. Not done: a listing-page walk
for the full Changi catalogue (category sitemaps exist, unprobed).

## 2026-09-05 (Sat) — /plan: promises board, rian's actions, dated roadmap

`import/progress.json` gained `quote` (16 accepted lines → task ids), a `due` ISO date on every
task, and `rian: true` on the thirteen things only rian can do. `/api/plan` derives each promise's
status from its tasks (all done → delivered; any blocked → at risk; any doing/done → in progress),
so the board can never say more than the task list — **sessions keep using `plan-set.py`; nothing
else to maintain.** New E tasks E4b/E4c/E7, plus A0 (Paris, done), A13 (switch the nineteen on,
Fri 11), C5 (brand pass two + sponsor sizes, Wed 9), I1 (wave-4 integration, Thu 17), P6
(delivery walkthrough, Fri 18). Production path as dated: egress test Sun 7 → sizing + provision
Tue 8 → first deploy Wed 9 → domain/Cloudflare Thu 10 → go-live checklist Wed 16. Verified in
the browser (mobile viewport; desktop grid is CSS-only and untested visually). 65 tasks,
1/16 promises delivered (crawlable HTML), 23% of active work done.

## 2026-09-05 (Sat) — to-do page reshaped per rian: comments everywhere, files only for text, questions section

Migration `c8d9e0f1a2b3` (client_todos.kind todo|question, accepts_files) deployed with a
dump; **Q's migration #2 now revises from c8d9e0f1a2b3.** Every item carries a
FeatureThread (`todo:<id>`); uploads only on articles, airport write-ups, category paragraphs
(409 elsewhere, verified); images go to a shared Drive folder; a `waiting` status renders
greyed ("not ready yet"), used for sponsor creative until the banner sizes are decided in the
brand pass. Removed: domain (decided dutyfreeprofessor.com), airport list (nineteen decided),
brand priorities (covered by /structure). New "Issues and questions" section: the Dubai /
hosting-blocks question in rian's voice replaces his draft email (five shops, Akamai and
Shopify protection, well over half the launch catalogue, stay transparent through the demo,
DFH's possible data route worth asking about). Seed script is now authoritative for seeded
rows (upsert + REMOVED list) and leaves hand-added rows alone. 10 items live.

## 2026-09-05 (Sat) — client to-do page with uploads; structure questions moved to the bottom

**Landed:** `/todo` (footer link "Your to-do list", noindex, flag `feature_client_todos`):
items per owner (Adam / Mark / both), due dates, "Mark complete" / "Reopen" with the typed
name the comment boxes use, and file hand-in per item. Uploads are sniffed by bytes (JPEG,
PNG, WebP, GIF, PDF, real docx, UTF-8 md/txt; everything else 415), capped at 25 MB, stored
under a random name in `uploads/<todo>/` (workspace dir, bind-mounted rw at /srv/uploads,
gitignored), JPEG/PNG re-encoded to drop metadata, served only via `/api/todos/uploads/{id}`
with pinned type + nosniff + attachment. Migration `b7c8d9e0f1a2` (client_todos,
client_uploads) deployed with a dump first; **Q's migration #2 must revise from
b7c8d9e0f1a2.** Seeded 13 items from the quote's NEEDED list + the reply pass (script:
`main/scripts/seed-todos.py`, run via `docker exec -i … python - < …`; scripts/ is not in
the image). Deps added: python-multipart, Pillow. Route inventory updated (+3).
Verified: 201 upload, 415 for an ELF named .png and a zip named .docx, 200 download with
`image/png` + nosniff, complete/reopen 200.
**Structure page:** the five questions now sit at the bottom with a jump link at the top.
**Two lessons:** an unguarded `rm -rf uploads/$TID` with an empty variable deleted the
bind-mounted host dir and broke uploads until a container restart (guard every rm; the
mount re-binds only on restart). The app container runs as root, so uploads land
root-owned on the host (issues.md P2).

## 2026-09-05 (Sat) — structure page rebuilt from rian's review; migration #1 deployed

**Landed:** `/structure` rebuilt from rian's 20 comments and his replies: five questions
(comparables-first, brand floor of two, three families with a page each, URLs, missing),
an "Already committed" block, live figures from /api/stats instead of typed numbers, broad
estimates everywhere (about a thousand rich pages at soft launch under the comparable-first
rule), a Family page in the field inventory and schema plan, FAQ as optional editorial (no
callout), tightened beauty/growth/brand sections, three-line blockers. Mark's wireframe
folded in (city-named airport pages, "Featured in <city>", filters, trust block, Explore /
About footer groups, evergreen pages). All 33 structure comments cleared (rian's + mine).
New owner-only `/issues` page (unlinked, noindex) rendering `import/issues.md`, published by
`main/scripts/publish-issues.sh` and by the project `/checkpoint`.
**Deployed 0.32.0** (dump first: `backups/dfp-2026-09-05-pre-migration-1.dump`). Migration #1
applied (`0a1b2c3d4e5f`: accounts, raw_records, source_kind, identity_mode, visible=False).
Backfills run in A's order: accounts (owner row), vertical (4,263 products), stuck_runs (8),
locations (MEX currency), orphan_tiles (25 listings purged, 12 empty products removed),
image_provenance (1,354 relabelled). Doc gate now fatal-consistent (version == CHANGELOG).
**Next (rian):** read `/structure` once more; supply the one sentence on which page types
ship 18 Sep (plan §10 #10) or accept "all six where thresholds are met"; send Mark the URL
+ password with comments due Thu 10. Live-strip caveat: its percentage is over the airports
switched on (4), the prose's "one in five" is launch scale; labelled accordingly.

## 2026-09-04 (Fri night) — Stream A first pass: robots policy, migration #1, raw records, read_one, defect list

**Landed (A1–A5 done on /plan; commits 56c1075, 12131cf, 5a281cc, 92405f2, 1481656, df35e51):**
`collectors/robots.py` (one matcher, by-name groups, wildcards, crawl-delay, the three unreadable
outcomes) wired into every `collect()`; migration #1 `0a1b2c3d4e5f` (accounts, raw_records,
source_kind, identity_mode/permission_record, visible=False, fx_rate/fx_source) run up/down/up on
dfp-devdb; `RawListing.raw/vertical` on all seven collectors, ingest writing `raw_records` (stub
run on devdb); `read_one()` on Avolta/Shopify/ARI/Extime (Avolta tested on a real Athens product
page), refusals on Dubai and Heinemann; the §3a defect list (veto rejects, per-fetch observed_at,
FX persisted, stuck-run sweep, location currency sync, Dubai stock None, Extime capacity
cross-check, image provenance split); collector skip hook; `brand_key()`. Six backfills, each
run twice on devdb. Verified: 304 tests, `check.sh` green. Docs: `main/docs/COLLECTORS.md`
(robots, read_one, defects). **Green and ready; not deployed.**

**Measured, needs rian (in `.logs/decisions-for-rian.md` #10 and Requests):** all four
Shopify hosts and Dubai answer 403 to our identity at robots.txt (200 to curl); the ratified
policy makes them refusals, so they go BLOCKED on their next run. LHR/ATH/MAD/BCN are page-1-only
now (their `/*?` rule is honoured). Deploy order Sat: dump, deploy, `alembic upgrade head`, then
`backfill` accounts, vertical, stuck_runs, locations, orphan_tiles, image_provenance.

**Requests filed:** A2 imports `BOT_NAME` into `fetch.py`; A2 adds HKD/MXN to `fx.py` fallbacks.

**Not done tonight:** targeted-40 beauty discovery (needs sitemap fetches; Shopify now refuses
us anyway); Extime `price_per_quantity`/`net_weight` live shape is unpinned, read it from the first
raw record after the next Extime run. **Next (Wave 2, Mon):** A6 beauty widening, A7 targeted list,
A8 migration #3 on `brand_key()`.

## 2026-09-04 (Fri night) — Stream Docs: hygiene, tier-1 trims, main/docs shells, the doc gates

**Landed (X1–X3 done on /plan; commits c2ea2cd, f258ac3, 6a3dc8d, plus the sweep):** `brief.md`
40 lines with the NOW block; `agents.md` 129 lines, rules only, robots rules merged, decisions
as §9 pointers; `main/docs/` ARCHITECTURE, DATA-MODEL, COLLECTORS (guardrails moved in, robots
policy, pipeline), SEO and QUALITY shells for B and Q, RUNBOOK, CLIENT-SURFACES, each opening
"Sources of truth"; `main/scripts/docmap.py` generates the module map, table map and CLI
reference into marked blocks; `main/docs-check.sh` (FAIL: agents budget, stale block, version
disagreement; WARN, `--strict` fails: doc older than its sources, unswept issues, handoff entry
over 25 lines); `check.sh` regenerates the maps then runs the gate quietly, about 8 s total;
project `/checkpoint` command loads from this cwd. `.logs/issues.md` swept and stamped;
`.logs/verification/docs-2026-09-04.md` is the first sweep report. The old folder's
`CLAUDE.md` redirects here; `alias dfp` is in RUNBOOK.
**Verified:** check.sh green on every commit (tests were 224 at the last run); docs-check
standalone shows exactly one FAIL; docmap output identical under `COLUMNS=40`.
**Found:** `app/version.py` is 0.31.0 while CHANGELOG and last night's handoff say 0.32.0 and
the container reports 0.31.0. The gate caught it on its first run. It stays advisory inside
`check.sh` only while that lag exists and turns fatal by itself once the two agree.
**In flight:** nothing.
**Next:** A fills COLLECTORS (per-platform recipes, posture table), Q fills QUALITY, B fills
SEO; X4 (WARN gates strict, weekly sweep) is buffer work; twelve pre-Decision-11 handoff
entries exceed 25 lines and are left, two are worth distilling into COLLECTORS.md.
**Needs rian:** bump `main/app/version.py` to 0.32.0 and the NOW-block version with it
(Requests in `.logs/decisions-for-rian.md`). No decision blocked this stream.

## 2026-09-04 (Fri night) — Stream B: B1 B2 B3 done, green and ready; hub pages next

Landed (four `B:` commits, 309 tests, `check.sh` passes; not deployed):
- **B1** product pages carry their body in the served HTML (H1, meta line with brand and
  category links, medals, verdict, price table; header nav mirrored so the hero does not move
  when React mounts) and seed the detail as `window.__DFP_PRODUCT__`, so first paint is the
  finished page. Verified: curl shows name and prices with no JS; JS-on and JS-off screenshots
  match; Lighthouse local mobile, gzip both sides: CLS 0.003 to 0, LCP 3.2s to 3.0s, perf 93.
- **B2** §6 corrections, all in `seo.py`/`main.py`/`config.py`: `PUBLIC_BASE_URL`, absolute
  JSON-LD URLs and `@id`, gtin by width, availability only when observed, per-shop Offers,
  Organization, no SearchAction, real 404s, HEAD, weak ETag + Last-Modified with 304,
  `no-store` on HTML and JSON, gzip in-app. Route layer moved into `mount_site()` and tested
  over a temporary shell with no DB (`tests/test_site_routes.py`). Mechanism: `main/docs/SEO.md`.
- **B3** `/structure` swept: sixteen collected today / nineteen at launch, live linked page
  count in the SiteMap, SearchAction line aligned, cost phrases replaced, beauty counts from
  the live DB. One bracketed placeholder for rian remains in the intro (§10 #10).
- Extras: `resolve_product_id()` consumers 301 (identity until migration #3); nine unauthenticated
  POST routes pinned in `tests/fixtures/mutating_routes.json` for Stream R.

Need from rian: `PUBLIC_BASE_URL` in `.app.env` before the deploy (decisions file, Requests);
fill the `/structure` placeholder before sending it to Mark; bump `app/version.py`.
Touched outside Owns (unowned by any stream): `ProductPage.tsx`, `api/queries.ts`,
`catalog_queries.py`. Stream C: `header_html` in `seo.py` mirrors `SiteHeader.tsx`; a header of
a different height re-introduces layout shift, so tell B when the mega menu lands.
Next (Wave 2): airport and brand pages once the brands table exists; IndexNow from the collector.

## 2026-09-04 (Fri) — overnight machinery: /plan page, briefs, slash commands, cron

Landed and verified: nightly pg_dump cron (03:15, 14-day retention; installed in rian's
crontab); `/plan` page (unlinked, noindex) reading `import/progress.json` per request, proven
to update with no deploy; `main/scripts/plan-set.py` (locked, atomic status writes);
`.logs/decisions-for-rian.md` (sessions record rian-only questions + the assumption they
proceed under; never stop). Briefs rewritten against plan v3: A, B, Docs (new), Q (new);
`OVERNIGHT-RULES.md` is the one home for session conduct (ownership, no deploy, no crawl,
delegation to sonnet/haiku for mechanical work, progress recording, the loop). Five project
slash commands in `.claude/commands/`: /stream-a, /stream-b, /stream-docs, /stream-a2,
/stream-q, each run as `/loop /stream-x` from the project folder. C/D/E/F briefs remain
STALE-bannered until rewritten Saturday. Assumptions the overnight sessions run under
(rian decides Sat morning): §10 #1 unpaid work deferred except cheap enablers; §10 #2 Fri 11
on the quote's own bar. Deployed 0.32.0+plan page at 22:0x; nothing was running.

## 2026-09-04 (Fri) — plan reviewed adversarially; v3 written; 15 items await rian

Sixteen agents (eight lenses, eight refutations): 69 findings kept, 27 dropped. Verified
first-hand before acting: 4 Sep is FRIDAY (v2 said Thursday; waves relabelled); the robots
matcher ignores a group addressed to DutyFreeProfessorBot (keys on "mozilla"); a bot-host
alias on the app container would expose the whole app ungated; four Avolta stores forbid
`/*?` (LHR ATH MAD BCN); the DB had no backup since 24 Aug (dump taken: 1.46 MB); the
structure page still says "558 / five airports", "SearchAction already ship", "free".
Applied without decision: plan v3 (dates, migration order table with dump-first, A2 row, A
re-cut, Wave 1 recollection -> targeted + drinks-refresh exemption, Decision 3 bot page as
its own static project, Decision 6 attribute-veto + brands table + merged_into_id, Decision 7
unified overrides + natural-key award pin, Decision 10 tripwire/rolling/BLOCKED/labels,
robots policy, verify per host, sampler v1, human review Mon 21, CHANGELOG Unreleased rule,
schema-only migrations); STALE banners on six briefs; A2 brief hardened (isolation, egress
allowlist, floor delay, no dialogs, price tier, visible=False, Shilla probe first);
agents.md 244 -> 180 with pointer rules; CHANGELOG Paris entry versioned 0.32.0.
§10 lists the fifteen owner items; #1 (scope vs capacity) and #2 (the Fri 11 milestone) are
the ones that change the build. KICKOFFS and briefs are rewritten AFTER those two.

## 2026-09-04 — Decisions 12 and 13 locked; all thirteen decided

Owner area v1 = five surfaces (issues, summaries, posture, inbox, drafts), /structure moves
behind it. Capacity = two sessions, three briefly, fixed deploy times. Plan status updated.
KICKOFFS.md is stale and must be rewritten against §9 before any new stream starts (A2's
prompt is current). Rian has one more item before the kickoffs are written.

## 2026-09-04 — Decision 11: docs structure adopted with mechanical vigilance

Gates in check.sh, one home per mechanism, /checkpoint duties, weekly doc sweep, code-comment standard, sessions open in this folder. Plan §9 #11, agents.md.

## 2026-09-04 — Decision 10: 95% to go public, dates only on the site; revisit after two weeks of real data

Rian flagged lower confidence in this one; QUALITY.md owes a plain-language explanation. Plan §9 #10.

## 2026-09-04 — Decision 9: owner inbox first, standard in Wave 2; lowest priority so far

Rian can manage with the current comments; the inbox is an efficiency gain. Plan §9 #9.

## 2026-09-04 — Decision 8 locked: articles are a table with status, not code

Stream D's migration; markdown import command for now; editor later. Plan §9 #8.

## 2026-09-04 — Decision 7 locked: overrides + decided_by; disagreements scored and queued

Rian's caveat recorded: collector disagreement → meaningfulness score → reverification queue;
humans clear a few dozen; AI re-ranks the rest from their verdicts. Plan §9 #7, issues.

## 2026-09-04 — Decision 6 locked, with three standing principles from rian

Identity fields for growth confirmed (vertical, size+unit, per-vertical attribute, edition
tokens in identity; splits 3 products). Principles recorded in plan §9 #6, agents.md, issues:
(A) everything re-doable → store raw records (confirmed: discarded today, `\d price_observations`
has no payload), versioned rules, `rederive` command; (B) human merge queue, merges as recorded
actions with redirects; (C) human-verified values survive recrawls, disagreement re-queues.

## 2026-09-04 — Decision 5: build rendered fetch now, try Seoul AND Singapore

Rian: A for both, start immediately, report to Adam with confidence; if Shilla still refuses,
propose a different airport in its place. Design notes: browser fetch is per-URL and targeted
(never whole catalogues); human data-entry fallback + likely-stale mechanism for the back
office (observations carry source_kind). Brief written: streams/A2-rendered-fetch.md.

## 2026-09-04 — Seoul and Singapore both need a browser (probed first-hand)

Shilla: Cloudflare challenge on every product page despite `Allow: /`; empty sitemap.
Changi: client-rendered shell; data API exists but is gated by a client-issued token (`s` from
storage `93gma2jf`); bare call 301s home. Neither is reachable by plain fetch. Both recorded
P1 in issues.md. Decision 5 is now about the rendered-fetch capability for BOTH, not SIN alone.

## 2026-09-04 — Decision 4 reframed by rian: launch set is 19; targeted beauty first

I had framed "does Paris join the sixteen" as a decision. Wrong: the quote promises three new
collectors, so the set is nineteen and Paris is delivered. Beauty must be collected at the
existing sixteen too (Avolta widening reaches ten at once). Rian's collection strategy: no
full crawls until structure is locked and all collectors work in all categories; target 20
comparable products per beauty category with targeted fetches at every airport that stocks
them; full crawls last, to add more before launch. Plan §9 #4 and Stream A updated.

## 2026-09-04 — two design notes from rian recorded

(1) Per-source identity mode gated on a recorded permission (consent converts a refusal;
UA change is the ceiling; bot page stays the default face). In plan §3b, agents.md, issues.
(2) AI-assisted QA later: a local model PROPOSES into the candidates queue, humans confirm
in batch (brand merges, draft descriptions, inconsistency lists). Plan §3b note + issues
parked. Both shape the `decided_by`/candidates schema now.

## 2026-09-04 — Decision 3: Dubai path A; docs must carry the collector list

Bot identity page on its own ungated host this week (Stream A), UA repointed; the ask to
Dubai when rian judges the relationship ready (Cannes). Checked: gating is per host with no
per-path exception, so the page cannot live on the demo host; `dutyfreeprofessor.com/bot`
(Adam's domain) is a Cloudflare 403 today. Rian also asked that documentation maintain a
living list of collectors and of issues: COLLECTORS.md (generated source table + posture)
and issues.md (register, last-swept line), both /checkpoint duties. Recorded in plan §9 #3.

## 2026-09-04 — Decision 2 locked: app-owned auth, FastAPI + React, one domain

Reaffirms poc-dfp D4. Researched with ten agents and verified first-hand: id-auth has no
signup (`allow_signup` removed as a no-op, id_auth.py:97-100), Google never provisions, the
gateway refuses Pattern B redirect hosts without a Caddy conf here (gateway.py:13272-13285),
react.md's `X-Auth-Grant` is emitted by nothing (only `X-Auth-User`, id_auth.py:1437).
Django weighed fairly: strong for an admin-heavy back office, but the rulebook forbids a
React SPA on Django and the SPA is the product. Rian asked whether this was sunk cost; the
honest answer is partly, so a named post-Cannes checkpoint costs Django against the real
admin. agents.md "Unresolved" block replaced with the decision. Plan §9 #2 marked decided.
Nine unauthenticated write routes (quote x2, discussion x6, sources kill switch) are the
first thing this auth must protect before any move off-server.

## 2026-09-04 — Decision 1: Paris hidden on the demo

`app.cli locations --hide CDG`. Visible set back to EZE, HKG, JFK, LHR; /api/stats 4 locations,
500 products, Whisky-led; sitemap 506 URLs (was 7,334). No deploy needed. Root cause is the
model default `visible=True` on Location (models/catalog.py); a first collection publishes a
shop by default. Stream A first pass: default to hidden. Rian: "we'll turn it on deliberately."

## 2026-09-04 — build plan v2 written; two corrections to last night's record

Plan v2 at `.logs/planning/build-plan-2026-09.md`, grounded in a six-lens read-only map
(collectors, built-vs-planned, docs, SEO, comments, quality). Adds Streams Q (verification)
and R (owner area), a Docs duty, the override/decided-by data shape, and thirteen decisions
for rian in §9. KICKOFFS.md is stale until those decisions land.

**Correction 1.** Paris (CDG) is PUBLICLY VISIBLE on the demo Adam and Mark watch, since
2026-09-03 22:47 UTC, because ingest creates locations with the model default
`visible=True`. Last night's entries said Paris "is not in the launch sixteen" (true) and
omitted that it was live on the demo (5 airports, 7,328 products, Fragrance leading). Not a
decision anyone made. Rian decides (plan §9 #1).

**Correction 2.** Four of the "nine beauty comparisons" (CK Be, Creed Aventus, La Vie Est
Belle, Prada Paradoxe) rest on orphan Avolta `-P` configurable tiles with no `::size`
sibling, so their size attribution was unguaranteed. Live-read the CK Be page at Athens
today through our own fetch: 100 ml = EUR 19.95, was 47.80 (50 ml 15.95, 200 ml 29.95). The
number stands; it is a real clearance, and the tile price matched the stated size. The
mechanism could not have guaranteed that, which is why the 25 orphan tiles are on Stream A's
first-pass list. The La Vie Est Belle 89.95 -> 43.70 -> 96.60 same-day flips are more likely
promo toggling than a markdown; described as a markdown last night, treat as unexplained.

## 2026-09-04 overnight — beauty price comparison ALREADY WORKS (Athens x Paris)

Athens holds 21 beauty products (uncategorised, `eau-de-*` listing URLs), overlapping Paris on
17 brands; 9 match exactly on match_key, giving 9 real cross-airport beauty comparisons already
in the database, no new collection needed:

Sisley Sisleya L'Integral 50ml Athens $503.03/Paris $356.44 (Paris -29%); Hugo Boss Dark Blue
75ml $23.30/$34.85 (Athens -33%); Calvin Klein Be 100ml $23.30/$31.36 (Athens -26%); Dior Miss
Dior Body Mist 100ml $64.28/$51.76 (Paris -19%); Creed Aventus 100ml $346.89/$287.58 (Paris
-17%); Lancome La Vie Est Belle 50ml $112.83/$95.00 (Paris -16%); Prada Paradoxe 90ml
$134.32/$154.25 (Athens -13%); Dior Sauvage Stick Deo 75g $47.13/$42.19 (Paris -10%); Azzaro
Sport 100ml $23.30/$23.23 (level).

Spreads run both directions. This is the beauty proof Adam asked for, available today, and
depends entirely on Paris counting as a comparable airport, making "add Paris to the launch
set" a commercial decision with a concrete payoff.

Cautions: Athens beauty is only 21 products, a demonstration not the 20-across-16 commitment.
Lancome appears twice (two rows share one match_key), the known P2 duplicate-product issue,
first sighting where a visitor would see it; worth moving up the list.

Method: join the latest observation per listing, not all observations, or price history looks
like contradictory duplicates (mechanism: main/docs/DATA-MODEL.md). Confirmed listing 8261
legitimately went 96.60 -> 89.95 -> 43.70 EUR, a real markdown with was_price set.

## 2026-09-04 overnight — Paris publishes the beauty attributes we designed for

Checked a live YSL fragrance page mid-run. Extime's RSC payload carries, per `variations`
entry, exactly the fields the "Built to grow" section proposes: `beauty_fragrance_concentration:
"Parfum"`, `net_weight: 385` + `net_weight_unit`, `sku: "101303606"`, `name: "100 ml"`,
`page_category: "beauty"`; plus a per-category attribute registry, e.g. `{"id":51,
"attribute":"beauty_fragrance_concentration","label":"TYPE OF FRAGRANCE"}`,
`{"id":110,"attribute":"product_type","label":"PRODUCT FORMAT"}`, and
`product_benefits`/`product_how_to_use`.

Three consequences: (1) concentration is collectible, not guesswork, only 8% of Paris
fragrance names state it (54 of 639); (2) `page_category: "beauty"` is a published vertical
signal, so the vertical need not come from our own classifier; (3) `net_weight` +
`net_weight_unit` is the size-as-value-plus-unit the design proposes (attributes JSONB:
mechanism: main/docs/DATA-MODEL.md), already supplied, putting a 50g cream, a lipstick and a
70cl bottle in the same table. The design was written from first principles before this was
checked, and the retailer's own model matches it.

**The run in flight does NOT capture these** - `extime.py` reads capacity, price, stock,
gtin, sku, brand, categories, terminal only. Fine for now: prices and identity still land;
concentration only matters for cross-retailer beauty matching, which doesn't exist yet.
Adding the fields is a collector change plus a re-run (about 4h, repeatable). Do NOT stop the
current run for it.

## 2026-09-04 overnight — full Paris collection running

Started 18:10, ~2.8h expected, monitor `b2bobwgdz` watching. Commits every 50
rows (COMMIT_EVERY), so a mid-run failure keeps what it collected. Source is NOT
bind-mounted into the container, so editing code is safe; DEPLOYING would kill
the run. Do not deploy until it finishes.

**Mid-run finding at ~12% of the catalogue, and it matters for the two committed
categories.** Paris has already produced 375 fragrances and 55 skincare from 216
brands, against 7 fragrances in the ENTIRE launch sixteen. The brands are exactly
the tier Adam asked for: DIOR 48, HERMES 36, Guerlain 33, YSL 28, Giorgio Armani
19, Lancome 17, Cartier 16, Paco Rabanne 15, Gucci, Valentino, Bvlgari, Chloe.
No Ralph Lauren or Chanel yet at 12% (Chanel may simply not sell through this
channel).

**The catch, and it is the same one the structure page argues about.** Barcode
coverage on these fragrances is poor: DIOR 6 of 48, HERMES 3 of 36, YSL 9 of 28.
Only Guerlain is healthy at 25 of 33. So cross-airport fragrance matching will
fall back to brand + name + size, which is precisely the fragile path that the
Creed Aventus example exposes: one retailer puts the concentration in the name,
another only in the URL, and the same perfume becomes two products. This is
now measured rather than predicted, and it is the argument for making
concentration a real field before beauty is collected anywhere else.

Also: Paris is still NOT in the launch sixteen, so none of this reaches Adam's
"20 products across 16 airports" on its own. It does make Paris the cheapest
route to a credible beauty section, which is a commercial question for rian.


## 2026-09-03 — Dubai diagnosed, invented barcodes stopped, category growth designed

**Dubai blocks our user-agent, not our IP.** Declared bot -> 403, `Mozilla/5.0` -> 200, same
machine/second; `/robots.txt` refused too; error page `errors.edgesuite.net` = Akamai bot management
at the edge. Last good run Aug 22 00:19 (1,527 products) after five good runs on the same UA. All 15
other launch sources return 200 with our real bot identity; Dubai is the only refusal, NOT bypassed.
UA also advertises a nonexistent `dutyfreeprofessor.com/bot`. Rian decides how to proceed.

**Invented barcodes fixed at the source.** `clean_gtin` padded 6-12 digit numbers to 13 and
check-digit tested, so a bare SKU passed about 1 in 10; `avolta.py` and `shopify.py` (via `barcode
or sku`) fed it SKUs. Measured: 119 stored barcodes were zero-padded shorts (Montreal 34/62; Dubai,
real gtin field, 3/1,477). Blanket ban wrong: 149 of Avolta's 489 barcodes agree exactly with
Dubai/Heinemann, 30% genuine EANs; separator is width. New `gtin_from_sku()` accepts a supplier code
only if already 8/12/13/14 digits, never pads (mechanism: main/docs/DATA-MODEL.md). Rejects 61 (1
corroborated), keeps 148 of 149; nulled 88 fakes (widths 6/7/10, none corroborated); left the
ambiguous 11-digit group (10 of 75 corroborated). 7 new tests, 179 passing.

**Category growth designed.** Athens keys "Creed Aventus" concentration only in the URL; Paris keys
`dior-j-adore-eau-de-parfum` with it in the identity key, so one perfume becomes two products.
IDENTITY (brand, line, size-as-value-plus-unit, one category-varying attribute) separates from
DESCRIPTION (schemaless, per category); mechanism: main/docs/DATA-MODEL.md. In /structure as "Built
to grow"; rian has committed to fragrance and cosmetics both.

**Every /structure figure re-verified**: 3,771 products, 1,186 brands, 13 categories, 702
multi-airport, 400 brands with 3+, 111 category-at-airport, 633 brand-at-airport, 558 public today,
87 exclusives, comparison rates (wine 6%, tequila 47%); all correct, scoped to `LAUNCH_AIRPORTS`
(the sixteen).

**Flagged, not changed:** `quote.ts` still sells "Three new data collectors" at $900 including
Paris, now built. Adam already commented on that line; commercial call for rian.

## 2026-09-03 — /structure is unlinked, URL-only

Removed the footer link entirely. A settings toggle was not enough: the point
is that the client should not be able to FIND the page, and a toggle only means
nobody has flipped it yet. The route, the page and its comment threads all still
work — reachable by typing /structure, which is how rian gets to it.

Verified against the bundle the container actually serves (the host's
`main/web/dist/` is gitignored and was stale, which produced a false positive on
the first check — grep the container, not the host): the link text is absent
from the main bundle; `/structure` survives only as a route. Still noindex,
still absent from sitemap.xml.

Durable check on exposure: comment keys are prefixed per page, and there are
ZERO `structure:*` comments — nobody has used the page. The one
`quote:structure-proposal` thread is the quote page's line item, which Adam was
always meant to see and has already replied on.


## 2026-09-03 — Paris (Extime) collector built and piloted

`app/services/collectors/extime.py`, registered `extime-paris`, location CDG (storefront covers BOTH
Paris CDG and Orly, no split; flagged in issues.md as possibly unmet). Recon (5 lenses + synthesis),
verified live: (1) parking spaces and tourist excursions share the EXACT product URL shape and
overlapping id ranges; only the sitemap shard distinguishes them, so the collector walks
`shopping.xml` shards, nothing else counts as retail; (2) size exists ONLY in the streamed RSC
payload (JSON-LD alone would file a litre as sizeless, the Armand bug), and the payload carries the
GTIN, for cross-shop matching; (3) `offers` is a single-element LIST, so
`product["offers"]["lowPrice"]` raises on every page. Publishes duty_free, never duty_paid. **Pilot
caught a real bug**: 60 listings produced 2 observations each: one family reachable from several
URLs (2 for sauvage-elixir, up to 4 for others; 12% of shard-1 slugs), each rendering the whole
family. Fixed with family dedupe (slug minus trailing id, skip already-read families) + per-run
source_sku dedupe, rule added to agents.md; re-run: 60 listings, 60 observations, max 1 each.

**Measured across all 13 shards, duplication is far bigger than the 12% shard-1 sample suggested:
12,430 product URLs collapse to 6,637 real families; 47% of the catalogue is the same products under
other URLs.** Dedupe halves the crawl (2.8h, not 5.2h) and the load on Extime. Also added gzip to
the shared fetcher: 659KB -> 89KB/page, verified, benefits every collector. 27 Extime tests, 172
passing overall; full pass = 6,637 fetches at 1.5s, about 2.8h, overnight, no deploys. Pilot data:
EUR->USD on 60/60 (rate 1.1583); sizes correct where meaningful (14 without size_ml are macarons, a
plush baguette, a travel pillow); GTIN on 32/60 (cross-shop match to DXB/Heinemann/SCA); category
mix Fragrance 27/60, 8 uncategorised (mapping gap, not blocking).

**Open decision for rian:** Extime's catalogue includes souvenir goods (macarons, plush baguettes,
travel pillows), zero comparison value but inflate product counts. Recommend collecting and letting
the site filter rather than dropping at collection time: a category excluded at the crawler is
invisible later, and this catalogue is not cheaply re-crawled.

## 2026-09-03 — /structure page live (v0.31.0)

The structure proposal is now a commentable page as well as a document.
`web/src/lib/structure.ts` (content, with every measured number) +
`pages/StructurePage.tsx` + css; lazy route `/structure`, noindexed, footer
link under Behind the scenes. Comment threads use the SAME mechanism as the
board and quote, keyed `structure:<section>`, so Mark's and Adam's replies
land in `discussion_comments` with everything else.

Shape: four questions FIRST (each numbered, with why it matters and its own
thread), then the measured evidence table, then URLs + thresholds, then the
reasoning sections (nothing-thin rule, page contents, Adam's brand-field
question, beyond-drinks attributes, linking/canonicals, the two blockers),
then a general thread.

The markdown version at `.logs/planning/site-structure-proposal.md` stays as
the sendable/printable copy; the page is the one to link them to.

HIDDEN FOR NOW: the footer link renders only when demo-settings `curator` is
on, because Adam and Mark are actively watching the site and rian does not
want comments landing before the page is finished. Rian reaches it by direct
URL, or by switching curator on at /settings. To reveal it, drop the
`settings.curator ?` guard in SiteFooter.tsx.

## 2026-09-03 — structure proposal for Mark drafted

`.logs/planning/site-structure-proposal.md`, ready for rian's review then
Mark. Grounded in measured data at 16-airport scale, not guesses: 3,770
products, 1,186 brands, and critically **581 brands (49%) have exactly one
product** — which is the fact that drives the whole design.

Proposal in short: URLs `/products/<slug>-<id>` (unchanged), `/brands/<slug>`,
`/categories/<slug>`, `/airports/<iata>-<city>`, and
`/airports/<iata>-<city>/<category>`. That last one is argued hardest: "whisky
at Heathrow" is the real search intent, and 111 category-airport pairs have 8+
products. THRESHOLDS keep thin pages from existing: brand page only at 3+
products (about 400 qualify), category-at-airport only at 8+ (111). Brands
with 1-2 products get NO page. Brand-x-airport gets no standalone page despite
633 qualifying pairs, because the brand page's price table already answers it
and 633 near-duplicates is a thin-content problem.

Answers Adam's brand-page question: price data is the spine (always
substantial), editorial fields optional and rendered only when filled, so no
empty fields and new fields (tasting notes) need no redesign. Same rule solves
cosmetics/fragrance: attributes are PER CATEGORY, so no empty ABV row on a
perfume.

Flagged as blockers in section 9 and added to issues.md: brand-name
normalisation and 259 uncategorised products.

Section 10 asks Mark four specific questions so his review is actionable.

## 2026-09-03 — brand alignment pass one (v0.30.0)

Rian redirected: design first, then Mark's structure proposal, then the Paris
collector. Done this pass:
- **Reference研究**: oliveoilprofessor.com uses Adobe Typekit **baskerville-urw**
  for display and a plain sans for body; their neutrals are #222 on #f5f5f5.
- **Display font: Fraunces -> Libre Baskerville** (open Baskerville revival,
  self-hosted, latin + latin-ext + italic in web/public/fonts). Google serves
  ONE file for 400 and 700 because it is VARIABLE (verified via fontTools:
  fvar wght axis 400-700), so it is declared `font-weight: 400 700` like the
  others. Fraunces files deleted; preload switched.
- **Palette: navy is now #003F6C** (sampled from the logo), navy-deep #002A48,
  navy-2 #0A5285, surface-inverse matched. Contrast re-verified: white on navy
  10.89, navy on cream 9.83, all pairs well past AA.
- **Logo** copied to web/public/logo-duty-free-professor.png, in the header
  (34px, 26px on mobile) and footer (on a light chip, since it is navy art on
  a navy footer). Intrinsic width/height declared so nothing shifts.
  In the navy header it reads as a knockout: white sign outline + white text.

STILL OPEN for the next design pass: mega menu, prominence changes for
featured/exclusives, and Mark's "avoid the dark backgrounds" note (the header
and hero are still dark; a lighter header would make the navy logo read as the
sign it is, and match the Professor sites more closely). Ask rian before
changing that, it is a taste call.

If rian gets Adobe Fonts access for the Professor account, the real
baskerville-urw can replace Libre Baskerville by swapping the @font-face block.

## 2026-09-03 (late) — git done properly

Rian's `git init` failed on identity, but `git add -A` had ALREADY STAGED
`.env` and `.app.env` (db password) plus ~9,000 node_modules/.venv files.
Nothing was committed, so: index reset, `.gitignore` written (secrets, deps,
data/, backups/, built output, the 86MB video), repo-local identity set,
clean baseline commit `7793c90` with 227 files. The first attempt had written
~195MB of unreferenced blobs into .git; `reflog expire` + `gc --prune=now`
took it to 1.9MB. Verified: no secret is tracked.

## 2026-09-03 (evening) — parallel build plan written

Rian replied to all of Adam's quote questions (consistent, no overcommitments;
one gap flagged: he did not explicitly confirm "one-time fee" where Adam asked
it twice). Then asked for a multi-session build plan.

Written: `.logs/planning/build-plan-2026-09.md` (waves, timeline, the three
tokens, file-ownership table, daily rhythm, risks) plus
`.logs/planning/streams/` with seven self-contained context packets
(A-data, B-pages, C-design, D-content, E-infrastructure, F-awards,
S-structure) and `KICKOFFS.md` with paste-ready prompts per session.

KEY CONSTRAINT FOUND: **this project is not a git repo.** With several sessions
on one working tree that is the main risk; step zero of the plan is
`git init` + baseline commit, then each session commits with its stream letter.
Other shared singletons handled by tokens rian grants: DEPLOY (sessions never
deploy; they report green and he ships at checkpoints), MIGRATION (single
alembic head; expected users are D for the subscriber table and possibly F),
CRAWL (deploys kill running collections, so A announces windows).

Waves: W1 now-Mon 7 = E + S + C (infra, proposal doc, design; near-zero file
overlap). W2 Mon 7-Fri 11 = A + B + C. W3 Mon 14-Wed 16 = F + D + A finishing
categories. W4 Wed 16-Fri 18 = ONE integration session, no parallelism.
Named risks: Adam's articles (due Mon 8) and Singapore's rendering capability.

## 2026-09-03 — ADAM APPROVED AND SUBMITTED: $8,400 quote request

He worked the whole quote page this morning (UTC): page opened 06:27, 17 comments 07:32-08:01,
toggled new-collectors ON at 07:59, and SUBMITTED a quote request at 08:08 for **$8,400** = The
Ambitious Version ($7,500) plus the three new collectors ($900). Every line commented
"good"/"approved"; not one switched off. Activity continued in bursts until 09:36.

His submission note asks: (a) does rian need the airport list confirmed Friday (he will send 20+ in
priority order), and (b) a real question about brand-page design: reuse the product-page information
plus extras, or should he specify everything he wants and cherry-pick, with no empty fields and room
to add later (e.g. tasting notes).

**~13 questions inside the line comments, all needing answers** (recurring theme: cost/difficulty to
EDIT things later): cost+time per batch of added airports (he proposed "10 airports $300"), does he
pick the 16, confirms he will not scrape sites that refuse; is the crawlable page a one-time fee and
how hard to add fields like "Color" or differ per category; cost to change the logo later; how soon
can Mark get the structure proposal (Mark in Europe for a week later this month, slower then); are
airport pages just replicated per airport and how editable; would a Johnnie Walker brand page differ
from a Paco Rabanne one; can he move/swap articles; how are featured savings laid out and
individually highlighted; awards assignment via dropdown; newsletter personalisation by home airport
+ category and how collected; how many banners and how hard to swap; will the 3 new collectors cover
most future airports.

NEXT: answer those questions (a reply doc, or as comments on the page so it stays in one place),
confirm scope, invoice the deposit. His editability questions all point the same way: the admin area
(currently AFTER soft launch) is what he actually wants, so expect that to become the next phase.

## 2026-09-02 (later) — quote page: intro trimmed, later-list relocated (v0.29.2)

- INTRO cut to two sentences naming the four groupings (essential /
  recommended / doable / out of reach) plus "three suggested configurations
  below, clicking one sets it; most features toggle individually".
- The out-of-reach list moved from its own section near the bottom to
  DIRECTLY UNDER the blue total box, as a collapsed expandable ("Features on
  the list but not in reach for soft launch", count badge, +/-). Expanded, the
  12 entries render in the SAME card format as the costed lines (dashed
  border, "After soft launch" chip, own comment thread keyed
  quote:later-<slug>) but with no prices. Widths matched to the costed cards.
- Whole page now says SOFT LAUNCH, not launch (rian's instruction): h1 is
  "Getting to the soft launch.", eyebrow "For Adam · soft launch", head title
  "Soft launch proposal", plus every body reference (checklist, cut-off,
  sponsor creative, domain, quality line name).
Verified live: expand/collapse works, cards align, totals unchanged
($3,000 / $5,700 / $7,500; collectors +$900).

## 2026-09-02 — quote page revised from rian's 8 comments (v0.29.1)

All applied, his comments then deleted and his test toggles cleared so Adam
opens on defaults:
- Presets renamed: The Essentials $3,000 / The Safe Bet $5,700 / The Ambitious
  Version $7,500 (totals unchanged).
- ALWAYS INCLUDED is now three lines (production env, sixteen airports,
  quality+launch). Brand alignment and crawlable pages moved into the
  switchable list carrying a gold RECOMMENDED badge, per his "can be turned
  off but not recommended".
- DEPENDENCY: REQUIRES map in lib/quote.ts + setWithDeps() in QuotePage.
  Switching the structure proposal off switches airport pages and brand pages
  off with it; switching either of those on brings the proposal back. Verified
  live (DB showed all three false after one click).
- Three new data collectors: price OVERRIDE field (item.price) so it shows
  $900 against a $3,800 list rather than the formula's $2,300, with the reason
  stated (the new reading techniques carry over to other difficult airports).
  priceOf() is now used everywhere instead of introPrice().
- Automated collection REMOVED from the sellable options and now heads the new
  "Not possible before the soft launch" section (LATER + LATER_INTRO), with
  the honest reason: a scheduler must run and be watched for weeks before it
  can be trusted unattended. Eleven more deferred items sit under it, each
  with its own one-line why, replacing the old single exclusion paragraph.
- Categories line now names cosmetics and fragrance and invites him to pick
  different ones.

## 2026-09-01 (later) — /quote is live: the proposal as a tailorable page (v0.29.0)

Rian wanted the proposal ON the site, Adam-editable. Built /quote (noindexed, lazy-loaded, footer
link under Behind the scenes) (mechanism: main/docs/CLIENT-SURFACES.md "The quote page").
`lib/quote.ts` holds all copy + prices: `DISCOUNT = 0.4` in ONE place, `introPrice()` derives every
shown rate and rounds to $50. Both rates shown per line (struck list price + project price) per
rian's "warm him up that it's discounted" ask.

5 CORE lines (locked, "Included" chip): production env, brand alignment, crawlable product pages,
sixteen airports (named in the detail list), quality+launch. $5,000 list -> $3,000. 11 OPTIONAL
lines with switches, each its own comment thread, including the bundled "Three new data collectors"
(CDG + Shilla/ICN + SIN, $3,800 -> $2,300, OFF by default) and automated collection ($2,200 ->
$1,300, off). Presets (Go live $3,000 / Get found $5,700 / Full launch $7,500) set the switches;
live total updates on every toggle; "Request a quote for $X" freezes the basket into
`quote_requests` and shows a confirmation. HOSTING copy stays $50/mo, framed as a starting point
with the step-up flagged BEFORE it happens (rian's wording).

Backend: `quote_selections` (upsert, ON CONFLICT) + `quote_requests`, migration `b0c1d2e3f4a5`,
`app/routers/quote.py`. `FeatureThread` + `useCommenterName` extracted to
`components/FeatureThread.tsx`, shared with the board.

Verified live end to end: toggle persists, total moves ($7,500 -> $6,600 with brand pages off),
request POST 201, /quote noindexed. State reset to defaults so Adam opens it clean.

NOTE: `notes/launch-proposal-2026-09.md` + `INTERNAL.md` still hold the pre-discount tiered figures
($5,000/$8,500/$11,000). The PAGE is now the live proposal; treat the docs as the internal reference
for the standard rate.

## 2026-09-01 — good meeting; launch-phase proposal drafted

Meeting landed well. Agreed direction: brand alignment (their logo, navy
#003F6C extracted from notes/DutyFree_Professor_W_O_Text.png, Professor fonts,
mega menu, nudge toward Mark's mockup WITHOUT copying it), emit crawlable
HTML, activate the 16 airports, hack in their articles (no backend), 2 extra
product types x ~20 products, airport + brand pages with a STRUCTURE PROPOSAL
for Mark as its own deliverable, awards selection algorithm (geo-affinity to
the traveller's airports + newest-per-competition, older ones suppressed but
in schema), live environment, more prominence for featured/exclusives, email
capture, sponsor banner slots.

Written: notes/launch-proposal-2026-09.md (CLIENT-FACING) and
notes/launch-proposal-INTERNAL.md (NEVER SEND). Rian judged $12,500 too high
for this client, so it was restructured into THREE TIERS with a comparison
table: A "Go Live" $5,000 (9.5d: brand-light, crawlable product pages, 16
airports, production env, QA), B "Go Live and Get Found" $8,500 (17.25d: adds
structure proposal, airport pages, brand pages, mega menu, articles,
prominence) RECOMMENDED, C "Full Launch" $11,000 (23.25d: adds 2 product
types, awards algorithm, email capture, sponsor slots). Add-ons repriced
(CDG/ICN $950, SIN $1,900, scheduler $2,200); ops $250/mo from launch.
KEY LEVER (internal): option A deliberately excludes brand pages, airport
pages and the mega menu, which are exactly Mark's asks, so B is what answers
his review without rian arguing for his own upsell. Floor is A; if pushed,
move DOWN a tier or sell a la carte (airport pages $1,000, brand pages
$1,500, articles $600), never discount the same scope.

Nothing built yet this phase. On acceptance, sequence: production env early,
then design + structure proposal, then crawlable pages, then content.

## 2026-08-31 (late) — named airport inventory for the meeting

Prep doc gained §5b: the 16 refreshable airports BY NAME with city, operator
and product count (Dubai 1,489 … Buenos Aires 132), totalling 3,771 products
and 702 comparable — beats Adam's stated 15-airport/2,000-product goal before
any new source. Doubles as the answer to his "average SKUs per store" question
(130-670 typical, ~1,500 Dubai). Heinemann trio (KEF, SYD, BorderShop) listed
separately as HELD BUT FROZEN. Board's more-airports card now names the
sixteen too, with cost kept honest ("every airport carries its own quality
checking and ongoing collection") so the coverage does not read as free.

## 2026-08-31 (night) — full coverage-claims audit after rian caught AMS

Rian found schiphol.nl product pages and asked for a re-verify of every
"cannot crawl" claim. All uncovered sites re-fetched fresh (evidence:
scratchpad/audit/, summary in issues.md). Corrections shipped to the live
coverage page: CDG Extime is OPENABLE (robots permissive, sitemaps, JSON-LD
prices server-side — Ardbeg 10 @ EUR62.50 verified) and left UNCOVERED list;
MAD entry removed (our own avolta-mad has 725 fresh prices — the entry was
stale); ICN reworded (Lotte=no verified, Shilla=OPEN with raw-HTML USD
prices, collector candidate); AMS reworded (catalogue EXISTS on schiphol.nl
but 403s all automated fetches incl. browser-UA — anti-bot = treat as no,
partnership lane; rian to eyeball whether his tab shows a price). Standing
claims verified current: DOH, IST, Heinemann (/*/search/ present today),
Lotte; DFA unverifiable (522 down).

Conceptual clarification for rian (recorded in issues): RENDERING ≠ PAID.
SIN-class sites can be read with our own headless Chromium; F3 no-paid-fetch
stands; only anti-bot evasion stays banned (also bars "stealth" AMS access).

New build opportunities from the audit: CDG collector (JSON-LD parse — our
easiest class), Shilla/ICN collector (raw HTML), SIN via in-house rendering.
Top-10 story now: 3 live + CDG buildable + ICN half + SIN renderable + 3
partnership (AMS/IST/FRA) + DXB hidden-ready. Meeting prep table updated.

## 2026-08-31 (evening) — the board is the plan of record (v0.28.0)

Per rian: Adam's emailed/commented features are now ON the board, and the
board tracks scope. Every card gained `scope` (launch/after/later/tbd →
chips "Launch scope / After launch / Later / In discussion") and the board
renders grouped by scope. New status value "building" ("In build") for work
in flight — flip cards to it as launch work starts. 24 cards total; new ones:
content-pages, airport-pages, brand-pages, brand-images, sponsor-banners,
enhanced-listings, store-portal, crowd-updates, manual-sources; "alerts"
became "Traveler accounts and alerts" (key unchanged — Adam's ranking rows
and all 18 of his comments still attach). Launch group mirrors the meeting
prep package; tbd group holds the monetization/partnership items.

Editing the plan = edit BOARD in web/src/lib/review.ts (scope/status fields)
and redeploy; Adam's taps/comments live in DB and survive. Big-picture line 4
now points at the board as the plan of record.

## 2026-08-31 (later) — PageSpeed pass (v0.27.0) + rich-snippet fields (0.26.3)

Baseline PSI mobile: Perf 64 / A11y 94 / BP 100 / SEO 100 / Agentic 1-of-2,
CLS 0.411, render-blocking 1.34s (Google Fonts), cache 187KiB, unused JS.
Landed:
- Schema (0.26.3): Product gains description / offers.url / offers.availability
  (site's own buyability rule). aggregateRating/review DELIBERATELY absent (no
  reviews; fabricating violates Google policy — say so, don't "fix" it).
  Demo product: Santa Teresa 1796 (…-1156): image, 8 medals, 3 shops.
- Perf (0.27.0): fonts SELF-HOSTED (web/public/fonts, latin+latin-ext var
  woff2, @font-face in tokens.css, preloads in index.html — Google Fonts refs
  gone); product-page skeleton mirrors real geometry (the pending→loaded swap
  was most of the CLS); LCP image preload via Head(preload_image=…) injected
  server-side; cache middleware (assets/fonts 1y immutable, medals 30d);
  /fonts + /medals now real StaticFiles mounts (HEAD works); route-level
  code-splitting (main 92→82KB gz, shopper path eager).
- A11y: --ink-soft #6C6C65, new --gold-ink (eyebrow text) + --gold-strong
  (primary buttons + gold tier buttons) — all AA-verified by computed ratios;
  footer h4→h3; footer em dashes swept.
Expected on re-test: CLS ≲0.05, Agentic 2/2, A11y 100, Perf well up (LCP still
bound by the external openfoodfacts image on slow 4G — the brand-image intake
phase will bring images first-party). Rian re-tests via his ungate→PSI→regate
flow.

## 2026-08-31 — Adam responded IN the app; meeting prep for Adam+Mark

Adam left 8 board rankings + 20 comments (all still in DB, unread until today's
prep): Before-launch = design, content (he brings 10+ articles + 2 VAs),
beyond-alcohol sampler; Go-heavy = seo, my-airports, featured, exclusives;
after-launch = sched-collection, admin, tracker, map. He AGREED $50/mo in
writing (decision 39), said YES to the global database (38), offered Heinemann
outreach + a store-partner-portal idea (36), offered brand-sourced product
images with 7-10 day lead (14), and listed the top-10 busiest airports as his
wish (11) — of which 3 are crawlable (DXB hidden-ready, LHR, HKG live), SIN
unassessed, and 5 are the documented closed operators (ICN/AMS/IST/CDG/FRA).
Plus an email feature list (accounts/newsletters/alerts/banners/VA sheets) —
mapped to phases in the prep doc. Mark's SEO critique: mostly valid on
client-rendered content, wrong on "nothing in HTML" (validator evidence in
notes/seo-evidence/), moot on indexing (gate), off-target on orphan pages
(competition site, not ours).

**notes/meeting-prep-2026-08-31.md** holds the full playbook: launch package
(live env, server-rendered product/airport/brand pages, surface design pass
per Mark's specifics, 15+ airports from inventory, article pipeline, brand
image intake, category sampler, polish) vs the named deferred list; work-and-
worth lines; money frame; walk-away criteria. After the meeting: whatever is
agreed becomes the Phase 2 plan in .logs/planning/.

## 2026-08-28 — sent to Adam; first response; video now dismissible (v0.26.2)

**Sent.** Adam looked but left NOTHING: zero comments, zero board rankings,
zero POSTs to /api/discussion since send (verified in DB + app logs). Logs
show /discuss opened twice on 8/28 (08:38 UTC, no video fetch at all; 13:24
UTC, full video fetch). His verbal feedback: needs better design, plus he
sent his own vibe-coded wireframe. Read: he skimmed, and responded in his
own medium rather than using the board. Note the Design row on the board had
already predicted exactly this.

**Suggested reply** (given to rian, not yet sent): welcome it, point out
design is already its own board row, ask for two minutes tapping one answer
per row — if he taps "Go heavy" on design, his wireframe becomes a scoped,
chargeable brief.

**Fix this session:** the walkthrough video re-rendered full-size at the top
of /discuss on every load with no way to dismiss. Now has "Close the video";
dismissal persists per browser (localStorage `dfp.videoHidden`) and collapses
to a "▶ Watch the video walkthrough" button that reopens it. Verified
close -> hard reload -> still closed -> reopen -> closed again in rian's
browser. NOTE: rian's own browser now has it dismissed.

**Open thread:** backend/Django re-examination was given as initial thoughts
(layers: app-native auth + headless WordPress for content -> Stripe Checkout
in Adam's account for consumer payments -> rian's hosting sub as contract not
code -> shared Postgres + separate portal service for the competition network,
where Django gets a fair fight). notes/backend-thinking.md NOT yet written -
rian was going to think on it.

## 2026-08-27 (afternoon) — the video is in and reviewed; verdict: send it

Rian dropped walkthrough-video.mp4 (14:49, 1080p-ish, 90MB) into public/ —
the review page picked it up automatically (the player shows). Reviewed via
frame sampling + full whisper transcript (scratchpad/video/transcript.txt).
Verdict given: GOOD, send as is. Structure is exactly the planned arc
(built-only demo -> real HK shop-site price verification -> honesty on
images/legal/data-quality -> Hamish-solution framing -> design honesty ->
demo-settings vision flip -> feature board + decisions walkthrough -> "do
less than more" expectation-setting). Positioning clean throughout (no
code-ownership or cheapness talk). One noted miss, not redo-worthy: he says
"can't remember which product you wanted" and shows JW GOLD instead of the
JW BLUE callback; the page's big-picture line + link covers it.

Remaining: rian sends Adam /discuss + the password. Everything else done.

## 2026-08-27 (evening) — rian's annotation pass applied; page is Adam-ready (v0.26.0)

Rian ranked every feature and left 19 working comments; all folded in:
- **His selections are now the dashed suggestions** (BOARD `suggested`:
  medals->more, design->essential, more-airports->essential,
  scheduled-collection->next; rest confirmed as-was). feature_priorities
  emptied so Adam starts clean; his comments deleted after being rewritten
  into the card copy in one voice (18 board edits — comparison
  experience-not-breadth, my-airports "select the airports", medals smarter-
  display, SEO AI-crawler avenue, design AI-native honesty + surface-pass-
  before-launch, live-env cost-conversation link, sched-collection manual-
  before-demo trade, admin/content full-backend honesty, exponential
  beyond-alcohol, etc.).
- **Heinemann retitled** "a relationship to build?" per his wording.
- **New decision #3 (urgent group now three): "Where this lives, and what it
  costs to run"** — rian hosts and runs it, $50/mo starting point, revisited
  as it grows, portability restated once, "first reactions only, then a live
  discussion". note<=240 chars (column limit — the first attempt rolled back).
- Big-picture line updated ("with my suggested timing") since sched-collection
  is no longer pre-marked essential.

The page is FRESH FOR ADAM: no comments anywhere, all suggestions dashed,
six decisions (3 urgent / 3 no-rush). Remaining before send: rian records the
video -> public/walkthrough-video.mp4, then /discuss + password to Adam.
Two ideas from his comments preserved in issues.md (award aging / re-entry
incentive; board tier-set nuance).

## 2026-08-27 (later) — sample-feature switches in demo settings

/settings now has a "Sample features" panel: one switch per unbuilt surface
(announce bar, editorial samples, + each FEATURES stub: reviews, price
tracker, guides, news, newsletter, alerts, airport guides, map) with All
off / All on bulk buttons. Implemented as `samples` map in DemoSettings
(localStorage; absent = shown) + `sampleOn()` helper; consumers: SiteHeader
nav, AnnouncementBar, HomePage (editorial/alerts band/newsletter band/
coming-soon grid), SiteFooter coming-soon column. Layered UNDER the server
FEATURE_* flags (both must be on). Verified live: built-only view = nav
Products/Exclusives/Awards, no announce bar, no teaser sections; All on
restores instantly without reload. Purpose: rian can demo "exactly what is
built today", then re-add samples one at a time on camera or with Adam.

## 2026-08-27 — board asks status-dependent questions; design row added

Rian's residual confusion resolved structurally: a single tier set could not
express "built but wants more work". Now BUILT rows show the EFFORT set
(good/more/heavy/drop: Good as is / A bit more before launch / Go heavy here /
Leave it out) and PARTIAL+PLANNED rows show the TIMING set (essential/next/
later/no: Before launch / Soon after launch / Later, maybe / Not interested).
Priority column accepts all 8 values; comparison row migrated essential→good.
Suggested defaults: all built rows "good"; design(new, partial)="next";
live-environment + scheduled-collection stay "essential/Before launch".
New board row: design-and-branding (partial; reskin possibility named).
"More time buys" lines must STAND ALONE (rian rule) — SEO's now says further
technical SEO is diminishing returns until content exists.

Still open: rian will annotate the page with comments meant for the build,
then wants one final pass that applies + deletes them for Adam. Video pending.

## 2026-08-26 (night) — board semantics fixed; rian approves the shape

Rian could work with the slim page but caught the board's conceptual flaw:
"Essential for launch" on an already-live feature was ambiguous (keep it? work
on it?). Resolution: the tier question is now ONLY launch scope ("does the
launch need it?"), and the status chip carries cost (Built = free to keep;
Not built + Needed = where rian's time goes). Tiers: Needed for launch / Soon
after launch / Later, maybe / Not interested. Status chips are filled badges
(Built green / Part-built gold / Not built neutral) with filter chips. Name
gate removed from ranking (Adam is the only ranker; taps save as the stored
commenter name or "Adam"). Decisions heading is now "For your comment" with
the first two (Cannes airports, Heinemann) presented as pre-launch and the
other three under "No rush". New general comment thread at the page end
(feature_key="general" — rides the feature-comment mechanism).

**Rian's plan:** he will leave comments on the page addressed to the build
(not Adam), then wants ONE final revision pass that acts on them and DELETES
his comments so the page is fresh for Adam. When that request comes: read all
feature comments + item comments, apply, then remove his rows
(discussion_comments) via SQL. Video still pending.

## 2026-08-26 (later) — review page final shape: big picture + feature board (v0.25.0)

Rian judged the pitch-structured page bloated; the fix was consolidation, not
more narrative. /discuss is now: video → FIVE email-style big-picture lines
(bold lead + one sentence, gold bar) → live-stats line → **the feature board**
→ five decisions → three bigger-idea bullets → one closing line. Nothing else.

**The feature board is the page's engine now** (lib/review.ts BOARD, 16 cards,
live+planned merged incl. SEO, live-environment, scheduled-collection,
admin-area): each card = status chip (Live/Partial/Planned) + name + what it
is + "Today:" + "More time buys:" + FOUR tiers (Essential for launch / Next
priority / Later / Not interested) + its own comment thread. Rian's suggested
tier renders as a DASHED hint until the client taps (confirmed choices are
solid; untouched rows stay visibly unconfirmed — that distinction is signal).
Comments can now attach to features (discussion_comments.feature_key, nullable
item_id, one-target check constraint, migration f8a9b0c1d2e3; GET/POST
/api/discussion/feature-comments).

**Decisions slimmed to five:** airports-for-Cannes, Heinemann door, 
competitions' database, product photos, lawyer-before-launch. Thirteen items
resolved off the page (12,13,16,17,18,20,22,25,26,28,31,35,37 — all still in
DB, curator mode shows them). Tier value 'must'→'essential' migrated.

Rian's still-open thread: he may yet simplify further (video-only was on the
table). Video recording still pending; send = /discuss + password.

## 2026-08-26 — review page rebuilt to rian's pitch; demo picker; positioning sweep (v0.24.0)

**/discuss is now rian's structure:** big picture / what's working (+grid+live
stats) / coverage (room to grow + limits, incl. the Heinemann ask and the
images-legality story) / the code and the machine / bigger ideas — all bullets.
**New "Pick the demo" ranking:** every feature (live+planned) gets Must-have /
Nice-to-have / Later buttons; latest choice per feature upserts server-side
(feature_priorities table, ON CONFLICT; GET+POST /api/discussion/
feature-priorities). Verified end-to-end in the browser; rian's own
comparison=must row is live (Adam's taps replace it — one shared row per
feature, by design).

**Positioning sweep (see the new agents.md rule):** removed every "code is
yours / moves in an afternoon" and "collection is cheap / $16-120/mo" statement
from rendered AND payload fields (items 11/13/18/19 rewritten, item 30
resolved); Heinemann became a decision ("can you open the door?"), plus a new
decision "Is the competitions' database on the table?". Em dashes swept from
all client-facing strings and DB fields. A 3-agent audit (positioning /
code-review / Adam's-eye) found the leaks plus a critical picker bug (name
input unmounted after the first keystroke — fixed with draft+commit flow,
always-enabled buttons that nudge to the name box, and error surfacing).

**Old copy archived** at notes/review-page-archive-2026-08-25.md. Internal
issues live in .logs/issues.md — the client page deliberately carries only
features and decisions now. Rian's queue: record the video (page says "skim
meanwhile" until the file lands at public/walkthrough-video.mp4), then send
/discuss + password.

## 2026-08-25 (night) — review page cut for Adam; medal artwork transparency

Rian is recording the video and sending Friday. The /discuss page was rewritten
for a skimming reader: every section is 2-3 short paragraphs, features are
one-liners, one flat built list. **Deliberate non-disclosure:** rian decided
NOT to reveal pre-work to Adam — the promised/built-beyond split is gone, and
nothing on the page or in the planned list mentions hidden airports still
collecting, features "switched off", or work beyond the demo. The page
describes exactly what the demo shows. Do not reintroduce that framing in
client-facing copy without rian's say-so (pricing strategy for the meeting).

Medal artwork: 7 of the PNGs carried an opaque white tile (flood-filled to
transparency from the edges; white inside the medal rim preserved). Medal img
URLs now carry ?v=N (MEDAL_ART_VERSION in Medal.tsx) because demoing.info sits
behind Cloudflare and the CDN kept serving the old artwork — bump the constant
whenever public/medals changes. Verified in the browser: clean circular badges
overhanging card corners on home; /discuss renders tight with live stats
(500 products · 4 shops · 156 comparable · 39 medals).

**Rian's queue:** record video → drop at public/walkthrough-video.mp4 (or .txt
with a link) → send Adam the /discuss link + password. Everything is deployed
(0.23.1); nothing running in the background.

## 2026-08-25 (evening) — re-crawls verified, ghost-listing purge, v0.23.1

**The Armand bug is confirmed dead in data.** All ten Avolta re-crawls done.
Armand de Brignac is now per-size families: Gold 75cl EZE/JFK/HKG $300-304,
Gold 1.5L ZRH $746 / HKG $869 — separate rows, no cross-size saving possible.
All ten multi-shop Veuve rows are size-pure with tight spreads.

**New bug found & fixed while verifying (d6e7f8a9b0c1):** per-variant emission
changed configurable tiles' listing keys (`sku-P` → `sku-P::size`), so the old
plain-sku listings were never updated again — 165 ghost listings, 14 barcode-
less ghost products duplicating real ones (a "1942 Tequila 70cl" card sat next
to the true Don Julio 1942 750ml on the featured row, splitting its award).
Purged where a `::` sibling exists at the same location; unreadable-page tiles
keep their old dated listing. **Known edge, reverse direction:** if a tile ever
goes configurable→single-size, its `::` rows would linger the same way; same
purge pattern applies.

Also: trip service (picker/suggested/compare) now respects location visibility
— rian saw 19 airports in the home picker; it was the one query path the
0.23.0 filter missed. Demo flags now featured_home=true, rich_cards=true (rian
wants the cards on). Live: 4 airports, 500 products, 156 comparable, 39 medals,
featured = 8 corroborated picks, 3 with medals. Footer reads 0.23.1.

## 2026-08-25 (afternoon) — demo scope became configuration; v0.23.0 deployed

**Rian de-scoped the Friday demo to the PoC promise, as switches, not removals.**
Two new control planes, both live:

- **Location visibility** (`locations.visible`, migration c5d6e7f8a9b0): hidden
  locations keep collecting but appear nowhere — every query path (summary,
  priced-locations, product page, featured, stats, category counts, coverage
  page, sitemap, page heads) filters on it. CLI: `python -m app.cli locations
  [--show/--hide/--only CODE...]`. **Demo set = LHR JFK EZE HKG** (the four
  airports in Adam's brief). 16 locations hidden, still crawling.
- **Feature switches** (`FEATURE_*` env → injected as `window.__DFP_FLAGS__`
  into the SPA shell; web/src/lib/flags.ts): my_airports ON, teasers ON,
  **featured_home OFF, rich_cards OFF** for the demo. Flip in .app.env +
  `srv-gw deploy` (restart does not re-read env_file).

Also: medal artwork fixed (was stretched full-bleed by the photo img rule +
missing wine-comp silver/bronze PNGs showed broken) — now a 54px badge
overhanging the card's top-left corner, onError falls back to the text tag.
Review page built-list split into "promised" vs "built beyond the promise"
(the Phase 2 pricing instrument). Stats/awards counts all scope to visible.

**In flight:** avolta-bcn/jfk/yyz/zrh re-crawls relaunched (killed twice by the
two deploys — batched commits, nothing lost). When they land: verify the
Armand/Veuve per-size families and re-check featured picks (featured is OFF in
the UI but the endpoint is live). Footer still says 0.22.0 — version.py and
CHANGELOG already say 0.23.0; next deploy picks it up.

**Rian's queue:** click through the 4-airport site, then record the video;
flip rich_cards/featured_home first if he wants the comparison cards shown.


## 2026-08-25 (night 4) — the dropped-decimal bug, per-variant listings, and the corroboration rule (v0.22.0)

Rian human-checked the featured list and caught what all sweeps missed: the $447 "saving" on Armand
de Brignac compared ZURICH'S 1.5L against JFK'S 75cl. Two stacked causes: (1) the platform writes
1.5-litre slugs as "-15l" (dot dropped; the page's own anchor says #1.5l) and our URL parser read
fifteen litres; (2) a configurable tile's price belongs to a DIFFERENT default size per store, so
same-slug tiles are not the same bottle. Four products were in the trap (Armand, Veuve Clicquot x10
listings, Bottega, Whispering Angel); the earlier Bottega purge had misdiagnosed this as the plain
variant bug.

Fixes, all tested (140 pass): parser reads bare two-digit-litre slugs as dot-dropped (15l -> 1.5L).
**Structural: a configurable tile now emits ONE LISTING PER SIZE the product page prices**
("sku::1500", name + "1.5L", gtin=None, never mint a barcode from parent digits; unreadable page ->
skip, never guess) (mechanism: main/docs/DATA-MODEL.md). `resolve_deferred_price` is gone, replaced
by this; same-size variants merge across stores by name+size key, so the comparison gets RICHER, not
just safer. Migration `b4c5d6e7f8a9` purged the four mixed-size products (the genuine 12L beer
24-pack survives); all ten Avolta stores re-crawling under per-variant emission.

**Corroboration rule in featuring** (rian's "too good to be true" instinct, coded): a spread over 3x
needs >= 3 shops to headline the home page; product pages still show everything. Gratien correctly
withheld; Campari's 75% stays (12 shops).

**The Gratien $21-vs-$77 verdict, evidence-based:** across 304 shared prices, JFK's median ratio vs
European Avolta stores is 1.07x, p90 1.52x, and the single maximum in the entire dataset is 3.74x,
the Gratien itself. JFK does not gouge in general; this is almost certainly Avolta's own
pricing-entry error ($17 fat-fingered as $77 fits the European price). Unprovable from outside, but
the site now neither hides it (product page) nor headlines it (corroboration rule).

Verify after crawls land: Armand/Veuve families rebuilt per-size with cross-shop rows.

## 2026-08-25 (night 3) — featuring v2 + medal artwork on cards (v0.21.0)

**Algorithm v2** (featured.py, rules in the docstring, 10 tests): one blended saving
score (60% dollar gap + 40% percentage gap, each normalised to the day's best), award
bonus +0.25 with a floor of min(2, available) winners — a LONE winner with a real
saving is still favored in — and an image bonus +0.10 that wins close calls but never
buries a clearly better saving. The best-dollar and best-percent champions are always
shown regardless of photo/award. Supersedes the slot-based v1 at rian's request.

**Cards**: winners' corner now shows the competition's real medal artwork
(top_award on ProductSummary — best tier then latest year; falls back to the text tag
when artwork is missing). The corner Save tag is gone (duplicated the footer chip).

Live mix confirms the intent: $447/$420/$335/$316 dollar monsters interleaved with
three photographed medal winners and the 75% Campari champion.

**Rian is human-verifying the Gratien & Meyer $21-vs-$77 find** (links handed over:
both retailer pages + our product page).
## 2026-08-25 (night 2) — home page is cards-only, algorithm-featured (v0.20.1)

The savings rows + card sample became ONE thing: eight cards (two grid rows) picked by
the agreed algorithm, now living in app/services/featured.py with rian's rules quoted
in the docstring and pinned by seven tests: 2 best by dollars, 2 best by percent,
2 award-winners by dollars + 2 by percent (skipped if no winner has a saving; overlap
NOT backfilled), remaining slots from the top-10-by-dollars with winners floated up.
Endpoint /api/products/featured-savings (registered BEFORE the dynamic product route),
scoped by at= like everything else. Old rows/useFeaturedTrip/dead CSS removed.

Pre-featuring sanity sweep caught the variant bug's little brother: on a multi-size
tile whose product page prices only sizes we DON'T carry (JFK sells Patron Silver only
as 5cl/20cl), the old fallback published the tile price against our 75cl -> $8.50
Patron. Fix: resolve_deferred_price() three-way (our size priced -> use it; readable
map without our size -> SKIP, the shop doesn't sell it; no map -> tile price). Purged
Patron JFK/EZE + Bottega Gold ZRH; JFK/EZE/ZRH re-crawling under the fix.

Also verified-real and kept: Gratien & Meyer at $21 ZRH vs $77 JFK — JFK's own page
microdata says 77. A 73% genuine gap, great demo material.

Process note repeated because it bit twice today: a scripted edit whose old-text
missed silently -> the fix existed in prose but not in code. Every replace now
asserts its match count; the two-script edit that split definition from call site
still slipped through when script 1 aborted after computing but before writing.
## 2026-08-25 (night) — comparison cards sampled on the home page (v0.19.3)

Under the "biggest savings" section the home page now shows four of the new
comparison cards ("How it looks shop by shop" / "at your airports" when scoped) —
fetched sort=compared limit=12 and deduped to one card per brand client-side, because
the raw most-compared list was three Johnnie Walkers out of four. Bar labels are now
short codes (IATA, else short location code like SCA, else "Online" for the
catalogue) instead of full trading names that cramped the column.

A process lesson worth keeping: one of the three label edits silently no-op'd (its
target text had been reworded by the previous edit in the same run) and everything
still LOOKED deployed — tests green, container rebuilt, function source showing the
new line — while the API served old labels, because the computed `label` sat unused
one line above an append still shipping `name`. Diagnosed by calling the function
directly inside the container. Rule reinforced: **every scripted replace gets an
assert on match count** — the no-assert edit is the one that lied.
## 2026-08-25 (later) — cards show the comparison inline (v0.19.0)

From a mockup rian liked (concept adopted, not the design): product cards now carry
the comparison itself — up to four shops, one row each with a bar scaled to that
shop's price, cheapest first and emphasised in our green, then a "Save $X / buy at
LHR" footer. Data: ProductSummary grew `top_prices` (label, usd, in_stock; one row
per shop, in-stock-preferred, cheapest-first, capped at 4) from the existing
_priced_locations query — and it inherits the `at=` scoping, so with airports set the
bars show THE SHOPPER'S shops. Bar widths are computed geometry passed as a CSS
variable per the frontend rule. The corner tag now prefers Exclusive/Award since the
footer carries the saving. Single-shop cards keep the old price footer.

Verified via API (unscoped: LHR 271.51 / BCN / MAD / JFK; scoped at=LHR,DXB: just
those two). NOT yet seen rendered — Chrome extension disconnected; rian should eyeball
the browse grid for bar proportions and row cramping on narrow cards.
## 2026-08-25 — SEO layer shipped; Django decision supported; revenue memo written

**Landed (all verified by curl-as-crawler + 120 tests + deploys):** the server-rendered
SEO layer at v0.18.2 — schema.org Product/AggregateOffer/award JSON-LD, unique titles
and descriptions, slug URLs with 301s from numeric, sitemap.xml (6,593 URLs), robots.txt,
noindex on /discuss//settings//sources. HTML-entity cleanup at ingest + migration (49
names, 22 brands). JFK + Buenos Aires collected (Adam's exact JW-Blue sentence now
answers with 13 shops; EZE honestly lacks Blue Label). Review page updated with the SEO
card and refreshed airports line. Revenue memo at notes/revenue-thinking.md and hosting
brief at notes/hosting-brief.md (both rian-private). Cross-session: Django dispatch
agreed for auth-heavy portals; django.md being written fleet-side by the Hartling
session; the WP 6.8 $wp$ hash trap recorded above.

**In flight:** nothing running. Collections are all manual — still no scheduler.

**Next:** the run-over-run regression check + nightly schedule (rian said he will
revisit; it is the top ops gap and the Sep 7 quote's natural centerpiece).

**Blocked/needs rian:** record the walkthrough video (public/walkthrough-video.mp4 or
.txt); send the two invoices; decide hosting (brief is written); Friday send to Adam.
## 2026-08-25 (later) — fleet note: WordPress password hashes are a trap now

From the Hartling owner-portals session (which measured real WP databases): WordPress
6.8+ stores passwords as `$wp$` = bcrypt over base64(HMAC-SHA-384(password,
"wp-sha384")) — NOT plain bcrypt. Feed the raw password to bcrypt and no post-6.8
account verifies; older rows are phpass (`$P$B`). Matters here the day any of Adam's
WordPress properties (competition network, professor sites) migrates onto this
platform with its users. The fleet dispatch rule and the hasher details are being
written into /srv/projects/standards/django.md by the Hartling session; DFP's own
auth question stays parked per agents.md (app-native when it comes, for the
portability promise).

## 2026-08-25 — the SEO layer, server-rendered (v0.18.1)

Built for Mark's checklist, in rian's priority order, all live (mechanism: main/docs/SEO.md
"Structured data per page type" + "What renders server-side"): (1) Schema.org on every product page:
Product with brand, gtin13, category, image, `award` (real medals), AggregateOffer (low/high/count
in USD), plus BreadcrumbList; WebSite+SearchAction on the home page, all server-rendered; (2) unique
titles per page, server-stamped with live shop counts; (3) descriptive URLs `/products/{slug}-{id}`,
numeric and stale-slug URLs 301 to canonical, slugify mirrored in Python and TS with a parity test
over Rémy, Moët, PATRÓN®; (4) sitemap.xml, 6,593 URLs from the database with per-product lastmod,
referenced from robots.txt; (5) unique meta descriptions generated per product with live prices.

**The architectural answer to "bots can't see a SPA":** every page's HTML leaves through FastAPI, so
the whole machine-readable layer (title, description, canonical, OG tags, JSON-LD) is injected into
<head> server-side from a cached copy of the built shell; crawlers that never run JS see all of it,
Google's renderer additionally sees the body. No SSR rewrite needed. /discuss, /settings, /sources
are meta-noindexed.

Injection safety is tested: product names are untrusted feed strings, so entity-escaping in head
tags and the < JSON-LD escape (a name containing </script> stays inert) are pinned in
tests/test_seo.py. 120 tests total.

**Found in passing and fixed:** 49 product names + 22 brands stored with raw HTML entities
("Bacard&iacute;") from a feed that ships them. Ingest now `html.unescape()`s name/brand at the one
boundary every collector crosses; migration `a3b4c5d6e7f8` repaired stored rows, recomputed match
keys and re-merged twins the cleanup reunited.

Still open for SEO, deliberately: category landing pages as real paths (query params today),
per-airport landing pages (needs the airport-guides feature), and OG images. Site remains gated, so
nothing indexes until launch, everything is simply ready.

## 2026-08-24 (night 2) — the review page became a document, not a list

rian's read: 47 flat headline cards is a filing cabinet, unreadable for someone who was not in the
room. His example: "Take facts, never log in", shorthand for the whole legal posture, meaningless
alone.

**Restructured into a narrative read top to bottom in about ten minutes**
(`main/web/src/lib/review.ts` holds the prose; the page renders items under the section that
explains them; mechanism: main/docs/CLIENT-SURFACES.md "The feature board"): 1. Start here (video);
2. What we built (+ live scale figures + a link to the Johnnie Walker row); 3. How good is the data?
(verification, the audit, what can still go wrong); 4. Which shops we could reach, and which said
no; 5. **How we treat other people's websites** (the four rules stated plainly, what that shorthand
meant); 6. What it takes to keep running (manual today, what automation needs to exist); 7.
Decisions I need from you (10, numbered); 8. Where this goes next (+ planned features).

Items went 27 -> 18 (8 worth-knowing, 10 decisions). Nine were folded into the prose and resolved
rather than deleted, so the curator view still shows them: the three posture items became one
section, the currency and listing-page items became prose, member-prices merged with promotions,
editorial-home merged with where-writers-work.

**Staleness sweep of the whole page**, found and fixed: hardcoded counts ("1,010 comparable",
"20,000+ observations") had rotted after the deep crawls, now read live from `/api/stats` at page
load (the rule: never write present state into prose nothing updates); "We cover fifteen airports"
was 17 and climbing, reworded to carry no number; the Johnnie Walker figures were correct but would
drift, so the prose now describes the shape and links to the live product page; em dashes removed
throughout the client-facing copy (rian's house style for Adam's emails applies here too).

Verified every BUILT/PLANNED claim against the database before shipping. Live at v0.16.0. Chrome
extension was down for the final visual pass, so the page has been verified by content and typecheck
but not re-photographed.

## 2026-08-24 (night) — a test suite, because the same bug kept coming back

**105 tests over the pure logic, wired into `check.sh` before every deploy** (0.5s, no
network, no database). Written after rian asked the fair question: every prompt finds
bugs, so how much can we trust this? The honest answer was that the variant-price bug
was the SAME bug four times (Grey Goose, Heathrow, Madrid, Barcelona) because the
audit fix addressed the symptom — quarantine the bad price — and not the cause, and
nothing stopped any hand-verified fix from silently regressing.

Coverage, all of it real incidents: `test_normalize.py` (multipack sizes, the 0.70cl
typo, URL-slug sizes, barcode padding/check-digit/restricted-prefix, match-key
collapsing), `test_awards_matching.py` (every wrong medal the audit found — varietal
matching, Tiger lager, Original-vs-Black, age vetoes — plus the legitimate matches that
must still pass), `test_collectors_avolta.py` (promoted price, declared currency,
configurable variants against a 1KB fixture trimmed from the real Heathrow page),
`test_robots.py` (the wildcard Disallow that stdlib misreads), `test_taxonomy.py` (junk
feed labels never becoming categories), `test_ingest_guards.py` (the GTIN size veto,
including the 700/750 sloppiness that must NOT veto).

**The suite found a bug on its first run**: `parse_abv("Bogus 250%")` returned 50.0 —
the regex matched "50" inside "250". Anchored with a negative lookbehind. Display-only
impact, but it was there.

**Mutation-tested** — reintroduced three real bugs (restricted barcodes accepted, first
price on a tile, brand gate dropped from medal matching) and confirmed each one fails a
specific test, then restored. A suite never seen failing is decoration.

Not covered, deliberately: query shaping, ingest against a live database, the frontend.
Those stay on typecheck + hand verification. The next-most-valuable ops work remains the
run-over-run product-count regression check (scheduler decision still deferred).

## 2026-08-24 (late) — multi-size tiles resolved; Adam's example complete

**Heathrow is on the Johnnie Walker Blue Label 1L row at £199.00 ($272), second
cheapest of 11 shops.** Closing this needed a third fix on top of the two below.

**Avolta tiles advertise the CHEAPEST variant of a multi-size product.** Heathrow's
Blue Label tile says £54.59 -- the 20cl -- against a slug that says 1l. Hamish's own
PoC hit the same trap ("£272.95, 20cl variant £54.59"). The ingest quarantine caught
it (skipped_no_price=1, no observation written), which is why the row was EMPTY rather
than wrong; the guard did exactly its job.

Fix: `variant_prices()` reads Magento's configurable-product block from the product
page (size attribute options -> variant ids -> optionPrices final/old) and the
collector defers every tile whose SKU ends `-P` (Magento's parent marker, 6.6-18.4%
of tiles per store), fetching its product page once to price the size the slug names.
Capped at MAX_VARIANT_LOOKUPS=40/run and logged when capped. **Fallback is the old
tile price, never a skip** -- resolution failure must not lose a listing, and the
ingest guard remains the backstop. LHR re-crawl: 258 prices, 0 skipped (was 1).

Verified the same bug at Madrid by direct page read: true 1L is EUR 240.80 (was
301.00), while the tile price we had stored was EUR 180.60 -- which had been making
Madrid look like the cheapest shop on Adam's headline product. ATH/BCN/MAD/YYZ/MEX/
ZRH/HKG re-crawled to correct this.

**Watch list:** the `-P` suffix is a Magento convention, not a guarantee -- if a store
drops it, its multi-size tiles silently revert to cheapest-variant prices with only
the median guard catching the worst. A run-over-run product-count regression check
would have caught the category truncation days earlier and is the top ops gap
(discussed with rian; scheduler decision deferred).

## 2026-08-24 (evening) — Adam's canonical example fixed; Avolta walk deepened

**"Johnnie Walker Blue 1L at Heathrow" — the sentence Adam will test first — now works.**
Two root causes, both systematic:

1. The Avolta collector walked only the FIRST PAGE of at most EIGHT categories,
   alphabetically — at Heathrow that meant whisky, white-spirits and wine were never
   visited at all. Now every discovered category (cap 16, "view-all"/"digital" skipped
   when real categories exist) pages through ?p=N (cap 6/category, robots-checked,
   stops when a page adds nothing new).
2. Avolta tile names omit the size their URL slug states, so "Johnnie Walker Blue
   Label" at ATH/MEX/YYZ sat as a size-less product apart from the 1L row — and its
   pre-cleanup restricted-circulation code (2020...) blocked the merge. New:
   `parse_size_ml_from_url` fallback in the collector; migration f2a3b4c5d6e7 nulls
   leftover restricted gtins, backfills sizes from listing URLs (only where all of a
   product's listings agree), recomputes match keys, and re-merges twins.

Result before the re-crawl even landed: product 60 = one row, 8 shops (ATH, BOG, DXB,
catalogue, KEF, MEX, SAL, YYZ). All eight Avolta stores re-crawling under the deeper
walk (v0.14.1). Watch for: configurable tiles on deep pages produce more small-variant
prices — the ingest quarantine skips them (logged as price_quarantined); if Heathrow's
JW Blue tile itself is configurable, its price will be quarantined rather than wrong,
and the row will need a per-variant price read from the product page (a bounded,
robots-permitted follow-up if it comes to that).

## 2026-08-24 (later) — the workspace moved: /srv/apps/dutyfreeprofessor

The whole DFP workspace now lives at **/srv/apps/dutyfreeprofessor** (project
`dutyfreeprofessor`, port 3149, domain unchanged: dutyfreeprofessor.demoing.info).
The old `/srv/apps/professormediagroup` path now belongs to Adam's **WordPress site**
(the gateway renamed project `professormedia` into the freed name; DB
wp_professormediagroup, container professormediagroup-wordpress, domain
professormediagroup.demoing.info). `professormediagroup/main/dutyfreeprofessor` is a
symlink to this workspace — the gateway cannot nest projects, so the link stands in
for the nesting rian asked for.

How the move was done (custom projects cannot be renamed): fresh pg_dump →
create-project dutyfreeprofessor → rsync the workspace (minus .env/.project.json/
data/.venv-dev — the first attempt clobbered the new .project.json and was redone) →
deploy → pg_restore → domain re-pointed → old project deleted → WP renamed.
Compose was already ${PROJECT_NAME}-parameterised, so containers renamed themselves;
the image tag was the one hardcoded bit (now ${PROJECT_NAME}-app:latest).

Loose ends to know: the host venv was rebuilt in place (absolute shebangs don't
survive a move); check.sh passes. Claude's memory for this project was copied to the
new path. The video drop location for the review page is now
**/srv/apps/dutyfreeprofessor/public/walkthrough-video.mp4** (or .txt with a URL).
Data verified identical post-restore (6,303 products / 20,271 observations / 422
medals / discussion + comments intact).

## 2026-08-24 — domain swap: the app moved to dutyfreeprofessor.demoing.info

The DFP app now lives at **https://dutyfreeprofessor.demoing.info** (same project,
same workspace, same container — only the domain moved; both hosts carry the default
shared-password gate and SSO sessions carry across). The freed
**professormediagroup.demoing.info** now serves a fresh WordPress site, template-cloned
(Kadence + ACF Pro + Gravity Forms + UpdraftPlus), project name **professormedia** at
/srv/apps/professormedia — the gateway cannot rename a custom project, so the app's
workspace keeps its historical name; the mismatch is internal only.

Details that matter later:
- The review page for Adam is now https://dutyfreeprofessor.demoing.info/discuss.
- Browser storage is per-domain: airport selections and the comment-name field start
  fresh on the new domain (nothing was lost server-side — comments and items are in
  the database).
- WP admin: https://professormediagroup.demoing.info/wp-admin/ with the template
  credentials (support + the template password). ACF Pro licence may need
  re-activation.
- A short DNS-cache gap: machines that looked the old name up during the swap cached
  a miss for a few minutes. Server-side verified clean; security audit clean.

## 2026-08-23 (evening 2) — the client review page

`/discuss` is now the page rian sends Adam first (v0.14.0): a video slot at the top,
then the realities (12, priority-ordered: data honesty, coverage, legal), then the
decisions (11, numbered), then what's-built-with-extent and what's-planned, then
background. **Every item carries a comment thread** — typed name (kept in the browser),
no account, since the shared password already gates the site. Comments live in a new
`discussion_comments` table; the API grew `POST /api/discussion/{id}/comments` and the
update endpoint now accepts theme/needs_decision/sort_order.

**The video slot** looks for, in order: `public/walkthrough-video.mp4` (in-page player)
or `public/walkthrough-video.txt` containing a URL (a watch button). Both are read live
from the bind-mounted public/ dir — dropping the file in needs NO redeploy. Until one
exists it shows a tasteful placeholder.

Curator controls (add item, resolve) hide behind Settings → "Curator controls" so Adam
sees a clean page. New comments have no notification — rian checks the page (or asks
for an email ping to be built).

Also this session: Madrid re-collected successfully (133 prices — the workaround probe
was right, their grid is server-rendered again), Dublin refreshed (665), the five Avolta
stores re-collected clean under the fixed parser, images at ~1,274.

## 2026-08-23 (later) — Quality audit: 51 confirmed findings, all critical ones fixed

17 auditors swept data, backend, frontend, copy, live pages, limitations: 51 findings, 0 refuted;
critical fixed (v0.13.2). **Four big ones:** (1) **Avolta variant prices (critical, data)**: wrong
variant/pre-promo price inflated savings. Fixed: typed finalPrice parsing, was-price capture,
quarantine <0.45x median, bad rows deleted; residual risk: <3-peer-shop products. (2) **Heinemann
robots (compliance)**: wildcard-robots misread let 4 collectors read a disallowed endpoint across
the Heinemann family (global, BorderShop, Keflavik, Sydney) (main/docs/COLLECTORS.md); all 4 now
refuse, 1 run's rows removed, data ages. Cannes partnership ask. (3) **Medal matching (critical,
client-facing)**: 1 in 3 medals sat on the wrong bottle. Rebuilt: brand gates, expression/number
vetoes, ambiguity=no-medal: 422 stand (was 486); needs competition product IDs. (4) **Duplicate
products (critical, data)**: barcode-less GTIN'd-bottle sightings minted 327 duplicates; fixed in
ingest + migration merge; GTIN joins veto size disagreement; GS1 02/2x barcodes no longer
cross-join. **Also fixed** (17 items): comparable>1-airport (960 vs 1,226); multipack=pack totals;
?at= sharing (/trip carries over); junk-category purge, per-location dedup, stale-airport pruning;
hero: live JW Blue (Panama $420/Dubai $317/Bogotá $300); plus 8 minor UI/search fixes. **Live sample
checks:** 12 stored prices re-fetched (Avolta LHR/ATH, Dubai API, Montreal, Attenza Panama,
BorderShop): 11 exact, 1 mismatch (promo-price bug, Laphroaig 20% off), fixed (refetch via fetch
port, compare price/currency). **Workaround probes:** Madrid re-renders server-side (re-enable:
config+sitemap; true EANs lift Avolta barcode coverage). Dublin GTINs dead end (recovery: name
matching). OFF images ~23%; brand-upload via competition network (top20=38.8% of head products,
medal winners first). **Queued:** brand-string normalisation (72 variants); Dublin name-variant
matching; image reuse across size variants; 14 restricted-GTIN joins by hand (1 known-bad split off
already). **In flight:** re-collection of 5 Avolta stores + Dublin (fixed parser); 461-product image
backlog. **Deploys kill running collections**: twice `srv-gw deploy` cut them mid-run (batched
commits kept partial progress); deploy before collecting.

## 2026-08-23 — The trip becomes a persistent airport set; Adam's nav and home page

**The headline feature changed shape.** Was a flight path (from/via/to); now a ticked airport set,
any order, capped at six (a traveller knows shops passed, not the leg). **The selection persists and
scopes the whole site**: `lib/airports.tsx` (localStorage, `MyAirportsProvider`), in the header,
passed as `at=` (browse grid, exclusives/awards, related-products rail, per-product "On your trip"
verdict); `/trip` redirects to `/savings`. **Savings has real filters** (search, category, min
saving, sort, award winners, travel exclusives); old flight-path picker/trip page deleted. **Nav is
Adam's order**: Products, Reviews, Price tracker, Exclusives, Buying guides, News, Awards + ticker +
header search; Exclusives/Awards are real pages (flags already existed), rest are placeholders.
**Home page follows his wireframe**: editorial row (labelled Sample), price-alerts band,
travel-exclusive/award-winner strips, newsletter band; four-stat trust strip gone; numbers now one
sentence above the closing band (rian's ask). **Four data bugs found, all fixed:** (1) Mexico City
published at ~1/17 real prices: `US$308.00`/`MX$5,390.00` shown side by side (USD declared in
microdata, read as pesos); an $18 Rémy Martin XO topped "cheapest anywhere" (main/docs/QUALITY.md).
Collector now reads `priceCurrency`. (2) related-products rail said "in 3 of your shops" naming
shops never chosen (unscoped query behind a personalised label). (3) 7 products carried Open Food
Facts URLs with `/products/invalid/`, 404ing to broken-image; rejected on intake, migration
`b8c9d0e1f2a3` cleared them. (4) Madrid counted in picker/shop count while holding nothing; both now
count only stocked shops. **In flight:** Mexico City re-collection (60s crawl delay); MEX prices
mis-denominated until it lands. **Checked, not assumed:** six other Avolta stores fine: median local
prices (€38.20 Athens, €26.56 Barcelona, HK$530, £29.99, C$64, CHF 46.70) all plausible; Mexico's
MXN 55.75 alone was absurd; fix isolated. **A fifth bug, found last:** `spec.html` had no route,
fell through to the SPA catch-all, served the shell (200) the whole time; explainers now served by
rule (main/docs/ARCHITECTURE.md), a missing page 404s, `/index.html` unaffected; the pre-app
placeholder archived. **Next:** more airports; then decide the auth question below.

## 2026-08-23 — Avolta added; concurrency in the collector fixed

**Avolta is collectable, and the earlier "WAF-blocked" conclusion was wrong.** Each
storefront's robots.txt permits product and category paths and asks for a crawl delay
(30s at Heathrow, 60s at Athens). The collector reads each host's own robots and honours
whichever delay that host states, never faster than our own. The earlier challenges came
from probing *search and facet* paths — exactly what Avolta disallows.

Live Avolta airports: Heathrow, Barcelona, Zurich, Athens, Hong Kong (Toronto and Mexico
City collecting). Eight configured out of their 168; scaling further is a config list,
not new code. Heathrow vs Barcelona alone gives ~43 comparable bottles.

**Madrid does not work and should not be forced.** Its grid renders client-side, so
there is nothing in the HTML to read. The only routes in are a headless browser or the
endpoints its robots disallows. It stays configured but excluded so the limit is visible.
Avolta barcode coverage is ~1/3 (real barcodes mixed with internal SKUs), so it matches
other Avolta airports well but the wider catalogue less cleanly.

**Three collector bugs fixed, all surfaced by running airports concurrently** (one
process per host, so each host's crawl delay applies independently):
1. Two airports stocking the same bottle both tried to create it; the barcode uniqueness
   constraint killed one run outright. Row creation now tolerates a concurrent creator —
   retailer, location, product and listing alike.
2. Once duplicates were handled they deadlocked, because each run held one transaction
   end to end. Collections now commit in batches, which shortens lock windows and means
   a run that fails part-way keeps what it collected.
3. A single dead category page ended a whole airport's run. Now skipped.

## 2026-08-22 — autonomous session: near-launchable demo

**Live:** professormediagroup.demoing.info (gated, shared password), v0.9.1. **Where the data
stands:** 10 shops, ~5,965 products, ~1,075 comparable, ~18,900 prices, 486 medals: BorderShop (SCA)
1,845/100% (DK/DE land border, DKK); Dubai (DXB) 1,527/100% (open API); Heinemann global 1,043/100%
(catalogue not an airport; robots blocks it); Keflavik (KEF) 973/100% (real ISK prices); Dublin &
Cork (DUB) 661/0% (sitemap-enumerated, tier picked); Montreal (YUL) 465/7% (Shopify); Sydney (SYD)
448/100% (AUD); Bogota/San Salvador/Panama 655/0% (1 Shopify adapter, name-match). **Barcodes are
the whole story:** publishers match cross-continent (Keflavik-BorderShop 354, Dubai-Keflavik 121);
Latin American shops (no barcodes) match only each other on brand+name; the strongest partnership
argument. **What the site is now:** Home page (editorial hero, trip search, live savings,
categories, award winners, teasers); Trip search (headline feature, suggests overlapping routes);
Airports (every shop: range+freshness; uncovered airports + reason); Products (search, category
rail, 3 refinements, sort; default "Featured" leads complete-looking products); Product pages (dark
hero, medals in original artwork, plain verdict, related bottles); Decisions & realities (20 items
(9 "decision", rest "worth knowing"), each with constraint + compromise); Coming-soon (reviews,
guides, price tracker, map, alerts: explain what they'll do, no 404s); Demo settings (build-time
toggles, e.g. "only products with a photo", moved out of storefront). **Next, in priority order:**
(1) record the walkthrough video for Adam (last PoC deliverable); (2) images: ~2% coverage, stays
low (exclusives, no open db); generated brand marks fill gaps; real answer: brand-supplied assets
(decisions item 1); (3) more airports: Avolta's 168-airport platform is WAF-blocked, a partnership
not engineering ask. Dead ends: King Power (API), Penha (liquor), ATU Riga (no alcohol), Duty Free
Americas/Fa-So-La/CDFG (blocked), Attenza's other subdomains (don't exist); (4) scheduled
collection, still manual; (5) auth deliberately absent (agents.md standards conflict unresolved).
**Known rough edges:** Dublin's sitemap lags (a few URLs 404/410/run, now skipped not aborted).
Image enrichment (background) is killed by deploy; rerun `cli images` after.

