| new file mode 100644 |
| index 0000000..816596f |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-30-home-design.md |
| @@ -0,0 +1,495 @@ |
| +# 🤖 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. |
| +**Scope:** the suite's home — identity, directory, provisioning, person |
| +preferences. Consumed by Sheets and Docs. It is not an application and 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.** |
| + |
| +The falsifiable form, which is the family's own test — *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. |
| + |
| +Factor XI names the category — *centralised infrastructure is glue: thin, |
| +blind, replaceable* — and `AGENTS.md` opens with this rule verbatim. |
| + |
| +--- |
| + |
| +## 1. Settled decisions (don't relitigate casually) |
| + |
| +| # | 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 | |
| +| 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 | |
| +| 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 | |
| +| 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 | |
| + |
| +--- |
| + |
| +## 2. What the home is, and the divergence it requires |
| + |
| +The home is **one shared instance**, not one per account. That is a |
| +deliberate divergence from CARLOS's central shape and from rastrillo's own |
| +rule that *"a CARLOS app serves one team… isolation by process and file, |
| +never by WHERE clause."* |
| + |
| +**Why it is legitimate here.** The home is not an app; it is the glue |
| +between apps, and its whole purpose is answering questions that span |
| +instance boundaries. An instance-per-person home could not answer "which |
| +teams does this person belong to" without becoming the federation story |
| +Pegamento explicitly refuses. Pegamento's doctrine already sorts this: the |
| +**person-facing archetype** — registry, vault — is a shared service by |
| +construction, and the family runs four of them. |
| + |
| +**What the divergence costs, named rather than waved past.** The home has a |
| +suite-wide blast radius: it is the one process whose failure touches |
| +everybody. Three things bound it: |
| + |
| +- **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 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. |
| +- **Never becomes a support console.** Reading someone's data to help them |
| + is what §0 forbids. |
| + |
| +--- |
| + |
| +## 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 |
| +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. |
| + |
| +--- |
| + |
| +## 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.* |
| + |
| +- **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. |
| +- **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. |
| + |
| +**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):** both origins are carloku.com |
| +subdomains for now, which **schedules** the RP rename drill rather than |
| +avoiding it — §5.5. |
| + |
| +--- |
| + |
| +## 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.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. |
| + |
| +**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. |
| + |
| +**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 |
| +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 |
| +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: *a |
| +claim may ride the assertion only if the instance would behave identically |
| +given a hostile value.* A wrong theme is ugly; a wrong capability is a |
| +breach. Theme passes (§8); a role would not. |
| + |
| +### 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. |
| + |
| +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.5 The RP rename drill |
| + |
| +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. |
| + |
| +**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. |
| + |
| +`LegacyRPID` removes the machinery cost. It does not shorten the window. |
| + |
| +--- |
| + |
| +## 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. |
| + |
| +**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. |
| + |
| +**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*: |
| + |
| +- 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. |
| + |
| +--- |
| + |
| +## 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 divergence is deliberate and bounded: the enqueue is opaque, reads |
| +nothing from the directory, stores its payload sealed and separately, and |
| +**the drain runs outside the home process entirely**, under a human's |
| +credential. The conflation the doctrine warns about is a *blind* service |
| +that also knows person↔instance — this home already knows person↔instance in |
| +the directory, which §6 discloses, so the enqueue adds no knowledge the |
| +service lacks. **Revisit trigger:** the queue moves to its own binary if the |
| +home ever grows a second reason to hold a console credential. |
| + |
| +--- |
| + |
| +## 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. |
| + |
| +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. |
| + |
| +--- |
| + |
| +## 9. The five-call 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; **the home hashes** (ruling 4). Via the instance's outbox. | |
| +| 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`. | |
| + |
| +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. |
| + |
| +**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 and **pruned once every instance has |
| +passed the cursor.** |
| + |
| +--- |
| + |
| +## 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. |
| + |
| +**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. |
| + |
| +--- |
| + |
| +## 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. |
| + |
| +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. |
| + |
| +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. |
| + |
| +--- |
| + |
| +## 12. Testing gates |
| + |
| +1. **Plaintext grep** — raw SQLite file and WAL plus this app's own 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. |
| +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. |
| +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. |
| + |
| +Review on a deployed canary, never localhost. Verify a deploy with |
| +`X-Carlos-Version` on the canonical host after the watch — never a 200. |
| + |
| +**Every branch gets a discussion, and that discussion is the review |
| +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 |
| +6. The operator drain |
| +7. Preferences and the assertion claim |
| +8. Rekey journal (call 5) |
| +9. Restore drill |
| + |
| +**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. |
| + |
| +--- |
| + |
| +## 14. Deliberately not built yet |
| + |
| +| 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 | |
| +| 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 | |
| + |
| +--- |
| + |
| +## 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. |