---
type: plan
version: 1
status: written 2026-09-10 from rian's ask of the same day; researched by seven readers and a three-angle design panel; decisions in §4, what rian decides in §12
supersedes: build plan §4 item 9 and §7 row R as the design of Stream R (the plan's decisions stand; this document is R's brief-level design)
---
# Accounts and login: the plan for Stream R

Duty Free Professor gets its own account system: sign-in, levels of access, an owner who can
view and act as anyone, and the foundation for discussions with notifications, a collection
oversight area, and later tens of thousands of consumer and brand-manager accounts. It is
modelled on the server's BW Auth application kit and is completely independent of it, so the
codebase can be handed to an owner with no relationship to Bowden Works.

The plan is in four phases. **R1 (the login system, this week)** is the stream brief
`streams/R-accounts.md` and the command `/stream-r`. **R2 (discussions)** and **R3 (collection
oversight)** are designed here and run after the 18 Sep delivery. **R4 (growth)** is shape only.

## 1. The ask, in rian's words (2026-09-10)

- A login system for this site, independent of the server's BW Auth so it can be transferred
  to a new owner, but modelled after BW Auth so efforts and learnings flow both ways. Security
  and scalability first; a similar codebase second.
- At launch three accounts: **rian** as super admin (every page including `/plan`; view and
  act as any current or future user), **Adam** and **Mark** as admin (the discussion pages the
  public does not see; comments under their own names).
- **Login required just to see the site** at launch. Built on the staging host first, then
  the server's shared-password gate comes off staging with the new login in its place, then
  production launches on DigitalOcean. Staging is pushed to live regularly; the login works on
  both; the three accounts exist on both.
- Near term: discussions like Caddie's (notifications, comment threads, decision status,
  emails, images in comments) and a super-admin area to oversee data collection (what the
  crawling produced, scheduling).
- Long term: tens of thousands of users; more admin tiers (content, subscriptions, QA,
  collector scheduling and monitoring); brand managers (several brands each; promoted
  listings, competitions, brand assets; a free tier and paid products); consumer users
  (browsing, alerts).

## 2. Where this sits against what the client was told

The standing rule is to check direction against Adam's written asks and flag divergence
rather than build silently. The research found these:

- **The accepted quote excludes accounts from this phase.** The launch proposal Adam accepted
  on 3 Sep lists "traveller accounts and logins, saved searches and price alerts, the
  administration area for self-publishing" under "not in this phase, in any option"
  (`notes/launch-proposal-2026-09.md`, `quote.ts` LATER). Adam ranked the admin area and
  scheduled collection after launch (`notes/meeting-prep-2026-08-31.md`). **R1 is not that
  line.** It is the enabler that replaces the shared password with named people, closes the
  write routes on a public host, and keeps the launch domain out of the index (Mark: "I would
  not index this until as absolutely late as possible"). It is unpaid infrastructure, which
  the plan's own rule (§10 #1) drops before any quote line; rian asked for it on 10 Sep, so
  the capacity call is his and is filed as a decide (§12).
- **"Search is always our driver" (Adam, 9 Sep).** A login wall means nothing is indexable.
  Members-only is a pre-launch state with a dated end: the storefront opens to the public by
  one configuration flip (`SITE_ACCESS=public`) owned by E7, the go-live checklist. The flip
  is a decide with a date, not a default.
- **The written promise that makes the independence rule binding:** "the code in a repository
  you own... could move to any cloud host in an afternoon" (rian to Adam, 20 Aug; the review
  page's card "Auth is app-native, not borrowed from rian's login system"). Nothing in this
  plan calls the server, registers a BW client, or imports from outside the repository.
- **"One login across his family of sites" (Adam, 31 Aug, yes in principle).** Compatible:
  DFP's own identity layer can later sign users into Adam's other properties (R4). It is a
  second reason the identity table stays free of any server coupling.
- **Adam's staff (Kristine, Matt) must become independent on content.** Agrees with the
  long-term tiers; nothing at launch.

## 3. The decision chain every auth module cites

App-owned identity by decision: `agents.md` "Decided 2026-09-04" → build plan §9 #2 → poc-dfp
D4 (the portability promise). The standards' override clause applies (`coding.md` line 8,
standards README "a project can diverge and say so"); the standards' own rule "apps never build
their own login" is a source-of-identity rule, and everything else in it is kept: default deny
with an inventory test, secrets in an env file at the gateway-readable mode the stack standard
fixes (660, group `dutyfreeprofessor-dev`) and never printed, never log a token, schema-only
migrations, an audit trail on every state change. **The server's "custom app with user login"
scaffolder trigger does not apply** and `srv-gw app-client-register` is never run on either DFP
host (registration auto-un-gates the host and couples the codebase to this server). Learnings
flow back to the kit only as nameless, past-tense rules, never as a pointer to this project.

## 4. The design (R1)

### 4.1 What is reused, and how

The BW application kit is vendored **byte-identical**, never imported, never hand-edited:

| Vendored copy | From `/srv/system/id-auth/app-auth/` | Role |
|---|---|---|
| `main/app/vendor/bw_accounts.py` | `bw_accounts.py` | levels, permissions, assignable lists, owner synthesis, `can()` |
| `main/app/vendor/bw_store_sqlalchemy.py` | `bw_store_sqlalchemy.py` | the store, mapped onto DFP's tables |
| `main/app/vendor/bw_admin_api.py` | `bw_admin_api.py` | the admin API and the `/api/bw/me` capability contract |
| `main/app/vendor/bw_view_as.py` | `bw_view_as.py` | View As mechanism and guards |
| `main/web/src/vendor/bw-admin/` | `react-admin/` (whole folder) | People, Levels, Matrix panels; View As banner and picker; `BwApi`, `useBwAuth` |
| `main/tests/kit/test_*.py` | `app-template/main/tests/` (five files) | the conformance suite: capabilities, levels, assignability, View As, invites |

`main/app/vendor/MANIFEST.sha256` records each file's hash. `main/scripts/vendor-check.sh`,
called from `main/check.sh`, byte-compares every vendored file against the kit when the kit
path exists and against the manifest when it does not (a transferred codebase has no kit), so
a hand edit fails the suite either way. `vendor-refresh.sh` is the one sanctioned way the
files change. `bw_auth.py` (the central client) is never vendored: it is the one file that
would couple the codebase to this server.

The kit's `bw_auth` parameter is duck-typed; DFP passes its own local directory (§4.5). Six
small upstream changes the audit and the review found (an `owner` default of `rian` in
`bw_accounts.init`; brand strings in `AccountMenu` and `AddPerson`; an always-on Status tab
in `AdminApp`; the popup in `ProfilePanel`; and an unused default `React` import in eight pack
files, which fails any vendoring app whose tsconfig sets `noUnusedLocals`) are handled without
touching a vendored byte: by **not mounting** the pieces that carry brand strings, by always
passing the owner explicitly, and, for the TypeScript flag, by compiling the vendored folder
under a `main/web/tsconfig.vendor.json` project reference without the unused-symbol flags
(`exclude: ["src/vendor"]` in the main tsconfig; `tsc -b` is already the driver), falling back
to relaxing `noUnusedLocals` and `noUnusedParameters` with a comment naming the proposal if
the reference shape fights `tsc -b`. They are written up as unified diffs in
`.logs/planning/kit-upstream-proposals.md` for rian to apply to the kit; DFP re-vendors after.
The kit router mounts at `/api/bw` (its default, so the pack and the conformance tests apply
verbatim); the prefix is one constant if it is ever renamed.

Everything DFP writes itself lives in `main/app/services/` under names chosen once, here, to
avoid the collisions the research found (`services/audit.py` is the data-quality audit):

| Module | Owns |
|---|---|
| `services/accounts.py` | kit wiring: the mapped store, `init`, `register_enforced`, the resolvers `is_owner`, `can` |
| `services/identity.py` | the request-scoped resolvers `optional_user` (effective) and `real_user` |
| `services/sessions.py` | the session store, the `SessionRow` mapping, the cookie, the loader middleware |
| `services/access.py` | the route policy dicts, the pure `decide()`, the default-deny middleware (replaces `mutations.py` and `owner_auth.py`) |
| `services/passwords.py` | argon2id hashing, verification, the generated-password alphabet, the rules |
| `services/directory.py` | `LocalDirectory`: the `bw_auth`-shaped adapter (userinfo, invites, resets, search) and the token mint |
| `services/mail.py` | the `Mailer` protocol; provider `none` in R1 |
| `services/audit_log.py` | the audit sink every mutation calls |
| `services/view_as.py` | the View As policy hooks (caddie's file, with `_report` pointed at the local directory's `report_impersonation`, the audit row) |
| `routers/auth.py` | `/api/auth/*`: login, logout, forgot, welcome, reset, change-password, heartbeat, whoami |
| `routers/ops.py` | `/api/ops/*` owner reads (R1: impersonations only) |
| `cli_accounts.py` | `accounts`, `sessions`, `audit-log` commands |

### 4.2 Identity data

`accounts` stays **the one identity table** and the FK target for every who-column (four exist,
seven arrive in migration #4). The kit's membership sits beside it in DFP-owned tables the kit's
store maps onto; consumers later hold an `accounts` row and no membership row, so the
permission tables stay staff-sized at any scale.

- `accounts` gains: `username` (String(64), UNIQUE, NULL, lowercase, immutable; the kit's key
  and the `@mention` handle; regex `^[a-z0-9][a-z0-9._-]{1,31}$`), `status` (`active` |
  `invited` | `disabled`, server default `active`), `last_login_at`, `disabled_at`,
  `invited_by_id` (FK accounts NULL). `email` stays the login alias; `display_name` the only
  editable name. Usernames never change: the kit keys members, grants, sessions and audit on
  them.
- `account_credentials` (account_id PK FK, `password_hash` Text NULL, `password_set_at`,
  `must_change_password` bool, `failed_logins` int, `locked_until`, `google_sub` String(64)
  UNIQUE NULL): credentials live off the principal row so no query over `accounts` (the People
  list, search, the mention directory, audit joins) can ever carry a hash.
- `account_levels` (name PK, `permissions` JSON list, `assignable` JSON list, timestamps),
  `account_members` (username PK FK accounts.username, level FK, `all_instances`, `active`,
  `added_at`), `account_grants` (username, instance_id, level, PK(username, instance_id)).
  Instances map **read-only onto `brands.slug`** (`InstancesSpec(brands, id='slug',
  label='name', readonly=True)`); `has_instances=False` at launch, so the brand-manager tier
  later is a level row plus one flag, with no duplicated brand catalogue.
- The store: `bw_store_sqlalchemy.mapped(SessionLocal, levels=LevelsSpec(account_levels),
  members=MembersSpec(account_members, active_col='active', soft_delete=True),
  instances=InstancesSpec(brands, ...), grants=GrantsSpec(account_grants))`;
  `owns_tables=False`, so Alembic owns every table and startup runs no DDL. A store subclass
  converts `IntegrityError` on the kit's add paths into `AccountsError('EXISTS')`, the
  race-tolerance rule.
- The owner is code, not a row: `ACCOUNT_OWNER=<username>` in `.app.env`. When unset or a
  placeholder, `init` receives the sentinel `"!no-owner"` (a truthy string that fails the
  username regex after the kit's `.strip().lower()`; the kit falls back to `owner="rian"` on any
  falsy value, so `None` or `""` would silently make rian the owner), nobody is owner,
  startup logs CRITICAL and `accounts owner-check` says so; the site stays up. The owner still
  needs an `accounts` row and a credential to sign in. Transfer of ownership: set
  `ACCOUNT_OWNER` to the new owner's username, create that account, recreate the container;
  the previous owner's row is an ordinary account (RUNBOOK, "Transfer of ownership").

### 4.3 Sessions

- Opaque random token (`secrets.token_urlsafe(32)`, 256 bits) in the cookie; `sha256` of it is
  `sessions.token_hash`, the primary key. No signing secret in the session path: a second
  host or worker needs no shared key, and `SESSION_SECRET` retires with `OWNER_PASSWORD`.
- Cookie name and Secure follow `settings.is_production` (compose sets `APP_ENV=production`;
  the rehearsal server sets no `APP_ENV` and is therefore production too): `__Host-dfp_session`
  with Secure in production, `dfp_session` without Secure only when `APP_ENV` is set to anything
  else (the vite dev loop). Never the request scheme: behind Caddy `request.url.scheme` is
  `http` (uvicorn trusts forwarded headers only from loopback) and a header any client can
  send must not pick the cookie; the stopgap's raw `X-Forwarded-Proto` read retires with it.
  A Secure cookie works on the local rehearsal because browsers and curl treat `127.0.0.1` as
  a trustworthy context. Attributes `HttpOnly; Secure; SameSite=Lax; Path=/`, no `Domain` (host-only: staging and
  production never share a cookie, and a `Domain` on `dutyfreeprofessor.com` would reach the
  bot host).
- `sessions(token_hash PK, account_id FK, created_at, expires_at, last_seen_at, revoked_at,
  revoked_by_id, revoke_reason, amr, acting_as FK accounts.username NULL, acting_mode, ip,
  user_agent, data JSON)`; indexes on `account_id`, `expires_at`, partial on `acting_as`.
- **Absolute 30 days at launch, no sliding.** Logout revokes; a password set, reset or change
  revokes every other session of the account; disabling, and removal from the People panel
  (which disables, §4.10), revoke all; `sessions revoke
  --username | --all` is the break-glass. When consumers arrive the heartbeat (below) slides
  expiry under a 90-day cap, as a POST write.
- **Presence is a POST.** The SPA sends `POST /api/auth/heartbeat` every five minutes while the
  tab is visible; it updates `last_seen_at`. **The one GET that writes** is the kit's View As
  fail-closed auto-stop (`bw_view_as.verify` runs on every request and clears an
  impersonation that is no longer allowed): confined to `sessions.acting_as/acting_mode` plus
  its audit row, executed on the session store's own short connection, never inside the
  request's `get_db` session, so it can never join a domain-row transaction. `agents.md`'s
  rule gains that one named exception; a test asserts a GET to a catalogue route with a plain
  session issues no statement.
- The `SessionRow` mapping exposes exactly the kit's keys (`user`, `bw_acting_as`,
  `bw_acting_mode`) with a dirty flag; the loader middleware runs outermost, before the
  access middleware, and flushes a dirty row after the response.

### 4.4 Passwords, tokens, lockout

- **argon2id** (`argon2-cffi`): `time_cost=3, memory_cost=32768 (32 MiB), parallelism=1`,
  about 50 to 90 ms on one vCPU; a `Semaphore(2)` around hash and verify so a login flood
  queues instead of starving page serving in the 1 GB cgroup; `check_needs_rehash` on
  success; `accounts bench` prints the cost on the actual host; a test asserts the parameters
  stay at or above the OWASP floor. Unknown username, a disabled or `invited` account (a
  credential row with `password_hash` NULL), or a locked account: verify against a fixed dummy
  hash so timing is uniform; every non-verifiable case takes that branch.
- **Rules:** 12 to 256 characters, NFC-normalised, no composition rules, refused when it
  contains the username or the email's local part. `must_change_password` is set by
  `create_user_with_password` and `reset_password_generated`, cleared by `POST
  /api/auth/change-password` and by a welcome or reset redemption, and enforced server-side in
  `decide()` (§4.7), never only in the SPA: while set, every route outside `POST
  /api/auth/change-password`, `POST /api/auth/logout`, `GET /api/bw/me`, `GET /api/whoami`, the
  `PUBLIC_ALWAYS` set and the SPA shell answers `403 PASSWORD_CHANGE_REQUIRED` (JSON) or `302
  /account` (HTML), so a handed-over password can do nothing but replace itself.
- **Lockout, two layers:** per account in the database (8 failures within 15 minutes lock for
  15 minutes; success resets; `accounts unlock <username>`; `set-password` also clears), and
  the existing per-address in-process throttle retuned to 10 per address per 15 minutes and
  60 a minute overall (two people behind one office address must not lock each other out).
  The per-account lock is silent: while `locked_until > now` the login runs the dummy verify
  and answers the same `401 LOGIN_REFUSED` as a wrong password (the audit row says `locked`);
  only the address throttle answers `429 LOGIN_LOCKED_OUT`, so the response sequence never
  tells a username that exists from one that does not. Failures against a username with no
  row count in a second in-process throttle keyed on the folded name (8 per 15 minutes, no
  total), so eight wrong guesses at a name that does not exist behave exactly as eight at one
  that does. The address throttle is per worker;
  one uvicorn worker is a launch assumption written into the RUNBOOK (a `login_attempts`
  table replaces it before a second worker).
- **Tokens** (`account_tokens`: token_hash PK, account_id, mode `invite` | `reset`, created,
  expires, used_at, created_by_id; no redirect column, nothing needs one):
  `secrets.token_urlsafe(32)`, stored hashed, single use (marked used in the same transaction
  as the password write), 48 hours for both modes, minted only for non-disabled accounts,
  links built from the requesting host's `PUBLIC_BASE_URL` so a staging link never lands on
  production. **The token travels only in the link's fragment** (`<PUBLIC_BASE_URL>/welcome#<token>`,
  `/reset#<token>`) and then in a JSON body: a browser never sends a fragment, so uvicorn's
  access log (which prints every request line and is what `docker logs` shows) and the edge
  never see it. `POST /api/auth/token-state {token}` answers `valid | expired_resendable | used |
  unknown` and writes nothing; `POST /api/auth/welcome {token, password}` and `POST
  /api/auth/reset {token, password}` redeem. No route template under `/api/auth` and no SPA path
  carries a token segment, and a test asserts it. Redeeming revokes every other session and
  mints a fresh one. Expired-but-unused rows are kept seven days so the welcome page can offer
  a fresh link.
- **Forgot:** `POST /api/auth/forgot` by username or email; the response is always the same;
  5 per address per 15 minutes; 3 tokens per account per 24 hours. Redeems: 10 per address per
  15 minutes.
- **CSRF:** the session cookie is `SameSite=Lax`, and every non-safe request must carry an
  `Origin` or `Referer` whose host is the request host or `PUBLIC_BASE_URL` (today's check,
  now required rather than optional; the test client sends it, and every POST in the curl
  lists of §7 carries `-H 'Origin: <PUBLIC_BASE_URL>'`). That covers `/api/bw/*`, which carries
  no CSRF token of its own.

### 4.5 The local directory (what the kit calls `bw_auth`)

`LocalDirectory` in `services/directory.py`, passed as `bw_auth=` to `build_router`:

- `userinfo(username)` → `{username, email, first, last}` from a non-disabled row; **raises**
  when absent (the kit treats the exception as "new person").
- `invite_user(username, email, first, last)`: inserts the `accounts` row (`status='invited'`,
  email lowercased) inside a savepoint; an `IntegrityError` on username or email raises
  `AccountsError('EXISTS')` and writes nothing (the kit answers 400). It never continues on an
  existing row, whatever its status: the kit reaches this path only after `userinfo` raised, so
  an existing row here is a disabled account, an email held by another account, or a
  concurrent duplicate, and each is a refusal (the savepoint-and-re-read rule is for shared
  domain rows, not principals). Then it mints an invite token and sends it through the mailer. **With no mail provider it raises
  `MailNotConfigured` before writing anything**, which the kit surfaces as `400 INVITE_FAILED`
  with a message naming the alternatives. A link is never part of an HTTP response.
- `create_user_with_password(...)` → the row plus a credential from a generated password of
  16 characters in the kit's unambiguous alphabet (no 0/O/1/l/I), grouped `xxxx-xxxx-xxxx-xxxx`,
  returned once and never stored or logged; `must_change_password` set. Same insert, same
  `EXISTS` refusal: a credential is created only for a row this call inserted (the kit's
  new-accounts-only boundary); an existing username of any status, disabled included, gets no
  password and no token. This is the working invite path on a host without mail; it sits
  behind `accounts.add`, the owner's alone at launch.
- `reset_password_generated(username)` → a fresh generated password in the same alphabet,
  `must_change_password` set, every session of the account revoked, returned once (the kit's
  owner-only new-password route answers `DELIVERY_UNSUPPORTED` without it).
- `send_reset`, `notify_added`: through the mailer, or raise. `search_users(q, limit)`: prefix
  match on username and display name over active accounts. `user_access` → `[]`;
  `report_access`, `report_instances` no-ops; `report_impersonation` writes the audit row.
- **The CLI is the launch path:** `accounts invite-link <username>` and `accounts reset-link
  <username>` print a fresh one-time link once to the operator's terminal, never to a log;
  `accounts set-password <username>` prompts (never argv); `accounts create --username --email
  --display-name --level`; `accounts disable | enable | unlock | list | owner-check | bench`
  (`disable` sets `status='disabled'` and `disabled_at`, revokes the account's sessions and
  leaves `account_members` untouched, so a disabled member is still a member to the kit and its
  invite route refuses with `EXISTS` before the directory is asked; a disabled account's
  credential changes only through `set-password` or, after `enable`, a reset link);
  `sessions revoke --username | --all`, `sessions prune`; `audit-log prune`.
- **Mail seam:** `services/mail.py` defines `Mailer.send(to, subject, text)`; `MAIL_PROVIDER`
  selects `none` (default; raises) or `resend` (`RESEND_API_KEY`, `MAIL_FROM`; an explicit
  User-Agent; the recipient is always the stored address). No console provider: printing a link
  to stdout is logging a token, and so is a request line with a token in its path or query,
  which is why the token rides in the fragment (§4.4). The Resend provider is R1's "if time remains" and R2's
  requirement; its secret goes in an app-only env file (§7), never the shared `.app.env`.

### 4.6 Permissions, levels, the super admin

Six DFP permissions, registered with `register_enforced` so the Levels editor labels them
honestly, plus the kit's own `accounts.*`, `levels.*`, `instances.*`:

| Permission | Gates |
|---|---|
| `client.view` | `/discuss`, `/structure`, `/quote`, `/todo`, `/settings` and their reads; the walkthrough video and explainers; hand-in downloads |
| `client.participate` | comment as oneself, edit one's own comment, set a priority, toggle a quote line, send a quote request, complete or reopen a to-do, upload a hand-in |
| `discussion.curate` | add, reword or resolve a decision card; edit anyone's comment; later resolve threads |
| `plan.view` | `/plan`, `/issues`, `GET /api/plan`, `GET /api/items`, the `item:*` comment threads, read and write |
| `items.act` | decide, done, dismiss, reopen on the running list |
| `sources.manage` | the `/sources` page and `GET /api/sources` (collector health with retailer-derived error text; build plan §10 #4 keeps it off the client pages), the kill switch; R3's oversight reads and writes. The page's coverage reads (`GET /api/sources/coverage`, `/uncovered`, `/expandable`) are storefront reads the `/airports` page also makes, and stay in `PUBLIC_WHEN_OPEN` |

Seeded by `app.cli backfill levels` (insert if absent, never update an edited level), two
levels: **`admin`** = `client.view` + `client.participate`, assignable nothing, **held by Adam and
Mark** (rian's word for them; the meaning is DFP's, not the kit's template of the same name);
**`member`** = nothing, the level a consumer is promoted onto if they ever need a membership
row (consumers hold none by default, §10). No kit template is seeded: the kit's `admin`,
`manager`, `team` and `collaborator` templates stay available from the Levels editor
(`adopt_template`) under names that do not collide, for the day rian delegates account
administration. `plan.view`, `items.act`, `discussion.curate`, `sources.manage` and
`accounts.view_as` are held by no level, so at launch they are the owner's by absence. Nothing
in the kit or its conformance suite constrains production's level names: the suite runs against
the two templates its own test store seeds (§4.12), never against production's seed. The one
constraint is that the kit has no rename, so a name is fixed at the first seed (§12). The UI
never switches on a level name: it reads the `/api/bw/me` booleans plus the DFP `capabilities`
block, which DFP's own `GET /api/bw/me` adds (§4.11).

### 4.7 The route policy and members-only mode

`services/access.py` replaces `mutations.py` and `owner_auth.py`. Every route is in exactly
one class, reads and page routes included:

- `PUBLIC_ALWAYS`: `GET /api/health` (ok and version only), `GET /api/bw/me`, `POST
  /api/auth/login | logout | forgot | token-state | welcome | reset` (all POST bodies; no token
  in any path), `/robots.txt`, the `/assets`, `/fonts`, `/medals` mounts, the shell's root files
  (`ROOT_FILES`: the regular files in the shell directory taken at mount time, matched by exact
  path; a path with a `..` segment or an encoded slash is never a root file), and the SPA paths
  `/login`, `/forgot`, `/welcome`, `/reset` (the token is read from `location.hash` client-side
  and never appears in a request path).
- `PUBLIC_WHEN_OPEN`: every read a storefront page or the site chrome makes (the catalog router:
  products, featured savings, a product and its similar products, airports, brands, the dataset,
  stats; the articles router; the trip router; `GET /api/sources/coverage`, `/uncovered` and
  `/expandable`, which the `/airports` page reads), the server-rendered page routes (product,
  airport, brand, article, `/data`), `/sitemap.xml`, `/feed.xml`, `/llms.txt`, `POST
  /api/subscribers`, the storefront SPA paths (`/`, `/products`, `/products/:slug`, `/savings`,
  `/trip`, `/exclusives`, `/awards`, `/airports`, `/airports/:slug`, `/brands/:slug`, `/articles`,
  `/articles/:slug`, `/data`, `/feature/:slug`) and the not-found shell. Public only when
  `SITE_ACCESS=public`; otherwise any signed-in account. The initial
  `tests/fixtures/open_routes.json` is exactly this list.
- `MEMBER`: every `/api/bw/*` route listed explicitly (the kit enforces its own permissions
  behind that floor; a re-vendor that adds a route fails the inventory until classified),
  `POST /api/auth/change-password`, `POST /api/auth/heartbeat`, `GET /api/whoami`, `/account`.
- `PERMISSION`: route → one permission string, or `owner` (`GET /api/ops/impersonations`).
- `SPA_*` sets classify the catch-all by path; `MOUNTS` classifies the static mounts
  (`/docs-static` is `client.view`).

`decide(method, key, page, site_open, signed_in, active, must_change, holds, read_only,
origin_ok, wants_html)` is pure. `wants_html` is computed by the middleware from the route key
alone, never from the `Accept` header: true for the page routes (product, airport, article,
brand, `/data`), the `/docs-static` mount and the catch-all (every SPA path and the not-found
shell); false for every `/api/*` key and the machine files, which have their own members-mode
answers. Order: unclassified → `403 ROUTE_NOT_CLASSIFIED`; origin on non-safe
methods → `403 ORIGIN_MISMATCH`; read-only View As on non-safe methods except stop and logout →
`403 VIEW_AS_READ_ONLY`; anonymous on a non-public route → `302 /login?next=<path>` for HTML
(`next` is the request path passed through `safe_next()` in `services/access.py`, else `/`;
the same helper is the only place a redirect target is ever accepted, on the server and, as
`safeNext()` in the SPA, on the client: `urlsplit` gives an empty scheme and netloc, the value
starts with a single `/`, contains no backslash, no control character and no `%5C` or `%2F`
after decoding, and is at most 200 characters; anything else becomes `/`. The two helpers
share `tests/fixtures/next_cases.json`), `401 NOT_SIGNED_IN` for JSON;
disabled → `401 ACCOUNT_DISABLED`; `must_change` on any route outside the change-password
exemption set (§4.4) → `403 PASSWORD_CHANGE_REQUIRED` (JSON) or `302 /account` (HTML);
permission missing → `403 FORBIDDEN` for JSON, the 404
shell for HTML when the permission is `plan.view` (a client is not told `/plan` exists).

Members-only mode (`SITE_ACCESS=members`, the code default, fail closed):

- Anonymous HTML requests, known route or not, answer a uniform 302 to `/login` so route
  existence is not disclosed and no server-rendered body (prices on product, airport, brand,
  article and data pages) is ever built for an anonymous caller: the middleware refuses
  before routing. The catch-all's file branch confines to the shell root (`resolve()` and a
  parent check, fixed on 10 Sep with `tests/test_catch_all_confinement.py`; uvicorn passes
  `..` and `%2e%2e` through unnormalised), and `ROOT_FILES` is matched by exact path, so no
  traversal reaches a file in any mode.
- `/robots.txt` answers 200 `User-agent: *` / `Disallow: /` with no Sitemap line (a 302 or 401
  there reads as "no restrictions"). `/sitemap.xml`, `/feed.xml`, `/llms.txt` answer a plain
  404 (machine files; the inventory test lists this frozenset).
- Every response carries `X-Robots-Tag: noindex, nofollow`, the login page included, so it is
  never indexed under a product URL. `Cache-Control: no-store` stays on HTML and JSON.
- FastAPI's `/docs`, `/redoc`, `/openapi.json` are off in production regardless of mode
  (`check.sh` and the Docker build call `app.openapi()` in-process). `INDEXNOW_KEY` stays
  unset until E7.
- The flip to `public` (an env line and a container recreate, E7's step): the
  `PUBLIC_WHEN_OPEN` set opens, robots returns the real per-bot policy with the Sitemap line,
  the machine files answer 200, the `X-Robots-Tag` stops, the not-found shell answers 404 to
  anonymous callers. Nothing else moves.

The inventory test (`tests/test_route_inventory.py`, rewritten) walks every `APIRoute` for
every method, every mount, the page routes (the site mounted over a temporary shell so they
are present) and every `<Route path>` in `App.tsx`, and asserts each is in exactly one class,
every `PUBLIC_WHEN_OPEN` key is in `tests/fixtures/open_routes.json` (opening a route is a
reviewed diff), every public write carries a reason, and every permission string is
registered. `test_site_routes.py`-style tests pin the members-mode answers.

### 4.8 Authorship from the session

Every write that names a person takes it from the session, never the payload: the `author`
and `who` fields leave `FeatureCommentIn`, `DiscussionCommentIn`, `FeaturePriorityIn`,
`QuoteSelectionIn`, `QuoteRequestIn`, `ActIn`, `WhoIn` and the upload form; the routes set
`<col>_id` to the **effective** account and the text column to its display name as a snapshot
(the text stays the raw record). Comment edits: one's own under `client.participate`, anyone's
under `discussion.curate`. The feature-comments routes are scoped by key prefix inside the
route, since `decide()` sees only the route key: the GET filters `item:*` rows out unless the
caller holds `plan.view`; `POST /api/discussion/feature-comments` with an `item:*` key, and the
edit route on a comment whose key is `item:*`, answer 404 unless the caller holds `plan.view`
(the same non-disclosure as the `/plan` 404 shell; `discussion.curate` alone does not open
them); every other key is `client.view` to read and `client.participate` to write. The name boxes,
`useCommenterName`, the `dfp.commenter` key and the hard-coded "Adam" fallbacks go
(`OwnerGate` goes with the capability guards, §4.11). Existing typed names: **no guest accounts are minted** (a deviation from build plan §2's
`guest:<name>` line, filed as a decide). `app.cli backfill authors --map Adam=adam --map
Mark=mark --map rian=rian [--check] [--include-defaulted]` links the five tables by folded
text; `feature_priorities` and `quote_selections`, where the SPA silently wrote "Adam" for an
anonymous actor, link only under the explicit flag rian chooses. Unknown spellings stay text.

### 4.9 View As

The kit's mechanism, DFP's policy: `can_view_as(real, target)` = owner or `accounts.view_as`;
`target_valid` = any `accounts` row with `status='active'` that is not the owner, membership
not required (a consumer's `can()` misses and the kit's rank hook returns 0, so the rank guard
still holds); **act mode is the owner's alone**
(rian's "view and act as any user"); read-only mode blocks every mutation but stop and
logout in `decide()`. In act mode a domain write stores the effective account's id (a comment
made as adam is adam's row). The record of who really acted is written in one place: the
access middleware emits `act_as.write` for every non-safe request it admits while the
session's `acting_mode` is `act`, with `account_id` the real account, `acting_as_id` the
effective one and `detail = {key, status}`, after the response, on the audit sink's own
connection; routes add nothing for act mode. The sink fills both ids from the request session
for every row it writes, the kit hook's rows included. That the row exists is a test
assertion, not a convention. The banner is the pack's, un-hideable. The pack's
`ViewAsPicker` lists targets from the kit's `GET /api/bw/view-as/targets`, which iterates
members; at launch every non-owner account is a member, so it suffices; when consumers arrive
the list becomes DFP's own (`GET /api/ops/view-as-targets` over active accounts) and the pack's
picker is unmounted, never edited. Active
impersonations are one SELECT over `sessions` (`GET /api/ops/impersonations`, owner), no
heartbeat protocol; the trail is `audit_log WHERE action LIKE 'view_as.%'`.

### 4.10 Audit

`audit_log(id, at, account_id, acting_as_id, session_hash, action, entity_type, entity_key,
detail JSON, ip)`. Written by the kit's audit hook (`add_member`, `set_member_level`,
`create_level`, ...), by every auth event (`login.ok`, `login.refused {reason}`, `login.locked`,
`logout`, `session.revoke`, `password.set {via}`, `token.mint`, `token.redeem`, with `reason`
one of `bad_password | unknown | locked | disabled | invited`,
`account.create | disable | enable`, `view_as.start | stop`), by the access middleware
(`act_as.write {key, status}`, §4.9), and by the kill switch, which today records no actor. The
sink is also DFP's one policy hook on the kit: on `remove_member` it runs the same path as
`accounts disable` (status, `disabled_at`, every session revoked with reason `removed`, an
`account.disable {via: panel}` row), because the kit's own remove only flips
`account_members.active`, which neither login nor the session loader reads, while the pack's
Remove button tells the owner it revokes access; `add_member` and the invite path refuse a
disabled username with `AccountsError('DISABLED')` until `accounts enable`, and enable never
re-adds the member row. Never a password, a token, or a typed unknown
username (it may be a password typed into the wrong box). `audit-log prune` keeps a bounded
history for the noisy actions.

### 4.11 The SPA

- `pages/LoginPage.tsx` (DFP's own form: username or email, password, "Forgot password?"; the
  pack's `SignIn` is an anchor to an OAuth start and is not used; it reads `?next=` through
  `safeNext()` in `lib/auth.ts` and after a successful login calls `navigate(safeNext(next),
  {replace: true})`; the helper is load-bearing, since the router falls back to
  `location.assign` when `pushState` throws, and nothing in the SPA ever assigns `location`
  from a query value or a server field), `ForgotPage`, `SetPasswordPage` (welcome and reset
  share it; the token comes from `location.hash`, is posted in a JSON body and never appears
  in a request path), `AccountPage` (change password, sign out everywhere),
  `AdminPage` at `/admin` composing the pack's `PeoplePanel`, `LevelsPanel`, `MatrixPanel`
  under DFP's own tabs (the pack's `AdminApp` and `AccountMenu` are not mounted), the pack's
  `ViewAsBanner` and `ViewAsPicker`.
- `BwAuthProvider` from the pack is the one `/api/bw/me` fetch; refetch after writes and on
  focus, never on a timer. The DFP `capabilities` block rides in the same response because DFP
  registers its own `GET /api/bw/me` in `routers/auth.py` before `main.py` includes the kit
  router (FastAPI's first-registered route wins, so the kit's own `/me` is shadowed, never
  edited): the handler calls the vendored `me_payload(...)` with the same arguments
  `build_router` receives and adds `capabilities: {client_view, client_participate,
  discussion_curate, plan_view, items_act, sources_manage, must_change_password}` from
  `can()` and the credential row, all false for an anonymous caller; with
  `must_change_password` true the SPA renders `AccountPage` on the change form and nothing
  else until the refetch clears it. The SPA declares `type DfpMe = Me & {capabilities}` in its own file and a
  `useDfpMe()` wrapper over the pack's `useMe()`; the vendored `types.ts` is not edited.
- The header gains DFP's own account menu (name, `/account`, `/admin` when
  `can_manage_accounts`, View As when `can_view_as_others`, sign out); the footer's "Behind the
  scenes" links draw by capability (`client_view`, and `sources_manage` for `/sources`); nothing
  else changes in the storefront chrome. `/plan` and its controls draw only with `plan_view`; the `/sources`
  kill switch with `sources_manage`; curator mode on `/discuss` with `discussion_curate`.
- The pack's `bw-admin.css` is themed by mapping its `--bw-*` tokens onto DFP's tokens in
  `tokens.css`; the vendored folder's stylesheet is imported once from the pack's entry.
- Copy in the house voice: "Sign in to see the site. It is members only while we build."
  "Wrong username or password." (also what a locked account sees) "Too many attempts. Try
  again in a few minutes." (the address throttle only). No em dash,
  never "free". `test_house_style.py` walks the new files.

### 4.12 Tests

- `tests/kit/`: the five conformance files byte-identical. They import `from tests.conftest
  import as_user, HAS_INSTANCES`, which under `pythonpath=['.']` resolves only to a root
  `main/tests/conftest.py` (new; neither a `tests/kit/conftest.py` nor `__init__.py` files can
  satisfy it), so the contract's importables live at the root: `as_user`, `as_session`,
  `OWNER='rian'`, `HAS_INSTANCES=False`, the test environment set before any `app.*` import,
  and the `pytest_collection_modifyitems` deselect set. The fixtures live in
  `main/tests/kit/conftest.py`: `client`, `kit`, `bw_calls` and any autouse store reset, so no
  DFP pure test acquires an accounts store or a monkeypatch and the suite keeps its second.
  `bw_calls` is the kit's `list[(path, payload)]`, fed by wrapping the `LocalDirectory`
  instance handed to `build_router`: each method appends the kit's central path name and its
  arguments before it runs, so a call that then raises is still visible: `userinfo` →
  `/app/userinfo`; `invite_user` → `/app/invite-user` with `delivery: 'email'`;
  `create_user_with_password` → the same path with `delivery: 'password'`; `notify_added` →
  `/app/notify-added`; `send_reset` → `/app/send-reset`; `reset_password_generated` →
  `/app/reset-password`; `search_users` → `/app/search-users`; `report_impersonation` →
  `/app/report-impersonation {real, target, mode, active}`. The mailer under test is a
  recording in-memory `Mailer` the kit conftest injects into the seam (`sent: list[(to,
  subject, text)]`), never a `MAIL_PROVIDER` value, so invites, notifications and resets
  succeed in the suite and no link is printed; a DFP test may read `sent[-1]` for the token
  state machine. The store reset also inserts active `accounts` rows for the kit's stubbed
  central users (`alice, aline, bob, carol, dup, existing`, email `<u>@example.com`), because
  the byte-identical tests expect `userinfo` to resolve them and `search_users('al')` to
  return exactly `alice` and `aline`. The
  shared in-memory SQLite engine is a plain helper module (`main/tests/kit/_env.py`), holding
  only the auth tables (new tables use `JSON().with_variant(JSONB, 'postgresql')` so SQLite can
  create them). The kit's store runs mapped, as in production. The kit conftest's store reset
  seeds only the kit's two templates, `admin` and `member`, through `bwa.create_level(owner,
  ...)`, never through `backfill levels`: `test_capabilities_me.py` pins the owner's
  `assignable_levels` to exactly those two. DFP's own suites seed DFP's `admin` and `member`
  through `backfill levels` in their own fixture, and take basenames that do not collide with
  the five kit files (pytest's prepend mode errors on duplicate basenames across `tests/` and
  `tests/kit/`).
  `GET /api/whoami` exists because `test_view_as` calls it (keys `username` effective,
  `real_user`, `is_owner` of the effective user). The deselect set is listed in the conftest
  by node id with a reason; with the recorder, the mailer and the seed above it is empty, and
  `test_start_and_stop_are_reported_to_central` stays because `services/view_as.py` keeps
  caddie's `_report` hook pointed at the local directory. `agents.md`'s test rule
  gains one clause: the accounts suite runs against an in-memory SQLite, still no server and
  no network.
- DFP's own: the pure `decide()` walked case by case, including the `next` cases (`/ok`,
  `//evil.example`, a backslash form, `/%5Cevil.example`, an absolute URL, a 201-character
  path, a control character, each answering `/login?next=/`; `safe_next()` and `safeNext()`
  share `tests/fixtures/next_cases.json`) and the must-change gate; the inventory over reads,
  pages and mounts, plus the assertion that no `/api/auth` template and no SPA path carries a
  token segment; members-mode answers (302, 401, robots, machine 404s, docs off,
  `X-Robots-Tag`), unchanged under `Accept: application/json`, `text/html` and no `Accept`;
  the traversal probes (`tests/test_catch_all_confinement.py`, extended to the middleware:
  `/../../etc/hostname` and its `%2e%2e` form answer 404 in public mode, 302 or 401 in
  members mode, never a file body); the identical response sequence (status, code, body,
  `Set-Cookie`, `Retry-After`) for an existing and a non-existent username over twelve wrong
  attempts from one address on the fake clock; removal from the People panel (the old cookie
  answers `401 ACCOUNT_DISABLED`, a fresh login is refused with `disabled`, re-adding answers
  `DISABLED` until `accounts enable`); `invite_user` and `create_user_with_password` against
  an existing username of any status and an existing email under another username raise
  `EXISTS` and change no credential or token row; a redeem through the test client with
  `caplog` on `uvicorn.access` and the root logger captures no substring of the token; an
  act-mode write with no who-column (a comment edit) produces exactly one `act_as.write` row
  naming both ids and a read-only View As write produces none;
  cookie flags; login round trip; dummy-verify timing branch; lockout arithmetic on a fake
  clock; token single-use state machine; password rules; authorship from the session; the
  GET-writes-nothing assertion; the owner sentinel (`is_owner('rian')` false when
  `ACCOUNT_OWNER` is unset or a placeholder); the alias backfill's idempotency.

### 4.13 Documentation

`main/docs/ACCOUNTS.md` is the one home for the mechanism ("Sources of truth: services/accounts.py,
identity.py, sessions.py, access.py, passwords.py, directory.py, mail.py, audit_log.py,
view_as.py, routers/auth.py, the vendored `bw_*` modules, tests/kit/"). `ARCHITECTURE.md`
step 2 and step 8 change; `CLIENT-SURFACES.md`'s comments and positioning rules change;
`RUNBOOK.md` replaces "Owner sign-in" with "Accounts" (the CLI, seeding a host, the rollout
order, transfer of ownership, the one-worker note; its "mode 600 or 660" and "mode 660"
sentences become the tightening recipe of §7 step 2 with the `fix-permissions` caveat, and
`.app.env.example`'s header drops "chmod 600"; one sentence that the container's stdout is an
access log of every request line, so nothing secret may ever sit in a URL path or query); `DATA-MODEL.md` regenerates; `agents.md`
replaces the stopgap lines under "Decided 2026-09-04" within its 140-line budget (it stands at
134); `.app.env.example` carries `ACCOUNT_OWNER=REPLACE_WITH_USERNAME` and `SITE_ACCESS=members`
and loses the two stopgap lines; the CHANGELOG's Unreleased section reads as one deploy.

## 5. Security review (the server checklist, answered before code)

- **Port binding:** unchanged; the app stays on `172.17.0.1:<port>` behind Caddy; nothing new
  listens. The browser sidecar is untouched (no env file, its own network).
- **Authentication:** the app's own login is the only barrier once the server gate lifts;
  there is no bypass list, no trusted subnet, no header trust (`X-Auth-User` is never read; a
  grep gate in `check.sh` keeps `main/app` free of it, because after `id-gate --mode public`
  Caddy no longer strips it).
- **Network exposure:** after the gate lifts, staging is internet-reachable through
  Cloudflare; every non-public path answers 302 or 401 to anonymous callers; FastAPI docs are
  off; the machine files answer 404 while members-only.
- **Credentials:** argon2id hashes in a table no listing query touches; tokens stored hashed,
  single use; the session cookie is opaque, host-only, HttpOnly, Secure; no new secret enters
  `.app.env` in R1 (which the db container also reads, a separate issue filed for E);
  `.app.env` tightens to owner-only plus a read ACL for `srv-gateway` (§7 step 2): the gateway
  runs the compose deploy as `srv-gateway` and reads the env file itself, so a bare 600 owned
  by rian masks it out and the deploy fails, while 660 leaves it readable by every developer
  in the project group; nothing is ever printed or pasted.
- **Container isolation, VPN, AutoRun:** unchanged; none.
- **Brute force:** per-account lockout in the database plus a per-address throttle keyed on
  `CF-Connecting-IP`; the droplet's Caddy must carry the Cloudflare `trusted_proxies` block or
  every visitor shares one key (filed for E7).
- **After any change on this server:** `srv-gw security-audit`.

## 6. Data: migration #4 and the backfills

One schema-only revision, `down_revision = "a4b5c6d7e8f9"` (the single head), every column
NULL or server-defaulted, downgrade in reverse:

1. `accounts` additions (§4.2). 2. `account_credentials`. 3. `account_levels`,
`account_members`, `account_grants`. 4. `sessions`. 5. `account_tokens`. 6. `audit_log`.
7. `overrides` exactly as build plan §4 #1 (`entity_type`, `entity_key`, `field`, `value`,
`collected_value`, `set_by` FK accounts NULL, `set_at`, `reason`, `collector_disagrees_since`;
`UNIQUE(entity_type, entity_key, field)`): it rides now because the plan names it as #4's
content and a reader exists (`award_picker.pin_from_override`); Stream F wires the pin after
(`decide-overrides-before-r` closes when it lands). 8. The seven who-columns, each `Integer NULL
FK accounts.id`: `discussion_comments.author_id` (indexed), `feature_priorities.author_id`,
`quote_selections.author_id`, `quote_requests.author_id`, `client_todos.completed_by_id`,
`client_uploads.uploaded_by_id`, `owner_item_states.acted_by_id`. Not in #4: threads and
notifications (R2), schedules (R3), `email_sends` (with the Resend provider), a separate
identities table (Google later is `google_sub` on the credential row).

Backfills, idempotent, each with `--check` (report what a run would change, write nothing,
exit 0): `backfill accounts` (extended: the seeded owner row
gets `username='rian'`, `status='active'` where NULL), `backfill levels`, `backfill authors`
(§4.8), `backfill overrides` (reports 0; the home for a later human-edit source). Rehearsal on
`dfp-devdb` from the newest nightly dump: upgrade, backfills twice, downgrade, upgrade.

## 7. Rollout

**Staging (the stream session, then rian).** The session lands R1 with `check.sh` green
(vendor check and conformance included), the migration rehearsed, the CHANGELOG's `## Unreleased`
section rewritten as one deploy (the stopgap's "before deploy" line replaced by the before and
after lists), and hands over "green and ready". The session never bumps the version; rian does,
at the deploy checkpoint. Rian, in order:

1. Nothing collecting (`pgrep -af app.cli`, `.logs/runs/`), then the dump:
   `docker exec dutyfreeprofessor-db pg_dump -U dfp -Fc dfp > backups/dfp-$(date +%F)-accounts.dump`.
2. In his own shell: `ACCOUNT_OWNER=rian` and `SITE_ACCESS=members` into `.app.env`; delete
   `OWNER_PASSWORD` and `SESSION_SECRET` if present (ignored either way); tighten the file so
   only rian and the gateway read it: `setfacl -b .app.env && chmod 600 .app.env && setfacl -m
   u:srv-gateway:r .app.env` (a bare `chmod 600` sets the ACL mask to nothing and `srv-gw
   deploy` fails: the gateway's `docker compose` reads `env_file` as `srv-gateway`, which today
   reaches the file only through the project group's ACL); check with `sudo -u srv-gateway test
   -r .app.env && echo readable` (reads nothing); `srv-gw fix-permissions` widens it back to 660
   plus the group ACL, so re-run the `setfacl` line after any permissions repair; `grep -c
   REPLACE_WITH_ .app.env` prints 0.
3. Bump `APP_VERSION` in `main/app/version.py`, the `Live version` line in `brief.md` and the
   CHANGELOG's `## Unreleased` heading to that version with today's date (`bash main/docs-check.sh`
   agrees), `main/check.sh`, commit; then `srv-gw deploy --project dutyfreeprofessor --build`;
   the container applies the migration on start (`docker logs dutyfreeprofessor-app` shows the
   upgrade).
4. Inside the container: `backfill accounts`, `backfill levels`, `accounts create --username
   adam --email <his> --display-name Adam --level admin` and the same for mark, `accounts
   set-password --username rian` (prompted), `accounts invite-link --username adam` and `mark`
   (printed once as `https://<host>/welcome#<token>`; rian sends them himself), `backfill authors --map Adam=adam --map Mark=mark
   --map rian=rian`, then `awards --rebuild` from the earlier release.
5. The session's curl list from the host against `http://172.17.0.1:<port>` (no Caddy, no
   id-auth): `/` 302 to `/login`; a product, an airport and `/data` 302; `/api/products` and
   `/api/stats` 401 `NOT_SIGNED_IN`; `/api/health` 200; `/docs`, `/openapi.json`, `/sitemap.xml`,
   `/feed.xml`, `/llms.txt` 404; `/robots.txt` `Disallow: /`; every HTML answer `X-Robots-Tag`
   and `no-store`; `curl --path-as-is` of `/../../../../../../etc/hostname` and its `%2e%2e`
   form 404 with no file body; every POST with `-H 'Origin: https://dutyfreeprofessor.demoing.info'`
   (a POST without it is `403 ORIGIN_MISMATCH`, itself one assertion): a wrong password 401 with
   no `Set-Cookie`; a right one answers `Set-Cookie: __Host-dfp_session=...; HttpOnly; Secure;
   SameSite=Lax; Path=/` read from the response headers (`curl -i`), over this plain-http
   transport too, because the flag follows `APP_ENV`, not the scheme; the ninth wrong attempt
   with the owner's username 401 like the eighth; the eleventh from one address 429 with any
   username; an owner route 200 when the cookie value is replayed with `-b
   '__Host-dfp_session=<value>'` (by value, not a jar, which may withhold a Secure cookie over
   http).
6. In a browser through the public host (the shared password, then the app login, for one
   day): rian sees `/plan`, starts View As adam read-only and act, is refused a write in
   read-only mode; adam and mark redeem their links, see `/discuss`, comment as themselves; an
   anonymous window sees only `/login`.
7. `srv-gw security-audit`.
8. **Owner only:** `srv-gw id-gate --site dutyfreeprofessor.demoing.info --mode public`. From
   then on the server's gate, its presence tracking and its header stripping are gone from
   this host; the app's login is the only barrier. Rollback is `srv-gw id-gate --site
   dutyfreeprofessor.demoing.info` (the app's login keeps working behind it: the gate routes
   only `/_grant` and `/bw-logout` to id-auth).
9. Repeat the curls through `https://dutyfreeprofessor.demoing.info` (the same Origin header
   on the POSTs, the two traversal probes included): 302 to the app's own `/login`, never to
   `auth.bowden.works`; no `X-Auth-User` echoed anywhere. Confirm in the
   Cloudflare dashboard that the zone's cache rules bypass `/api/*`, `/login`, `/auth/*`,
   `/account*` and that Bot Fight Mode is off.
10. `srv-gw security-audit` again; `/checkpoint`; resolve the stopgap decide as superseded.

**Production (Stream E with rian present, after staging holds).** E4 provisions the droplet;
`.app.env` there is staged at 0600, owned by the user that runs compose on the droplet, with
placeholders for `DATABASE_URL`, `POSTGRES_PASSWORD`,
`PUBLIC_BASE_URL=https://dutyfreeprofessor.com`, `ACCOUNT_OWNER`, `SITE_ACCESS=members`,
`FEATURE_CLIENT_TODOS`; no `INDEXNOW_KEY`, no stopgap lines; rian fills them in his own shell.
`deploy/production.sh` (E4b) dumps first because a migration rides, syncs, builds, and the
container migrates on start. Rian seeds over SSH exactly as staging, with production-only
passwords and links minted on the production base URL. The curl list runs through the domain
credential; **only then** the domain password comes off (the `basic_auth` block or the Access
rule), and the curls run again anonymously. The droplet's Caddy carries the Cloudflare
`trusted_proxies` block. E7 later flips `SITE_ACCESS=public` together with the IndexNow key,
the real robots and sitemap, and the uptime checks.

**Never, on either host:** `srv-gw app-client-register`; `srv-gw add-dns` for a second hostname
on the app container while any write route is open; minting `INDEXNOW_KEY` before E7;
`SITE_ACCESS=public` before E7; a `Domain` attribute on the cookie; `tailscale serve` on 80 or
443; hand-editing `sites.d`; a session editing `.app.env`; a deploy inside a crawl window.

**The stopgap** (commit d5f89b0, unreleased at the time of writing): R1 replaces it in code. If
rian deploys it first, nothing changes: R1's deploy simply stops reading the two lines and the
RUNBOOK tells him to delete them.

## 8. R2: discussions on the Interaction Standard

Caddie's implementation is the extraction source (`/srv/apps/caddie/main/app/services/interaction.py`
and the `caddie-ui` pack). What is standard there ports verbatim; what is caddie-specific is
five hooks DFP supplies (deep link, subject resolver, recipient policy, mention directory,
base URL).

- **Chrome:** the whole `caddie-ui` pack stamped byte-identical into `main/web/src/vendor/caddie-ui/`
  (its stamp script with `--src-dir`; added to the vendor manifest), `caddie-ui.css` imported
  once, its `--cu-*` tokens mapped in `tokens.css`. Mounted: `Bell` in the header's actions
  behind a 30-line adapter (poll `GET /api/notifications` every 30 s, paused while hidden),
  `ThreadCard`, `ThreadReplies`, `ThreadStarter`, `Composer`, `Body`, `Lightbox` inline where
  each subject already renders. Not mounted: `GlobalBar`, `AppBar`, `LocatorProvider`;
  `SidePanel` only once `SiteHeader` publishes a measured bottom. `Body` is the one vetted
  renderer (no `innerHTML`, images only under the app's own prefix), which is the security
  reason to take the pack rather than write components. The pack's canonical copy lives in
  another tenant's workspace; after a transfer the manifest check stands in for it, and the
  fallback is a fork under DFP's own name.
- **Schema, migration #6:** `threads(id, subject_type, subject_id, created_at, resolved,
  resolved_by_id FK accounts, resolved_at, UNIQUE(subject_type, subject_id))`;
  `discussion_comments` gains `thread_id` (FK NULL), `edited_at`, `deleted_at`, and
  `feature_key` widens to 80; `notifications` in the standard's §5 event shape with
  `recipient_id` and `actor_id` as `FK accounts` (the who-columns rule; usernames are joined
  at read time), `dedupe_key` UNIQUE, `read`, `resolved`, `delivered_at NULL`;
  `email_sends(bucket, sent_at)`.
- **Subjects** (keys never renamed): `decision:<card id>`,
  `feature:<board key>`, `page:discuss` for the literal `general`, `structure:<key>`,
  `quote:<key>` and `quote:later-<slug>` (the slugs frozen as explicit keys in `quote.ts`
  before the backfill), `todo:<id>`, `item:<running-list id>`; candidates: plan tasks and the
  quote promises on `/plan`. Deep links `/<page>#<anchor>#c-<comment id>` with a `domId` on
  every card.
- **Rules:** author from the session; author-only edit with `edited_at`, curate edits any;
  soft delete; resolve propagates to the notifications a thread raised; mentions parsed
  server-side from the stored body against a directory scoped by the subject's permission (an
  `@` of someone who cannot see the subject is dropped silently, so a plan thread never
  notifies a client); fan-out = participants plus the mentioned plus `discussion.curate`
  holders, never the actor, deduped; **decision is an event kind**, emitted when rian records
  a decision or resolves a card, never a thread state (state stays in `owner_item_states` and
  `discussion_items`).
- **Migration path keeps every comment:** `app.cli backfill threads [--check]` splits
  `feature_key` on the first colon, maps bare board keys and `general`, maps `item_id` to a
  `decision` thread, sets `thread_id`, sets `edited_at` where `updated_at` exceeds
  `created_at` by two seconds; reads move to one `discussion_of(subjects)` per page while the
  old comment POST accepts `feature_key` for one release; a later schema-only migration makes
  `thread_id` NOT NULL and drops `item_id`, `feature_key` and the check constraint.
- **Mail:** the Resend provider, `MAIL_*` in an app-only env file (a compose edit: security
  review first, then `srv-gw security-audit`), caps from `email_sends` (6 auth mails per
  account per day, 25 invites per host per day, 40 notification mails per account per day),
  invites and resets by email, and `app.cli notify digest` every 30 minutes from host cron on
  the machine E5 picks. Per-user preferences (`account_preferences`) come with it.
- **Slices:** T1 threads + backfill + the ported service; T2 the pack stamped, `/discuss` on
  it; T3 the other surfaces; T4 notifications and the bell (mention, reply, decision); T5
  attachments in comments through `services/uploads.py`'s sniff-and-strip seam under
  `/api/attachments/{subject}`; T6 mail.

## 9. R3: collection oversight

Reads first, all read-only over tables that exist, under `/api/ops/*` gated by `sources.manage`
(the owner's alone at launch): every source including hidden and never-run ones with its last
run, counts, skip split, error, FX source and the shrink percentage against the previous good
run; run history with rejection counts and stuck detection; a run's rejections by stage and
reason; the verify tripwire (blocked sources, spot-checked dates; dates, never rates, even
here); the audit's latest snapshot and metrics over threshold; the merge queue; freshness for
every location including hidden ones; active impersonations. Writes now: the kill switch
toggle on the page (finally wired; the route gains an actor in the audit log) and
`POST /api/ops/verify/checks/{id}/clear` with `cleared_by` from the session; the CLI twin
`sources enable | disable | list`, since two docs already cite a command that does not exist.
Prune commands and their cron lines (sessions, tokens, audit) on the machine E5 picks.

**The scheduler is not built before launch.** The client was told collection stays hand-run,
`decide-where-collectors-run` is open and blocking E5, and a tick on two machines would put two
readers with one User-Agent against every retailer, defeating the per-host crawl delay that is
enforced inside one process. After launch, as a watched trial on one source with the pause rows
visible: `collection_schedules(id, source_id FK sources, kind collect|verify, enabled, cadence,
limit, delay_seconds, next_due_at, last_run_id, last_started_at, last_status,
consecutive_errors, paused_until, pause_reason blocked|shrink|deploy|human, shrink_ratio,
created_by and updated_by FK accounts, timestamps)`, `app.cli scheduler tick` from host
cron every five minutes on exactly one machine, a Postgres advisory lock for single flight, a
`.logs/runs/DEPLOYING` marker honoured by both deploy paths, always `--source` per row (the
bare `collect` loops every collector including the refusing ones), `blocked` pauses until a
human resumes, shrink alerts and pauses, and the after-collection verify scheduled for the
next morning. Behind `FEATURE_SCHEDULER=false` until the decides in §12 are answered.

## 10. R4: growth, shape only

Invariants that keep the launch choices from forcing a rework: usernames immutable and
lowercase, email a login alias; consumers hold an `accounts` row and no membership row, so
`can()` costs one indexed miss and the permission tables stay staff-sized; any active
non-owner account is a View As target (`target_valid` never consults membership), so growth to
consumers changes the picker's list source, never the policy; consumer abilities
(alerts, saved airports, entitlements) live on `accounts` and a `subscriptions` table, never as
kit permissions; instances are brands, so the brand-manager tier is `has_instances=True`, one
instance-scoped permission (`brand.manage`), one level row and the canonical-brand filter;
entitlements resolve server-side beside `can()` with a payments provider behind one seam; the
in-process throttle and the argon2 semaphore are per worker, so a `login_attempts` table and a
short-TTL levels memo (a store decorator, never a kit edit) come before a second worker; the
`/api/bw/me` fan-out (about twenty short queries) gets that memo; sessions, tokens and audit
have prune commands from day one; self-registration adds email verification, a honeypot and
throttles, and never a membership row; Sign in with Google is deliberate-link-only, matched on
`sub`, with DFP's own OAuth client per host; a strong second factor (TOTP or passkeys) for
staff levels before delegation; account export and deletion for consumers; and, if Adam's
"one login across the family" is wanted, DFP becomes the identity provider for his other
properties with `oauth_clients` and `authorization_codes`, which is why `accounts` carries no
server coupling anywhere.

## 11. Tasks (the `/plan` lane R)

| Id | Phase | Task |
|---|---|---|
| L1 | R1 | Vendor the kit and the React pack byte-identical; manifest and `vendor-check.sh` in `check.sh`; the pack compiled under a `tsconfig.vendor.json` reference (§4.1); argon2 in `pyproject.toml` and the dev venv; the `X-Auth-User` grep gate |
| O2 | R1 | Migration #4 and the models; `backfill accounts`, `levels`, `authors`, `overrides`; `cli_accounts.py`; rehearsed on `dfp-devdb` up and down twice |
| L3 | R1 | Identity: passwords, sessions store and loader, tokens (fragment links, POST bodies), the local directory, the mail seam (`none`), the audit sink with the remove-to-disable mapping, `/api/auth/*`, the silent per-account lock and the address throttle |
| L5 | R1 | Kit wiring (before L4: the policy needs `can()`): the mapped store, `register_enforced`, seed levels, View As policy and act mode, `build_router` at `/api/bw`, DFP's own `/api/bw/me`, `/api/whoami`, `/api/ops/impersonations`; the kit's write routes listed in the stopgap's owner dict for this one commit so the inventory stays green |
| L4 | R1 | Access policy (after L5): `services/access.py` replaces `mutations.py` and `owner_auth.py`; `safe_next()`; the must-change gate; `act_as.write` from the middleware; `SITE_ACCESS`; members-mode answers; docs off in production; `X-Robots-Tag`; the inventory over reads, pages, mounts and `App.tsx`; the server half of the stopgap retired (`owner_auth.py`, `routers/owner.py`, the two config fields, the two example lines) |
| L6 | R1 | Authorship from the session on every write; payload author fields removed; comment edit rules; `item:*` scoping on the GET, the POST and the edit route |
| L7 | R1 | The SPA: login (with `safeNext()`), forgot, set-password (token from the hash), account and admin pages; the header menu; capability guards; the name boxes, `OwnerGate`, `api/owner.ts` and their importers replaced by the capabilities; tokens mapped; house style |
| L8 | R1 | Tests: the conformance pack under `tests/kit/` with the DFP conftest; DFP's own suites (§4.12) |
| L9 | R1 | Docs (§4.13), `.app.env.example`, CHANGELOG, `KICKOFFS.md`, the upstream proposals file |
| L10 | R1 | The end-to-end rehearsal on a local server against `dfp_r`: the curl list of §7 step 5 and the browser round trip; the handoff with rian's ordered steps; "green and ready" |
| L11 | R1, if time remains | The Resend provider behind `MAIL_PROVIDER=resend`, inert until the lines exist |
| T1 to T6 | R2 | §8's slices |
| V1 | R3 | `/api/ops/*` reads |
| V2 | R3 | The collection page: sources, runs, rejections, verify, audit, merges, freshness, impersonations; the kill switch toggle; verify-clear; `sources enable/disable` CLI |
| V3 | R3 | Prune commands (`sessions`, `audit-log`, tokens) and their cron lines on E5's machine |
| V4 | R3, gated | The scheduler as a watched trial behind `FEATURE_SCHEDULER`, after `decide-where-collectors-run` and the hand-run decide |

## 12. What rian decides (filed on the running list)

1. **Capacity:** R1 is unpaid and runs the night of 10 Sep as asked; R2 and R3 sit in the
   buffer after 18 Sep. Assumed.
2. **The level names:** Adam and Mark on `admin` with DFP's meaning (see the client pages and
   participate); `member` empty; no kit template seeded. The kit cannot rename a level, so a
   different name is chosen before the first seed or never. Assumed as written.
3. **Typed authors:** an alias map for the three, no guest accounts; the two "Adam"-defaulted
   tables linked only on his say-so. Assumed.
4. **The un-walling date:** `SITE_ACCESS=public` is E7's step; the date is his to name with
   Adam and Mark ("search is always our driver"). No assumption; nothing before E7.
5. **Mail:** a Resend account owned by the project (not the server's), the key in an app-only
   env file; until then invites by CLI link or generated password. Assumed.
6. **The scheduler:** a watched trial after launch, announced to Adam as such; or not at all
   until the priced line is bought. Assumed: trial after launch, behind a flag.
7. **The kit upstream proposals:** apply the six small changes and add a vendor drift check to
   the scaffolder, then DFP re-vendors. His to do.
8. **Overrides in #4:** rides, per the plan; F wires the pin after. Assumed.

## 13. Learnings for the kit (nameless, past tense)

An app that had to stay transferable kept its identity table free of any central coupling and
vendored the kit with a hash manifest so a hand edit failed the suite even after the upstream
path was gone. A silently defaulted owner name is a footgun in a copied kit: the owner should
be required. The conformance pack proved cheapest when its fixture contract, not its
transport, was the seam. A vendored copy on this server had already drifted from the kit
unnoticed; "never hand-edited" needs a check, not a promise. A vendorable React pack must
compile under the strictest tsconfig a vendoring app is likely to run (`noUnusedLocals`); the
kit's own tsconfig should carry those flags so an unused import is caught upstream. A secret
in a URL path or query is a secret in the access log: invite and reset tokens ride in the
link's fragment and a JSON body. A file-serving catch-all confines its join to its root, or it
serves the whole container.
