# Stream AW5: the launch surface (page status, the article centre, email capture)

> **Status 19 Sep 2026, 11:30 PDT:** written from `AWAY-2026-09-19.md` (sections 1 and 7 bind),
> rian's asks 7, 8 and 9 of 19 Sep 10:00, and the code at `05941b4`. Not started. Runs fifth,
> after AW4; deploy **D4** follows it.

**Runs:** as `/stream-aw5`, one subagent per task in order, in the worktree
`/srv/apps/dutyfreeprofessor/.claude/worktrees/collectors-live-page-plan-8951e2` on branch
`claude/collectors-live-page-plan-8951e2`. Never deploys (the orchestrator deploys D4 when the
gate is open), never runs a collection, **no network at all** (no image fetched, no host read).
Rehearses the migration and the import on a fresh copy of the newest dump in `backups/` restored
to `dfp-devdb` (127.0.0.1:5433), never on staging's database.

## Goal
Three small things rian named for the soft launch. A signed-in owner or admin sees, on every page,
whether the page they are on is indexable, noindex or unlisted, in the app's own terms with the
reason, and the public never sees the mark. Ten sample articles in the Professor's voice sit on
staging so Adam can see what an article centre looks like before he hands in his own, each marked
as a sample so the launch plan keeps them off production. Email capture is proven end to end on a
restored copy and its export procedure is written down for rian.

## What already exists, so you build on it
- `app/services/publish.py`: the one reader of `indexed` and `hidden` (`is_indexed`, `is_hidden`,
  `find(db, kind, ref)`, `place_by_identifier`, `hidden_forward`, `redirect_for`, `facts`), and
  `INDEXED_PAGES` / `is_indexed_path(path)` for the family and category pages (no row of their own).
- `app/services/seo.py`: `Head(noindex, follow)`; `_published()` sets `noindex, follow` on brand,
  line and place heads; `category_head` / `family_head` read `is_indexed_path`; `STATIC_HEADS`
  (client, owner and account surfaces are `noindex, nofollow`); `NOT_FOUND_HEAD`; `is_known_route`;
  `sitemap_entries` (static hubs, approved rows, every published article); `article_body`,
  `head_for_article`, `article_head` (class for class with `ArticlePage.tsx`, seeded as
  `window.__DFP_ARTICLE__`, Article + BreadcrumbList JSON-LD).
- `app/main.py mount_site`: `page()`, `not_found()`, `hidden_redirect()`, page routes ahead of the
  catch-all (`/articles/{slug}`, `/data` via `_PAGE_ROUTES`, the families), the catch-all `spa`.
- `app/cli_index.py`: `index suggest | list | approve | remove` (a person's decision, K6).
- `app/services/access.py`: every route in one class; `PERMISSION` (a string or `owner`);
  `HTML_KEYS`; `tests/fixtures/open_routes.json` pins `PUBLIC_WHEN_OPEN`;
  `tests/test_route_inventory.py` fails on an unclassified route or SPA path.
- `app/services/accounts.py`: the six DFP permissions, `SEED_LEVELS` (`admin` = client.view +
  client.participate; `member` = nothing), `can()`, `is_owner()`, `capabilities_for()`;
  `app/cli.py backfill_levels` (insert if absent, never update). Rule (`docs/ACCOUNTS.md`): a
  permission question goes through `can()` and `is_owner()`, never a level name.
- `web/src/lib/auth.tsx` (`DfpCapabilities`, `useCapabilities`, `useDfpMe`); `App.tsx` mounts
  `ViewAsBanner`, `Lightbox`, `DiscussionPanel` at the root, where a site-wide overlay lives.
- `app/services/editorial.py` (`read_document`, `upsert_article` idempotent on slug, `set_status`,
  `list_articles`, `article_summary`, `article_out`, `reading_minutes`, `sitemap_rows`);
  `models/editorial.py` (`Article`: slug, title, standfirst, body_md, kind, category,
  airport_code, brand_id, author_id, status, published_at, hero_image; `ArticleSummary`,
  `ArticleOut`, `ArticlePage`); `cli_editorial.py` (`articles import <file> [--kind --slug
  --title --standfirst --category --author --hero-image --publish]`, `list [--all]`, `publish |
  unpublish <slug>...`; `subscribers export [--out] [--include-unsubscribed] | stats |
  unsubscribe`); `routers/articles.py` (four GETs, none writes). `import/` is mounted read-only
  in the container at `/srv/import/`.
- `web/src/pages/ArticlesPage.tsx` (lead card, grid, pager, `ARTICLES_PAGE_SIZE` 12),
  `ArticlePage.tsx`, `components/ArticleCard.tsx`, `LatestArticles.tsx` (home page; the mega
  menu's Articles column also lists the newest), `lib/articles.ts`, `api/editorial.ts`
  (`useArticles`, `useArticle` with the seed, `CONSENT_TEXT`, `INTERESTS`, `useSubscribe`).
- One sample exists: `import/articles/is-it-really-a-saving.md`, hero
  `/media/articles/price-check-board.svg` (editorial pictures ship under `web/public/media/`).
- `app/services/subscribers.py` (`subscribe` upsert with the consent record, `unsubscribe` keeps
  the row, `export_csv`, `stats`), `routers/subscribers.py` (`POST /api/subscribers`, throttled,
  honeypot, one answer for new and existing), `SubscribeForm.tsx` (the checkbox prints
  `CONSENT_TEXT`), `docs/RUNBOOK.md` "Content" (the three subscriber commands).
- Tests to extend: `test_publish.py`, `test_seo.py`, `test_seo_article.py`, `test_site_routes.py`,
  `test_access.py`, `test_route_inventory.py`, `test_articles.py`, `test_subscribers.py`,
  `test_house_style.py`. Docs with one home each: `SEO.md`, `ACCOUNTS.md`, `RUNBOOK.md`.

## Tasks, in order (commit prefix `AW5:`; register each on /plan first)

### AW5.1 Page status: the four words, the read, the badge
- **The mapping, written before the code** into `docs/SEO.md` ("Page status: what a signed-in
  admin sees"). The app has four states, so rian's three words gain a fourth:
  **indexable**: no robots directive and in the sitemap (the static hubs, every published
  article, a brand, product line, place, family or category page a person approved);
  **noindex**: reachable and linked, `noindex, follow`, out of the sitemap (every generated page
  until approved, the K6 default; `/articles` after AW5.3);
  **unlisted**: reachable only by direct link, `noindex, nofollow`, never in the sitemap (the
  client, owner and account surfaces in `STATIC_HEADS`, the explainer pages, the not-found shell);
  **hidden**: the address answers a redirect, so nobody stands on it (a hidden brand, line or
  place 302s to `hidden_forward`; an alias or retired slug and a merged variant 301); the status
  names the target. Two facts ride beside the word: `in_sitemap`, and `site_open` (while
  `SITE_ACCESS=members` every response carries `X-Robots-Tag: noindex, nofollow`; the badge says
  "members-only: nothing indexes until the flip" ahead of the word). The `SEO.md` section states
  the four words and their tests only: no stream id, no "after", no launch stamp ("noindex: every
  generated page until a person approves it, and `/articles` until it is named in
  `INDEXED_PAGES`"); its members-only sentence describes the mechanism, not the current setting.
- **The resolver** `publish.status_of_path(db, path) -> PageStatus` (`path, status, reason,
  robots, in_sitemap, forwards_to, canonical`), pure over rows and the declared tables:
  `/brands/<slug>` via `find` (alias followed); `/products/<slug>` via `urls.parse_product_slug`,
  then the line by slug or the variant id; `/airports/<slug>[/<cat>]` via
  `urls.parse_airport_slug` and `place_by_identifier`; family and category paths via
  `is_indexed_path`; `/articles/<slug>` via `editorial.article_by_slug` (a draft is `missing`,
  like an unknown route); the rest via `STATIC_HEADS` and `is_known_route`. No head builder is
  called; nothing is written.
- **The read** `GET /api/pages/status?path=` in a new `routers/pages.py` (`PageStatusOut`; path
  at most 400 chars, must start with `/`), classified in `access.PERMISSION` under a new
  permission `pages.status`: the seventh in `accounts.DFP_PERMISSIONS`, added to
  `SEED_LEVELS["admin"]`; `capabilities_for` gains `pages_status`, `DfpCapabilities` and
  `NO_CAPABILITIES` mirror it. A host whose `admin` level was seeded earlier gets the grant from
  `app.cli backfill level_permissions [--check]`: idempotent; adds `pages.status` to `admin` only
  when its permissions still equal the previous seed exactly, else prints "edited by hand: grant
  it on /admin" (a human value is never overwritten by a machine).
- **The badge** `components/PageStatusBadge.tsx` + `.css` (imported in the component), mounted
  once in `App.tsx` beside `ViewAsBanner`. Null unless `caps.pages_status`; the query `enabled`
  only then, keyed on `location.pathname`. A fixed corner chip drawn with the one `Badge`
  component (`components/Badge.tsx`; add a `status` tone in `Badge.css` and its pair to
  `test_tokens_contrast.PAIRS`, never a second badge, per AW7's brief): the word, the reason on
  hover, the sitemap fact, the members-only note, a `/review` link for the owner. No `seo.py`
  builder and no `header_html` ever emits it.
- **Tests** (`tests/test_page_status.py`): the mapping per kind, and a consistency pin: for an
  article, a line, a brand, a place, a category and three static paths, `status_of_path` agrees
  with the head builder's `noindex` / `follow` and with membership of `sitemap_entries`; a hidden
  brand names its target; a draft and an unknown route are `missing`; the backfill's three cases.
  `test_access.py`: anonymous 401 in both modes, a `member`-level account 403, the owner and an
  `admin` account 200; `open_routes.json` unchanged. A grep test: no rendered body contains
  `page-status`. Docs: `SEO.md`, `ACCOUNTS.md` (seven permissions, the backfill), CHANGELOG.

### AW5.2 Ten sample articles, the sample and tags columns, the import commands
- **Migration**, schema only, `down_revision` = the single head `alembic heads` prints in the
  worktree at run time (AW4's `aw4a1b2c3d4e`); `alembic heads` must print one head before the
  commit; rehearsed up, down, up on the copy:
  `articles.sample BOOLEAN NOT NULL DEFAULT false`, `articles.tags JSONB NOT NULL DEFAULT '[]'`,
  `articles.hero_credit VARCHAR(200) NULL` (the attribution a hero needs). `Article.sample`,
  `tags`, `hero_credit`; `ArticleSummary.sample`, `tags`, `hero_credit`; `--hero-credit` on
  import; the SPA and `article_body` print it as a `<figcaption>` under the hero when set.
- **CLI**: `articles import --sample` and `--tag <word>` (repeatable; lowercase, the `_INTEREST`
  shape from `subscribers.py`, at most eight); `articles list` prints `sample`; `articles list
  --samples` filters; `articles unpublish --samples` takes every sample row to draft in one command
  (the launch plan's step). A re-import keeps `sample` as the command sets it.
- **The ten pieces** as Markdown under `import/articles/samples/`, `# Title` first line, in the
  Professor's voice (`is-it-really-a-saving.md` is the register). Topics a traveller searches for:
  how airport pricing works; allowances and why they differ by destination; reading a whisky age
  statement; is perfume a bargain at the airport; the best-value bottle categories; the shape of
  an airport shopping guide (one airport, its sections, written generically); litre bottles and
  travel exclusives; reading a shelf price (loyalty tiers, destination pricing, currency); what a
  competition medal tells you; a pre-flight checklist. Factual where a fact is checkable from
  general knowledge, generic where it is not, **never a price as a fact**: a price sentence links
  to the live comparison (`/savings`, `/alcohol/<category>`, a `/products/<slug>` only if it
  exists on the copy). No em dash, never "cheap" or "free", never a bare "product". Each has a
  standfirst and one to three tags; `category` is the kicker ("Duty free basics", "Buying
  guides", "Airport guides"), or a taxonomy name (`Whisky`, `Perfume`) when the piece is about one.
- **Hero images**: read AW3's handoff entry first. AW3 serves Adam's approved pictures from
  `/uploads/images/<level>/<id>-<sha8>.webp` (`services/imagery.py resolve()`, with a credit
  line). Read the addresses from the tracked manifest `import/images/adam-2026-09-19.json`
  (`chosen: true`, `level` of `line` or `brand`, its `url`), never from the restored copy (the
  copy predates the import). Where a piece is about that line or brand, use the address as
  `--hero-image` with its credit as `--hero-credit`; otherwise no hero (the card draws its
  stand-in). Nothing is fetched; no file is committed.
- **The commands** in `import/articles/samples/README.md` and the handoff: ten
  `docker exec dutyfreeprofessor-app python -m app.cli articles import
  /srv/import/articles/samples/<file>.md --sample --category "<kicker>" --standfirst "<line>"
  --tag <t> [--hero-image <addr>]` lines, one `articles publish <ten slugs>`, and the launch step
  `articles unpublish --samples`. The orchestrator runs them on staging after D4.
- **Tests**: `test_articles.py` (`--sample` survives a re-import; `unpublish --samples` touches only
  samples; tags cleaned and capped; a public read carries both); `test_house_style.py` walks the
  samples folder (no em dash, "cheap", "free" or bare "product" outside a code span).

### AW5.3 The article centre: `/articles` server-rendered, the grid, tags, related lines
- **The page** `ArticlesPage.tsx`: the newest piece leads large; the grid shows picture, kicker,
  title, standfirst, reading time and tags as chips (`?tag=` filters; `GET /api/articles` gains
  `tag`); a sample carries a visible mark on its card and at the top of its page ("Sample article:
  a draft the Professor may replace"), never hidden from the client on staging.
- **Related product lines** on the piece, a block after the body: with `brand_id`, the brand page
  link and up to four of its lines from `GET /api/products?brand=` (fetched after mount,
  `ProductCard`); when `category` is a taxonomy category, the category page link
  (`urls.category_path` / `categoryPath`). `ArticleSummary` gains `brand_slug`, `brand_name`,
  `category_path` (None when the kicker is not a category). Omitted from the server body, like
  "More from the Professor".
- **Server-rendered** `GET /articles` as a page route ahead of the catch-all (pattern `/data`,
  `_PAGE_ROUTES`): `seo.articles_body(page)` class for class with the SPA (lead, grid, pager,
  marks, tags), seeded as `window.__DFP_ARTICLES__` for page 1 (`useArticles` reads it as
  `useArticle` reads the piece). Head: `CollectionPage` with an `ItemList` of `Article` entries
  (headline, url, datePublished, image when present) + `BreadcrumbList`; `?page=` and `?tag=`
  canonicalise to `/articles`.
- **Noindex until approved**: `/articles` leaves `STATIC_HEADS` and `sitemap_entries`'
  `static_paths`; its head reads `publish.is_indexed_path("/articles")`, `noindex, follow` until
  `INDEXED_PAGES` names it, and it joins the sitemap only then. The pieces still index on publish.
  Update `test_sitemap_lists_the_index_and_every_published_article` and `TestRouteInventory` to
  the new rule; do not weaken them. Access: `GET /articles` joins `PUBLIC_WHEN_OPEN`, `HTML_KEYS`
  and `open_routes.json` (a reviewed diff). The `LatestArticles` rail stays on the home page and
  in the mega menu; the centre does not draw it.
- **Tests**: `test_seo_article.py` (the body class for class; the mark present for a sample and
  absent otherwise; the `ItemList` of `Article`; `noindex, follow` by default and indexable once
  named; the tag filter; the seed); `test_site_routes.py` (`/articles` 200 with the body, `?page=2`
  canonical); `test_articles.py` (the related fields, the `tag` query). Docs: `SEO.md`, CHANGELOG.

### AW5.4 Email capture: verified end to end on the copy, the export procedure
- Against the copy: `POST /api/subscribers` (with Origin) writes a row; the same address again
  answers the same 200 and one row; `subscribers stats` counts; `subscribers export --out <file>`
  is mode 600 with `EXPORT_COLUMNS`; `subscribers unsubscribe <address>` stamps
  `unsubscribed_at` and the default export drops the row. Numbers in the handoff, never an address.
- Confirm by reading: no mailer touches subscriptions (`services/mail.py` provider `none`;
  nothing under `subscribers` imports it); no route reads the list; the form's checkbox prints
  `CONSENT_TEXT`, and a test pins that the sentence the form sends is the one it shows.
- `docs/RUNBOOK.md` Content: the procedure as rian runs it: `subscribers stats` first, then
  `docker exec ... subscribers export > ~/subscribers-<date>.csv && chmod 600 ...` (never `--out`
  inside the container, never `backups/` or `uploads/`), the file to Adam by a channel outside
  chat, never pasted, deleted after sending. Fix only what is broken; a bug becomes a test.

### AW5.5 Rehearsal and hand-back
- On the copy: migration up, down, up; the ten imported and published; `/articles` and one piece
  render with the marks; the badge shows to the owner and to an `admin`-level account, never to a
  `member`; an anonymous status request is refused; the served HTML of `/articles`, a piece, a
  brand page and `/` contains no `page-status`.
- `main/check.sh` prints "checks passed"; CHANGELOG `## Unreleased` lines for the three visible
  things; the handoff (25 lines or fewer): the migration, the after-deploy backfill, the import
  commands, the launch step, the numbers, what rian must decide.

## Working assumptions (rian's to overturn; the cost is stated)
- **The badge shows to the owner and the `admin` level** through the permission `pages.status`.
  *To overturn:* untick it on `/admin` Levels, one click; or one line in `SEED_LEVELS`.
- **Samples are published on staging and flagged**; the launch plan runs `articles unpublish
  --samples` before production's database is replaced. *To overturn:* skip the step; they go live.
- **No hero unless AW3 published an approved image at a public address.** *To overturn:* name
  images; each is one `--hero-image` on a re-import.
- **`/articles` is noindex until approved**, unlike the other hubs. *To overturn:* add
  `"/articles"` to `INDEXED_PAGES`, one line.
- **Tags exist as a column now**, empty by default. *To overturn:* leave it empty; nothing draws it.

## Questions for rian
- Is `admin` Adam's level, so Adam sees the badge on staging? Assumption: yes, and he may. Not critical.
- May `/articles` stay noindex until you approve the address, though the pieces index on publish?
  Assumption: yes. Not critical (staging is members-only; production is untouched).
- Byline on the samples: the house byline or an author account? Assumption: the house byline. Not critical.

## Owns
`app/services/publish.py` (`status_of_path` and its dataclass only); new `app/routers/pages.py`;
`services/access.py` (two keys, `HTML_KEYS`); `services/accounts.py` (the permission, the seed,
`capabilities_for`); `app/cli.py` (`backfill_level_permissions` and its `BACKFILLS` line);
`services/editorial.py`, `models/editorial.py`, `cli_editorial.py`, `routers/articles.py`;
`services/seo.py` (the articles section, the `/articles` static head, `_PAGE_ROUTES`,
`sitemap_entries`' static list); `app/main.py` (the `/articles` route); the migration (`down_revision` = the one head `alembic
heads` prints; one head after it);
`import/articles/samples/*`; `web/src/components/PageStatusBadge.*`, `ArticleCard.*`,
`pages/ArticlesPage.*`, `pages/ArticlePage.*`, `lib/auth.tsx` (the field), `api/editorial.ts`,
`App.tsx` (one mount); `tests/test_page_status.py` and the tests named above;
`tests/fixtures/open_routes.json` (one key); `docs/SEO.md`, `docs/ACCOUNTS.md` (permissions),
`docs/RUNBOOK.md` (Content); `main/CHANGELOG.md` Unreleased.

## Must not touch
Staging's and production's databases; `.app.env`, `.env`, `.bw-auth.env`; `app/vendor/*`,
`web/src/vendor/*`; `services/subscribers.py` and `routers/subscribers.py` unless AW5.4 finds a
bug (then the fix and its test only); collectors, ingest, identity, the review, `decisions/*`;
`header_html` and every non-article body in `seo.py`; `SiteHeader.tsx`, `SiteFooter.tsx`,
`HomePage.tsx`, `MegaMenu.tsx`; AW1 to AW4's files (`collectors/*`, `images.py`, `featured.py`,
`CollectorsPage.tsx`); the main checkout's dirty paths (`.logs/runs/*`, `notes/*`);
`main/check.sh`, `docs-check.sh`; `import/progress.json` by hand (`plan-set.py` only).

## Rules that bite here
- **A GET must never write**: the status read and every article read resolve rows and return.
- **The badge is never in the crawler body**: no `seo.py` builder, no `header_html`, no explainer
  file emits it; the SPA draws it after `/api/bw/me` answers, only on `pages_status`.
- **Every new route is in exactly one class** of `access.py`; opening one is a diff to
  `open_routes.json`. **A catch-all makes a missing route look like a working one**: `/articles`
  gets its own route; `is_known_route` and `App.tsx` stay in step.
- **One word per concept** (`VOCABULARY.md`): product line, product variant, brand; never bare
  "product" in copy, code names or the ten pieces.
- **Client-facing copy**: no em dash, never "cheap" or "free", nothing implying the code is the
  client's (`test_house_style.py`). **A price is an observation, never a fact.**
- **Migrations schema-only with server defaults; every backfill idempotent with `--check`**;
  rehearse up, down, up; a green suite is not the database's acceptance.
- **Import a component's stylesheet in the component**; verify a new page by a string only it
  contains (the sample mark, the badge word). **A bug that shipped becomes a test.**
- **Decisions only rian can make** go on the running list with `--assumption` and `--blocks
  AW5.<n>`; carry on. **Plan-gate**: AW5.1 and AW5.3 are multi-file; a short plan in the handoff first.

## Out of scope, noted for a later session
- `/feature/<slug>` teasers and `/trip` carry no robots directive yet are out of the sitemap; the
  badge will say indexable with `in_sitemap` false. Decide later whether teasers are `noindex`.
- Explainer `.html` pages are files served by rule: the read answers `unlisted`, no badge is drawn.
- `articles.category` doubles as kicker and category link; a `section` column would be cleaner
  once Adam's vocabulary is known. Samples appear in `/feed.xml` and `/llms.txt` on staging until
  the launch step. Related lines are fetched by brand name; a `brand_id` join is cheaper later.
