oficina / home Public
π€ 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, anddesign-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 302s 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
membershipsrow 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.roleis 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_aton a thirty-day session would work) but freshness:sessions.Freshcomparesauth_timeagainst 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/signindirectly for classification and the Keymail upgrade β the part that is hard to get right and holds nothing. Corrected 2026-09-05: an earlier draft saidrastrillo/auth, which cannot serve that purpose. Its classification is reachable only throughauth.Begin, andBeginwritesauth_links.address TEXT NOT NULLβ the very thing ruling 14 exists to avoid. The seam is one level down:signin.Flowtakes aLinkStorethe app supplies, so the app seals inPutLinkand opens inTakeLink, 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 insideacme.docs, by whoeveracme.docsconsiders 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 inteam_instancesfor this team? β joiningconsumers.originto 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
kindshown, since a guest row inacme.docsis 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_deleteon.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.worksresolves 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.docsandacme.sheetswere 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 302s 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
- 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. - 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.
- 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). - 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.docssees noacme.sheetstile; 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 inacme.sheetsdoes see theacme.sheetstile. - Origin separation β no account-authored string reachable on the credential origin. Β§4 made mechanical.
- 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.SignInRecoveryis 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. - Contrast test copying
ui/contrast_test.go's arithmetic verbatim. - Restore drill β a real restore from replication, before launch, against a written RTO.
- 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).
- 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 onAuthorizationis not something to rely on), the personalised/redirect included (ruling 22). It exists for the edge-cache reason Β§11 sets out. - 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-pullowns 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
- Skeleton,
AGENTS.md, gates 1, 2 and 9 - Hand-rolled sign-in (Β§5.1), second factor, recovery codes
- Assertion minting, the redirect protocol, and gate 3
- Directory: calls 2 and 3, blind index, person refs β and the roster (ruling 23): the
membershipstable, 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 - Signup, queue, rate limits, name reservation
- The operator drain
- Preferences, the assertion claim, call 6
- Rekey journal (call 5), epoch, decommission
- Restore drill and the RTO
- Activity on the front door (ruling 18,
design-activity-pull). Per team, following?team=β RULED 2026-09-05 by Paul.design-activity-pullwas 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
subthis home asserted, and Β§5.4 already calls a hostilesubaccount 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_instancesrow's directory entry; this spec reaches the same outcome by joiningmemberships β team_instancesat render, which puts no second writer intodirectory. 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 β theauthsubject 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, whetheractoris 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.worksmove needs no RP rename drill.