oficina / home Public

Adopt the suite doctrine: membership becomes a record of the home's own

Appendix A.1 of the suite sharing doctrine (oficina/suite, revision 4,
commit 210312f). The largest of the four app amendments, because the
doctrine's ruling 12 gives this spec a table it did not have.

Numbered ruling 23, not 22. The doctrine's appendix said "add ruling 22" and
22 is taken by the team-selection ruling; a review of the doctrine caught
the collision before it landed.

The roster gets its own record. The doctrine's first revision proposed
deriving it from call-2 directory rows, and that was wrong three ways, all
checkable against this spec: §6's row has no owner role, since kind is
member or guest; §7 said outright that the home never learns who an owner
is; the row is scoped per (team, app) where a roster is per team, so a
projection could express "a member of acme.docs but not acme.sheets", which
the doctrine's ruling 1 forbids. And any instance can write a row under its
own consumer token, so reading team standing from one would let a
compromised instance announce a member straight around §14's owner step-up.
Changing the reader without changing the writer is not a design.

So: a memberships table, (team, person_ref, role), written only at the
directory origin by an owner behind step-up, or seeded by the drain with the
team's first owner from the sealed queue payload §7 already carries — the
circularity of gating a membership write on an owner has to break outside
the roster. Call 8 reads it; call 2 cannot write it. Guests are not on it
and nothing about guests changes.

§7 is amended rather than contradicted. The home now learns who owns a
*team*, because the record carries a role. Who owns an *item* is still the
instance's business and the home never asks.

§0 costs a sentence. Publishing the database now identifies each team's
owners, which the old directory row could not express. The doctrine assumed
the roster added no column class; it was wrong, and suite gate 8 tests the
corrected sentence against the persisted schema rather than a fixture.

§2.2's "never federates — data does not cross an instance boundary" becomes
"never crosses a team". The word instance carried a process assumption
nobody had examined. "Never holds an authorization model" becomes "never
holds a per-item grant", because the home does now hold the roster, and the
line is between a fact about its own records and a decision belonging to the
app of record.

§5.4 gains one narrow exception. The roster epoch rides the assertion and
fails the closed-enumerated-set half of the test as written. It is
admissible on a different argument, which is now stated rather than assumed:
the instance decides nothing from it — an unparseable claim is ignored, a
differing one buys exactly one call-8 refetch, and call 8's answer is what
gets enforced. A hostile claim costs one request and can neither admit nor
exclude anybody.

§10 corrects "losing this database loses credentials, not access". True of
the directory, false of the roster: call 2 cannot rebuild it and no channel
reaches a hibernating instance. Recovery is the instances' copies plus an
owner's confirmation. And a restore mints a new roster generation, because
refusing a lower epoch alone is not enough — a restored home reissues the
same numbers and two writes later an instance accepts an epoch it thinks is
fresh, carrying members that were removed.

New §13a lists the consumed rulings and writes out what the doctrine makes
this spec concede: a compromised credential origin can sign in as anyone,
which is not new and ruling 23 did not cause it, but §2's careful sentence
about a compromised *directory* does not cover it and should not be read as
if it did. The Pegamento filing is reshaped too — the divergence is not
about the roster, it is that this home was never a §10.3 glue.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 1cdc9d4001b5c8bd04fbf4170935c58152d55bb4 parent 2b68301
1 file changed, +135 −12
  • docs/superpowers/specs/2026-08-30-home-design.md +135 −12

CI passed — run details

diff --git a/docs/superpowers/specs/2026-08-30-home-design.md b/docs/superpowers/specs/2026-08-30-home-design.md
index cec6520..df4131f 100644
--- a/docs/superpowers/specs/2026-08-30-home-design.md
+++ b/docs/superpowers/specs/2026-08-30-home-design.md
@@ -80,10 +80,18 @@ 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, and that some person
-> belongs to a particular set of them.** No document. No cell. No readable
+> 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
@@ -120,6 +128,7 @@ blind, replaceable*.
| 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"** — §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 |
+| 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. | Deriving the roster from call-2 directory rows, which the doctrine's own first revision proposed: 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 |
| 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) |
---
@@ -217,10 +226,21 @@ Each is something somebody will propose in good faith.
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 an authorization model.** It knows you can reach a team in
- an app; what you may *do* is the instance's business (§5.5). Ruling 15
- exists because the per-team 2FA floor would have breached this.
-- **Never federates.** Data does not cross an instance boundary.
+- **Never 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 exists because the per-team 2FA floor would have
+ breached this.
+
+ **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
@@ -516,6 +536,17 @@ Two-sided, and the first draft wrote only the half it needed for theme
> (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.** 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.
@@ -601,7 +632,15 @@ 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)`. `kind` is member or guest, because a row implying an org
+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
+§14's owner step-up. 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.
@@ -704,9 +743,13 @@ handed, not whether there is 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. Who counts as an owner is the instance's business,
- decided from its own grants — §2.2's second never-do working exactly as
- intended, and the home never learns the answer.
+ 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 beside it as a **hint** (ruling
@@ -804,6 +847,8 @@ every assertion, and one that fails §5.4 is not a preference.
| 4 | Provisioning enqueue | person → home | Rate-limited, depth-capped, human-drained. |
| 5 | Rekey journal pull | instance → home | **Scoped to this consumer**, keyed on person refs. |
| 6 | Preference write-through | person → home | Redirect POST to the directory origin (§8). |
+| 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**: 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,
@@ -909,8 +954,34 @@ ceremony.
Migrations additive-only.
-**Losing this database loses credentials, not access.** The directory
-rebuilds as instances re-upsert; passkeys do not. *A backup you have never
+**`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 (§14), or seeded by the drain
+with the team's first owner from 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. 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
+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.
---
@@ -1201,6 +1272,58 @@ 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 4, commit `210312f`**. 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. |
+
+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 |