oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– The Oficina home β€” design
2
3**Status:** design, revised **2026-09-05** (rulings 17-23, and five claims
4corrected). Originally 2026-08-30. Drafted by the Sheets
5session with Docs (`oficina/docs`) as a declared consumer.
6**Review passes:** consumer review by docs-e6 (5 findings, all applied);
7review dispatched to **Fable** (16 confirmed, 6 suspected β€” verdict "not
8ready", now addressed). The 2026-09-05 revision was reviewed by **Fable**,
9which returned 11 findings β€” three false claims in this file among them β€”
10all applied. **Ruling 23 and the suite amendment reviewed by Fable 5.1 across five
11passes** β€” seven blocking findings, then six, then seven, then one, the
12fifth approving; two of the corrections were themselves wrong and a third
13ground for ruling 15 failed before the fourth held.
14**The 2026-09-05 Β§0/Β§2.2 amendment reviewed by Fable 5.1** β€” three passes β€”
15the first CHANGES NEEDED with three blocking, the second with five, the
16third approving. **Ruling 22
17reviewed by Fable across four passes
18(2026-09-05) β€” three returning CHANGES NEEDED, the fourth approving; the
19`?team=` decision took a second opinion from GPT-6 Astra via Codex.**
20Source claims re-verified by this session before acting.
21**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.
22**Scope:** the suite's home β€” identity, directory, provisioning, person
23preferences. Consumed by Sheets and Docs. Not an application; holds no
24documents.
25
26---
27
28## 0. The one rule
29
30> **The home is glue: thin, blind, replaceable. It knows that you exist and
31> where your teams are. It never stores a readable address, and it never
32> retains what is in anything.**
33
34**"Retains", amended from "learns" on 2026-09-05** (ruling 18,
35`design-activity-pull`, approved that day). The falsifiable form below is
36**unchanged by that amendment** β€” ruling 23 later did change it, and says so β€” that amendment put nothing new at rest.
37
38**"Retains" means holds without a bound.** That definition is doing real
39work and a first draft of this gloss got it wrong three times over, so it is
40stated once, plainly:
41
42- The home **does** learn what is in things. It handles titles on every
43 front-door render, and `design-activity-pull`'s Β§7 keeps them in memory
44 across requests, for every person who rendered inside the window. A rule
45 claiming the home never learns them would be false the first time the
46 feature ran.
47- What it never does is hold them **unbounded**. **A bound is a written
48 number, with a reason, sized to the render and not to retention** β€” and
49 there are three of them, not one: an **age** (`design-activity-pull` Β§7's
50 refresh TTL and hard maximum), a **maximum number of person entries**, and
51 a **maximum bytes per person** (that design's Β§2.1, evicting oldest
52 first). The entry count is the one that caps the cross-person set Β§2
53 worries about; the age alone does not. Said this precisely because "without
54 a bound" is otherwise satisfied by any finite number, and a thirty-day
55 maximum over ten thousand people is exactly the in-process title index this
56 gloss exists to forbid. At rest is the case **where the bound is not the
57 home's own to enforce** β€” a file outlives the process, and Litestream ships it β€” which is why
58 the falsifiable form below is the test that matters. (It is unchanged by
59 *this* amendment; ruling 23 changed it, and Β§0 says how.) **Two
60 platform facts sit behind that line and are not yet established** β€” whether
61 swap is configured and whether core dumps are off (Β§15). A swapped or
62 dumped page of titles is at rest, so the boundary is only as good as those
63 answers. The
64 RAM hold is the bounded case: Β§7's refresh TTL and hard maximum are the
65 bound, and `design-activity-pull`'s gate 20 tests it by advancing past the
66 maximum
67 through a *failed* pull, which is the path where a bound is easiest to
68 lose.
69- So the rule **does** reach the cache, and reaches it by requiring the
70 bound. An earlier draft said "at rest, and only at rest", which would have
71 left Β§0 permitting an in-process title index for everyone, held until
72 restart, with nothing in the one rule to say no.
73
74**"Stores" was proposed in review and rejected β€” RULED 2026-09-05 by Paul.**
75The case for it was good: Β§3.2 and Β§3.4 delimit it, Β§2.2 now uses it, and it would
76have made the sentence parallel. **"Retains" is kept because it says the
77right thing about time.** "Stores" reads as a place β€” *put somewhere* β€” and
78the risk here is duration. That choice is what makes the definition above
79"without a bound" rather than "at rest": the word was kept precisely so the
80rule would reach a title held in RAM across requests, and a definition that
81excluded the RAM hold would have thrown away the reason for keeping it.
82
83**"Blind" in the rule above is Factor XI's category label**, not a claim that
84the home never sees a title. It sees them; it keeps none.
85
86The falsifiable form β€” the family's own *publish the database and see*:
87
88> Publish the home's database in full and the worst anyone learns is **how
89> many people exist, what their teams are called, which people belong to
90> which team, and which of them own it.** No document. No cell. No readable
91> address.
92
93**"And which of them own it" is new, and it is a real cost.** Ruling 23
94gives the home a membership record with a role, so publishing the database
95now identifies every team's owners β€” a class of information the old
96directory row could not express, because its `kind` was only member or
97guest. Suite gate 8 tests this sentence against the persisted schema rather
98than a fixture. The suite doctrine's revision 1 assumed the roster added no
99column class; that was wrong, and this is the correction.
100
101**"Readable" is doing real work in that sentence**, and the first draft's
102absolute phrasing ("never stores your address") was false β€” see Β§3.2. A
103magic link must survive a restart between send and click, so an address is
104at rest *somewhere*; ours is sealed, enumerated, and short-lived. Saying
105"never" would have been a claim the schema could not keep.
106
107Factor XI names the category β€” *centralised infrastructure is glue: thin,
108blind, replaceable*.
109
110---
111
112## 1. Settled decisions (don't relitigate casually)
113
114| # | Ruling | Rejected | Who, when |
115|---|---|---|---|
116| 1 | **One RP: the home's credential origin. Instances never enroll passkeys** | Per-instance passkeys (three teams = three credentials) | Paul, 2026-08-30 |
117| 2 | **Split origins: credentials and directory on different hostnames** | One origin, mitigated with strict rendering + CSP | This session, 2026-08-30 |
118| 3 | **Directory is a blind index: `HMAC(pepper, lowercased address)`** | Readable addresses; a sealed per-person vault blob | This session, 2026-08-30 |
119| 4 | **The home hashes; instances never hold the pepper** | Instances hashing (a suite-wide secret on every instance) | This session, 2026-08-30 |
120| 5 | **Instances store readable addresses; the home never does** | Neither stores (kills notification); the home stores (aggregation risk) | Paul, 2026-08-30 |
121| 6 | **Recovery codes** | Operator reset; both | Paul, 2026-08-30 |
122| 7 | **Open signup; the operator drain is the abuse gate** | Invite-only; require Keymail; payment up front | Paul, 2026-08-30 |
123| 8 | **Provisioning queues; an operator drains** | A standing console token in a web-facing app | Paul, 2026-08-30 |
124| 9 | **The directory is a hint. Sign-in is an authority, and says so** | Treating both alike | This session, 2026-08-30 |
125| 10 | **Theme is per person, suite-level, resolved before first paint** | Per document; per team; follow-OS only | Paul, 2026-08-30 |
126| 11 | **One shared instance β€” and this is not a divergence** β€” Β§2 | Instance-per-person; calling it a divergence | This session, 2026-08-30 |
127| 12 | **Sealed at rest, DEK from platform secrets** | Plaintext (a plaintext column is a plaintext bucket leak) | This session, 2026-08-30 |
128| 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 |
129| 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 |
130| 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 |
131| 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 |
132| 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 |
133| 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 |
134| 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 |
135| 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 |
136| 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 |
137| 22 | **The team selection is navigation state, not session state: `?team=<sqid>` on every team view, with `/` a generic entry that picks a team and `302`s to the explicit URL** β€” Β§11 | A session cookie (history does not restore one, so Back has no correct behaviour; and a shared cookie couples every same-origin team-dependent request across tabs, not just New); a URL fragment read by CSS `:target` (the server never learns the team, so nothing server-rendered can be team-scoped β€” not the grid, not the switcher's `aria-current`, not gate 4's 404); a `prefs` row (Β§14) | Paul, 2026-09-05, after a second opinion from **GPT-6 Astra via Codex** (its default; `gpt-5.3-codex` and `gpt-5.3-sol` were both refused by the account, so the model is named from Codex's own rollout record rather than from what was asked for) |
138| 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 |
139| 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. |
140
141---
142
143## 2. What the home is
144
145One shared instance, not one per account β€” **and this is not a divergence**
146(ruling 11; proposed as doctrine at `carlos/pegamento` #1).
147
148**The instance boundary rule governs content, and the home holds none at
149rest.** **Re-examined 2026-09-05**, because the amendment gave the boundary
150rule something to bite on: the activity cache is a cross-person set of recent
151titles in the one process that also mints sessions. Ruling 11 still holds β€”
152the safety property was never process isolation but the leak profile and the
153re-check β€” and the cache is bounded, per-person-keyed and never at rest. But
154it is no longer true that there is *nothing* for the rule to bite on, and
155`carlos/pegamento` #1 should see this rather than the original phrasing.
156Rastrillo's *"a CARLOS app serves one team… isolation by process and file"*
157and Pegamento Β§1's four boundaries are all boundaries *around content*. A
158registry has nothing for the rule to bite on. Factor XI says it outright,
159with "centralised" in the sentence. Pegamento already assumes it: Β§4
160observes Eleven's `home/` and Kass's `internal/store/` are the same service
161built twice, and neither is instance-per-person.
162
163Sharing is also what makes the service possible: an instance-per-person home
164could not answer "which teams does this person belong to" without becoming
165the federation story Β§3 refuses.
166
167**Blast radius is bounded by what a service holds, not by how many processes
168it runs in.** This draft originally reached for process isolation as the
169safety property; for a service holding no content **at rest** the safety
170properties are the leak profile (Β§3) and the authorization re-check (Β§6).
171
172**What sharing costs**, with the first two structural rather than
173aspirational:
174
175- **Split origins mean a directory compromise reaches no credentials** (Β§4),
176 and **every instance re-checks authorization on every request** (Β§6), so a
177 compromised directory grants nothing **of an instance's own**. **Ruling 23
178 changed the rest of this sentence and it is amended rather than
179 annotated.** A compromised **directory origin** can write the roster, and a
180 member reads everything team-visible in every instance of that team β€” so it
181 costs the graph, a convenience outage, **and team-visible content across a
182 team's apps for the duration of the compromise β€” **plus one roster TTL for
183 any forged call-8 response, plus, for any `memberships` row it actually
184 wrote, until an owner finds and removes it.** The bound is a floor, not a
185 ceiling. An earlier pass of this branch wrote "until an owner notices" and
186 a later one deleted it as contradicted by Β§13a; both were wrong. Β§13a's
187 "what honest code leaves behind" means a row is not evidence a compromise
188 is *obliged* to leave β€” not that a written row does not persist. A written
189 row persists exactly until somebody removes it. It still costs no *per-item* grant, which
190 is the thing the home does not hold. **The credential origin is a
191 different and worse tier** β€” Β§13a: it "can sign in as anyone", and so reads
192 individually granted items too β€” and this bullet is about the directory,
193 which is the distinction the doctrine's ruling 13 exists to keep.
194- **It holds no content *at rest*.** The worst case in a published database
195 is Β§3's leak list, never anyone's work. **Since the 2026-09-05 amendment a
196 live compromise is larger**: the activity cache holds recent titles in RAM
197 for every person who rendered inside its TTL, and minting-for-anyone β€” which
198 was always true β€” now also buys any person's recent titles through call
199 7. `design-activity-pull` Β§5.1 argues that is inside the
200 hole Β§3.1's live-host model already concedes β€” it is, and it is bigger than
201 it was, so it is said here rather than left to the other document.
202- **Losing it costs a convenience, not access** β€” for the directory. Sign-in
203 is one exception and Β§5 says so rather than pretending otherwise; Β§2.1
204 lists what shrinks it. **The roster is a second, added by ruling 23**: past
205 its TTL, team-rung access stops for everyone, and Β§10 records that it does
206 not rebuild itself.
207- **It stays small.** Every proposed feature is weighed against "does this
208 belong in an instance instead?" A home that grows features grows blast
209 radius.
210
211### 2.1 The single point of failure, and cheap mitigations
212
213Sign-in is an authority, so a home outage stops *new* sessions suite-wide.
214Existing 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
215changing the architecture:
216
217- **The home does not hibernate.** It is the one app on the estate where
218 cold-wake latency is a suite-wide outage. Stated because hibernation is
219 the platform default and would otherwise apply silently.
220- **A rehearsed cold-standby restore with a written RTO.** The drill exists
221 in Β§12; the target belongs here.
222- **Sliding session renewal at instances**, so long-lived teams rarely
223 intersect a short outage.
224- **A status page**, so an outage is legible rather than mysterious.
225
226### 2.2 What the home never does
227
228Each is something somebody will propose in good faith.
229
230- **Never *stores* content from an instance.** No previews at rest, no
231 thumbnails, no cell values, no titles in a database, a log, an error
232 string, a metric label or an on-disk cache. It **may render**, transiently
233 and per request, **and only to that person**, what an instance hands it for
234 the person it hands it for. **"Per request" does not exclude
235 `design-activity-pull` Β§7's cache**, which holds pulled titles in RAM
236 across renders: Β§0's gloss defines *retains* as holds without a bound,
237 and that cache is bounded and tested. Read the two together.
238
239 **Amended 2026-09-05** from *never renders*, on ruling 18 and
240 `design-activity-pull`, which passed its review that day (four Fable
241 passes, the fourth approving). "Renders" was doing custody's work: the
242 clause was written to stop the home *becoming a place documents live*, and
243 storage is what makes it that. A title that exists for the length of one
244 response, or that design's RAM cache TTL, addressed to the one person
245 entitled to it, does not.
246
247 **It licenses nothing else**, recorded so it is not read wider than it is:
248 no search over pulled content, no cross-person aggregation, no analytics,
249 no operator view, no on-disk cache, no retry queue holding a response body.
250 And note what the amendment bought in exchange: "never stores" has a grep
251 behind it, where "never renders" had no test at all β€” which is a fair sign
252 it was a statement of intent rather than a mechanism.
253- **Never holds a per-item grant.** Which sheets you may edit, which
254 document a guest may comment on: all instance-side, decided there on every
255 request (Β§5.5).
256
257 **Ruling 15's ground has to move off Β§2.2 entirely, and two drafts of
258 this paragraph failed to.** It first read "ruling 15 exists because the
259 per-team 2FA floor would have breached this", against the old clause
260 *never holds an authorization model* β€” false under the new one, since a
261 2FA floor is not a per-item grant. It was then re-grounded on the floor
262 being "the instance's decision on its own request path", which is also
263 false: authentication is the home's (ruling 1, one RP), the team is the
264 home's (rulings 17 and 23), and the home knows `aud` β†’ team through
265 `team_instances` β€” so a per-team floor would be enforced at assertion
266 minting, on a resource the home owns, which this bullet's own line
267 *permits*. An instance demands nothing at authentication; it exchanges an
268 assertion.
269
270 **So ruling 15 stands on mechanics, not on Β§2.2 β€” and the mechanics are
271 not "per-team enrolment", which was a third wrong ground.** Enrolment is
272 per person at one RP (ruling 1), and per-team *compliance* is just
273 `memberships β‹ˆ credentials`, both of which now exist. What is
274 actually missing is the demand: **`passkey.Gate` asks for the ceremony
275 only when the account is already enrolled** β€” `Enrolled(sess.Subject)`, a
276 count over `passkey_credentials` β€” so an un-enrolled member of a floored
277 team returns `(false, nil)` and sails straight through the floor. A real
278 floor therefore needs two things slice one has neither of: **a per-team
279 policy record**, and **a policy-driven demand at assertion minting** β€”
280 step up if the session's `Method` lacks the factor, force enrolment if
281 there is none. That is the objection, and Β§5.2 and Β§14 carry it.
282
283 **It does hold the membership roster** (suite ruling 4, ruling 23). "Is
284 this person on this team" is a fact about the home's own records. "May
285 this person open this sheet" is not, and never becomes one. The line is
286 between a fact the home already owns and a decision that belongs to the
287 app of record.
288- **Never crosses a team.** Data does not cross a team label; a team's
289 instances across every app are one trust domain (suite ruling 1). This
290 said *instance boundary* until 2026-09-05, and the word carried a process
291 assumption nobody had examined β€” it was where the boundary happened to
292 fall while every app was one process.
293- **Never becomes a support console.** Reading someone's data to help them
294 is what **Β§2.2's first bullet** forbids β€” "only to that person", "no
295 operator view". **It used to be Β§0**, and the 2026-09-05 amendment is why
296 it no longer can be: "never *learns*" forbade an operator reading content,
297 and "never *retains*" does not β€” a console that displays without keeping
298 passes Β§0 and the falsifiable form both. The prohibition did not weaken,
299 but its anchor moved, and a reference left pointing at Β§0 would be a
300 prohibition resting on nothing.
301
302### 2.3 A recorded divergence: the directory is location plus graph
303
304Pegamento's registry *"answers who exists and how to verify them, never
305where they are"*, and Β§4 deliberately does not generalise the graph facet
306("one variant is not enough, least of all in the facet with the worst leak
307profile").
308
309Our directory is a person↔team↔host map: location **and** graph. Ruling 9
310aligns it with Woodstar's *"cache + queue, never authority"* β€” but the
311alignment is an argument, not a blessing, and the first draft claimed the
312doctrine "already sorts this". It does not. **Recorded as a divergence,
313bounded by the hint ruling**, and put to `carlos/pegamento` #1.
314
315---
316
317## 3. Trust model and the leak list
318
319Sealed at rest, DEK from platform config, HKDF purpose keys, AES-GCM
320(ruling 12) β€” the platform replicates the whole SQLite file, so a plaintext
321column is a plaintext bucket leak.
322
323### 3.1 Two threat models, stated separately
324
325The first draft collapsed these and called the result "a confirmation
326oracle, not a dump", which was honest for one and rationalising for the
327other.
328
329**Database leaked, pepper not:** counts, the bipartite graph, team names,
330hostnames, timestamps. No address recoverable.
331
332**Live host compromised, so the pepper leaks too:** the attacker runs the
333whole directory against breach corpora of billions of known addresses,
334offline. Email address space is exactly the guessable space a blind index is
335weakest against, so for most rows **this is a dump with names**, plus the
336graph and hostnames. Calling it an oracle would be a euphemism.
337
338**Mitigation, and its limit:** the index uses an expensive KDF rather than a
339bare HMAC, pricing the dictionary run rather than preventing it. It does not
340make the second model safe; it makes it expensive. Said plainly rather than
341counted as a fix.
342
343> **OPEN β€” for Paul, raised 2026-09-07.** The sentence above and **ruling 3**
344> contradict each other, and the code can only implement one. Ruling 3 says
345> `HMAC(pepper, lowercased address)`; Β§7's directory row and Β§7's rekey
346> reasoning say HMAC too. This paragraph is the only place an expensive KDF
347> appears. `internal/home/links.go`'s `blindIndex` implements **ruling 3**,
348> on the ground that a settled Β§1 row outranks a threat-model note β€” so as
349> written this paragraph is currently false about the code.
350>
351> It is a real tradeoff, not a typo. A dictionary run against a leaked
352> pepper is exactly the second threat model's attack, and an HMAC prices it
353> at nothing. Against that: the index is computed on the sign-in path and on
354> every call-2 directory upsert, so the cost is paid per request. That cost
355> is probably affordable β€” sign-in is already rate-limited at 20 per IP and
356> 5 per address per 15 minutes, and call 2 fires on grant change rather than
357> per request β€” which makes the case for the KDF stronger than it first
358> looks.
359>
360> Changing either sentence changes a Β§1 row, so it is Paul's ruling. Until
361> then the code follows ruling 3 and this note records the gap rather than
362> hiding it.
363
364### 3.2 What is at rest, precisely
365
366**Sealed:** team display names; provisioning queue payloads; the rekey
367journal's new addresses; **sign-in link state** (Β§5.1).
368
369**Plaintext, published in the README:**
370
371- Counts of people and teams.
372- Blind-index values (Β§3.1's caveat applies).
373- The bipartite graph: person ref ↔ team ↔ app ↔ `kind`.
374- **The membership roster: `(team, person_ref, role)`** (ruling 23), and the
375 roster's `(generation, epoch)`. **Plaintext**, necessarily β€” call 8 reads
376 it β€” and it is the new column class Β§0's cost sentence names. `role` is
377 what makes a published database identify each team's owners.
378- Team hostnames and sqids β€” these appear in URLs and were never secret.
379- Passkey credential ids and **public** keys.
380- Session rows keyed by **person ref**. Their timestamps are **exact at
381 rest** β€” expiry is compared against them, so they cannot be coarsened in
382 the table β€” and Β§3.3's coarsening is a **rule for publication**, applied
383 when the database is published, never a storage format. An earlier draft
384 listed them as "coarsened timestamps". What forces exactness is not expiry
385 alone (a day-granular `expires_at` on a thirty-day session would work) but
386 **freshness**: `sessions.Fresh` compares `auth_time` against a max age in
387 minutes, and Β§5.2's show-codes-once page depends on it.
388- Each person's theme.
389
390**Never at rest at all: pulled content** β€” titles, URLs, actors. Held in
391RAM only, bounded per `design-activity-pull` Β§7 and Β§2.1, since Β§2.2's
3922026-09-05 amendment.
393
394**Never at rest readable: an email address.** Sealed and short-lived in link
395state and the journal; hashed in the index; transient in RAM elsewhere.
396
397**Deleting a row is not erasing bytes.** SQLite does not zero deleted pages
398and Litestream ships WAL frames continuously, so a *transiently written*
399readable address reaches the bucket before any delete. `PRAGMA
400secure_delete` is on, and anything holding an address is sealed before it is
401written rather than cleaned up afterwards. This is why Β§5.1 exists.
402
403### 3.3 The graph is less anonymous than "unidentified" suggests
404
405Hostnames approximate company names, so each person carries a team-set
406fingerprint. One outside fact β€” "alice consults for Acme and BigCo" β€”
407identifies the row and reveals the rest of their memberships, guest rows
408included. Timestamps correlate with events observable at instances.
409
410So: published timestamps are **coarsened to the day**, and Β§0 stopped saying
411"some unidentified person" long ago.
412
413**Ruling 23 makes this argument weaker than it reads, and that is said here
414rather than left to Β§0's one-line cost.** The fingerprint above needs
415*several* teams plus an outside fact. An **owner row needs one team and a
416fact that is usually public** β€” who owns Acme is on Acme's website. So
417`(team, person_ref, role=owner)` names a person ref outright, with no
418multi-team fingerprint required, and naming it then reveals every other
419membership and guest row that ref holds. **Naming owners is materially more
420identifying than "belongs to a set of teams"**, and it is the class Β§0's new
421sentence is about. **Said plainly rather than
422counted as a fix: coarsening protects a deliberate publication and nothing
423else.** Β§3.1's leak models ship the raw database file, whose session rows
424carry exact timestamps (Β§3.2), so the timestamp-correlation fingerprint is
425**unmitigated in a leak**. There is no export verb in this design that
426coarsens; if one is ever wanted, that is where the rule would attach.
427Sealing team display names
428buys little while hostnames are plaintext; it is kept because it is free,
429and it is not counted as protection.
430
431### 3.4 Logs and error text are storage
432
433The home handles readable addresses transiently on every sign-in, upsert,
434rekey and welcome mail. One error string β€” *"failed to hash
435alice@example.com"* β€” ships an address to platform-held `carlos logs`,
436storage we cannot grep.
437
438**Rule: no address, team name, queue payload, or pulled content β€” a title,
439a URL, an actor β€” in any log line, error string, job status message or
440metric label.** (Pulled content added 2026-09-05 with Β§2.2's amendment: a
441log line is at rest, so *never stores* reaches it, and a failed pull is
442exactly where a title would otherwise land in an error string.) Errors
443carry person refs and
444codes. Test-enforced (Β§12 gate 9). Outbound mail necessarily carries
445addresses through platform SMTP; that is inherent and disclosed. Log lines
446are not.
447
448---
449
450## 4. Two origins
451
452The 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.
453
454A team name is account-authored, so a home rendering names on its RP origin
455is what `decisions.md` warns against. Tito's answer: *credentials only ever
456on the root origin, account-authored content only on the account's own host.*
457
458- **Credential origin** β€” sign-in, WebAuthn, recovery, assertion minting.
459 **No account-authored string, ever.** This is the RP.
460- **Directory origin** β€” a different hostname. Renders team names, holds no
461 credentials.
462
463The directory obtains its session by exchanging an assertion, the same
464mechanism instances use (Β§5.3), so there is one assertion path rather than
465two. An XSS on the directory cannot reach the RP.
466
467**The hostnames (ruling 16, 2026-09-03).** The suite's primary domain is
468**`oficina.works`**, registered and ours. It replaces the provisional
469platform-default addresses `<app>.<sqid>.oncarlos.com`, where `bds` was a
470Carloku *account* sqid β€” a hosting artifact that earns nothing on a domain
471we own.
472
473- **`id.oficina.works`** β€” the credential origin. This is the RP.
474- **`oficina.works`** β€” the directory, at the apex. A different hostname
475 from the RP, which is what this section requires.
476- **`<team>.<app>.oficina.works`** β€” one team, one instance, one hostname:
477 `acme.docs.oficina.works`, `acme.sheets.oficina.works`. This is the team
478 slot the provisional shape never had, and Β§7's "team name and hostname
479 reserved at enqueue" needs somewhere to reserve them.
480
481Hosting stays on Carloku; `oficina.works` is a custom domain in front of it,
482with a wildcard certificate per app zone (`*.docs.oficina.works`).
483
484**Never widen the RP ID to a registrable suffix** β€” and note that the reason
485changed with the domain rather than disappearing. WebAuthn permits an RP ID
486that is a suffix of the origin's hostname, so `oficina.works` would be
487*accepted* for `id.oficina.works`. On `oncarlos.com` the objection was other
488Carloku tenants. On a zone we own it is that `oficina.works` as an RP ID is
489valid at *every* host in the zone β€” the directory and each team instance
490included β€” which collapses the split this very section exists to make
491(ruling 2). **The RP ID is the full host**, asserted by a test.
492
493---
494
495## 5. Identity
496
497### 5.1 Sign-in is hand-rolled, and why β€” RULED 2026-08-30 by Paul
498
499**`rastrillo/auth` as shipped cannot host a blind identity service.**
500Verified at `origin/main` 2026-08-30 and **re-verified at the v0.22.0 pin
5012026-09-05**: `auth_links.address TEXT NOT NULL`, and `admit()` sets
502`Subject: id.Address`. (The 2026-08-30 draft also cited
503`auth_sessions.address TEXT NOT NULL`. At v0.22.0 that table is no longer
504written β€” `0002_sessions_backfill.sql` moved its rows into the shared
505`sessions` table β€” so the readable address now lands in `sessions.subject`.
506The leak moved; it did not close, and the ruling stands on `auth_links` and
507the Subject either way.) β€” which `passkey_credentials`, `passkey_challenges`,
508`passkey_pending` and `passkey_recovery_codes` all inherit. `auth.Config`
509has no subject-remapping hook, so it cannot be configured away.
510
511The first draft cited "Kass's pattern" as though the plugin implemented it.
512It does not. That was the review's highest-cost finding, because the
513tempting week-2 fix is adding the auth tables to `plaintextExempt` β€” turning
514a guarantee into a comment.
515
516**So (ruling 14):**
517
518- **The Subject is an opaque person ref**, never an address. Sessions,
519 credentials and recovery codes all key on it.
520- **Link state is sealed before it is written** and short-lived. A magic
521 link must survive a restart between send and click, so the address is at
522 rest β€” sealed, enumerated in Β§3.2, never readable.
523- **`PRAGMA secure_delete`**, because deleting a row does not remove bytes
524 from a file that is continuously replicated.
525- We use **`keymaildev/signin` directly** for classification and the Keymail
526 upgrade β€” the part that is hard to get right and holds nothing.
527 **Corrected 2026-09-05:** an earlier draft said `rastrillo/auth`, which
528 cannot serve that purpose. Its classification is reachable only through
529 `auth.Begin`, and `Begin` writes `auth_links.address TEXT NOT NULL` β€” the
530 very thing ruling 14 exists to avoid. The seam is one level down:
531 `signin.Flow` takes a `LinkStore` the app supplies, so the app seals in
532 `PutLink` and opens in `TakeLink`, and classification, the Keymail round
533 trip and the rate limits all come with it.
534
535**Filed upstream as `rastrillo/rastrillo` #2**: a `Config.Subject` hook, a
536sealed link store, and written `secure_delete`/WAL guidance. Every
537server-blind app on rastrillo hits this on day one and will otherwise
538hand-roll it differently. Adopt it the day it lands and delete our version.
539
540Magic link, auto-upgrading to Keymail when the address has a claimed inbox;
541classification fails open, so every address has a working path β€” which
542matters more here than in a normal app, because guests arrive from outside
543the suite entirely. No passwords.
544
545### 5.2 Second factor and recovery
546
547Passkey via `passkey.Handlers.Gate`, **optional per person**.
548
549**Per-team enforcement is not in slice one** (ruling 15). The first draft
550promised it and neither spec could build it: there was no per-team policy record, `Gate` is strictly per-account
551(`Enrolled(sess.Subject)`) and asks nothing of an un-enrolled account, and
552every workaround breached something β€” an
553`amr` claim on the assertion fails Β§5.4's boundary test. It moves to Β§14
554with a trigger, rather than being discovered by the first paying team that
555flips the switch.
556
557**Recovery is recovery codes** (ruling 6): ten, shown once, redeemed as a
558plain form POST with no JS. Sign-in only β€” step-up still demands a real
559assertion.
560
561**No operator reset in slice one**, recorded so the absence is a decision:
562it needs an identity-verification process and a person to run it, and we
563have neither. **Trigger:** the first real person locked out with passkey and
564codes both lost.
565
566**Losing everything is disclosed, not discovered:** losing email access
567*and* passkey *and* codes loses your sign-in. It does not lose your team's
568data β€” other owners still reach the instance, which is the authority **on
569items**; team standing's authority is the roster now. **A sole owner locked out of all three β€” passkey, codes and their mailbox β€”
570leaves a team whose roster nobody can write**, and the only exit is Β§10's
571seed verb, an operator reset in all but name, so Β§14's operator-reset
572trigger fires earlier than it reads. (With mailbox access the magic link
573still signs them in, as this section's first sentence says.)
574
575### 5.3 The assertion, fully specified
576
577The first draft specified signature, audience, expiry and jti β€” and never
578said **what identifies the person**, which is the most load-bearing claim in
579the contract. Two consumers were about to guess.
580
581**Claims:**
582
583| Claim | Value |
584|---|---|
585| `sub` | The **person ref** β€” the home's opaque identifier, stable across email change. Never an address. |
586| `aud` | The exact canonical HTTPS consumer origin, identifying the app and team instance. The directory uses its own exact origin. |
587| `iss` | The credential origin. |
588| `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. |
589| `iat` / `exp` | See expiry below. |
590| `jti` | Single use, per verifier. |
591| `v` | Wire version1. |
592| `nonce` | The consumer's browser-bound request nonce, independent from jti. |
593| `auth_time` | Original credential authentication time; never refreshed by the handoff. |
594| `ext` | Optional cosmetic object. The initial Home transport omits it; later theme delivery remains subject to Β§5.4. |
595| 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. |
596
597**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.
598
599**Expiry is 120 seconds, not "seconds"** β€” sized to worst-case cold wake
600plus clock skew. The exchange request *is* what wakes a hibernating
601instance, and process start plus decrypt plus migrate-check can exceed a
602few seconds, so a tighter window makes the **first sign-in to any cold
603instance fail and loop** β€” flaky, timing-dependent, and miserable to debug.
604Widening it costs nothing against the threat model, because **single-use
605`jti` is what stops replay**, not brevity.
606
607**Every verifier holds its own self-pruning `jti` store**, sized by
608assertion lifetime rather than by user count (docs-e6, 2026-08-30). Audience
609binding makes local stores sufficient β€” an assertion for team A is not
610valid at team B, so B never needs to know A consumed it. Β§10's
611`assertion_jti` table is **the directory origin's own** store, not a shared
612one.
613
614**Delivery is an auto-POST, not a query string**, so assertions stay out of
615browser history and referrers. (An earlier draft also said "edge logs". No
616platform request log carrying paths was found when this was checked on
6172026-09-05 β€” the edge records per-host counts and bytes, not URLs. The
618history and referrer reasons stand on their own and are sufficient; the
619third was inherited rather than verified.)
620
621### 5.4 The boundary test for claims
622
623Two-sided, and the first draft wrote only the half it needed for theme
624(docs-e6, 2026-08-30):
625
626> A claim may ride the assertion only if **(a)** the instance makes no trust
627> decision based on it, validating against a **closed enumerated set** and
628> treating a failed match as *absent* rather than as the raw value; **and
629> (b)** the home already holds it for its own purposes, so carrying it adds
630> nothing to what the home knows.
631
632**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
633epoch (suite Β§5.3) rides the assertion and fails (a) as written β€” a
634monotonically rising integer is not a closed enumerated set and does not
635fail cosmetically. It is admissible on a different argument, and the
636argument has to be stated rather than assumed: **the instance decides
637nothing from it.** A claim it cannot parse is ignored. A claim that differs
638from the epoch it holds buys exactly one call-8 refetch, and call 8's answer
639is what it enforces. So a hostile claim costs one request and can neither
640admit nor exclude anybody. Nothing else may use this exception without the
641same property.
642
643Theme passes both. A role fails (a). "Which documents you can see" passes
644(a) and fails (b), handing the home a document-level graph it has no
645business holding.
646
647**The identity claims are exempt by construction, and the test must say so**
648(review, 2026-08-30): `sub` flagrantly fails (a) β€” a hostile `sub` is
649account takeover β€” yet it must ride the assertion. The rule is therefore:
650**signed identity claims are trusted for identity; every other claim must be
651cosmetically failable.** Without that sentence the test proves too much, and
652a future reader either discards it or argues an `amr` claim in under cover
653of the inconsistency.
654
655**Two things the first wording got wrong**, recorded so they are not
656reintroduced. "The instance would behave identically" is false even for
657theme β€” a hostile value gives an ugly page, which is different behaviour β€”
658and a test its own example fails is a gap someone smuggles through. And
659"hostile value" assumed the validation that makes it safe: a free-string
660theme interpolated into CSS is an injection, not an ugly page.
661
662### 5.5 What the instance does with it
663
664Verifies the signature by `kid`, checks `aud` is itself, consumes the `jti`
665locally, mints a session β€” and then **decides capability from two sources,
666since ruling 23**. **Per-item** capability comes entirely from its own
667grants, exactly as before: an assertion for a person with no grant is a
668valid assertion that gets nothing individually granted. **Team standing**
669comes from its copy of the roster (call 8, suite ruling 5), not from a
670grant β€” so a member with no per-item grant still reaches everything at the
671team rung, which is the point of a team. An earlier version of this
672paragraph said capability came *entirely* from the instance's own grants;
673that is the status quo ruling 23's Rejected column names as overturned, and
674Docs and Sheets build from this section.
675
676**The link-share ordering problem** (docs-e6, 2026-08-30). A link-share
677visitor who wants to comment signs in β€” and under the attribution rule,
678signing in is what *mints* their guest grant. So they return with a valid
679assertion and no grant: correct behaviour, wrong outcome.
680
681**Resolution: the capability never rides the assertion.** The instance
682begins sign-in with a **server-built return URL carrying a nonce** keying
683pending state it holds itself. On return it verifies the assertion, redeems
684its own pending state to mint the grant, then applies the session. The home
685learns nothing about links or capabilities, so Β§5.4 survives intact.
686
687This also handles the cross-device case, where the magic link completes in
688whichever browser opened the email: the nonce rides the URL, not a cookie.
689
690### 5.6 The redirect protocol
691
692Absent from the first draft, and the abuse suite did not test it.
693
694- **Return hosts are validated against the consumers allowlist.** Otherwise
695 the credential origin is an open redirector.
696- **A browser-bound state nonce** ties the returning assertion to the
697 browser that began the flow. Without it an attacker can fix *their own*
698 signed assertion onto a victim's browser β€” login CSRF, and the victim then
699 works inside the attacker's account.
700- **The return URL preserves its query string**, validated host and all.
701 Ruling 22 puts the team in `?team=<sqid>`, so a shared team link followed
702 while signed out otherwise returns the person to a bare `/` and silently
703 redirects them to a different team than the one they were sent. Β§11 states
704 this as a cost; it is a mechanism, and it lives here because a step-3
705 builder reads Β§5.6, not Β§11.
706- All three are in gate 3.
707
708### 5.7 The RP rename drill β€” not needed for the `oficina.works` move
709
710**Ruling 16 moved the domain before this service enrolled its first
711credential**, which is the whole reason the move cost nothing. At the time
712of the move `home` held no `passkey.Config` at all β€” sign-in across the
713suite was magic-link only β€” so no credential had ever been scoped to an
714`oncarlos.com` host and there was nothing to strand. The drill below is
715therefore documented rather than scheduled.
716
717Every *later* hostname move pays the full price described here, which is the
718argument for having taken this one early. **The mechanism
719exists:** `passkey.Config.LegacyRPID` is *"a relying party this instance
720used to be, accepted for assertions (never registration) so a hostname move
721doesn't strand enrolled keys"* β€” verified at source. Set it, run the window,
722drop it.
723
724**Size the window by the guest tail.** The person least likely to survive is
725the guest who signed in once, months ago, to comment on one document β€” no
726ongoing relationship, no reason to notice, invisible to any plan assuming
727people re-authenticate when nudged. **Months, not weeks.** A drill that
728quietly drops the guests is a drill that failed while reporting success.
729
730---
731
732## 6. The directory
733
734`HMAC(pepper, lowercased address) β†’ (person ref, app, team sqid, host, kind,
735sealed team name)`. **A row is a hint and never an authority** (ruling 9),
736and ruling 23 makes that load-bearing rather than merely true: **a directory
737row never grants or revokes team standing, whatever its `kind` says.** Call 2
738writes these rows under an instance's own consumer token, so if team standing
739were read from them, any instance could announce a member and walk around
740the owner step-up ruling 23 requires. Team standing comes from the membership record and
741nowhere else.
742
743`kind` is member or guest, because a row implying an org
744membership someone does not have is a lie the directory tells on the org's
745behalf.
746
747**The home hashes, not the instance** (ruling 4). One compromised instance
748holding the pepper would be an offline dictionary attack against the whole
749suite directory, inverting factor VIII.
750
751**Call 2 returns the person ref**, and instances store it against their
752member rows. Without this, call 5 is incoherent β€” an instance holds no
753HMACs and can compute none, so a journal keyed on hashes matches nothing it
754has (review, 2026-08-30). The person ref is also the assertion's `sub`, so
755one identifier links assertion to grant to journal.
756
757**Never-signed-in guests get a person stub.** Call 2 for an address with no
758person row mints one, keyed by the blind index; credentials attach at first
759sign-in.
760
761**The directory is a hint, never an authority** (ruling 9):
762
763- A failed upsert never fails a share. The instance is the authority.
764- Instances re-check authorization on every request. The directory answers
765 *"where might I have things"*, never *"may I have this"*.
766- The outbox **drains on wake and on each share mutation, not on a ticker** β€”
767 a hibernating instance runs no timers.
768
769**Pepper rotation uses an epoch, not a sweep.** The first draft called for a
770"re-announce sweep" as an operator verb β€” which has no channel to reach
771instances: **no call in this table pushes to an instance**, and instances
772hibernate. (One call runs home→instance — call 7's activity pull — but it
773fires per render, when a person is already looking at their front door, so
774it is not a channel the home can reach a sleeping fleet down. Call 8 is the
775instance calling the home.) A fleet-wide poke
776would in any case contradict the platform's zero-infrastructure-access
777contract. Instead: **every call-2 and call-5 response carries a pepper
778epoch**, and an instance whose stored epoch differs re-enumerates its grants
779on next wake. Rotation becomes a lag rather than an incident.
780
781**A guest grant discloses the granting team's name.** Kept β€” the alternative
782removes the reason the directory exists β€” but **disclosed in the sharing
783app's share dialog, in words**, not left emergent.
784
785---
786
787## 7. Provisioning
788
789Signup writes a request. An **operator command drains it** under a real
790console credential: creates the instance, delivers its bearer token and
791allowlist entry, sends the welcome mail, marks it live.
792
793**Why queued.** The platform has no machine credential for instance
794creation; console auth is a 90-day human terminal token, and `auth.go` names
795why an app holding one is wrong. Filed as `carlos/platform` #4.
796
797**The load-bearing safety fact: the home holds no provisioning credential.**
798The queue is opaque; the drain runs outside the home process under a human's
799credential. So the dangerous version of the conflation β€” an RCE on an
800internet-facing sign-in surface reaching instance creation β€” is **impossible
801by construction, not unlikely by policy.**
802
803*(The weaker argument, kept because true and demoted because contestable:
804Β§9's conflation concerns a blind service that also knows person↔instance,
805and this home already does. That is an argument about what the doctrine
806meant. Put to `carlos/pegamento` #1: is it the credential or the knowledge
807that must not be fused?)*
808
809**The queue holds readable addresses, and Β§3.2 discloses it.** The first
810draft claimed the enqueue "adds no knowledge the service lacks" β€” untrue:
811the directory holds hashes, the queue holds addresses. That is new custody.
812The argument leans on the disclosure, not on denying the delta.
813
814**The label is reserved suite-wide at enqueue** (ruling 19). Claiming `acme`
815takes it across every app, so only Acme can later create `acme.sheets`.
816Without that, a stranger squats the sibling hostname of a team you already
817have, on an apex that reads as one suite β€” which is impersonation whatever
818the model calls it.
819
820**Who decides that a later request really is Acme β€” and why the home never
821answers it.** The first claim of a free label is a decision about a resource
822alone: is this label taken? The *second* instance is the hard one, because
823"only Acme may create `acme.sheets`" needs a principal, and the home holding
824a 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)
825("*where might I have things*, never *may I have this*"). An earlier draft
826argued this away as "a name, not a document". **That argument is wrong and
827is recorded here so it is not made again:** Β§2.2 says *never holds an
828authorization model* β€” the clause as it stood before ruling 23 rewrote it β€”
829not *never holds a content authorization model*, and
830ruling 15 rejected a per-team 2FA floor. **(That example is withdrawn from
831this argument.** Ruling 15's own Rejected column was buildability, and Β§2.2
832now grounds it on mechanics β€” an un-enrolled member sails through `Gate`,
833and there is no per-team policy record. It was never a clean illustration of
834Β§2.2 and it is not one now.) So the distinction has to be sharper. Two
835paths, and the home answers only facts about its own records in both:
836
837**Both paths end in the queue**, and this is not a detail. Β§7's
838load-bearing safety fact is that **the home holds no provisioning
839credential** β€” so nothing the home can do creates `acme.sheets`, and ruling
8407's "a human sees every team before it exists" holds for siblings exactly as
841for first teams. What the two paths differ in is what the operator is
842handed, not whether there is one.
843
844- **Fast path β€” an existing instance vouches for its own team.** "Create
845 `acme.sheets`" is initiated from inside `acme.docs`, by whoever
846 `acme.docs` considers an owner, and reaches the home as an instance→home
847 call under that instance's consumer credential. The home checks one
848 thing: **is the calling origin already in `team_instances` for this
849 team?** β€” joining `consumers.origin` to the instance host the home itself
850 recorded at the drain. That answers the *label* question and enqueues the
851 request pre-settled. **Amended 2026-09-05 by ruling 23:** the home now
852 learns who owns a *team*, because the membership record carries a role and
853 an owner is who may write it. What stays the instance's business is who
854 owns an *item* β€” a sheet, a document, a calendar β€” decided from its own
855 grants, which is Β§2.2's second never-do working exactly as intended. The
856 home knows Ana owns the Acme team; it does not know she owns a single
857 document in it, and it never asks.
858- **Fallback β€” everyone else arrives unvouched.** The request is enqueued
859 the same way, and the human decides the label question too. The home may
860 render the requester's own directory rows **and their roster role, which is the stronger hint the home now owns,** beside it (ruling 9) β€” with `kind` shown, since a *guest* row in `acme.docs` is emphatically
861 not "is Acme". They are a hint and nothing else: the operator adjudicates.
862
863**Two things this does not smuggle in.** Consumer-level authorization is not
864new and is not Β§2.2's concern: Β§9 already scopes every call to its consumer,
865and a label on `oficina.works` is the home's own namespace. What Β§2.2
866forbade, before ruling 23, was any *person*-level entitlement, and neither
867path formed one. **Ruling 23 forms one deliberately** β€” the roster is a
868person-level entitlement the home holds and enforces β€” so the line moved
869rather than held: the home decides about **resources it owns** (a label, a
870team's roster) and never about **an instance's items**. Both paths here are
871still on the resources-it-owns side. And the
872new instance's initial admin and welcome address arrive as **sealed queue
873payload**, exactly as a fallback requester's do (Β§3.2) β€” the home carries
874that opaquely and never judges it.
875
876**A compromised `acme.docs` can vouch for `acme.*` siblings.** Bounded by
877the registry's `provisionable` set, one row per (team, app), the drain still
878seeing every request, and the sibling getting its own consumer token from
879that drain rather than inheriting one. Recorded rather than left to be
880discovered; Β§2's blast-radius argument survives it.
881
882So the home's whole vocabulary here is two facts: *is this label free*, and
883*is this calling origin already an instance of this team*. It never forms an
884opinion 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
885distinction ruling 19 rests on, and it is a narrower one than "names versus
886documents".
887
888**Two consequences, recorded rather than left to be discovered.** Β§14's
889auto-drain row now carries a third decision β€” auto-draining also means
890automating the fallback adjudication, and the machine credential does not
891supply the judgement. And **a queue entry that is purged or times out must
892release its label**, or a request that never became a team holds a name
893nobody can claim.
894
895**The drain is also the abuse gate** (ruling 7) β€” a human sees every team
896before it exists. So when the machine credential lands, **auto-drain is a
897policy decision, not an automatic consequence.**
898
899**Honest copy: "usually within a day", not "in minutes."** A human drain
900does not run at 3am on a Sunday. The first draft prided itself on not faking
901a spinner while writing a promise the mechanism cannot keep.
902
903**Queue mechanics**, none of which were specified:
904
905- **Address verified before enqueue**, or the queue mails strangers.
906- **Entry TTL with sealed-payload purge** β€” undrained spam otherwise holds
907 addresses indefinitely.
908- **Rate-limit keys stored hashed**; per-address limiting otherwise stores
909 address-derived plaintext, defeating Β§3.2 through the back door.
910- **A total queue-depth cap.** Address and IP limits are weak against
911 plus-addressing and CGNAT.
912- **Idempotent drain** β€” a crash between instance-create and token-delivery
913 must not double-create.
914- **Team name and hostname reserved at enqueue**, or two people race for one.
915
916---
917
918## 8. Person preferences
919
920Theme is per person, suite-level, resolved before first paint (ruling 10).
921Not per document, not per team β€” or a person sets it separately in every
922team, which is the credentials problem wearing a different hat.
923
924**It rides the assertion** (Β§5.3), which makes before-first-paint achievable:
925the instance sets a cookie on its own origin at session mint and reads it
926synchronously. Never a fetch β€” for a spreadsheet a late preference does not
927merely flip the chrome, it **repaints every coloured cell**.
928
929**The write-through is a redirect-based POST to the directory origin**
930(call 6), chosen because it works with scripts off; a cross-origin
931credentialed fetch would need a CORS and SameSite design and would fail the
932zero-JS baseline.
933
934**The home wins at sign-in.** If a write-through fails, cookie and `prefs`
935row disagree; without a stated winner the next sign-in silently reverts
936somebody's choice β€” "it keeps forgetting", a bug report nobody can act on.
937The assertion's value is authoritative at mint; the instance re-writes on
938next toggle; a lost write costs one session.
939
940**Other live sessions keep a stale cookie until their next assertion.** Said
941out loud because "the preference follows you" otherwise implies immediacy
942across devices that it does not have.
943
944**The preference set stays small on purpose** β€” every addition is a claim in
945every assertion, and one that fails Β§5.4 is not a preference.
946
947---
948
949## 9. The contract
950
951| # | Call | Direction | Notes |
952|---|---|---|---|
953| 1 | Sign-in and session minting | person β†’ home | The identity authority. Not a hint. |
954| 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. |
955| 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. |
956| 4 | Provisioning enqueue | person β†’ home | Rate-limited, depth-capped, human-drained. |
957| 5 | Rekey journal pull | instance β†’ home | **Scoped to this consumer**, keyed on person refs. Carries the **roster epoch** (ruling 23). |
958| 6 | Preference write-through | person β†’ home | Redirect POST to the directory origin (Β§8). |
959| 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. |
960| 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. |
961
962Auth follows Pegamento's service contract: **per-consumer bearer token
963stored hashed**, consumer identity is its origin, origins allowlisted,
964**with a hard body cap whose number carries its reason in a comment**
965(contract item 6, unadopted in the first draft). **Tokens rotate**, and the
966verb exists before launch rather than after the first leak.
967
968**Bootstrap:** a fresh instance gets its token as platform config and its
969allowlist entry from the drain, in the step that creates it.
970
971**Call 4b β€” sibling instance request** (ruling 19's fast path) is
972instance β†’ home, not person β†’ home, and belongs in the table above rather
973than only in Β§7's prose: an instance asks for `<team>.<app>` under its own
974consumer credential, the home checks the calling origin against
975`team_instances` for that team, and enqueues the request with the label
976question settled. Docs and Sheets implement from that table, so a call that
977exists only in Β§7 is a call nobody builds. Named for `carlos/pegamento`'s
978re-review alongside the rest of Β§9.
979
980**Call 2 drives two events**, not one: a grant changes; and an **instance**
981is deleted (instance-scoped revoke β€” after Β§10's split this removes that
982`team_instances` row and its directory rows, and leaves `teams` and its
983label reservation alone while any sibling instance survives). Without the
984second, rows point at a host that 404s β€” a convenience failure under Β§6,
985which is exactly why it would survive being missed.
986
987**Renaming is no longer one of them.** An earlier draft had call 2 drive a
988re-announce "so guests stop seeing a stale name". That cannot work now the
989home owns the display name (Β§10): if call 2's name never overwrites, a
990re-announce un-stales nothing. **Rename is an operator verb at the home** β€”
991one row, one write, immediately true for every instance and every guest,
992which is strictly better than a re-announce that depended on each instance
993waking up. It is built with the rest of the operator tooling in step 6.
994
995**It renames the display name only, never the label.** A relabel is a
996hostname move for every instance in the team β€” certificates, consumer
997origins, allowlist entries β€” which is a new team, not a rename. And the
998divergence Β§9 may log carries the **team sqid and consumer origin, never
999either name**, or gate 9 fires on the log line meant to help.
1000
1001### 9.1 Call 5, rewritten
1002
1003The first draft was incoherent: it keyed the journal on `old_hmac β†’
1004new_hmac`, and under ruling 4 an instance holds no hashes and can compute
1005none, so nothing would ever have matched (review, 2026-08-30).
1006
1007- **Rows are `(person_ref, sealed new address)`.** The ref is what instances
1008 already store from call 2.
1009- **The pull is scoped per consumer** β€” to persons with a directory row on
1010 *that* instance. An unscoped journal would deliver every person's new
1011 address to every instance in the suite, so one compromised instance
1012 harvests suite-wide address changes: precisely the blast-radius inversion
1013 ruling 4 exists to prevent.
1014- **Retention is a per-consumer TTL, not the slowest cursor.** "Prune once
1015 every instance has passed" never completes when a dormant instance runs no
1016 timers to advance its cursor. A consumer returning past its TTL gets a
1017 **full re-announce** instead of a pull.
1018
1019 *This is the same defect as the unbounded `jti` store, in a second place.
1020 The generalisation, which both specs now carry: **any "prune when all
1021 consumers have caught up" is unbounded in a hibernating fleet.***
1022- **Decommission deletes the consumer row**, releasing its cursor.
1023- **Restore rewind is named:** an instance restored from backup holds a
1024 cursor pointing before pruned rows and would silently miss rekeys,
1025 notifying a dead address forever. A cursor older than the TTL takes the
1026 re-announce path, which covers this without a separate mechanism.
1027
1028**Email change requires proving control of the new address** before the
1029rekey. The first draft presumed the change had happened and specified no
1030ceremony.
1031
1032---
1033
1034## 10. Data model
1035
1036The 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.
1037
1038- `people` β€” person ref, blind index, created_at. **No address column.**
1039- `credentials`, `recovery_codes` β€” keyed on person ref (Β§5.1).
1040- `signin_links` β€” sealed address, expiry. `secure_delete` on.
1041- `sessions` β€” per origin, keyed on person ref.
1042- `directory` β€” person ref, app, team sqid, host, kind, updated_at.
1043- `teams` β€” **the suite-wide team** (ruling 17): team **id** (the integer the
1044 sqid encodes β€” ruling 22's `/` entry picks the oldest team (Β§11) and
1045 needs it, and a sqid sorts
1046 by string not by age), team sqid, **label** (the
1047 `<team>` DNS segment, and the reservation of ruling 19), sealed display
1048 name. One row per team, not per instance. **The home owns the display
1049 name**, set once at provisioning by the human who drains the queue and
1050 already sees every team before it exists (ruling 8). Call 2's sealed team
1051 name is therefore **advisory and never overwrites it** β€” otherwise a team
1052 renamed in Docs but not Sheets would flap with whichever instance woke
1053 last, and the rename event Β§9 used to have would have meant two different things.
1054- `team_instances` β€” team sqid, app, host. One row per (team, app); this is
1055 what `<team>.<app>.oficina.works` resolves against. Splitting these two is
1056 what lets the switcher mean "Acme" rather than "the acme row that happens
1057 to be in Docs": before the split, `acme.docs` and `acme.sheets` were
1058 unrelated rows sharing a DNS label, and nothing in the model said they
1059 were the same team.
1060- `prefs` β€” person ref, theme.
1061- `provisioning_queue` β€” sealed payload, state, hashed rate keys, TTL.
1062- `rekey_journal` β€” consumer, person ref, sealed address, TTL.
1063- `consumers` β€” origin, hashed token, cursor, epoch, rotation state.
1064- `assertion_jti` β€” **the directory origin's own** store, self-pruning.
1065
1066Migrations additive-only.
1067
1068**`memberships`** β€” `(team, person_ref, role)`, role being `owner` or
1069`member`, added by ruling 23. Written **only** at the directory origin, by a
1070team owner, behind a **fresh-assertion step-up β€” which is Β§14's row, now triggered**.
1071An earlier pass of this branch claimed it was a different mechanism for a
1072different caller; that was wrong and is reversed here. The directory origin
1073holds no credential (Β§4: it "obtains its session by exchanging an assertion,
1074the same mechanism instances use"), so its session's `AuthTime` comes from
1075that exchange, and the only way to make a failed `sessions.Fresh` pass is a
1076**new exchange in which the credential origin re-verifies** rather than
1077minting from its standing session β€” a `max_age`-style parameter on the
1078exchange, which is exactly what Β§14 describes. (Β§5.2's show-codes-once page
1079does sit behind a plain `sessions.Fresh`, but that page is on the credential
1080origin, where the session was minted by real authentication. It does not
1081transfer.) The doctrine agrees: Β§5.4 says "home Β§14 names it; this is its
1082trigger".
1083
1084**Or seeded by the drain** with the person the sealed queue payload Β§7
1085already carries. That bootstrap exists because gating a membership write on an owner
1086needs an owner to exist first, and the circularity has to break outside the
1087roster. **The seed needs one thing said out loud:** the payload holds an
1088**address**, the roster keys on a **person ref**, and turning one into the
1089other needs the pepper β€” which only the home holds (ruling 4) β€” plus a
1090person stub if that owner has never signed in (Β§6). So the drain calls a
1091**home-side operator verb**, the same surface that marks an instance live;
1092it never holds the pepper itself. That verb mints an owner, so it is
1093security-load-bearing and is named rather than left to be improvised. **A
1094sibling drain (call 4b) seeds nothing** β€” the team already has owners, and
1095the person its payload names is the **welcome-mail recipient and that
1096instance's own initial admin**, not a roster write; Β§7 and Β§10 agree once
1097that is said. Call 8 reads this table and nothing else. Call 2 cannot write it.
1098
1099Guests are **not** on it. A guest grant is minted in the instance and
1100announced to the directory as `kind = guest`, exactly as before; nothing
1101about guests changes.
1102
1103**Losing this database loses credentials, not access β€” except the roster.**
1104The directory rebuilds as instances re-upsert; passkeys do not. **The
1105membership record does not rebuild either**, and that is new with ruling 23:
1106call 2 cannot write it, and no channel reaches a hibernating instance to ask
1107for 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
1108at the directory origin, or it is re-entry by hand.
1109
1110**A restore mints a new roster generation.** Refusing an epoch lower than
1111the one an instance holds is not enough on its own: a restored home reissues
1112the same numbers over a recovered roster and, two writes later, an instance
1113accepts an epoch it believes is fresh carrying members that were removed. So
1114the roster carries `(generation, epoch)`, the generation changes whenever the
1115home cannot prove continuity with the epochs it last issued, and an instance
1116seeing an unfamiliar generation holds its roster stale and admits nobody on
1117team standing until an owner confirms it. A restore is a human event and it
1118costs a human action. *A backup you have never
1119restored is a hope, not a backup* β€” the restore drill runs before launch.
1120
1121---
1122
1123## 11. Shape and design
1124
1125Implementation 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.
1126
1127Ruling24 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.
1128
1129rastrillo `server` shape, zero-JS baseline, `day` theme, and the
1130**attribute** vocabulary β€” `rst-btn`, not `class="rst-btn"` (ruling 20).
1131
1132**The seven utilities are the exception, and they are not a loophole.**
1133`rst-sr-only`, `rst-mono`, `rst-m-hide`, `rst-grow`, `rst-nm`, `rst-danger`
1134and `rst-cell-mut` **keep the class spelling**, because they have **no
1135attribute twin at all** β€” verified at v0.22.0: five `.rst-sr-only`
1136selectors and zero `[rst-sr-only]`. tokens.css says why in its own header:
1137"Utilities keep the class spelling and are not paired… They are not kinds β€”
1138they are cross-cutting styling, which is what class is for, and they stay
1139class after the move", and rastrillo's Β§6-v3 agrees ("`class` is for
1140utilities and the app's own CSS"). Writing `<span rst-sr-only>` matches
1141nothing, so screen-reader-only text renders **visible**. Kinds, variants and
1142parts are attributes; these seven are classes.
1143
1144**Corrected 2026-09-05, verified against the pinned `rastrillo v0.22.0`.**
1145An earlier draft recorded "zero bare `rst-*` attribute selectors against
1146~528 `.rst-` class selector occurrences", verified 2026-08-30 against
1147`origin/main` post-design-system-v2. That was true when written and is
1148false at every version this repo has ever pinned: `ui/tokens.css` at
1149v0.22.0 has **129 distinct bare `rst-*` attribute selectors** (a 130th
1150attribute, `rst-tone`, appears only ever valued β€” `[rst-tone~="positive"]`)
1151against **536** `.rst-` class occurrences. The twins arrived in **v0.21.0**, which
1152is what this repo's first `go.mod` pinned β€” so the claim was falsified
1153before the skeleton existed, not by a later bump.
1154
1155**Write the attribute spelling: `rst-btn`, not `class="rst-btn"`** (ruling
115620, 2026-09-05). This section ruled the class vocabulary on 2026-08-30 and
1157every reason it gave has since died, so the ruling went back to Paul rather
1158than being propped up with a third. Two reasons died. "The attribute
1159grammar is unreleased" died at
1160v0.21.0. "The partials we render alongside write classes" died at **v0.22.0,
1161the current pin**, where the shipped partials flipped to the attribute
1162spelling β€” rastrillo's design-system Β§6-v3 stage 2 β€” and the class spelling
1163in `ui/partials/` falls from 124 occurrences to 6. tokens.css's own header
1164still says every shipped partial writes classes; **that sentence is stale
1165upstream.**
1166
1167**So the app starts on the spelling that survives.** rastrillo Β§6-v3 rules
1168"One spelling, not two. No permanent class fallback" and drops the class
1169selectors before 1.0; this app has no `rst-` markup at all yet; and an app
1170writing classes beside v0.22.0's attribute partials *is* the mixed codebase
1171the old rule wanted to avoid. Free today, a sweep after the first template.
1172
1173Re-check the counts whenever the pin moves, say which version was checked,
1174and **count `ui/partials/` as well as `ui/tokens.css`** β€” checking one and
1175not the other is how the second reason survived a pass looking for exactly
1176this. `Pair`, `Allocate` and `--rst-header-rule` remain in zero **source** files
1177β€” they appear only in rastrillo's own design-system spec, in its
1178ruled-but-not-built list.
1179
1180**`rastrillo doctor` landed in v0.21.0** β€” an earlier draft's "until
1181`rastrillo doctor` lands" was a trigger that had already fired. When this
1182app vendors `static/`, run `rastrillo doctor --fix` on each upgrade rather
1183than re-copying by hand. There is no `static/tokens.css` in the tree today
1184(Β§11's zero-JS page links no stylesheet), so this is an instruction with no
1185object until the first vendored asset arrives. (The unstyled page is
1186`internal/home/page.go`; this section defines no page of its own.)
1187
1188Every screen works with scripts off β€” **with one named exception, and the
1189day it arrived is the day this sentence was tested** (ruling 21). This
1190section used to say "no surface here has any reason to require script, and
1191the day one does is the day this section is wrong". That day came with
1192passkeys: a WebAuthn ceremony is `navigator.credentials`, script by
1193construction. rastrillo ships the hard half (`webauthn.JS()` embeds `js/webauthn.mjs`),
1194so the only question was whether one screen may require script, and the
1195answer is yes. Precisely: `webauthn.mjs` exports bare `navigator.credentials`
1196wrappers and makes no network call, so the app still writes a few lines of
1197glue β€” fetch `/begin`, call the wrapper, POST `/finish`, follow the `to` it
1198returns. **That glue is a static same-origin file, never an inline
1199`<script>`**, which also means the credential origin's CSP moves from the
1200`script-src 'none'` a zero-JS RP could otherwise have to `script-src 'self'`.
1201Gate 5 is untouched: embedded module bytes and app source are not
1202account-authored strings.
1203
1204**The carve-out is exactly the passkey enrolment and ceremony screens.**
1205Magic-link sign-in, recovery-code redemption and the whole springboard stay
1206scriptless, so a person with script off keeps a working sign-in and loses
1207only the optional second factor (ruling 15 already makes it optional). The
1208no-`<script>` test gets an **explicit allowlist of those screens**, never a
1209blanket exemption β€” a rule with a list is checkable; a rule with an escape
1210hatch is not.
1211
1212**The springboard** (ruling 17). The directory origin is the suite's front
1213door, and it is **one page**: a grid of apps, with a **team switcher as a
1214dropdown in the header**. You pick a team above; the grid below is that
1215team's apps. It is a dropdown rather than a first-level screen because a
1216person with one team would otherwise pay a click for a choice they do not
1217have β€” and one team is expected to be the common case at launch, which is an
1218assumption to revisit rather than a measurement.
1219
1220**Zero-JS, concretely** (Β§11's own rule, and gate 6): the switcher is a
1221`<details>`/`<summary>` disclosure whose items are ordinary GET links to
1222`/?team=<sqid>` β€” rastrillo v0.22.0 ships exactly that as
1223`ui/partials/dropdown.html`, and ruling 22 keeps the switcher able to use it
1224rather than needing a form of submit buttons. A `<select>` with an
1225`onchange` would need script and gate 6 would fail it.
1226**The selection lives in the URL** β€” `?team=<sqid>` on every team view β€”
1227**because it is navigation state, not session state** (ruling 22). Which
1228team you are looking at is *where you are*, and a URL is what a browser,
1229its history, its tabs and another person all already understand that to
1230mean.
1231
1232**`/` stays a generic entry.** It picks a team β€” the one you have, or the
1233**oldest** if several β€” the lowest *id*, the integer the sqid encodes,
1234not the sqid's string order. The column must be `AUTOINCREMENT`: a plain
1235SQLite rowid can be reused after the highest row is deleted, and then
1236"oldest" silently stops meaning oldest β€” and `302`s to the explicit URL, so "take me home"
1237is still one clean link that works for everybody, while every actual team
1238view is addressable. If you have no teams it renders the empty state rather
1239than redirecting.
1240
1241**This is where the 2026-08-30 draft already had it.** Ruling 17 said "the
1242selection lives in the URL (`?team=…`)", and ruling 22 is a reaffirmation
1243with the reasons supplied and `/`'s redirect added β€” after two detours that
1244were both wrong. Recorded, because each looked right when written.
1245
1246An earlier draft said a **URL fragment** "cannot work" scriptlessly. It can:
1247CSS `:target` switches on it with no request at all β€” `href="#acme"` and
1248`#acme:target { display: block }`. What actually rules it out is narrower:
1249the **server never learns which team you are looking at**, so nothing
1250server-rendered can be team-scoped β€” not the grid (which tiles, which New
1251buttons), not the switcher's own `aria-current`, and not gate 4's 404 for a
1252team you are not in.
1253
1254A second draft moved the selection into a **session cookie**, on the
1255reasoning that `?team=` "puts an account-authored name in referrers, history
1256and platform logs". False three ways: Β§10 gives every team a **sqid**, which
1257is opaque, so nothing account-authored is in that URL at all; referrers send
1258only the origin cross-site under the browser default
1259`strict-origin-when-cross-origin`, which a builder should set explicitly
1260rather than inherit; and no platform request log carrying paths was
1261found when this was checked. The cookie then lost on its own merits:
1262
1263- **History does not restore a cookie.** Press Back and the page you see may
1264 be Acme's while the next request from it resolves to Beta β€” display and
1265 server silently disagreeing, which is the worst shape a bug can take.
1266- **A shared cookie couples every tab, on every request.** An earlier draft
1267 patched this by putting the sqid on the New button; that fixes one link.
1268 Every team-dependent link and form has the same problem, and fixing them
1269 all means putting the team in the URL β€” at which point the cookie does
1270 nothing.
1271- **It buys nothing on caching.** Both shapes need `Cache-Control:
1272 no-store`; a cookie does not protect a response from the platform edge.
1273- **GET is the right verb here.** Selecting a team **mutates nothing** β€” it
1274 is a read of a different view β€” so GET is what HTTP means by it. A cookie
1275 would have introduced mutable state to express a location. (An earlier
1276 draft cited Β§8 as having "refused a state-changing GET for theme". It says
1277 no such thing: Β§8 chose a redirect-based POST over a *cross-origin
1278 credentialed fetch*, on CORS, SameSite and zero-JS grounds. The
1279 distinction that matters is real β€” a theme write mutates a stored
1280 preference and this does not β€” but the citation was invented, which is
1281 the failure this file keeps having to retract.)
1282
1283**Three costs, stated rather than discovered.** A shared front-door link is
1284team-specific: send someone `/?team=<sqid>` and they land on that team, or
1285on a **404 if they are not in it** (gate 4 β€” 404, never 403). `/` is the
1286link to share when you mean "the suite". A person in several teams always
1287lands on their oldest from `/`, which is stable rather than clever, and the
1288switcher is one click away. And **a shared team link followed while signed
1289out must survive sign-in** β€” Β§5.6 makes the return URL carry the query, and
1290gate 3 asserts it; without that the person arrives at a bare `/` and is
1291silently redirected to a different team than the one they were sent.
1292
1293**No-store is route-wide, and this is the first place the spec says so** β€”
1294the *need* predates ruling 22 β€” the front door has always rendered the
1295viewer's own rows β€” but nothing had written it down. Every authenticated
1296response on both origins sends `Cache-Control: no-store`, `/`'s personalised
1297redirect included, asserted by **gate 10**. The platform edge caches any 2xx GET carrying
1298a positive `max-age`, keyed on host, path and query, shared across every
1299visitor, and it does **not** refuse on a request `Cookie` β€” which is why
1300this needs a gate rather than a convention.
1301
1302**Tiles come from the viewer's own rows, never from the label** β€” and since
1303ruling 23 "rows" means two tables, not one. **A member's tiles come from
1304`memberships β‹ˆ team_instances`**; **everyone's `directory` rows are unioned in on top, whatever their
1305`kind`** β€” de-duplicated, because a row is a hint (Β§6) and an ex-member who
1306kept a per-item grant has a `kind = member` row and no roster row (doctrine
1307gate 5 keeps that grant). Filtering the union on `kind = guest` would hide
1308their tile, which is the same class of hole one door along. This is not a refinement, it is a hole that had to be
1309closed: a member with team standing and no per-item grant has **no directory
1310row at all** (call 2 fires when a grant changes), so a tiles-from-directory
1311front door would have hidden their own team from them. The home owns both
1312tables, so it joins them rather than taking the doctrine's own mechanism β€”
1313Β§5.4 has the **home** announce a new member into each `team_instances` row's
1314directory entry at roster-write time (no instance and no call 2 involved),
1315which works but puts a second writer into `directory`. **The outcome is
1316the doctrine's** β€” Β§5.4's "the front door shows the whole team on first
1317sign-in" β€” reached by a different mechanism, and Β§15 carries it for the
1318doctrine's next revision.
1319
1320**The two cases read differently, and both are gate 4's business.** For a
1321**guest**, the instances under an app are their own `(person ref, app,
1322team)` directory rows; reading it as "every `acme.*` host" would tell a
1323guest in `acme.docs` that Acme also uses Sheets, which is another person's
1324row and what gate 4 forbids. For a **member**, every `acme.*` host *is*
1325their own standing β€” through the roster, not through a grant β€” so the join
1326shows them.
1327Gate 4 asserts both: a guest of `acme.docs` sees no `acme.sheets` tile, and
1328a member of Acme sees no Beta instance. A registry entry with no instance
1329under it reveals only that the app exists in the suite and that you have no
1330team in it, both of which you already knew.
1331
1332**Which apps exist comes from a registry, not from your grants.** It is
1333**compiled-in configuration, not a table** β€” the suite's app list changes
1334when someone ships a release, never at runtime, and a table would invite a
1335runtime edit nobody reviewed. It holds, per app: slug, display name,
1336`provisionable`, `integrated`, and β€” **for `integrated: false` entries
1337only** β€” a foreign host. An integrated app needs no hostname field at all:
1338ruling 16 fixes its shape as `<team>.<app>.oficina.works`, and a free
1339per-app pattern is an invitation to drift from it. The directory renders
1340your teams' instances
1341under each entry; an entry you have no instance in still renders, showing
1342the New button, which is the affordance a grants-only list cannot have. The
1343registry is not invented for this screen β€” Β§4's `<team>.<app>.oficina.works`
1344has to resolve per app and Β§7 has to know what can be provisioned, and
1345without it that knowledge is scattered across templates.
1346
1347**Preview navigation exception, 2026-09-09 β€” approved by Paul for the
1348current preview connection.** The directory may read the operator-only
1349`HOME_DOCS_PREVIEW_ORIGIN` setting and link the Docs catalog entry to that
1350canonical HTTPS origin's fixed `/signin` path, labelled **Open preview**.
1351An empty setting keeps the existing `/apps/docs` team chooser. This is an
1352opt-in preview launcher, not a team instance or a change to the canonical
1353`<team>.<app>.oficina.works` route policy. It does not register a consumer
1354or grant access; the destination must already implement its own Home
1355handshake. The setting is rejected outside the directory role, for a
1356noncanonical origin, or for the credential/directory origins themselves.
1357The current catalog is a staged list with explicit unavailable states;
1358the full team-scoped grid and its switcher remain to be implemented.
1359
1360**Suite preview extension, 2026-09-10 β€” Paul requested the remaining apps
1361use the working Home handoff.** `HOME_APP_PREVIEW_ORIGINS` (or its canonical
1362base64 alternative) is an optional operator map from a compiled catalog
1363slug to a canonical HTTPS origin. Each configured link opens the fixed
1364`/signin` path. Unknown apps, empty destinations, self-origins, duplicate
1365JSON keys and non-directory use are refused. The legacy Docs setting may
1366coexist with other app entries, but specifying Docs in both settings is
1367refused. Configuration changes navigation only; each destination must have
1368its separately reviewed consumer and account-linking integration before
1369its link is enabled. Memoria is included in the catalog. Mail and Files
1370still require their own vault-unlock flow; this setting grants no keys or
1371membership and changes none of the canonical team routing rules.
1372
1373**Mail and Files are `integrated: false`** β€” Keymail and Slopbox are
1374separate products in separate repositories with their own auth. They render
1375as plain outbound links, never as team-scoped tiles, until each verifies
1376assertions per Β§5.3 (Β§14).
1377
1378No activity feed **yet** β€” ruling 18 approves one, pulled at render, per
1379team; `design-activity-pull` passed its review on 2026-09-05 and it is
1380sequenced as Β§13 step 10, blocked on steps 3 and 4.
1381No template gallery, no upsell chrome, no waffle.
1382
1383---
1384
1385## 12. Testing gates
1386
13871. **Plaintext grep** β€” raw SQLite file and WAL plus this app's store
1388 objects, against a seeded corpus of known addresses **and, since the
1389 2026-09-05 amendment, known titles, URLs and actor strings**. The verb is
1390 now *never stores*, so what the grep looks for has to grow with it or the
1391 clause has no test behind it. `design-activity-pull`'s gate 11 is the
1392 driven version, through the failure paths.
13932. **No-address-at-rest** β€” the schema has no address column, and a full
1394 sign-in plus directory upsert plus rekey **plus the roster seed verb**
1395 (Β§10 β€” a fourth path that unseals an address and hashes it) leaves no
1396 readable address
1397 anywhere, WAL included.
13983. **Assertion abuse suite** β€” audience confusion, replay, expiry, algorithm
1399 confusion, signature stripping, **open redirect**, **login CSRF**,
1400 **unknown `kid`**, and **query preservation on the return URL** (Β§5.6).
14014. **Two-person isolation** β€” one person's directory never contains
1402 another's rows; 404, not 403. **Three cases since ruling 23** (Β§11): a
1403 guest of `acme.docs` sees no `acme.sheets` tile; a member of Acme sees no
1404 Beta instance; and β€” the positive one, since the hole took three passes
1405 to find β€” **a member of Acme with no grant in `acme.sheets` does see the
1406 `acme.sheets` tile.**
14075. **Origin separation** β€” no account-authored string reachable on the
1408 credential origin. Β§4 made mechanical.
14096. **Scriptless-affordance diff** β€” every page, script-stripped. **For the
1410 ruling-21 screens: every affordance except the passkey ceremony button
1411 itself, and the allowlist is named explicitly in the test. The ruling24 handoff keeps its Continue button with scripts off.** Exempting
1412 the whole screen would be wrong rather than merely loose β€” the
1413 confirm page carries the ceremony *and* the recovery-code form, and
1414 `passkey.SignInRecovery` is a plain form POST precisely "because
1415 recovery is exactly the moment WebAuthn or JavaScript isn't working".
1416 A blanket exemption would let that form regress unnoticed, on the one
1417 screen where scriptlessness matters most.
14187. **Contrast test** copying `ui/contrast_test.go`'s arithmetic verbatim.
14198. **Restore drill** β€” a real restore from replication, before launch,
1420 against a written RTO.
14219. **No-content-in-logs** β€” scan log lines, error strings and metric labels
1422 for addresses, team names, queue payloads **and pulled content β€” titles,
1423 URLs, actors** (Β§3.4).
142410. **No-store** β€” drive every route on both origins and assert each
1425 authenticated response carries `Cache-Control: no-store`, **call 8's
1426 roster read included** (it is a bearer-authenticated GET, and whether the
1427 edge refuses on `Authorization` is not something to rely on), the
1428 personalised `/` redirect included (ruling 22). It exists for the edge-cache
1429 reason Β§11 sets out.
143021. **The roster write** β€” assert it requires a fresh assertion (Β§14's
1431 step-up, triggered by ruling 23), that call 2 cannot write `memberships`,
1432 and that a write naming a team the caller does not own answers 404 like
1433 any other unknown team. The most security-load-bearing new write in this
1434 spec had no home-side gate; suite gates 5 and 8 are instance-side and
1435 schema-side.
1436 (Numbered 21, not 11: `design-activity-pull` owns gates 11-20.)
1437
1438Review on a deployed canary, never localhost. Verify with
1439`X-Carlos-Version` on the canonical host after the watch.
1440
1441**Every branch gets a discussion, and that discussion is the review
1442record**, including which model reviewed it.
1443
1444---
1445
1446## 13. Sequencing
1447
14481. Skeleton, `AGENTS.md`, gates 1, 2 and 9
14492. Hand-rolled sign-in (Β§5.1), second factor, recovery codes
14503. Assertion minting, the redirect protocol, and gate 3
14514. Directory: calls 2 and 3, blind index, person refs β€” **and the roster** (ruling 23): the `memberships` table, the seed verb, the owner write behind Β§14's step-up, call 8, and gate 21. The doctrine puts call 8 on top of this step, and a suite-shape Sheets or Docs needs it for team standing, so the ship gate below is not just step 4 but step 4 including the roster
14525. Signup, queue, rate limits, name reservation
14536. The operator drain
14547. Preferences, the assertion claim, call 6
14558. Rekey journal (call 5), epoch, decommission
14569. Restore drill and the RTO
145710. **Activity on the front door** (ruling 18, `design-activity-pull`).
1458 **Per team, following `?team=` β€” RULED 2026-09-05 by Paul.**
1459 `design-activity-pull` was revised to match on 2026-09-05 (its ruling 6)
1460 and approved. Per-team also shrinks the fan-out Β§6 there worries
1461 about, from every instance a person can reach to the apps in one team.
1462 **This is not a merge-logic tweak.** Per-team retires that design's
1463 headline argument β€” that only the home can answer "my things" rather
1464 than "this team's things" β€” so its Β§2.1 was re-justified and its Β§5.4
1465 reopening revisited, not just its query changed β€” done 2026-09-05.
1466 Blocked on steps 3 and 4, and deliberately last. It has no claim on
1467 anything earlier and must not acquire one: the identity layer is the
1468 suite's critical path and this is a front-door nicety.
1469
1470**Sheets and Docs are unblocked from step 3, not step 9** β€” assertions plus
1471a stub directory is enough for both. But **step 4 must land before either
1472ships**, because call 2's person ref is what their grant rows key on.
1473
1474---
1475
1476## 13a. Consumed suite rulings
1477
1478The suite sharing doctrine (`oficina/suite`) binds this spec. Read against
1479**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.
1480
1481| # | What it settles for the home |
1482|---|---|
1483| 1 | The team is the boundary. Β§2.2's "never federates" becomes "never crosses a team". |
1484| 2 | Sharing is membership across teams. |
1485| 3 | Custody and authorization are two rules; authorization is never cached. The home holds neither β€” it answers facts about its own records. |
1486| 4 | The home owns identity, the team and the membership roster. It never owns a per-item grant. |
1487| 5 | Two shapes. A lone instance never dials a home; a suite instance takes identity and roster from one. |
1488| 8 | Two credentials. Only the home can mint an assertion, which is why call 7 carries one and sibling calls do not. |
1489| 12 | Membership is its own record, written only by an owner. This is ruling 23. |
1490| 13 | A compromised home is three compromises, conceded separately β€” see below. |
1491| 14 | Custody is bounded at acquisition and retired by the feed. |
1492| 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" |
1493
1494Calls 7 and 8 are added to Β§9. Call 7 is the activity pull, and it waits on
1495the identity layer (Β§13 step 10) because it carries a real assertion. Call 8
1496is the roster pull and waits on step 4.
1497
1498### What the doctrine makes the home concede
1499
1500Suite ruling 13 splits "a compromised home" into three, and Β§2's careful
1501sentence about a compromised **directory** does not cover all of them. The
1502home spec should not be read as promising more than this:
1503
1504- **A leaked database** gives up the graph, the roster, and β€” new with
1505 ruling 23 β€” who owns each team. No content.
1506- **A compromised directory origin** can add a member, and a member reads
1507 everything team-visible in every instance of that team, through each app's
1508 ordinary front door. That is the blast radius ruling 23 actually adds.
1509- **A compromised credential origin** can sign in as anyone, owners
1510 included. It holds the signing key (Β§5.3), an instance's grants join is
1511 keyed on the `sub` this home asserted, and Β§5.4 already calls a hostile
1512 `sub` account takeover. **This is not new and ruling 23 did not cause it**
1513 β€” it has been true since ruling 1 put every credential on one origin, and
1514 a passkey does not help because the passkey lives there too.
1515
1516**The step-up bounds none of them**, because the compromised authority is
1517the one performing it, and neither does a directory row or a notification:
1518those are what honest code leaves behind, not what a compromise must. Β§3.3's
1519idiom applies β€” said plainly rather than counted as a fix.
1520
1521**The Pegamento filing.** The suite doctrine records a divergence from
1522Pegamento Β§10.3, *"a compromised glue grants nothing"*. The honest shape of
1523it is not that the roster diverges: it is that **this home was never a
1524Β§10.3 glue.** Β§10.3 is written for a registry, and ruling 1 made this an
1525identity authority. That filing belongs at `carlos/pegamento`.
1526
1527---
1528
1529## 14. Deliberately not built yet
1530
1531| Thing | Trigger |
1532|---|---|
1533| **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 |
1534| Pegamento's vault facet β€” team names the home cannot read | The vault ships with a recovery story |
1535| Operator reset for a fully locked-out person | The first person loses passkey and codes together |
1536| 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) |
1537| **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 |
1538| **Account deletion / erasure** | Before a person asks, which under most regimes is not optional for long |
1539| ~~**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 |
1540| Any preference beyond theme | It passes Β§5.4 |
1541| Billing, plans, quotas | Somebody has to pay |
1542| **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) |
1543| 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. |
1544
1545---
1546
1547## 15. Open
1548
1549- **The doctrine's next revision should carry Β§11's tile mechanism.** Its
1550 Β§5.4 has the home announce a new member into each `team_instances` row's
1551 directory entry; this spec reaches the same outcome by joining
1552 `memberships β‹ˆ team_instances` at render, which puts no second writer into
1553 `directory`. Same outcome, different mechanism, and the doctrine does not
1554 know about it.
1555- **Docs' re-review** of Β§9 after this revision β€” **calls 7 and 8 included** β€” the contract changed
1556 substantially (person refs, call 5 rewritten, call 6 added).
1557- **`rastrillo/rastrillo` #2** β€” the `auth` subject hook and sealed link
1558 store. Adopt and delete ours the day it lands.
1559- **`rastrillo/rastrillo` #1** β€” assertion verification helper.
1560- **`carlos/platform` #4** β€” the machine credential.
1561- **`carlos/pegamento` #1** β€” the shared-instance ruling, and whether Β§9
1562 protects the credential or the knowledge.
1563- ~~`design-activity-pull`'s own review pass.~~ **Run 2026-09-05: four
1564 passes by Fable, the fourth approving.** What remains open there is design,
1565 not review: the per-instance deadline, the fan-out budget's number, the
1566 body cap, the refresh TTL, the hard maximum cache age, the cache bounds,
1567 the minimum pull interval, whether the two origins are one process, and
1568 whether swap and core dumps are off, whether `actor` is worth having,
1569 and
1570 the front door's layout.
1571- **`design-activity-pull` Β§11's last row cites "(Β§2.2, Β§0)"** for "an
1572 operator view of anyone's feed". After the 2026-09-05 amendment Β§0 no
1573 longer forbids that β€” Β§2.2's first bullet does. **And its Scope line says
1574 "without the home learning or holding any of it"**, which Β§0's gloss now
1575 says outright is false: the home does learn, and holds bounded in RAM.
1576 (Its Β§5.4 is safe β€” "learns nothing *durable*".) Two one-word fixes in
1577 that file, recorded here so they are not lost. **Third: no gate there
1578 (11-20) exercises eviction on the entry-count or bytes-per-person bounds**
1579 β€” gate 20 tests the age bound only, and the entry count is the one that
1580 caps the cross-person set.
1581- ~~Class or attribute spelling β€” Β§11 needs a re-ruling~~ β€” **closed by
1582 ruling 20**, 2026-09-05: the app writes attributes, and the seven
1583 unpaired utilities keep `class`.
1584- ~~A real suite domain β€” triggers Β§5.7~~ β€” closed by ruling 16. Β§5.7 is
1585 titled for the case it does not apply to: the `oficina.works` move needs
1586 no RP rename drill.
1587