# Stream AW2: featured selection v3

**Status, 2026-09-19 (brief by the orchestrating session; design decided by the panel the same day; nothing
built):** third in the away run (`AWAY-2026-09-19.md` sections 1 and 7 bind it), after AW3 lands the picture
tiers; deploys with D2 to staging only. Branch `claude/collectors-live-page-plan-8951e2`, base `05941b4`.

## Goal
One standard for what is featured, written for Adam and Mark on a page the site itself reads, applied by one function
everywhere a list says "featured": the home page's headline and grid, the category and family savings rails, brand
pages, the airport best-value-by-family block, the full list with `sort=featured`. Rian's factors, in his order: an
admin's pin (the data path now, a UI later); the picture and how good its source is; an award; the saving as a
percentage; the saving in dollars; what the panel found he missed. Pictures weigh heavily, a few award winners show,
percent-led and dollar-led savings mix, no winner is forced in on a poor saving; the first four to eight slots carry
the mix, then the saving weighs more. The v2.1 guards survive unchanged.

## What already exists
`featured.py` v2.1 (pure; a test class per rule), `airport_featured.py`, `catalog_queries.featured_savings` beside
`list_product_variants(sort="featured")`, a SQL ORDER BY that reads NO saving yet feeds every category, family, brand and
airport rail and `GET /api/products`: two answers to one question, the defect this closes. AW3's `Picture.tier` 0..6.

## Tasks, in order (commit prefix `AW2:`)
- **AW2.1 Adopt the design, measure the page as it is.** The Design below is decided; do not redesign. Land `app.cli
  featured evidence` (D7), read-only, its figures read in AW2.1 from `featured_savings`'s select restricted to each
  page's ids (the same columns before and after); run it on the copy for `home` and its three largest pools; the
  numbers go in the handoff under "before". One commit.
- **AW2.2 featured.py v3, every featured sort through it, the admin pin.** D1 to D6; tests as D9, a test that pinned
  the SQL order updated, never weakened; `DATA-MODEL.md` gains `featured`; `docmap --write`; D10's changelog. One commit.
- **AW2.3 The methodology page.** D8, plus a line each in `SEO.md` and `ARCHITECTURE.md`. One commit.
- **AW2.4 Evidence after, tuning by numbers, hand-back.** The same pages, first 8 and first 4, before and after side by
  side in the handoff; a page that misses a D7 target tunes one constant, re-runs, records the reason in D2 and bumps
  `VERSION` and the doc's version line; never by taste. Page time as D4. Handoff entry of 25 lines or fewer: the migration
  command, no backfill, the page address awaiting approval, the pins CLI, the numbers, the questions.

## Design (decided by the panel, 2026-09-19)
Winner: the "user" design, the only one whose scoring survives the copy (the largest dollar saving is $2,003 against a
p90 of $39, so a max-normalised base scores nearly every record 0.02 and no winner reaches an award window). Grafted
from "robust": the failure guarantees, the line pin, input hygiene, unknown age as stale, the expiry floor, the writer's
bool gap, the two-way doc test, the vanished-id test; from "simplest": the tier through `pictures_of`, the filter helper, a
merged variant resolved to its survivor, pins by score, the caps sentence. Numbers: the 19 Sep nightly as `dfp_aw3` (2,555 comparables).

### D1 The record (`services/featured.py`)
```
SavingRecord (frozen, slots; field order kept so v2 positional constructors and every test still build one)
  variant_id, saving_usd, saving_pct, awarded, has_image=False, shop_count=2      # v2; shop_count = airports (D10)
  picture_tier: int = 0 (imagery's 0..6 from Picture.tier; clamped in order());  product_line_id: int | None = None  (the gate retires `line_id`)
  brand_id: int | None = None;  category: str | None = None (None never collides);  is_exclusive: bool = False
  pinned: bool = False (the variant's own column);  line_pinned: bool = False (its line's, AND NOT hidden; lifts ONE)
  age_days: int | None = 0 (since the NEWEST observation; None = unknown = stale; 0 = fresh);  oldest_age_days: the same
  for the OLDEST (None = expired);  in_stock: bool = True (some shop's latest observation in stock or no signal)
  sized: bool = True (quantity_value set);  compared: bool = True (two or more airports; False is the tail);  name: str = ""
tier(r)  := r.picture_tier or (LEGACY_PHOTO_TIER if r.has_image else 0)   # the one place v2's flag meets v3
photo(r) := tier(r) in PHOTO_TIERS;  PHOTO_TIERS := {t for (kind, level), t in imagery.TIERS.items() if level in
             imagery.STRUCTURED_DATA_LEVELS}   # {2,3,5,6}: a bottle, not a brand mark; headline() reads it
```

### D2 Constants (in `featured.py`, every name in `DOC_CONSTANTS`, each in FEATURED.md as `` `NAME = value` ``)
AW2.1 landed D1's record, `tier`, `photo`, `PHOTO_TIERS`, the gates (`gates`, `eligible`), `standings`, `lead`, `clean`, `evidence` and the constants they read (`REAL_SAVING_USD`, `LEGACY_PHOTO_TIER`, `STALE_DAYS`, `EXPIRED_AFTER_DAYS`, `FIRST`, `FIRST_MIX`, `DECAY_END`, the three caps, `LEAD_MARGIN`, plus the evidence's own `PICTURED_TARGET 6` and `AWARDS_TARGET_MAX 4`); `pick_featured` is still v2.1. AW2.2 lands `VERSION`, the weights, `PICTURE_STEP`, `AWARD_FLOOR`, `order()`, D4 and the pin, and removes `IMAGE_BONUS` and `MIN_WINNERS`.
| Constant | Value | Reason |
|---|---|---|
| `VERSION` | 2 | equals the doc's version line; a rule change bumps both (2: AW2.4's award bonus, dated 2026-09-20 with the doc, the day it reaches staging) |
| `REAL_SAVING_USD` | 0.50 | under half a dollar is rounding; `airport_featured` imports it, its `MIN_SAVING_USD` goes |
| `PCT_WEIGHT`, `USD_WEIGHT` | 0.5, 0.5 | over standings both axes share one scale; v2's 0.6/0.4 compensated for max-normalising dollars, which the $2,003 outlier flattens ($66 at 43% scored 0.02); rian lists percent first; AW2.4's numbers decide any tilt |
| `PICTURE_STEP`, `LEGACY_PHOTO_TIER` | 0.06, 3 | per tier: tier 6 adds 0.36, tier 3 0.18; a tier-6 picture lifts a better-half saving (standing 0.55) past a top-decile one with none (0.91 vs 0.90); the champions keep the best savings regardless. A set `thumb_url` was a public variant photo before AW3 |
| `AWARD_BONUS`, `AWARD_FLOOR` | 0.10, 0.5 | counted only when `saving(r) >= AWARD_FLOOR * best`; replaces `MIN_WINNERS` (assumption 5): no winner forced in on a poor saving. AW2.4: at v2's 0.25 the home page stayed a medal page (six winners of eight; 27 in a pool of 1,089, their tier-3 pictures adding 0.18 on top); the sweep 0.25, 0.20, 0.15, 0.12, 0.10, 0.08, 0.06 gave 6, 6, 5, 5, 4, 3, 3 winners with first-eight medians $39.30, $39.30, $45.46, $45.46, $52.08, $59.14, $59.14 and moved no other page; 0.10 is the largest value inside the window of one to four, so the award keeps the most weight the numbers allow |
| `STALE_DAYS` | 14 | on the NEWEST leg: bites 13 of 1,472; the oldest leg (370, Whisky 123 of 227) was rejected because collectors stagger |
| `EXPIRED_AFTER_DAYS` | 60 | on the OLDEST leg: a host that refused us cannot keep a frozen saving on every rail; the saving stays on its page, dated |
| `MAX_UNCORROBORATED_RATIO`, `CORROBORATION_SHOPS`, `HEADLINE_DWARF`, `HEADLINE_OUTLIERS` | 3.0, 3, 2.0, 1 | the v2.1 guard and headline rule, unchanged, refused to drop |
| `MAX_PER_BRAND_FIRST`, `MAX_PER_CATEGORY_FIRST`, `MAX_EXCLUSIVE_FIRST` | 1, 3, 1 | three Estée Lauder and two sizes of one Bvlgari line in Perfume's eight today; 38 of Whisky's 227 are exclusives and the home page's next shelf is the exclusives (the same bottle twice on one screen is a surprise) |
| `FIRST`, `FIRST_MIX`, `DECAY_END` | 8, 4, 24 | the home grid and the family sample; the champions' window; the browse page size, after which only the saving counts |
| `LEAD_MARGIN` | 0.15 | the evidence's percent-led/dollar-led split on standings; within fifteen points the two say the same thing |
Removed: `IMAGE_BONUS`, `MIN_WINNERS` (an `AttributeError` test). No constant for stock (a saving you cannot buy is not a saving; 5 rows), size (no size is as likely two sizes folded: "Aromatics Elixir" at 68%; 18 rows) or standing.

### D3 `order(records, first=FIRST) -> list[int]`: pure, every id exactly once
```
hygiene    : a duplicate variant_id keeps the first; NaN or negative saving_usd reads 0; picture_tier outside 0..6 reads 0;
             each counted in order.stats (a bad adapter is a number in evidence, never a 500)
eligible(r): r.compared and r.saving_usd > REAL_SAVING_USD and r.in_stock and r.sized
             and r.age_days is not None and r.age_days <= STALE_DAYS and r.oldest_age_days is not None and r.oldest_age_days <= EXPIRED_AFTER_DAYS
             and (r.saving_pct < 1 - 1/MAX_UNCORROBORATED_RATIO or r.shop_count >= CORROBORATION_SHOPS)
pool       := [r if eligible(r)];  standing(v) := |pool values below v| / (n - 1), 1.0 when n = 1   (v2.1's _rank_saving
             over the pool: one notion of "where a saving stands", immune to the $2,003 bottle)
saving(r)  := PCT_WEIGHT * pct_standing(r) + USD_WEIGHT * usd_standing(r);   best := max over pool
bonus(r)   := PICTURE_STEP * tier(r) + (AWARD_BONUS if r.awarded and saving(r) >= AWARD_FLOOR * best else 0)
fade(s)    := 1 for s <= FIRST, else max(0, (DECAY_END - s) / (DECAY_END - FIRST));   score(r,s) := saving(r) + bonus(r) * fade(s)
tie(r)     := (-r.awarded, -tier(r), -r.shop_count, r.name, r.variant_id)
1 pins     : own pins in pool, plus ONE variant per pinned line (its best by score(.,1)); by score(.,1), then tie.
             A pin orders, never admits: a pinned record outside the pool is not invented in (D6).
2 champions: best saving_usd and best saving_pct over pool minus pins minus exclusives, at most two, dollar first, each
             within the first FIRST_MIX slots (a four-card rail carries both when both exist).
3 slots len(pins)+1 .. FIRST, greedy: a champion takes the slot as soon as the slots left in FIRST_MIX equal the
             champions left; else the best score(r, s) that respects the caps counted over everything placed (pins and
             champions count, are never refused): MAX_PER_BRAND_FIRST, MAX_PER_CATEGORY_FIRST, MAX_EXCLUSIVE_FIRST. A cap is
             off when the pool cannot honour it (fewer distinct values than the slots need: a brand or category page); when
             nothing respects the caps they release. A rail runs short only for lack of eligible records, never padded.
4 slots FIRST+1 .. DECAY_END: greedy by score(r, s), no caps.   5 the rest of pool: by saving(r), then tie.
6 ineligible comparisons (stale, expired, out of stock, unsized, uncorroborated, saving <= 0.50): by saving_usd, tie.
7 the tail (not compared): by (-tier(r), name, variant_id)   (silence favours a picture: here and in tie)
8 headline := pins[0] if pins else headline(the first FIRST placed), v2.1 unchanged with photo(r); moved to slot 1
pick_featured(records, total=8) := order(records, first=min(total, FIRST))[:total]   # airport block, home page
```
Deterministic: no clock, no randomness, no rank feeding a score; two workers answer alike; milliseconds at 2,555.
**As built (AW2.2):** `order.stats` is `ordering(records, first).stats` (an `Ordering` dataclass beside the ids: hygiene, pool,
pins, champions, headline, the caps switched off), because a function attribute is shared state between two workers;
`pick_featured` is the order's first `total` restricted to the pool, so a rail runs short rather than padded with a stale, sold-out
or held-back comparison (D3's own sentence) and a pin outside the pool is not admitted (D6). Where the design was silent: a pin
heads the band it belongs to (an ineligible comparison, the tail), so a pinned one-shop variant heads the tail; when pins fill the
first four the champions take the slots right after them; an unpin of a hidden line is allowed (it releases, it never leads);
`_filtered_summary` narrows the observation ranking to an airport's, a brand's or a category's variants (identical results; the
airport page ran the whole catalogue's ranking three times, and this path's first run took a minute there). **Review fixes (after
AW2.4):** band 8's move of a headline taken from below the window gives up the last non-champion in the window, never a champion
(`_lead_first`; the champions keep their order); the three gate columns read airport shops only, like `airport_count`, so a
catalogue's observation never decides fresh, expired or in stock for a two-airport comparison (D10, the doc's own sentence). **Named
risk:** standing compresses the top band (the top 100 dollar savings on the copy all stand above 0.93), so pictures and
medals reorder it freely: a $40 saving with a tier-6 picture can sit above a $200 one at slot 3; the champions and the p75 target (D7) are the honesty check.

### D4 Records and the one list path (`catalog_queries.py`)
- `_filtered_summary(db, *, <every keyword of list_product_variants>) -> Select`: its filter block extracted, so the
  count and the records come from one statement and `total` and the ranked ids cannot drift (tested).
- `_summary_base` gains labelled columns every reader carries (the list, `_airport_rows`): `newest_observed_at`,
  `oldest_observed_at` (over the latest-per-listing rows already joined); `cheapest_available` (`min(price_usd)` filtered by
  `_available()`; None when nothing is in stock anywhere); `quantity_value`; `pinned` (the variant's column); `line_pinned`
  (`ProductLine.featured AND NOT hidden`, an outer join on the variant's own line; the CLI pins the fold row).
- `featured_records(db, *, <the same keywords>, now=None) -> list[SavingRecord]`: the filtered statement in full,
  `pictures_of(db, rows)` once (`picture_tier = picture.tier if picture else 0`, never restated; a test proves all seven
  tiers reach a record), `_award_counts` once; each D1 field from its column; `saving_usd = dearest - cheapest` (0 when
  either is None or `airport_count < 2`), the ages from `now` (injected; a NULL date gives None).
- `list_product_variants(sort="featured")`: `ids = order(featured_records(db, <same filters>))[offset:offset+limit]`,
  fetch with `only_ids=ids`, reorder, skipping an id the fetch did not return (merged or hidden between the two
  statements; tested); `total` stays the SQL count; `only_ids` is an order (given, no ranking runs); the SQL "featured"
  ORDER BY is deleted, `compared`, `price`, `name` untouched. `featured_savings(db, at_codes, total)` =
  `featured_records(at_codes, multi_only=True)`, `pick_featured`, the same fetch; its private select goes; tested to agree
  with the list. `airport_featured.candidates(rows, tiers=None)` reads the new columns with `getattr(r, ..., default)`
  (hand-seeded test rows); `featured_at` passes `tiers` from `pictures_of`.
- The ranking is one statement (one snapshot) and the fetch a second, so a card may show a price newer than the one
  that ranked it, both dated; no lock, no write; page N+1 of a live order may repeat or skip a card (on the API doc).
  Page time (AW2.4, same copy): home `featured_records` + `order()` within 1.2 times `featured_savings` before;
  `/products` first page under 800 ms; mitigation: narrow `_latest_observation_subquery` to the filtered ids, then bound
  the pool; never a cache (a second answer, and blind to a pin made in the CLI).
  **As built (AW2.4):** the unfiltered `/products` first page ranked 11,252 records to show 24 (1,125 ms on the copy; `pictures_of` 331 of them, the
  product line rows 212); the first mitigation is moot on an unfiltered page, so the second: `_featured_ids` ranks the comparisons alone
  (`multi_only`, exactly the records `compared` names) and the whole list only when a page reaches the uncompared tail; every band but the
  tail is decided over the comparisons, so the bounded order is the whole order's prefix (tested); 542 ms after.

### D5 The pin
- Migration `aw2c1d2e3f4a_featured_pin.py`, `down_revision` = the head `alembic heads` prints at build (`aw3b1c2d3e4f` today):
  `featured BOOLEAN NOT NULL DEFAULT false` on `product_lines` and `product_variants`, partial indexes `ix_<table>_featured (id)
  WHERE featured`; downgrade drops indexes then columns; schema only, no backfill. Models beside `hidden`/`indexed`; not on `Brand`.
- Ledger: `writer.FIELDS` gains `"featured"` on `product_variant` and `product_line`; `appliers._make` registers
  `_flag("featured")` for both (two lines); `undo._column_value` returns it beside `hidden` so `decisions verify` covers drift.
  The writer is out of bounds and its `_validate` (line 266) bool-checks only `hidden, indexed, ignored`, so `set_featured`
  checks `isinstance(featured, bool)` before any batch opens (`VALUE_INVALID`, tested); AW2.2 files a one-word issue for the writer's owner.
- `services/featured_pins.py`, in `publish.set_indexed`'s shape: `KINDS = {"line": "product_line", "variant":
  "product_variant"}`; a line by slug or id through `publish.find` (an old slug forwards), a variant by id through
  `resolve_variant_id` (a merged variant pins its survivor, as a URL forwards; not a `VARIANT_MERGED` refusal).
  `set_featured(db, targets, featured, by, *, batch_kind="cli", reason=None) -> dict`: named twice is one decision;
  refusals `PAGE_NOT_FOUND`, `PAGE_HIDDEN` (a hidden line, or a variant whose line is hidden), `VALUE_INVALID`; already-so
  counted, not recorded; no batch when nothing changes; one `writer.batch(mode bulk|individual, commit=False,
  tail=False)`, one `record` per row, `writer.Refused` (`DECISION_HELD`) caught per row; the CLI commits;
  `set_indexed`'s dict. `current(db)`: every pin with name, path, `decided_at`, `by`, `inert`, `drift`.
- Guarantees: the CLI killed before commit leaves no batch, no row, no column (one transaction); two pins on one row
  serialise on the writer's advisory lock (5 s `lock_timeout`, then a clean failure to re-run); a bulk pin over a
  person's individual unpin is `DECISION_HELD` for that row; undo releases to false (a rule never pins); a merge
  (`_variant_merged_consequences`) and a line fold (`overrides.move`) carry the pin, tested; hand-set is drift; no API write.
- `app/cli_featured.py`, registered in `cli.py`: `featured pin|unpin line:<slug>|variant:<id> ... --by <user> [--reason]`
  prints the batch uid, the `decisions undo-batch <uid>` line, one line per refusal, and each row's comparison state
  (`comparison: 10 airports, $14.20 / 18%` or `comparison: none: shows only in the full list`); an unknown kind exits 1
  before any database call; exit 2 when all refused. `featured list`: per pin, newest first, where it leads, or `inert`/`drift`.

### D6 A pin's place (assumption 2; robust's reading is the overturn)
A pin orders; it never admits and never labels. It leads every list the record is already a comparison in; a pinned one-shop variant heads the tail only, and the CLI says so at pin time. Overturn (a pin leads even with no saving): one band rule, one test.

### D7 Evidence (`featured.evidence(records, order, first) -> Evidence`, pure and tested)
`app.cli featured evidence [--pages ...] [--first 8] [--verbose] [--json]` prints it: read-only, one session, never a
batch. Pages: `home` (`featured_savings`), `/products`, `/alcohol` (family), `/alcohol/whisky` (the category rail's call),
`/brands/<slug>`, `/airports/<iata>` (per family); default `home` plus the three largest eligible pools from `category_counts`,
never hard-coded (Perfume 535, Whisky 227, Skincare 158 on the copy). Columns per page for the first
N: pool; pictured (tier >= 1) beside the pool's picture rate; bottles (`PHOTO_TIERS`); tiers 0..6; awarded; pinned;
%led / $led / both (`LEAD_MARGIN` on standings); median $ and %; pool p75 $ and %; brands; categories; exclusives;
two-shop spreads held back; gates (the D3 gates in the first N, 0 by construction); hygiene counts; query ms and
`order()` ms (the page-time tool); `--verbose` one line per slot. Targets, as functions of the pool so Perfume (one photo
in 535) is not asked for six: pictured in the first eight >= min(6, bottles in the pool); awards 1..4 when the pool holds
a winner in its better half, else 0 (today's home page is six of eight: a medal page); both leads in the first four when
both champions exist; distinct brands = min(8, brands in the pool); no category over 3; exclusives <= 1; median $ and % of
the first eight >= the pool's p75 of each (good savings, not the best-looking of mediocre ones); gates 0.

### D8 The doc, the reader, the API, the page, the link
- `main/docs/FEATURED.md`: `# How we choose what is featured`, `**Version 1, 2026-09-20**`, one lede paragraph, eight
  `##` sections, each D2/D3/D6 rule in plain words: What "featured" means · What a comparison is (two or more of our
  airports; the online catalogues identify, never compare) · A price is what a shop showed on a date · The factors, in
  order (a pipe table What counts | How much | In the code, one row per `DOC_CONSTANTS` name; the picture ladder in
  words; "where the saving stands" in one sentence) · The first eight, and after (the caps relax rather than leave a rail
  short) · The guards · What an admin's pick does · What is never done (no retailer photograph, no marketing copy, no
  login; "duty free" names the channel; "lowest price"). House style throughout.
- `process_doc.read_doc(name)` over `_DOCS = {"REVIEW-PROCESS": _CANDIDATES, "FEATURED": (image path, checkout path)}`;
  `read()` = `read_doc("REVIEW-PROCESS")`, `_CANDIDATES[0]` untouched; an unknown name is `KeyError`; the lede becomes
  `sections[0]` "In short"; `Dockerfile` `COPY docs/FEATURED.md ./docs/FEATURED.md`; read per request, no cache.
  `routers/featured.py GET /api/featured/method -> FeaturedMethodOut(version, dated, version_line, sections[title, anchor, html])`
  in `schemas.py`, never restated in TypeScript; 404 `DOC_MISSING` when absent or unversioned.
- `/how-we-choose` (assumption 1): `main.py` route before the catch-all like `/data`, `not_found(request)` when missing;
  `seo.head_for_method` (title "How we choose what is featured | Duty Free Professor", description the lede, canonical,
  `noindex, follow` unless `publish.is_indexed_path`; the constant stays empty); `seo.method_body` (header; hero with
  eyebrow HOW WE CHOOSE, the h1, the lede, `Version 1, 20 September 2026` muted; an "On this page" list; one `<section
  id="<anchor>" class="method-section">` per section, the factors table as a table); `_PAGE_ROUTES`; `App.tsx` route to
  `pages/HowWeChoosePage.tsx` (imports its CSS, `Prose.tsx`, fetches the API, a 404 renders the SPA not-found); `access.py`
  and `open_routes.json` gain both GETs and the SPA path. Verified by the served `<h1>` and the version line.
- The link `<a class="how-we-choose" href="/how-we-choose">How we choose</a>` in the rail head: HomePage's savings
  section, FamilyPage and CategoryPage rails (`seo.family_body`, `category_body`), BrandPage and BrowsePage when the sort
  is featured (`brand_body`), AirportFeatured (`airport_featured_html`), AirportCategoryPage when featured, MegaMenu's
  featured card; the body tests assert it exactly when the rail is. An absent doc 404s the page and the API, rails render.
  **As built (AW2.3):** the API and the seed carry `title` too (the doc's `#` heading), so the h1 is read, never restated;
  `read_doc` takes the lead section's title and anchor from a per-doc spec (`_DOCS`) and drops the version line from the
  featured lede only (the review process keeps it inside its standing rules, byte for byte); the served page seeds the
  same dict as `window.__DFP_METHOD__` so the SPA's first paint is the finished page; the brand page's list passes
  `method_link` into `_list_section` (the airport page's own full list, not in D8's eight, carries no anchor).

### D9 Tests (records shaped like the copy's rows, ids and figures in the docstring: Lagavulin 16 `$66.19 / 43% / 10 shops / tier 3 / awarded`)
- `test_featured.py`: the v2.1 classes stay green (`rec(image=True)` reads as tier 3); `TestWinnerFloor` becomes
  `TestAwardFloor`; one class per D3 rule: `TestPinsLead`, `TestPictureLadder`, `TestChampionsInTheFirstFour`, `TestDiversity`,
  `TestGates` (each gate, unknown age, the default 0 fresh), `TestDecay`, `TestTail`, `TestInputHygiene`, `TestDeterminism`
  (two shuffles, one order), `TestPickFeaturedIsOrder`, `TestEvidence`, `TestRemoved`.
- `test_featured_records.py` (SQLite kit): the seven tiers through `pictures_of`; a pin from the variant and from its line; a
  hidden line's pin inert; `total` and the ranked ids agree; `only_ids` is an order; a dropped id is skipped; `featured_savings`
  and the list agree; `shop_count` is the airport count. `test_featured_pins.py` (`forbid_ledger_updates`): pin, unpin, undo,
  drift, every refusal code, named twice, no empty batch, a merge and a line fold carry the pin, an old slug and a merged
  variant forward, `list` marks inert and drift. `test_cli_featured.py`: the grammar exits 1 before any database call;
  `evidence --json`. `test_airport_featured.py`: the new fields, `order`.
- `test_featured_doc.py`: `DOC_CONSTANTS` both ways, values equal; version equals `VERSION`; eight sections; house style.
  `test_process_doc.py`: the FEATURED path, image path first, missing, the Dockerfile copy, `KeyError`. `test_featured_api.py`:
  the shape, 404 `DOC_MISSING`, in `PUBLIC_WHEN_OPEN`. `test_site_routes.py`: 200 with the h1 and version line, `noindex,
  follow`, 404 absent; `TestRouteInventory`; the four body tests' anchor.

### D10 Decided in the panel, for the changelog, and the cut list
`shop_count` is the airport count: an online catalogue-only shop no longer corroborates a spread (the doc's definition of a
comparison; code and doc now say one thing); a changelog line beside the rails fix. Under time pressure, cut in this order:
`DECAY_END = FIRST`; the size gate (keep the field); `featured list`'s "where it leads" line; the "On this page" list and the
MegaMenu link; `--verbose` and `--json`. Never cut: the guard, the headline, one transaction per pin batch, GET never writes, the 404 for an absent doc, the doc test, `order()` behind every featured sort.

## Evidence before (AW2.1, on the copy; the same columns AW2.4 prints after)
`app.cli featured evidence` (D7) on `dfp_aw2`: `backups/dfp-2026-09-19-pre-D1.dump` (13:15 PDT, 18,124 product variants, 76,109
observations, newest 20:12 UTC) restored to `dfp-devdb` and migrated to `aw3b1c2d3e4f`, so the tiers read AW3's cascade with no
picture imported (every tier is 0 or 3). The orders are v2.1's (`featured_savings` for home, the SQL featured sort for the rails).
Default pages: `home` and the three largest eligible pools (Skincare is hidden on this copy, so Makeup is third). Full figures with the
slots: `AW2-evidence-before.json`. Not in the pool: home 1,240 of 2,376 comparables save under $0.50, 22 out of stock, 39 unsized, 3 stale,
5 held back as two-shop spreads.

**First 8**

| page | pool | pool pictured / bottles | pictured / bottles | awarded | %led / $led / both | median $ / % | pool p75 $ / % | brands | max per category | exclusives | champions in first 4 | gates | targets met | query / order ms |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| home | 1112 | 207 / 207 | 7 / 7 | 6 | 3 / 1 / 4 | 39.30 / 41% | 21.74 / 29% | 8 | 3 | 0 | 1 of 2 | 0 | 7 of 9 | 367 / 257 |
| /beauty/perfume | 338 | 0 / 0 | 0 / 0 | 0 | 7 / 0 / 1 | 7.84 / 14% | 20.20 / 15% | 5 | 8 | 0 | 0 of 2 | 0 | 5 of 9 | 170 / 319 |
| /alcohol/whisky | 205 | 55 / 55 | 8 / 8 | 7 | 3 / 0 / 3 | 16.59 / 23% | 40.08 / 36% | 7 | 8 | 0 | 0 of 2 | 2 | 3 of 9 | 173 / 299 |
| /beauty/makeup | 92 | 0 / 0 | 0 / 0 | 0 | 0 / 1 / 7 | 21.52 / 31% | 11.00 / 26% | 5 | 8 | 1 | 0 of 2 | 0 | 7 of 9 | 175 / 313 |

**First 4**

| page | pool | pool pictured / bottles | pictured / bottles | awarded | %led / $led / both | median $ / % | pool p75 $ / % | brands | max per category | exclusives | champions in first 4 | gates | targets met | query / order ms |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| home | 1112 | 207 / 207 | 3 / 3 | 3 | 1 / 1 / 2 | 62.38 / 42% | 21.74 / 29% | 4 | 3 | 0 | 1 of 2 | 0 | 7 of 9 | 387 / 302 |
| /beauty/perfume | 338 | 0 / 0 | 0 / 0 | 0 | 3 / 0 / 1 | 2.80 / 9% | 20.20 / 15% | 3 | 4 | 0 | 0 of 2 | 0 | 5 of 9 | 191 / 331 |
| /alcohol/whisky | 205 | 55 / 55 | 4 / 4 | 4 | 2 / 0 / 1 | 7.97 / 18% | 40.08 / 36% | 4 | 4 | 0 | 0 of 2 | 1 | 5 of 9 | 169 / 318 |
| /beauty/makeup | 92 | 0 / 0 | 0 / 0 | 0 | 0 / 0 / 4 | 15.39 / 33% | 11.00 / 26% | 3 | 4 | 1 | 0 of 2 | 0 | 7 of 9 | 172 / 316 |

What the before says: the home page is a medal page (6 winners of 8, 7 of 8 on Whisky) with the percent champion at slot 8, so the
award window and "both champions in the first four" are the two misses; the rails ranked by the SQL sort show a median saving under
the pool's p75 on Perfume and Whisky (a rail led by $2.25 and $0.00 savings), a brand short of eight everywhere but home, and two
Whisky cards with no saving at all. Perfume and Makeup hold no picture on this copy, so their picture target is 0 and met. Timings:
`featured_records` 145 to 400 ms per page, the v2.1 order 250 to 340 ms (AW2.4's budget: home within 1.2 times before).

## Evidence after (AW2.4, the same dump, the v3 order at version 2)
`app.cli featured evidence` on `dfp_aw2c`: `backups/dfp-2026-09-19-pre-D1.dump` restored fresh and migrated `aw3b1c2d3e4f` to
`aw2c1d2e3f4a` (up, down, up), so the columns are the before's; the orders are v3's (`pick_featured` for home, `order()` for the
rails). Full figures with the slots: `AW2-evidence-after.json`. Home's pool is 1,089 (before 1,112) because a catalogue-only shop
no longer corroborates (D10): 24 more save under $0.50 and one fewer two-shop spread is held back.
**Rerun after the review fixes** (`dfp_aw2d`, the same dump restored fresh and migrated to head, the gate columns now airport-only):
every figure below is unchanged, pool delta 0 on every page (1,089 / 323 / 205 / 90), the same slots; on this copy no two-airport
comparison had a catalogue observation newer, older or in stock where the airports' were not.

**First 8**

| page | pool | pool pictured / bottles | pictured / bottles | awarded | %led / $led / both | median $ / % | pool p75 $ / % | brands | max per category | exclusives | champions in first 4 | gates | targets met | query / order ms |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| home | 1089 | 205 / 205 | 7 / 7 | 4 | 1 / 1 / 6 | 52.08 / 42% | 22.17 / 30% | 8 | 3 | 0 | 2 of 2 | 0 | 9 of 9 | 453 / 30 |
| /beauty/perfume | 323 | 0 / 0 | 0 / 0 | 0 | 0 / 0 / 8 | 82.45 / 49% | 20.56 / 15% | 8 | 8 | 0 | 2 of 2 | 0 | 9 of 9 | 133 / 7 |
| /alcohol/whisky | 205 | 55 / 55 | 6 / 6 | 2 | 1 / 2 / 5 | 62.60 / 43% | 40.08 / 36% | 8 | 8 | 0 | 2 of 2 | 0 | 9 of 9 | 58 / 4 |
| /beauty/makeup | 90 | 0 / 0 | 0 / 0 | 0 | 0 / 1 / 7 | 23.38 / 38% | 11.38 / 27% | 7 | 8 | 1 | 2 of 2 | 0 | 8 of 9 | 49 / 2 |

**First 4**

| page | pool | pool pictured / bottles | pictured / bottles | awarded | %led / $led / both | median $ / % | pool p75 $ / % | brands | max per category | exclusives | champions in first 4 | gates | targets met | query / order ms |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| home | 1089 | 205 / 205 | 3 / 3 | 2 | 1 / 1 / 2 | 62.38 / 42% | 22.17 / 30% | 4 | 3 | 0 | 2 of 2 | 0 | 8 of 9 | 411 / 27 |
| /beauty/perfume | 323 | 0 / 0 | 0 / 0 | 0 | 0 / 0 / 4 | 92.63 / 62% | 20.56 / 15% | 4 | 4 | 0 | 2 of 2 | 0 | 9 of 9 | 138 / 9 |
| /alcohol/whisky | 205 | 55 / 55 | 2 / 2 | 2 | 0 / 1 / 3 | 82.52 / 42% | 40.08 / 36% | 4 | 4 | 0 | 2 of 2 | 0 | 8 of 9 | 65 / 5 |
| /beauty/makeup | 90 | 0 / 0 | 0 / 0 | 0 | 0 / 0 / 4 | 26.14 / 47% | 11.38 / 27% | 3 | 4 | 0 | 2 of 2 | 0 | 8 of 9 | 54 / 3 |

`/products` at first 8 (11,252 records, the same pool as home): 9 of 9, the same eight cards.

**The one tuning, by the numbers (version 1 to 2).** At version 1 (`AWARD_BONUS 0.25`) home at first 8 met 8 of 9: six winners
against a window of one to four, the same medal page the before was, with the first eight's median at $39.30. The sweep in D2's row
put 0.10 inside the window with no other page moved, and version 2 carries it: the constant, the doc's row and version line,
`TestAwardFloor` restated with the winner two rungs up rather than at the top, the airport picker's close call restated at twelve
rums (a rung of six is 0.2, which no award is meant to buy). Nothing else was tuned.

**What the after says.** Every rail's first-eight median is above its pool's p75 (Whisky $62.60 against $40.08, before $16.59;
Perfume $82.45 against $20.56, before $7.84; Makeup $23.38 against $11.38); both champions sit in the first four on every page
(before 0 or 1 of 2); gates 0 everywhere (before 2 on Whisky); eight brands everywhere but Makeup. Home is Lagavulin 16 (the
headline: an award with a bottle), Oban Little Bay, Hibiki 30 ($2,003, the dollar champion), Campari 1L (75%, the percent champion),
Martell Noblige, Hendrick's, Rémy Martin 1738, Santa Teresa 1796: four medals, seven pictures, eight brands, three whiskies (the cap).

**The three misses no constant reaches**, all the champions rule's (D3 band 2: the two best savings are forced into the first four
and never refused by a cap, picture or not): Makeup brands 7 of 8 and 3 of 4, because both champions are Lancôme Définicils (the
trio set #14173 at $60.28, the single #9341 at 58%); home pictured 3 of 4 and Whisky 2 of 4 at first 4, because the dollar champion
Hibiki 30 (and on Whisky the percent champion Glen Deveron 20) carries no picture, so those slots cannot be pictured; at first 8 both
pages meet the target. D7's targets were left as written rather than moved to meet the page; the question goes to rian below.

**Page time (D4).** Home `featured_savings(total=8)`: 395 ms, the median of five (before: 367 query + 257 order, 624 ms; 0.63 times,
inside the 1.2 budget). `/products` first page (`list_product_variants(sort="featured", limit=24)`): 1,125 ms at first run against the
800 budget (count 133, the summary 287, `pictures_of` 331, awards 107, `order()` 47, the fetch 47), 542 ms after `_featured_ids`
(D4, as built); the Compared filter 547 ms; a category rail 184 ms.

## Working assumptions (rian's to overturn)
1. **The page lives at `/how-we-choose`**, public, `noindex, follow` until named in `publish.INDEXED_PAGES`. *Overturn: one constant, one route, one redirect row.*
2. **A pin orders, never admits and never labels** (D6). *Overturn: one band rule and one test; a badge is one card prop.*
3. **A pin lives on product lines and product variants, not brands**; a line pin lifts one variant. *Overturn: one
   `_flag` line; every variant of a pinned line as pinned is one line in `order()`.*
4. **"Duty free" may name the shopping channel**; "free" and "cheap" never as a price word. *Overturn: a reword.*
5. **The winner floor becomes the award floor** (`AWARD_FLOOR`). *Overturn: restore `MIN_WINNERS`, one test class.*
   **The picture tier is AW3's, read through `Picture.tier`.** *Overturn: nothing to redo.*
6. **Freshness is a newest-leg gate at 14 days plus an oldest-leg expiry at 60**; the date on the card is later work.
   *Overturn: an oldest-leg penalty (`STALE_PENALTY 0.10`) instead of the gate; one constant, one test class.*
7. **A merged variant pins its survivor; an online-only shop does not corroborate.** *Overturn: one refusal code; one column.*

## Questions for rian
- `/how-we-choose`, or under `/data` (assumption 1)? A visible mark on a pin, a pin on a brand, a pin with no comparison leading (assumptions 2 and 3)? Neither critical.
- Should a champion honour the brand cap (Makeup's two champions are two sizes of one Lancôme line), and should a champion without a
  picture count against the picture target (AW2.4's three misses)? Assumed: the design stands, a champion is never refused, D7's targets
  stay as written. Overturn: `champions_of` skips a second champion of the first's brand (one line, one test), or two target lines. Not critical.
- Version 2 is dated 2026-09-20 like version 1 (the day the rules reach staging), so the bump does not predate its predecessor. Not critical.

## Owns
`services/featured.py`, `airport_featured.py` (call sites), `catalog_queries.py` (D4), new `services/featured_pins.py`,
`app/cli_featured.py`, `routers/featured.py`, `cli.py` (one line), `schemas.py`, `process_doc.py` (`read_doc`),
`decisions/writer.py` `FIELDS` only, `appliers.py` `_make` and `undo.py` `_column_value` (a line each), `models/catalog.py`
(the `featured` column only), one migration, `main/docs/FEATURED.md` (new), a line each in `DATA-MODEL.md`, `SEO.md`,
`ARCHITECTURE.md`, `main.py`, `Dockerfile`, `App.tsx`, `seo.py`, `access.py`, `open_routes.json`, `pages/HowWeChoosePage.tsx`
and its CSS, the rail heads of D8, `CHANGELOG.md` Unreleased, and tests for all of it.

## Must not touch
Production, `.app.env`, `.env`, `.bw-auth.env`; AW3's files (`services/images.py`, `services/imagery.py`, the image columns, its
migration, manifest and commands, `uploads/`); `services/collectors/`; identity, keying and the certain boundary; `decisions/writer.py`
beyond `FIELDS`; `services/trip.py`; `award_picker.py`; `.logs/runs/`, `notes/`; `main/check.sh`.

## Rules that bite here
- The corroboration guard is refused to drop. A GET must never write: the evidence command, the method API and every
  list read compute and return; a pin is written only by the CLI through the ledger's writer.
- A personalised label needs a personalised query ("on your trip" only over `featured_records` scoped by `at_codes`). Build-time
  toggles belong in Settings. One word per concept; no em dash, never "cheap" or "free" as a price word (assumption 4). Import a
  component's stylesheet in the component; verify a new page by a string only it contains.
- Migrations are schema-only with server defaults; no backfill. Never migrate the live database; rehearse on `dfp-devdb`
  (127.0.0.1:5433) restored from `backups/dfp-nightly-2026-09-19.dump` or newer, `DATABASE_URL` pointed at it. A bug that shipped becomes a test. No collection, no fetch.

## Out of scope, noted
- The category and family "Biggest savings" rails shipped in 0.52.0 on a sort that reads no saving; AW2.2 fixes it and the
  changelog line says so plainly. `similar_product_variants` and the exclusives rails inherit v3 with no comparison inputs
  (a "related" order is later work); `/savings` and `/trip` sort in `services/trip.py`, a third mechanism left alone. An
  admin UI for pins, a pin ordinal, and the observation date on the card are later work.
