# 🤖 The Oficina home — design

**Status:** design, revised **2026-09-05** (rulings 17-23, and five claims
corrected). Originally 2026-08-30. 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). The 2026-09-05 revision was reviewed by **Fable**,
which returned 11 findings — three false claims in this file among them —
all applied. **Ruling 23 and the suite amendment reviewed by Fable 5.1 across five
passes** — seven blocking findings, then six, then seven, then one, the
fifth approving; two of the corrections were themselves wrong and a third
ground for ruling 15 failed before the fourth held.
**The 2026-09-05 §0/§2.2 amendment reviewed by Fable 5.1** — three passes —
the first CHANGES NEEDED with three blocking, the second with five, the
third approving. **Ruling 22
reviewed by Fable across four passes
(2026-09-05) — three returning CHANGES NEEDED, the fourth approving; the
`?team=` decision took a second opinion from GPT-6 Astra via Codex.**
Source claims re-verified by this session before acting.
**2026-09-08 transport amendment:** bounded design approved by Claude Fable 5.1 at Home36f876c; shared consumer3c550c3 and Rastrillo assertion29e4491 passed independent code review and CI. Home mounting still requires code and canary review.
**Scope:** the suite's home — identity, directory, provisioning, person
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 a readable address, and it never
> retains what is in anything.**

**"Retains", amended from "learns" on 2026-09-05** (ruling 18,
`design-activity-pull`, approved that day). The falsifiable form below is
**unchanged by that amendment** — ruling 23 later did change it, and says so — that amendment put nothing new at rest.

**"Retains" means holds without a bound.** That definition is doing real
work and a first draft of this gloss got it wrong three times over, so it is
stated once, plainly:

- The home **does** learn what is in things. It handles titles on every
  front-door render, and `design-activity-pull`'s §7 keeps them in memory
  across requests, for every person who rendered inside the window. A rule
  claiming the home never learns them would be false the first time the
  feature ran.
- What it never does is hold them **unbounded**. **A bound is a written
  number, with a reason, sized to the render and not to retention** — and
  there are three of them, not one: an **age** (`design-activity-pull` §7's
  refresh TTL and hard maximum), a **maximum number of person entries**, and
  a **maximum bytes per person** (that design's §2.1, evicting oldest
  first). The entry count is the one that caps the cross-person set §2
  worries about; the age alone does not. Said this precisely because "without
  a bound" is otherwise satisfied by any finite number, and a thirty-day
  maximum over ten thousand people is exactly the in-process title index this
  gloss exists to forbid. At rest is the case **where the bound is not the
  home's own to enforce** — a file outlives the process, and Litestream ships it — which is why
  the falsifiable form below is the test that matters. (It is unchanged by
  *this* amendment; ruling 23 changed it, and §0 says how.) **Two
  platform facts sit behind that line and are not yet established** — whether
  swap is configured and whether core dumps are off (§15). A swapped or
  dumped page of titles is at rest, so the boundary is only as good as those
  answers. The
  RAM hold is the bounded case: §7's refresh TTL and hard maximum are the
  bound, and `design-activity-pull`'s gate 20 tests it by advancing past the
  maximum
  through a *failed* pull, which is the path where a bound is easiest to
  lose.
- So the rule **does** reach the cache, and reaches it by requiring the
  bound. An earlier draft said "at rest, and only at rest", which would have
  left §0 permitting an in-process title index for everyone, held until
  restart, with nothing in the one rule to say no.

**"Stores" was proposed in review and rejected — RULED 2026-09-05 by Paul.**
The case for it was good: §3.2 and §3.4 delimit it, §2.2 now uses it, and it would
have made the sentence parallel. **"Retains" is kept because it says the
right thing about time.** "Stores" reads as a place — *put somewhere* — and
the risk here is duration. That choice is what makes the definition above
"without a bound" rather than "at rest": the word was kept precisely so the
rule would reach a title held in RAM across requests, and a definition that
excluded the RAM hold would have thrown away the reason for keeping it.

**"Blind" in the rule above is Factor XI's category label**, not a claim that
the home never sees a title. It sees them; it keeps none.

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, which people belong to
> which team, and which of them own it.** No document. No cell. No readable
> address.

**"And which of them own it" is new, and it is a real cost.** Ruling 23
gives the home a membership record with a role, so publishing the database
now identifies every team's owners — a class of information the old
directory row could not express, because its `kind` was only member or
guest. Suite gate 8 tests this sentence against the persisted schema rather
than a fixture. The suite doctrine's revision 1 assumed the roster added no
column class; that was wrong, and this is the correction.

**"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*.

---

## 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 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 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 — 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`~~ — superseded by 16. The half that survives: **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 |
| 16 | **`oficina.works` is the primary domain — RP `id.oficina.works`, directory at the apex, instances at `<team>.<app>.oficina.works`** — §4 | Staying on `oncarlos.com` until passkeys forced the move; keeping the `bds` slot | Paul, 2026-09-03 |
| 17 | **The springboard is one page: an app grid, with the team switcher as a dropdown in the header. Which apps exist comes from a registry, not from your grants. A team is a suite-wide entity that instances belong to** — §11, §10 | A team-switcher screen as a first level; deriving tiles from grants alone, which hides an app you have no team in and leaves nowhere to press New; grouping by hostname label alone, with no row that exists before the first instance and outlives any one of them | Paul, 2026-09-05 |
| 18 | **Activity is approved in principle, pulled at render, sequenced last, and scoped PER TEAM, following `?team=`** — the front door shows a real feed with titles, the home holds none of it at rest, and its slot is a step 10 after step 4 — `design-activity-pull` | Push into the home; a contentless heat map; a cross-team merged list (which `design-activity-pull` specified and argued for until its ruling 6 revised it, 2026-09-05); refusing the feature | Paul, 2026-09-05 |
| 19 | **A team name is reserved suite-wide. Both paths end in the drain — an existing instance settles the label question for the operator, it does not bypass them. The home answers only facts about its own records, never "may this person" **about an instance's items** — narrowed by ruling 23, which has the home answer exactly that question for a team's own roster** — §7 | Per-app first-come, which lets a stranger squat `acme.sheets` behind your `acme.docs` on a domain that reads as one suite | Paul, 2026-09-05 |
| 20 | **Write the attribute spelling — `rst-btn`, not `class="rst-btn"`. The seven unpaired utilities keep `class`** — §11 | The class vocabulary, which this section ruled on 2026-08-30 and whose every stated reason has since died; "follow whatever the pin's partials use", which changes this app's markup on a dependency bump | Paul, 2026-09-05 |
| 21 | **§11's zero-JS rule gains one named carve-out: passkey enrolment and ceremony screens may load script. Everything else stays scriptless** — §11 | Passkeys leaving slice one; a blanket exemption; a progressive-enhancement version, since the ceremony itself cannot happen without script either way | Paul, 2026-09-05 |
| 22 | **The team selection is navigation state, not session state: `?team=<sqid>` on every team view, with `/` a generic entry that picks a team and `302`s to the explicit URL** — §11 | A session cookie (history does not restore one, so Back has no correct behaviour; and a shared cookie couples every same-origin team-dependent request across tabs, not just New); a URL fragment read by CSS `:target` (the server never learns the team, so nothing server-rendered can be team-scoped — not the grid, not the switcher's `aria-current`, not gate 4's 404); a `prefs` row (§14) | Paul, 2026-09-05, after a second opinion from **GPT-6 Astra via Codex** (its default; `gpt-5.3-codex` and `gpt-5.3-sol` were both refused by the account, so the model is named from Codex's own rollout record rather than from what was asked for) |
| 23 | **The suite sharing doctrine (`oficina/suite`) binds this spec, and membership becomes a record of the home's own.** The team is the boundary; the home owns identity, the team and the membership roster; it never owns a per-item grant. `(team, person, role)` is written only at the directory origin by an owner behind step-up, or seeded by the drain with the team's first owner. A directory row never grants team standing. Call 8, roster pull, is added to §9. | **The status quo this overturns: team standing living only in each instance's grants, with the home holding no roster at all** (§5.5, §7 as they stood). And the doctrine's own first revision, which derived the roster from call-2 directory rows: the row has no owner role (§6 — `kind` is member or guest), §7 says the home never learns who an owner is, the row is scoped per (team, app) where the roster is per team, and any instance can write one under its consumer token — so deriving from it routes around the step-up entirely | Paul, 2026-09-05 (suite ruling 12). Numbered 23 because the doctrine's appendix said "add ruling 22" and 22 was already taken; a review of the doctrine caught the collision |
| 24 | **Automatic app entry uses the shared P-256 assertion core, separate credential/directory processes, and a static auto-POST enhancement on the handoff screen.** Exact contract: implementation/team-entry.md at36f876c, with the reviewed consumer implementation at3c550c3. The Continue button works without script; no other credential screen gains script. | A second app-specific verifier; shared-domain cookies; an arbitrary return URL; requiring a manual handoff click when scripts are enabled | Codex implementation decision, 2026-09-08, under Paul's explicit approval in this session of Home sign-in and automatic return to the selected team app. Reviewed by Claude Fable 5.1; not attributed to the older ruling21. |

---

## 2. What the home is

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 at
rest.** **Re-examined 2026-09-05**, because the amendment gave the boundary
rule something to bite on: the activity cache is a cross-person set of recent
titles in the one process that also mints sessions. Ruling 11 still holds —
the safety property was never process isolation but the leak profile and the
re-check — and the cache is bounded, per-person-keyed and never at rest. But
it is no longer true that there is *nothing* for the rule to bite on, and
`carlos/pegamento` #1 should see this rather than the original phrasing.
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.** This draft originally reached for process isolation as the
safety property; for a service holding no content **at rest** 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 **of an instance's own**. **Ruling 23
  changed the rest of this sentence and it is amended rather than
  annotated.** A compromised **directory origin** can write the roster, and a
  member reads everything team-visible in every instance of that team — so it
  costs the graph, a convenience outage, **and team-visible content across a
  team's apps for the duration of the compromise — **plus one roster TTL for
  any forged call-8 response, plus, for any `memberships` row it actually
  wrote, until an owner finds and removes it.** The bound is a floor, not a
  ceiling. An earlier pass of this branch wrote "until an owner notices" and
  a later one deleted it as contradicted by §13a; both were wrong. §13a's
  "what honest code leaves behind" means a row is not evidence a compromise
  is *obliged* to leave — not that a written row does not persist. A written
  row persists exactly until somebody removes it. It still costs no *per-item* grant, which
  is the thing the home does not hold. **The credential origin is a
  different and worse tier** — §13a: it "can sign in as anyone", and so reads
  individually granted items too — and this bullet is about the directory,
  which is the distinction the doctrine's ruling 13 exists to keep.
- **It holds no content *at rest*.** The worst case in a published database
  is §3's leak list, never anyone's work. **Since the 2026-09-05 amendment a
  live compromise is larger**: the activity cache holds recent titles in RAM
  for every person who rendered inside its TTL, and minting-for-anyone — which
  was always true — now also buys any person's recent titles through call
  7. `design-activity-pull` §5.1 argues that is inside the
  hole §3.1's live-host model already concedes — it is, and it is bigger than
  it was, so it is said here rather than left to the other document.
- **Losing it costs a convenience, not access** — for the directory. Sign-in
  is one exception and §5 says so rather than pretending otherwise; §2.1
  lists what shrinks it. **The roster is a second, added by ruling 23**: past
  its TTL, team-rung access stops for everyone, and §10 records that it does
  not rebuild itself.
- **It stays small.** Every proposed feature is weighed against "does this
  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 **for individual grants**. Since ruling 23 they do **not** keep team standing: past the roster TTL with no successful refetch an instance stops admitting anyone on team standing, people already in a session included (doctrine §5.3). So a long home outage degrades a signed-in member to their per-item grants. 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 *stores* content from an instance.** No previews at rest, no
  thumbnails, no cell values, no titles in a database, a log, an error
  string, a metric label or an on-disk cache. It **may render**, transiently
  and per request, **and only to that person**, what an instance hands it for
  the person it hands it for. **"Per request" does not exclude
  `design-activity-pull` §7's cache**, which holds pulled titles in RAM
  across renders: §0's gloss defines *retains* as holds without a bound,
  and that cache is bounded and tested. Read the two together.

  **Amended 2026-09-05** from *never renders*, on ruling 18 and
  `design-activity-pull`, which passed its review that day (four Fable
  passes, the fourth approving). "Renders" was doing custody's work: the
  clause was written to stop the home *becoming a place documents live*, and
  storage is what makes it that. A title that exists for the length of one
  response, or that design's RAM cache TTL, addressed to the one person
  entitled to it, does not.

  **It licenses nothing else**, recorded so it is not read wider than it is:
  no search over pulled content, no cross-person aggregation, no analytics,
  no operator view, no on-disk cache, no retry queue holding a response body.
  And note what the amendment bought in exchange: "never stores" has a grep
  behind it, where "never renders" had no test at all — which is a fair sign
  it was a statement of intent rather than a mechanism.
- **Never holds a per-item grant.** Which sheets you may edit, which
  document a guest may comment on: all instance-side, decided there on every
  request (§5.5).

  **Ruling 15's ground has to move off §2.2 entirely, and two drafts of
  this paragraph failed to.** It first read "ruling 15 exists because the
  per-team 2FA floor would have breached this", against the old clause
  *never holds an authorization model* — false under the new one, since a
  2FA floor is not a per-item grant. It was then re-grounded on the floor
  being "the instance's decision on its own request path", which is also
  false: authentication is the home's (ruling 1, one RP), the team is the
  home's (rulings 17 and 23), and the home knows `aud` → team through
  `team_instances` — so a per-team floor would be enforced at assertion
  minting, on a resource the home owns, which this bullet's own line
  *permits*. An instance demands nothing at authentication; it exchanges an
  assertion.

  **So ruling 15 stands on mechanics, not on §2.2 — and the mechanics are
  not "per-team enrolment", which was a third wrong ground.** Enrolment is
  per person at one RP (ruling 1), and per-team *compliance* is just
  `memberships ⋈ credentials`, both of which now exist. What is
  actually missing is the demand: **`passkey.Gate` asks for the ceremony
  only when the account is already enrolled** — `Enrolled(sess.Subject)`, a
  count over `passkey_credentials` — so an un-enrolled member of a floored
  team returns `(false, nil)` and sails straight through the floor. A real
  floor therefore needs two things slice one has neither of: **a per-team
  policy record**, and **a policy-driven demand at assertion minting** —
  step up if the session's `Method` lacks the factor, force enrolment if
  there is none. That is the objection, and §5.2 and §14 carry it.

  **It does hold the membership roster** (suite ruling 4, ruling 23). "Is
  this person on this team" is a fact about the home's own records. "May
  this person open this sheet" is not, and never becomes one. The line is
  between a fact the home already owns and a decision that belongs to the
  app of record.
- **Never crosses a team.** Data does not cross a team label; a team's
  instances across every app are one trust domain (suite ruling 1). This
  said *instance boundary* until 2026-09-05, and the word carried a process
  assumption nobody had examined — it was where the boundary happened to
  fall while every app was one process.
- **Never becomes a support console.** Reading someone's data to help them
  is what **§2.2's first bullet** forbids — "only to that person", "no
  operator view". **It used to be §0**, and the 2026-09-05 amendment is why
  it no longer can be: "never *learns*" forbade an operator reading content,
  and "never *retains*" does not — a console that displays without keeping
  passes §0 and the falsifiable form both. The prohibition did not weaken,
  but its anchor moved, and a reference left pointing at §0 would be a
  prohibition resting on nothing.

### 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
(ruling 12) — the platform replicates the whole SQLite file, so a plaintext
column is a plaintext bucket leak.

### 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.

> **OPEN — for Paul, raised 2026-09-07.** The sentence above and **ruling 3**
> contradict each other, and the code can only implement one. Ruling 3 says
> `HMAC(pepper, lowercased address)`; §7's directory row and §7's rekey
> reasoning say HMAC too. This paragraph is the only place an expensive KDF
> appears. `internal/home/links.go`'s `blindIndex` implements **ruling 3**,
> on the ground that a settled §1 row outranks a threat-model note — so as
> written this paragraph is currently false about the code.
>
> It is a real tradeoff, not a typo. A dictionary run against a leaked
> pepper is exactly the second threat model's attack, and an HMAC prices it
> at nothing. Against that: the index is computed on the sign-in path and on
> every call-2 directory upsert, so the cost is paid per request. That cost
> is probably affordable — sign-in is already rate-limited at 20 per IP and
> 5 per address per 15 minutes, and call 2 fires on grant change rather than
> per request — which makes the case for the KDF stronger than it first
> looks.
>
> Changing either sentence changes a §1 row, so it is Paul's ruling. Until
> then the code follows ruling 3 and this note records the gap rather than
> hiding it.

### 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`.
- **The membership roster: `(team, person_ref, role)`** (ruling 23), and the
  roster's `(generation, epoch)`. **Plaintext**, necessarily — call 8 reads
  it — and it is the new column class §0's cost sentence names. `role` is
  what makes a published database identify each team's owners.
- Team hostnames and sqids — these appear in URLs and were never secret.
- Passkey credential ids and **public** keys.
- Session rows keyed by **person ref**. Their timestamps are **exact at
  rest** — expiry is compared against them, so they cannot be coarsened in
  the table — and §3.3's coarsening is a **rule for publication**, applied
  when the database is published, never a storage format. An earlier draft
  listed them as "coarsened timestamps". What forces exactness is not expiry
  alone (a day-granular `expires_at` on a thirty-day session would work) but
  **freshness**: `sessions.Fresh` compares `auth_time` against a max age in
  minutes, and §5.2's show-codes-once page depends on it.
- Each person's theme.

**Never at rest at all: pulled content** — titles, URLs, actors. Held in
RAM only, bounded per `design-activity-pull` §7 and §2.1, since §2.2's
2026-09-05 amendment.

**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 stopped saying
"some unidentified person" long ago.

**Ruling 23 makes this argument weaker than it reads, and that is said here
rather than left to §0's one-line cost.** The fingerprint above needs
*several* teams plus an outside fact. An **owner row needs one team and a
fact that is usually public** — who owns Acme is on Acme's website. So
`(team, person_ref, role=owner)` names a person ref outright, with no
multi-team fingerprint required, and naming it then reveals every other
membership and guest row that ref holds. **Naming owners is materially more
identifying than "belongs to a set of teams"**, and it is the class §0's new
sentence is about. **Said plainly rather than
counted as a fix: coarsening protects a deliberate publication and nothing
else.** §3.1's leak models ship the raw database file, whose session rows
carry exact timestamps (§3.2), so the timestamp-correlation fingerprint is
**unmitigated in a leak**. There is no export verb in this design that
coarsens; if one is ever wanted, that is where the rule would attach.
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, queue payload, or pulled content — a title,
a URL, an actor — in any log line, error string, job status message or
metric label.** (Pulled content added 2026-09-05 with §2.2's amendment: a
log line is at rest, so *never stores* reaches it, and a failed pull is
exactly where a title would otherwise land in an error string.) 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

The 2026-09-08 implementation runs explicit credential and directory roles in separate processes and databases, matching the paired preview topology. Each role refuses the other hostname. The directory loads its sealing key and pinned verification keys only; a private assertion key in its configuration is refused. Both databases need their own privacy checks and restore drill before production.

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, 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 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.

**The hostnames (ruling 16, 2026-09-03).** The suite's primary domain is
**`oficina.works`**, registered and ours. It replaces the provisional
platform-default addresses `<app>.<sqid>.oncarlos.com`, where `bds` was a
Carloku *account* sqid — a hosting artifact that earns nothing on a domain
we own.

- **`id.oficina.works`** — the credential origin. This is the RP.
- **`oficina.works`** — the directory, at the apex. A different hostname
  from the RP, which is what this section requires.
- **`<team>.<app>.oficina.works`** — one team, one instance, one hostname:
  `acme.docs.oficina.works`, `acme.sheets.oficina.works`. This is the team
  slot the provisional shape never had, and §7's "team name and hostname
  reserved at enqueue" needs somewhere to reserve them.

Hosting stays on Carloku; `oficina.works` is a custom domain in front of it,
with a wildcard certificate per app zone (`*.docs.oficina.works`).

**Never widen the RP ID to a registrable suffix** — and note that the reason
changed with the domain rather than disappearing. WebAuthn permits an RP ID
that is a suffix of the origin's hostname, so `oficina.works` would be
*accepted* for `id.oficina.works`. On `oncarlos.com` the objection was other
Carloku tenants. On a zone we own it is that `oficina.works` as an RP ID is
valid at *every* host in the zone — the directory and each team instance
included — which collapses the split this very section exists to make
(ruling 2). **The RP ID is the full host**, asserted by a test.

---

## 5. Identity

### 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` 2026-08-30 and **re-verified at the v0.22.0 pin
2026-09-05**: `auth_links.address TEXT NOT NULL`, and `admit()` sets
`Subject: id.Address`. (The 2026-08-30 draft also cited
`auth_sessions.address TEXT NOT NULL`. At v0.22.0 that table is no longer
written — `0002_sessions_backfill.sql` moved its rows into the shared
`sessions` table — so the readable address now lands in `sessions.subject`.
The leak moved; it did not close, and the ruling stands on `auth_links` and
the Subject either way.) — 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 use **`keymaildev/signin` directly** for classification and the Keymail
  upgrade — the part that is hard to get right and holds nothing.
  **Corrected 2026-09-05:** an earlier draft said `rastrillo/auth`, which
  cannot serve that purpose. Its classification is reachable only through
  `auth.Begin`, and `Begin` writes `auth_links.address TEXT NOT NULL` — the
  very thing ruling 14 exists to avoid. The seam is one level down:
  `signin.Flow` takes a `LinkStore` the app supplies, so the app seals in
  `PutLink` and opens in `TakeLink`, and classification, the Keymail round
  trip and the rate limits all come with it.

**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 `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: there was no per-team policy record, `Gate` is strictly per-account
(`Enrolled(sess.Subject)`) and asks nothing of an un-enrolled account, and
every workaround breached something — 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, 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**, 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.

**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, which is the authority **on
items**; team standing's authority is the roster now. **A sole owner locked out of all three — passkey, codes and their mailbox —
leaves a team whose roster nobody can write**, and the only exit is §10's
seed verb, an operator reset in all but name, so §14's operator-reset
trigger fires earlier than it reads. (With mailbox access the magic link
still signs them in, as this section's first sentence says.)

### 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` | The exact canonical HTTPS consumer origin, identifying the app and team instance. The directory uses its own exact origin. |
| `iss` | The credential origin. |
| `kid` | Which pinned verification key signed it. The initial transport performs no fetch on an unknown key and fails closed. Deploy old and new public keys before rotating the signer. |
| `iat` / `exp` | See expiry below. |
| `jti` | Single use, per verifier. |
| `v` | Wire version1. |
| `nonce` | The consumer's browser-bound request nonce, independent from jti. |
| `auth_time` | Original credential authentication time; never refreshed by the handoff. |
| `ext` | Optional cosmetic object. The initial Home transport omits it; later theme delivery remains subject to §5.4. |
| Future roster hint | Not emitted by the initial transport. Ruling23's invalidation-only contract in §5.4 remains for the separately reviewed roster adapter; it is not an unimplemented claim silently added to wire version1. |

**Signature:** P-256 through the shared Rastrillo assertion package (ruling24). The private key stays in credential-only platform config, never in either database. Initial isolated consumers pin public keys. Published-key discovery and production consumer registration remain later, separately reviewed changes.

**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
browser history and referrers. (An earlier draft also said "edge logs". No
platform request log carrying paths was found when this was checked on
2026-09-05 — the edge records per-host counts and bytes, not URLs. The
history and referrer reasons stand on their own and are sufficient; the
third was inherited rather than verified.)

### 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 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.

**One narrow exception, added 2026-09-05: an invalidation hint.** (Scoped, per the doctrine's revision 5, to the **call-8 path only** — an instance answering call 7 must not act on the claim at all.) The roster
epoch (suite §5.3) rides the assertion and fails (a) as written — a
monotonically rising integer is not a closed enumerated set and does not
fail cosmetically. It is admissible on a different argument, and the
argument has to be stated rather than assumed: **the instance decides
nothing from it.** A claim it cannot parse is ignored. A claim that differs
from the epoch it holds buys exactly one call-8 refetch, and call 8's answer
is what it enforces. So a hostile claim costs one request and can neither
admit nor exclude anybody. Nothing else may use this exception without the
same property.

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 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 from two sources,
since ruling 23**. **Per-item** capability comes entirely from its own
grants, exactly as before: an assertion for a person with no grant is a
valid assertion that gets nothing individually granted. **Team standing**
comes from its copy of the roster (call 8, suite ruling 5), not from a
grant — so a member with no per-item grant still reaches everything at the
team rung, which is the point of a team. An earlier version of this
paragraph said capability came *entirely* from the instance's own grants;
that is the status quo ruling 23's Rejected column names as overturned, and
Docs and Sheets build from this section.

**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 rides the URL, not a cookie.

### 5.6 The redirect protocol

Absent from the first draft, and the abuse suite did not test 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.
- **The return URL preserves its query string**, validated host and all.
  Ruling 22 puts the team in `?team=<sqid>`, so a shared team link followed
  while signed out otherwise returns the person to a bare `/` and silently
  redirects them to a different team than the one they were sent. §11 states
  this as a cost; it is a mechanism, and it lives here because a step-3
  builder reads §5.6, not §11.
- All three are in gate 3.

### 5.7 The RP rename drill — not needed for the `oficina.works` move

**Ruling 16 moved the domain before this service enrolled its first
credential**, which is the whole reason the move cost nothing. At the time
of the move `home` held no `passkey.Config` at all — sign-in across the
suite was magic-link only — so no credential had ever been scoped to an
`oncarlos.com` host and there was nothing to strand. The drill below is
therefore documented rather than scheduled.

Every *later* hostname move pays the full price described here, which is the
argument for having taken this one early. **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) → (person ref, app, team sqid, host, kind,
sealed team name)`. **A row is a hint and never an authority** (ruling 9),
and ruling 23 makes that load-bearing rather than merely true: **a directory
row never grants or revokes team standing, whatever its `kind` says.** Call 2
writes these rows under an instance's own consumer token, so if team standing
were read from them, any instance could announce a member and walk around
the owner step-up ruling 23 requires. Team standing comes from the membership record and
nowhere else.

`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.

**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):

- A failed upsert never fails a share. The instance is the authority.
- 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: **no call in this table pushes to an instance**, and instances
hibernate. (One call runs home→instance — call 7's activity pull — but it
fires per render, when a person is already looking at their front door, so
it is not a channel the home can reach a sleeping fleet down. Call 8 is the
instance calling the home.) A fleet-wide poke
would in any case 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 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 label is reserved suite-wide at enqueue** (ruling 19). Claiming `acme`
takes it across every app, so only Acme can later create `acme.sheets`.
Without that, a stranger squats the sibling hostname of a team you already
have, on an apex that reads as one suite — which is impersonation whatever
the model calls it.

**Who decides that a later request really is Acme — and why the home never
answers it.** The first claim of a free label is a decision about a resource
alone: is this label taken? The *second* instance is the hard one, because
"only Acme may create `acme.sheets`" needs a principal, and the home holding
a person↔resource entitlement **over an instance's items** is what §2.2 forbids and ruling 9 refuses (ruling 23 has the home hold exactly such an entitlement over a resource of its own, the roster — that is the narrowing, and it is why this sentence now carries the qualifier)
("*where might I have things*, never *may I have this*"). An earlier draft
argued this away as "a name, not a document". **That argument is wrong and
is recorded here so it is not made again:** §2.2 says *never holds an
authorization model* — the clause as it stood before ruling 23 rewrote it —
not *never holds a content authorization model*, and
ruling 15 rejected a per-team 2FA floor. **(That example is withdrawn from
this argument.** Ruling 15's own Rejected column was buildability, and §2.2
now grounds it on mechanics — an un-enrolled member sails through `Gate`,
and there is no per-team policy record. It was never a clean illustration of
§2.2 and it is not one now.) So the distinction has to be sharper. Two
paths, and the home answers only facts about its own records in both:

**Both paths end in the queue**, and this is not a detail. §7's
load-bearing safety fact is that **the home holds no provisioning
credential** — so nothing the home can do creates `acme.sheets`, and ruling
7's "a human sees every team before it exists" holds for siblings exactly as
for first teams. What the two paths differ in is what the operator is
handed, not whether there is one.

- **Fast path — an existing instance vouches for its own team.** "Create
  `acme.sheets`" is initiated from inside `acme.docs`, by whoever
  `acme.docs` considers an owner, and reaches the home as an instance→home
  call under that instance's consumer credential. The home checks one
  thing: **is the calling origin already in `team_instances` for this
  team?** — joining `consumers.origin` to the instance host the home itself
  recorded at the drain. That answers the *label* question and enqueues the
  request pre-settled. **Amended 2026-09-05 by ruling 23:** the home now
  learns who owns a *team*, because the membership record carries a role and
  an owner is who may write it. What stays the instance's business is who
  owns an *item* — a sheet, a document, a calendar — decided from its own
  grants, which is §2.2's second never-do working exactly as intended. The
  home knows Ana owns the Acme team; it does not know she owns a single
  document in it, and it never asks.
- **Fallback — everyone else arrives unvouched.** The request is enqueued
  the same way, and the human decides the label question too. The home may
  render the requester's own directory rows **and their roster role, which is the stronger hint the home now owns,** beside it (ruling 9) — with `kind` shown, since a *guest* row in `acme.docs` is emphatically
  not "is Acme". They are a hint and nothing else: the operator adjudicates.

**Two things this does not smuggle in.** Consumer-level authorization is not
new and is not §2.2's concern: §9 already scopes every call to its consumer,
and a label on `oficina.works` is the home's own namespace. What §2.2
forbade, before ruling 23, was any *person*-level entitlement, and neither
path formed one. **Ruling 23 forms one deliberately** — the roster is a
person-level entitlement the home holds and enforces — so the line moved
rather than held: the home decides about **resources it owns** (a label, a
team's roster) and never about **an instance's items**. Both paths here are
still on the resources-it-owns side. And the
new instance's initial admin and welcome address arrive as **sealed queue
payload**, exactly as a fallback requester's do (§3.2) — the home carries
that opaquely and never judges it.

**A compromised `acme.docs` can vouch for `acme.*` siblings.** Bounded by
the registry's `provisionable` set, one row per (team, app), the drain still
seeing every request, and the sibling getting its own consumer token from
that drain rather than inheriting one. Recorded rather than left to be
discovered; §2's blast-radius argument survives it.

So the home's whole vocabulary here is two facts: *is this label free*, and
*is this calling origin already an instance of this team*. It never forms an
opinion about whether a person is entitled to anything **in an instance**. Since ruling 23 it does form one about team standing, which is its own record; the paths here form none of either. That is the
distinction ruling 19 rests on, and it is a narrower one than "names versus
documents".

**Two consequences, recorded rather than left to be discovered.** §14's
auto-drain row now carries a third decision — auto-draining also means
automating the fallback adjudication, and the machine credential does not
supply the judgement. And **a queue entry that is purged or times out must
release its label**, or a request that never became a team holds a name
nobody can claim.

**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, 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 contract

| # | Call | Direction | Notes |
|---|---|---|---|
| 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, which is advisory**: the home owns the display name (§10) and does not overwrite it, so this is a consistency check the home may log a divergence on, never a write. Home hashes; **returns the person ref**, the pepper epoch **and the roster epoch** (ruling 23 — the doctrine carries it in calls 2, 5 and 8 and in the assertion; a builder reading only §9 would otherwise ship two of the four staleness carriers). Instance-scoped revoke included. Via the outbox. |
| 3 | Directory list | person → home | Every team across every app, with `kind` — **joined from `memberships ⋈ team_instances` for members and `directory` for guests** (ruling 23, §11), or a builder implementing this row rebuilds the missing-tile hole. |
| 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. Carries the **roster epoch** (ruling 23). |
| 6 | Preference write-through | person → home | Redirect POST to the directory origin (§8). |
| 7 | Activity pull | home → instance | **An assertion for the person**, `aud` = the instance. The home renders and discards; RAM only, never at rest. `design-activity-pull` §4–5, suite §6. |
| 8 | Roster pull | instance → home | Consumer token, scoped to the caller's own team. Returns `(generation, epoch)` and the team's members with roles, **paged with an epoch-bound cursor**, the **epoch per team** (a suite-wide epoch would make a bump in one team a cross-team activity signal to every instance, which suite ruling 1 forbids), and a request for a team that is not the caller's answered **identically to an unknown team — 404, never 403** (gate 4's pattern): a page from a superseded epoch is refused, and an instance stages every page and replaces its roster atomically only when a complete set for one epoch has arrived. Suite §5.3. |

Auth follows Pegamento's service contract: **per-consumer bearer token
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 4b — sibling instance request** (ruling 19's fast path) is
instance → home, not person → home, and belongs in the table above rather
than only in §7's prose: an instance asks for `<team>.<app>` under its own
consumer credential, the home checks the calling origin against
`team_instances` for that team, and enqueues the request with the label
question settled. Docs and Sheets implement from that table, so a call that
exists only in §7 is a call nobody builds. Named for `carlos/pegamento`'s
re-review alongside the rest of §9.

**Call 2 drives two events**, not one: a grant changes; and an **instance**
is deleted (instance-scoped revoke — after §10's split this removes that
`team_instances` row and its directory rows, and leaves `teams` and its
label reservation alone while any sibling instance survives). Without the
second, rows point at a host that 404s — a convenience failure under §6,
which is exactly why it would survive being missed.

**Renaming is no longer one of them.** An earlier draft had call 2 drive a
re-announce "so guests stop seeing a stale name". That cannot work now the
home owns the display name (§10): if call 2's name never overwrites, a
re-announce un-stales nothing. **Rename is an operator verb at the home** —
one row, one write, immediately true for every instance and every guest,
which is strictly better than a re-announce that depended on each instance
waking up. It is built with the rest of the operator tooling in step 6.

**It renames the display name only, never the label.** A relabel is a
hostname move for every instance in the team — certificates, consumer
origins, allowlist entries — which is a new team, not a rename. And the
divergence §9 may log carries the **team sqid and consumer origin, never
either name**, or gate 9 fires on the log line meant to help.

### 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

The explicit directory role has its own sessions table and the shared handoff module's pending, inbox, replay and clock-fence tables. Paths and tokens are sealed before insertion; only hashes identify browser handles, nonces and assertion IDs. The credential database retains people, sealed sign-in links and passkey state. This split does not make the directory authoritative for any instance item.

- `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` — **the suite-wide team** (ruling 17): team **id** (the integer the
  sqid encodes — ruling 22's `/` entry picks the oldest team (§11) and
  needs it, and a sqid sorts
  by string not by age), team sqid, **label** (the
  `<team>` DNS segment, and the reservation of ruling 19), sealed display
  name. One row per team, not per instance. **The home owns the display
  name**, set once at provisioning by the human who drains the queue and
  already sees every team before it exists (ruling 8). Call 2's sealed team
  name is therefore **advisory and never overwrites it** — otherwise a team
  renamed in Docs but not Sheets would flap with whichever instance woke
  last, and the rename event §9 used to have would have meant two different things.
- `team_instances` — team sqid, app, host. One row per (team, app); this is
  what `<team>.<app>.oficina.works` resolves against. Splitting these two is
  what lets the switcher mean "Acme" rather than "the acme row that happens
  to be in Docs": before the split, `acme.docs` and `acme.sheets` were
  unrelated rows sharing a DNS label, and nothing in the model said they
  were the same team.
- `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.

**`memberships`** — `(team, person_ref, role)`, role being `owner` or
`member`, added by ruling 23. Written **only** at the directory origin, by a
team owner, behind a **fresh-assertion step-up — which is §14's row, now triggered**.
An earlier pass of this branch claimed it was a different mechanism for a
different caller; that was wrong and is reversed here. The directory origin
holds no credential (§4: it "obtains its session by exchanging an assertion,
the same mechanism instances use"), so its session's `AuthTime` comes from
that exchange, and the only way to make a failed `sessions.Fresh` pass is a
**new exchange in which the credential origin re-verifies** rather than
minting from its standing session — a `max_age`-style parameter on the
exchange, which is exactly what §14 describes. (§5.2's show-codes-once page
does sit behind a plain `sessions.Fresh`, but that page is on the credential
origin, where the session was minted by real authentication. It does not
transfer.) The doctrine agrees: §5.4 says "home §14 names it; this is its
trigger".

**Or seeded by the drain** with the person the sealed queue payload §7
already carries. That bootstrap exists because gating a membership write on an owner
needs an owner to exist first, and the circularity has to break outside the
roster. **The seed needs one thing said out loud:** the payload holds an
**address**, the roster keys on a **person ref**, and turning one into the
other needs the pepper — which only the home holds (ruling 4) — plus a
person stub if that owner has never signed in (§6). So the drain calls a
**home-side operator verb**, the same surface that marks an instance live;
it never holds the pepper itself. That verb mints an owner, so it is
security-load-bearing and is named rather than left to be improvised. **A
sibling drain (call 4b) seeds nothing** — the team already has owners, and
the person its payload names is the **welcome-mail recipient and that
instance's own initial admin**, not a roster write; §7 and §10 agree once
that is said. Call 8 reads this table and nothing else. Call 2 cannot write it.

Guests are **not** on it. A guest grant is minted in the instance and
announced to the directory as `kind = guest`, exactly as before; nothing
about guests changes.

**Losing this database loses credentials, not access — except the roster.**
The directory rebuilds as instances re-upsert; passkeys do not. **The
membership record does not rebuild either**, and that is new with ruling 23:
call 2 cannot write it, and no channel reaches a hibernating instance to ask
for it. Recovery is the instances' own copies plus an owner's confirmation — and the mechanism is worth naming, because "the instances' copies" is not a channel: no call carries a roster *to* the home (call 8 is a pull), so it means **an operator reading an instance's copy out by hand and re-seeding through §7's operator verb**. After a total loss the confirming owner cannot be one the home knows, so the seed verb is what breaks that circularity too
at the directory origin, or it is re-entry by hand.

**A restore mints a new roster generation.** Refusing an epoch lower than
the one an instance holds is not enough on its own: a restored home reissues
the same numbers over a recovered roster and, two writes later, an instance
accepts an epoch it believes is fresh carrying members that were removed. So
the roster carries `(generation, epoch)`, the generation changes whenever the
home cannot prove continuity with the epochs it last issued, and an instance
seeing an unfamiliar generation holds its roster stale and admits nobody on
team standing until an owner confirms it. A restore is a human event and it
costs a human action. *A backup you have never
restored is a hope, not a backup* — the restore drill runs before launch.

---

## 11. Shape and design

Implementation status, 2026-09-11: the registry contains six integrated native apps (Docs, Sheets, Slides, Calendar, Meet and Memoria), each with the shared scriptless team chooser. All remain provisionable false. The proposed app grid, team switcher and New button below remain unimplemented; the preview launcher is the current directory surface. See implementation/directory-runtime.md for consumer evidence and deployment boundaries.

Ruling24 adds exactly one progressive enhancement: assertion.html may load /static/assertion.js to submit its registered-destination form. The visible Continue button remains usable with scripts off. This extends the earlier passkey-only allowlist without a blanket script exemption. Credential form-action is the process-wide exact consumer manifest; markup injection could post to any registered origin, which is why the credential origin must render no account-authored markup.

rastrillo `server` shape, zero-JS baseline, `day` theme, and the
**attribute** vocabulary — `rst-btn`, not `class="rst-btn"` (ruling 20).

**The seven utilities are the exception, and they are not a loophole.**
`rst-sr-only`, `rst-mono`, `rst-m-hide`, `rst-grow`, `rst-nm`, `rst-danger`
and `rst-cell-mut` **keep the class spelling**, because they have **no
attribute twin at all** — verified at v0.22.0: five `.rst-sr-only`
selectors and zero `[rst-sr-only]`. tokens.css says why in its own header:
"Utilities keep the class spelling and are not paired… They are not kinds —
they are cross-cutting styling, which is what class is for, and they stay
class after the move", and rastrillo's §6-v3 agrees ("`class` is for
utilities and the app's own CSS"). Writing `<span rst-sr-only>` matches
nothing, so screen-reader-only text renders **visible**. Kinds, variants and
parts are attributes; these seven are classes.

**Corrected 2026-09-05, verified against the pinned `rastrillo v0.22.0`.**
An earlier draft recorded "zero bare `rst-*` attribute selectors against
~528 `.rst-` class selector occurrences", verified 2026-08-30 against
`origin/main` post-design-system-v2. That was true when written and is
false at every version this repo has ever pinned: `ui/tokens.css` at
v0.22.0 has **129 distinct bare `rst-*` attribute selectors** (a 130th
attribute, `rst-tone`, appears only ever valued — `[rst-tone~="positive"]`)
against **536** `.rst-` class occurrences. The twins arrived in **v0.21.0**, which
is what this repo's first `go.mod` pinned — so the claim was falsified
before the skeleton existed, not by a later bump.

**Write the attribute spelling: `rst-btn`, not `class="rst-btn"`** (ruling
20, 2026-09-05). This section ruled the class vocabulary on 2026-08-30 and
every reason it gave has since died, so the ruling went back to Paul rather
than being propped up with a third. Two reasons died. "The attribute
grammar is unreleased" died at
v0.21.0. "The partials we render alongside write classes" died at **v0.22.0,
the current pin**, where the shipped partials flipped to the attribute
spelling — rastrillo's design-system §6-v3 stage 2 — and the class spelling
in `ui/partials/` falls from 124 occurrences to 6. tokens.css's own header
still says every shipped partial writes classes; **that sentence is stale
upstream.**

**So the app starts on the spelling that survives.** rastrillo §6-v3 rules
"One spelling, not two. No permanent class fallback" and drops the class
selectors before 1.0; this app has no `rst-` markup at all yet; and an app
writing classes beside v0.22.0's attribute partials *is* the mixed codebase
the old rule wanted to avoid. Free today, a sweep after the first template.

Re-check the counts whenever the pin moves, say which version was checked,
and **count `ui/partials/` as well as `ui/tokens.css`** — checking one and
not the other is how the second reason survived a pass looking for exactly
this. `Pair`, `Allocate` and `--rst-header-rule` remain in zero **source** files
— they appear only in rastrillo's own design-system spec, in its
ruled-but-not-built list.

**`rastrillo doctor` landed in v0.21.0** — an earlier draft's "until
`rastrillo doctor` lands" was a trigger that had already fired. When this
app vendors `static/`, run `rastrillo doctor --fix` on each upgrade rather
than re-copying by hand. There is no `static/tokens.css` in the tree today
(§11's zero-JS page links no stylesheet), so this is an instruction with no
object until the first vendored asset arrives. (The unstyled page is
`internal/home/page.go`; this section defines no page of its own.)

Every screen works with scripts off — **with one named exception, and the
day it arrived is the day this sentence was tested** (ruling 21). This
section used to say "no surface here has any reason to require script, and
the day one does is the day this section is wrong". That day came with
passkeys: a WebAuthn ceremony is `navigator.credentials`, script by
construction. rastrillo ships the hard half (`webauthn.JS()` embeds `js/webauthn.mjs`),
so the only question was whether one screen may require script, and the
answer is yes. Precisely: `webauthn.mjs` exports bare `navigator.credentials`
wrappers and makes no network call, so the app still writes a few lines of
glue — fetch `/begin`, call the wrapper, POST `/finish`, follow the `to` it
returns. **That glue is a static same-origin file, never an inline
`<script>`**, which also means the credential origin's CSP moves from the
`script-src 'none'` a zero-JS RP could otherwise have to `script-src 'self'`.
Gate 5 is untouched: embedded module bytes and app source are not
account-authored strings.

**The carve-out is exactly the passkey enrolment and ceremony screens.**
Magic-link sign-in, recovery-code redemption and the whole springboard stay
scriptless, so a person with script off keeps a working sign-in and loses
only the optional second factor (ruling 15 already makes it optional). The
no-`<script>` test gets an **explicit allowlist of those screens**, never a
blanket exemption — a rule with a list is checkable; a rule with an escape
hatch is not.

**The springboard** (ruling 17). The directory origin is the suite's front
door, and it is **one page**: a grid of apps, with a **team switcher as a
dropdown in the header**. You pick a team above; the grid below is that
team's apps. It is a dropdown rather than a first-level screen because a
person with one team would otherwise pay a click for a choice they do not
have — and one team is expected to be the common case at launch, which is an
assumption to revisit rather than a measurement.

**Zero-JS, concretely** (§11's own rule, and gate 6): the switcher is a
`<details>`/`<summary>` disclosure whose items are ordinary GET links to
`/?team=<sqid>` — rastrillo v0.22.0 ships exactly that as
`ui/partials/dropdown.html`, and ruling 22 keeps the switcher able to use it
rather than needing a form of submit buttons. A `<select>` with an
`onchange` would need script and gate 6 would fail it.
**The selection lives in the URL** — `?team=<sqid>` on every team view —
**because it is navigation state, not session state** (ruling 22). Which
team you are looking at is *where you are*, and a URL is what a browser,
its history, its tabs and another person all already understand that to
mean.

**`/` stays a generic entry.** It picks a team — the one you have, or the
**oldest** if several — the lowest *id*, the integer the sqid encodes,
not the sqid's string order. The column must be `AUTOINCREMENT`: a plain
SQLite rowid can be reused after the highest row is deleted, and then
"oldest" silently stops meaning oldest — and `302`s to the explicit URL, so "take me home"
is still one clean link that works for everybody, while every actual team
view is addressable. If you have no teams it renders the empty state rather
than redirecting.

**This is where the 2026-08-30 draft already had it.** Ruling 17 said "the
selection lives in the URL (`?team=…`)", and ruling 22 is a reaffirmation
with the reasons supplied and `/`'s redirect added — after two detours that
were both wrong. Recorded, because each looked right when written.

An earlier draft said a **URL fragment** "cannot work" scriptlessly. It can:
CSS `:target` switches on it with no request at all — `href="#acme"` and
`#acme:target { display: block }`. What actually rules it out is narrower:
the **server never learns which team you are looking at**, so nothing
server-rendered can be team-scoped — not the grid (which tiles, which New
buttons), not the switcher's own `aria-current`, and not gate 4's 404 for a
team you are not in.

A second draft moved the selection into a **session cookie**, on the
reasoning that `?team=` "puts an account-authored name in referrers, history
and platform logs". False three ways: §10 gives every team a **sqid**, which
is opaque, so nothing account-authored is in that URL at all; referrers send
only the origin cross-site under the browser default
`strict-origin-when-cross-origin`, which a builder should set explicitly
rather than inherit; and no platform request log carrying paths was
found when this was checked. The cookie then lost on its own merits:

- **History does not restore a cookie.** Press Back and the page you see may
  be Acme's while the next request from it resolves to Beta — display and
  server silently disagreeing, which is the worst shape a bug can take.
- **A shared cookie couples every tab, on every request.** An earlier draft
  patched this by putting the sqid on the New button; that fixes one link.
  Every team-dependent link and form has the same problem, and fixing them
  all means putting the team in the URL — at which point the cookie does
  nothing.
- **It buys nothing on caching.** Both shapes need `Cache-Control:
  no-store`; a cookie does not protect a response from the platform edge.
- **GET is the right verb here.** Selecting a team **mutates nothing** — it
  is a read of a different view — so GET is what HTTP means by it. A cookie
  would have introduced mutable state to express a location. (An earlier
  draft cited §8 as having "refused a state-changing GET for theme". It says
  no such thing: §8 chose a redirect-based POST over a *cross-origin
  credentialed fetch*, on CORS, SameSite and zero-JS grounds. The
  distinction that matters is real — a theme write mutates a stored
  preference and this does not — but the citation was invented, which is
  the failure this file keeps having to retract.)

**Three costs, stated rather than discovered.** A shared front-door link is
team-specific: send someone `/?team=<sqid>` and they land on that team, or
on a **404 if they are not in it** (gate 4 — 404, never 403). `/` is the
link to share when you mean "the suite". A person in several teams always
lands on their oldest from `/`, which is stable rather than clever, and the
switcher is one click away. And **a shared team link followed while signed
out must survive sign-in** — §5.6 makes the return URL carry the query, and
gate 3 asserts it; without that the person arrives at a bare `/` and is
silently redirected to a different team than the one they were sent.

**No-store is route-wide, and this is the first place the spec says so** —
the *need* predates ruling 22 — the front door has always rendered the
viewer's own rows — but nothing had written it down. Every authenticated
response on both origins sends `Cache-Control: no-store`, `/`'s personalised
redirect included, asserted by **gate 10**. The platform edge caches any 2xx GET carrying
a positive `max-age`, keyed on host, path and query, shared across every
visitor, and it does **not** refuse on a request `Cookie` — which is why
this needs a gate rather than a convention.

**Tiles come from the viewer's own rows, never from the label** — and since
ruling 23 "rows" means two tables, not one. **A member's tiles come from
`memberships ⋈ team_instances`**; **everyone's `directory` rows are unioned in on top, whatever their
`kind`** — de-duplicated, because a row is a hint (§6) and an ex-member who
kept a per-item grant has a `kind = member` row and no roster row (doctrine
gate 5 keeps that grant). Filtering the union on `kind = guest` would hide
their tile, which is the same class of hole one door along. This is not a refinement, it is a hole that had to be
closed: a member with team standing and no per-item grant has **no directory
row at all** (call 2 fires when a grant changes), so a tiles-from-directory
front door would have hidden their own team from them. The home owns both
tables, so it joins them rather than taking the doctrine's own mechanism —
§5.4 has the **home** announce a new member into each `team_instances` row's
directory entry at roster-write time (no instance and no call 2 involved),
which works but puts a second writer into `directory`. **The outcome is
the doctrine's** — §5.4's "the front door shows the whole team on first
sign-in" — reached by a different mechanism, and §15 carries it for the
doctrine's next revision.

**The two cases read differently, and both are gate 4's business.** For a
**guest**, the instances under an app are their own `(person ref, app,
team)` directory rows; reading it as "every `acme.*` host" would tell a
guest in `acme.docs` that Acme also uses Sheets, which is another person's
row and what gate 4 forbids. For a **member**, every `acme.*` host *is*
their own standing — through the roster, not through a grant — so the join
shows them.
Gate 4 asserts both: a guest of `acme.docs` sees no `acme.sheets` tile, and
a member of Acme sees no Beta instance. A registry entry with no instance
under it reveals only that the app exists in the suite and that you have no
team in it, both of which you already knew.

**Which apps exist comes from a registry, not from your grants.** It is
**compiled-in configuration, not a table** — the suite's app list changes
when someone ships a release, never at runtime, and a table would invite a
runtime edit nobody reviewed. It holds, per app: slug, display name,
`provisionable`, `integrated`, and — **for `integrated: false` entries
only** — a foreign host. An integrated app needs no hostname field at all:
ruling 16 fixes its shape as `<team>.<app>.oficina.works`, and a free
per-app pattern is an invitation to drift from it. The directory renders
your teams' instances
under each entry; an entry you have no instance in still renders, showing
the New button, which is the affordance a grants-only list cannot have. The
registry is not invented for this screen — §4's `<team>.<app>.oficina.works`
has to resolve per app and §7 has to know what can be provisioned, and
without it that knowledge is scattered across templates.

**Preview navigation exception, 2026-09-09 — approved by Paul for the
current preview connection.** The directory may read the operator-only
`HOME_DOCS_PREVIEW_ORIGIN` setting and link the Docs catalog entry to that
canonical HTTPS origin's fixed `/signin` path, labelled **Open preview**.
An empty setting keeps the existing `/apps/docs` team chooser. This is an
opt-in preview launcher, not a team instance or a change to the canonical
`<team>.<app>.oficina.works` route policy. It does not register a consumer
or grant access; the destination must already implement its own Home
handshake. The setting is rejected outside the directory role, for a
noncanonical origin, or for the credential/directory origins themselves.
The current catalog is a staged list with explicit unavailable states;
the full team-scoped grid and its switcher remain to be implemented.

**Suite preview extension, 2026-09-10 — Paul requested the remaining apps
use the working Home handoff.** `HOME_APP_PREVIEW_ORIGINS` (or its canonical
base64 alternative) is an optional operator map from a compiled catalog
slug to a canonical HTTPS origin. Each configured link opens the fixed
`/signin` path. Unknown apps, empty destinations, self-origins, duplicate
JSON keys and non-directory use are refused. The legacy Docs setting may
coexist with other app entries, but specifying Docs in both settings is
refused. Configuration changes navigation only; each destination must have
its separately reviewed consumer and account-linking integration before
its link is enabled. Memoria is included in the catalog. Mail and Files
still require their own vault-unlock flow; this setting grants no keys or
membership and changes none of the canonical team routing rules.

**Mail and Files are `integrated: false`** — Keymail and Slopbox are
separate products in separate repositories with their own auth. They render
as plain outbound links, never as team-scoped tiles, until each verifies
assertions per §5.3 (§14).

No activity feed **yet** — ruling 18 approves one, pulled at render, per
team; `design-activity-pull` passed its review on 2026-09-05 and it is
sequenced as §13 step 10, blocked on steps 3 and 4.
No template gallery, no upsell chrome, no waffle.

---

## 12. Testing gates

1. **Plaintext grep** — raw SQLite file and WAL plus this app's store
   objects, against a seeded corpus of known addresses **and, since the
   2026-09-05 amendment, known titles, URLs and actor strings**. The verb is
   now *never stores*, so what the grep looks for has to grow with it or the
   clause has no test behind it. `design-activity-pull`'s gate 11 is the
   driven version, through the failure paths.
2. **No-address-at-rest** — the schema has no address column, and a full
   sign-in plus directory upsert plus rekey **plus the roster seed verb**
   (§10 — a fourth path that unseals an address and hashes it) leaves no
   readable address
   anywhere, WAL included.
3. **Assertion abuse suite** — audience confusion, replay, expiry, algorithm
   confusion, signature stripping, **open redirect**, **login CSRF**,
   **unknown `kid`**, and **query preservation on the return URL** (§5.6).
4. **Two-person isolation** — one person's directory never contains
   another's rows; 404, not 403. **Three cases since ruling 23** (§11): a
   guest of `acme.docs` sees no `acme.sheets` tile; a member of Acme sees no
   Beta instance; and — the positive one, since the hole took three passes
   to find — **a member of Acme with no grant in `acme.sheets` does see the
   `acme.sheets` tile.**
5. **Origin separation** — no account-authored string reachable on the
   credential origin. §4 made mechanical.
6. **Scriptless-affordance diff** — every page, script-stripped. **For the
   ruling-21 screens: every affordance except the passkey ceremony button
   itself, and the allowlist is named explicitly in the test. The ruling24 handoff keeps its Continue button with scripts off.** Exempting
   the whole screen would be wrong rather than merely loose — the
   confirm page carries the ceremony *and* the recovery-code form, and
   `passkey.SignInRecovery` is a plain form POST precisely "because
   recovery is exactly the moment WebAuthn or JavaScript isn't working".
   A blanket exemption would let that form regress unnoticed, on the one
   screen where scriptlessness matters most.
7. **Contrast test** copying `ui/contrast_test.go`'s arithmetic verbatim.
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, queue payloads **and pulled content — titles,
   URLs, actors** (§3.4).
10. **No-store** — drive every route on both origins and assert each
   authenticated response carries `Cache-Control: no-store`, **call 8's
   roster read included** (it is a bearer-authenticated GET, and whether the
   edge refuses on `Authorization` is not something to rely on), the
   personalised `/` redirect included (ruling 22). It exists for the edge-cache
   reason §11 sets out.
21. **The roster write** — assert it requires a fresh assertion (§14's
   step-up, triggered by ruling 23), that call 2 cannot write `memberships`,
   and that a write naming a team the caller does not own answers 404 like
   any other unknown team. The most security-load-bearing new write in this
   spec had no home-side gate; suite gates 5 and 8 are instance-side and
   schema-side.
    (Numbered 21, not 11: `design-activity-pull` owns gates 11-20.)

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.

---

## 13. Sequencing

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 — **and the roster** (ruling 23): the `memberships` table, the seed verb, the owner write behind §14's step-up, call 8, and gate 21. The doctrine puts call 8 on top of this step, and a suite-shape Sheets or Docs needs it for team standing, so the ship gate below is not just step 4 but step 4 including the roster
5. Signup, queue, rate limits, name reservation
6. The operator drain
7. Preferences, the assertion claim, call 6
8. Rekey journal (call 5), epoch, decommission
9. Restore drill and the RTO
10. **Activity on the front door** (ruling 18, `design-activity-pull`).
    **Per team, following `?team=` — RULED 2026-09-05 by Paul.**
    `design-activity-pull` was revised to match on 2026-09-05 (its ruling 6)
    and approved. Per-team also shrinks the fan-out §6 there worries
    about, from every instance a person can reach to the apps in one team.
    **This is not a merge-logic tweak.** Per-team retires that design's
    headline argument — that only the home can answer "my things" rather
    than "this team's things" — so its §2.1 was re-justified and its §5.4
    reopening revisited, not just its query changed — done 2026-09-05.
    Blocked on steps 3 and 4, and deliberately last. It has no claim on
    anything earlier and must not acquire one: the identity layer is the
    suite's critical path and this is a front-door nicety.

**Sheets and Docs are unblocked from step 3, not step 9** — assertions plus
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.

---

## 13a. Consumed suite rulings

The suite sharing doctrine (`oficina/suite`) binds this spec. Read against
**revision 7, commit `2b78680`**, reachable from that repository's `main`. (An earlier version of this line cited revision 4 at `210312f` under gate 9's one-revision grace; three revisions later that grace is spent, and the cited revision now contains §5.3's epoch scoping outright rather than by grace. **Every ruling this spec consumes is textually unchanged from revision 4 through 7** — 5 changed the status block and A.1's note; 6 and 7 scheduled S6 and added §6 paragraphs, and the doctrine's §1 rulings table is byte-identical between 4 and 7.) `design-activity-pull` §1.1 carries the same citation and the fuller account.. Ruling 23 adopts it.

| # | What it settles for the home |
|---|---|
| 1 | The team is the boundary. §2.2's "never federates" becomes "never crosses a team". |
| 2 | Sharing is membership across teams. |
| 3 | Custody and authorization are two rules; authorization is never cached. The home holds neither — it answers facts about its own records. |
| 4 | The home owns identity, the team and the membership roster. It never owns a per-item grant. |
| 5 | Two shapes. A lone instance never dials a home; a suite instance takes identity and roster from one. |
| 8 | Two credentials. Only the home can mint an assertion, which is why call 7 carries one and sibling calls do not. |
| 12 | Membership is its own record, written only by an owner. This is ruling 23. |
| 13 | A compromised home is three compromises, conceded separately — see below. |
| 14 | Custody is bounded at acquisition and retired by the feed. |
| 25 | **Supersedes 21 and 24: no script by default; script only to progressively enhance a page or provide interaction HTML and CSS cannot, and always prefer none.** Every screen works with scripts off; scripts are static same-origin files, never inline, and the no-`<script>` sweep names Home's script files (a closed list) rather than screens — §11 | A closed list of screens allowed script, which the busy rule on every form outgrew | Paul, 2026-09-29, verbatim: "the default [is] no script, but scripts can be added to progressively enhance and provide interaction, but always prefer no JavaScript" |

Calls 7 and 8 are added to §9. Call 7 is the activity pull, and it waits on
the identity layer (§13 step 10) because it carries a real assertion. Call 8
is the roster pull and waits on step 4.

### What the doctrine makes the home concede

Suite ruling 13 splits "a compromised home" into three, and §2's careful
sentence about a compromised **directory** does not cover all of them. The
home spec should not be read as promising more than this:

- **A leaked database** gives up the graph, the roster, and — new with
  ruling 23 — who owns each team. No content.
- **A compromised directory origin** can add a member, and a member reads
  everything team-visible in every instance of that team, through each app's
  ordinary front door. That is the blast radius ruling 23 actually adds.
- **A compromised credential origin** can sign in as anyone, owners
  included. It holds the signing key (§5.3), an instance's grants join is
  keyed on the `sub` this home asserted, and §5.4 already calls a hostile
  `sub` account takeover. **This is not new and ruling 23 did not cause it**
  — it has been true since ruling 1 put every credential on one origin, and
  a passkey does not help because the passkey lives there too.

**The step-up bounds none of them**, because the compromised authority is
the one performing it, and neither does a directory row or a notification:
those are what honest code leaves behind, not what a compromise must. §3.3's
idiom applies — said plainly rather than counted as a fix.

**The Pegamento filing.** The suite doctrine records a divergence from
Pegamento §10.3, *"a compromised glue grants nothing"*. The honest shape of
it is not that the roster diverges: it is that **this home was never a
§10.3 glue.** §10.3 is written for a registry, and ruling 1 made this an
identity authority. That filing belongs at `carlos/pegamento`.

---

## 14. Deliberately not built yet

| Thing | Trigger |
|---|---|
| **Owner-enforceable per-team 2FA** (ruling 15) | A team asks, *and* **a per-team policy record is written** (an owner's write at the directory origin, the roster's own shape) **and assertion minting learns to act on it** — `passkey.Gate` asks for the ceremony only when the account is already enrolled, so an un-enrolled member of a floored team passes straight through and a floor has to force enrolment, not merely demand a ceremony. **Not "argued past §2.2"**, and not "a sixth call carrying team policy": ruling 23 rewrote that clause, and since the home would enforce at minting no call carries policy anywhere |
| 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, **and** ruling 19's fallback adjudication is automated — three decisions. A machine credential supplies no judgement about who may take a reserved name (§7) |
| **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) | **Triggered 2026-09-05 by ruling 23: roster writes need it** (doctrine §5.4, A.1). It is built with the roster — a fresh-assertion round trip with a `max_age`-style parameter, requested by the directory origin and honoured by the credential origin. The caller differs from the instance flow this row first imagined; the mechanism does not |
| Any preference beyond theme | It passes §5.4 |
| Billing, plans, quotas | Somebody has to pay |
| **Mail and Files behind suite SSO** | Keymail or Slopbox verifies assertions per §5.3 in its own repository. Until then they are `integrated: false` registry entries rendered as outbound links (§11, ruling 17) |
| A support console | Never, under **§2.2's first bullet** — "only to that person", "no operator view". Anchored there rather than §0 since the 2026-09-05 amendment; see §2.2. |

---

## 15. Open

- **The doctrine's next revision should carry §11's tile mechanism.** Its
  §5.4 has the home announce a new member into each `team_instances` row's
  directory entry; this spec reaches the same outcome by joining
  `memberships ⋈ team_instances` at render, which puts no second writer into
  `directory`. Same outcome, different mechanism, and the doctrine does not
  know about it.
- **Docs' re-review** of §9 after this revision — **calls 7 and 8 included** — 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.
- ~~`design-activity-pull`'s own review pass.~~ **Run 2026-09-05: four
  passes by Fable, the fourth approving.** What remains open there is design,
  not review: the per-instance deadline, the fan-out budget's number, the
  body cap, the refresh TTL, the hard maximum cache age, the cache bounds,
  the minimum pull interval, whether the two origins are one process, and
  whether swap and core dumps are off, whether `actor` is worth having,
  and
  the front door's layout.
- **`design-activity-pull` §11's last row cites "(§2.2, §0)"** for "an
  operator view of anyone's feed". After the 2026-09-05 amendment §0 no
  longer forbids that — §2.2's first bullet does. **And its Scope line says
  "without the home learning or holding any of it"**, which §0's gloss now
  says outright is false: the home does learn, and holds bounded in RAM.
  (Its §5.4 is safe — "learns nothing *durable*".) Two one-word fixes in
  that file, recorded here so they are not lost. **Third: no gate there
  (11-20) exercises eviction on the entry-count or bytes-per-person bounds**
  — gate 20 tests the age bound only, and the entry count is the one that
  caps the cross-person set.
- ~~Class or attribute spelling — §11 needs a re-ruling~~ — **closed by
  ruling 20**, 2026-09-05: the app writes attributes, and the seven
  unpaired utilities keep `class`.
- ~~A real suite domain — triggers §5.7~~ — closed by ruling 16. §5.7 is
  titled for the case it does not apply to: the `oficina.works` move needs
  no RP rename drill.
