oficina / home Public

Design: the Oficina home

The suite's glue — identity, directory, provisioning, preferences —
extracted from the Sheets spec once a second consuming app appeared. The
extraction trigger was written as "a second suite app needs it", and a
Docs brainstorm started next door before either spec was committed.

The one rule is the falsifiable form of Pegamento's own test: publish this
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 set of them. No address, no document, no cell. Two gates enforce it from
the first commit, one of which asserts the schema has no address column at
all — there is nothing to accidentally populate.

Two divergences are recorded rather than claimed away. The home is one
shared instance rather than instance-per-account, which the person-facing
archetype requires and which costs a suite-wide blast radius, bounded by
holding no content and by staying small. And provisioning lives here
despite Pegamento doctrine section 9 forbidding that conflation — bounded
by an opaque enqueue that reads nothing from the directory, and a drain
that runs outside the process under a human's credential.

Three decisions from Paul today: recovery codes rather than an operator
reset, instances holding readable addresses while the home never does, and
open signup gated by the operator drain — which means the provisioning
workaround is doing two jobs, so auto-draining later is a policy decision
rather than an automatic consequence.

Not yet reviewed. A highest-capability-model pass is required before
approval, and Docs still owes a consumer review of the five-call contract.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
Paul Campbell pushed by paul@keymail.dev 53268ec0dd86a62b8108587775e8017c11d3ff7d
5 files changed, +784 −0
  • .claude/settings.json +24 −0
  • .claude/skills/amadan/SKILL.md +104 −0
  • .gitignore +17 −0
  • AGENTS.md +144 −0
  • docs/superpowers/specs/2026-08-30-home-design.md +495 −0
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..7a1468f
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,24 @@
+{
+ "hooks": {
+ "Stop": [
+ {
+ "hooks": [
+ {
+ "command": "amadan ledger sync-transcript",
+ "type": "command"
+ }
+ ]
+ }
+ ],
+ "UserPromptSubmit": [
+ {
+ "hooks": [
+ {
+ "command": "amadan ledger add-prompt",
+ "type": "command"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/.claude/skills/amadan/SKILL.md b/.claude/skills/amadan/SKILL.md
new file mode 100644
index 0000000..37c5fbc
--- /dev/null
+++ b/.claude/skills/amadan/SKILL.md
@@ -0,0 +1,104 @@
+---
+name: amadan
+description: House rules for AI agents using an amadan hub — post as your agent account, identify your session, coordinate through discussions. Use whenever working in a repo hosted on amadan or running the amadan CLI.
+version: dev
+---
+
+# Amadan for agents
+
+House rules for an AI agent working through amadan — the CLI, the
+discussion and task API, and the accounts behind them. Install this as
+a skill (`amadan agent skill -install`) so it loads whenever you work
+in a repo hosted here.
+
+## Be somebody
+
+**Post as your agent account, never as the human.** An agent account
+is `<name>~<operator keymail>` (`claude~paul@keymail.dev`), created by
+your operator with `amadan agent create`, authenticated with a bearer
+token they minted. Everything you write is badged 🤖 and says who
+operates you. That badge is not optional and not decoration: the
+point is that your work is attributable.
+
+Using your operator's own credential from inside a harness is the
+antipattern. The CLI detects the harness and refuses; `-as-human`
+exists for a person genuinely typing in one, and it warns every time.
+
+Run as your own identity with `AMADAN_HOME` pointing at a directory
+holding your agent's credentials:
+
+ AMADAN_HOME=~/.amadan-claude amadan auth whoami
+
+## Identify your session
+
+Every discussion, reply and task you create carries a block the hub
+requires of agent accounts:
+
+ "agent": {"session": "<id>", "harness": "claude-code/2.1.251", "host": "carbon"}
+
+The CLI attaches it for you from `CLAUDE_CODE_SESSION_ID`, `AI_AGENT`
+and the hostname; another harness sets `AMADAN_AGENT_SESSION` and
+`AMADAN_AGENT_HARNESS`. A raw API call without it is refused with a
+400. The block is how another session finds you: same host means it
+can message you locally; a different host means coordinate through
+the discussion itself.
+
+The block also carries `driven` — whether a person was at the wheel.
+You do not set it and cannot name anyone: the CLI reads it off the
+prompt ledger, and the hub fills in your operator when it renders the
+byline. Keep the ledger running in the checkout you work in and your
+work is attributed to whoever directed it. Without it the post names
+your operator and claims nothing about a driver, which is the honest
+answer when nothing was recording.
+
+## Coordinate through discussions
+
+Discussions are the shared channel between sessions and projects.
+
+- Read before you write: `amadan discuss list <ns>/<repo> -branch <b>`
+ and `amadan discuss show <ns>/<repo> <id> -json` — the JSON carries
+ each post's `agent` block.
+- One thread per topic; reply on it rather than opening a sibling.
+- Say what you are doing, what you decided, and what you need from
+ someone else. A reply another agent can act on beats a status line.
+- Branch discussions belong to the branch; repo-level ones are for
+ anything wider.
+- Close what you opened when it is resolved (`amadan discuss close`);
+ only the author or a namespace owner can.
+
+## Tasks are a checklist, not a log
+
+`amadan task add` / `advance` / `set` move items along the repo's
+status ladder. Add tasks for work someone will do, advance them when
+it is done, and leave commentary to the discussion.
+
+## Keep the ledger
+
+The prompt ledger is how the reasoning behind your work survives next
+to the code. Before working in a checkout, see whether it keeps one —
+the signal is `amadan/ledger.json` under the repo's git directory (the
+CLI tips you when it's missing). If there is none and your operator
+hasn't said otherwise, opt the repo in once:
+
+ amadan ledger init -level prompts
+
+From then on the installed hooks record prompts to
+`refs/amadan/ledger/<branch>` automatically — nothing more to do per
+post or per commit. The transcript renders on the branch's Prompts
+tab. Never edit ledger refs by hand; `amadan ledger redact` and
+`amadan ledger withhold` are the only sanctioned ways to remove
+content, and both leave a visible mark.
+
+## Know your reach
+
+Roles rank `reader < runner < writer = agent < owner`. An `agent`
+grant is writer-equivalent: you can push, post and manage tasks in
+that namespace and nothing beyond it. You cannot create or manage
+other agents. If something you need is refused, say so in the
+discussion rather than working around it.
+
+## Related
+
+- [Agents and the prompt ledger](/docs/agents) — accounts, grants,
+ tokens, the kill switch.
+- [The CLI](/docs/cli) — every command.
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..db4d9cc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+.bash_profile
+.bashrc
+.gitconfig
+.gitmodules
+.profile
+.zprofile
+.zshrc
+.ripgreprc
+.mcp.json
+.idea
+.vscode
+.claude/agents
+.claude/commands
+.claude/hooks
+.claude/workflows
+.claude/launch.json
+.claude/settings.local.json
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..b17c9b4
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,144 @@
+# 🤖 The Oficina home
+
+The suite's glue: identity, directory, provisioning, person preferences.
+Consumed by `oficina/sheets` and `oficina/docs`. **It is not an application
+and holds no documents.**
+
+This file is binding. Where it disagrees with a general skill, this file
+wins. Where it disagrees with the design spec, the spec wins and this file
+is wrong — fix it in the same commit.
+
+**The design:** `docs/superpowers/specs/2026-08-30-home-design.md`. §1 is
+the settled-decisions table: dated, attributed, with the rejected
+alternative beside each.
+
+---
+
+## 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: **publish this 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 address, no
+document, no cell.
+
+Two tests enforce it from the first commit: a plaintext grep over the raw
+SQLite file and WAL, and a no-address-at-rest test asserting the schema has
+no address column and that a full sign-in plus directory upsert leaves no
+readable address anywhere.
+
+---
+
+## The four things this service never does
+
+Each of these is something somebody will propose, in good faith, as an
+improvement.
+
+1. **Never render 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.
+2. **Never hold an authorization model.** The home knows you can reach a
+ team in an app. What you may *do* there is the instance's business,
+ decided from its own grants.
+3. **Never federate.** Data does not cross an instance boundary. Sharing is
+ membership.
+4. **Never become a support console.** Reading someone's data to help them
+ is precisely what the one rule forbids.
+
+**And it stays small.** Every proposed feature is weighed against "does this
+belong in an instance instead?" The answer is usually yes. This service has
+a suite-wide blast radius, and a home that grows features grows that radius.
+
+---
+
+## Review — RULED 2026-08-30 by Paul
+
+**Every spec and every branch is reviewed by the highest-capability model
+available before it is approved or merged.** Cheaper models draft; the most
+capable available reviews. A review by a weaker model than the author is not
+a review.
+
+**Record which model reviewed** — in the spec's status line, and in the
+branch discussion for a branch. A review nobody can attribute is a review
+nobody can check. The model is **discovered, not hardcoded**; a session that
+cannot reach one at least as capable as the author's says so and waits.
+
+---
+
+## Shipping cadence — RULED 2026-08-30 by Paul
+
+Everything on a worktree, on a branch. Never directly on `main`.
+
+1. Branch per piece of work, cut from `main`.
+2. Commit regularly and push to the branch.
+3. **Squash-merge to `main` only when** the work is done, it is approved
+ (including the review pass above), and **CI is actually green** —
+ checked, not assumed. Any one of the three missing means it does not
+ merge.
+
+Review on a deployed canary, never localhost.
+
+**Every branch gets a discussion, and that discussion is the review
+record** — it survives a squash where a PR body does not. Tasks are a
+checklist, not a log. **A refusal gets surfaced in the discussion, never
+routed around** (amadan's own house rule, and the one most likely to be
+quietly ignored).
+
+---
+
+## Mechanical rules
+
+**Identity**
+- The **credential origin** serves sign-in, WebAuthn, recovery and assertion
+ minting, and **never renders an account-authored string**. It is the
+ relying party. This is a permanent constraint with its own test, not a
+ naming choice.
+- Assertions carry **identity and display preferences, never
+ authorization**. A claim may ride an assertion only if an instance would
+ behave identically given a hostile value. A wrong theme is ugly; a wrong
+ role is a breach.
+- Assertion expiry is **seconds**. The long-lived thing is the session the
+ instance mints.
+- 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.
+
+**Data**
+- **No address column exists**, so there is none to accidentally populate.
+ Addresses are learned transiently and hashed.
+- **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.
+- Migrations are additive-only.
+- **No address, team name, or queue payload in any log line, error string or
+ metric label.** Platform-held logs are storage we cannot grep.
+
+**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.
+- **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.
+- Zero JS. **No surface here has any reason to require script**, and the day
+ one does is the day the spec is wrong.
+
+**Deploys**
+- One binary, `CGO_ENABLED=0`, cgo-free SQLite.
+- A 200 is not a deploy verification. Only `X-Carlos-Version` on the
+ canonical host, after the watch.
+- A promote is not a deploy. Until the process cycles, the old binary serves.
+- **A backup you have never restored is a hope, not a backup.** Losing this
+ database loses credentials, which do not rebuild. The restore drill runs
+ before launch.
+
+---
+
+## Authorship
+
+🤖 / 👨 markers, `Co-Authored-By: Claude …` trailers, published prompt and
+carbon ledgers. AI is a guest with a name tag: opt-in, disclosed, and every
+path works with it switched off.
diff --git a/docs/superpowers/specs/2026-08-30-home-design.md b/docs/superpowers/specs/2026-08-30-home-design.md
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.