# Accounts

Sources of truth: `main/app/services/accounts.py`, `main/app/services/identity.py`,
`main/app/services/sessions.py`, `main/app/services/access.py`, `main/app/services/passwords.py`,
`main/app/services/directory.py`, `main/app/services/mail.py`, `main/app/services/audit_log.py`,
`main/app/services/view_as.py`, `main/app/routers/auth.py`, `main/app/routers/ops.py`,
`main/app/cli_accounts.py`, `main/app/models/accounts.py`, `main/app/vendor/`, `main/tests/kit/`.

The site's own account system: 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, and a members-only mode the go-live checklist flips to public. It is modelled on
the server's BW application kit and completely independent of it, so the codebase can be
handed to an owner with no relationship to the server it was built on. Design record:
`.logs/planning/accounts-2026-09.md`; the procedure for a host: `RUNBOOK.md` (Accounts).

## What is vendored, and the one rule about it

The kit's four modules sit in `app/vendor/` and its React pack in `web/src/vendor/bw-admin/`,
byte-identical. `scripts/vendor-check.sh` (run by `check.sh`) compares each file against the
kit when it exists on the machine and against `app/vendor/MANIFEST.sha256` when it does not;
`scripts/vendor-refresh.sh` is the only way they change. A fix to a vendored file is a
proposal in `.logs/planning/kit-upstream-proposals.md`, applied upstream, then re-vendored.
The kit's central client (`bw_auth.py`) is never vendored: `check.sh` grep-gates `main/app`
against any import of it, and against `X-Auth-User` and `X-Auth-Grant` (once the server's gate
lifts, nothing strips those headers, so the app must never read them).

The React pack compiles as its own TypeScript project reference (`web/tsconfig.vendor.json`)
without the unused-symbol flags, because eight pack files carry an unused default `React`
import; the main project keeps `noUnusedLocals`.

## Identity

`accounts` is the one identity table and the FK target of every who-column. `username` is the
kit's key and the `@mention` handle: lowercase, immutable, `^[a-z0-9][a-z0-9._-]{1,31}$`.
`email` is the login alias; `display_name` the only editable name; `status` is `active`,
`invited` (no password yet) or `disabled`. The credential (argon2id hash, the must-change
flag, the lockout counters) lives in `account_credentials`, so no query over `accounts` can
ever carry a hash. The kit's membership is three DFP-owned tables the vendored store maps onto
(`account_levels`, `account_members`, `account_grants`); a consumer later holds an `accounts`
row and no member row, so the permission tables stay staff-sized. Instances map read-only
onto `brands.slug`, unused until a brand-manager tier turns `has_instances` on.

**The owner is code, not a row.** `ACCOUNT_OWNER=<username>` in `.app.env` names the super
admin; the kit synthesises every permission for that name. Unset or a placeholder hands the
kit the sentinel `!no-owner` (the kit defaults an owner on any falsy value, and a silently
defaulted owner is a footgun in a copied kit): nobody is owner, the startup log says
CRITICAL, `accounts owner-check` says the same, and the site stays up. The owner still needs
an `accounts` row and a password to sign in.

## Permissions and levels

Six DFP permissions, registered with the kit as enforced so the Levels editor labels them
honestly: `client.view` (the client pages and their reads), `client.participate` (comment,
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), `plan.view` (`/plan`, `/issues`, the running list and its `item:*`
threads), `items.act` (decide, done, dismiss, reopen), `sources.manage` (`/sources`, the
collector health read, the kill switch). The kit's own `accounts.*`, `levels.*` and
`instances.*` ride along.

`backfill levels` seeds two levels, insert if absent and never update: `admin` (`client.view`
and `client.participate`; assignable nothing) and `member` (nothing). The kit has no rename,
so a name is fixed at its first seed. The four other DFP permissions and `accounts.view_as`
are held by no level, so at launch they are the owner's by absence. The kit's own templates
(`admin`, `manager`, `team`, `collaborator`) are adoptable from the Levels editor under names
that do not collide, for the day account administration is delegated.

Every permission question goes through `accounts.can()` and `accounts.is_owner()`, never a
level name; the SPA reads the `/api/bw/me` booleans and the DFP `capabilities` block
(`client_view`, `client_participate`, `discussion_curate`, `plan_view`, `items_act`,
`sources_manage`, `must_change_password`) and switches on nothing else.

## Sessions and the cookie

An opaque 256-bit token rides in the cookie; its sha256 is the `sessions` row's key. No
signing secret sits in the session path, so a second host or worker needs no shared key and a
database read yields nothing a browser could present. The cookie is `__Host-dfp_session` with
`Secure` when `APP_ENV` is production (the compose default, and a local rehearsal with no
`APP_ENV`), `dfp_session` without `Secure` only when `APP_ENV` is anything else; never the
request scheme, which is `http` behind the proxy. `HttpOnly; SameSite=Lax; Path=/`, no
`Domain`. Absolute 30 days, no sliding. Logout revokes; a password set, reset or change
revokes every other session of the account; disabling, and removal from the People panel,
revoke all; `sessions revoke --username | --all` is the break-glass.

The loader middleware runs outermost: it loads the row on every request, attaches it as
`request.state.session`, and flushes the kit's View As keys after the response if the request
changed them. That flush is the one GET that writes: confined to `sessions.acting_as` and
`acting_mode` plus the audit row, on the store's own short connection, never inside the
request's `get_db` session. `POST /api/auth/heartbeat` (every five minutes from a visible
tab) moves `last_seen_at` and nothing else.

## Passwords, lockout, tokens

argon2id, `time_cost=3`, `memory_cost=32 MiB`, `parallelism=1`, above the OWASP floor; a
`Semaphore(2)` around hash and verify so a login flood queues instead of starving page
serving; `accounts bench` prints the cost on the actual host. Rules: 12 to 256 characters,
NFC-normalised, no composition rules, refused when it contains the username or the email's
local part.

Every refused login is the same `401 LOGIN_REFUSED` with no `Set-Cookie`: an unknown name,
an invited account, a disabled one, a locked one and a wrong password all spend a real or a
dummy verify of the same cost, so the response sequence never tells a name that exists from
one that does not. Two lockout layers: per account in the database (8 failures within 15
minutes lock for 15 minutes; a success or `accounts unlock` resets; the lock is silent and the
audit row says `locked`) and a per-address throttle in the process (10 per address per 15
minutes, 60 a minute overall; the only `429`, with `Retry-After`). Failures against a name with
no row count in a second in-process throttle keyed on the folded name. The address throttle
and the argon2 semaphore are per worker: one uvicorn worker is a launch assumption, and a
`login_attempts` table comes before a second.

A welcome or reset token is `secrets.token_urlsafe(32)`, stored hashed in `account_tokens`,
single use (marked used in the same transaction as the password write), 48 hours, minted
only for non-disabled accounts, and built from `PUBLIC_BASE_URL` so a staging link never lands
on production. **The token travels only in the link's fragment** (`/welcome#<token>`,
`/reset#<token>`) and then in a JSON body: a browser never sends a fragment, so the
container's stdout, which is an access log of every request line, never sees it. No route
under `/api/auth` and no SPA path carries a token segment, and a test asserts it.
`POST /api/auth/token-state` answers `valid | expired_resendable | used | unknown` and writes
nothing. Redeeming revokes every other session and signs the browser in.

`must_change_password` is set by the generated-password paths (`create_user_with_password`,
`reset_password_generated`) and cleared by a change, a welcome or a reset. While set, the
access policy admits only the change-password, logout, `/api/bw/me`, `/api/whoami`, the
public routes and the SPA shell; everything else answers `403 PASSWORD_CHANGE_REQUIRED` or
`302 /account`, so a handed-over password can do nothing but replace itself.

## The route policy

`services/access.py` puts every route, page, mount and SPA path in exactly one class:
`PUBLIC_ALWAYS` (the sign-in machinery, health, `/api/bw/me`, robots, the static mounts, the
shell's root files by exact path), `PUBLIC_WHEN_OPEN` (every storefront read, the page
routes, the machine files, the not-found shell; pinned to `tests/fixtures/open_routes.json` so
opening a route is a reviewed diff), `MEMBER` (any signed-in account; the kit's routes listed
one by one, so a re-vendor that adds a route fails the inventory), and `PERMISSION` (one
string, or `owner`). A route in no class answers `403 ROUTE_NOT_CLASSIFIED` before its
handler runs. `decide()` is pure and walked case by case in `tests/test_access.py`.

Order: unclassified; Origin on every non-safe request (an `Origin` or `Referer` whose host is
the request host or `PUBLIC_BASE_URL`, required, the CSRF check for every write including the
kit's); read-only View As on every non-safe request but stop and logout; then, for a
non-public route, `302 /login?next=<path>` for a page or `401 NOT_SIGNED_IN` for JSON;
`ACCOUNT_DISABLED`; the must-change gate; the permission (`403 FORBIDDEN`, or the 404 shell
for a page under `plan.view`, so a client is not told `/plan` exists). Whether an answer is a
page or JSON comes from the route key, never the `Accept` header. `safe_next()` is the only
place a redirect target is accepted, on the server and as `safeNext()` in the SPA; the two
share `tests/fixtures/next_cases.json`.

**Members-only mode** (`SITE_ACCESS=members`, the code default, fail closed): every anonymous
page request answers one `302 /login`, known route or not, so route existence is not
disclosed and no server-rendered price page is ever built for an anonymous caller;
`/robots.txt` answers `User-agent: *` / `Disallow: /` with no Sitemap line; `/sitemap.xml`,
`/feed.xml` and `/llms.txt` answer a plain 404; every response carries `X-Robots-Tag: noindex,
nofollow`; FastAPI's docs are off in production in any mode. The flip to `public` is one env
line and a container recreate: the storefront opens, robots returns the per-bot policy with
the Sitemap line, the machine files answer, the `X-Robots-Tag` stops, the not-found shell
answers 404 to anonymous callers. Nothing else moves.

The owner's review area grows by the same rule: every `/api/collectors/*` read and write (the
Listings table and its CSV, the product override, a listing's pin and ignore, the merge desk's
tables, the batch and the proposals) is keyed under `sources.manage`, and every write takes who
decided from `identity.actor`, never from the payload.

## Authorship, View As, audit

Every write that names a person takes it from the session: the who-column gets the effective
account's id and the text column its display name as a snapshot (the raw record). The
`item:*` comment threads are filtered from the list read and answer 404 to a post or an edit
unless the caller holds `plan.view`. A comment is editable by its author under
`client.participate` and by anyone under `discussion.curate`.

View As is the kit's mechanism with DFP's policy: the owner, or a level holding
`accounts.view_as`, may view as any active account that is not the owner, member or not; act
mode is the owner's alone; read-only mode blocks every mutation but stop and logout. In act
mode a write stores the effective account's id, and the access middleware writes one
`act_as.write {key, status}` row with `account_id` the real account and `acting_as_id` the
effective one, the one record of who really acted. Active impersonations are one SELECT over
`sessions` (`GET /api/ops/impersonations`, owner).

`audit_log` rows carry the real and the acting-as account, the session hash and the address,
filled from the request's session for every row, the kit hook's included. Actions: the kit's
(`kit.add_member`, `kit.set_member_level`, `kit.create_level`, ...), `login.ok`,
`login.refused {reason: bad_password | unknown | locked | disabled | invited}`, `login.locked`,
`logout`, `session.revoke`, `password.set {via}`, `token.mint`, `token.redeem`,
`account.create | disable | enable | unlock`, `view_as.start | stop` (the fail-closed auto-stop
carries `auto: true`), `act_as.write`. Never a password, a token, or a typed unknown username.
On `remove_member` the sink runs the same path as `accounts disable`, because the kit's own
remove only flips `account_members.active`, which neither login nor the session loader reads,
while the panel's Remove button says it revokes access; re-adding answers `DISABLED` until
`accounts enable`, which never re-adds the member row.

## Recorded writes

The shape every state change a person makes on a row follows, written here so the oversight
lane's publication-block clear copies it rather than inventing a second one. The running list
is the reference (`app/routers/items.py`): `POST /api/items/{id}/<state>` where `<state>` is one
of `decide`, `done`, `dismiss`, `archive`, `reopen`; the body is `{"text": ...}`, optional
except for `decide`, and is the note or the decision; the route is classified under one
permission in `services/access.py` (`items.act`), so a caller without it is refused before the
handler runs and a client is not told the row exists; the actor is `identity.actor(request)`,
the effective account, never a name or an id from the payload (a payload `acted_by` is
ignored); the row records the who-column pair (`acted_by` as the display name snapshot,
`acted_by_id` as the account), `acted_at`, the new status and the note; under an act-mode View
As the access middleware adds the `act_as.write` audit row naming the real account; a state
that means something was decided emits the `decision` event before the row write; the answer
is the merged row as the page shows it. The read alongside takes `show=recent|archived|all`
and refuses anything else with 422 (`BAD_SHOW`). `archived` is the state for a row put away
for good, from open or closed; `reopen` returns any state to open and clears the note.

The conversation writes follow the same shape (`app/routers/discussion.py`, `services/discussion.py`).
**Resolve and reopen with a closing word and an outcome** (the workflow plan,
`.logs/planning/discussion-workflow-2026-09-14.md`): `POST /api/discussion/threads/{id}/resolve`
`{resolved, comment?, outcome?}` (`client.participate`; anyone who wrote a live comment in the
thread or a `discussion.curate` holder, checked before the first write, so a refused caller writes
nothing). `outcome` is the thread's one mark: `decision` on a resolution (rian's card design of 14 Sep:
"record as a decision made", the green outline, the Decisions view) or `later` on an open thread
(`POST /api/discussion/threads/{id}/stage {stage: current|later}`, the same permission as
resolve: parked, the Later tab, the discussion free to go on; a resolved thread is reopened
first); a plain resolution stores `NULL` (the older `later` on a resolution is still accepted).
The read carries `later` and `decision` as booleans. The words, when given, are
posted first as the thread's last comment by the actor through the one comment path (their
`author_id` is the record) and the comment's id is kept as `closing_comment_id`, the thread's
record; then `resolved`, `resolved_by_id`, `resolved_at` and `outcome` change, in one transaction;
the `resolved` event the people in the thread hear carries the words, and the word's own reply
rows are flagged resolved at birth so nobody is rung twice. A reopening word reaches them as an
ordinary reply, and reopening clears the outcome and the record. Every permission lookup precedes
the first write, and the sessions never autoflush, so the word is flushed before the flagging
query runs. The read carries `resolved_by`, `outcome` and `closing_word`, which the card's status
line shows ("Parked for later by Rian: …").
**The ask, the one hand-off** (the workflow plan §2; it replaced the curator-only follow-up mark,
whose routes and columns stay for one release, unused by the SPA): `POST
/api/discussion/threads/{id}/asks {for, note, kind?}` (`client.participate`): one person, who
must be someone `@name` could reach on the subject (the mention directory; oneself allowed), and
either a `kind` (`later`, `resolve`, `reply`: the person asked gets a button that is the action,
the words optional and defaulting to the kind's line, and the ask completes itself when they do
the thing, `complete_asks_by_action`: their reply, their resolve, their Mark as Later; nobody
else's action completes it) or a line saying what (422 `NOTE_REQUIRED`, `NOT_REACHABLE`). A row in `thread_asks` (`for_id`, `by_id`,
`note`, `created_at`) and a `turn` notification to the person (needs-you, dedupe `ask:<id>`,
source `ask`); the asker never rings themselves. `POST /api/discussion/asks/{id}/done {comment?}`:
the person asked or a curator (403 otherwise); the word, if any, is their comment through the
one comment path; `done_at` and `done_by_id` are set, the person's turn row leaves the count, and
the asker hears it as `status` news (dedupe `askdone:<id>`); done twice changes nothing. The
threads read carries `asks` (open first) and `needs_you` counts an open ask for the viewer beside
an unread mention; reading the thread clears the turn row but not the ask. The panel lists
threads with an open ask under Asks, resolved or not. Not a task system: for whom, what, by whom,
done by whom.

**Archive:** `POST /api/discussion/threads/{id}/archive {archived}` (`discussion.curate`): a
resolved conversation put away for good (incorporated into the build, or no longer relevant), or
brought back; an open thread is resolved (Done) first, so nothing archived is an open issue.
`archived_at`, `archived_by_id`; the read carries `archived`. Never deleted, still repliable; the
panel hides it unless "Show archived", and its closing word stays in the Decisions tab.

**Flag:** `POST /api/discussion/comments/{id}/flag` (`client.participate`): a personal marker on
a comment, one row in `comment_flags` per person and comment, a second call takes it back;
nobody else sees it and nobody is told. The read carries the viewer's own `flagged` per comment
and per thread, for the panel's Flagged view and the For you tab.

**Got it, the one acknowledgement:** `POST /api/discussion/comments/{id}/ack`
(`client.participate`): seen and agreed, one row in `comment_acks` per person and comment, no
words; a second call takes it back. Setting it completes the person's open asks on that thread
(the asker told as news), which is how "confirm you have seen this" closes without a comment.
The comment read carries `acks` (names) and `acked` (the viewer's).
**Re-filing a comment:** `POST /api/discussion/comments/{id}/move {subject_type, subject_id,
label?}` (`discussion.curate`). The words, the author and the times never change; the comment
keeps who moved it and the thread it came from (`moved_from_thread_id`, `moved_by_id`,
`moved_at`; the card says "moved here"), the legacy key follows so the one-release reads agree,
the notifications the comment raised are re-addressed so the bell's links land, and the emptied
thread stays as a starter. A comment already on the target is left alone. `app.cli discussion
rethread --check|--apply --file import/rethread-<date>.json` applies a map once, attributed to
the owner account: the topics it names, one move per comment, the listed soft deletes; `--check`
is a walk that writes nothing (it never depended on a rollback), a second `--apply` reports every
entry as already done.

## Notifications

`app/services/discussion.py` emits the Interaction Standard's event into `notifications`, one
row per recipient, from three places: a comment (a `mention` for each account named `@name` in
the STORED body, then a `reply` to everyone who took part in the thread plus every
`discussion.curate` holder), a thread resolved (`resolved`, to the people in it; the rows the
thread raised are flagged `resolved` and leave the unread count, reopening un-flags them) and
something decided (`decision`: a decision card resolved on `/discuss`, a running-list decision
recorded on `/plan`; news, never "needs you"). Never the actor; every emit carries a
`dedupe_key`, so a retry cannot ring twice. A recipient must hold the subject's permission
(`permission_of`: `plan.view` for the running list's `item:*` subjects, `client.view` for every
other), so an `@` of someone who cannot see the subject is dropped silently and a plan thread
never produces a row for a client; the `@claude` handle is in the directory only on the owner's
subjects and notifies nobody (a session reads it, `RUNBOOK.md`). Every permission lookup runs
before a write path's first write, because the kit answers `can()` from a session of its own.
`GET /api/notifications` is the effective account's inbox (forty rows, unread first, the
`unread` count and `needs_you` when an unread row is a mention); `POST /api/notifications/read`
marks rows read (`ids`, or all). Both are `MEMBER` routes. The bell is not mounted on DFP (rian,
14 Sep): the comment bubble on the header's profile circle wears the unread count (`unreadCount` in
`web/src/lib/threads.ts`) and opens the side panel on For you, whose own number is what needs
dealing with (`forYouCount`: unread, asked of you, flagged); the inbox rows still feed the digest mail and the read state.

**Read state per thread** (`thread_reads`, one row per thread and account, `read_at`): what
`GET /api/discussion/threads` says a thread is to the viewer is computed at read time, never
stored twice. `unread` is a live comment by someone else after the viewer's `read_at` (no row:
never opened; one's own words are never unread to oneself); `needs_you` is an unread,
unresolved `mention` or `turn` row for the viewer whose source comment is in the thread
(`needs_you_threads`), the same rows the bell counts, so the bell and the panel's Needs-you tab
agree by construction. `POST /api/discussion/threads/{id}/read` (`client.view`; a running-list
thread is 404 to a client, a read-only View As is refused like every write) moves the viewer's
own stamp inside a savepoint and marks their notifications from that thread read in the same
write, so opening a conversation in the panel or on the page clears it from the bell too. The
read carries `started_at`, `last_activity_at` (the newest comment or edit) and `reply_count`
for the panel's two sorts, and takes `since=` (ISO; a bad value is 422 `BAD_SINCE`).

**The read seed** (`backfill thread_reads_seed`, once, at the panel's arrival): a read row stamped
now for every active account and every thread with a live comment where none exists, so "unread"
starts at zero and means new activity from then on; a second run inserts nothing.

**The mention that never rang** (`backfill legacy_mentions`, also run at the end of `backfill
threads`): a comment written before the write path emitted notifications, or linked to its
thread by the backfill, named `@someone` and nobody heard. The backfill resolves every live
comment's stored mentions against its subject's directory under the write path's own rules
(a name that cannot see the subject is dropped, `@claude` rings nobody, never the author) and
emits the `mention` row under the write path's own dedupe key (`mention:<comment>:<user>`),
dated by the comment and filed resolved from birth when its thread already is; a row emitted by
hand or by the write path is left alone, so a second run reports zero.

## The local directory and mail

`LocalDirectory` is what the kit calls its central client, duck-typed: `userinfo` raises for an
absent row and resolves a disabled one with its status (so the kit's add path refuses it with
`DISABLED` rather than treating it as new); `invite_user` and `create_user_with_password`
insert the principal inside a savepoint and answer `EXISTS` on any existing username or email
without continuing (the savepoint-and-re-read rule is for shared domain rows, not
principals); a generated password is 16 characters of the unambiguous alphabet, grouped
`xxxx-xxxx-xxxx-xxxx`, returned once and never stored or logged. `services/mail.py` is the
seam: provider `none` (the default) raises `MailNotConfigured` before anything is written,
which the People panel shows as the invite failing with the alternatives; there is
deliberately no console provider. Until a provider exists the CLI is the invite path:
`accounts invite-link` and `reset-link` print a one-time link once to the operator's terminal.
Every send counts against a daily cap in `email_sends` (`mail.send_capped`: 6 auth mails per
account, 25 invites per host, 40 notification mails per account); a spent cap raises
`MailCapReached` before the provider is called, which the directory turns into `MAIL_CAP`. The
notification digest (`app/cli_notify.py`, `notify digest`, host cron every thirty minutes) mails
one batch per person for what the panel's Needs-you tab lists and nothing else (an unread,
unresolved mention or turn; a reply, a resolution or a decision is the bell's news and is
stamped delivered without a mail), honours `account_preferences`
(`GET/POST /api/notifications/preferences`, the checkbox on `/account`), and stamps
`delivered_at`; `RUNBOOK.md` (Mail) has the procedure and the env lines.

## Tests

`tests/kit/` holds the kit's five conformance files byte-identical (part of the vendor
manifest); their contract (`as_user`, `as_session`, `OWNER`, `HAS_INSTANCES`) lives in the root
`tests/conftest.py` and their fixtures (`client`, `kit`, `bw_calls` over a recorder that wraps
the directory, the recording mailer, the store reset that seeds the kit's two templates and
the stubbed principals) in `tests/kit/conftest.py`, so no pure DFP test acquires an accounts
store. The accounts suites run in-process against an in-memory SQLite holding the auth
tables (`tests/kit/_env.py`), still no server and no network, with argon2 at a fraction of
its production cost; the production parameters are pinned separately.
