# Changelog

## Unreleased
- **A privacy policy and terms of use, linked from every page's footer.** `/privacy` says what
  the site keeps about a visitor, a subscriber and an account holder, the one sign-in cookie, and
  that no advertising or analytics trackers run; `/terms` says the prices are dated observations
  rather than offers, and how the site may be used. Both are standard text for now, kept out of
  search until they are approved.
- **The site's own menu now says what it is to a screen reader, and the article index names its
  list of pieces.** A reader who moves between the parts of a page heard the main menu announced
  as nothing at all, on every page; and on the articles index the page title was followed
  straight by a headline, with the list of pieces itself unnamed. The menu is now named and the
  list carries a heading, spoken but not drawn. On the collectors page the menu for choosing what
  a collector looks for now closes when you press Escape, as every other menu on the site does.
- **Every page is checked at both a desktop and a phone width before a release.** One command
  now draws a page in a real browser at whatever width is asked for and reports what sticks out
  past the edge of the screen, what text a box cuts off, and what a person reaches with the Tab
  key, so a page that is too wide for a phone is caught here rather than by a reader holding one.
- **Every address on the site can be checked at once, signed out.** One command now fetches every
  address the site declares with nobody signed in and says what each one answered, so the check
  before the site opens covers all of them instead of the four somebody thought to try. If a page
  or a read that should ask for a sign-in answers as though it were public, the command stops with
  that address named.
- **The database moves to the live site once.** Finishing the launch leaves a marker on both
  machines, and the command that replaces the live database then refuses to run again unless
  someone types the flag that overrides it. From the launch on, the live site is where collecting
  happens and where the catalogue is decided, so copying the other site over it a second time would
  throw all of that away. A push to the live site carries the code and its database changes, never
  the data again. Rehearsing the same replace on a scratch database here is unaffected.
- **One line tells the app which site it is.** `SITE_ROLE=live` on the live host hides the
  surfaces built while the site was being made: the build conversation and its tabs, the demo
  settings, the picture review, the plan and the running list. On the live site those addresses
  do not exist at all, for the owner as much as for anyone else, so nothing built for the making
  of the site is on show once it is open. The catalogue's own pages, the data sources, the
  collectors and the review are unaffected, because they belong wherever the catalogue is, and
  so is the side panel people leave comments in. Without the line a host reads as the staging
  one and shows everything it did before, so the live host is checked by asking it
  (`/api/health` now says which it is) rather than by trusting a file.
- **The article centre is a page now, not an empty frame.** `/articles` is rendered by the
  server: the newest piece leads, the rest fill the grid with their picture, section, headline,
  standfirst, reading time and tags, and a reader or a search engine sees all of it without
  running any JavaScript. The tags in use sit above the grid as a filter rail, so `/articles`
  can be narrowed to one subject and the address shared. A piece about a brand or a category now
  ends with what it is about: the brand's page, a few of its bottles, the category page and the
  piece's own tags. The centre itself stays out of search until it is approved by hand, while
  each piece still reaches search the moment it is published.
- **Ten sample pieces for the article centre.** The Professor has written ten articles, on how
  airport pricing works, allowances, reading a shelf price, whisky age statements, fragrance,
  which categories are worth buying airside, litre bottles and travel exclusives, airport guides,
  what a competition medal tells you and a pre-flight checklist, so the article centre can be
  looked at with real copy in it. Each is marked **Sample** on its card and at the top of its
  page, so nobody mistakes one for the client's own work, and one command takes every sample back
  to draft before the site goes live.
- **Articles carry tags and a picture credit.** An article can now hold a few short labels, which
  the article centre will filter on, and a hero picture prints its attribution as a caption
  underneath: a supplied picture is licensed, not ours, and the credit rides with the address
  rather than being remembered somewhere else.
- **The first article import after a database refresh no longer fails.** A refresh replays the
  rows people wrote with their own ids and leaves the table's id counter behind them, so the next
  import collided with a row that was already there and stopped at the first command. The import
  now lifts the counter once, says it did, and carries on.
- **A product that vanished from a shop no longer marks the shop as refusing us.** A page the
  shop has withdrawn answers "not found", which the collectors read as the shop turning us
  away: one delisted bottle ended the run and left the whole airport reading "refused" with
  Start switched off. A withdrawn page is now counted as missing, named in the log and walked
  past, and only a real refusal (the host turning us away) closes a source.
- **The page status, for the people who decide it.** Signed in as the owner or on the admin level,
  every page now carries a small chip in its corner saying what that address is: **indexable** (no
  robots directive and in the sitemap), **noindex** (reachable and linked, out of the sitemap until
  a person approves it), **unlisted** (reachable by direct link only, never indexed) or **hidden**
  (the address answers a redirect, and the chip names where to). Beside the word: whether the
  sitemap lists it, and, while the site is members-only, that nothing indexes until that flips.
  Nobody else can see it and it is never part of the page a visitor or a crawler is served. The
  new permission is `pages.status`; after the deploy, `backfill level_permissions` grants it to an
  `admin` level seeded earlier, and never to one edited by hand.
- **One standard for what is featured.** Every list that says "featured" (the home page's headline
  and grid, the savings rails on the category and family pages, brand pages, the airport
  best-value block and the full product list) now runs the same order: an admin's pick first, then
  the picture and how good its source is, an award counted only on a real saving, and where the
  saving stands in percent and in dollars, with the two best savings always in the first four, one
  bottle per brand, no more than three per category and one exclusive in the first eight. The
  category and family "Biggest savings" rails shipped in 0.52.0 on a sort that read no saving at
  all (a rail could lead with two cards saving nothing); they now show real savings. A comparison
  is two or more of our airports: an online catalogue identifies a product variant but never
  corroborates a spread, and never decides whether one is fresh or in stock. Stale comparisons (no price in a fortnight, or an oldest price past sixty
  days), sold-out and unsized ones stay on their pages, dated, and off the rails. An admin can pin
  a product line or a product variant (`app.cli featured pin`): the pin leads every list the record
  is already a comparison in, never admits one, and is a ledger decision an undo releases.
- **How we choose what is featured, on a page.** `/how-we-choose` says in plain words how every
  featured list is ordered: what "featured" means, what a comparison is, that a price is what a
  shop showed on a date, the factors in the owner's order with every threshold written beside the
  code's name for it, the first eight and after, the guards, what an admin's pick does and what is
  never done. The site reads the same document it shows (its version line is the rules version),
  and every featured rail links the page from its head: "How we choose". The page is public and
  `noindex` until its address is approved.
- **Pictures at three levels.** A product variant, its product line and its brand can each hold a
  picture, with who supplied it and under what licence recorded on the row, and every card, page
  and served body shows the best one it can reach: the product variant's own, else its product
  line's, else the brand's mark, styled as a mark and never given to a search engine as the
  product's image. The client's folder and the brand-owner export were classified into a
  reviewable manifest, staged as content-hashed WebP derivatives and imported with their
  provenance, and a product variant or product line page names who supplied its picture; a
  supplied picture outranks any fetched one and no machine replaces it. A brand
  without a mark can take an openly licensed one from Wikimedia Commons, a product line without a
  picture takes its representative variant's Open Food Facts photo, and the Open Food Facts name
  search reads its payload again (it had found nothing since a rename). `/images` counts every
  level, says what each card shows today and from where, and lists what shows nothing at all.
  The Commons lookup reads each host's robots.txt before its first request, as the collectors
  do, and a matching Disallow stops it; both Wikimedia hosts publish one, so the lookup is
  refused until a decision says otherwise.
- A new shop on the Avolta platform: Helsinki (HEL, EUR), configuration and tests on the tiles its
  storefront serves, its address declared as `/airports/helsinki-vantaa-hel`, and Finland's flag on
  the airport cards. Its robots permit every category page, so a collection reads them all. It
  stays hidden until it is shown, and its prices arrive with its first collection.
- A new shop on the Avolta platform: London Gatwick (LGW, GBP), configuration and tests on the
  tiles its storefront serves, its address declared as `/airports/gatwick-lgw-london`. Its robots
  permit page one of each category only, as Heathrow's do, so later collections grow it. It stays
  hidden until it is shown, and its prices arrive with its first collection.
- A new shop on the Avolta platform: Las Vegas (LAS, USD), configuration and tests on the tiles
  its storefront serves, its address declared as `/airports/las-vegas-las`. It stays hidden until
  it is shown, and its prices arrive with its first collection.
- The live collectors tab and the review's approval gate now give one answer about what is
  collecting. The tab read the twenty-four hour timer on its own, so a run a reboot orphaned was
  drawn as a collection in progress, elapsed time climbing and a prices-per-minute rate beside it,
  while the review refused every approval because of that same run.

- **The live collectors page is a control room.** `/collectors#live` starts a collector (look for
  new, or read the held listings back, with an optional limit), pauses and resumes it, sets its
  pace and stops it, from the page, every action recorded against the person who pressed it. A
  running collector answers between requests, never inside one: a pause or a stop lands within
  two seconds plus the request in flight, a paused run keeps its heartbeat, and a run the guard
  sheds ends `stopped` with its prices kept instead of sitting `running` for a day. Each row says
  one of nine words (running, pausing, paused, stopping, stalled, dead, idle, refused, off), how
  far along it is ("of 30" for an exact count, "of about 1,240" for an estimate, never 100 while
  running) and about how long is left. The pace can never go under the host's own robots.txt
  crawl delay, our 1 s floor, or 10 s on a rendered source, and a host that refused us cannot be
  started from the page at all. The board shows the app container's memory against the lines
  where starts are refused and where the guard sheds, the opening hours collectors in their own
  group, and a banner while a deploy has collection frozen.

## 0.52.0 - 2026-09-18
- **A category is a page.** `/alcohol/whisky` and `/beauty/perfume` exist, with `/alcohol` and
  `/beauty` above them. Until now a category was a filter on `/products` that named `/products`
  as its canonical, by design, so the site had no indexable page for its broadest search terms:
  the structure review settled `/alcohol/whisky` with Mark on 10 Sep and nobody had built it. A
  category page carries the figures, the airports that stock it with how often each is the
  cheapest, its brands, the primer and the list; a family landing carries its categories, the
  biggest savings across all of them, its airports and its exclusives. Both render server-side,
  class for class with the SPA, with CollectionPage, ItemList and BreadcrumbList.
- Every category name now links to its own page rather than to a filtered catalogue, and the
  Drinks and Beauty departments land on `/alcohol` and `/beauty`. A category has a page from the
  moment it has a published product, so a link can never point at a page that is not there; a
  thin one is kept out of search by the flag instead.
- **Nothing new is indexed.** Both page types are `noindex, follow` and absent from the sitemap
  until a person names the address in `publish.INDEXED_PAGES`, which is empty.
- **The category primer.** The client's category copy is stored in parts, a summary and a buying
  line that are always open and named sections that open on a click, so a reader who came for
  prices does not scroll past a history of distillation to reach them. Whisky is written; the
  rest are a paste into `category_guides.py` with no code change.
- A collection that a reboot or a deploy killed no longer holds the review. A run is counted as
  live only while it could really be collecting: collectors run inside the app container, so a run
  that began before that container did is dead however recently it started. The twenty-four hour
  timer stays as the outer bound. Before this, a host reboot mid-sweep left two three-hour-old runs
  marked `running`, `backfill stuck_runs` declined them as too young, and every approval on
  `/review` would have been refused with "wait" for the next twenty-one hours.
- The airport page stops throwing away the page the server sent it. With the sponsor shelf tile
  switched on in a browser, the page asked the server for one product fewer than the server had
  just rendered and seeded, so the seed was rejected as a different view, a loading skeleton was
  drawn over a finished page, and the same page was fetched again. A setting that lives in one
  browser no longer changes what the server is asked for.
- Every part of an airport page we mean to have and do not have yet draws a placeholder where it
  will sit, at the size and in the shape of the real thing, with what to gather on hover. The
  panel that listed the missing parts is gone, and what only we see (the count, where the hours
  came from, and what the guide's author says still needs checking) sits in one strip at the end.

## 0.51.0 — 2026-09-19 — the review we go live with, and a live collectors tab
- A live tab on the collectors page: what is collecting right now, prices per minute, every source's state, the last 24 hours; it polls every five seconds.
- The airport page reads as a lookup: a contents line under the hero, one best-value card per
  family instead of four, opening hours as one line with the terminal detail opening in place,
  and the shops a guide names carried into the page's Airport markup.
- An airport's written guide is data, not code: one document per place (`places.attributes.guide`),
  imported from `import/places/<slug>.json` with `app.cli places guide import`. Adding an airport's
  guide is a file and a command, never a release. Heathrow's is rebuilt from the September profile
  with the earlier guide's gate positions and store sizes merged in.
- The airport page carries the guide's own sections: what decides how you shop there stands above
  the terminals, each terminal keeps its shops and what is worth buying, and the allowances and
  tips move below the products under their own titles.

- The review we go live with (Stream K12): every suggestion names the kind of judgement it is and a new kind is critical until answered with a note; the sheet opens with what the pass saw and groups by kind with one confirmation per group; "approve the rest" waits until no lead is open; a brand fold card shows both directions and a merge card names the retailers it joins; the guide tab shows the precedents with overturn; `pass status` says when the review is done.

- **The AI pass is the review; the programmatic stage stops asking.** Every one of the 938
  questions waiting on `/review` came from a word list, written in one instant by a backfill: the
  AI pass had never run, and rian's ruling is that he never reviews the programmatic stage at all.
  Those lists leave his queue and become the pass's input, readable per brand with the words each
  one matched and both sides of a pair named (`proposals hints`). Nothing is deleted: a withdrawal
  keeps every row, and a pass named explicitly still draws it.

- **Defer, with a note the next pass reads.** A judgement can now be answered "not yet, and here is
  why" instead of "no". A deferred row leaves the queue and is never applied, and a later pass is
  refused if it re-asks that question without naming the note it read, so consensus accumulates
  into the reasoning rules instead of every pass starting from zero.

- **A product line is reviewed as one table.** Each proposed line draws with every listing that
  would sit in it, the attribute kinds that vary as columns, each shop's listed name, price and a
  link to the source, and each proposed fold called out with its reason. Approve, overturn or defer
  per row and for the whole line; the per-row question list is one click away.

- **A brand row is no longer joined or split by accident.** The brand key stopped folding trailing
  words, which had been quietly joining two brands with no decision behind it and eating parts of
  real names ("L'Oreal Paris" keyed as `loreal`), and trademark glyphs no longer split one brand
  into two rows. Measured on a copy: 47 listed spellings change, 9 groups split, 7 glyph twins
  merge; nothing is applied.

- **The review process document records consensus rather than prescribing defaults** (version 4),
  and gains a brand section and the display-name policy. The set, coffret and refill question is no
  longer answered in advance: the pass raises it per case.

- **A brand's review sheet shows the brand's whole queue.** Each word list loads as its own pass
  and the sheet drew the newest pass alone, so a brand whose nineteen waiting rows came from three
  lists drew three of them while the brand index, which counts every pass, said nineteen. With no
  pass named the sheet now shows every live pass, under one heading per list with that list's own
  count and its own approval (an approval is one batch under one pass, so there is no button that
  spans them); naming a pass still narrows to it. The index candidates leave the brand sheet: they
  are a publishing decision, not catalogue cleanup.

- **A review row says what it is about and asks its question in words.** A row proposing that two
  product lines are the same sent both sides as uuids and resolved neither, so the judgement
  reached the page with nothing on it to judge: rian opened a brand's sheet and could not answer
  one of nineteen rows. The sheet now resolves every pair side and merge target to its name, and
  every row names its own subject, so no row is identified by its key. A rule's evidence (the name
  it read, with the words it matched marked) is shown instead of being dropped as "no listed text",
  which it was on every rule row. The row reads as the question -- both names, then "One product
  line, or two?", then the reason without its `rule:` prefix -- the Rules column no longer repeats
  the proposal verbatim, the pass selector names the work rather than the generator, and the page's
  own explanation moves into a disclosure that is closed once it has been read.
- **The review area is three tabs, and it shows every level of folding, not brands alone**
  (Stream K10; no migration). `/review` opens on **How decisions are made**, with **Folding and
  splitting** and **Publish candidates** beside it; the address carries the tab. The first tab
  READS `docs/REVIEW-PROCESS.md` on the host rather than restating it, so the boundary, the
  grouping defaults and the spot-check thresholds a person sees are the ones a pass follows, and
  the doc ships in the image for it. The second answers "where is the work": brands, product
  lines, attributes and wordings, and product variants, each with what is waiting, what it would
  join or set, what was read to suggest it and where it is decided -- and it shows product-line
  work outnumbering brand work sixteen to one. The third is the index candidates, now reachable
  past the first hundred, with a name filter.
- **The product line page's price block answers a shopper who has chosen nothing.** The block
  offers every variant itself instead of asking for a choice it gave no way to make; a bottle
  with no airports chosen now PRINTS its price at every shop that stocks it, on the page, with
  the date each was seen, instead of naming the airports and linking away; and with airports
  chosen the comparison is untouched while every other shop's price sits behind a disclosure, so
  browsing them costs nothing and gives up nothing. A price still always carries its date, "From
  $X" still never appears, and a family's cheapest is still never printed as the family's price.
- Markdown tables render as tables (`services/markdown.py`), which is what the grouping defaults
  are written as.

## 0.50.1 — 2026-09-17 — the review sheet opens

- **A brand's review sheet answered an error** when its rows came from a rule rather than a
  session: the rules put the words they matched where a character position belongs. The words now
  have their own place on the evidence, and the sheet says which words a rule matched.
- **A rule's row is no longer shown as stale.** A rule reads a product's own name rather than one
  shop's listing, and the sheet had been reporting "the listing is gone" for every such row.

Newest first. The running version is shown in the app footer.

## Unreleased

- **A brand the trailer fold joined wrongly can be split apart, and the list is scoped per
  vertical** (Stream K9; no migration; REVIEW-PROCESS.md version 4, identity rules stay at v6
  because no brand row re-slugs and no variant key moves, proved on a copy of the 17 Sep
  catalogue). `normalize.brand_key` dropped a trailing listed word before a listing's brand was
  resolved to a row, so "Tanqueray Gin" and "Tanqueray" became one row with no decision behind
  it and nothing to undo. Now: a person names the spellings that do not belong and the brand
  they move to, and that is ONE recorded decision -- the variants move, their keys and product
  lines are re-derived, the emptied lines are pruned, and `decisions undo-batch` puts every one
  of them back. Rejecting one of the `rule:brand_trailers` proposals offers that split on the
  sheet in one click instead of recording a rejection and moving nothing. The word list itself
  is now per vertical: the drink and producer words in liquor, the house and city words in
  beauty, the corporate suffixes and the article everywhere, and nothing else in a vertical
  nobody has written a list for -- so two fashion companies whose names differ only by "London"
  stay two brands, and what the drinks list would have joined for them is a pair on the sheet.
  A spelling that is already placed is never re-homed by a change to the list. `/review` shows
  every word list, its words, the vertical it applies to and what it folded.
- `app.cli brands spellings|split`.

## 0.50.0 — 2026-09-18 — the catalogue refactor: one term per concept, the decisions ledger, the certain key, the review, product line pages, publish, the launch path

- **Index candidates on `/review`, and `app.cli index`** (Stream K6; no migration). `index suggest`
  writes the pages that meet their rule as suggestions (a brand over the 10 Sep floor, a product
  line with a variant compared across two places, an airport with fifteen variants); the review
  page lists them with each page's three facts (reachable, reviewed, indexed) and approves per row
  or everything shown; `index approve|remove <kind>:<slug>` does the same from the shell. Every
  approval is one batch with one undo. A page that falls under its rule is suggested for removal,
  never removed; a page a person removed is not suggested again.

- **Search engines are shown only the pages a person approved** (Stream K6; no migration). Every
  brand, product line, airport and category-at-airport page says `noindex, follow` until it is
  approved for indexing on `/review`; the sitemap and IndexNow carry only approved pages (a
  category-at-airport page follows its airport), one address per product line and never a variant
  address, which is a redirect. Approving a page pings IndexNow once. Articles and the hub pages
  are unchanged. The feed's product items link the product line page.

- **Every brand page exists from the moment the brand is first collected; a page a person hid
  answers a redirect, never "not found"** (Stream K6; no migration). The 10 Sep floor (three
  variants across two airports) no longer decides whether a brand page exists: it decides which
  brands the index lists and which pages are suggested for search indexing. A product variant's
  brand name now always links to the brand's page. A hidden brand sends its visitor to its
  products, a hidden product line to its brand, a hidden airport to the airports index (302).

- **`/products/` shows product lines: every variant of a line on one page, with a price printed only
  when it is unambiguous** (Stream K5; no migration). `/products/<line>` lists every product variant
  of the line with selectors from its picked attributes (size, concentration); `?variant=<id>` chooses
  one and `&airports=LHR,CDG` names the comparison. With airports chosen it prints the prices at those
  that stock the variant and names the ones that do not; with none chosen it prints a price only when
  the variant is sold at one place, and otherwise asks for an airport or a variant. An online catalogue
  never prices the page, and every price shows its date. Every card and search suggestion opens the
  line with its variant and the airports its comparison showed; every old variant address answers 301
  to its line. Each line page is `noindex, follow` with the bare line address as its canonical, and
  carries ProductGroup structured data listing every offer with its shop and date. `LINE_PAGES=false`
  restores the variant pages. After the deploy: nothing to run.

- **The review: a pass's proposals for one brand load under a named pass, and the owner approves them
  at `/review` in batches that undo as one** (Stream K4; no migration). `app.cli proposals load --file`
  takes one brand's proposals file (natural keys only, every value citing the listed words it came
  from, validated by `proposals_schema.json`), idempotent by content, with `--check` changing nothing;
  the sheet shows the spot-checks first, then each proposed product line with the value proposed, the
  rules' value, the value now, the cited words highlighted and the decision in force; a person
  approves per row, per product line, or everything except the spot-checks, rejects a row in one
  sentence (a rejected pair is Keep separate), and every approval says "N approved, M held, R
  refused" and can be undone from the sheet's history. Owner only, noindex, linked from the account
  menu. `REVIEW-PROCESS.md` is version 3 (section 6, the file). After the deploy: nothing to run.

- **A rule now joins two listings only when what the shops stated agrees; everything a word list
  used to decide is a proposal on the review sheet** (Stream K3, identity rules v6, no migration).
  The key of a product variant with no barcode is the brand, the name with only the certain parts
  taken out (the brand, the stated quantity, a pack figure, a strength, the concentration), the
  options the shop published as fields, and the quantity. The lists that used to delete words on
  the way (drink words, regions, format words, connectors, the audience, packaging wording, part of
  a brand's name) moved to `services/proposal_rules.py`, where each writes what it would have
  grouped as proposals under its own pass, with the words it read as the reason, and applies
  nothing. A shade or a size a shop publishes as an option arrives as a field and is never glued
  into a name. A stated strength that differs (40 against 43) stops the automatic merge in every
  category, a Keep separate is honoured by every automatic path, and a person may still Confirm
  same across either and then set the value. A listing already placed stays on its product variant
  while the shop's words for it are unchanged, so a rules change never quietly un-places one; a new
  listing that matches nothing becomes its own product variant on its own product line and is
  offered to the nearest approved product line as a proposal. The boundary was chosen from three
  rehearsed on a copy of staging (the numbers are in `docs/REVIEW-PROCESS.md` section 1). After the
  deploy: the K3 block in `RUNBOOK.md`.
- **Every catalogue decision is a row, undoable singly or by batch, and travels between staging
  and production by natural key instead of being lost on a refresh** (Stream K2, migration
  `d2e3f4a5b6c7`). An alias, a preferred name, a merge, a pin, an ignore, a pair ruling: every
  one is now a row in the append-only `decisions` ledger, carrying who decided it, when, why and
  what the rules had at the time; a whole batch of decisions (a sheet approval, a desk Confirm
  ticked) undoes in one transaction, and a single decision undoes on its own, restoring whatever
  was in force before it. The attribute registry (`services/attributes.py`) gives every kind of
  attribute one accessor and one place its identity, display and storage are declared, so a new
  kind is a registry line, never a migration. Places (`places`, `shop_places`) give every shop a
  comparison unit (an airport, a mall, a port, a border crossing) counted once, the same before
  and after `app.cli backfill places` has run. `overrides` is gone (0 rows on every host); the
  `decisions` ledger is the one record of human decision now. After the deploy: migration
  `d2e3f4a5b6c7`, then `app.cli backfill places` and `app.cli decisions verify` (the K2 block in
  `RUNBOOK.md`).
- **The review area and the database say the same words.** One term per concept everywhere (rian, 16 Sep): a product variant is what a barcode names, a product line is what you search for, a brand's alias points at the brand, a shop is a storefront, an attribute is what tells two variants apart, a suggestion waits for a person and is answered with Confirm same or Keep separate. The `/collectors` tabs read Suggestions and Product variants; the listings table's columns say Variant id, Quantity ml and Line alias of. Nothing behaves differently. After the deploy: migration `c1d2e3f4a5b6`, then `app.cli backfill suggestion_decisions` and `app.cli backfill attribute_keys` (the K1 block in `RUNBOOK.md`).
- **A link to a comment opens the discussion panel at that comment**, expanded, scrolled to, shown whole and kept marked until the panel closes, instead of landing at the top of the page. A signed-out reader who signs in on the way still lands on the comment.
- **The catalogue model decided** (`.logs/planning/catalogue-model-decisions-2026-09-15.md`;
  the words in `main/docs/VOCABULARY.md`). Identity rules v5: a shade or a confectionery flavour
  the shop marked leaves the line, so one lipstick in eighteen shades is one line in eighteen
  variations, not eighteen lines; a variation is read only from what the shop marked; "Triple
  Cask" and "Triple Serum" keep their word unless the name is a pack; `rederive` re-derives every
  row's line as well as its key. Every human decision over a value at every level is now one row
  in `overrides` (an alias, a preferred name, a pin, an ignore, a review), so the desk's first
  confirm writes the same ledger a product decision does, and a line's decisions follow its
  products through a rekey. The review area says "Brand" where it said "House", "Suggested match"
  where it said "Suggested merge with", and "Confirm ticked" where it said "Approve ticked". New:
  `app.cli backfill prune_lines`, `app.cli review brand|line <id> checked|hidden|clear` (a hidden
  brand leaves the site everywhere at once); the data moves refuse while a collection runs. After
  the deploy: the v5 block in `RUNBOOK.md`.
- **Product images: top 200 per group** (was 40) in both units, and an Export CSV button on the recommended lists.
- **Bring in.** Starting a topic, or the first comment on something nobody has spoken about, asks who should follow it: chips for the people the conversation can reach, and a reminder that nobody sees a new conversation under For you until they are brought in. The chosen people are named in the first comment, so it reaches them the way a mention does.
- **The Listings table, with more room.** A horizontal scrollbar sits above the table's header as
  well as below the rows, so a wide table scrolls from where the eye is; **Wide** takes the page edge
  to edge and is kept in the browser like the column choice; **Full screen** lays the table over the
  whole window (Esc or the button leaves it).

## 0.49.0 — 2026-09-15 — Stream L: the quantity model, the listed columns, the giant Listings table, the merge desk

- **Every product's quantity carries its unit.** Millilitres, grams and pieces are told apart
  (a 75 g stick is no longer a 75 ml bottle), a multipack keeps its count, a set keeps every part,
  and an unknown quantity never matches a known one. Identity rules v4; every product re-keyed.
- **The Listings table on `/collectors` shows every column** (what the shop showed, what the rules
  made of it, what a person decided, the shop, the collector, the price), sorts and filters on any
  of them with the database doing the work, downloads a CSV of the current view, and lets the owner
  pin a listing to a product or set it aside.
- **The merge desk.** A brands table and a lines table beside the keyboard queue; tick the
  suggested merges you agree with and approve them in one batch as your own decision; proposals a
  session files wait there until a person decides.
- **Decisions survive.** A product's name, line, variation or quantity a person decided is kept
  through every rekey and recollection; a confirmed merge holds across the next collection.
- **The collectors page says "refused since <date>"** for a shop whose host refuses our reader,
  instead of "nothing attempted".

- **A discuss bubble beside each section of the settings page** opens the side panel at that conversation (the count on the bubble, a dot when it needs you) or, for a section nobody has spoken about, at a composer for it in the panel's footer; nothing sits inline on the page.
- **Colour schemes with a light header.** Four more schemes give the header a light ground with the base colour as text (paper on navy and cream, chalk on ink and paper, linen on forest, frost on midnight); the heroes and footer keep the base. The accent picker is gone: each scheme carries its own. The newsletter sign-up counts as real, so its sample switch is gone too.
- **Demo settings, reworked.** One large title; "Brand review" is now "Design settings" with a colour scheme for the whole site (navy and cream, slate and white, ink and paper, forest and linen, midnight and sky, burgundy and ivory) and an accent on top of it; the boxed notes are plain lines; each section has its own conversation, and the page an "Anything else"; the switches for airport pages and news are gone because both are real now, and the News teaser left the Articles menu with them.
- **The bell and the speech bubble are gone.** The profile circle in the header wears the For you number (what needs dealing with) and opens the discussion on For you; the account menu lives in the panel's header, under your name.
- **A page that fails to draw now says so** (the message and a reload link) instead of leaving a blank window; the Product images page no longer blanks when switching to brand images.
- **Product images** (`/images`, in the account menu for everyone who sees the review page): which images would cover the most price comparisons, ranked from the live catalogue in alcohol, perfume and cosmetics. Two units, because the client may be able to get either: the forty products per group carried by the most airports, or the forty brands whose one image would stand in for the most comparable products. Every comparable product behind it, filterable by group, brand, search and "no image yet". Tick what you can source and copy the list into the discussion; ticks stay in the browser.
- **The discussion panel, redesigned.** Three tabs (For you: conversations you started, replied to, were named in, were asked something in, or flagged; Current; Later) and a view chooser (All; Open & Unread, the default, where a fresh resolution stays until read; Unread; Flagged; Requests; My asks; Decisions; Archived), a search box, and an order that puts what needs doing first. The For you number counts what needs dealing with: unread, asked of you, flagged. An Archived pill in the corner opens the archive; a click anywhere outside the panel closes it.
- **The conversation card, redesigned**, the same on the pages and in the panel: where it belongs and who wrote it on the left; on the right a link icon (tap to copy the address, hold for quote, branch to a new conversation, or move) and small marks for what you flagged, what was asked of you, and what you asked; the first comment four lines deep until you click it; Got it and Flag appear as you hover; replies, edit and remove under the comment; Request, Reply, Resolve, and Mark as Later or Current under the conversation. Read cards are filled, unread ones are not, and a circle beside the link icon shows which and toggles it; a click anywhere on a card reads it. A resolved card has a green outline; an archived one a darker fill.
- **Request.** Hand one thing to one person: "Confirm: move to Later", "Confirm: resolved", "Please reply", or Other in your own words. They see it under For you and in the digest; for the first three they get a button that does the thing, and the request completes itself when they do; for Other they press Done. Got it on a comment also closes anything asked of you there.
- **Resolve, with a decision.** Resolving asks for a closing word and whether to record a decision; a decided conversation keeps a green outline and lists under Decisions. Anyone who took part can resolve, reopen, or mark a conversation Later.
- **Flag** any comment for yourself; nobody else sees it. **Got it** on any comment: seen and agreed, the names on hover.
- **Links in comments are links.** A pasted address reads "Google Doc" (or the site's name); a pasted link to a comment reads "Adam's comment, 7 Sep". The words you typed are stored unchanged.
- **Typing @ offers names**: the people on this site, filtered as you type.
- **Topics.** A thought that fits no card gets a conversation of its own, from the panel's footer or by branching a comment; topics live under Topics on the review page.
- **Archive** (Rian): a resolved conversation put away for good, back with the Archived pill.
- **"Mark (by email, 7 Sep)" and "Adam (by email, 7 Sep)" now read Mark and Adam**, linked to their accounts. Everything written before 14 September counts as read for everyone, so Unread means new from now on.

## 0.48.0 — 2026-09-14 — the one side panel: every conversation in one place (R2b)

- **Mentions from before the bell now ring.** A comment that named someone before the bell existed (or was linked up by the September migration) rings that person once after the deploy, dated by the comment, and never twice. The digest mail lists only what needs you (a mention), never the replies and resolutions the bell already shows as news.

- **The 13 September re-threading.** Adam's airport-page drafts, his category example, the cruise-port idea, the dutyfreeawards.com note and Mark's indexing warnings each get a topic of their own instead of sharing "Structure: anything else"; the Instagram and YouTube note joins its to-do, the banner question joins the sponsor line; one test comment is removed (it stays in the record). A moved comment keeps its author and time and says "moved here"; an old link to it still opens the right place.

- **Follow-ups.** Rian can mark any conversation as needing follow-up with a one-line note (who and when show on the card), find them all under a Follow-ups tab in the panel, and clear the mark with a note that is posted as his comment, so the people in the conversation hear it was done.

- **Resolve with a closing word.** Resolving a conversation, from the panel or the page, now asks for a closing comment (optional, the box ready to type in): it is posted as the last word, by you, and the conversation is marked resolved in the same step, so the people in it hear how it ended rather than only that it did. Reopening can carry a word the same way.

- **@ offers names.** Every comment box now says who you can mention on that page (the names under the box), so "@" plus a name is never a guess. The pop-up picker itself belongs to the shared comment box every app in this family uses, and has been requested there.

- **Topics.** A thought that fits no card gets a thread of its own: "Start a topic" at the foot of the panel takes a title and a first comment, and the topic then lives under Topics on the review page like any other conversation, with its own link, replies and mentions.

- **What is new, what needs you, what is done.** Each card says what it is to you: **Needs you** (someone named you or asked you something, the same thing the bell counts) or **Unread** (someone else wrote after you last opened it), on the page and in the panel; a search box filters the panel's list as you type (the label, the subject, the words, the names). Opening a conversation, in the panel or on the page, is reading it: the chip goes and the bell's count drops together.

- **One side panel for every conversation.** A speech-bubble control beside the bell (and "Every conversation" in the account menu) opens a panel docked on the right, pushing the page aside: every open conversation you may see, newest activity first, each with its breadcrumb, its first comment, the reply count on the Reply button and the resolve control. Reply opens the replies and a composer under the card; clicking the card goes to the thing it is about on its page while the panel stays open; a bell row opens the panel at its conversation; Escape closes it. Full width on a phone.

## 0.47.0 — 2026-09-13 — three streams land together: discussions and pages (R2), the complete airport page (G), lines and the merge session (M)

- Category-at-airport pages ship built but switched off (`FEATURE_CATEGORY_AT_AIRPORT`, default off): rian is holding them for a later quote. Off, no page answers, the sitemap and the airport page's rail carry none; on is one environment line.

- Under Account, a checkbox to be emailed a digest of what is waiting for you. The mails themselves start once the site has a mail sender; until then the bell is the only channel and nothing is sent.

- A comment can carry a picture or a PDF: paste it, drop it on the box, or use the paperclip, and it rides along when you send. Pictures open full size in place. Images and PDF only; anything else is refused, and a file is never reachable by guessing its address.

- The running list can archive: any item, open or closed, can be put away for good with a note, and a filter at the top of the list brings the archived set, or every closed item at any age, back into view without a second page. Reopen returns an archived item to open.

- The client pages are one page in four tabs: the review, your to-do list, the site structure and the launch proposal, at `/discuss`. The old addresses keep working: a link to any of them, a comment on any of them included, opens the right tab at the right place. The running list has its own page, `/issues`, and the build plan is the plan.

- A bell in the header. It rings when someone names you with @name in a comment, when someone replies in a conversation you took part in, when a conversation you were in is resolved, and when a decision is recorded on a card you commented on. A mention is "needs you"; the rest is news. Clicking a row opens the comment where it was written and marks it read; "Show resolved" keeps the rows a resolved conversation cleared. Nothing rings for your own actions, and nobody is ever told about a page they cannot see.

- The structure proposal, the quote, the to-do list and the running list carry the same conversations as the review page now, drawn the same way, so a comment works identically wherever it is left; a link to any comment on any of them opens its card. The after-launch lines on the quote keep their conversations under fixed keys, so rewording a line never loses what was said about it.

- The conversations on the review page are drawn by the same components every app in this family uses: a card per subject with the first comment as its root, replies under it when **Reply** is pressed, a composer that sends on Ctrl+Enter, edit and remove on your own words, and resolve on a conversation you opened. A subject nobody has spoken about is a starter whose click is the composer, so there is one answer to "where do I say this?". A link to a comment opens its card and settles on it.

- Every comment now belongs to a thread on the thing it is about (a decision card, a feature, a structure section, a quote line, a to-do, a running-list item, or the discuss page itself), the groundwork for replies, mentions and the bell. A comment can be removed by its author or by rian; it leaves the page and stays in the record. "Edited" now appears only on a comment that was actually rewritten: it used to appear on every comment the account link-up had touched, Adam's fresh ones included.
- An airport page's **Opening hours** now come from a table rather than a typed constant: collected from the airport operator's own site where its robots allow it (Heathrow, Dublin and Toronto Pearson read on the first run), or entered by hand where they do not, and the line carries the date it was collected or entered. The placeholder view names which it was and who entered it; a shopper and the crawler see the hours and the date and nothing more. `GET /api/airports/{iata}/hours` answers the same.

- **Featured at this airport**: one row of cards per family (spirits; wine, champagne and beer; perfume; skincare and makeup) with the best value at that airport, the bottles it is the cheapest of our airports for, picked by the home page's own rules. A family with nothing to compare draws no row. The crawler's copy of the page carries the same rows.

- The airport page's full list has the storefront's own chips: **Comparable**, **Award winners** and **Travel exclusives**, each a link with its own address (`?awarded_only=true` and the like, the names `/api/products` takes), kept across the category chips, the sort and the pages. Every filtered address names the clean airport page as its canonical, so search engines index one page per airport. `GET /api/airports/{iata}` takes the same three parameters.

- **Compare from here** on every airport page opens My savings on that airport (`/savings?from=LHR`), beside the airports you saved. The link never changes your saved set: the airport leads the comparison for that visit only.

- **Category at airport pages**: whisky at Heathrow, gin at Zurich, at `/airports/<airport>/<category>`, built only where the pairing holds enough published products to be a real list (the bar is read at request time, so a category switches on as its coverage crosses it, beauty included). Each carries the standing line, the airport's other category pages, the exclusives in that category there, and the full list with the shopping-feature chips, sort and pages; the airport page links to the ones it has. In the sitemap; a pair under the bar is a real 404. The site-wide category address is a question for rian on the running list.
- Two shops' spellings of one bottle now join by rule where every one of the four facts agrees: the house, the line, the variation and the size. "1 Million EDT 100ml" and "1 Million Eau de Toilette 100 ml" become one product with both shops' prices. Where a shop names no variation, the bottle waits for a person to confirm it, and a gift set or a refill never joins the bottle it is sold beside.

- `/collectors` gained a **Merge** view, the merge session: the queue by level and score, each pair side by side with its listings, airports, prices, barcodes and images, and confirm, reject and skip on the keyboard. A confirm applies the merge or the alias and takes the preferred name; a reject is final; both are recorded against the person who decided. Progress shows what was decided today and what remains.

- The merge queue now suggests at three levels, brand, line and product, each pair with a score and one plain sentence saying why (a known rebrand, one name within another with lines in common, a shop that named no variation beside one that did). A pair a person rejects never comes back.

- Every product now belongs to a **line**, the real product above the priced one (Rabanne's 1 Million over its Eau de Toilette, Parfum and Elixir at every size), and every beauty product carries its **variation**, read from the name and kept in one vocabulary a person can correct. Nothing on the storefront changes yet; the line page and the merge session build on this.

- `/collectors` gained a **Listings** view: one row per listing, with the tile as the shop showed it (brand, name, size, price, from the newest fragment the collector kept) beside what we made of it (house, line, variation, size, product, our latest price), and a filter for the rows where the two disagree in brand, name or size. The surface every merge decision is checked against.

## 0.46.0 — 2026-09-11 — the airport page in one screen: facts up top, terminals as rows, three shelves as tabs

- The top of an airport page is now one compact panel: the summary of the duty free there, the standing facts beside it (operator, terminals, opening hours, the map, which shops we read, when we last checked), and the four counts. The stat cards, the shops sentence and the guide's own heading are folded into it.

- Where to shop is a row per terminal, showing its airlines and the first line of what is there, opening in place when you want the rest. Four terminals of prose no longer push the prices off the screen.

- The products are one shelf at a time instead of four grids stacked: **Best value here**, **Travel exclusives** and **All products** are tabs, and the shelf is in the address (`?tab=`), so any of the three can be linked or bookmarked. The category chips and the sort belong to the full list, where they apply. A filter, a sort or a page number selects the full list on its own. A shelf with nothing on it is not offered. The crawler's copy of the page draws the same shelf for the same address.

- The placeholder view is one panel of rows now — what is missing, who fills it, what is needed — instead of a card in every empty place, which made the page longer than the page.

## 0.45.0 — 2026-09-11 — Heathrow's duty free guide, an airport template that shows its own gaps

- An airport page can now carry the written half the structure proposal asks for: where the duty free is, terminal by terminal, the specialty and luxury shops around it, services like reserve and collect, a link to the airport's own map, and a dated line saying when a person last checked the locations. Heathrow is written from Adam's guide: World Duty Free in all four terminals, T5's Harrods and Prada, T3's second store inside the Flight Connection Centre, T2's two gate locations, T4's prestige beauty, and Reserve & Collect. The crawler's copy of the page draws the same block from the same words.

- New demo setting, **Placeholder view** (on by default): on any airport page, every part of the template nobody has filled draws a card where it will go, saying what it is, what we need, and who fills it — a person writes it, we build it, the collectors fill it, or it waits until after launch. Fourteen parts are checked, from the shops we read to the category-at-airport pages and the comparison tool. The airports list marks which airports have a guide written. Turn the setting off and the page is exactly what a shopper sees: nothing invented, and the server's copy never draws a placeholder at all.

- The home page's "Tracking N products across N shops" line is gone.

- The airports page is the airports we price and nothing else. The notes for us — which shops are catalogues rather than terminals, which airports we cannot read and why — are out; they live in `/sources` and the running list.

## 0.44.0 — 2026-09-11 — a footer ready for launch, pages that open at the top, loading outlines instead of jumps

- Links now open their page at the top. The site used to keep the window where it was, so a link near the bottom of one page (the footer, a settings sample link) opened the next page far down, sometimes on a bare footer while the page loaded. Back and forward still return to where you were. A second cause of a jump is fixed too: after following a "See it on a sample page" link, changing airports on the home page scrolled back to the sponsor tile.

- The footer is rebuilt for launch: the logo sits straight on the navy (no white box), then the promise and the social links; Shop (all products, drinks, beauty, travel exclusives, award winners, my savings); Airports (the six with the most products, named from the data, and "All 19 airports"); Read (articles, our price data). Under it, the copyright and the two sentences a price comparison owes its readers: prices are public shelf prices, checked and dated, and we are not affiliated with the retailers or brands. The "Coming soon" and "Behind the scenes" columns are gone.

- Pages that load show an outline of what is coming, in the real layout, instead of a line of text that the page then pushes down: product cards on the catalogue, its departments and shelves, Exclusives and Awards; rows on My savings; cards on the airports list; the hero, stat cards and a row of cards on an airport or brand page; the article list and an article. A change of filter keeps the last answer under the same veil the home page uses.

- The department menu loads its lists (airports and their flags, brands, shelves, articles) in the background once the page has settled, so the first hover opens a full panel rather than "Loading the airports...".

- The newsletter form: Tab from the name goes to the last name when there was no space in it, and straight to the email when there was; the home airport and interests appear as soon as the email box is chosen, not on the first letter.

- The home page's "What's coming" section is gone now that the articles section is real. In `/settings`, the "Editorial sample articles" switch is gone too, and each unbuilt feature's switch now says what it still controls (its links in the navigation and the Articles menu, and its teaser band).

## 0.43.0 — 2026-09-11 — airports set in two places, a newsletter form that grows, awards higher on the home page

- Airports are chosen in two places only: the picker on the home page and the dropdown under "Set your airports" in the header. Every other "Set my airports" button (the home page's savings heading and closing band, the Your airports card in the menu, the product page, the savings page with fewer than two airports, the airports page) opens that same dropdown where the shopper is, instead of sending them to another page; the home picker's "Browse all airports" tile opens it too. The savings page no longer carries its own picker or suggested pairings, and the airports page's "Add to my trip" buttons are gone (a card still says when its airport is on the trip).

- The newsletter form starts with two fields, a name and an email. Leaving the name splits it into first and last name (everything after the first space becomes the last name), and starting the email brings in the home airport and the interests. The same form in the footer and after articles works the same way.

- On the home page, the award-winning bottles now come before the articles (the billboard still follows them), and each winner wears its medal, the competition's own artwork, on its photo. The "By category" tiles are gone.

## 0.42.0 — 2026-09-11 — a sticky skyscraper beside articles, native tiles on the product and airport pages, rectangles on Exclusives and Awards

- An article's sponsor placement is now a sticky skyscraper (300 by 600) in its own column to the right of the text, staying in view as the reader scrolls. When it is off, the text is centred, as before; on screens too narrow for two columns it is left out. It replaces the magazine rectangle after the text.

- The product page's tile among the related bottles and the airport page's shelf tile are now native, like the one on the home page's savings: drawn as a product card, labelled Sponsored, and taking one card's place. The product page shows seven related bottles and the tile (eight without it). The airport shelf shows 23 bottles and the tile per page (24 without it), and its pages stay contiguous, so no bottle falls between two pages.

- Travel exclusives and Award winners each have a new placement: a 300 by 250 rectangle at the top right, beside the page's introduction (under it on a phone). When it is off, the introduction runs alone.

- `/settings` lists the new placements with their "See it on a sample page" links.

## 0.41.0 — 2026-09-11 — the articles section, a first article, and every sponsor placement one click from settings

- Articles look like a publication now, taking their cues from the Professor sites (the Olive Oil Professor's blog) in Duty Free Professor's own colours: section labels between rules ("— From the Professor —"), serif headlines, a small-capital "date · author" line on every piece. The articles page leads with the newest piece large, picture beside the words, then a grid. An article opens on a centred head (its section, headline, standfirst, "11 Sep 2026 · Duty Free Professor · 3 min read"), the picture at full width, then an unhurried serif reading column with a dropped first letter, spaced-capital subheads and centred pull quotes; after it, a short note on who publishes the site, the sponsor placements, and more to read.

- A piece with no author account takes the house byline, "Duty Free Professor"; one with an author names them. Cards and the article API now carry the author's name and a reading time. A piece with no picture gets a drawn stand-in on its card rather than an empty frame.

- A first article, "How to tell whether a duty-free price is really a saving", a buying guide under the house byline with its own drawn picture (a departures board of the checks), published on staging for Adam to read and keep, change or replace. Its source is `import/articles/is-it-really-a-saving.md`; editorial pictures ship under `/media/`.

- On `/settings`, every sponsor placement has a "See it on a sample page" link that switches it on in this browser and opens a real page of its kind scrolled to it: the home page, the airport with the most products, the top featured product, the newest article. The note above them describes the native tile as well as the banner boxes.

## 0.40.0 — 2026-09-11 — a quick savings page, a native sponsor tile, the billboard lower down

- "All my savings" answers in tens of milliseconds: a two-airport route took 0.6 to 2.3 seconds on staging (Madrid and Barcelona the slowest), because every price observation in the catalogue was ranked to find each shop's latest, and the database re-ran the route's "stocked at two or more stops" list once for every price row. Now that list is fetched once and only those bottles at the route's shops are ranked: 1 to 80 ms across ten routes, the same results in every sort and filter tried. Equal prices and equal savings now always fall in the same order.

- The savings on the home page fill the grid: with the sponsor tile off, two full rows of eight cards under the headline (it showed seven and an empty place).

- The sponsor tile on the savings shelf is now a native placement, drawn like a product card and labelled Sponsored: a square image in the card's photo box, the brand name, a headline, a description and a call to action. These are the assets of a responsive display ad (Google Ads' responsive display ads, IAB native), so an advertiser's existing files and words fit: a square image 1200 by 1200 (at least 300 by 300), a headline of up to 30 characters, a description of up to 90, a brand name of up to 25. Switched on from `/settings` without creative, it shows a sample ad at those limits with the image box naming its spec.

- The home billboard moved below the award-winning bottles, and when it is off its empty band no longer leaves a gap.

## 0.39.0 — 2026-09-11 — the trip savings hold still: placeholders, a refresh veil, and a quicker answer

- The home page's savings section no longer disappears while it loads. On a first visit it draws stand-ins of the cards' own shape (the headline and seven cards, shimmering) that are exactly the size of what replaces them. When your airports change, the last savings stay in place, dimmed under a shimmer, with "Finding the savings at ATH, BCN" until the new ones land. Measured with the browser's layout-shift score: 0.05 before, 0.0005 after.

- Choosing a single airport no longer empties the section: the savings stay site-wide until a second airport makes a route (the header menu's featured card follows the same rule).

- Product cards keep one height whatever they hold, everywhere they appear: the name takes two lines (longer names end in an ellipsis), the stocked line one, the shop bars four rows. A grid no longer changes size when its cards change.

- The answer comes faster: pricing the featured cards ranked every price observation in the catalogue to find each listing's latest, about 450 of the 600 ms the request took on staging, and up to six seconds for some airport sets; it now ranks only those products' observations, 130 to 220 ms, with identical results. Two shops at the same price now always list in the same order.

- The headline saving strongly prefers an award winner with a photo: one takes the big card whenever no more than one other featured card saves more than twice its dollars (a single luxury bottle's gap may tower over it; two may not). Tuned on staging's savings for 438 airport sets: award winners with photos now headline 11% of them, up from 5%. The headline also shows its saving as a percentage, "Save $43.47 (37%)".

- The line of airport codes under "The savings on your trip" ("ATH · EZE · BCN · BOG - shop by shop, cheapest first") is gone, and so is the sentence under "The biggest savings right now", so the heading is the same height either way.

## 0.38.0 — 2026-09-11 — the search answers as you type

- The header search suggests as you type, from the second letter: airports (flag, city, airport name, code), brands with a page (and how many products each has), and products (a picture or the monogram, the category, how many shops stock it), with the letters you typed marked, then "See all results". Airports and brands answer on every key; products arrive once typing pauses, in about a third of a second.

- The arrow keys move through the suggestions and Enter opens the one chosen; Enter with none chosen searches as before (a code or a city typed whole opens that airport). Escape closes the list, and a second Escape clears the box. A click anywhere else closes it.

- A new read, `GET /api/search/suggest?q=`, matches products exactly as the products page does (the words in the name or the brand, accents ignored, visible shops only) and ranks them for typing: names that begin with the words, then brands that do, then the most widely stocked. It carries no prices, which is what makes it fast: the products page's query spends about half a second per search pricing the whole catalogue. It is a storefront read like `/api/products`, members-only until the site opens.

## 0.37.0 — 2026-09-11 — the header's second pass: Articles, the profile circle, a compact airport picker

- The reading pages have their own department: Articles, after Beauty, opens a panel with Reviews & tasting notes, Buying guides and News (each with its line of what it is for), "All articles", and the three newest articles once any are published. Reviews, Buying guides and News leave the row; Price tracker, Exclusives and Awards stay.

- The Drinks and Beauty panels say "Popular brands", not "Popular houses".

- The search box reads "Search product, brand, or airport", and means it: an airport code or a city typed whole ("LHR", "Paris") opens that airport's page, and a search that names airports ("spain", "heathrow") lists them above the products it found.

- The top row ends with the airports button and a profile circle: the account's initials (a picture later), opening onto Account, Admin and View as for those who may, then Build Discussion (the review page) with the discussion pages this account may open beneath it (the proposal, the site structure, the to-do list, the demo settings, the data sources, the build plan), then Sign out. Signed out, the circle leads to sign in. My savings moved to the end of the nav row. The footer's "Behind the scenes" column lists the same pages from the same list.

- On a phone the airports button shows the plane alone, so the logo, the button, the profile circle and the menu share one line.

- The home page's "Where are you flying?" card runs the full width and holds exactly two rows of airports, however wide the screen, the last tile leading to every airport; the hint and the buttons share its last line.

- The home page's closing band now meets the footer, with no strip of page between them.

## 0.36.0 — 2026-09-11 — the header leads with departments: Airports, Drinks, Beauty

- The nav row now starts with three departments, as in Adam's prototype: Airports, Drinks and Beauty. Each is a link to its own page and opens its own panel on hover, or from the small arrow beside it (click or keyboard; Escape closes it). Products and "Airports we price" leave the row; Airports takes their place. The rest of the row (Reviews to Awards) is unchanged.

- The Drinks and Beauty panels have four columns: the department's shelves with our own drawn glass and bottle marks and their counts, its ten leading houses (a whisky house no longer sits among fragrance houses), the Professor's picks (medal winners for drinks, travel exclusives, bottles priced at two or more airports), and a featured saving from the same selection as the home page, dated by the latest collection. Where the department has no featured saving, the column invites the shopper to set their airports.

- The Airports panel lists every visible airport with its flag, grouped by region. The old panel showed twelve of nineteen, alphabetically, and left out Paris, the biggest shop.

- "All drinks" and "All beauty" are real pages: `/products?family=liquor` and `/products?family=beauty`, headed with the department's name, their filter chips limited to its shelves. The products API takes a `family` filter, and each brand in `/api/brands` carries the family most of its products sit in.

- On a phone, the departments lead the menu drawer as collapsible sections, ahead of the other links.

- The server-rendered pages carry the same header, so nothing moves when the page comes alive; an airport page marks Airports, as the app does.

## 0.35.0 — 2026-09-10 — the site gets its own sign-in: accounts, levels, View As, members-only mode

- The site has its own sign-in. Every visitor signs in with a username or email and a password before seeing anything (members only while we build; the storefront opens to the public later by one setting); the shared password on the dev host is retired by the deploy steps below. Three accounts at launch: the owner, Adam and Mark. Adam and Mark see the client pages (the review page, the proposal, the structure page, the to-do list, the settings) and comment, rank, toggle and complete under their own names; the plan, the data sources page and the account administration are the owner's. A comment is now the signed-in person's, never a typed name; each person edits their own comments. The owner can view the site as anyone (read-only, or acting) from the account menu, and manage people and levels on `/admin`.

- Sign-in pages: `/login` (with "Forgot password?"), `/forgot`, a welcome link to choose a first password, a reset link, and `/account` to change a password or sign out. Eight wrong passwords in a row lock an account for a quarter of an hour; ten attempts from one address in a quarter of an hour are refused for a while.

- A mail provider for invites and reset links, inert until `MAIL_PROVIDER=resend`, `RESEND_API_KEY` and `MAIL_FROM` are set (in an app-only env file, never the shared one); until then the welcome and reset links come from the CLI.

- Every route now needs to be classified before it answers: a page or an API read without a signed-in account answers with the sign-in page or a 401 while the site is members only; a write from another site is refused.

- The app's own file-serving route (the one that answers a stray `/logo.png`) now serves only files under the built site's folder. Until now a request path with `..` segments could read any file inside the container to anyone past the gate; found in the accounts plan's review on 10 Sep, fixed with a test.

- The four wine competitions' silver and bronze medals (and the Asia competition's gold) now draw as badges on cards and product pages like every other medal, from the competitions' own artwork; until now they fell back to a text tag.

- The home page's airport picker no longer moves the card below it when the airports arrive: the collapsed list's rows are drawn at their final height while loading. Measured on a phone profile: layout shift 0.017 to 0.001.

- Medal matching is stricter about variants: a competition entry no longer lands on a bottling it does not name (a plain entry never sits on the Rye Cask Finish, the Cask Strength or the Peated), and entries written with a typographic apostrophe now match their brand. After deploy: `python -m app.cli awards --rebuild` (review sample in `.logs/verification/awards-matching-review-2026-09-10.md`).

- **Before this deploy** (`main/docs/RUNBOOK.md`, Accounts): nothing collecting; `docker exec dutyfreeprofessor-db pg_dump -U dfp -Fc dfp > backups/dfp-$(date +%F)-accounts.dump`; `ACCOUNT_OWNER=rian` and `SITE_ACCESS=members` into `.app.env`, the two owner sign-in lines from the earlier note deleted (they are ignored), the file tightened with the `setfacl` recipe. **After it, in this order, inside the container:** `backfill accounts`, `backfill levels`, `accounts create --username adam ...` and `mark` on level `admin`, `accounts set-password --username rian`, `accounts invite-link --username adam` and `mark` (printed once; send them yourself), `backfill authors --map Adam=adam --map Mark=mark --map rian=rian`, then `awards --rebuild`. Then the curl list and the browser round trip from the runbook, `srv-gw security-audit`, and, owner only, `srv-gw id-gate --site dutyfreeprofessor.demoing.info --mode public`. Migration #4 rides (`b5c6d7e8f9a0`): the account tables, the overrides table and seven who-columns; the container applies it on start.

## 0.34.0 — 2026-09-10 — the structure review lands: airport addresses by name, Perfume, the brand floor, the price of record

- Product page titles and medal lines no longer carry a dash: "Santa Teresa 1796: duty-free prices at 3 shops" and "Double Gold, NYISC 2024", as the house style reads everywhere else.

- The structure page has been rebuilt around the review: each of the five questions carries what was settled, two new sections list what the review decided and what is still open, the airport page is specified as the duty free situation only (shops by terminal, specialty shops, perhaps a map), the brand page gains Mark's templated fields, the family reads Perfume throughout, the addresses show the reviewed shapes, and the page rows that shipped on 5 Sep read live. The to-do ask for airport write-ups is reworded the same way (applied to the live list; the row keeps its comment). The feature board's airport card matches.

- The shop's own price in its own currency is the price of record: it leads each row of a product's price table in bold, and the US dollar figure beside it is labelled as our conversion at the exchange rate held on the date checked. The product's structured data no longer states a US dollar price range; each shop's offer is in the shop's currency, and a range appears only when every shop quotes the same currency.

- A brand page now needs three or more published products priced, between them, at two or more airports, the floor the review settled; a brand under it has no page, no sitemap entry and no link from its products, and the brands index, the page and the sitemap all apply the one rule.

- The category is Perfume, not Fragrance, wherever a shopper reads it: navigation, filters, breadcrumbs and category links; cologne and eau de toilette stay beneath it. After deploy: `backfill perfume` (renames the stored category; safe to repeat).

- Airport addresses lead with the airport's common name, then its code, then its city, as the structure review asked: `/airports/heathrow-lhr-london` where it was `/airports/lhr-london`; JFK is the one airport known by its code, `/airports/jfk-new-york`. Every earlier address, the bare code included, redirects to the new one and keeps its filters. The names are a declared table (twenty-one airports), so an airport nobody has named yet keeps its earlier address; the API now sends every airport link ready-made so no page rebuilds one.

- The structure proposal has been reviewed: Mark's points and Adam's replies now sit on the page beside the part of the structure each one is about, with rian's answers under them. Settled by that round: the family is renamed Perfume, a brand needs three products across two or more airports before it gets a page, launch publishes the comparable set with the rest opening as price history accrues, and the shop's own price in its own currency is the price of record.

- Comments can be edited in place wherever a comment thread appears (the structure page, the quote, the feature board, the to-do list, the running list). The wording changes; the author never does, and an edited comment is marked as edited, so nobody's words can be quietly reattributed. No migration: the table already carried the timestamp this uses.

## 0.33.0 — 2026-09-05 — the heavy build lands: fresh prices, brands and merges, airport and brand pages, articles and email capture, the running list

- A shop that verify has blocked (an uncleared size, identity, currency or parse failure) now disappears from the public site until a human clears the check: its prices leave every card and price table, its airport page and its products' sitemap entries go with them, and the counts on the home page and the data page shrink to match; a moved price or a retailer's refusal changes nothing, those rows keep their date. The same rule now covers the trip comparison (`/api/trip`, the stops offered, the suggested routes) and the coverage list, which had kept showing such a shop and linking to its airport page.
- Article pages are served: `/articles` lists the published pieces and `/articles/<slug>` serves one with its content in the HTML like a product page (Article and breadcrumb structured data, a real 404 for a draft or unknown slug), published articles join the sitemap and the RSS feed, and an airport's published write-up appears on its airport page. After deploy: import Adam's hand-ins and publish them (`main/docs/RUNBOOK.md`, Content).
- The medal in a card's corner and the medals listed on a product page now follow the written strategy: each competition's latest result, the highest level first, ties to the newer year, then the competition nearest the traveller's chosen airports; a bottle that won Double Gold in 2024 and Silver in 2025 shows the Silver. `GET /api/products/{id}` accepts `at=` so the order can follow a traveller's airports.
- The structure page marks the airport and brand pages as built and deploying this weekend, and the server-rendered header matches the new browse panel's closed state.
- The header has a browse panel: beside Products, a small toggle (or resting the mouse on it) opens the shelves by category with their product counts, the twelve biggest brands with a page, and the airports we price, every row a link to a real page. It is keyboard-usable (Enter opens, Tab moves through, Escape closes), closes on a click outside or on navigation, opens nothing over the page's layout, and on a phone the same three lists sit as collapsible sections at the foot of the menu drawer, which now scrolls. The brands column appears by itself once the brands table is live; categories will group by family (drinks, beauty) once the API says which is which.
- The home page leads with the headline saving: the featured list's top pick printed large (the bottle, the saving in dollars, the cheapest shop and price, the dearest price, how many shops were compared) above the rest of the featured cards; the travel exclusives moved up to directly under the savings, on their own band, with the live count in the heading and an Exclusive mark on each bottle. On a product page the saving is printed as large as the price. Gold labels (categories, brand lines, kinds) use the darker gold everywhere they are text, so every small label passes WCAG AA.
- Instagram and YouTube links in the header's nav row and under the footer tagline, shown only once the site is given the profile URLs (they arrive from the server's environment, the same place the Organization markup will state them), so no empty icon ships before Adam and Mark hand them over.
- The sample editorial block is gone from the home page; the latest articles appear there by themselves once the first one is published. The newsletter is a real form: the full one on the home page's newsletter band, a compact one at the top of the footer on every other page; both post to `/api/subscribers`.
- The product page's brand name now links to the brand's own page when it has one (and to a catalogue search when the house is too thin for a page), in the served HTML and in the app alike; the browse panel's categories column is grouped by family (Drinks, Beauty, Confectionery, Tobacco) now that `/api/stats` says which family each category belongs to; and a traveller with saved airports sees the medals on a product page ordered for those airports, the page keeping its content while that order loads.

- Brand pages: `/brands/<slug>` for every brand with two or more products priced at the airports shown (the slug is the brands table's, so "Moët & Chandon" and "MOET CHANDON" are one page): the products with prices across airports, which airports stock the brand and how often each is the cheapest, category filters for houses with several, sort and paging. Server-rendered with CollectionPage, ItemList and Brand structured data, breadcrumbs and sitemap entries; an alias slug redirects to the house; a thin or unknown brand is a 404. `GET /api/brands` and `GET /api/brands/{slug}` back it. The product API now carries the brand's slug (`brand_slug`) on every card and product, and only for a brand that has a page, so a brand name links to its page when there is one and to a catalogue search when there is not; the product page's link switches over next.
- Articles are stored, not built: migration #5 (`a4b5c6d7e8f9`, schema only) adds `articles` (three kinds: article, airport write-up, category intro; draft or published) and `subscribers`. `python -m app.cli articles import <file | --upload id | --todo id>` takes Adam's Markdown, text or Word hand-ins from `/todo` straight into drafts (Word via `mammoth`; embedded images are counted and left out for the shared Drive), idempotent on slug, `articles publish <slug>` makes a piece public; `GET /api/articles`, `/api/articles/{slug}`, `/api/articles/airport/{iata}` and `/api/articles/category/{name}` serve published text only, with the body rendered to safe HTML by one renderer. Article pages (`/articles`, `/articles/<slug>`), the latest-articles block and the airport/category text blocks exist as components and switch on when Stream B wires the page route and Stream C places them. After deploy: `alembic upgrade head`; no backfill; import Adam's files with the commands in `main/docs/RUNBOOK.md` (Content).
- Email capture: `POST /api/subscribers` stores first name, last name, email, home airport and interests with the consent sentence and its timestamp (no IP, no user agent, the address never logged), answers the same for a new and an already-listed address, ignores bot submissions that fill the hidden field, and is rate-limited per visitor and overall. The list leaves the database only through `python -m app.cli subscribers export` in the container (owner only); there is no read route. The form component (`SubscribeForm`) is ready for the home page and footer.
- Sponsor slots: a `SponsorSlot` component for the IAB sizes (728x90, 970x250, 970x90, 300x250, 336x280, 300x600, 160x600, 320x50, 320x100) that reserves its box at the size's aspect ratio so nothing shifts, shows Adam's creative from `public/sponsors/` (versioned file names, registered in `web/src/lib/sponsors.ts`) when there is one and nothing when there is not, and can only ever show a static image; positions follow Stream C's brand pass.
- Medals: the rules for which medal a bottle shows are now written down (the one-page strategy on the review page, decision card 5, and in `award_picker.py`): each competition's latest result stays on the page and in the structured data, the highest level leads, ties go to the newer year, then the competition nearest the traveller's chosen airports (JFK: New York), then the judges' score; a pin by competition, year and medal is designed in and waits for the `overrides` table. `python -m app.cli awards --rebuild` now reconciles by that natural key instead of deleting and reinserting, so award ids no longer change on a rebuild. The matcher is stricter: every brand word must be on the bottle and category words in a brand name no longer count, which removes other producers' medals from Charles Heidsieck, Beefeater, Johnnie Walker, Jacob's Creek and Wente rows, and two different entries landing on one bottle in one competition-year now store nothing. After deploy: `python -m app.cli awards --rebuild` (review sample in `.logs/verification/awards-matching-review-2026-09-05.md`). The card corner and product page pick up the new rules once `catalog_queries` calls the picker (issue for B).
- Brands are a table and duplicates are folded: migration #3 (`f3a4b5c6d7e8`, schema only) adds `brands` (one row per house, whatever the shops call it), `products.brand_id`, the size as the shop states it (`size_value`/`size_unit`), a per-vertical attribute (a fragrance's concentration) that keeps an EDP and an EDT apart without ever splitting a product a feed says nothing about, `merged_into_id`, `product_merges`, `merge_candidates` and `reverifications`. The match key now folds the brand, so "Moët & Chandon", "MOET ET CHANDON" and "Don Julio®" lead to one product; a merged product's old address answers under its survivor (301). After deploy, in this order: `backfill implausible_sizes`, `backfill brands`, `backfill sizes`, `backfill categories`, `rederive`, `backfill merges` (on the 5 Sep dump: 2,215 brand rows, 1,032 products categorised, 40 duplicate groups folded, the rest queued for the Mon 21 review).
- Beauty widening: every Avolta store walks its drinks and beauty trees separately (no more alphabetical cap that once dropped whisky at Heathrow); beauty is read one page deep and a beauty family's product page is fetched only for the forty targeted lines (`import/beauty-targets.json`, from `.logs/runs/beauty-candidates-2026-09-05.md`); the four stores whose robots forbids paging re-read the product URLs already held so page-2 bottles keep their history; Dublin walks its beauty tree for the targeted lines; the Shopify shops now read their Spanish shelves (Licores, Vinos, Rones...) and their beauty shelves in full. A size no single item comes in (a mist declared at 7,624 ml by its retailer) is now refused at collection and cleared from rows already stored.
- The crawl surface: `robots.txt` now states the site's Content Signals (search yes, AI answers yes, AI training no) and addresses the training crawlers by name; `/feed.xml` is an RSS feed of products newly added to the catalogue; `/llms.txt` briefs AI assistants on what the site is and where its pages and machine-readable surfaces are, with live counts; `/data` is a public description of the dataset (what is collected, how, what a row holds, coverage, contact) with schema.org Dataset markup; and `python -m app.cli_pages indexnow` tells the IndexNow engines which pages changed after a collection. After deploy: mint an `INDEXNOW_KEY` in `.app.env` (the key file is served at `/<key>.txt`).
- Airport pages: `/airports/<iata>-<city>` (for example `/airports/lhr-london`) for every airport shown on the site, with the counts (products priced, sold at other airports too, cheapest here, travel exclusives), the products where that airport is the cheapest of ours, its categories as filters, its travel exclusives and the full product list with sort and paging. The page arrives with its content in the HTML like a product page, carries CollectionPage, ItemList and Airport structured data, breadcrumbs and a sitemap entry, and is linked from the airports index and from every airport row in a product's price table. A bare code or stale words redirect; a hidden airport is a 404. `GET /api/airports` and `GET /api/airports/{iata}` back it.
- The reader now links to its own identity page, `https://bot.dutyfreeprofessor.com` (a separate static site), in every request; the page states the exact user-agent string, the pacing and robots behaviour, how to block or slow the reader, and the contact address.
- The Issues and to-dos tab now ranks by what the plan is waiting on: each item names the plan tasks it gates and a weight, and urgency is computed from the live task statuses on every request (in progress or due within days → needed now; gated work already done → went ahead under the assumption; otherwise before its task, or later). A "Before you go further" list at the top holds only what rian must settle now; informational items fold away under "Later".
- The build-plan page gained a second tab, Issues and to-dos: what only rian can decide, what he must do or check, and the issues to address, each with a comment thread and decide / done / dismiss / reopen actions. Items come from `import/items.json`, which sessions maintain through `main/scripts/items.py`; rian's actions are stored in the new `owner_item_states` table (migration `e2f3a4b5c6d7`, schema only, no backfill) and merged on every request. The old `/issues` page now redirects there; the two markdown registers are generated from the list.
- Seoul (The Shilla Duty Free) collector on the rendered fetch: server-rendered product pages read as a browser receives them, the discount price in USD with the list price crossed out, the barcode from the page's reference number, stock from what the page actually shows. Discovery walks the home page and each product's related-product rail under a per-run render budget, because the store's category grids are drawn by scripts its robots.txt forbids us to fetch. After deploy: `python -m app.cli collect --source shilla-icn` inside an announced window.
- Singapore (iShopChangi) collector on the rendered fetch: the targeted set only (drinks stocked at two or more of the collected shops plus the beauty brands shared by Paris and Athens), the duty-free departure price in SGD, one row per size, category from the page's own breadcrumb. The source publishes no barcodes, so its rows match other shops on brand, name and size. After deploy: `python -m app.cli collect --source ishopchangi-sin` inside an announced window.
- `python -m app.cli verify`: re-reads a sample of published listings from the retailer through the same collector and records a verdict per listing (pass, price moved, gone, size or identity or currency mismatch, parse failure, refused, review). One host at a time, robots re-read, crawl delay honoured, a shop abandoned at its first refusal. A correctness failure blocks that shop's publication until a human clears it with `verify-clear`; `verify-status` shows the rolling pass rate and the only date the public site may show per shop ("Spot-checked"). Plain-language explanation in `main/docs/QUALITY.md`.
- Migration #2 (schema only): `verification_runs`, `verification_checks`, `audit_snapshots`, `rejected_observations`, and `collection_runs.skip_counts` (the per-reason split of skipped listings). From this deploy every listing a collector or ingest refuses is stored with its reason and payload instead of only logged, and the audit's snapshots go to the database. After deploy: `alembic upgrade head`; no backfill.
- The rendered fetch: a browser sidecar (Playwright's Chromium in its own isolated container, no secrets, no database access, every request outside the retailer's own hosts refused) for the sources that draw their prices with JavaScript, behind the same rules as every other read: our own bot identity, robots.txt re-read each run, a ten-second floor between renders, a per-run render budget, and a challenge page treated as a refusal. After deploy: the compose file gains the `browser` service and a second network; `srv-gw deploy --build` builds it.
- `python -m app.cli audit`: a no-network data audit, nightly. Sixteen measures of the shapes a misread leaves behind (a location whose currency disagrees with its prices, a shop where every row is in stock, a size no bottle comes in, a spread no third shop corroborates), each against a threshold that starts at the value measured on 2026-09-05 and lives beside it in the code, plus the lists the dated human review starts from (brand spellings the fold has not joined, the merge candidates the rules would not settle alone, the largest spreads and discounts, sample rows with retailer links). Snapshots go to `.logs/verification/` and, after migration #2, to `audit_snapshots`. A merged product counts in no measure; duplicates are read by the merge rules themselves (what `backfill merges` would fold, so zero after it); three thresholds are re-pinned to the post-migration numbers (duplicate groups 0, brand folds 0, unexplained large formats 2), and the Mon 21 checklist gains a section asking, pair by pair, whether a merge candidate is one product or two.
- The build-plan page (`/plan`) now opens with the sixteen promises of the accepted quote, each one's state derived from the tasks that deliver it (delivered only when every task is done), then the list of things only rian can do in date order, then a day-by-day roadmap to Cannes with delivery and Cannes marked. Every task carries a target date; the production-environment steps (egress test, sizing, DigitalOcean provisioning, first deploy, domain and Cloudflare, go-live checklist) are now dated tasks of their own.
- The structure proposal page (`/structure`) now frames its figures as the sixteen airports collected today with nineteen at launch, reads the published-pages count live instead of stating one, reflects the structured data that actually ships, and carries re-measured beauty counts. One sentence is marked for rian to complete before it goes to Mark.
- Structured data corrected for the validators that matter: every URL in it is absolute and built from `PUBLIC_BASE_URL`, barcodes are typed by width (gtin8/12/13/14), availability is stated only when a stock flag was actually observed, each shop's price appears as its own offer in its own currency, the home page declares the Organization, and the retired SearchAction is gone. Unknown products and mistyped addresses now return a real 404, HEAD requests work, pages carry ETag and Last-Modified (so crawlers get 304s), and HTML and JSON are marked no-store so a CDN can never show an owner's change late. After deploy: set `PUBLIC_BASE_URL` in `.app.env`.
- Audit defect list fixed: a barcode/size contradiction is now refused rather than creating a wrong-size product; each price carries the exact minute it was read and the FX rate it was converted with; Dubai stock is unknown unless the API states it; a location's currency follows its collector; runs whose process died are marked as errors; image attribution says barcode or name match, and a name match must agree on age statements. After deploy: `backfill stuck_runs`, `backfill locations`, `backfill orphan_tiles`, `backfill image_provenance`.
- Product pages now arrive with their content in the HTML: the name, brand, size, medals, the verdict and the full price table are rendered on the server with the same markup the app draws, so search engines, AI assistants and anyone with JavaScript off read the page. The app then takes over in place (no skeleton, one fewer request; layout shift measured at 0). Brand and category on a product page are now links. Text responses are gzip-compressed by the app itself.
- Every collected listing now keeps the fragment it was parsed from (`raw_records`, facts only: no descriptions or imagery) and names its category family (`products.vertical`), so a rule change can be re-run without a re-fetch. After deploy: `python -m app.cli backfill vertical`.
- Migration #1 (schema only): `accounts`, `raw_records`, `price_observations.source_kind`, `sources.identity_mode` + `permission_record`; a newly collected airport now starts hidden. After deploy: `alembic upgrade head`, then `python -m app.cli backfill accounts`.
- Every collector now reads its host's robots.txt through one shared policy (`robots.py`): a group addressed to DutyFreeProfessorBot by name is honoured, wildcard rules apply, the host's crawl delay is respected, and a host that refuses the robots read itself is treated as a refusal. Four query-blocked Avolta stores become page-1-only; the four Shopify shops and Dubai currently refuse our identity and show their last-seen dates.

## 0.32.0 — 2026-09-03 — Paris (Extime) collector

- New collector `extime-paris` for CDG/Orly: sitemap-sharded discovery, RSC
  payload parsing for size and GTIN, duty-free pricing, per-variant emission.
- Product families deduped: 12,430 catalogue URLs are 6,637 real products.
  Halves both the recorded observations (which were doubling) and the crawl.
- Shared fetcher now requests gzip: 659KB -> 89KB per page, all collectors.

## 0.31.0 - 2026-09-03

- New /structure page: the site structure proposal opened for comment, with
  the four decisions at the top, the measured catalogue evidence behind them,
  and a comment thread on every section. Same thread mechanism as the feature
  board and the quote, so replies all land in one place. Noindexed. Its footer link
  appears only with curator mode on, so it stays out of the client's way until
  it is ready to be shown.

## 0.30.0 - 2026-09-03

- Brand alignment, first pass. The Duty Free Professor logo replaces the
  wordmark in the header and footer. The palette moves to the navy taken from
  the logo (#003F6C), with every contrast pair re-checked. The display face
  becomes Libre Baskerville, the open Baskerville revival, chosen to sit with
  the baskerville-urw used across the other Professor sites; Fraunces is gone
  and its files removed. Inter stays for body text.

## 0.29.3 - 2026-09-02

- Quote page: timeline starts Thursday, the design milestone moves to Monday
  as the brand alignment pass plus the structure proposal, and the 11th now
  reads as brand alignment feedback incorporated. Logo and font requests
  dropped from what is needed, since those are in hand. The rate note now
  explains the reduction as a fit with this phase, and says plainly that later
  rounds are likely to sit closer to the standard rate.

## 0.29.2 - 2026-09-02

- Quote page: shorter intro naming the four groupings; the out-of-reach
  features moved directly under the price box as an expandable list in the
  same card format as the costed lines, without prices; and the whole page
  now says soft launch rather than launch.

## 0.29.1 - 2026-09-02

- Quote page revised: presets renamed (The Essentials, The Safe Bet, The
  Ambitious Version); brand alignment and crawlable pages became optional but
  marked Recommended, leaving three always-included lines; the airport and
  brand pages now depend on the structure proposal and follow it on and off;
  the three collectors carry a deeper reduction with the reason stated; and a
  "Not possible before the soft launch" section replaces the one-line
  exclusion note, with automated collection at its head.

## 0.29.0 - 2026-09-01

- New /quote page: the launch proposal as something the client can tailor.
  Five core lines plus eleven optional ones, each with both the standard rate
  and this project's rate, a switch, and its own comment thread. Presets set a
  starting point; the total follows every toggle; a request button freezes the
  basket and sends it. Selections and requests persist server-side
  (quote_selections, quote_requests). Noindexed, like the review page.
- The comment thread moved into a shared component, so the board and the quote
  page use the same one and the client only learns one way to reply.

## 0.28.0 - 2026-08-31

- The feature board becomes the plan of record: every card carries a scope
  chip (Launch scope / After launch / Later / In discussion) and the board
  groups by scope. All of the client's requested features joined as cards
  (crawlable content pages, airport hub pages, brand pages, official brand
  images, sponsor banners, producer enhanced listings, store partner portal,
  crowdsourced price updates, manually checked prices; traveler accounts
  absorbed the alerts card). New "In build" status for work in flight.

## 0.27.0 - 2026-08-31

- Performance pass driven by PageSpeed Insights: fonts self-hosted (kills the
  render-blocking Google Fonts chain and the third-party dependency), a
  product-page loading skeleton that mirrors the real layout (the pending-to-
  loaded swap was most of the 0.41 layout shift), the LCP product image
  preloaded from the server-injected head, immutable cache headers for hashed
  assets and fonts, and route-level code splitting (main bundle 92 to 82 KB
  gzipped; shopper path stays eager).
- Accessibility: gold text and buttons and soft ink meet WCAG AA contrast
  (new gold-ink and gold-strong tokens); footer headings no longer skip
  levels.

## 0.26.3 - 2026-08-31

- Product schema fills three of Google's recommended fields: description (the
  same factual sentence as the meta description), offers.url, and
  offers.availability driven by the site's own buyability rule. The remaining
  aggregateRating/review warning stays deliberately: we hold no reviews and
  will not fabricate them.

## 0.26.2 - 2026-08-28

- The review page's video player can be closed, and stays closed for that
  browser until reopened from the "Watch the video walkthrough" button in its
  place. The page is read many times; the video only needs watching once.

## 0.26.1 - 2026-08-27

- Collector health now follows location visibility like every other surface:
  a source is listed only where prices it collected are on display, and
  configured-but-never-run collectors appear only when nothing is hidden.
  The Heinemann coverage card no longer mentions previously gathered prices.
  Pre-descope explainer pages moved out of the served static directory.

## 0.26.0 - 2026-08-27

- Rian's review pass folded in: his rankings became the board's dashed
  suggestions, his working comments were rewritten into each card's copy in
  one consistent voice, then removed so the page is fresh for the client.
  Heinemann reworded to "a relationship to build?"; a new hosting decision
  ("Where this lives, and what it costs to run": rian hosts and runs it,
  $50/mo starting point, live discussion invited) joins the urgent three.

## 0.25.0 - 2026-08-26

- Review page slimmed to its final shape: five email-style big-picture lines,
  ONE feature board, and five decisions. The board merges "what's working"
  with the ranking: every card shows status (Live/Partial/Planned), what it
  is, how far it is today, what more time buys, four tiers (Essential for
  launch / Next priority / Later / Not interested) with rian's suggestion
  pre-marked as a dashed hint until the client confirms, and its own comment
  thread. Comments now attach to features as well as decision items (nullable
  item_id + feature_key, one-target check constraint).
- Board refined after rian's skim: the tier question is now purely launch
  scope ("does the launch need it?" - Needed for launch / Soon after / Later,
  maybe / Not interested); status badges are filled chips (Built / Part-built
  / Not built) with filter buttons; the name gate is gone (the client is the
  only ranker); decisions retitled "For your comment" with the first two
  marked urgent and the rest "No rush"; a general "Anything else" thread
  closes the page.
- The board's question now depends on status: built rows ask about effort
  (Good as is / A bit more before launch / Go heavy here / Leave it out),
  unbuilt rows ask about timing (Before launch / Soon after / Later, maybe /
  Not interested) - one tap always answers "where does the time go". Added a
  Design and branding row; "More time buys" lines rewritten to stand alone
  (SEO honestly says not to spend more there yet).
- Demo settings grew a "Sample features" panel: per-browser switches for every
  unbuilt surface (announcement bar, editorial samples, and each feature stub's
  nav/footer/coming-soon presence), with "All off - built only" and "All on -
  full vision" bulk buttons. Instant, local, layered under the server flags.

## 0.24.0 - 2026-08-25

- Client review page restructured to the client-facing pitch: big picture,
  what's working, coverage (room to grow and the limits), the code and the
  machine, bigger ideas — bullets throughout.
- New "Pick the demo" ranking: every feature (live and planned) gets
  Must-have / Nice-to-have / Later buttons; the latest choice per feature is
  stored server-side (feature_priorities table, upsert) so the ranking is
  visible without a meeting.
- Positioning pass over everything the client reads: portability acknowledged
  once with honest ramp-up friction; the environment and operation presented
  as a hosted, tended service; no cost-of-collection talk. Discussion items
  updated to match; Heinemann relationship and competitions-database questions
  added as decisions.

## 0.23.1 - 2026-08-25

- Repair: purged 165 pre-variant configurable-tile listings that the 0.22.0
  per-size re-crawl had superseded but not replaced (they kept stale ambiguous
  prices; 14 barcode-less ghosts duplicated real products, e.g. a "1942 Tequila
  70cl" beside the true Don Julio 1942 750ml row). A tile whose page could not
  be re-read keeps its old listing, honestly dated.
- The airport picker, suggested routes and trip comparison now respect location
  visibility (the trip service had been missed in 0.23.0's filter).
- The client review page rewritten for a skimming reader: short sections, a
  one-line body per feature, one flat built list. Medal artwork retouched to
  true transparency (several files carried an opaque white tile) and medal
  URLs are now versioned so CDN and browser caches pick up artwork changes.

## 0.23.0 - 2026-08-25

- Coverage is now configuration: each location has a visibility switch
  (`app.cli locations`). Hidden locations keep collecting — history accrues —
  but appear nowhere on the site: catalogue, product pages, stats, category
  counts, coverage page, sitemap and page metadata all follow the visible set.
  The demo shows the four airports from the client's brief.
- Feature switches (`FEATURE_*` in .app.env, injected into the SPA shell):
  my-airports, the featured-savings home section, in-card price bars, and the
  teaser slots each toggle without a code change. Demo config switches off the
  merchandising layer (featured cards, bars); everything stays built.
- Award medals render as a small badge overhanging the card's top-left corner;
  a missing artwork file falls back to the text tag instead of a broken image.
  (Previously the photo box's full-bleed rule stretched the medal to fill it.)
- The client review page's built list is split into "what the proof of concept
  promised" and "built beyond the promise".

## 0.22.0 - 2026-08-25

- Multi-size products are now collected as one listing per size the shop's own
  page prices, so a magnum at one store can never be compared against the 75cl
  at another. Dropped-decimal slugs ("-15l" meaning 1.5L) parse correctly.
- The home page will not headline a spread over 3x unless at least three shops
  corroborate it; product pages still show every observed price.

## 0.21.0 - 2026-08-25

- Featuring algorithm v2: blended dollar+percentage saving score, award-winner
  bonus with a guaranteed floor, photo preference as a tie-breaker, champions
  always shown.
- Award winners' cards show the competition's medal artwork in the corner; the
  duplicate Save tag over the photo is gone.

## 0.20.x - 2026-08-25

- The home page's savings section is cards only: eight comparison cards picked
  by the agreed featuring rules (dollar champions, percentage champions, award
  winners prioritised, best-of-rest fill).
- Multi-size tiles whose product page does not sell our size are now skipped
  instead of inheriting the cheapest variant's price.

## 0.19.0 - 2026-08-25

- Product cards show the price comparison inline: up to four shops with
  price-proportional bars, cheapest emphasised, and a save/buy-at footer.
  Scoped to the shopper's airports when set.

## 0.18.x - 2026-08-25

- Server-rendered SEO layer: schema.org Product markup with medals and live price
  ranges, unique titles and meta descriptions, descriptive product URLs with 301s,
  generated sitemap.xml, robots.txt; review/settings/sources pages noindexed.
- HTML entities from one feed decoded at ingest and repaired in stored names.
- New York JFK and Buenos Aires Ezeiza added to collection (config only).
- Review page: search-engine readiness added to the built list.

## 0.14.0 - 2026-08-23

- The discussion page became the client review page: a walkthrough-video slot, the
  realities and decisions in priority order with a comment thread on every item (typed
  name, no account -- the shared password already gates the site), an honest what's-built
  list with extent, and the planned features. Curator controls (add, resolve) moved
  behind a demo setting so the client sees a clean page.
- Madrid joined coverage: its storefront renders server-side again and the existing
  collector works on it -- 133 prices on first collection.

## 0.13.0 - 2026-08-23

Quality-audit release: seventeen independent checks over the data, the code and the copy,
then fixes for everything confirmed.

- **Prices.** The Avolta collector took the first price in a tile, which on multi-size
  products was the smallest variant's — Grey Goose 1L showed at $18.73. It now reads the
  typed final price, records promotional was-prices, and the ingest quarantines any price
  under half the product's cross-shop median instead of publishing it. The wrong rows are
  deleted. "Cheapest" now ignores sold-out shelves whenever anything is in stock.
- **Identity.** Barcode-less sightings of a bottle no longer create a duplicate product
  per airport per run (327 duplicates merged); a barcode whose size disagrees with the
  product is treated as a retailer labelling error and refused; restricted-circulation
  barcodes (GS1 02/2x) no longer drive cross-retailer joins; multipack sizes are the pack
  total; junk feed strings are no longer categories.
- **Medals.** The matcher now requires brand agreement, keeps expression words and ages,
  and refuses ambiguity. 422 medals stand, each defensible, replacing 486 of which about
  a third were on the wrong bottle.
- **Compliance.** Heinemann's robots.txt now disallows its search endpoint, so the global
  catalogue collector refuses to run; every platform collector re-checks robots on every
  run. Existing catalogue data remains but no longer refreshes.
- **Honesty.** "Comparable" now means more than one real airport (the online catalogue
  no longer inflates it); search ignores accents and treats % and _ literally; stale
  persisted airports are pruned; the savings view is shareable by URL; the mobile
  airport picker actually opens; unknown pages return a 404 page.

## 0.12.x - 2026-08-23

- The trip became a persistent airport set ("which airports will you be at"), scoping the
  whole site: catalogue, exclusives, awards, related rails and each product page.
- Adam's wireframe navigation, announcement bar, header search and editorial home page.
- Mexico City prices were being recorded in the wrong currency (USD quoted, stored as
  pesos); collectors now read the currency the page declares.
- Exclusives and Awards became real pages; explainer pages are served by rule and a
  missing page 404s instead of rendering the app shell.

## 0.11.4 - 2026-08-23

- Corrected the coverage page: it still listed Avolta's airports as blocking us, which is
  no longer true and contradicted the app's own data. "Not yet covered" now means only
  what we genuinely cannot read, and airports we could add with configuration alone are
  stated separately.

## 0.11.3 - 2026-08-23

- Collections commit in batches instead of holding one transaction for an entire run.
  Concurrent collectors touching the same products were deadlocking against each other;
  short transactions rarely do. A run that fails part-way now also keeps what it had
  already collected.

## 0.11.2 - 2026-08-23

- Fixed the rest of the concurrency problem: two airports stocking the same bottle would
  both try to create it, and the barcode uniqueness constraint killed one run outright.
  Product and listing creation now tolerate a concurrent creator, as retailer and
  location already did.
- A dead category page no longer ends a whole airport's collection.

## 0.11.1 - 2026-08-23

- Fixed: collectors for the same retailer running concurrently would collide creating
  the shared retailer row, and every process but one lost its entire run. Row creation
  now tolerates a concurrent creator.
- A storefront that serves category pages but no products in the HTML now reports that
  plainly instead of a silent "ok, 0 prices".

## 0.11.0 - 2026-08-22

- Avolta added: Heathrow, Athens, Madrid, Barcelona, Zurich, Hong Kong, Toronto and
  Mexico City. Avolta runs one platform across 168 airports, so this is the largest
  single source of per-airport prices available.
- Each Avolta storefront's own robots.txt is read and its requested crawl delay honoured
  (30 seconds at some airports, 60 at others), never faster than our own setting. The
  paths they disallow are never touched.

## 0.10.0 - 2026-08-22

- Sydney and the Danish/German BorderShop added, both publishing barcodes on every
  product. Ten shops now collect.
- Shops running the same commerce platform (Keflavik, Sydney, BorderShop) share one
  parameterised collector instead of a near-duplicate module each; adding another is a
  few lines of configuration. Each was checked against its own robots.txt first.

## 0.9.1 - 2026-08-22

- Products without a photograph now show a generated brand mark instead of an empty
  box. Most of this catalogue is travel-retail exclusives that no open image database
  carries, so the placeholder needs to look deliberate rather than broken.

## 0.9.0 - 2026-08-22

- Airports is now a real page listing every shop we price, with how much of its range we
  hold and how fresh it is — plus the airports we cannot cover and the honest reason for
  each.
- Data sources page rebuilt around coverage and barcode quality, since barcode coverage
  is what makes cross-shop matching trustworthy.
- Trip page suggests routes that actually have overlap instead of making you guess.
- A single delisted product page no longer aborts a whole collection.

- Keflavik (Reykjavik) added. It publishes barcodes on essentially every product, which
  makes it one of the few sources able to anchor reliable cross-shop matching.
- Product pages rebuilt: a proper hero with the photo, medals and a plain verdict
  ("cheapest at X, save Y"), plus related bottles worth comparing.

## 0.8.1 - 2026-08-22

- Fixed: the data-sources page hung while a collection was running. Listing sources was
  a GET that created database rows, which deadlocked against the collector holding them.
  It is now read-only, and a collector can be paused before its first run.

## 0.8.0 - 2026-08-22

- Dublin and Cork added via ARI's The Loop, enumerated through their sitemap because
  their robots.txt disallows the faster category path. Takes the genuine duty-free tier
  where the site shows both duty-free and duty-paid prices.
- Product images now fall back to a strict name search when a barcode misses, which
  lifts coverage well beyond barcode-only lookups. Still openly licensed, still credited.
- Default "Featured" sort leads with products that have a photo, a medal and a real
  cross-shop comparison, so the catalogue's first screen looks finished.
- A "was" price equal to or below the current price is discarded rather than shown as a
  saving.

## 0.7.0 - 2026-08-21

- A real home page: editorial hero with the trip search as the main action, live savings
  from a real route, category and award browsing, and honest teasers for what's coming.
- Every future feature (airports, price tracker, reviews, guides, map, alerts) now exists
  in the site and explains what it will do rather than 404ing.
- Filters rebuilt for shoppers: a search field, a category rail, three refinements and a
  sort. Build-time toggles moved to a separate Demo settings page.
- Filters and trip selections live in the URL, so any view can be linked.
- "Decisions & realities" replaces the terse discussion list: what needs deciding, the
  real-world constraint behind it, and a suggested compromise.

## 0.6.0 - 2026-08-21

- Trip search: pick the airports on a route and see what is stocked at more than one of
  them, ranked by how much the choice is worth. This is the question the original brief
  asked -- buy at departure, or wait until you land.
- A running "To discuss" list inside the app, seeded with the open questions.
- Ecuador storefront dropped; it returns 404.

## 0.5.0 - 2026-08-21

- Five more airports: Montreal, Panama, Bogota, Guayaquil and San Salvador, through one
  Shopify adapter. Adding another Shopify shop is now configuration, not code.
- Product photography from Open Food Facts, matched on barcode and credited on the page.
  Retailer photography is still deliberately never collected.
- Barcodes are check-digit validated. Some shops put sequential internal SKUs in the
  barcode field, which would have joined unrelated bottles together.
- "With photos" filter.

## 0.4.0 - 2026-08-21

- Products with a price of zero (gift-with-purchase items and unavailable lines) are no
  longer ingested, and existing zero-priced rows were removed. They were sorting to the
  top of "cheapest" and made the comparison look wrong.
- Competition medals now use the competitions' own medal artwork rather than a text
  badge, for all four spirits and four wine competitions.

## 0.3.1 - 2026-08-21

- More retailer packaging labels ("beverage can", "PET bottle", "Multipack") are treated
  as formats rather than categories, and aperitif spellings fold into one category.

## 0.3.0 - 2026-08-21

- Consumer category vocabulary (Whisky, Cognac & Brandy, Rum, Gin, Vodka, Tequila &
  Mezcal, Liqueurs & Aperitifs, Wine, Champagne & Sparkling and more), taken from the
  client's own wireframe, replacing whatever label each retailer feed happened to emit.
  Categories are resolved from each retailer's own category tree first and the product
  name second, because many bottles never state what they are.
- Travel-retail exclusives are now captured and filterable.

## 0.2.2 - 2026-08-21

- Product pages no longer show raw country codes ("AE", "WOR") where a feed supplies a
  code instead of a country name.

## 0.2.1 - 2026-08-21

- Backfilled products collected before the category fix, so packaging words no longer
  appear as categories on existing rows.

## 0.2.0 - 2026-08-21

- Competition medals: winners exported from the competition network and matched onto
  duty-free products by brand and name, conservatively enough that a wrong medal is
  unlikely.
- Category labels no longer show packaging words ("Bottle", "Gift Box") as categories.
- Dubai collection now stops at the end of the liquor section instead of walking the
  whole 33,000-product catalogue.

## 0.1.0 - 2026-08-21

- First working slice: Dubai Duty Free and Heinemann global collectors, barcode-based
  cross-retailer product matching, USD conversion, browse/search, and the per-airport
  price comparison table.
- Per-source kill switch and collection health, so a collector can be paused without a deploy.
