---
type: plan
---

# The launch: mosiah to live, and the two environments after it

<!-- Role: the one document rian executes top to bottom to move the site from staging (mosiah) to
     live for the soft launch of Monday 21 Sep 2026, and the standing description of how the two
     environments live afterwards. Written by Stream AW6 of the away run of 19 Sep
     (`streams/AWAY-2026-09-19.md` section 3), on the branch, with nothing deployed and production
     never contacted. Part one is a script with a clock; part two is the steady state it leaves
     behind. Retire part one once the launch is done and the second pass (line 22) has run; part
     two then moves into `main/docs/RUNBOOK.md` if it is still true. -->

Written 19 Sep 2026 from the tree at `6a1a989`. Every command below either exists in this tree or
is named as **rian's task** (a line only he can write, marked so). **Nothing in part one has been
run.** The rehearsal that proved the database half is
`.logs/runs/rehearsal-2026-09-17-k7-replace.md`; the memory evidence behind part two's collector
arithmetic is `.logs/runs/window-2026-09-19b-sweep.md` and `sweep-2026-09-19b/memory-trace.tsv`.

## How to read this

- **One home per mechanism.** `main/docs/RUNBOOK.md` "Launch checklist (staging to production)"
  holds the 23 numbered lines. This document puts them in rian's order with a clock and says only
  what differs; where a line is identical it is cited (`RUNBOOK 23`) and not copied. Read the
  RUNBOOK line beside this one; do not read one without the other.
- **The clock.** `T0` is the freeze. A line marked `+m:ss` carries a **measured** elapsed time and
  says where the measurement came from. A line marked **gate** ends when its check passes and not
  before, so it has no duration. A line marked `≈` is an **allowance, not a measurement**, and says
  what it is based on.
- **Stop at the first line that does not print what it says.** That is the RUNBOOK's rule and it is
  this document's rule. The rollback is at the end of part one.
- **Every line that touches the droplet is rian's**, in his own shell. No session runs
  `deploy/production.sh` against `bwlive`.

## The shape of the launch, in one paragraph

Staging's whole database becomes production's, once. Before that, staging must be the catalogue we
want to be seen (the review passes settled, the last sweep verified, the sample articles taken back
to draft) and production must be safe to overwrite (its own dump taken first, by the script). After
that, production is the record: pushes to it carry code and migrations and never data again, the
collectors run there, and staging is refreshed downward from production's nightly. Two environment
lines in the droplet's `.app.env` do the rest: `SITE_ROLE=live` hides the development surfaces, and
`SITE_ACCESS=public` opens the storefront. The first is new (Stream AW6.2); the second is the
go-live flip that has been waiting since the accounts work.

---

# Part one: the launch, in order

Planned for **Sunday 20 September, evening PDT**, with `T0` at a time rian picks. The gates below
come first and have no clock: they are conditions, and if one is not met the launch moves rather
than proceeds around it.

## Gates (before T0; no clock, each ends when it passes)

| # | Gate | Command | What it must print | RUNBOOK |
|---|---|---|---|---|
| G1 | The review passes are complete | `docker exec dutyfreeprofessor-app python -m app.cli pass status` | the done line: `done: 0 open, 0 deferred, 0 wants, 0 parked; <n> brands settled` (exit 0; it exits 3 until then) | 3 |
| G2 | No decision has drifted from its column | `docker exec dutyfreeprofessor-app python -m app.cli decisions verify` | `decisions verify: <n> effective decision(s) checked, 0 drift` | 3 |
| G3 | The last staging collection is finished and verified | `docker exec dutyfreeprofessor-app python -m app.cli sweep plan`, then the newest `.logs/runs/window-*.md` says finished, then `docker exec dutyfreeprofessor-app python -m app.cli verify-status` | `sweep plan` lists the sources it would reach and the refused ones it never contacts; `verify-status` shows no uncleared `MISMATCH` or `PARSE_FAIL` | 2 |
| G4 | The branch is merged and green | `git status` clean on `master`; `bash main/check.sh` | `checks passed` (measured 19 Sep in the worktree: 2,496 tests, `docs-check: 0 fail`, 1 m 38 s) | 5 |
| G5 | The version is named | `main/app/version.py` and `main/CHANGELOG.md`'s first versioned heading agree, and `brief.md`'s NOW block says the same | `bash main/docs-check.sh` prints `0 fail` (it is the check that fails when they disagree) | 5 |
| G6 | Indexing is decided | the product line, brand and airport pages rian approved read `indexed`; everything else is `noindex, follow` | Stream K6's index candidates; nothing unapproved is in the sitemap | 4 |
| G7 | Who stays on live is decided | the accounts question below (line 8) is answered before T0, not during the launch | - | - |

**If G3 forces a sweep:** budget an evening, not an hour. Measured 18 to 19 Sep: the nine
`*.shopdutyfree.com` hosts publish `Crawl-delay: 60` and LHR publishes 30, so one Avolta airport is
about 2 h 20 m of obedient waiting and the ten in sequence are roughly 27 hours. Eleven collectors
in parallel, one per host, finished in an evening (11 sources ok, 10,266 prices, peak 1,590 MiB).
Parallel does not speed up any host; it just stops one host's delay from blocking the others.

## The launch, from T0

| # | T | Line | Command | What it must print | RUNBOOK |
|---|---|---|---|---|---|
| 1 | **T0** | **Freeze collections on both machines** | `pgrep -af 'app.cli (collect\|verify\|hours)'` here, and `ssh deploy@bwlive "pgrep -af 'app.cli (collect\|verify\|hours)'"`; open `.logs/runs/window-2026-09-20-launch.md` saying collections are frozen until line 20 | both print nothing | 1 |
| 2 | +0:05 ≈ | The deploy freeze marker, so nothing starts under you | `printf 'launch %s by %s\n' "$(date -u +%FT%TZ)" "$USER" > uploads/.collect-freeze` | nothing; from here a Start from `/collectors#live` answers `COLLECT_FROZEN` and `app.cli collect` exits 3 (RUNBOOK Deploy, step 1) | Deploy 1 |
| 3 | gate | **Sample articles excluded, before the dump** | `docker exec dutyfreeprofessor-app python -m app.cli articles unpublish --samples` | one `<slug>: draft` line per sample (ten of them, AW5.2) | - |
| 4 | +0:10 ≈ | The samples are really out | `docker exec dutyfreeprofessor-app python -m app.cli articles list --samples` | every sample row reads `draft`; none published. **This is the last moment it can be done**: after line 9 the samples would be on production | - |
| 5 | +2:00 ≈ | Staging's own rollback dump | `docker exec dutyfreeprofessor-db pg_dump -U dfp -Fc dfp > backups/dfp-$(date +%F)-pre-launch.dump` | a file with a non-zero size (≈25 MB at the rehearsal's shape) | 6 |
| 6 | ≈10 min | Staging on the launch code | `srv-gw deploy --project dutyfreeprofessor --build`, then `curl -s https://dutyfreeprofessor.demoing.info/api/health` | `{"status":"ok","version":"<launch version>","database":"ok","role":"staging"}` (`role` is `staging` here and always is on mosiah; the environment line at 17 is what makes it `live`, and only on the droplet). *Allowance, not a measurement: no staging build in this tree is timed.* | 7 |
| 7 | gate | The after-deploy blocks the branch carries | the blocks each stream's handoff names, in order, then `docker exec dutyfreeprofessor-app alembic current` | `alembic current` prints the code's head, once. **The away run's four deploys each carry their own** (D1 to D4); read the top entries of `.logs/handoff.md` and run what has not run on staging yet | 8 |
| 8 | gate | **Accounts, decided before the dump** | `docker exec dutyfreeprofessor-app python -m app.cli accounts list` | the list printed and read. The assumption this plan proceeds under: **rian is owner by `ACCOUNT_OWNER` (code, not a row), `adam` and `mark` stay at `admin`, every other row is disabled** (`python -m app.cli accounts disable --username <u>`, one per row) before the dump, so the disabled state travels with the database. Overturn with one `accounts enable --username <u>` per row after the launch | - |
| 9 | +0:05 (measured) | **The staging dump with its sidecars** | `deploy/launch-dump.sh` | the dump path and size, `tables: <N>, rows <M>`, `head: <head>` (equal to line 7's), `sources: <n> (<k> enabled)`. Measured at the rehearsal: 25 MB, 54 tables, 130,290 rows, 5.0 s; it refuses if a row changes while it runs | 9 |
| 10 | +0:01 (measured) | The dry run | `deploy/production.sh --replace-db backups/launch/staging-<stamp>.dump --dry-run` | the three heads equal, every command printed, ending `dry run: nothing ran`. Measured 0.4 s | 10 |
| 11 | ≈10 min | **The replace: production's database becomes staging's** | `deploy/production.sh --replace-db backups/launch/staging-<stamp>.dump` | in order: three heads equal; `backups/production/pre-replace-<stamp>.dump` arrived here; build; `stop app`; the restore; `alembic_version <head>`. *The database half measured 13.3 s and 15.1 s at the rehearsal; the rsync, build, stop and start on the droplet are not measured anywhere in this tree, hence the allowance.* **This is the last time the database moves in this direction** (part two) | 11 |
| 12 | (inside 11) | The counts | - | `<N> tables, <M> rows: equal`, the same N and M line 9 printed. A difference refuses and prints the rollback; the rehearsal proved that path by dropping one table (8 rows) from a copy | 12 |
| 13 | (inside 11) | Sessions | - | `revoked <n> session(s)` and `expired <k> unused welcome or reset link(s)`; nobody is signed in on production, rian included | 13 |
| 14 | (inside 11) | Sources confirmed | - | the two columns match line for line (slug, enabled, delay, identity mode); type `yes`. A difference refuses | 14 |
| 15 | (inside 11) | Health | - | `{"status":"ok","version":"<launch version>","database":"ok","role":"staging"}` and `done: production holds staging's database`. `staging` is the correct answer on production at this step, because the environment line is not set until 17. Then `srv-gw security-audit` shows nothing new | 15 |
| 16 | ≈5 min | Sign in on production as rian | `https://dutyfreeprofessor.com/login` in a browser; open a product line page, an airport page and `/review` | all three open. Still members-only at this point: that is correct | 16 |
| 17 | ≈3 min | **The two environment lines, in rian's own shell on the droplet** | in `.app.env` on `bwlive`: `SITE_ROLE=live`, then `SITE_ACCESS=public`; then `ssh deploy@bwlive "cd /srv/apps/dutyfreeprofessor && docker compose -f docker-compose.yml -f docker-compose.production.yml up -d app"` | `curl -s https://dutyfreeprofessor.com/api/health` prints `{"status":"ok","version":"<launch version>","database":"ok","role":"live"}`. **Both lines or neither**: `SITE_ROLE` unset reads as `staging` and the development surfaces stay visible on the public site. Never edited by a session; never printed | 17 |
| 18 | ≈5 min | **The unauthenticated sweep, signed out** | if `git log --oneline \| grep 'AW7:'` shows the script landed: `main/scripts/route-sweep.py https://dutyfreeprofessor.com --open`; otherwise the curls in the table below | no owner or member route answers 200 | 18 |
| 19 | ≈3 min | Sitemap and IndexNow | `curl -s https://dutyfreeprofessor.com/sitemap.xml \| grep -c '/products/'` against `ssh deploy@bwlive "docker exec dutyfreeprofessor-db psql -U dfp -d dfp -tAc 'select count(*) from product_lines where indexed and not hidden'"`; then `curl -s -o /dev/null -w '%{http_code}\n' https://dutyfreeprofessor.com/<key>.txt` | the two counts equal, and `200` for the key. **The key must be minted first**: running-list item `do-mint-indexnow-key-in-app-env-before-the-gate-comes-off-p`, any alphanumeric string of 8 to 128 characters (`python3 -c "import secrets; print(secrets.token_hex(16))"`), set in the droplet's `.app.env` at line 17 and never before, because IndexNow must not run while the site is closed | 19, 20 |
| 20 | ≈2 min | Collections may resume, **on production only** | close `.logs/runs/window-2026-09-20-launch.md` with the time; `rm -f uploads/.collect-freeze` on mosiah | the marker is gone (one left behind keeps refusing starts and the page calls it stale after six hours) | 21 |
| 21 | ≈20 min | **The second pass** | read lines 11 to 19 again against what the terminal actually printed, then open the home page, a product line page and an airport page from a phone on mobile data, signed out | what you read matches what you remember running. This is the step that catches a line nobody read the output of | 22 |
| 22 | ≈10 min | Tell Adam | the points below | - | 23 |

### Line 18 by hand, if the sweep script has not landed

Signed out, against `https://dutyfreeprofessor.com`, with `curl -s -o /dev/null -w '%{http_code}\n'`:

| Address | What it must answer, on live | Why |
|---|---|---|
| `/api/review/sheets` | `401` | owner-only JSON |
| `/api/collectors` | `401` | `sources.manage` JSON |
| `/api/sources` | `401` | `sources.manage` JSON |
| `/api/whoami` | `401` | any signed-in account |
| `/api/pages/status?path=/` | `401` | `pages.status` |
| `/api/plan` | `404` | a development route on live (AW6.2), not a sign-in prompt |
| `/api/items` | `404` | as above |
| `/api/discussion` | `404` | as above |
| `/plan` | `404` | a development page on live; the not-found shell, and the status is what is checked |
| `/issues`, `/discuss`, `/settings`, `/images` | `404` | as above |
| `/account` | `302` to `/login?next=/account` | a member page, signed out |
| `/` , `/products`, `/airports` | `200` | the storefront, now public |
| `/sitemap.xml`, `/robots.txt`, `/feed.xml` | `200` | the flip opened them |

**Without AW6.2** (if it does not land before the launch) `/api/health` carries no `role` field,
`SITE_ROLE` is not read at all, the `404` rows above read `302` to `/login` instead, and the
development pages are reachable to a signed-in client account on the public site. That is the whole cost of the environment line; it is not a security hole (the classes
still gate them), it is the public seeing surfaces built for the build.

### The points for Adam (line 22)

House style: no em dashes, and never the words "cheap" or "free" in anything a reader sees.

- The site is live at `https://dutyfreeprofessor.com`, open to anyone, no sign-in.
- What is in scope: the nineteen airports, the product line pages, the brand pages, the airport
  pages, the article centre and the dataset page.
- Every page stays out of search until it is approved one at a time, so the index grows on purpose
  rather than all at once. That is why a page can be live and still say `noindex`.
- The ten sample articles he read on staging are **not** on the live site; they were written to show
  the article centre working and they went back to draft before the launch.
- Where to comment: the build discussion stays on the staging address he already uses, and it is
  unchanged by the launch.
- What is not in this launch: the two further categories at around twenty product lines each (A11)
  and brand pass two (C5), both waiting on his word about launch scope.

### Rollback (any line from 11 on)

The replace prints the rollback command when it refuses. By hand, on the droplet in
`/srv/apps/dutyfreeprofessor`, exactly as **RUNBOOK "Launch checklist", Rollback**: drop and
recreate the `public` schema, `pg_restore --exit-on-error` from `backups/pre-replace-<stamp>.dump`
(the droplet's own copy; `backups/production/` holds the same file on mosiah), then check out the
previous tag and run `deploy/production.sh`. Measured at the rehearsal: 6.8 s for the database half.

Two lines this plan adds to that paragraph:

- **`SITE_ACCESS=members` again** if line 17 ran (it is in the RUNBOOK's rollback already).
- **`SITE_ROLE`**: leave it `live` if you are rolling back the database only and keeping the launch
  code. Set it to `staging` (or delete the line) only if you redeploy pre-launch code and want the
  development surfaces back on the droplet to diagnose. The default when the line is absent is
  `staging`, so a rollback that wipes `.app.env` shows more, not less: check it rather than assume.

---

# Part two: the two environments, after the launch

From line 20 onward there are two machines with two jobs. This is the steady state; it is written
here because nothing else in the tree says it in one place.

## Pushes carry code and migrations, never data again

- **A push to live is `deploy/production.sh` plain**: it dumps production first, rsyncs `main/`,
  `import/`, `public/`, `uploads/` and the compose files, builds, recreates and waits for
  `/api/health`. It never copies `.app.env` or `data/`. Add `--caddy` only when `deploy/caddy/`
  changed.
- **Migrations ride the deploy** and are applied by the container's `alembic upgrade head` on start.
  They are schema-only with server defaults, which is what makes this safe (`agents.md`).
- **Backfills do not ride the deploy.** Every backfill is an idempotent `app.cli` command with a
  `--check` form, and after a push to live rian runs the ones the release names, by hand, on the
  droplet. The RUNBOOK's Production chain is **not** this: that chain is only for the one case where
  production is not a copy of staging (the rollback, or a code deploy onto the pre-launch schema).
- **`--replace-db` does not run a second time.** A finished replace writes
  `backups/production/LAUNCHED` here (`launched <stamp> from <dump>`) and a copy in the droplet's
  `backups/`; after that `deploy/production.sh --replace-db` refuses before step 1 and before any
  ssh, naming the file and `--force-replace-db`, whose message says what a second replace discards:
  every collection and every decision taken on live since the launch. `--dry-run` reports the marker
  the same way and writes nothing. A `--local` rehearsal on `dfp-devdb` is never blocked, because it
  replaces a scratch database here and not live. The marker is the mechanism; the rule is rian's:
  *a push to live never syncs collection data again.*

## Collectors on live: the arithmetic, and what has to change first

**Measured, on mosiah, 18 to 19 Sep** (`window-2026-09-19b-sweep.md`, `memory-trace.tsv`):

- One collector settles at **140 to 160 MB and keeps growing** as it accumulates. The 73 MB figure
  taken seconds after launch on 18 Sep was wrong, and acting on it cost two OOM kills.
- **Eleven collectors sat at 1,079 MiB** one minute in, and the sweep **peaked at 1,590 MiB**.
- Eight collectors under a 1 GB cgroup exhausted it in six minutes; the kernel killed two mid-run
  and left four runs `running` with prices committed. The cap is 4 GB on mosiah now.

**The droplet is 4 GB total and 2 vCPU**, with Postgres capped at 512 MiB and the browser sidecar at
2 GB while a rendered source runs. A sweep of mosiah's shape does not fit there. So:

1. **`app` has no effective cap on the droplet today.** `docker-compose.yml` sets `mem_limit: 4g` for
   `app` and `docker-compose.production.yml` does not override it, so the live app container is
   allowed the entire machine. **Rian's line** (this stream may not edit either compose file): add
   `mem_limit: 2g` for `app` in `docker-compose.production.yml`. That leaves 4,096 − 2,048 − 512 =
   1,536 MiB for the kernel, dockerd, Caddy and page cache.
2. **Admit three or four hosts at a time, not eleven.** Copy `admit-queue.sh` and `guard.sh` from
   `.logs/runs/sweep-2026-09-19b/` to the droplet with two numbers changed: `CEILING_MIB=700` (the
   queue admits a collector only while the container total is below it, so with uvicorn near 200 MiB
   and 150 MiB a collector the fourth is admitted and the fifth waits) and `SHED_MIB=1700` (the guard
   sheds the heaviest collector before the 2 GB cgroup fires). Both scripts already do exactly this;
   only the two constants move.
3. **A full sweep on live is an overnight in waves, not an evening.** Ten Avolta airports at four
   at a time, each wave paced by its slowest host's own `Crawl-delay: 60` (about 2 h 20 m for an
   Avolta airport, measured), is three waves and roughly seven hours, before the non-Avolta sources.
4. **Never run a rendered source while a sweep is running on live.** The sidecar's 2 GB and the
   app's 2 GB do not both fit in 4 GB beside Postgres.
5. **No cron.** Collection stays hand-run (`main/docs/COLLECTORS.md`); it is started from
   `/collectors#live` (the control room Stream AW4 landed: Start with a mode and a limit, Pause,
   Resume, Stop) or by the queue script by hand. Nothing schedules a crawl.
6. **Nothing on mosiah writes the catalogue after the launch.** Decided by rian 17 Sep
   (`decide-after-the-launch-which-machine-collects-production-only`): production collects, and the
   catalogue flows live to staging only. The rendered-fetch sidecar exists on both machines; that is
   fine, because it fetches nothing on its own.

**Still open, and it is rian's** (`decide-where-do-the-crawls-run-once-the-site-is-live-this-host`,
weight `costly`): size the live host for crawling, keep crawling on mosiah and publish results to
production, or give the crawls a box of their own. The plan above is the "make it fit on the
droplet" answer and it fits at three or four hosts at a time. If he picks a different option, only
this section is rewritten; nothing else in the launch depends on it.

## Discussions: development on staging, review on live

- **Development threads stay on staging.** The `/discuss` tabs, the quote, the structure proposal
  and the to-do list are the build conversation, and on live they answer 404 (AW6.2). Adam keeps
  using the staging address he already uses.
- **Review threads happen on live**, because the side panel's threads, notifications and attachments
  stay live features: a comment about a real page belongs where the page is.
- **A refresh pulls production down to staging**, never the other way:
  `python3 main/scripts/staging-refresh.py --from backups/production/<nightly>.dump --check`, then
  without `--check`. What it does to each side (RUNBOOK "Staging refresh"): staging's
  `CLIENT_WRITTEN_TABLES` rows are exported first and put back by natural key, so the development
  conversation survives; live's threads arrive with the dump; staging's decisions ledger is exported
  and replayed after `alembic upgrade head`.
- **The consequence to hold on to:** after the launch, a catalogue decision taken on staging is
  replayed onto a database that came from production, which is a decision applied to rows it was not
  taken on. So **take no catalogue decision on staging after the launch**, or refresh with
  `--discard-decisions` and mean it.
- **A live review comment and a staging development comment cannot be told apart by data.**
  `threads` and `discussion_comments` carry no origin column, and `Decision.origin_host` is
  `settings.app_env`, which is `production` on both hosts. Telling them apart needs a schema-only
  column plus an idempotent backfill. **Rian's task, after the launch**, not before: nothing breaks
  without it, and the filter is cosmetic until both sides are busy.

## Pages by environment

**The app knows which environment it is by `SITE_ROLE`, never by a branch.** One line in the
droplet's `.app.env`; unset reads as `staging`. `SITE_ROLE` is not the public gate: `SITE_ACCESS`
still decides what an anonymous visitor may read, and a development surface is hidden from a
*signed-in* client account on live too. AW6.2 lands the two sets in `services/access.py`, and
`tests/test_route_inventory.py` fails if a route is in neither, so a new owner route cannot ship
unplaced.

| Address | Kind | Staging | Live | Class today |
|---|---|---|---|---|
| `/` | storefront | yes | yes | public when open |
| `/products`, `/products/<line>` | storefront | yes | yes | public when open |
| `/savings`, `/trip`, `/exclusives`, `/awards` | storefront | yes | yes | public when open |
| `/airports`, `/airports/<slug>`, `/airports/<slug>/<category>` | storefront | yes | yes | public when open |
| `/brands/<slug>` | storefront | yes | yes | public when open |
| `/alcohol`, `/alcohol/<category>`, `/beauty`, `/beauty/<category>` | storefront | yes | yes | public when open |
| `/articles`, `/articles/<slug>` | storefront | yes | yes | public when open |
| `/data` | storefront | yes | **yes** | public when open (in the reviewed `open_routes.json`) |
| `/how-we-choose` | storefront | yes | yes | public when open |
| `/feature/<slug>` | storefront | yes | yes | public when open |
| `/sitemap.xml`, `/feed.xml`, `/llms.txt`, `/robots.txt` | machine | yes | yes | public when open / always |
| `/login`, `/forgot`, `/welcome`, `/reset` | sign-in | yes | yes | public always |
| `/account` | member | yes | yes | member |
| `/admin` | live feature | yes | **yes** | `accounts.view` |
| `/collectors` | live feature | yes | **yes** | `sources.manage` |
| `/sources` | live feature | yes | **yes** | `sources.manage` |
| `/review` | live feature | yes | **yes** | owner |
| `/discuss` | development | yes | **no (404)** | `client.view` |
| `/quote`, `/structure`, `/todo` | development | yes | **no (404)** | `client.view` (301s to `/discuss`) |
| `/settings` | development | yes | **no (404)** | `client.view` |
| `/images` | development | yes | **no (404)** | `client.view` |
| `/plan` | development | yes | **no (404)** | `plan.view` |
| `/issues` | development | yes | **no (404)** | `plan.view` |
| `/<page>.html` (the explainer pages) | development | yes | **no (404)** | `client.view` |
| `/docs-static/*` | development | yes | **no (404)** | `client.view` |
| `/api/plan`, `/api/items*` | development API | yes | **no (404)** | `plan.view` / `items.act` |
| `/api/quote/*`, `/api/todos*`, `/api/images/*` | development API | yes | **no (404)** | `client.*` |
| `/api/discussion`, `/api/discussion/{item_id}*`, `/api/discussion/feature-comments*`, `/api/discussion/feature-priorities` | development API | yes | **no (404)** | `client.*` |
| `/api/discussion/threads*`, `/api/discussion/comments*`, `/api/discussion/asks*`, `/api/discussion/people`, `/api/discussion/topics`, `/api/attachments/*` | live API (the side panel) | yes | **yes** | `client.*` |
| `/api/collectors/*`, `/api/sources*`, `/api/review/*`, `/api/pages/status`, `/api/ops/impersonations` | live API | yes | yes | `sources.manage` / owner / `pages.status` |
| `/api/notifications*`, `/api/whoami`, `/api/bw/*`, `/api/auth/*` | accounts | yes | yes | member / public always |
| `/api/health` | always | yes | yes | public always (`ok`, version and role only) |

**Working assumptions in that table**, each one sentence to overturn:

- `/data` stays live. *Overturned: add it to the development sets in `access.py`;
  `tests/fixtures/open_routes.json` does not change (no storefront route moves).*
- `/review`, `/collectors`, `/sources` and `/admin` are live features, because the catalogue is
  reviewed where the catalogue is. *Overturned: move the name between the two sets and re-run the
  inventory test.*
- The discussion API stays live so the side panel works there; only the development pages and their
  own reads answer 404. *Overturned: move the keys, and the panel on live must then be gated too or
  it errors.*
- A development page on live is a **real 404 status with the not-found shell**, answered before any
  sign-in redirect, so live never discloses that the address exists. Verify by status code, never by
  what the shell renders: a catch-all route makes a missing route look like a working one.

## Sample articles

The ten sample pieces are flagged in the `articles.sample` column (AW5.2), never in a list pasted
into a checklist. `articles unpublish --samples` reads the column, and it runs **before** the dump
at line 3, so they cannot ride the database replacement. On live they do not exist as published
rows; if one ever appears there, it came through a later push and the same command removes it.

## Backups and monitoring on live, as built

- **Nightly dump on the droplet at 03:15 UTC** into `backups/`, kept 14 days, from the deploy user's
  crontab; **pulled to mosiah at 04:15 local** into `backups/production/`. Both installed by
  `deploy/install-backup-cron.sh`. The pulled copy is what a staging refresh reads.
- **`deploy/uptime-check.sh` every five minutes** from mosiah: `/api/health` must answer 200 with
  `"status":"ok"` and `/login` must answer 200, from outside the droplet. Two consecutive failures
  send one email, and one more on recovery; the state file keeps it to one email per outage.
  Failures land in `.logs/runs/uptime-incidents.log`, every check in `.logs/runs/uptime.log`.
- **Restore is rehearsed on `dfp-devdb` first**, never against a live database
  (RUNBOOK "Backups and restore").
- `data/postgres/` is the running cluster and is not a backup.

## Gaps before Monday, and what happens without them

Stream W never ran; its six tasks are `todo`. None of these stops the launch, and each has a
by-hand fallback in this plan:

| Gap | Effect if it is still open on Monday | Fallback |
|---|---|---|
| **W1 legal pages** (privacy, terms) | a public site with no privacy page. `GET /{page}.html` is a development route on live under AW6.2, so a legal page added to `public/` would 404 there: it needs a public allow-list or its own prefix first | **rian's call, and the one question here that may be genuinely blocking.** Either accept the gap for the soft launch, or hold the flip at line 17 |
| **W4 route sweep script** | line 18 has no script | the curl table above, by hand |
| **W5 edge checklist** (trusted proxies, cache bypass, Bot Fight Mode off) | Cloudflare may cache or challenge something it should not | watch line 21's phone pass; fix reactively |
| **W3 app-only env file** | `MAIL_PROVIDER`, `MAIL_FROM` and `RESEND_API_KEY` cannot be set, because `.app.env` is read by the database container too | mail stays inert: invites and resets go by CLI link, which is how the launch's accounts are seeded anyway |
| **W2 lazy chunks gated** | the built JavaScript is public and carries the client pages' static copy | running-list item `decide-the-built-javascript-is-public-while-the-site-is-members`; AW6.2 hides the routes, not the chunks |

---

## What this document does not decide

Five questions are rian's. Each is on the running list with the assumption this plan proceeds
under, and each is repeated in Stream AW6's hand-back:

1. **Do the missing legal pages (W1) block Monday?** `decide-do-the-missing-legal-pages-w1-block-monday-s-soft-launch`,
   weight `blocking`. *Assumed: no; the soft launch goes ahead and the pages follow. If he says
   they block, only line 17 waits; everything before it still runs.*
2. **Are `/data` and `/review` live?** `decide-on-live-are-data-and-review-live-features-or-staging-onl`,
   blocks AW6.2. *Assumed: both live, as the table says. Reversing either is one name moved between
   two sets in `access.py` plus the inventory test.*
3. **Where do the crawls run?** `decide-where-do-the-crawls-run-once-the-site-is-live-this-host`
   (already open since 19 Sep, weight `costly`; not duplicated). *Assumed: on the droplet, three or
   four hosts at a time, under the cap and the two constants above.*
4. **Which accounts stay on live?** `decide-which-accounts-stay-enabled-on-live-at-the-launch`.
   *Assumed: rian as owner, `adam` and `mark` at `admin`, every other row disabled before the dump.
   This one has to be answered before the dump, not after: the copy is the last time data moves.*
5. **Are A11 and C5 still launch scope?** `decide-are-a11-two-further-product-types-and-c5-brand-pass-two`,
   weight `costly`. The two quoted lines still open on our side, found by the double-check below.
   *Assumed: neither is in this launch; the walkthrough says so rather than leaving them ambiguous.*

---

# The proposal, double-checked against what is built

**The answer to "double check against the proposal", checked against the tree and staging on
20 Sep 2026, not against a status word.** Every line of the accepted quote (`main/web/src/lib/quote.ts`)
is a key in `import/progress.json`'s `quote` block, and every key is below with a verdict and a
proof that is a file, a commit, a route, a command's output or a run log. Where the file's status
and the tree disagree, the tree wins and the disagreement is named.

Three verdicts are used: **done** (the line is delivered), **waiting on Adam** (ours is built, his
part has not arrived), **not done** (something of ours is still open, with what it would take).

## The sixteen quoted lines

- **`core-environment` · Live production environment — done.** `deploy/production.sh` (deploy,
  `--replace-db`, `--dry-run`, `--local`), `deploy/install-backup-cron.sh` (dump on the droplet
  03:15 UTC, pulled here 04:15), `deploy/uptime-check.sh`, `docker-compose.production.yml`, the
  RUNBOOK's Production, Backups and restore, and Launch checklist sections, and
  `tests/test_production_script.py`. The database half is rehearsed:
  `.logs/runs/rehearsal-2026-09-17-k7-replace.md`, 54 tables and 130,290 rows equal in 15 s. The one
  step of this line still ahead is the public flip, which is part one of this document
  (`do-go-live-day-flip-production-to-public-and-run-the-checkl`).
- **`core-airports` · Sixteen airports live (nineteen at launch) — done.** `app.cli shops` on
  staging prints nineteen `visible` (ATH, BCN, BOG, CDG, DUB, DXB, EZE, HKG, ICN, JFK, LHR, MAD,
  MEX, PTY, SAL, SIN, YUL, YYZ, ZRH; Cork rides with Dublin) and seven `HIDDEN`; A13 set the same
  nineteen on production on 11 Sep. **Two of the nineteen are thin** and rian should know before
  Adam sees them: SIN 27 listings and ICN 199, against CDG 7,197 and LHR 570. Dubai's 1,489 are
  dated 21 Aug prices under the BLOCKED rule, which is the decided position, not a defect. The
  three shops added this weekend (LAS 265, LGW 473, HEL 442) are collected and hidden, and are
  beyond what this line quoted.
- **`core-quality` · Quality, performance and soft launch — not done.** Q1 to Q4 are in the tree:
  `app.cli audit` and `app.cli verify` (`app/cli_quality.py`, `services/audit.py`, `services/verify.py`,
  `tests/test_audit.py`, `tests/test_verify.py`) and `main/docs/QUALITY.md`; the documented soft-launch
  checklist is the RUNBOOK's 23 lines plus part one of this document. **Open: I1** (the integration
  pass: the full run, `docs-check --strict`, two widths, accessibility, the heaviest pages timed) and
  **P6** (the walkthrough with Adam). I1 is written as Stream AW7 and is an evening's work; P6 is an
  hour with Adam and his to schedule.
- **`core-crawlable` · Emitting HTML that AI can read — done.** The served body carries the heading,
  the price table and the medals before any script runs: `services/seo.py` with
  `tests/test_seo_body.py`, `test_seo.py`, `test_seo_airport.py`, `test_seo_brand.py`,
  `test_seo_line.py`, `test_seo_article.py`, `test_crawl_surface.py`; absolute addresses from
  `PUBLIC_BASE_URL`, real 404s (`test_page_gone.py`, `test_catch_all_confinement.py`). While the site
  is members-only the crawler sees the sign-in door and `robots.txt` disallows everything, by
  design; line 17 of part one is what makes this line visible from outside.
- **`core-brand` · Brand alignment — not done** (C5, and rian's word on scope). C1 landed: the logo,
  the navy carried into `main/web/src/styles/tokens.css`, the serif, contrast pinned by
  `tests/test_tokens_contrast.py`. **C5 is `doing`**: rian rejected the first banner placements on
  11 Sep as arbitrary boxes dropped between sections, so the slots stay in code and hidden until a
  design pass makes them part of the layout. What it would take: one design pass on the templates,
  then the sizes to Adam.
- **`structure-proposal` · Site structure proposal — done.** Swept before it went out (B3) and sent
  to Mark on 5 Sep with the to-do page (P2). It now lives at `/discuss?tab=structure`, with
  `/structure` redirecting to it (`routers/discussion.py` `MOVED_PAGES`), and on live it is a
  development surface that answers 404. One page type it does not cover is on the running list:
  `issue-brand-at-airport-pages-a-page-type-the-structure-proposa`.
- **`airport-pages` · Airport pages — done.** `routers/airports.py` and `pages/AirportPage.tsx`:
  the served body with CollectionPage, ItemList and Airport markup (`tests/test_seo_airport.py`),
  the comparison preset, the shopping-feature filters, the featured block
  (`services/airport_featured.py`), opening hours where robots allow (`services/hours/`). **The
  editorial half is one airport deep:** `app.cli places list` prints `guide: 4 areas` for Heathrow
  and `no guide` for the other twenty-three, and Heathrow's template is waiting on
  `decide-approve-the-airport-template-on-heathrow`.
- **`brand-pages` · Brand pages and browsable brands — done.** The brands table (migration
  `f3a4b5c6d7e8`, `b46e3f8`), the fold and the duplicate groups as recorded merges (`758233e`),
  `/brands/<slug>` in the served body (`tests/test_seo_brand.py`, `pages/BrandPage.tsx`), and the
  path from brand to product line to product variant through `/products/`
  (`tests/test_product_line_page.py`).
- **`mega-menu` · Mega menu — done.** `components/MegaMenu.tsx` inside `SiteHeader.tsx` (`c06a766`),
  keyboard and hover verified in headless Chrome on two dev copies, one with brands and one without.
- **`articles` · Your articles published — waiting on Adam.** D1 is done: the articles table
  (migration `a4b5c6d7e8f9`, `89f33a0`) and `app.cli articles import` for Markdown, text, Word and
  the uploads he leaves on the to-do page. **None of Adam's pieces are in the database.**
  `app.cli articles list --all` on staging prints eleven rows: ten written by the Professor and
  flagged `sample` (AW5.2, unpublished before the dump at line 3 of part one) and one earlier piece
  of ours. The article centre at `/articles` and the hero-credit field are built and empty. Three
  asks on the running list are the whole of what is missing (`do-give-adam-feedback-on-his-heathrow-airport-profile-and-h`,
  `do-ask-adam-for-the-remaining-category-primers`, `do-put-four-clauses-of-adam-s-whiskey-primer-back-to-him-be`).
- **`prominence` · Featured savings and exclusives given prominence — done, and since improved.**
  C3 put the headline saving card, the exclusives band and the enlarged product-variant saving in
  place (`6fa56d8`, `1034869`). This weekend AW2 replaced what sits behind every featured rail with
  one standard, made the pin a recorded decision rather than a flag, and published the standard the
  site reads at runtime at `/how-we-choose` (`services/featured.py`, `main/docs/FEATURED.md`,
  `tests/test_featured.py`, `test_featured_doc.py`, `test_featured_pins.py`).
- **`categories` · Two further product types — not done** (and rian's word on scope). A7 landed the
  targeted forty (`fcdb0a7`, `import/beauty-targets.json`: twenty fragrance, twenty skincare) and
  the beauty widening collected beauty rows at the shops (`3d5f3f1`), so the material is largely in
  the database. **A11 is `todo`** and is blocked by
  `issue-paco-rabanne-and-rabanne-are-two-brands-in-our-data-whic`, which splits a whole fragrance
  line in two. What it would take: settle that split (Stream K9's split path exists), then one
  curation and review pass over the beauty rows to leave about twenty product lines standing in each
  of two types. A session, not a night.
- **`awards` · Awards selection strategy — waiting on Adam.** F1 is done: the strategy document
  (`notes/awards-strategy-for-adam-2026-09-05.md`, published as decision card 5 on `/discuss`).
  F2 is further along than the file says: the picker landed with 78 tests (`e2d50a3`,
  `services/award_picker.py`, `tests/test_award_picker.py`) **and is wired** into
  `services/catalog_queries.py`, which calls `award_picker.pick` on both the listing and the product
  line paths (`tests/test_award_wiring.py`). What is left is the pin through a recorded decision
  (the comment at `catalog_queries.py:815` still promises it through the retired `overrides` table;
  AW2 has since shown what a pin as a ledger decision looks like) and Adam's answer to the strategy card
  (`do-adam-awards-strategy`), which decides whether the rules the picker already applies are the
  right ones.
- **`email-capture` · Email capture — done.** `POST /api/subscribers` with a honeypot, a throttle
  and a recorded consent (`000765e`), the form component (`198d952`), and owner-only
  `app.cli subscribers export | stats | unsubscribe` (`app/cli_editorial.py`,
  `tests/test_subscribers.py`, including one that keeps addresses out of the logs). AW5.3 drove the
  whole path on a restored copy and wrote the export procedure into the RUNBOOK. `subscribers stats`
  on staging prints zero, which is what a members-only site with no visitors should print.
- **`sponsor-slots` · Sponsor banner positions — not done** (C5 again, and Adam's creative). D4 is
  done: `SponsorSlot` reserves its box at the nine IAB sizes, shows a static image only and nothing
  when there is no creative (`3724f38`), and it is placed in seven templates (home, product variant,
  product line, airport, article, collection, settings). But `SPONSORS` and `NATIVE_SPONSORS` in
  `web/src/lib/sponsors.ts` are both empty, and C5 is the placement decision. Nothing renders today.
  What it would take: the design pass, the sizes to Adam, and his files under `public/sponsors/`
  with versioned names.
- **`new-collectors` · Three new data collectors — done for two of the three airports, thin on the
  third.** Paris: `extime-paris`, 7,048 prices, CDG 7,197 listings. Seoul: the hardened sidecar
  (`main/browser/`), `fetch.render()` with its honesty contract (`tests/test_render.py`), the probe
  (`.logs/runs/shilla-probe-2026-09-05.log`) and a real collector — `shilla-icn` is idle and `ok`
  at 192 prices, ICN visible with 199 listings. Singapore: the collector exists
  (`services/collectors/changi.py`, `tests/test_collectors_changi.py`) and SIN is visible, but with
  **27 listings**; `app.cli status` on staging still prints `ishopchangi-sin refused blocked
  (18 prices)`. **The 403 that stopped it was answered on 13 Sep**
  (`.logs/runs/window-2026-09-13-changi-retry.md`): twenty pages at a thirty-second pace stored
  nineteen prices with no refusal on the droplet, and thirty seconds became the source's own pace.
  What it would take to finish the line: one run over `import/changi-targets.json` (622 addresses)
  at that pace, about five hours, on whichever host collects. Whether that happens before Monday is
  rian's: `decide-singapore-is-public-with-27-listings-finish-it-before-mo`. *Assumed: it does not,
  and SIN launches as it is.*
- **`merge-session` (not a quote line) · One page per real product variant — done, unpaid.** Rian's
  decision of 12 Sep, carried in the same block so it shows on `/plan`. M1 to M7 are in the tree:
  the listings view, the product line above the variant, match key v3, aliases with a preferred
  name, rules-first suggestions, the keyboard confirm session and the inheritance rules
  (`services/merge_session.py`, `merge_desk.py`, `suggest.py`, `merges.py`, and their tests).

## The cross-check against the quote page itself

`quote.ts` holds sixteen lines (`CORE` three, `OPTIONS` thirteen) and the `quote` block holds
seventeen keys. **Every quoted line maps, key for key; no line is unmapped.** The seventeenth key is
`merge-session`, which is not a quote line at all and is labelled as such above.

`LATER` (the twelve after-launch entries) is out of scope by construction, and nothing in the tree
quietly delivers one. Two are worth a sentence so they are not mistaken for delivered: accounts
exist for the team, not for travellers, so **traveller accounts** remain unbuilt; and every
observation is stored with its date, so a **price history** could be drawn later without collecting
anything new, which is what the line promised.

## The two that need rian's word on launch scope

Both are the same two the away plan named, and both are now precise. One running-list item
carries them together: `decide-are-a11-two-further-product-types-and-c5-brand-pass-two`.

1. **A11, the two further product types.** Blocked behind a brand split, not behind effort. If it
   stays in launch scope, the split and one curation pass come first; if it does not, the quote line
   is delivered short and should be said out loud rather than left ambiguous at the walkthrough.
2. **C5, brand pass two.** It carries two lines at once: `core-brand` and `sponsor-slots`. Until the
   design pass happens, banner positions exist in code and show nothing, and Adam has no sizes to
   supply creative against. If Monday is soft, this can follow the launch; if the sponsor
   conversation is part of the launch, it is the one piece of design work still owed.

## What was corrected in the away plan

`streams/AWAY-2026-09-19.md` section 6 was the first, quicker answer, written from the file at
19 Sep 10:00. It has been corrected in place where the tree disagreed: Singapore's hold was answered
on 13 Sep and its pace settled, so neither "blocked on the 403" nor a clean "done" for the
three-collector line was true; the sponsor line was listed as done when its placements are not;
the awards picker is built and wired, not merely waiting; the quality line is open on I1 and P6;
and A11's blocker is now named. This section is the long form; section 6 points at it.
