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
2 files changed,
+586
−442
AGENTS.md+26 −3docs/superpowers/specs/2026-08-30-home-design.md+560 −439
No CI script — run details
Intent
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. |