# Stream R: accounts and login (written 2026-09-10 from rian's ask of the same day)
> **Status 11 Sep:** R1 landed (L1 to L11 done, on staging since 11 Sep). R2 (`/stream-r2`, widened below with T7 to T10) runs in wave one of the away period beside M and G; R3 (`/stream-r3`) in wave two, after R2. The design is `.logs/planning/accounts-2026-09.md` (read it in full first; this brief is the task list, not the design).
**Runs:** autonomously, alone in the tree or beside E (which touches only `deploy/`, the
production overlay and the droplet). Never deploys; rian holds the deploy and migration tokens.

## Goal
The site's own account system, modelled on the server's BW application kit and independent of
the server: sign-in with a hashed password, revocable sessions, levels of access with the owner
synthesised as super admin, View As with an owner-only act mode, an audit trail, authorship from
the session on every write, and a members-only mode that the go-live checklist later flips to
public. Three accounts at launch (rian; Adam and Mark on `admin`), seeded per host by CLI.

## R1, in order (ids are `/plan` tasks; record each with `main/scripts/plan-set.py`)
1. **L1 Vendor the kit** (plan §4.1). Byte-identical copies of `bw_accounts.py`,
   `bw_store_sqlalchemy.py`, `bw_admin_api.py`, `bw_view_as.py` into `main/app/vendor/` and the
   whole `react-admin/` folder into `main/web/src/vendor/bw-admin/`; `MANIFEST.sha256`;
   `main/scripts/vendor-check.sh` (byte-compare against `/srv/system/id-auth/app-auth/` when it
   exists, the manifest when it does not) and `vendor-refresh.sh`; `check.sh` runs the check and
   a grep gate that keeps `main/app` free of `X-Auth-User`, `X-Auth-Grant` and `bw_auth`.
   Eight pack files carry an unused default `React` import that `main/web/tsconfig.json`'s
   `noUnusedLocals` rejects (TS6133): compile the vendored folder under a
   `main/web/tsconfig.vendor.json` project reference without the unused-symbol flags and
   `exclude: ["src/vendor"]` in the main tsconfig (`tsc -b` is already the driver); if that shape
   fights `tsc -b` within an hour, relax `noUnusedLocals` and `noUnusedParameters` with a comment
   naming the sixth upstream proposal, restored when the kit is re-vendored.
   `argon2-cffi>=23.1` in `pyproject.toml` and `../.venv-dev/bin/pip install "argon2-cffi>=23.1"`.
   Test: `check.sh` fails when a vendored byte changes and passes when it is refreshed, and
   `npx tsc -b` is green with the pack vendored and untouched.
2. **O2 Migration #4** (plan §6). One revision, `down_revision = "a4b5c6d7e8f9"`, schema-only,
   every column NULL or server-defaulted, JSON columns as `JSON().with_variant(JSONB,
   "postgresql")`; the models in `models/accounts.py` (a new module) and the seven `*_id`
   columns on their existing models; `cli_accounts.py` (`accounts create | set-password |
   invite-link | reset-link | disable | enable | unlock | list | owner-check | bench`,
   `sessions revoke | prune`, `audit-log prune`) registered like `cli_quality.register`; the
   backfills `accounts` (extended), `levels`, `authors`, `overrides` join `BACKFILLS` in
   `main/app/cli.py`; the one `backfill` parser there gains `--check` (print what would change,
   write nothing, exit 0; every name honours it), `--map K=V` (repeatable) and
   `--include-defaulted` (`authors` only; any other name rejects them), and `cmd_backfill`
   passes `args` through as `fn(db, args)` with the existing backfills ignoring it. Rehearse on `dfp-devdb`: restore the newest nightly dump as `dfp_r`, `upgrade
   head`, every backfill twice (the second run changes zero rows), `downgrade -1`, `upgrade head`.
   Test: the backfills' idempotency on an in-memory SQLite; `alembic heads` one head.
3. **L3 Identity** (plan §4.3 to §4.5). `services/passwords.py`, `services/sessions.py` (the
   store, `SessionRow`, the cookie, the loader middleware, the one GET-write exception on its
   own connection), `services/directory.py` (`LocalDirectory` and the token mint),
   `services/mail.py` (`none` provider), `services/audit_log.py` (with the sink's
   `remove_member` mapping to disable-and-revoke, plan §4.10), `routers/auth.py`
   (`/api/auth/login | logout | forgot | token-state | welcome | reset | change-password |
   heartbeat`, all POST bodies; no token in any path, the link carries it in the fragment), the
   silent per-account lock (answers 401) and the retuned address throttle (the only 429), with
   unknown usernames counted per folded name. Tests: cookie flags (`__Host-dfp_session; Secure`
   in production, `dfp_session` without Secure when `APP_ENV` is anything else), the login round
   trip, the dummy-verify branch, lockout on a fake clock, the identical response sequence for
   a real and a non-existent username, the token state machine, no token in a route template or
   an access-log line, the password rules, the create paths' `EXISTS` refusal, the removal
   test, a GET that writes nothing.
4. **L5 Kit wiring** (plan §4.2, §4.6, §4.9; before L4, whose policy needs `can()`). `services/accounts.py` (the mapped store onto
   `account_levels`, `account_members`, `account_grants` and `brands` read-only; `init` with the
   owner from `ACCOUNT_OWNER` or the sentinel; `register_enforced` of the six permissions; the
   `IntegrityError` to `EXISTS` subclass), `services/identity.py`, `services/view_as.py` (act
   mode owner-only), `build_router(...)` mounted at `/api/bw` with every hook, the DFP
   `capabilities` block from DFP's own `GET /api/bw/me` registered before the kit router (plan
   §4.11), `GET /api/ops/impersonations` (owner), `GET /api/whoami` in `routers/auth.py` over
   `identity.optional_user` and `real_user` (the keys `test_view_as` asserts: `username`
   effective, `real_user`, `is_owner` of the effective user). For this one commit the kit
   router's write routes are listed in the stopgap's `OWNER_MUTATIONS` so
   `test_route_inventory.py` stays green; L4 deletes that dict. Tests: with `ACCOUNT_OWNER` unset and with it
   `REPLACE_WITH_USERNAME`, `is_owner("rian")` is false and `is_owner("!no-owner")` is false;
   `can()` false for a consumer with no member row; the owner may View As an active account
   that holds no member row.
5. **L4 Access policy** (plan §4.7; after L5). `services/access.py` replaces
   `services/mutations.py` and `services/owner_auth.py`: the class dicts, the SPA path sets,
   `MOUNTS`, `ROOT_FILES`, `safe_next()` and its fixture list, the pure `decide()` (`wants_html`
   from the route key, never `Accept`; the must-change gate of plan §4.4), the middleware, which
   takes `signed_in` and `active` from L3's session loader, `holds` from `services/accounts.can`
   and `read_only` from `services/view_as` (both L5) and emits `act_as.write` for every admitted
   non-safe request in act mode; `SITE_ACCESS` in `config.py` (default `members`); FastAPI docs off in
   production; `X-Robots-Tag` and the robots and machine-file answers while members-only; the
   server half of the stopgap retired (`owner_password` and `session_secret` in `config.py`,
   `services/owner_auth.py`, `routers/owner.py`, their lines in `main.py`,
   `tests/test_owner_auth.py`, the two `.app.env.example` lines); `OwnerGate.tsx`, `api/owner.ts`
   and their two importers stay until L7 and compile as they are, so between L4 and L7 the owner
   controls on `/plan` and `/discuss` answer 403, which is expected. `tests/test_route_inventory.py`
   rewritten over reads, pages, mounts and `App.tsx` with `tests/fixtures/open_routes.json`;
   `tests/test_owner_auth.py` becomes `tests/test_access.py` around the new `decide()`, with the
   traversal probes through the middleware, the `next` cases, the must-change gate, and an
   act-mode comment edit producing exactly one `act_as.write` row naming both ids (a read-only
   View As write none).
6. **L6 Authorship from the session** (plan §4.8). Every write that names a person reads the
   effective account; the payload `author` and `who` fields go; comment edit rules; the
   feature-comments GET, POST and edit route scoped by key prefix (`item:*` needs `plan.view`;
   the writes answer 404 without it). Tests: a comment posted as adam by rian in act mode is
   adam's row with an `act_as.write` row naming rian; an `admin` holder cannot edit another's
   comment; an `admin` holder's POST with an `item:*` key and edit of an `item:*` comment both
   answer 404, the GET omits `item:*` rows for them and returns them for the owner.
7. **L7 The SPA** (plan §4.11). `LoginPage`, `ForgotPage`, `SetPasswordPage`, `AccountPage`,
   `AdminPage` (the pack's panels under DFP tabs), the header account menu, `ViewAsBanner` and
   `ViewAsPicker`, `BwAuthProvider` as the one `/api/bw/me` fetch (refetch on focus and after
   writes, never on a timer), capability guards on `/plan`, `/sources`, curator mode and the
   client pages, the name boxes, `useCommenterName`, `dfp.commenter`, `OwnerGate.tsx`,
   `OwnerGate.css` and `api/owner.ts` removed with `useOwnerSession().data?.signed_in` in
   `pages/plan/ItemsTab.tsx` and `pages/DiscussionPage.tsx` replaced by the `plan_view`,
   `items_act` and `discussion_curate` capabilities from `useDfpMe()`, `LoginPage` and
   `SetPasswordPage` navigating only through `safeNext()` (the SPA never assigns `location`
   from a query value), `/welcome` and `/reset` reading the token from `location.hash`, the
   `--bw-*` tokens mapped, the 404 shell for a client on `/plan`. The typed client regenerates through `check.sh`.
   `test_house_style.py` walks the new files: no em dash, never "free".
8. **L8 Tests** (plan §4.12). The five conformance files byte-identical under `tests/kit/`;
   the contract's importables (`as_user`, `as_session`, `OWNER`, `HAS_INSTANCES`) in a new root
   `main/tests/conftest.py` because the files import `tests.conftest`; the fixtures (`client`,
   `kit`, `bw_calls` wrapping `LocalDirectory` under the kit's path names, the recording mailer,
   the store reset seeding the kit's `admin` and `member` plus the six stubbed central accounts)
   in `main/tests/kit/conftest.py` so no DFP pure test picks them up; DFP's own suites seed DFP's
   levels in their own fixture and reuse no kit basename; every test pure, about a second.
9. **L9 Docs** (plan §4.13). `main/docs/ACCOUNTS.md` (new, "Sources of truth" first),
   `ARCHITECTURE.md` steps 2 and 8, `CLIENT-SURFACES.md` comments and positioning rules,
   `RUNBOOK.md` ("Accounts": the CLI, seeding a host, the rollout order of plan §7, transfer of
   ownership, the one-worker note; "Owner sign-in" removed), `DATA-MODEL.md` regenerated,
   `agents.md` (replace the stopgap lines under "Decided 2026-09-04"; add the GET-write
   exception and the SQLite-in-tests clause; stay within 140 lines), `.app.env.example`,
   `main/CHANGELOG.md` Unreleased as one deploy with the before and after lists,
   `KICKOFFS.md`'s table, `.logs/planning/kit-upstream-proposals.md` (the six diffs and the
   scaffolder drift check). `python3 main/scripts/docmap.py --write` then `bash
   main/docs-check.sh --strict` at 0 fail.
10. **L10 The rehearsal and the handoff** (plan §7 steps 5 and 6). A local server over the
    built SPA against `dfp_r` (no `APP_ENV`, so production: the cookie is `__Host-dfp_session;
    Secure` on `http://127.0.0.1:8765`, which loopback accepts; a bare `dfp_session` there means
    the name was keyed on the scheme, which is wrong): every curl in the list with its expected
    answer, each POST carrying the Origin header of plan §4.4, the two traversal probes with
    `--path-as-is`, the `Set-Cookie` attributes read from the response, then the browser
    round trip (sign in, `/plan`, View As read-only refused a write, act mode, sign out; a
    `admin` account sees `/discuss` and not `/plan`; an anonymous window sees `/login`). Tear
    down `dfp_r`, the local server and the `main/static` symlink. The handoff names what rian
    sets, the deploy, every after-deploy command in order, and "green and ready".

## If time remains
- **L11** the Resend provider behind `MAIL_PROVIDER=resend` (plan §4.5), inert without its lines.
- The `login_attempts` table design note in `ACCOUNTS.md` for the multi-worker day.

## R2 (`/stream-r2`): discussions and the page consolidation
(widened 2026-09-11 by the away-plan finalising session, from `AWAY-PLAN.md` section 4; R1 is
on staging, so R2 runs in wave one of the away period beside Streams M and G, staging only)

> **Status 11 Sep:** not started. The design is plan §8, taken as written; the three things rian
> asked for on top, and the staging-refresh question, are tasks T7 to T10 below.

**Order:** T7 first (a refresh that loses Adam's comments is not recoverable, and every later
task writes to the tables a refresh replaces), then T1, T2, T3, T4, then T8, T9, T10, then T5,
T6. Commit prefix `R:`; record each with `plan-set.py`.

### Working assumptions this lane proceeds under (rian's to overturn; `AWAY-PLAN.md` §3)
- **The review area's stated order stands** (`decide-what-the-review-area-needs-next-now-that-you-can-see-the`
  is still rian's to answer). T9 delivers the second item, clearing from the page; Stream M's
  M6 delivers the first; the per-airport view is Stream G's G3; saved views and counts over
  time are Stream R3's. *If rian reorders:* the tasks reorder; nothing built here depends on
  the order.
- **Adam keeps commenting on staging** until rian says otherwise, so a staging refresh must
  preserve his rows (T7). *If rian points Adam at production instead:* T7's procedure still
  stands for rian's and Mark's rows; nothing is undone.
- **Mail (T6) lands only after the app-only env file exists** (Stream W's W3, wave two).
  T6 writes the provider wiring and the security review as a request; it never edits
  `docker-compose.yml`. *If W3 lands first:* T6 completes against it.

### Tasks
- **T7 A staging refresh preserves discussion_comments.** The refresh procedure in
  `RUNBOOK.md` ("Staging refresh") dumps the client-written tables first
  (`discussion_comments`, `discussion_items`, `feature_priorities`, `quote_selections`,
  `quote_requests`, `client_todos`, `client_uploads`, `owner_item_states`, and after T1 `threads`
  and `notifications`; the list is read from a `CLIENT_WRITTEN_TABLES` constant in
  `services/discussion.py`, never typed twice), restores production's dump, then re-applies
  the client rows by id with a conflict report; as one `app.cli staging-refresh --from <dump>
  [--check]` command, rehearsed on `dfp-devdb` against the newest nightly dump with Adam's
  three comments of 11 Sep (`todo:9`, `todo:11`, `todo:13`) as the fixture that must survive.
  A test on SQLite pins that a refresh with `--check` writes nothing and lists every table.
- **T1 to T6** as plan §8 defines them (threads and the backfill; the pack stamped and
  `/discuss` on it; the other surfaces; notifications and the bell; attachments; mail), each
  as the `/plan` row already says. **T1 begins with one move commit:** the discussion classes
  (`DiscussionItem`, `DiscussionComment`, `FeaturePriority`) leave `models/catalog.py` for a
  new `models/discussion.py`, re-exported from `catalog.py` by one import line so nothing else
  changes; that commit is the only time this lane touches `models/catalog.py`, and it is the
  first thing the lane does, before Stream M reaches M2. Migration #6 is one head: check
  `alembic heads` immediately before writing and before committing; chain onto another lane's
  new revision if one is in `alembic/versions/`; never `alembic merge`.
- **T8 Collapse the pages.** `/discuss`, `/issues`, `/todo`, `/plan`, `/structure` and `/quote`
  are six surfaces onto three things: the running list, the client's material, and the build.
  First, write the proposal into this brief under a "T8 proposal" heading (the smallest set
  of pages, which subject keys each carries, the redirect table old address to new with the
  anchor preserved) and commit it before any route moves. The subject keys of plan §8 are
  frozen and never renamed. Every old address, including deep links of the shape
  `/<page>#<anchor>#c-<id>`, redirects to its new home with the fragment intact (a client-side
  redirect for the fragment, a server 301 for the path). No address Adam or Mark has been
  given may break: `tests/test_site_routes.py` gains one case per old address.
- **T9 Issues that resolve and archive.** The running list already has the states
  (`import/items.json` and `owner_item_states`); the page shows them, lets rian resolve and
  archive from the page (a recorded write with the actor from the session, through
  `routers/items.py`), and keeps the archived set reachable (a filter, not a separate page).
  This is the same write path as Stream R3's publication-block clearing; R2 starts first, so
  R2 owns the shared route shape (`POST /api/items/{id}/<state>` with `note` and the actor
  from the session) and writes it into `main/docs/ACCOUNTS.md` under "Recorded writes" so V2
  copies it; R3 raises an issue for anything it needs changed.
- **T10 A place for rian to talk to Claude.** On the rian-only pages, a comment mentioning
  `@claude` is a subject a session reads on its next run: the mention directory (one of the
  five hooks DFP supplies) gains a `claude` entry visible only where the subject's permission
  is `plan.view`; `app.cli discussion inbox --for claude [--since <date>]` lists those
  comments, and `/checkpoint`'s read order names it. It must not notify Adam or Mark: a test
  pins that a plan thread mentioning `@claude` produces no notification row for a `client`
  holder and that an `@claude` on a client-visible subject is dropped silently.

### T8 proposal (Stream R2, 2026-09-12, written and committed before any route moved)

Six surfaces become three pages. `/settings`, `/sources` and `/collectors` are not among the
six and do not move.

1. **`/discuss`: the client's material**, in four tabs selected by `?tab=` (the site's own
   convention for a shelf in the address, as on the airport page): the review (the default:
   the video, the big picture, the feature board, the decision cards, the page's own thread),
   `?tab=todo` (the to-do list and the questions), `?tab=structure` (Mark's structure proposal),
   `?tab=quote` (the launch proposal). Subject keys carried, all unchanged: `decision:*`,
   `feature:*`, `page:discuss`, `todo:*`, `structure:*`, `quote:*` and `quote:later-*`.
   `client.view`; the to-do tab only while `clientTodos` is on.
2. **`/issues`: the running list**, a page of its own (until now a redirect onto a tab of
   `/plan`): decisions for rian, his to-dos, the issues, with T9's resolve and archive and the
   archived set behind a filter. Subject keys: `item:*`. `plan.view`.
3. **`/plan`: the build**: promises, roadmap, waves, streams. No subjects. `plan.view`.

**Redirects, every one with the fragment intact.** A deep link is `/<page>#<anchor>#c-<id>`.
The server answers a 301 whose Location carries no fragment, so the browser inherits the
request's own (RFC 7231 §7.1.2); the tab rides in the query, which survives the same rule;
in-app, the old route navigates the same way with `location.hash` appended.

| Old address | New address | How |
|---|---|---|
| `/todo` (+`#…`) | `/discuss?tab=todo` (+`#…`) | server 301 on `GET /todo` ahead of the shell; the SPA route navigates the same way |
| `/structure` (+`#…`) | `/discuss?tab=structure` (+`#…`) | the same |
| `/quote` (+`#…`) | `/discuss?tab=quote` (+`#…`) | the same |
| `/issues` (+`#…`) | `/issues` (+`#…`) | the page itself now (it redirected to `/plan#items`) |
| `/plan#items` (+`#…`) | `/issues` (+`#…`) | client-side (a server never sees a fragment); `/plan` itself stays |
| `/discuss` (+`#…`) | `/discuss` (+`#…`) | unchanged; a comment anchor without `?tab=` selects its tab from the subject type (`t-structure-…` opens the structure tab) |

The old section anchors on `/structure` (`#urls`, `#map`, `#fields`, `#schema`, `#questions`,
each section's key) keep working on the structure tab: the page scrolls to a section id once
the tab has mounted. The account menu's Build Discussion group links the four tabs, `/issues`
and `/plan`. `deep_link` in `services/discussion.py` emits the new addresses and `PAGE_OF`
there is the one place they live. `tests/test_site_routes.py` gains one case per old address
(the 301 and its Location); `test_discussion_threads.py` pins `deep_link`.

### R2 Owns
New: `main/app/services/discussion.py`, `main/app/services/notifications.py`,
`main/app/models/discussion.py`, `main/app/routers/notifications.py`,
`main/app/routers/attachments.py`, migration #6 and its later NOT NULL follow-up,
`main/web/src/vendor/caddie-ui/*`, `main/web/src/components/Bell.tsx` (the adapter),
`main/tests/test_discussion_*.py`, `main/tests/test_staging_refresh.py`.
Existing: `main/app/routers/discussion.py`, `routers/items.py`, `routers/todos.py`,
`routers/plan.py`, `routers/quote.py`, `main/app/services/items.py`, `services/uploads.py`,
`services/mail.py`, `services/mail_resend.py`, `models/owner.py`, `models/client.py`, the
discussion shapes in `models/schemas.py` (the `Discussion*`, `Feature*` classes only),
`main/scripts/items.py`, `main/web/src/pages/DiscussionPage.tsx`, `StructurePage.tsx`,
`QuotePage.tsx`, `TodoPage.tsx`, `PlanPage.tsx`, `pages/plan/*`, `FeaturePage.tsx`,
`components/FeatureThread.tsx`, `components/SiteHeader.tsx` (the bell mount only),
`main/web/src/styles/tokens.css` (the `--cu-*` map), `lib/quote.ts`, `lib/structure.ts`,
`lib/features.ts`, `lib/review.ts`, `lib/clientPages.ts`, `main/docs/ACCOUNTS.md`,
`main/docs/CLIENT-SURFACES.md`, `main/scripts/vendor-check.sh` (the second manifest),
`.claude/commands/checkpoint.md` (T10's read line), and the tests those files already have.

### Shared, append your own lines only
`main/app/services/access.py` (your route keys in the class dicts; never restructure),
`main/app/cli.py` (one `BACKFILLS` entry, one `register` line), `main/app/main.py` (your
`include_router` lines), `main/web/src/App.tsx` (your route and redirect lines),
`main/web/src/lib/urls.ts`, `main/tests/fixtures/open_routes.json`,
`main/tests/test_site_routes.py` (your cases in your own class), `main/docs/RUNBOOK.md` (your
"Staging refresh" section), `main/CHANGELOG.md` Unreleased, `import/progress.json` through
`plan-set.py`, `.logs/handoff.md` prepend-only. Re-read a shared file immediately before
editing it, edit once, commit within the same task.

### R2 Must not touch
Stream M's column: `services/lines.py`, `services/merges.py`, `services/normalize.py`,
`models/catalog.py` (after the one move commit), `services/collector_view.py`,
`routers/collectors.py`, `pages/CollectorsPage.tsx`, `DATA-MODEL.md`. Stream G's column:
`services/hours/*`, `cli_hours.py`, `models/hours.py`, `routers/airports.py`,
`airport_guides.py`, `coverage.py`, `models/hubs.py`, `seo.py`, `main.py` beyond an
`include_router` line, `pages/AirportPage.tsx`, `AirportsPage.tsx`, `SavingsPage.tsx`,
`lib/airportTemplate.ts`, `test_seo_airport.py`. Plus everything under the R1 list below:
`docker-compose.yml`, `deploy/`, the env files, the collectors, `ingest.py`,
`awards_import.py`, the kit, caddie (read, never edit).

## R3 (after R2; `/stream-r3`): collection oversight, plan §9, tasks V1 to V4; V4 is gated.
R3 copies T9's recorded-write shape from `ACCOUNTS.md` for the publication-block clear and
mounts Stream G's `AirportHours` provenance component on the collection page's per-airport
rows (the issue G3 files names the import).

## R1 Owns (historical; R1 landed 11 Sep)
`main/app/vendor/*`, `main/app/services/{accounts,identity,sessions,access,passwords,directory,
mail,audit_log,view_as}.py`, `main/app/routers/{auth,ops}.py`, `main/app/cli_accounts.py`,
`main/app/models/accounts.py`, the migration, `main/app/cli.py` (the `backfill` parser,
`cmd_backfill`, `BACKFILLS`, `backfill_accounts` and the `register_accounts(sub)` line),
`main/web/src/vendor/*`, `main/web/tsconfig.json` and `tsconfig.vendor.json`, the new pages and
the account menu, `main/scripts/vendor-check.sh` and `vendor-refresh.sh`, `main/tests/kit/*`,
`main/tests/conftest.py` and the new tests, `main/docs/ACCOUNTS.md`, and the lines it changes
in the files the tasks name.

## R1 Must not touch
`docker-compose.yml` and anything under `deploy/` (Stream E); `.app.env`, `.env`, `.bw-auth.env`;
the collectors, `ingest.py`, `awards_import.py`, `seo.py` beyond the heads for the new pages and
the members-mode answers; the browser sidecar; the kit under `/srv/system/id-auth/` (propose,
never edit); `import/items.json` and `import/progress.json` except through their scripts.

## Ground rules
`.logs/planning/streams/OVERNIGHT-RULES.md` binds. Plus: never register a BW client, never
add a hostname, never mint `INDEXNOW_KEY`, never write a link or a password into a log or the
transcript, never run the server's app scaffolder, never edit a vendored byte. Security review
before code is plan §5; `srv-gw security-audit` runs when rian deploys, not in this session.

## R2b (after R2; `/stream-r2b`): the one side panel, tasks T11 to T14
Written 2026-09-13 from rian's finding: "comments are all over the place and it is hard to track what is
new, what is resolved, what has to-dos or follow-ups". The Interaction Standard §6 already answers it, and
R2 did not build that part: **the conversation surface is the ONE SIDE PANEL, not inline rows**. R2 mounted
the pack's `ThreadCard`/`Composer` inline on each page and the bell; it never mounted `SidePanel` as the
list of every thread. The backend is nearly there: `GET /api/discussion/threads` (`threads_for`) already
returns every thread the viewer may see with its live comments, `POST /threads/{id}/resolve` exists,
`notifications` carry `read` and `resolved`, and the pack ships `SidePanel` (tabs, footer, back) untouched.
Rules from the standard that bind here: the list stays a list; a card goes TO its subject (the deep link,
`#t-<type>-<id>`), a subject with nowhere to go does nothing but Reply; Reply opens the conversation INLINE
under the card, the count on the button; labels are breadcrumbs in the app's own nouns; one panel, one
Escape, one right edge; full width on phones; resolved rows leave the default list and sit behind "Show
resolved"; a picture opens in the lightbox. Nothing is re-implemented: a needed change goes to the pack.

- **T11 The panel.** Mount the pack's `SidePanel` from the header (a "Discussion" control beside the
  bell) and from every bell row, docked right, pushing the page aside. Its body is the list of every thread
  the account may see, newest activity first, each a `ThreadCard` with the breadcrumb label, the subject's
  first comment, the reply count on the Reply button, and a resolve control; Reply expands the replies and
  a `Composer` inline. Card click follows the deep link and the page scrolls to the subject; the panel
  stays open (the mode). Escape closes.
- **T12 What is new, what needs you, what is done.** Per thread, for the viewer: unread (any comment after
  the viewer's last read of that thread), needs-you (an unread mention of the viewer or a turn), resolved.
  Read state per thread per account (`thread_reads(thread_id, account_id, read_at)`, one row, moved on
  open; migration, schema only, one head, rehearsed on `dfp-devdb`). `GET /api/discussion/threads` gains
  `unread`, `needs_you`, `last_activity_at`, `reply_count`; a `since=` filter. Panel tabs: **Needs you ·
  Unread · Open · All**, plus "Show resolved". The bell's count and the panel's Needs-you agree by
  construction (both from the same rows).
- **T13 Resolve, reopen and follow-ups from the list.** Resolve and reopen on the card, recorded against
  the account, propagating to notifications as §5 says. A thread can be marked **needs follow-up** by a
  curator with a one-line note (a `todo` mark on the thread, who and when), listed under its own tab, and
  cleared with a note; that is the "has to-dos" rian asked to see, without inventing a task system.
- **T14 Mentions that predate the panel, and the mail.** `backfill threads` (and a standalone
  `backfill legacy_mentions`) emits a `mention` notification for every legacy comment that names a current
  account and never rang, idempotent by dedupe key, so the production migration rings Adam's 13 Sep
  mention the way staging now does (emitted by hand there on 13 Sep). The digest mail lists what the
  panel's Needs-you tab lists, nothing else.

**Owns:** `app/services/discussion.py`, `app/routers/discussion.py`, `routers/notifications.py`, the new
migration, `web/src/components/{Bell,SubjectThread}.tsx`, a new `DiscussionPanel.tsx`, `AccountMenu.tsx`
for the control, `api/discussion.ts`, `docs/ACCOUNTS.md` (Recorded writes), tests.
**Must not touch:** the pack (`web/src/vendor/caddie-ui`, stamped byte-identical; a change is a request
to caddie), the storefront pages, collectors, `access.py` beyond the new routes' entries.

### R2b, widened 13 Sep (rian): default view, sort and search, topics, mentions, resolving, the re-threading
- **T11 amended:** when the panel opens it shows **open threads, newest activity first**; the person digs
  in from there. **T12 amended:** sort by thread started and by last reply or update; a **search box that
  filters the list as you type** (label, subject, comment text, author), client-side over the loaded list,
  no round trip per keystroke.
- **T15 Topics: a thread that belongs to no section.** A new subject type `topic` (slug id, `client.view`
  permission, breadcrumb `Topic › <title>`, page `/discuss?tab=review` with the anchor): a starter in the
  panel's footer, "Start a topic", takes a title and a first comment; the thread is then a card like any
  other. Adam's cruise-port comments are the first case. `SUBJECT_TYPES`, `PAGE_OF`, `permission_of` and
  `valid_subject` gain the type; the access policy and the mutating-route fixture gain the route.
- **T16 `@` offers names.** The pack's `Composer` has no mention picker, and the standard forbids
  re-implementing pack pieces, so this is two parts: a request to caddie for a `Composer` mention picker
  (a `directory` prop: `[{handle, display_name}]`, `@` opens the list, arrows and Enter choose, the handle
  is inserted), filed on the running list for rian to take into caddie; and on the DFP side
  `GET /api/discussion/people?subject_type=` returning the mention directory the viewer may reach (the
  existing `mention_directory`), wired to the prop the day the pack ships it. Until then, `@handle` typed by
  hand keeps working exactly as now.
- **T17 Resolve with a closing word.** Resolving a thread from the list or the page asks for a closing
  comment (optional but offered, the box pre-focused), posted as the thread's last comment by the resolver
  and then the thread marked resolved in the same write; the `resolved` event carries it. Reopen likewise
  may carry a comment. Recorded against the account (`ACCOUNTS.md` Recorded writes).
- **T18 The re-threading.** A recorded `move comment` write (`POST /api/discussion/comments/{id}/move`
  with a target subject; who moved it and from where kept on the comment; curators only), then the map
  below applied once by an idempotent `app.cli discussion rethread --apply` that reads it from
  `import/rethread-2026-09-13.json`; a `--check` mode prints the moves. Nothing is deleted except the one
  test comment, and that is the soft delete. The map is the planning session's reading of every thread on
  13 Sep; rian may edit the file before it is applied.

**The re-threading map (`import/rethread-2026-09-13.json`, applied by T18):**
| From | Comments | To | Why |
|---|---|---|---|
| structure:general (118) | 195, 212, 217 | new topic `airport-information-pages`, "Airport information pages: Adam's drafts" | three drafts of the same document |
| structure:p-airport (121) | 191, 200, 209 | the same topic | the list of 19, Mark's feedback on the Heathrow draft, Adam's reply |
| structure:general (118) | 208 | new topic `category-page-drafts`, "Category page drafts" | the whiskey example |
| structure:general (118) | 218, 219, 220 | new topic `cruise-ports`, "Cruise ports as duty free locations" | one idea across three comments |
| structure:general (118) | 221 | new topic `dutyfreeawards-platform`, "dutyfreeawards.com: platform and content" | a different site |
| structure:general (118) | 206, 207 | new topic `indexing-before-launch`, "Stay out of the index until launch" | Mark's indexing warnings |
| page:discuss (87) | 45 | todo:13 (115) | the Instagram and YouTube links are that to-do |
| page:discuss (87) | 46 | quote:sponsor-slots (102) | banners are that line |
| feature:my-airports (66) | 213 ("test") | soft delete | a test comment |
Left as they are: every quote thread, the decision cards, the to-do threads, and the five structure
questions, each of which stays on one subject. **Ready for rian to resolve with a closing word** once T17
lands: `structure:q-single` (211 "we all agree"), `q-thresholds` (193), `q-family` (194), `schema` (210),
`q-urls` (204), `urls` (196 to 199), and the fifteen `quote:*` threads whose lines shipped.
