oficina / home Public

Revision after review: the blindness claim was false at source

A review dispatched to Fable returned sixteen confirmed findings and a
"not ready" verdict. The headline one attacked the document's central
claim and was correct.

Section 0 said the home never stores an address and section 10 said no
address column exists. Both were falsified by the sign-in stack the spec
itself chose: rastrillo/auth stores address NOT NULL in auth_links and
auth_sessions, and sets the session Subject to the readable address,
which passkey credentials, challenges and recovery codes all inherit.
Config has no subject-remapping hook, so it cannot be configured away.
The spec had cited "Kass's pattern" as though the plugin implemented it.
It does not.

Paul's call: hand-roll sign-in, keep the claim true. The subject is an
opaque person ref, link state is sealed before it is written rather than
cleaned up after — deleting a row does not erase bytes from a file
Litestream ships continuously — and the missing pieces are filed upstream
as rastrillo/rastrillo #2. The one rule now says "never stores a readable
address", because a magic link must survive a restart and an absolute
claim was one the schema could not keep.

The contract had three holes that guaranteed divergence for two teams
starting at step 3: the assertion never defined what identifies the
person, call 5 keyed a journal on hashes no instance can compute, and the
consumer spec still said the instance hashes. Call 2 now returns a person
ref that is also the assertion subject, so one identifier links assertion
to grant to journal.

Ruling 15 narrows the per-team 2FA promise to per-person for slice one.
No call carried team policy, rastrillo's Gate is strictly per-account, and
every workaround breached something — both specs were promising a feature
neither could build.

Also: two threat models stated separately rather than collapsed into a
flattering one, since an attacker holding the pepper gets bulk recovery
against breach corpora rather than a confirmation oracle; expiry sized to
cold-wake plus skew, because the exchange request is what wakes a
hibernating instance; the redirect protocol and its login-CSRF and open
redirect gates, which were absent; a pepper epoch instead of a sweep with
no channel to reach hibernating instances; log hygiene copied from the
sibling spec; honest provisioning copy the manual drain can keep; and the
lifecycle verbs a real identity service needs, now named in section 14
rather than missing.
Reviewed-By: Fable (dispatched) + docs-e6 consumer review
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
Paul Campbell pushed by paul@keymail.dev da52b09f7c179fec763766e47d9a715283e598c2 parent 4d928a2
2 files changed, +586 −442
  • AGENTS.md +26 −3
  • docs/superpowers/specs/2026-08-30-home-design.md +560 −439

No CI script — run details

diff --git a/AGENTS.md b/AGENTS.md
index b17c9b4..1fb6bf9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -105,10 +105,31 @@ quietly ignored).
- Every assertion names the **app and instance host** it is for. Without
audience binding, team A's assertion replays against team B, and a
deployment has thousands of siblings to replay against.
+- **Signed identity claims are trusted for identity; every other claim must
+ be cosmetically failable.** The subject itself fails the "hostile value
+ changes nothing" test — a hostile subject is account takeover — so the
+ test must exempt identity by construction or it proves too much and gets
+ argued around.
+- **Any "prune when all consumers have caught up" is unbounded in a
+ hibernating fleet.** A dormant instance runs no timers to advance its
+ cursor. Retention is a fixed horizon or a per-consumer TTL, never a
+ slowest-consumer rule. This shipped twice in one spec before it was
+ caught.
**Data**
- **No address column exists**, so there is none to accidentally populate.
- Addresses are learned transiently and hashed.
+ Addresses are learned transiently and hashed — and where one must survive
+ a restart (a magic link between send and click), it is **sealed before it
+ is written**, never cleaned up afterwards. Deleting a row does not erase
+ bytes: SQLite leaves deleted pages and Litestream ships WAL frames
+ continuously, so a transiently readable write reaches the bucket before
+ any delete. `PRAGMA secure_delete` is on.
+- **The session Subject is an opaque person ref, never an address.**
+ `rastrillo/auth` as shipped sets `Subject: id.Address` and stores readable
+ addresses in `auth_links` and `auth_sessions` — verified at source, filed
+ upstream as `rastrillo/rastrillo` #2. We hand-roll session minting until
+ that lands, and use `auth` only for address classification and the Keymail
+ upgrade, which hold nothing.
- **The home hashes; instances never hold the pepper.** A pepper on every
instance makes one compromise an offline dictionary attack against the
whole suite directory.
@@ -118,8 +139,10 @@ quietly ignored).
**Frontend**
- Write `class="rst-*"`. The bare-attribute grammar is ruled but not
- released — the shipped `ui/tokens.css` has zero attribute selectors
- against 382 class selectors.
+ released — the shipped `ui/tokens.css` has zero **bare `rst-*`** attribute
+ selectors against ~528 `.rst-` class selector occurrences. (It does carry
+ `[data-tone=]`, `[aria-*]` and `[type=]`; "zero attribute selectors" would
+ not survive being checked.)
- **Re-copy `static/tokens.css` on every rastrillo upgrade.** It is frozen
at scaffold time while partials upgrade with the module. Adopt
`rastrillo doctor` the day it lands and delete this step.
diff --git a/docs/superpowers/specs/2026-08-30-home-design.md b/docs/superpowers/specs/2026-08-30-home-design.md
index 033ee7a..aa0f313 100644
--- a/docs/superpowers/specs/2026-08-30-home-design.md
+++ b/docs/superpowers/specs/2026-08-30-home-design.md
@@ -1,31 +1,38 @@
# 🤖 The Oficina home — design
-**Status:** design, 2026-08-30. Drafted by the Sheets session with Docs
-(`oficina/docs`) as a declared consumer, from decisions settled in the
-Sheets brainstorm with Paul on 2026-08-30. **Not yet reviewed** — the Sheets
-spec's §12.1 requires a highest-capability-model pass before approval.
+**Status:** design, revised 2026-08-30 after review. Drafted by the Sheets
+session with Docs (`oficina/docs`) as a declared consumer.
+**Review passes:** consumer review by docs-e6 (5 findings, all applied);
+review dispatched to **Fable** (16 confirmed, 6 suspected — verdict "not
+ready", now addressed). Source claims re-verified by this session before
+acting.
**Scope:** the suite's home — identity, directory, provisioning, person
-preferences. Consumed by Sheets and Docs. It is not an application and holds
-no documents.
+preferences. Consumed by Sheets and Docs. Not an application; holds no
+documents.
---
## 0. The one rule
> **The home is glue: thin, blind, replaceable. It knows that you exist and
-> where your teams are. It never stores your address, and it never learns
-> what is in anything.**
+> where your teams are. It never stores a readable address, and it never
+> learns what is in anything.**
-The falsifiable form, which is the family's own test — *publish the database
-and see*:
+The falsifiable form — the family's own *publish the database and see*:
> Publish the home's database in full and the worst anyone learns is **how
-> many people exist, what their teams are called, and that some
-> unidentified person belongs to a particular set of them.** No email
-> address. No document. No cell.
+> many people exist, what their teams are called, and that some person
+> belongs to a particular set of them.** No document. No cell. No readable
+> address.
+
+**"Readable" is doing real work in that sentence**, and the first draft's
+absolute phrasing ("never stores your address") was false — see §3.2. A
+magic link must survive a restart between send and click, so an address is
+at rest *somewhere*; ours is sealed, enumerated, and short-lived. Saying
+"never" would have been a claim the schema could not keep.
Factor XI names the category — *centralised infrastructure is glue: thin,
-blind, replaceable* — and `AGENTS.md` opens with this rule verbatim.
+blind, replaceable*.
---
@@ -34,507 +41,614 @@ blind, replaceable* — and `AGENTS.md` opens with this rule verbatim.
| # | Ruling | Rejected | Who, when |
|---|---|---|---|
| 1 | **One RP: the home's credential origin. Instances never enroll passkeys** | Per-instance passkeys (three teams = three credentials) | Paul, 2026-08-30 |
-| 13 | **Provisional origins are `oncarlos.com` hosts under account `bds`; the RP ID is always the full host** — §4 | `carloku.com` (the console brand, not the apps domain); a suffix RP ID | Corrected 2026-08-30 |
| 2 | **Split origins: credentials and directory on different hostnames** | One origin, mitigated with strict rendering + CSP | This session, 2026-08-30 |
| 3 | **Directory is a blind index: `HMAC(pepper, lowercased address)`** | Readable addresses; a sealed per-person vault blob | This session, 2026-08-30 |
| 4 | **The home hashes; instances never hold the pepper** | Instances hashing (a suite-wide secret on every instance) | This session, 2026-08-30 |
-| 5 | **Instances store readable addresses; the home never does** | Neither stores (kills share notification); the home stores (aggregation risk) | Paul, 2026-08-30 |
-| 6 | **Recovery codes** (`RegenerateRecoveryCodes`) | Operator reset; both | Paul, 2026-08-30 |
-| 7 | **Open signup; the operator drain is the abuse gate** | Invite-only; require a claimed Keymail; payment up front | Paul, 2026-08-30 |
+| 5 | **Instances store readable addresses; the home never does** | Neither stores (kills notification); the home stores (aggregation risk) | Paul, 2026-08-30 |
+| 6 | **Recovery codes** | Operator reset; both | Paul, 2026-08-30 |
+| 7 | **Open signup; the operator drain is the abuse gate** | Invite-only; require Keymail; payment up front | Paul, 2026-08-30 |
| 8 | **Provisioning queues; an operator drains** | A standing console token in a web-facing app | Paul, 2026-08-30 |
-| 9 | **The directory is a hint. Sign-in is an authority, and says so** | Treating both as hints; treating both as authorities | This session, 2026-08-30 |
+| 9 | **The directory is a hint. Sign-in is an authority, and says so** | Treating both alike | This session, 2026-08-30 |
| 10 | **Theme is per person, suite-level, resolved before first paint** | Per document; per team; follow-OS only | Paul, 2026-08-30 |
-| 11 | **One shared instance, not instance-per-account** — §2 | Instance-per-person (the boundary the home exists to cross) | This session, 2026-08-30 |
-| 12 | **Sealed at rest, DEK from platform secrets** — Sheets' posture | Plaintext (a plaintext column is a plaintext bucket leak) | This session, 2026-08-30 |
+| 11 | **One shared instance — and this is not a divergence** — §2 | Instance-per-person; calling it a divergence | This session, 2026-08-30 |
+| 12 | **Sealed at rest, DEK from platform secrets** | Plaintext (a plaintext column is a plaintext bucket leak) | This session, 2026-08-30 |
+| 13 | **Provisional origins are `oncarlos.com` hosts under account `bds`; the RP ID is always the full host** | `carloku.com` (console brand); a suffix RP ID | Corrected 2026-08-30 |
+| 14 | **Hand-roll sign-in: subject is an opaque person ref, link state is sealed** — §5.1 | `rastrillo/auth` as shipped (stores readable addresses); blocking on upstream | Paul, 2026-08-30 |
+| 15 | **Passkey 2FA is optional per person. Owner-enforceable per-team is NOT in slice one** — §14 | Promising a per-team floor neither spec can build | Paul, 2026-08-30 |
---
-## 2. What the home is, and the divergence it requires
+## 2. What the home is
-The home is **one shared instance**, not one per account — and **this is not
-a divergence**, which the first draft got wrong by writing an apology for it
-(corrected by docs-e6, 2026-08-30; proposed as a doctrine ruling at
-`carlos/pegamento` #1).
+One shared instance, not one per account — **and this is not a divergence**
+(ruling 11; proposed as doctrine at `carlos/pegamento` #1).
**The instance boundary rule governs content, and the home holds none.**
-Rastrillo's *"a CARLOS app serves one team… isolation by process and file,
-never by WHERE clause"* and Pegamento §1's four boundaries are all
-boundaries *around content*. A registry has nothing for the rule to bite on.
-Factor XI says it outright — *centralised infrastructure is glue: thin,
-blind, replaceable* — and centralised is in the sentence. Pegamento already
-assumes this without ruling it: §4 observes that Eleven's `home/` and Kass's
-`internal/store/` are the same service built twice, and neither is
-instance-per-person.
-
-Sharing is also what makes the service possible at all: an
-instance-per-person home could not answer "which teams does this person
-belong to" without becoming the federation story §3 refuses.
+Rastrillo's *"a CARLOS app serves one team… isolation by process and file"*
+and Pegamento §1's four boundaries are all boundaries *around content*. A
+registry has nothing for the rule to bite on. Factor XI says it outright,
+with "centralised" in the sentence. Pegamento already assumes it: §4
+observes Eleven's `home/` and Kass's `internal/store/` are the same service
+built twice, and neither is instance-per-person.
+
+Sharing is also what makes the service possible: an instance-per-person home
+could not answer "which teams does this person belong to" without becoming
+the federation story §3 refuses.
**Blast radius is bounded by what a service holds, not by how many processes
-it runs in.** That sentence is the correction. This draft originally reached
-for process isolation as the safety property; for a service holding no
-content the safety properties are the leak profile (§3) and the
-authorization re-check (§6).
-
-**What sharing costs, named rather than waved past.** The home has a
-suite-wide blast radius: it is the one process whose failure touches
-everybody. Four things bound it, and the first two are structural rather
-than aspirational:
-
-- **It holds no content.** The worst case is disclosure of §3's leak list,
- not of anybody's work.
-- **Losing it costs a convenience, not access** — for the directory (§6).
- Sign-in is the exception, and §5 says so out loud rather than pretending
- otherwise.
+it runs in.** This draft originally reached for process isolation as the
+safety property; for a service holding no content the safety properties are
+the leak profile (§3) and the authorization re-check (§6).
+
+**What sharing costs**, with the first two structural rather than
+aspirational:
+
- **Split origins mean a directory compromise reaches no credentials** (§4),
and **every instance re-checks authorization on every request** (§6), so a
compromised directory grants nothing. A compromised home costs the graph
and a convenience outage. It does not cost a document.
+- **It holds no content.** The worst case is §3's leak list, never anyone's
+ work.
+- **Losing it costs a convenience, not access** — for the directory. Sign-in
+ is the exception and §5 says so rather than pretending otherwise; §2.1
+ lists what shrinks it.
- **It stays small.** Every proposed feature is weighed against "does this
- belong in an instance instead?", and the answer is usually yes. A home
- that grows features grows blast radius.
-
-**What the home never does**, each being something somebody will propose:
-
-- **Never renders content from an instance.** No document previews, no sheet
- thumbnails, no cell values. The directory shows what you have access to,
- never what is in it.
-- **Never holds an authorization model.** It knows you have access to a team
- in an app; what you may do there is the instance's business (§5.4).
-- **Never federates.** Data does not cross an instance boundary; sharing is
- membership.
+ belong in an instance instead?" A home that grows features grows blast
+ radius.
+
+### 2.1 The single point of failure, and cheap mitigations
+
+Sign-in is an authority, so a home outage stops *new* sessions suite-wide.
+Existing sessions keep working. Four things shrink that exception without
+changing the architecture:
+
+- **The home does not hibernate.** It is the one app on the estate where
+ cold-wake latency is a suite-wide outage. Stated because hibernation is
+ the platform default and would otherwise apply silently.
+- **A rehearsed cold-standby restore with a written RTO.** The drill exists
+ in §12; the target belongs here.
+- **Sliding session renewal at instances**, so long-lived teams rarely
+ intersect a short outage.
+- **A status page**, so an outage is legible rather than mysterious.
+
+### 2.2 What the home never does
+
+Each is something somebody will propose in good faith.
+
+- **Never renders content from an instance.** No previews, no thumbnails, no
+ cell values.
+- **Never holds an authorization model.** It knows you can reach a team in
+ an app; what you may *do* is the instance's business (§5.5). Ruling 15
+ exists because the per-team 2FA floor would have breached this.
+- **Never federates.** Data does not cross an instance boundary.
- **Never becomes a support console.** Reading someone's data to help them
is what §0 forbids.
+### 2.3 A recorded divergence: the directory is location plus graph
+
+Pegamento's registry *"answers who exists and how to verify them, never
+where they are"*, and §4 deliberately does not generalise the graph facet
+("one variant is not enough, least of all in the facet with the worst leak
+profile").
+
+Our directory is a person↔team↔host map: location **and** graph. Ruling 9
+aligns it with Woodstar's *"cache + queue, never authority"* — but the
+alignment is an argument, not a blessing, and the first draft claimed the
+doctrine "already sorts this". It does not. **Recorded as a divergence,
+bounded by the hint ruling**, and put to `carlos/pegamento` #1.
+
---
## 3. Trust model and the leak list
-Sealed at rest, DEK from platform config, HKDF purpose keys, AES-GCM — the
-same posture as Sheets (ruling 12), because the same reasoning applies: the
-platform replicates the whole SQLite file to object storage, so a plaintext
+Sealed at rest, DEK from platform config, HKDF purpose keys, AES-GCM
+(ruling 12) — the platform replicates the whole SQLite file, so a plaintext
column is a plaintext bucket leak.
-**Sealed:** team display names; provisioning request payloads, which hold a
-proposed team name and the requester's address until the drain completes.
-
-**Plaintext, and published in the README:**
-
-- The count of people and the count of teams.
-- `HMAC(pepper, address)` values — unlinkable to an address without the
- pepper, and the pepper lives only here (ruling 4).
-- The bipartite graph: that *some* unidentified person belongs to a set of
- team ids, per app, with `kind`.
-- Team hostnames and sqids. These appear in URLs; they were never secret.
-- Passkey credential ids and **public** keys. Public material: a public key
- verifies signatures and nothing else.
-- Session rows, timestamps, and each person's theme preference.
-
-**Never stored, at any point: a readable email address.** The home learns
-one transiently — at sign-in to compute the HMAC, and at directory upsert
-(§6) for the same reason — and never writes it. Kass's pattern, where the
-readable address lives solely in unclaimed sign-in links and is blanked on
-claim.
-
-**The residual risk, stated plainly:** a compromised live host can read the
-pepper and therefore test any address it can already guess against the
-directory — **a confirmation oracle, not a dump.** It cannot produce an
-address it does not have, and it learns nothing about content because there
-is none.
-
-**The gate:** the plaintext grep from the first commit, over the raw SQLite
-file and WAL plus every object this app writes to its own store, against a
-corpus of known addresses seeded by the test. `plaintextExempt` starts empty.
+### 3.1 Two threat models, stated separately
+
+The first draft collapsed these and called the result "a confirmation
+oracle, not a dump", which was honest for one and rationalising for the
+other.
+
+**Database leaked, pepper not:** counts, the bipartite graph, team names,
+hostnames, timestamps. No address recoverable.
+
+**Live host compromised, so the pepper leaks too:** the attacker runs the
+whole directory against breach corpora of billions of known addresses,
+offline. Email address space is exactly the guessable space a blind index is
+weakest against, so for most rows **this is a dump with names**, plus the
+graph and hostnames. Calling it an oracle would be a euphemism.
+
+**Mitigation, and its limit:** the index uses an expensive KDF rather than a
+bare HMAC, pricing the dictionary run rather than preventing it. It does not
+make the second model safe; it makes it expensive. Said plainly rather than
+counted as a fix.
+
+### 3.2 What is at rest, precisely
+
+**Sealed:** team display names; provisioning queue payloads; the rekey
+journal's new addresses; **sign-in link state** (§5.1).
+
+**Plaintext, published in the README:**
+
+- Counts of people and teams.
+- Blind-index values (§3.1's caveat applies).
+- The bipartite graph: person ref ↔ team ↔ app ↔ `kind`.
+- Team hostnames and sqids — these appear in URLs and were never secret.
+- Passkey credential ids and **public** keys.
+- Session rows keyed by **person ref**, and coarsened timestamps (§3.3).
+- Each person's theme.
+
+**Never at rest readable: an email address.** Sealed and short-lived in link
+state and the journal; hashed in the index; transient in RAM elsewhere.
+
+**Deleting a row is not erasing bytes.** SQLite does not zero deleted pages
+and Litestream ships WAL frames continuously, so a *transiently written*
+readable address reaches the bucket before any delete. `PRAGMA
+secure_delete` is on, and anything holding an address is sealed before it is
+written rather than cleaned up afterwards. This is why §5.1 exists.
+
+### 3.3 The graph is less anonymous than "unidentified" suggests
+
+Hostnames approximate company names, so each person carries a team-set
+fingerprint. One outside fact — "alice consults for Acme and BigCo" —
+identifies the row and reveals the rest of their memberships, guest rows
+included. Timestamps correlate with events observable at instances.
+
+So: published timestamps are **coarsened to the day**, and §0 says "some
+person" rather than "some unidentified person". Sealing team display names
+buys little while hostnames are plaintext; it is kept because it is free,
+and it is not counted as protection.
+
+### 3.4 Logs and error text are storage
+
+The home handles readable addresses transiently on every sign-in, upsert,
+rekey and welcome mail. One error string — *"failed to hash
+alice@example.com"* — ships an address to platform-held `carlos logs`,
+storage we cannot grep.
+
+**Rule: no address, team name, or queue payload in any log line, error
+string, job status message or metric label.** Errors carry person refs and
+codes. Test-enforced (§12 gate 9). Outbound mail necessarily carries
+addresses through platform SMTP; that is inherent and disclosed. Log lines
+are not.
---
## 4. Two origins
-Ruling 2. A team name is account-authored, so a home rendering team names on
-its passkey RP origin is what `decisions.md` warns against. The answer is
-Tito's, already recorded: *credentials only ever on the root origin,
-account-authored content only on the account's own host.*
+A team name is account-authored, so a home rendering names on its RP origin
+is what `decisions.md` warns against. Tito's answer: *credentials only ever
+on the root origin, account-authored content only on the account's own host.*
-- **Credential origin** — sign-in, the WebAuthn ceremony, recovery, and
- assertion minting. **No team name, no title, no account-authored string,
- ever.** This is the relying party.
+- **Credential origin** — sign-in, WebAuthn, recovery, assertion minting.
+ **No account-authored string, ever.** This is the RP.
- **Directory origin** — a different hostname. Renders team names, holds no
credentials.
-The directory obtains its session by exchanging a signed assertion from the
-credential origin — the same mechanism instances use (§5.3), so there is one
-assertion path rather than two. An XSS on the directory cannot reach the RP,
-because it is not the same origin.
+The directory obtains its session by exchanging an assertion, the same
+mechanism instances use (§5.3), so there is one assertion path rather than
+two. An XSS on the directory cannot reach the RP.
-**This is a permanent design constraint, not a naming choice.** Any feature
-wanting to render account-authored content on the credential origin is
-refused, and this sentence is the reason.
-
-**Provisional hostnames (Paul, 2026-08-30; corrected same day).** The
-account is `oficina`, sqid **`bds`**. Default addresses are
-`<app>.<sqid>.<apps-domain>`, and the hosted apps-domain is
-**`oncarlos.com`** — *not* `carloku.com`, which is the console brand
-(`console.carloku.com`) and appears nowhere in `platform.md`. Both origins
-are therefore distinct `oncarlos.com` hosts under `bds`. Using them now
-**schedules** the RP rename drill rather than avoiding it — §5.5.
+**Provisional hostnames (ruling 13).** Account `oficina`, sqid **`bds`**.
+Default addresses are `<app>.<sqid>.<apps-domain>` and the hosted
+apps-domain is **`oncarlos.com`** — *not* `carloku.com`, which is the
+console brand and appears nowhere in `platform.md`.
**Never widen the RP ID to a registrable suffix.** WebAuthn permits an RP ID
that is a suffix of the origin's hostname, so `oncarlos.com` would be
*accepted* — and it is shared by every Carloku tenant, which would scope our
-credentials across other people's apps. **The RP ID is the full host,
-always**, and it is asserted by a test rather than remembered.
+credentials across other people's apps. **The RP ID is the full host**,
+asserted by a test.
---
## 5. Identity
-### 5.1 Sign-in
-
-`rastrillo/auth`: magic link auto-upgrading to "Sign in with Keymail" when
-the address has a claimed inbox. Classification fails open, so every address
-has a working path — which matters more here than in a normal app, because
-guests arrive from outside the suite entirely and we can assume nothing
-about their email. No passwords.
+### 5.1 Sign-in is hand-rolled, and why — RULED 2026-08-30 by Paul
+
+**`rastrillo/auth` as shipped cannot host a blind identity service.**
+Verified at `origin/main`: `auth_links.address TEXT NOT NULL`,
+`auth_sessions.address TEXT NOT NULL`, and `admit()` sets
+`Subject: id.Address` — which `passkey_credentials`, `passkey_challenges`,
+`passkey_pending` and `passkey_recovery_codes` all inherit. `auth.Config`
+has no subject-remapping hook, so it cannot be configured away.
+
+The first draft cited "Kass's pattern" as though the plugin implemented it.
+It does not. That was the review's highest-cost finding, because the
+tempting week-2 fix is adding the auth tables to `plaintextExempt` — turning
+a guarantee into a comment.
+
+**So (ruling 14):**
+
+- **The Subject is an opaque person ref**, never an address. Sessions,
+ credentials and recovery codes all key on it.
+- **Link state is sealed before it is written** and short-lived. A magic
+ link must survive a restart between send and click, so the address is at
+ rest — sealed, enumerated in §3.2, never readable.
+- **`PRAGMA secure_delete`**, because deleting a row does not remove bytes
+ from a file that is continuously replicated.
+- We still use `rastrillo/auth` for **address classification and Keymail
+ upgrade** — the part that is hard to get right and holds nothing.
+
+**Filed upstream as `rastrillo/rastrillo` #2**: a `Config.Subject` hook, a
+sealed link store, and written `secure_delete`/WAL guidance. Every
+server-blind app on rastrillo hits this on day one and will otherwise
+hand-roll it differently. Adopt it the day it lands and delete our version.
+
+Magic link, auto-upgrading to Keymail when the address has a claimed inbox;
+classification fails open, so every address has a working path — which
+matters more here than in a normal app, because guests arrive from outside
+the suite entirely. No passwords.
### 5.2 Second factor and recovery
-Passkey via `Config.SecondFactor = passkey.Handlers.Gate`. **Optional per
-person; a team owner can require it for their team.** Business documents
-hold payroll and commissions, and a team wanting the floor raised should not
-have to ask us.
+Passkey via `passkey.Handlers.Gate`, **optional per person**.
+
+**Per-team enforcement is not in slice one** (ruling 15). The first draft
+promised it and neither spec could build it: no call carried a team's
+policy, `Gate` is strictly per-account (`Enrolled(sess.Subject)`), and every
+workaround breached something — a home-side policy contradicts §2.2, and an
+`amr` claim on the assertion fails §5.4's boundary test. It moves to §14
+with a trigger, rather than being discovered by the first paying team that
+flips the switch.
-**Recovery is recovery codes** (ruling 6): ten minted by
-`RegenerateRecoveryCodes` behind `sessions.RequireFresh`, shown once,
-redeemed at `POST /passkey/signin/recovery` as a plain form POST with no JS.
-Sign-in only — step-up still demands a real assertion.
+**Recovery is recovery codes** (ruling 6): ten, shown once, redeemed as a
+plain form POST with no JS. Sign-in only — step-up still demands a real
+assertion.
-**No operator reset in slice one**, and the reason is recorded so its absence
-is a decision rather than a gap: an operator reset verb needs an
-identity-verification process and a person to run it, and we have neither.
-**Trigger to revisit:** the first real person locked out with passkey and
+**No operator reset in slice one**, recorded so the absence is a decision:
+it needs an identity-verification process and a person to run it, and we
+have neither. **Trigger:** the first real person locked out with passkey and
codes both lost.
-**What losing everything means, disclosed in the product rather than
-discovered:** losing your email access *and* your passkey *and* your
-recovery codes loses your sign-in. It does not lose your team's data — the
-team's other owners still reach the instance, and the instance is the
+**Losing everything is disclosed, not discovered:** losing email access
+*and* passkey *and* codes loses your sign-in. It does not lose your team's
+data — other owners still reach the instance, and the instance is the
authority.
-### 5.3 Assertions
-
-The home mints a short-lived signed assertion; the instance verifies it and
-mints its own session. **This is the only way identity reaches an instance.**
-
-- **Signature:** Ed25519. The private key is platform config, never in the
- database. Verification keys are published on the credential origin, two
- active at a time so rotation has an overlap rather than a flag day.
-- **Audience:** the assertion names the **app and instance host** it is for.
- Without it, an assertion minted for team A replays against team B — and a
- deployment has thousands of sibling instances to replay against.
-- **Expiry: seconds, not minutes.** It is exchanged for a session
- immediately; the long-lived thing is the session the instance mints, which
- is `sessions`' problem and already solved.
-- **Single use:** a `jti` consumed on first exchange. **The store must be
- self-pruning, sized by assertion lifetime rather than by user count**
- (docs-e6, 2026-08-30) — the guest population is dominated by
- single-purpose visitors, so a store retaining identifiers forever is
- correct on day one and a growing table for the life of the deployment.
-
-**Assertions carry identity and display preferences, never authorization.**
-A guest's assertion is the same shape as an owner's; the instance decides
-capability from its own grants. Encoding capability would require the home
-to hold an authorization model (§2), and a leaked assertion buys exactly
-what a stolen session buys anyway — so narrowing reduces no blast radius
-while adding a second assertion type and a second audience-binding surface.
-
-**The boundary test for any new claim**, so that line stays holdable. It is
-**two-sided**, and the first draft wrote only the half it needed for theme
-(corrected by docs-e6, 2026-08-30):
+### 5.3 The assertion, fully specified
+
+The first draft specified signature, audience, expiry and jti — and never
+said **what identifies the person**, which is the most load-bearing claim in
+the contract. Two consumers were about to guess.
+
+**Claims:**
+
+| Claim | Value |
+|---|---|
+| `sub` | The **person ref** — the home's opaque identifier, stable across email change. Never an address. |
+| `aud` | `app` + **instance host**. The directory origin has its own audience form. |
+| `iss` | The credential origin. |
+| `kid` | Which verification key signed it. Two are active during rotation; a verifier caches by `kid` and refetches once on an unknown one, then fails closed. |
+| `iat` / `exp` | See expiry below. |
+| `jti` | Single use, per verifier. |
+| `prefs` | Theme only (§8), subject to §5.4's test. |
+
+**Signature:** Ed25519. Private key in platform config, never in the
+database. Verification keys published on the credential origin.
+
+**Expiry is 120 seconds, not "seconds"** — sized to worst-case cold wake
+plus clock skew. The exchange request *is* what wakes a hibernating
+instance, and process start plus decrypt plus migrate-check can exceed a
+few seconds, so a tighter window makes the **first sign-in to any cold
+instance fail and loop** — flaky, timing-dependent, and miserable to debug.
+Widening it costs nothing against the threat model, because **single-use
+`jti` is what stops replay**, not brevity.
+
+**Every verifier holds its own self-pruning `jti` store**, sized by
+assertion lifetime rather than by user count (docs-e6, 2026-08-30). Audience
+binding makes local stores sufficient — an assertion for team A is not
+valid at team B, so B never needs to know A consumed it. §10's
+`assertion_jti` table is **the directory origin's own** store, not a shared
+one.
+
+**Delivery is an auto-POST, not a query string**, so assertions stay out of
+edge logs, browser history and referrers.
+
+### 5.4 The boundary test for claims
+
+Two-sided, and the first draft wrote only the half it needed for theme
+(docs-e6, 2026-08-30):
> A claim may ride the assertion only if **(a)** the instance makes no trust
-> decision based on it, validating it against a **closed enumerated set** and
+> decision based on it, validating against a **closed enumerated set** and
> treating a failed match as *absent* rather than as the raw value; **and
> (b)** the home already holds it for its own purposes, so carrying it adds
> nothing to what the home knows.
-Theme passes both. A role fails (a) — the instance grants on it. A list of
-"which documents you can see" would sail past (a) and fail (b), because it
-hands the home a document-level graph it has no business holding, which is
-§2's constraint rather than an instance concern.
+Theme passes both. A role fails (a). "Which documents you can see" passes
+(a) and fails (b), handing the home a document-level graph it has no
+business holding.
+
+**The identity claims are exempt by construction, and the test must say so**
+(review, 2026-08-30): `sub` flagrantly fails (a) — a hostile `sub` is
+account takeover — yet it must ride the assertion. The rule is therefore:
+**signed identity claims are trusted for identity; every other claim must be
+cosmetically failable.** Without that sentence the test proves too much, and
+a future reader either discards it or argues an `amr` claim in under cover
+of the inconsistency.
**Two things the first wording got wrong**, recorded so they are not
reintroduced. "The instance would behave identically" is false even for
-theme — a hostile value produces an ugly page, which is different behaviour
-— and a test its own passing example fails is a gap a future reader smuggles
-something through. And "hostile value" silently assumed the validation that
-makes it safe: a free-string theme interpolated into CSS is an injection,
-not an ugly page. Hence the closed set in (a), stated rather than assumed.
-
-### 5.4 What the instance does with it
-
-Verifies the signature, checks the audience is itself, consumes the `jti`,
-mints a session — and then **decides everything about capability from its
-own `grants` table.** An assertion for a person with no grant on that
-instance is a perfectly valid assertion that gets nothing.
-
-**The link-share ordering problem, and why it does not touch the assertion**
-(docs-e6, 2026-08-30). A link-share visitor who wants to comment signs in —
-and under the attribution rule, signing in is what *mints* their guest
-grant. So when they return from the home they have no grant yet, and "a
-valid assertion that gets nothing" is correct behaviour with the wrong
-outcome.
-
-**Resolution: the capability never rides the assertion. It stays entirely
-instance-side.** The instance begins sign-in with a **server-built return
-URL carrying a nonce** that keys pending state it holds itself — the link
-token and what it grants. On return it verifies the assertion, redeems its
-own pending state to mint the grant, then applies the session. The home
-learns nothing about links, tokens or capabilities, so §5.3's rule survives
-intact rather than needing an exception.
+theme — a hostile value gives an ugly page, which is different behaviour —
+and a test its own example fails is a gap someone smuggles through. And
+"hostile value" assumed the validation that makes it safe: a free-string
+theme interpolated into CSS is an injection, not an ugly page.
+
+### 5.5 What the instance does with it
+
+Verifies the signature by `kid`, checks `aud` is itself, consumes the `jti`
+locally, mints a session — and then **decides capability entirely from its
+own grants**. An assertion for a person with no grant is a valid assertion
+that gets nothing.
+
+**The link-share ordering problem** (docs-e6, 2026-08-30). A link-share
+visitor who wants to comment signs in — and under the attribution rule,
+signing in is what *mints* their guest grant. So they return with a valid
+assertion and no grant: correct behaviour, wrong outcome.
+
+**Resolution: the capability never rides the assertion.** The instance
+begins sign-in with a **server-built return URL carrying a nonce** keying
+pending state it holds itself. On return it verifies the assertion, redeems
+its own pending state to mint the grant, then applies the session. The home
+learns nothing about links or capabilities, so §5.4 survives intact.
This also handles the cross-device case, where the magic link completes in
-whichever browser opened the email: the nonce travels in the URL rather than
-in a cookie, so the round trip reassembles wherever it lands.
+whichever browser opened the email: the nonce rides the URL, not a cookie.
-Filed as `rastrillo/rastrillo` #1: rastrillo has no helper for this, and two
-apps need the identical thing. Until it exists both apps hand-roll it, which
-is the duplication the filing exists to end.
+### 5.6 The redirect protocol
-### 5.5 The RP rename drill
+Absent from the first draft, and the abuse suite did not test it.
-Provisional hostnames (§4) schedule this rather than avoid it. **The
-mechanism already exists:** `passkey.Config.LegacyRPID` is *"a relying party
-this instance used to be, accepted for assertions (never registration) so a
-hostname move doesn't strand enrolled keys"* — verified at source. The drill
-is: set `LegacyRPID` to the old host, run the window, drop it.
+- **Return hosts are validated against the consumers allowlist.** Otherwise
+ the credential origin is an open redirector.
+- **A browser-bound state nonce** ties the returning assertion to the
+ browser that began the flow. Without it an attacker can fix *their own*
+ signed assertion onto a victim's browser — login CSRF, and the victim then
+ works inside the attacker's account.
+- Both are in gate 3.
-**Size the window by the guest tail, not by active members.** The person
-least likely to survive is the third-party guest who signed in once, months
-ago, to comment on one document — no ongoing relationship, no reason to be
-paying attention, invisible to any plan assuming people re-authenticate when
-nudged. **The window is measured in months.** A drill that quietly drops the
-guests is a drill that failed while reporting success.
+### 5.7 The RP rename drill
-`LegacyRPID` removes the machinery cost. It does not shorten the window.
+Provisional hostnames schedule this rather than avoid it. **The mechanism
+exists:** `passkey.Config.LegacyRPID` is *"a relying party this instance
+used to be, accepted for assertions (never registration) so a hostname move
+doesn't strand enrolled keys"* — verified at source. Set it, run the window,
+drop it.
+
+**Size the window by the guest tail.** The person least likely to survive is
+the guest who signed in once, months ago, to comment on one document — no
+ongoing relationship, no reason to notice, invisible to any plan assuming
+people re-authenticate when nudged. **Months, not weeks.** A drill that
+quietly drops the guests is a drill that failed while reporting success.
---
## 6. The directory
-`HMAC(pepper, lowercased address) → (app, team sqid, host, kind)`, where
-`kind` is member or guest — because a guest is not "in" a team the way a
-member is, and a row implying an org membership they do not have is a lie
-the directory tells on the org's behalf.
+`HMAC(pepper, lowercased address) → (person ref, app, team sqid, host, kind,
+sealed team name)`. `kind` is member or guest, because a row implying an org
+membership someone does not have is a lie the directory tells on the org's
+behalf.
+
+**The home hashes, not the instance** (ruling 4). One compromised instance
+holding the pepper would be an offline dictionary attack against the whole
+suite directory, inverting factor VIII.
+
+**Call 2 returns the person ref**, and instances store it against their
+member rows. Without this, call 5 is incoherent — an instance holds no
+HMACs and can compute none, so a journal keyed on hashes matches nothing it
+has (review, 2026-08-30). The person ref is also the assertion's `sub`, so
+one identifier links assertion to grant to journal.
-**The home hashes, not the instance** (ruling 4). Call 2 sends the address
-over the authenticated channel and the home hashes it, learning it
-transiently exactly as it already does at sign-in. The alternative — every
-instance holding the pepper — makes one compromised instance an offline
-dictionary attack against the entire suite directory, inverting factor VIII,
-whose whole point is that a compromise is bounded to one team.
+**Never-signed-in guests get a person stub.** Call 2 for an address with no
+person row mints one, keyed by the blind index; credentials attach at first
+sign-in.
-**The directory is a hint, never an authority** (ruling 9). The blueprint's
-position on glue is that losing it *costs a convenience, never content*:
+**The directory is a hint, never an authority** (ruling 9):
- A failed upsert never fails a share. The instance is the authority.
-- Instances re-check authorization on every request whatever the directory
- says. The directory answers *"where might I have things"*, never *"may I
- have this"*.
-- The instance-side outbox **drains on wake and on each share mutation, not
- on a ticker** — a hibernating instance runs no timers.
-
-A home that is down means people reach their teams by bookmark instead of by
-list.
-
-**Pepper rotation** is a convenience outage, not data loss: access is
-unaffected and the directory refills. It requires a **re-announce sweep** —
-a named operator verb — not merely outbox retry, which redelivers *failed*
-upserts and does not re-enumerate existing grants.
-
-**A guest grant discloses the granting team's name.** Sharing one document
-with an outside guest puts a row in their directory carrying the team's
-name. Kept, because the alternative removes the reason the directory exists
-— but **disclosed in the sharing app's share dialog, in words**, rather than
-left as an emergent property nobody decided.
+- Instances re-check authorization on every request. The directory answers
+ *"where might I have things"*, never *"may I have this"*.
+- The outbox **drains on wake and on each share mutation, not on a ticker** —
+ a hibernating instance runs no timers.
+
+**Pepper rotation uses an epoch, not a sweep.** The first draft called for a
+"re-announce sweep" as an operator verb — which has no channel to reach
+instances, since every call is instance→home and instances hibernate; a
+fleet-wide poke would contradict the platform's zero-infrastructure-access
+contract. Instead: **every call-2 and call-5 response carries a pepper
+epoch**, and an instance whose stored epoch differs re-enumerates its grants
+on next wake. Rotation becomes a lag rather than an incident.
+
+**A guest grant discloses the granting team's name.** Kept — the alternative
+removes the reason the directory exists — but **disclosed in the sharing
+app's share dialog, in words**, not left emergent.
---
## 7. Provisioning
-Signup writes a provisioning request. An **operator command drains it** under
-a real console credential: creates the instance, delivers its home bearer
-token and origin-allowlist entry, sends the welcome mail, marks it live.
-
-**Why queued rather than immediate.** The platform has no machine credential
-for instance creation — console auth is a 90-day human terminal token, and
-`auth.go` names why an app holding one is wrong. Filed as `carlos/platform`
-#4. When that credential exists, the drainer's body becomes a direct API call
-and nothing else changes.
-
-**The drain is also the abuse gate** (ruling 7). Every new team is a real
-provisioned instance costing real money, and a human sees each one before it
-exists. The workaround is doing two jobs, and the second is worth keeping
-after the first becomes unnecessary — so when the machine credential lands,
-**auto-drain is a policy decision, not an automatic consequence.**
-
-Beneath that: per-address and per-IP rate limits on the queue itself, so it
-cannot be flooded even though draining is manual. The signup page says the
-team arrives in minutes rather than faking a spinner.
-
-**Recorded divergence from Pegamento doctrine §9.** The doctrine says
-provisioning is not Pegamento, and flags Eleven's home — a blind vault *and*
-a graph-aware provisioning broker in one binary — as "the one place
-blindness breaks", adding "Pegamento does not repeat that conflation."
-
-**The load-bearing fact is that the home holds no provisioning credential.**
-The queue is opaque; the drain runs outside the home process entirely, under
-a human's console credential. So the genuinely dangerous version of the
-conflation — an RCE on an internet-facing sign-in surface reaching instance
-creation — is **impossible by construction, not unlikely by policy.**
-
-*(A secondary argument, kept because it is true and dropped as the lead
-because it is weaker: the conflation §9 warns about is a blind service that
-also knows person↔instance, and this home already knows person↔instance in
-the directory. That is an argument about what the doctrine meant, and a
-reviewer may simply disagree — where the credential argument survives any
-reading. docs-e6, 2026-08-30.)*
-
-Asked as a question at `carlos/pegamento` #1: if what §9 protects is the
-credential rather than the knowledge, the doctrine could say so — *a blind
-service may know that somebody asked for an instance; it may not be able to
-create one.* If instead the knowledge is the problem, the queue moves to its
-own binary. **Revisit trigger:** that ruling, either way.
+Signup writes a request. An **operator command drains it** under a real
+console credential: creates the instance, delivers its bearer token and
+allowlist entry, sends the welcome mail, marks it live.
+
+**Why queued.** The platform has no machine credential for instance
+creation; console auth is a 90-day human terminal token, and `auth.go` names
+why an app holding one is wrong. Filed as `carlos/platform` #4.
+
+**The load-bearing safety fact: the home holds no provisioning credential.**
+The queue is opaque; the drain runs outside the home process under a human's
+credential. So the dangerous version of the conflation — an RCE on an
+internet-facing sign-in surface reaching instance creation — is **impossible
+by construction, not unlikely by policy.**
+
+*(The weaker argument, kept because true and demoted because contestable:
+§9's conflation concerns a blind service that also knows person↔instance,
+and this home already does. That is an argument about what the doctrine
+meant. Put to `carlos/pegamento` #1: is it the credential or the knowledge
+that must not be fused?)*
+
+**The queue holds readable addresses, and §3.2 discloses it.** The first
+draft claimed the enqueue "adds no knowledge the service lacks" — untrue:
+the directory holds hashes, the queue holds addresses. That is new custody.
+The argument leans on the disclosure, not on denying the delta.
+
+**The drain is also the abuse gate** (ruling 7) — a human sees every team
+before it exists. So when the machine credential lands, **auto-drain is a
+policy decision, not an automatic consequence.**
+
+**Honest copy: "usually within a day", not "in minutes."** A human drain
+does not run at 3am on a Sunday. The first draft prided itself on not faking
+a spinner while writing a promise the mechanism cannot keep.
+
+**Queue mechanics**, none of which were specified:
+
+- **Address verified before enqueue**, or the queue mails strangers.
+- **Entry TTL with sealed-payload purge** — undrained spam otherwise holds
+ addresses indefinitely.
+- **Rate-limit keys stored hashed**; per-address limiting otherwise stores
+ address-derived plaintext, defeating §3.2 through the back door.
+- **A total queue-depth cap.** Address and IP limits are weak against
+ plus-addressing and CGNAT.
+- **Idempotent drain** — a crash between instance-create and token-delivery
+ must not double-create.
+- **Team name and hostname reserved at enqueue**, or two people race for one.
---
## 8. Person preferences
-**Theme is per person, suite-level, and must resolve before first paint**
-(ruling 10). Not per document, not per team — or a person sets it separately
-in every team they belong to, which is the credentials problem wearing a
-different hat.
-
-**It rides the assertion** (§5.3), which is what makes before-first-paint
-achievable: the instance sets a cookie on its own origin at session mint and
-reads it synchronously at render. Never a fetch — for a spreadsheet a
-late-arriving preference does not merely flip the chrome, it **repaints
-every coloured cell**, because fills resolve against the theme.
-
-The in-editor toggle writes through to the home and updates the local cookie
-optimistically, so the switch is instant and the preference follows the
-person into their next team.
-
-**The home wins at sign-in** (docs-e6, 2026-08-30). If a write-through
-fails, the instance's cookie and the home's `prefs` row disagree — and
-without a stated winner the next sign-in silently reverts somebody's
-choice, which is the kind of bug people report as "it keeps forgetting".
-So: the assertion's value is authoritative at session mint, the instance
-re-writes through on the next toggle, and a lost write costs one session
-rather than being invisible.
-
-Theme passes §5.3's boundary test: a hostile value produces an ugly page,
-not a breach. **The preference set stays small on purpose** — every addition
-is a claim in every assertion, and a preference that fails the boundary test
-is not a preference.
+Theme is per person, suite-level, resolved before first paint (ruling 10).
+Not per document, not per team — or a person sets it separately in every
+team, which is the credentials problem wearing a different hat.
+
+**It rides the assertion** (§5.3), which makes before-first-paint achievable:
+the instance sets a cookie on its own origin at session mint and reads it
+synchronously. Never a fetch — for a spreadsheet a late preference does not
+merely flip the chrome, it **repaints every coloured cell**.
+
+**The write-through is a redirect-based POST to the directory origin**
+(call 6), chosen because it works with scripts off; a cross-origin
+credentialed fetch would need a CORS and SameSite design and would fail the
+zero-JS baseline.
+
+**The home wins at sign-in.** If a write-through fails, cookie and `prefs`
+row disagree; without a stated winner the next sign-in silently reverts
+somebody's choice — "it keeps forgetting", a bug report nobody can act on.
+The assertion's value is authoritative at mint; the instance re-writes on
+next toggle; a lost write costs one session.
+
+**Other live sessions keep a stale cookie until their next assertion.** Said
+out loud because "the preference follows you" otherwise implies immediacy
+across devices that it does not have.
+
+**The preference set stays small on purpose** — every addition is a claim in
+every assertion, and one that fails §5.4 is not a preference.
---
-## 9. The five-call contract
+## 9. The contract
| # | Call | Direction | Notes |
|---|---|---|---|
-| 1 | Sign-in and session minting | person → home | The home is the identity authority. Not a hint. |
-| 2 | Directory upsert / revoke | instance → home | Sends the address **and the team's display name**; **the home hashes** (ruling 4). Via the instance's outbox. Also carries team-scoped revoke. |
-| 3 | Directory list | person → home | Every team across every suite app, with `kind`. |
-| 4 | Provisioning enqueue | person → home | Rate-limited; drained by a human. |
-| 5 | Rekey journal pull | instance → home | Cursor-based, carrying `old_hmac → new_hmac`. |
+| 1 | Sign-in and session minting | person → home | The identity authority. Not a hint. |
+| 2 | Directory upsert / revoke | instance → home | Sends address + sealed team name; home hashes; **returns the person ref** and the pepper epoch. Team-scoped revoke included. Via the outbox. |
+| 3 | Directory list | person → home | Every team across every app, with `kind`. |
+| 4 | Provisioning enqueue | person → home | Rate-limited, depth-capped, human-drained. |
+| 5 | Rekey journal pull | instance → home | **Scoped to this consumer**, keyed on person refs. |
+| 6 | Preference write-through | person → home | Redirect POST to the directory origin (§8). |
Auth follows Pegamento's service contract: **per-consumer bearer token
-stored hashed**, consumer identity is its origin, origins are an allowlist.
-
-**Bootstrap:** a freshly provisioned instance receives its bearer token as
-platform config and its allowlist entry from the operator drain, in the same
-step that creates it. An instance with neither cannot call the home and, per
-§6, works fine without it.
-
-**Call 2 carries the team's display name, and it is the only path by which
-the home ever learns one** (docs-e6, 2026-08-30). The first draft had §4
-rendering team names on the directory origin and no call that could ever
-have delivered one — the whole split-origin design existed to protect a
-string the contract never sent. The name is sealed at rest (§3).
-
-Three events therefore drive call 2, not one:
-
-- **A grant changes** — upsert or revoke that person's row.
-- **A team is renamed** — re-announce every row for that team, so guests
- stop seeing a stale name indefinitely. The outbox already gives this
- at-least-once delivery.
-- **A team is deleted** — **team-scoped revoke**, dropping every row for
- that team at once.
-
-Without the third, every row for a deleted team points at a host that 404s.
-Under §6 that is a convenience failure rather than a correctness one, so it
-would survive being missed — which is exactly why it is written here rather
-than left to be inferred.
-
-**Email change** is call 5's reason: the person's HMAC changes and their rows
-orphan. The journal carries `old_hmac → new_hmac` **and the new readable
-address**, because instances hold addresses (ruling 5) and need the new one
-to keep notifying. The home learns both transiently and stores neither —
-the journal row's address is sealed.
-
-**Pruning is by time horizon, not by the slowest cursor** (docs-e6,
-2026-08-30). "Prune once every instance has passed the cursor" is unbounded
-for the same reason the `jti` store was, with hibernation as the mechanism
-rather than guest volume: a hibernating instance runs no timers and pulls
-only on wake, so a team dormant for a year holds the cursor for a year and
-nothing prunes. A guest-heavy app has many instances a person touched once,
-and some go quiet permanently — where "the slowest cursor" means "never".
-
-So: the journal prunes past a **fixed horizon**, and an instance whose
-cursor is older than the horizon does a **full re-upsert on next wake**
-instead of a journal pull. That trades a rare expensive catch-up for a
-bounded table, and §6's hint ruling is what makes it safe — a stale window
-costs a convenience, never access.
+stored hashed**, consumer identity is its origin, origins allowlisted,
+**with a hard body cap whose number carries its reason in a comment**
+(contract item 6, unadopted in the first draft). **Tokens rotate**, and the
+verb exists before launch rather than after the first leak.
+
+**Bootstrap:** a fresh instance gets its token as platform config and its
+allowlist entry from the drain, in the step that creates it.
+
+**Call 2 drives three events**, not one: a grant changes; a team is renamed
+(re-announce, so guests stop seeing a stale name); a team is deleted
+(team-scoped revoke). Without the third, rows point at a host that 404s —
+a convenience failure under §6, which is exactly why it would survive being
+missed.
+
+### 9.1 Call 5, rewritten
+
+The first draft was incoherent: it keyed the journal on `old_hmac →
+new_hmac`, and under ruling 4 an instance holds no hashes and can compute
+none, so nothing would ever have matched (review, 2026-08-30).
+
+- **Rows are `(person_ref, sealed new address)`.** The ref is what instances
+ already store from call 2.
+- **The pull is scoped per consumer** — to persons with a directory row on
+ *that* instance. An unscoped journal would deliver every person's new
+ address to every instance in the suite, so one compromised instance
+ harvests suite-wide address changes: precisely the blast-radius inversion
+ ruling 4 exists to prevent.
+- **Retention is a per-consumer TTL, not the slowest cursor.** "Prune once
+ every instance has passed" never completes when a dormant instance runs no
+ timers to advance its cursor. A consumer returning past its TTL gets a
+ **full re-announce** instead of a pull.
+
+ *This is the same defect as the unbounded `jti` store, in a second place.
+ The generalisation, which both specs now carry: **any "prune when all
+ consumers have caught up" is unbounded in a hibernating fleet.***
+- **Decommission deletes the consumer row**, releasing its cursor.
+- **Restore rewind is named:** an instance restored from backup holds a
+ cursor pointing before pruned rows and would silently miss rekeys,
+ notifying a dead address forever. A cursor older than the TTL takes the
+ re-announce path, which covers this without a separate mechanism.
+
+**Email change requires proving control of the new address** before the
+rekey. The first draft presumed the change had happened and specified no
+ceremony.
---
## 10. Data model
-- `people` — id, HMAC of address, created_at. **No address column exists**,
- so there is none to accidentally populate.
-- `credentials` — passkey credential ids and public keys, per person.
-- `recovery_codes` — hashed, single-use.
-- `sessions` — SQLite-backed, per origin.
-- `directory` — person_id, app, team sqid, host, kind, updated_at.
-- `teams` — sqid, host, display name (sealed), created_at.
-- `prefs` — person_id, theme.
-- `provisioning_queue` — sealed payload, state, rate-limit keys, timestamps.
-- `rekey_journal` — cursor, old_hmac, new_hmac, sealed new address, pruned
- past the slowest cursor.
-- `consumers` — origin, hashed bearer token.
-- `assertion_jti` — consumed identifiers, self-pruning by expiry.
-
-Migrations are additive-only.
+- `people` — person ref, blind index, created_at. **No address column.**
+- `credentials`, `recovery_codes` — keyed on person ref (§5.1).
+- `signin_links` — sealed address, expiry. `secure_delete` on.
+- `sessions` — per origin, keyed on person ref.
+- `directory` — person ref, app, team sqid, host, kind, updated_at.
+- `teams` — sqid, host, sealed display name.
+- `prefs` — person ref, theme.
+- `provisioning_queue` — sealed payload, state, hashed rate keys, TTL.
+- `rekey_journal` — consumer, person ref, sealed address, TTL.
+- `consumers` — origin, hashed token, cursor, epoch, rotation state.
+- `assertion_jti` — **the directory origin's own** store, self-pruning.
+
+Migrations additive-only.
**Losing this database loses credentials, not access.** The directory
-rebuilds as instances re-upsert; passkeys do not rebuild. Litestream covers
-it, and *a backup you have never restored is a hope, not a backup* — so the
-restore drill runs before launch, not after.
+rebuilds as instances re-upsert; passkeys do not. *A backup you have never
+restored is a hope, not a backup* — the restore drill runs before launch.
---
## 11. Shape and design
rastrillo `server` shape, zero-JS baseline, `day` theme, and the **class**
-vocabulary (`class="rst-*"`) — not the bare-attribute grammar, which is
-ruled but not released: `ui/tokens.css` at `origin/main` has zero attribute
-selectors against 382 class selectors. Re-copy `static/tokens.css`
-deliberately on every rastrillo upgrade until `rastrillo doctor` lands.
+vocabulary — `class="rst-*"`.
+
+**Verified at `origin/main` post-design-system-v2, 2026-08-30:**
+`ui/tokens.css` has **zero bare `rst-*` attribute selectors** against ~528
+`.rst-` class selector occurrences. (It does contain attribute selectors for
+`[data-tone=]`, `[aria-*]`, `[type=]` — an earlier draft said "zero
+attribute selectors", which was imprecise. The conclusion is unchanged:
+write classes.) `Pair`, `Allocate`, `rastrillo doctor` and
+`--rst-header-rule` remain in zero files.
+
+Re-copy `static/tokens.css` deliberately on every rastrillo upgrade until
+`rastrillo doctor` lands.
-Every screen works with scripts off — sign-in, recovery, the directory,
-signup. **No surface here has any reason to require script**, and the day
-one does is the day this section is wrong.
+Every screen works with scripts off. **No surface here has any reason to
+require script**, and the day one does is the day this section is wrong.
The directory is the suite's front door: your teams grouped by app, and a
New button. No template gallery, no upsell chrome, no waffle.
@@ -543,25 +657,27 @@ New button. No template gallery, no upsell chrome, no waffle.
## 12. Testing gates
-1. **Plaintext grep** — raw SQLite file and WAL plus this app's own store
+1. **Plaintext grep** — raw SQLite file and WAL plus this app's store
objects, against a seeded corpus of known addresses.
-2. **No-address-at-rest** — assert the schema has no address column, and
- that a full sign-in plus directory upsert leaves no readable address
- anywhere.
-3. **Assertion abuse suite** — audience confusion (team A's assertion
- refused by team B), replay (a consumed `jti` refused), expiry, algorithm
- confusion, signature stripping.
+2. **No-address-at-rest** — the schema has no address column, and a full
+ sign-in plus directory upsert plus rekey leaves no readable address
+ anywhere, WAL included.
+3. **Assertion abuse suite** — audience confusion, replay, expiry, algorithm
+ confusion, signature stripping, **open redirect**, **login CSRF**, and
+ **unknown `kid`**.
4. **Two-person isolation** — one person's directory never contains
another's rows; 404, not 403.
-5. **Origin separation** — assert no account-authored string is reachable on
- the credential origin. §4's constraint made mechanical rather than
- remembered.
+5. **Origin separation** — no account-authored string reachable on the
+ credential origin. §4 made mechanical.
6. **Scriptless-affordance diff** — every page, script-stripped.
7. **Contrast test** copying `ui/contrast_test.go`'s arithmetic verbatim.
-8. **Restore drill** — a real restore from replication, before launch.
+8. **Restore drill** — a real restore from replication, before launch,
+ against a written RTO.
+9. **No-content-in-logs** — scan log lines, error strings and metric labels
+ for addresses, team names and queue payloads (§3.4).
-Review on a deployed canary, never localhost. Verify a deploy with
-`X-Carlos-Version` on the canonical host after the watch — never a 200.
+Review on a deployed canary, never localhost. Verify with
+`X-Carlos-Version` on the canonical host after the watch.
**Every branch gets a discussion, and that discussion is the review
record**, including which model reviewed it.
@@ -570,19 +686,19 @@ record**, including which model reviewed it.
## 13. Sequencing
-1. Skeleton, `AGENTS.md`, the plaintext and no-address gates
-2. Sign-in, second factor, recovery codes
-3. Assertion minting and the abuse suite (gate 3)
-4. The directory: calls 2 and 3, blind index
-5. Signup, the provisioning queue, rate limits
+1. Skeleton, `AGENTS.md`, gates 1, 2 and 9
+2. Hand-rolled sign-in (§5.1), second factor, recovery codes
+3. Assertion minting, the redirect protocol, and gate 3
+4. Directory: calls 2 and 3, blind index, person refs
+5. Signup, queue, rate limits, name reservation
6. The operator drain
-7. Preferences and the assertion claim
-8. Rekey journal (call 5)
-9. Restore drill
+7. Preferences, the assertion claim, call 6
+8. Rekey journal (call 5), epoch, decommission
+9. Restore drill and the RTO
**Sheets and Docs are unblocked from step 3, not step 9** — assertions plus
-a stub directory is enough for both to build against. The directory, signup
-and rekeying land while they work.
+a stub directory is enough for both. But **step 4 must land before either
+ships**, because call 2's person ref is what their grant rows key on.
---
@@ -590,22 +706,27 @@ and rekeying land while they work.
| Thing | Trigger |
|---|---|
-| Pegamento's real vault facet — sealed team names the home itself cannot read | The vault ships with a recovery story. Then §6's names move into it and the directory origin stops seeing them too |
-| Operator reset for a fully locked-out person | The first real person loses passkey and codes together |
-| Auto-drain of the provisioning queue | The platform ships a machine credential **and** we decide to give up the abuse gate — two decisions, not one |
-| Any preference beyond theme | It passes §5.3's boundary test |
+| **Owner-enforceable per-team 2FA** (ruling 15) | A team asks, *and* either a sixth call carrying team policy is argued past §2.2 or rastrillo grows a per-team notion |
+| Pegamento's vault facet — team names the home cannot read | The vault ships with a recovery story |
+| Operator reset for a fully locked-out person | The first person loses passkey and codes together |
+| Auto-drain of the queue | The platform ships a machine credential **and** we choose to give up the abuse gate — two decisions |
+| **Suite-wide sign-out / session revocation** | Named as an exposure now: a stolen laptop's instance sessions live to their TTL with no revocation channel. Trigger: the first report, or before any team holds regulated data |
+| **Account deletion / erasure** | Before a person asks, which under most regimes is not optional for long |
+| **Step-up for instances** (`RequireFresh`-grade) | An instance flow needs it; requires a fresh-assertion round trip with a `max_age`-style parameter |
+| Any preference beyond theme | It passes §5.4 |
| Billing, plans, quotas | Somebody has to pay |
-| A support console | Never, under §0 — reading someone's data to help them is what the one rule forbids |
+| A support console | Never, under §0 |
---
## 15. Open
-- **Not yet reviewed.** Needs a highest-capability-model pass before Paul
- approves it, per the Sheets spec's §12.1.
-- **Assertion verification helper** — `rastrillo/rastrillo` #1.
-- **The platform machine credential** — `carlos/platform` #4.
-- **A real suite domain** — triggers §5.5.
-- **Docs' review as a declared consumer.** §9's contract is the surface both
- apps build against and should be corrected by its other consumer before
- approval.
+- **Docs' re-review** of §9 after this revision — the contract changed
+ substantially (person refs, call 5 rewritten, call 6 added).
+- **`rastrillo/rastrillo` #2** — the `auth` subject hook and sealed link
+ store. Adopt and delete ours the day it lands.
+- **`rastrillo/rastrillo` #1** — assertion verification helper.
+- **`carlos/platform` #4** — the machine credential.
+- **`carlos/pegamento` #1** — the shared-instance ruling, and whether §9
+ protects the credential or the knowledge.
+- **A real suite domain** — triggers §5.7.