Changes touching this path
- the browser mints the seed and the account binds the key: Clerk in the site's own routes, variant-B onboarding, and the account tier's one producer (#753)
Migration 0007 adds account_seed (the passphrase-wrapped envelope, opaque
ciphertext the server cannot open) and forge_identity, a NOLOGIN role scoped
to exactly the identity tier -- SELECT/INSERT on tenant, SELECT/INSERT plus a
column-scoped UPDATE of retired_at on account_key, the full row on
account_seed, and nothing else (pinned live by
forge_identity_is_scoped_to_the_identity_tier).
The producer lives in site/src/server/identity/ behind a verified Clerk
session (verifyToken in the site's own route handlers, reusing the millerbyte
Clerk instance and pattern, never the gateway service). Registration is one
transaction: upsert tenant on the verified clerk_user_id, add the key, store
the envelope, retire what rotation names -- and never zero live keys. The
Rust service still writes none of the tier; tests/account_tier.rs now guards
that boundary instead of a vacancy.
Onboarding is #487's variant B, the two-column contract: the app owns the 32
bytes (crypto.getRandomValues -> 24-word BIP39 over the ENTROPY, never the
64-byte derivation -> ed25519 pubkey via @noble/curves, held to the engine's
from_seed by the pinned RFC 8032 vector), a WebCrypto PBKDF2+AES-GCM envelope,
a .txt download that excludes the passphrase, and two consent checkboxes that
no download waives. Clerk mounts only under /account, which joins /k/ on the
prerender deny-list; every static surface stays static.
Follow-ons filed rather than folded: #926 (CLI raw-seed import, so BIP39
recovery reaches the CLI) and #927 (account-aware may_push, burn entitlement
via account, and the #506 historical-key 301s the anonymous read tier
deliberately cannot serve today).
278e1886 · dbf3dbe6… - a long-lived container holds no credential: the forge and the site seed their environment from a file the container env only points to - ADR 0059, the loot half of (#1034)
27df4dc9 · dbf3dbe6…diff - the second sweep's fix-up, and the guard at its centre could never have failed: it asserted that a view statement contains the member family prefix, while the statement BEGINS with create or replace view forge_member and then the view's own name - so the needle matched the name it was reading - and the two assertions beside it were absences that an UNGATED view also passes. The replacement computes the TRANSITIVE set of views reaching the gate, matching whole identifiers against each view's body rather than its header, and the contrast is committed rather than transcribed: a synthetic family per gate carrying an ungated base-table view, a shadow-prefix trap, and a one-hop and a two-hop view, asserting the OLD needle green on the first two while the new check refuses them, with the chained views as positive controls - a spelling that demands a direct FROM the gate fails that control, which is exactly why it is not the fix. The same shape had left the owner family unguarded since #1043, and both were red-proved on one planted break, deleting the join from a chained view and reading back that it reaches its gate by no chain at all. Beyond the ticket, migration 0003 had NO reach check whatsoever, only the outer-join tripwire, so the ANONYMOUS family was the least guarded of the three rather than the most; it gains the same check, red-proved the same way, and all three migration files are restored byte-identical with md5 verified. The night's dominant defect was counting: two different roles were each called the fourth role, ADR 0077 called the same thing a third family and a fourth family 284 lines apart, and ADR 0059's heading said FOUR and they are named rather than counted from here on, directly above a sentence listing five. Roughly twenty sites now list NAMES instead, because a corrected ordinal drifts again on the next role while a list of names cannot, and the arithmetic is settled against a live cluster rather than by reading - pg_views reads fifteen, eleven and eleven, so thirty-seven views in THREE families, which makes ADR 0077's own amendment the half that was wrong. The sentence that had been wrong three separate times is made CODE-DERIVED and stops being prose anyone must remember: removal now asks whether the member read tier is configured, the same question the pool itself asks, so the web half follows the deployment - while the sync half stays prose and is NAMED as the one line left to remember, because nothing in that process can observe the access class. The tenant validator is fixed in the CODE rather than in its claim, since the claim is what later readers rely on and narrowing the regex to eighteen digits would reject legal ids: set_config was measured accepting a nineteen-digit value with the first member query raising out of range, so a range check joins the pattern and the maximum itself is asserted as the positive control (#1739)
74afeacc · dbf3dbe6…diff - two concurrent key rotations can no longer leave an account with no live key, which is a lockout because a key that is not live is refused the sync read, the push and the burn: the floor in site/src/server/identity/identity.ts is a count, the BEGIN it runs inside is the postgres default READ COMMITTED, and two registrations that each retire the key the other leaves live write different rows, wait for nothing, each count the other as live and both commit. registerKey now selects the key rows of that account in account_key under FOR UPDATE, ordered so two waiters take them in the same order, before it writes any of them, so the second registration waits and then counts a state that has stopped moving; and identity/db.ts stops rewrapping a refusal raised inside a transaction as a plain Error, which had been answering every one of them with the anonymous 500 rather than the 422 that carries the sentence the account page renders. the ticket preferred a database constraint and that was the right thing to prefer, but a floor of this kind says a row must EXIST, which a declarative postgres constraint does not say, and the trigger that would say it was measured failing on a throwaway cluster: with a DEFERRABLE INITIALLY DEFERRED constraint trigger counting live rows over a table shaped like account_key, two sessions each retiring the row the other left live both committed and zero live rows were left, because a deferred trigger fires at commit and still reads its own snapshot. what closes the race is a lock either way, so it is taken where it needs no migration, the forge release chain no-migration-since-v0.4.20 state stands, and this owes a site deploy and no forge deploy. red first against a throwaway postgres with the counts read: with the locking clause dropped the new concurrent test ends with the account on zero live keys and neither session refused (1 failed and 10 passed), with the refusal rewrapped the two class pins go red (2 failed and 9 passed), and with the pause keyed on a statement that never matches it refuses on zero arrivals rather than passing vacuously (1 failed and 10 passed). ADR 0038 records the decision beside the #2004 amendment whose argument rests on this invariant, and names what the lock does not cover. bash ci/local.sh is green against a postgres 18 it created (3977 passed over 122 binaries, 7 ignored, and 67 passed over the seven site live files with none skipped) and the site gate is green end to end (668 passed and 62 skipped over 61 files, 62 surfaces) with no ceiling moved (#2015)
18aca67b · dbf3dbe6…diff - key management on the site: a key can be retired by itself, named, and chosen at unlock. per the operator grill on #2398 (ADR 0100): POST /api/identity/retire retires one key of the signed-in account, refusing the last live key under the account-row lock registration takes and never re-dating a retirement, with no route to un-retire and no deletion, since #506 keeps every key resolvable; /account lists live keys and folds retired ones under a collapsed section, and the retire confirmation names the repos the key owns. names live in a new owner-only account_key_label table (migration 0027) that only forge_identity reaches, trimmed, at most 40 characters, with control, line-separator and direction characters refused and a CHECK repeating the cap; they reach only /account, the unlock picker and the private bar, always beside the fingerprint. the unlock picker lists every live key, defaulting to the key that owns the URL namespace, then the key this device last unlocked, then the only live key, and a key with no stored passphrase asks for its 24 words and can save one. both routes are Bearer only and refuse a key of another account; /api/identity/me stops returning the internal tenant id. rebased by hand over migration 0026 and ADR 0099. pg tests pin the role reach, route and picker tests pin the rules, each red under a named mutation; bash ci/local.sh green against Postgres 18 (4568 passed), site gate 842 passed. owes the setup-forge tripwire update, a forge deploy, then a site deploy (#2398)
a90c85fb · dbf3dbe6…diff
Renames are not followed. loot's tree maps a path to an address, so a rename is a delete and an add. This list is the history of the name, not of the bytes.