# Stream AW6: the launch plan, from mosiah (staging) to live, and the two environments after it

**Status 19 Sep 2026 (written by the away run's planning subagent; not started).** Sixth in the away
run (`AWAY-2026-09-19.md` section 3), after AW5, in the worktree on `claude/collectors-live-page-plan-8951e2`.
Documents first; one contained code change, one contained script change; nothing deployed, nothing on production.

## Goal
One dated document rian executes top to bottom to move the site from staging to live for the soft
launch of Monday 21 Sep, with times, each line a command or a check with its expected output, and
the same document saying how the two environments live afterwards: pushes carry code and
migrations and never data again, collectors run on live within what the droplet holds, development
discussion stays on staging while review discussion happens on live, and every route is a live
feature or a staging-only surface by an environment line, never by a branch. The proposal
double-check at the end is the answer rian asked for.

## What already exists, so you build on it
- `main/docs/RUNBOOK.md` "Launch checklist (staging to production)": 23 lines with expected output and
  the rollback line (Stream K7). The plan cites these by number; it never copies a mechanism.
- `deploy/production.sh`: the code deploy (rsync, build, up, health; refuses while a collection runs)
  and `--replace-db <dump>` (nine fatal steps, `--dry-run`, `--local` rehearsal). Rehearsed 17 Sep:
  `.logs/runs/rehearsal-2026-09-17-k7-replace.md` (54 tables, 130,290 rows equal, 15 s).
- `deploy/launch-dump.sh`: the staging dump with `.counts`, `.head`, `.sources` sidecars; refuses
  while a collection runs and when rows change during the dump.
- `deploy/install-backup-cron.sh` (nightly dump on the droplet 03:15 UTC, pulled here 04:15 into
  `backups/production/`), `deploy/uptime-check.sh` (five-minute check, mail on two failures).
- `main/scripts/staging-refresh.py` and `main/app/cli_refresh.py`: production's dump replaces staging,
  every `CLIENT_WRITTEN_TABLES` row (`services/discussion.py`) is exported first and applied back by
  natural key, the ledger travels by `decisions export` and `decisions replay`; `--check` restores
  nothing. `tests/test_staging_refresh.py` pins the policy.
- `main/app/services/access.py`: the four classes, `SPA_PERMISSION`, the pure `decide()`, the
  middleware, `_render()` (a 404 "No such page." on an HTML key is the not-found shell).
  `tests/test_access.py` walks `decide()`; `tests/test_route_inventory.py` and
  `tests/fixtures/open_routes.json` make a reclassification a reviewed diff.
- `main/app/config.py`: `app_env` (set to `production` by `docker-compose.yml` on BOTH hosts, so it
  cannot tell them apart), `site_access` and `site_open` (fail closed), `spa_flags` into the shell.
- `main/web/src/lib/clientPages.ts` (the account menu's build-discussion links, each with a `shown`
  test), `lib/flags.ts` (`window.__DFP_FLAGS__`), `App.tsx` (`guarded(...)` per route).
- `routers/health.py` (`/api/health`: status, version, database); `routers/discussion.py`
  `MOVED_PAGES` (the 301s for `/todo /structure /quote`); `PAGE_OF` in `services/discussion.py`.
- `ACCOUNTS.md` (levels, `SITE_ACCESS=members` until the flip), `RUNBOOK.md` Accounts (the `.app.env`
  lines, seeding, `accounts list | disable`, `sessions revoke --all`), `CLIENT-SURFACES.md`.
- `.logs/runs/window-2026-09-19b-sweep.md` and `sweep-2026-09-19b/memory-trace.tsv`, `admit-queue.sh`
  (`CEILING_MIB`), `guard.sh` (`SHED_MIB`): eleven collectors at 1,079 MiB a minute in, 1,590 MiB
  peak; the droplet is 4 GB total (`db` 512m, the browser sidecar 2g when it runs).
- Running list: `decide-where-do-the-crawls-run-once-the-site-is-live-this-host` (open) and
  `decide-after-the-launch-which-machine-collects-production-only` (decided 17 Sep: production
  collects only after rian declares the launch; the catalogue then flows live to staging only).
- `import/progress.json` `quote` block (17 keys to task ids), `main/web/src/lib/quote.ts` (`CORE`,
  `OPTIONS`, `LATER`), `AWAY-2026-09-19.md` section 6 (the first answer);
  `tests/test_production_script.py` (the script's refusals in seconds, no ssh, no docker).
- Stream W (`W-readiness.md`) never ran: no `route-sweep.sh`, no `EDGE-CHECKLIST.md`, no legal
  pages, no IndexNow mint block; W1 to W6 are `todo`.

## Tasks, in order (commit prefix `AW6:`)

### AW6.1 The plan document
`.logs/planning/launch-plan-2026-09-19.md`, frontmatter `type: plan`. Part one, the launch, in
rian's order with a clock: T0 is the freeze; every later line carries T0 plus the elapsed time the
rehearsal or the sweep window measured, or "gate" where only a check ends it. Each line is one
command or one check with the output it must print; where the RUNBOOK checklist has the identical
line, cite it (`RUNBOOK Launch checklist, line 9`) and write only what differs. The order: the
freeze (line 1); the last staging sweep and verify (`app.cli sweep plan`, the window file, `app.cli
verify-status`); the review passes complete (`app.cli pass status` prints done, `decisions verify`
0 drift); `main/check.sh` green on the merged branch; sample articles excluded (`app.cli articles unpublish
--samples`, AW5.2, before the dump; `articles list --samples` prints none published); the copy with `--replace-db`
(lines 9 to 15), stated as the last time the database moves; accounts on live (`accounts list`
printed; rian owner by `ACCOUNT_OWNER`, adam and mark on `admin`, every other row `accounts
disable`d) and sessions revoked (line 13); the two env lines in the droplet's `.app.env` in rian's
own shell (`SITE_ROLE=live` from AW6.2, then `SITE_ACCESS=public`) and the recreate (line 17); the
unauthenticated sweep (line 18: `main/scripts/route-sweep.py https://<live host> --open` if AW7
landed it, checked by `git log --oneline | grep AW7:`, else the curls: the owner and member routes
and the answer each must give); sitemap (line 19) and IndexNow (line 20, the mint command from the
running-list item); the announcement to Adam (line 23, the points); the rollback (the RUNBOOK
paragraph, plus `SITE_ROLE` back to `staging`). Part two, the two environments after launch: pushes
are code and migrations only (`deploy/production.sh` plain; `alembic upgrade head` on start; the
after-deploy backfills run on live by hand; the RUNBOOK sentence that says so and the AW6.4 marker
that refuses a second `--replace-db`); collectors on live: the arithmetic from the trace (per
collector 140 to 160 MB growing, eleven at 1.6 GB peak) against the droplet (4 GB, `db` 512m,
sidecar 2g while a rendered source runs), the admit ceiling it gives (a `mem_limit` for `app` in
`docker-compose.production.yml` and a `CEILING_MIB` for a copy of `admit-queue.sh` there, three or
four hosts at a time, a full sweep an overnight in waves rather than one evening), the start
mechanism (the page-driven start AW4 lands, or the queue script by hand; no cron: collection stays
hand-run, `COLLECTORS.md`), what stays on mosiah (nothing that writes the catalogue; the rendered
sources' sidecar is on both); discussions: development threads on staging, review threads on live,
and what `staging-refresh.py --from backups/production/<nightly>` does to each (staging's
`CLIENT_WRITTEN_TABLES` rows survive by natural key, live's threads arrive with the dump, staging's
ledger is replayed, so after launch no catalogue decision is taken on staging or it is refreshed
with `--discard-decisions`); pages by environment: a table of every `App.tsx` route and page route,
staging only or live, from AW6.2's sets, and the sentence that the app knows by `SITE_ROLE`, never
by a branch; sample articles excluded; backups and monitoring on live as built (the two cron
lines, `uptime-check.sh`, the incident log). Every line names the file or command that does it.
Proof: the document exists, every command in it is one that exists in the tree or is named as
rian's task, `bash main/docs-check.sh` prints `0 fail`. Commit.

### AW6.2 The environment line
`config.py`: `site_role: str = "staging"` beside `site_access`, a property `is_live` true only for
the literal `live` (an unset line is staging: more to signed-in people, nothing more to the
public, whom `site_access` still gates). `spa_flags` gains `developmentSurfaces` (`not is_live`);
`/api/health` gains `role`, and `PUBLIC_ALWAYS`'s reason says "ok, version and role only". `access.py`, one place: `DEVELOPMENT_SPA` (`/discuss /quote
/structure /todo /settings /images /plan /issues`) and `DEVELOPMENT_ROUTES` (`GET /todo /structure
/quote`, `MOUNT /docs-static`, `GET /{page}.html`, `/api/plan`, `/api/items*`, `/api/quote/*`,
`/api/todos*`, `/api/images/*`, `GET|POST /api/discussion`, `/api/discussion/{item_id}*`,
`/api/discussion/feature-comments*`, `/api/discussion/feature-priorities`), and `LIVE_FEATURES`
naming every other `PERMISSION` key and `SPA_PERMISSION` path (`/collectors`, `/review`, `/sources`,
`/admin`, the threads, notifications and attachments the side panel needs, the account routes).
`decide()` takes `live: bool`; after classification and the origin check, a development key or
path answers `Verdict(404, "NOT_FOUND", summary="No such page.")` (the shell for HTML keys, JSON
for the rest) before any sign-in redirect, so live never discloses them; the middleware passes
`settings.is_live`. Tests: `test_access.py` walks `dec(live=True, ...)` for a development page
signed in as the owner (404), a development API read (404 JSON), `/collectors` and `/review` for
the owner (admitted), the storefront in public mode (admitted), the sign-in pages (admitted), and
`live=False` unchanged; `test_route_inventory.py` gains one test: every `PERMISSION` key and every
`SPA_PERMISSION` path is in exactly one of `DEVELOPMENT_*` or `LIVE_FEATURES`, so a new owner route
cannot ship unplaced. SPA: `Flags.developmentSurfaces`, `clientPages.ts` `shown` reads it for the
development links, `App.tsx` guards those routes on it (`NotFoundPage`); stylesheet imports stay
in their components. Docs: `ACCOUNTS.md` route policy gains one paragraph (the environment line,
what it hides, that it is not the public gate); `RUNBOOK.md` Accounts adds `SITE_ROLE` to the
`.app.env` lines and the Launch checklist's line 17 names both lines; `.app.env.example` is untracked in
git (a commit of it aborts the fast-forward at D4): edit it in place for rian, never `git add`
it; the `SITE_ROLE=staging` line is carried by the RUNBOOK lines and the plan; `CLIENT-SURFACES.md` positioning rules gain one sentence; `CHANGELOG.md`
Unreleased one line. No present state, no project names in `main/docs`. `main/check.sh` green
(the typed client regenerates for the health field). If this does not fit one task, stop, write
the exact change into the plan as rian's task, and commit the plan.

### AW6.3 The proposal double-check
Every key in `import/progress.json`'s `quote` block against the tree, one line each: `key: done,
<proof: commit, route, command or page>`, `waiting on Adam, <what>`, or `not done, <what it would
take>`; the proof is a thing in the tree or on staging, never a status word from the file.
Cross-check the keys against `quote.ts` (`CORE`, `OPTIONS`) and note any quote line the block does
not map. Compare with `AWAY-2026-09-19.md` section 6 and correct it in place where wrong (say what
changed in the commit body). The result is the last section of the plan document, headed as the
answer rian asked for, with A11 and C5 marked as needing his word on launch scope. Commit.

### AW6.4 The launch marker in `deploy/production.sh`
After a successful `--replace-db` (the end of step 9) the script writes
`backups/production/LAUNCHED` here (`launched <stamp> from <dump>`) and copies it to the droplet's
`backups/`. `--replace-db` refuses at the top, before step 1 and before any ssh, when that file
exists, naming it and `--force-replace-db`, whose message says what a second replace discards
(every collection and decision taken on live since the launch). `--dry-run` reports the marker the
same way and writes nothing. Tests in `tests/test_production_script.py`: the refusal with the
marker present, `--force-replace-db` passing it, `--dry-run` writing no marker; the docstring names
rian's rule (a push to live never syncs collection data again). RUNBOOK Production gains the
sentence; the plan's part two names it. Commit.

## Working assumptions (rian's to overturn)
- `/review`, `/collectors`, `/sources` and `/admin` are live features; `/discuss` and its tabs,
  `/settings`, `/images`, `/plan`, `/issues` and the explainer pages are staging only. *Overturned:
  move a name between the two sets in `access.py` and re-run the inventory.*
- `/data` (the public dataset page, in the reviewed `open_routes.json`) stays live. *Overturned: add
  it to `DEVELOPMENT_SPA` and `DEVELOPMENT_ROUTES`; the fixture does not change.*
- The discussion API (threads, comments, notifications, attachments) stays live so the side panel
  works there; only the development pages and their own reads answer 404. *Overturned: move the
  keys; the panel on live then errors and must be gated too.*
- Collectors run on live after the launch, three or four hosts at a time under a ceiling sized
  from the trace; nothing on mosiah writes the catalogue. *Overturned (a crawl box or a bigger
  droplet): only the plan's collectors section is rewritten.*
- Accounts move with the database; rian, adam and mark stay, every other row is disabled on live.
  *Overturned: one `accounts enable` per row.*
- The plan is dated Sunday 20 Sep evening PDT, T0 at a time rian picks; the passes are a gate, not
  a time. *Overturned: the clock column changes.* `SITE_ROLE` defaults to `staging`; live sets it.
  *Overturned (default live): flip the default and its test; staging's `.app.env` gains the line.*

## Questions for rian
Each goes on the running list with `--assumption` and in the hand-back: do the missing legal pages
(W1) block Monday; are `/data` and `/review` live; where do the crawls run; which accounts stay.

## Owns
`.logs/planning/launch-plan-2026-09-19.md` (new); `AWAY-2026-09-19.md` section 6 only; `config.py`
(the setting and the flag); `services/access.py` (the two sets, `decide()`'s parameter, the
middleware line); `routers/health.py`; `tests/test_access.py`, `test_route_inventory.py`,
`test_production_script.py`; `web/src/lib/flags.ts`, `lib/clientPages.ts`, `App.tsx` (the guards on
the development routes only); `deploy/production.sh` (AW6.4 only); `.app.env.example` (edited in place, never committed: it is
untracked, and a tracked copy would abort D4's fast-forward in the main checkout);
`ACCOUNTS.md` (one paragraph), `RUNBOOK.md` (Accounts lines, checklist line 17, Production sentence),
`CLIENT-SURFACES.md` (one sentence), `CHANGELOG.md` Unreleased; `import/progress.json` through
`plan-set.py`, `import/items.json` through `items.py`, `.logs/handoff.md` prepend only.

## Must not touch
Production in any form (`bwlive`, its `.app.env`, its Caddy, the zone); `.env`, `.app.env`,
`.bw-auth.env`; staging's database; `deploy/launch-dump.sh`; the collectors, `ingest.py`, `keying.py`,
identity rules; `tests/fixtures/open_routes.json` (no storefront route moves); `docker-compose.yml`
and `docker-compose.production.yml` (the live `mem_limit` is rian's line, written into the plan);
`staging-refresh.py` and `cli_refresh.py` (their behaviour is stated, not changed); the kit under
`main/app/vendor/` and `main/web/src/vendor/`; the sweep's files under `.logs/runs/` and the main
checkout's dirty paths (`AWAY-2026-09-19.md` section 1); any other stream's files.

## Rules that bite here
- **Nothing on production in this period; no deploy by this stream** (`AWAY-2026-09-19.md` section
  1 and 7). The document is for rian to execute; every production line in it is his.
- **Every route is in exactly one class of `services/access.py` or it is refused; a GET must never
  write** (`agents.md`). The environment sets are a second placement on top of the classes, not a
  fifth class; the inventory test makes an unplaced route fail.
- **`SITE_ACCESS=members` until the go-live flip** (`ACCOUNTS.md`); `SITE_ROLE` is not the gate. **A
  token rides only in a link's fragment and a JSON body, never a path** (`agents.md`).
- **Migrations are schema-only with server defaults; every backfill is an idempotent `app.cli`
  command with `--check`** (`agents.md`). This stream carries none; the restore carries staging's schema.
- **Docs are gated, not remembered** (`agents.md`): `main/docs-check.sh` runs in `check.sh`; no
  present state ("currently", counts, deploy stamps) and no project names in `main/docs`; dated
  facts belong in the plan under `.logs/planning`. One home per mechanism: the RUNBOOK keeps the
  checklist, the plan cites it.
- **A catch-all route makes a missing route look like a working one** (`agents.md`): a development
  page on live is a real 404 status with the not-found shell, verified by status, not by the shell.
- **One word per concept** (`VOCABULARY.md`): product line, product variant, brand; never bare
  "product" in the plan or the proposal check.
- **Client-facing copy: no em dashes, never "cheap" or "free"**; `test_house_style.py` walks the
  SPA; the plan's announcement points follow the same rule.
- **`main/check.sh` before every commit; one task, one `AW6:` commit; a bug found becomes a test
  with the real record; never edit `.env` or `.app.env`, never print a secret** (`OVERNIGHT-RULES.md`):
  the plan names the line rian sets and checks by behaviour (`/api/health` prints `"role":"live"`).

## Out of scope, noted
- Stream W never ran: line 18's sweep script, the edge checklist (trusted proxies, cache bypass,
  Bot Fight Mode off), the app-only env file and the legal pages are open before Monday; the plan
  lists them as gaps with the by-hand fallback and rian's decision.
- Comments carry no environment: `threads` and `discussion_comments` have no origin column, and
  `Decision.origin_host` is `settings.app_env`, `production` on both hosts. Telling a live review
  comment from a staging development comment by data needs a schema-only column plus a backfill
  (a later task, written into the plan's discussions section as rian's).
- On live the side panel lists threads whose subjects (`item:*`, `structure:*`) have no page; a
  filter by environment is later. `GET /{page}.html` (`client.view`) is the rule W1's legal pages
  would use; when they land it needs a public allow-list or a public prefix.
- The sample column and `articles unpublish --samples` are AW5.2's; if AW5 stops short, the plan
  falls back to `articles list` and setting each sample row to `draft` by hand.
