Rulings 17-21, and four claims this file made that were false
The springboard needed a shape, and settling it turned up four claims in this spec that did not survive being checked. Ruling 17: the springboard is one page — an app grid with the team switcher as a dropdown in the header, a zero-JS <details> disclosure of GET links with the selection in the URL, not a preference row. Which apps exist comes from a compiled-in registry rather than from your grants, which is what lets an app you have no team in still show its New button. Tiles come from the viewer's own directory rows and never from every host sharing a label, or a guest in acme.docs would learn Acme uses Sheets. Ruling 17 also needed a team the model did not have. §10 had `teams` as one row per instance, so "Acme" in Docs and "Acme" in Sheets were unrelated rows sharing a DNS label and the switcher had no grouping key. Split into `teams` (suite-wide, carrying the label and the display name) and `team_instances` (one per team and app). Ruling 19 reserves a label suite-wide, and its first argument was wrong: "the home decides who may take a name, never who may read a document" is not what §2.2 turns on. §2.2 says never holds an authorization model, full stop, and ruling 15 rejected a per-team 2FA floor — no content in it anywhere — on those grounds. The wrong argument is recorded as wrong, since it is the one somebody makes again. What is true is narrower: the home answers only facts about its own records — is this label free, and is this calling origin already an instance of this team. An existing instance can vouch for a sibling under its own consumer credential; everyone else arrives unvouched. And both paths end in the queue, because the home holds no provisioning credential and ruling 7's human sees every team before it exists. Ruling 18 replaced an earlier draft of itself. That draft forbade an activity surface on the reasoning that the home would have to learn document names and so retract §0 — but design-activity-pull holds nothing at rest, so §0 is untouched and only §2.2's verb changes, renders to stores, when that design passes the review it has not had. §13 gains a step 10, blocked on steps 3 and 4 and deliberately last. Ruling 20 switches the app to the attribute spelling, because §11's every reason for the class vocabulary had died: the grammar shipped at v0.21.0, this repo's first pin, and at v0.22.0 the shipped partials flipped, class occurrences in ui/partials falling from 124 to 6. The seven unpaired utilities keep class — rst-sr-only among them has no attribute twin at all, so writing it as an attribute renders screen-reader-only text visible. Ruling 21: §11 said "the day one does is the day this section is wrong", and passkeys are that day. One named carve-out for the enrolment and ceremony screens, with gate 6 carrying the allowlist explicitly — the confirm page hosts the recovery form too, and recovery is exactly the moment JavaScript isn't working. The four false claims, all found by review: §11's "zero bare rst-* attribute selectors against ~528" was true when verified against v0.20.x and false at every version this repo has pinned; "until rastrillo doctor lands" was a trigger that fired in v0.21.0; §3.2 listed session rows as carrying coarsened timestamps, which freshness forbids; and §15's suite-domain question died with ruling 16. §3.3 now says plainly that coarsening protects a deliberate publication and nothing else, so the timestamp fingerprint is unmitigated in a leak, rather than leaving a dead mitigation standing. Reviewed by Fable across four passes; the first three returned CHANGES NEEDED and each found something that had to change, the fourth approved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+308
−38
docs/superpowers/specs/2026-08-30-home-design.md+308 −38
Intent
diff --git a/docs/superpowers/specs/2026-08-30-home-design.md b/docs/superpowers/specs/2026-08-30-home-design.md| index 444fbd9..d254bc7 100644 |
| --- a/docs/superpowers/specs/2026-08-30-home-design.md |
| +++ b/docs/superpowers/specs/2026-08-30-home-design.md |
| @@ -1,11 +1,13 @@ |
| # 🤖 The Oficina home — design |
| -**Status:** design, revised 2026-08-30 after review. Drafted by the Sheets |
| +**Status:** design, revised **2026-09-05** (rulings 17-21, and four claims |
| +corrected). Originally 2026-08-30. Drafted by the Sheets |
| session with Docs (`oficina/docs`) as a declared consumer. |
| **Review passes:** consumer review by docs-e6 (5 findings, all applied); |
| review dispatched to **Fable** (16 confirmed, 6 suspected — verdict "not |
| -ready", now addressed). Source claims re-verified by this session before |
| -acting. |
| +ready", now addressed). The 2026-09-05 revision was reviewed by **Fable**, |
| +which returned 11 findings — three false claims in this file among them — |
| +all applied. Source claims re-verified by this session before acting. |
| **Scope:** the suite's home — identity, directory, provisioning, person |
| preferences. Consumed by Sheets and Docs. Not an application; holds no |
| documents. |
| @@ -56,7 +58,12 @@ blind, replaceable*. |
| | 14 | **Hand-roll sign-in: subject is an opaque person ref, link state is sealed** — §5.1 | `rastrillo/auth` as shipped (stores readable addresses); blocking on upstream | Paul, 2026-08-30 | |
| | 15 | **Passkey 2FA is optional per person. Owner-enforceable per-team is NOT in slice one** — §14 | Promising a per-team floor neither spec can build | Paul, 2026-08-30 | |
| | 16 | **`oficina.works` is the primary domain — RP `id.oficina.works`, directory at the apex, instances at `<team>.<app>.oficina.works`** — §4 | Staying on `oncarlos.com` until passkeys forced the move; keeping the `bds` slot | Paul, 2026-09-03 | |
| +| 17 | **The springboard is one page: an app grid, with the team switcher as a dropdown in the header. Which apps exist comes from a registry, not from your grants. A team is a suite-wide entity that instances belong to** — §11, §10 | A team-switcher screen as a first level; deriving tiles from grants alone, which hides an app you have no team in and leaves nowhere to press New; grouping by hostname label alone, with no row that exists before the first instance and outlives any one of them | Paul, 2026-09-05 | |
| +| 18 | **Activity is approved in principle, pulled at render, and sequenced last** — 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; refusing the feature | Paul, 2026-09-05 | |
| +| 19 | **A team name is reserved suite-wide. Both paths end in the drain — an existing instance settles the label question for the operator, it does not bypass them. The home answers only facts about its own records, never "may this person"** — §7 | Per-app first-come, which lets a stranger squat `acme.sheets` behind your `acme.docs` on a domain that reads as one suite | Paul, 2026-09-05 | |
| +| 20 | **Write the attribute spelling — `rst-btn`, not `class="rst-btn"`. The seven unpaired utilities keep `class`** — §11 | The class vocabulary, which this section ruled on 2026-08-30 and whose every stated reason has since died; "follow whatever the pin's partials use", which changes this app's markup on a dependency bump | Paul, 2026-09-05 | |
| +| 21 | **§11's zero-JS rule gains one named carve-out: passkey enrolment and ceremony screens may load script. Everything else stays scriptless** — §11 | Passkeys leaving slice one; a blanket exemption; a progressive-enhancement version, since the ceremony itself cannot happen without script either way | Paul, 2026-09-05 | |
| --- |
| ## 2. What the home is |
| @@ -117,7 +124,14 @@ changing the architecture: |
| Each is something somebody will propose in good faith. |
| - **Never renders content from an instance.** No previews, no thumbnails, no |
| - cell values. |
| + cell values. **Under amendment** (ruling 18): `design-activity-pull` |
| + changes this clause's verb to *never **stores***, on the argument that |
| + "renders" was doing custody's work — a title that exists for the length of |
| + one response, or of that design's RAM cache TTL, addressed to the person |
| + entitled to it, does not make the home a place documents live. The verb changes when that design passes |
| + its own review, not before, and it licenses nothing else: no search over |
| + pulled content, no cross-person aggregation, no on-disk cache, no operator |
| + view. Until then this clause stands as written. |
| - **Never holds an authorization model.** It knows you can reach a team in |
| an app; what you may *do* is the instance's business (§5.5). Ruling 15 |
| exists because the per-team 2FA floor would have breached this. |
| @@ -178,7 +192,14 @@ journal's new addresses; **sign-in link state** (§5.1). |
| - The bipartite graph: person ref ↔ team ↔ app ↔ `kind`. |
| - Team hostnames and sqids — these appear in URLs and were never secret. |
| - Passkey credential ids and **public** keys. |
| -- Session rows keyed by **person ref**, and coarsened timestamps (§3.3). |
| +- Session rows keyed by **person ref**. Their timestamps are **exact at |
| + rest** — expiry is compared against them, so they cannot be coarsened in |
| + the table — and §3.3's coarsening is a **rule for publication**, applied |
| + when the database is published, never a storage format. An earlier draft |
| + listed them as "coarsened timestamps". What forces exactness is not expiry |
| + alone (a day-granular `expires_at` on a thirty-day session would work) but |
| + **freshness**: `sessions.Fresh` compares `auth_time` against a max age in |
| + minutes, and §5.2's show-codes-once page depends on it. |
| - Each person's theme. |
| **Never at rest readable: an email address.** Sealed and short-lived in link |
| @@ -198,7 +219,12 @@ identifies the row and reveals the rest of their memberships, guest rows |
| included. Timestamps correlate with events observable at instances. |
| So: published timestamps are **coarsened to the day**, and §0 says "some |
| -person" rather than "some unidentified person". Sealing team display names |
| +person" rather than "some unidentified person". **Said plainly rather than |
| +counted as a fix: coarsening protects a deliberate publication and nothing |
| +else.** §3.1's leak models ship the raw database file, whose session rows |
| +carry exact timestamps (§3.2), so the timestamp-correlation fingerprint is |
| +**unmitigated in a leak**. There is no export verb in this design that |
| +coarsens; if one is ever wanted, that is where the rule would attach. Sealing team display names |
| buys little while hostnames are plaintext; it is kept because it is free, |
| and it is not counted as protection. |
| @@ -265,9 +291,14 @@ included — which collapses the split this very section exists to make |
| ### 5.1 Sign-in is hand-rolled, and why — RULED 2026-08-30 by Paul |
| **`rastrillo/auth` as shipped cannot host a blind identity service.** |
| -Verified at `origin/main`: `auth_links.address TEXT NOT NULL`, |
| -`auth_sessions.address TEXT NOT NULL`, and `admit()` sets |
| -`Subject: id.Address` — which `passkey_credentials`, `passkey_challenges`, |
| +Verified at `origin/main` 2026-08-30 and **re-verified at the v0.22.0 pin |
| +2026-09-05**: `auth_links.address TEXT NOT NULL`, and `admit()` sets |
| +`Subject: id.Address`. (The 2026-08-30 draft also cited |
| +`auth_sessions.address TEXT NOT NULL`. At v0.22.0 that table is no longer |
| +written — `0002_sessions_backfill.sql` moved its rows into the shared |
| +`sessions` table — so the readable address now lands in `sessions.subject`. |
| +The leak moved; it did not close, and the ruling stands on `auth_links` and |
| +the Subject either way.) — which `passkey_credentials`, `passkey_challenges`, |
| `passkey_pending` and `passkey_recovery_codes` all inherit. `auth.Config` |
| has no subject-remapping hook, so it cannot be configured away. |
| @@ -520,6 +551,75 @@ draft claimed the enqueue "adds no knowledge the service lacks" — untrue: |
| the directory holds hashes, the queue holds addresses. That is new custody. |
| The argument leans on the disclosure, not on denying the delta. |
| +**The label is reserved suite-wide at enqueue** (ruling 19). Claiming `acme` |
| +takes it across every app, so only Acme can later create `acme.sheets`. |
| +Without that, a stranger squats the sibling hostname of a team you already |
| +have, on an apex that reads as one suite — which is impersonation whatever |
| +the model calls it. |
| + |
| +**Who decides that a later request really is Acme — and why the home never |
| +answers it.** The first claim of a free label is a decision about a resource |
| +alone: is this label taken? The *second* instance is the hard one, because |
| +"only Acme may create `acme.sheets`" needs a principal, and the home holding |
| +a person↔resource entitlement is what §2.2 forbids and ruling 9 refuses |
| +("*where might I have things*, never *may I have this*"). An earlier draft |
| +argued this away as "a name, not a document". **That argument is wrong and |
| +is recorded here so it is not made again:** §2.2 says *never holds an |
| +authorization model*, not *never holds a content authorization model*, and |
| +ruling 15 rejected a per-team 2FA floor — a policy with no content in it at |
| +all — on exactly these grounds. So the distinction has to be sharper. Two |
| +paths, and the home answers only facts about its own records in both: |
| + |
| +**Both paths end in the queue**, and this is not a detail. §7's |
| +load-bearing safety fact is that **the home holds no provisioning |
| +credential** — so nothing the home can do creates `acme.sheets`, and ruling |
| +7's "a human sees every team before it exists" holds for siblings exactly as |
| +for first teams. What the two paths differ in is what the operator is |
| +handed, not whether there is one. |
| + |
| +- **Fast path — an existing instance vouches for its own team.** "Create |
| + `acme.sheets`" is initiated from inside `acme.docs`, by whoever |
| + `acme.docs` considers an owner, and reaches the home as an instance→home |
| + call under that instance's consumer credential. The home checks one |
| + thing: **is the calling origin already in `team_instances` for this |
| + team?** — joining `consumers.origin` to the instance host the home itself |
| + recorded at the drain. That answers the *label* question and enqueues the |
| + request pre-settled. Who counts as an owner is the instance's business, |
| + decided from its own grants — §2.2's second never-do working exactly as |
| + intended, and the home never learns the answer. |
| +- **Fallback — everyone else arrives unvouched.** The request is enqueued |
| + the same way, and the human decides the label question too. The home may |
| + render the requester's own directory rows beside it as a **hint** (ruling |
| + 9) — with `kind` shown, since a *guest* row in `acme.docs` is emphatically |
| + not "is Acme". They are a hint and nothing else: the operator adjudicates. |
| + |
| +**Two things this does not smuggle in.** Consumer-level authorization is not |
| +new and is not §2.2's concern: §9 already scopes every call to its consumer, |
| +and a label on `oficina.works` is the home's own namespace. What §2.2 |
| +forbids is a *person*-level entitlement, and neither path forms one. And the |
| +new instance's first owner and welcome address arrive as **sealed queue |
| +payload**, exactly as a fallback requester's do (§3.2) — the home carries |
| +that opaquely and never judges it. |
| + |
| +**A compromised `acme.docs` can vouch for `acme.*` siblings.** Bounded by |
| +the registry's `provisionable` set, one row per (team, app), the drain still |
| +seeing every request, and the sibling getting its own consumer token from |
| +that drain rather than inheriting one. Recorded rather than left to be |
| +discovered; §2's blast-radius argument survives it. |
| + |
| +So the home's whole vocabulary here is two facts: *is this label free*, and |
| +*is this calling origin already an instance of this team*. It never forms an |
| +opinion about whether a person is entitled to anything. That is the |
| +distinction ruling 19 rests on, and it is a narrower one than "names versus |
| +documents". |
| + |
| +**Two consequences, recorded rather than left to be discovered.** §14's |
| +auto-drain row now carries a third decision — auto-draining also means |
| +automating the fallback adjudication, and the machine credential does not |
| +supply the judgement. And **a queue entry that is purged or times out must |
| +release its label**, or a request that never became a team holds a name |
| +nobody can claim. |
| + |
| **The drain is also the abuse gate** (ruling 7) — a human sees every team |
| before it exists. So when the machine credential lands, **auto-drain is a |
| policy decision, not an automatic consequence.** |
| @@ -579,7 +679,7 @@ every assertion, and one that fails §5.4 is not a preference. |
| | # | Call | Direction | Notes | |
| |---|---|---|---| |
| | 1 | Sign-in and session minting | person → home | The identity authority. Not a hint. | |
| -| 2 | Directory upsert / revoke | instance → home | Sends address + sealed team name; home hashes; **returns the person ref** and the pepper epoch. Team-scoped revoke included. Via the outbox. | |
| +| 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** and the pepper epoch. Instance-scoped revoke included. Via the outbox. | |
| | 3 | Directory list | person → home | Every team across every app, with `kind`. | |
| | 4 | Provisioning enqueue | person → home | Rate-limited, depth-capped, human-drained. | |
| | 5 | Rekey journal pull | instance → home | **Scoped to this consumer**, keyed on person refs. | |
| @@ -594,11 +694,35 @@ verb exists before launch rather than after the first leak. |
| **Bootstrap:** a fresh instance gets its token as platform config and its |
| allowlist entry from the drain, in the step that creates it. |
| -**Call 2 drives three events**, not one: a grant changes; a team is renamed |
| -(re-announce, so guests stop seeing a stale name); a team is deleted |
| -(team-scoped revoke). Without the third, rows point at a host that 404s — |
| -a convenience failure under §6, which is exactly why it would survive being |
| -missed. |
| +**Call 4b — sibling instance request** (ruling 19's fast path) is |
| +instance → home, not person → home, and belongs in the table above rather |
| +than only in §7's prose: an instance asks for `<team>.<app>` under its own |
| +consumer credential, the home checks the calling origin against |
| +`team_instances` for that team, and enqueues the request with the label |
| +question settled. Docs and Sheets implement from that table, so a call that |
| +exists only in §7 is a call nobody builds. Named for `carlos/pegamento`'s |
| +re-review alongside the rest of §9. |
| + |
| +**Call 2 drives two events**, not one: a grant changes; and an **instance** |
| +is deleted (instance-scoped revoke — after §10's split this removes that |
| +`team_instances` row and its directory rows, and leaves `teams` and its |
| +label reservation alone while any sibling instance survives). Without the |
| +second, rows point at a host that 404s — a convenience failure under §6, |
| +which is exactly why it would survive being missed. |
| + |
| +**Renaming is no longer one of them.** An earlier draft had call 2 drive a |
| +re-announce "so guests stop seeing a stale name". That cannot work now the |
| +home owns the display name (§10): if call 2's name never overwrites, a |
| +re-announce un-stales nothing. **Rename is an operator verb at the home** — |
| +one row, one write, immediately true for every instance and every guest, |
| +which is strictly better than a re-announce that depended on each instance |
| +waking up. It is built with the rest of the operator tooling in step 6. |
| + |
| +**It renames the display name only, never the label.** A relabel is a |
| +hostname move for every instance in the team — certificates, consumer |
| +origins, allowlist entries — which is a new team, not a rename. And the |
| +divergence §9 may log carries the **team sqid and consumer origin, never |
| +either name**, or gate 9 fires on the log line meant to help. |
| ### 9.1 Call 5, rewritten |
| @@ -640,7 +764,20 @@ ceremony. |
| - `signin_links` — sealed address, expiry. `secure_delete` on. |
| - `sessions` — per origin, keyed on person ref. |
| - `directory` — person ref, app, team sqid, host, kind, updated_at. |
| -- `teams` — sqid, host, sealed display name. |
| +- `teams` — **the suite-wide team** (ruling 17): team sqid, **label** (the |
| + `<team>` DNS segment, and the reservation of ruling 19), sealed display |
| + name. One row per team, not per instance. **The home owns the display |
| + name**, set once at provisioning by the human who drains the queue and |
| + already sees every team before it exists (ruling 8). Call 2's sealed team |
| + name is therefore **advisory and never overwrites it** — otherwise a team |
| + renamed in Docs but not Sheets would flap with whichever instance woke |
| + last, and the rename event §9 used to have would have meant two different things. |
| +- `team_instances` — team sqid, app, host. One row per (team, app); this is |
| + what `<team>.<app>.oficina.works` resolves against. Splitting these two is |
| + what lets the switcher mean "Acme" rather than "the acme row that happens |
| + to be in Docs": before the split, `acme.docs` and `acme.sheets` were |
| + unrelated rows sharing a DNS label, and nothing in the model said they |
| + were the same team. |
| - `prefs` — person ref, theme. |
| - `provisioning_queue` — sealed payload, state, hashed rate keys, TTL. |
| - `rekey_journal` — consumer, person ref, sealed address, TTL. |
| @@ -657,25 +794,134 @@ restored is a hope, not a backup* — the restore drill runs before launch. |
| ## 11. Shape and design |
| -rastrillo `server` shape, zero-JS baseline, `day` theme, and the **class** |
| -vocabulary — `class="rst-*"`. |
| - |
| -**Verified at `origin/main` post-design-system-v2, 2026-08-30:** |
| -`ui/tokens.css` has **zero bare `rst-*` attribute selectors** against ~528 |
| -`.rst-` class selector occurrences. (It does contain attribute selectors for |
| -`[data-tone=]`, `[aria-*]`, `[type=]` — an earlier draft said "zero |
| -attribute selectors", which was imprecise. The conclusion is unchanged: |
| -write classes.) `Pair`, `Allocate`, `rastrillo doctor` and |
| -`--rst-header-rule` remain in zero files. |
| - |
| -Re-copy `static/tokens.css` deliberately on every rastrillo upgrade until |
| -`rastrillo doctor` lands. |
| - |
| -Every screen works with scripts off. **No surface here has any reason to |
| -require script**, and the day one does is the day this section is wrong. |
| - |
| -The directory is the suite's front door: your teams grouped by app, and a |
| -New button. No template gallery, no upsell chrome, no waffle. |
| +rastrillo `server` shape, zero-JS baseline, `day` theme, and the |
| +**attribute** vocabulary — `rst-btn`, not `class="rst-btn"` (ruling 20). |
| + |
| +**The seven utilities are the exception, and they are not a loophole.** |
| +`rst-sr-only`, `rst-mono`, `rst-m-hide`, `rst-grow`, `rst-nm`, `rst-danger` |
| +and `rst-cell-mut` **keep the class spelling**, because they have **no |
| +attribute twin at all** — verified at v0.22.0: five `.rst-sr-only` |
| +selectors and zero `[rst-sr-only]`. tokens.css says why in its own header: |
| +"Utilities keep the class spelling and are not paired… They are not kinds — |
| +they are cross-cutting styling, which is what class is for, and they stay |
| +class after the move", and rastrillo's §6-v3 agrees ("`class` is for |
| +utilities and the app's own CSS"). Writing `<span rst-sr-only>` matches |
| +nothing, so screen-reader-only text renders **visible**. Kinds, variants and |
| +parts are attributes; these seven are classes. |
| + |
| +**Corrected 2026-09-05, verified against the pinned `rastrillo v0.22.0`.** |
| +An earlier draft recorded "zero bare `rst-*` attribute selectors against |
| +~528 `.rst-` class selector occurrences", verified 2026-08-30 against |
| +`origin/main` post-design-system-v2. That was true when written and is |
| +false at every version this repo has ever pinned: `ui/tokens.css` at |
| +v0.22.0 has **129 distinct bare `rst-*` attribute selectors** (a 130th |
| +attribute, `rst-tone`, appears only ever valued — `[rst-tone~="positive"]`) |
| +against **536** `.rst-` class occurrences. The twins arrived in **v0.21.0**, which |
| +is what this repo's first `go.mod` pinned — so the claim was falsified |
| +before the skeleton existed, not by a later bump. |
| + |
| +**Write the attribute spelling: `rst-btn`, not `class="rst-btn"`** (ruling |
| +20, 2026-09-05). This section ruled the class vocabulary on 2026-08-30 and |
| +every reason it gave has since died, so the ruling went back to Paul rather |
| +than being propped up with a third. Two reasons died. "The attribute grammar is unreleased" died at |
| +v0.21.0. "The partials we render alongside write classes" died at **v0.22.0, |
| +the current pin**, where the shipped partials flipped to the attribute |
| +spelling — rastrillo's design-system §6-v3 stage 2 — and the class spelling |
| +in `ui/partials/` falls from 124 occurrences to 6. tokens.css's own header |
| +still says every shipped partial writes classes; **that sentence is stale |
| +upstream.** |
| + |
| +**So the app starts on the spelling that survives.** rastrillo §6-v3 rules |
| +"One spelling, not two. No permanent class fallback" and drops the class |
| +selectors before 1.0; this app has no `rst-` markup at all yet; and an app |
| +writing classes beside v0.22.0's attribute partials *is* the mixed codebase |
| +the old rule wanted to avoid. Free today, a sweep after the first template. |
| + |
| +Re-check the counts whenever the pin moves, say which version was checked, |
| +and **count `ui/partials/` as well as `ui/tokens.css`** — checking one and |
| +not the other is how the second reason survived a pass looking for exactly |
| +this. `Pair`, `Allocate` and `--rst-header-rule` remain in zero **source** files |
| +— they appear only in rastrillo's own design-system spec, in its |
| +ruled-but-not-built list. |
| + |
| +**`rastrillo doctor` landed in v0.21.0** — an earlier draft's "until |
| +`rastrillo doctor` lands" was a trigger that had already fired. When this |
| +app vendors `static/`, run `rastrillo doctor --fix` on each upgrade rather |
| +than re-copying by hand. There is no `static/tokens.css` in the tree today |
| +(§11's zero-JS page links no stylesheet), so this is an instruction with no |
| +object until the first vendored asset arrives. (The unstyled page is |
| +`internal/home/page.go`; this section defines no page of its own.) |
| + |
| +Every screen works with scripts off — **with one named exception, and the |
| +day it arrived is the day this sentence was tested** (ruling 21). This |
| +section used to say "no surface here has any reason to require script, and |
| +the day one does is the day this section is wrong". That day came with |
| +passkeys: a WebAuthn ceremony is `navigator.credentials`, script by |
| +construction. rastrillo ships the hard half (`webauthn.JS()` embeds `js/webauthn.mjs`), |
| +so the only question was whether one screen may require script, and the |
| +answer is yes. Precisely: `webauthn.mjs` exports bare `navigator.credentials` |
| +wrappers and makes no network call, so the app still writes a few lines of |
| +glue — fetch `/begin`, call the wrapper, POST `/finish`, follow the `to` it |
| +returns. **That glue is a static same-origin file, never an inline |
| +`<script>`**, which also means the credential origin's CSP moves from the |
| +`script-src 'none'` a zero-JS RP could otherwise have to `script-src 'self'`. |
| +Gate 5 is untouched: embedded module bytes and app source are not |
| +account-authored strings. |
| + |
| +**The carve-out is exactly the passkey enrolment and ceremony screens.** |
| +Magic-link sign-in, recovery-code redemption and the whole springboard stay |
| +scriptless, so a person with script off keeps a working sign-in and loses |
| +only the optional second factor (ruling 15 already makes it optional). The |
| +no-`<script>` test gets an **explicit allowlist of those screens**, never a |
| +blanket exemption — a rule with a list is checkable; a rule with an escape |
| +hatch is not. |
| + |
| +**The springboard** (ruling 17). The directory origin is the suite's front |
| +door, and it is **one page**: a grid of apps, with a **team switcher as a |
| +dropdown in the header**. You pick a team above; the grid below is that |
| +team's apps. It is a dropdown rather than a first-level screen because a |
| +person with one team would otherwise pay a click for a choice they do not |
| +have — and one team is expected to be the common case at launch, which is an |
| +assumption to revisit rather than a measurement. |
| + |
| +**Zero-JS, concretely** (§11's own rule, and gate 6): the switcher is a |
| +`<details>`/`<summary>` disclosure whose items are ordinary GET links — |
| +rastrillo v0.22.0 ships exactly that as `ui/partials/dropdown.html`. A |
| +`<select>` with an `onchange` would need script and gate 6 would fail it. |
| +**The selection lives in the URL** (`?team=…`), not in a preference row: |
| +§14 rules out any preference beyond theme, and a switcher that quietly added |
| +one would breach that without anyone deciding to. |
| + |
| +**Tiles come from the viewer's own rows, never from the label.** The |
| +instances shown under an app are that person's own `(person ref, app, team)` |
| +directory rows. Read the other way — "every `acme.*` host" — a guest in |
| +`acme.docs` would learn that Acme also uses Sheets, which is another |
| +person's row and what gate 4 forbids. A registry entry with no instance |
| +under it reveals only that the app exists in the suite and that you have no |
| +team in it, both of which you already knew. |
| + |
| +**Which apps exist comes from a registry, not from your grants.** It is |
| +**compiled-in configuration, not a table** — the suite's app list changes |
| +when someone ships a release, never at runtime, and a table would invite a |
| +runtime edit nobody reviewed. It holds, per app: slug, display name, |
| +`provisionable`, `integrated`, and — **for `integrated: false` entries |
| +only** — a foreign host. An integrated app needs no hostname field at all: |
| +ruling 16 fixes its shape as `<team>.<app>.oficina.works`, and a free |
| +per-app pattern is an invitation to drift from it. The directory renders your teams' instances |
| +under each entry; an entry you have no instance in still renders, showing |
| +the New button, which is the affordance a grants-only list cannot have. The |
| +registry is not invented for this screen — §4's `<team>.<app>.oficina.works` |
| +has to resolve per app and §7 has to know what can be provisioned, and |
| +without it that knowledge is scattered across templates. |
| + |
| +**Mail and Files are `integrated: false`** — Keymail and Slopbox are |
| +separate products in separate repositories with their own auth. They render |
| +as plain outbound links, never as team-scoped tiles, until each verifies |
| +assertions per §5.3 (§14). |
| + |
| +No activity feed **yet** — ruling 18 approves one, pulled at render, blocked |
| +on `design-activity-pull`'s outstanding review and sequenced as §13 step 10. |
| +No template gallery, no upsell chrome, no waffle. |
| --- |
| @@ -693,7 +939,15 @@ New button. No template gallery, no upsell chrome, no waffle. |
| another's rows; 404, not 403. |
| 5. **Origin separation** — no account-authored string reachable on the |
| credential origin. §4 made mechanical. |
| -6. **Scriptless-affordance diff** — every page, script-stripped. |
| +6. **Scriptless-affordance diff** — every page, script-stripped. **For the |
| + ruling-21 screens: every affordance except the passkey ceremony button |
| + itself, and the allowlist is named explicitly in the test.** Exempting |
| + the whole screen would be wrong rather than merely loose — the |
| + confirm page carries the ceremony *and* the recovery-code form, and |
| + `passkey.SignInRecovery` is a plain form POST precisely "because |
| + recovery is exactly the moment WebAuthn or JavaScript isn't working". |
| + A blanket exemption would let that form regress unnoticed, on the one |
| + screen where scriptlessness matters most. |
| 7. **Contrast test** copying `ui/contrast_test.go`'s arithmetic verbatim. |
| 8. **Restore drill** — a real restore from replication, before launch, |
| against a written RTO. |
| @@ -719,6 +973,10 @@ record**, including which model reviewed it. |
| 7. Preferences, the assertion claim, call 6 |
| 8. Rekey journal (call 5), epoch, decommission |
| 9. Restore drill and the RTO |
| +10. **Activity on the front door** (ruling 18, `design-activity-pull`) — |
| + blocked on steps 3 and 4, and deliberately last. It has no claim on |
| + anything earlier and must not acquire one: the identity layer is the |
| + suite's critical path and this is a front-door nicety. |
| **Sheets and Docs are unblocked from step 3, not step 9** — assertions plus |
| a stub directory is enough for both. But **step 4 must land before either |
| @@ -733,12 +991,13 @@ ships**, because call 2's person ref is what their grant rows key on. |
| | **Owner-enforceable per-team 2FA** (ruling 15) | A team asks, *and* either a sixth call carrying team policy is argued past §2.2 or rastrillo grows a per-team notion | |
| | Pegamento's vault facet — team names the home cannot read | The vault ships with a recovery story | |
| | Operator reset for a fully locked-out person | The first person loses passkey and codes together | |
| -| Auto-drain of the queue | The platform ships a machine credential **and** we choose to give up the abuse gate — two decisions | |
| +| Auto-drain of the queue | The platform ships a machine credential, **and** we choose to give up the abuse gate, **and** ruling 19's fallback adjudication is automated — three decisions. A machine credential supplies no judgement about who may take a reserved name (§7) | |
| | **Suite-wide sign-out / session revocation** | Named as an exposure now: a stolen laptop's instance sessions live to their TTL with no revocation channel. Trigger: the first report, or before any team holds regulated data | |
| | **Account deletion / erasure** | Before a person asks, which under most regimes is not optional for long | |
| | **Step-up for instances** (`RequireFresh`-grade) | An instance flow needs it; requires a fresh-assertion round trip with a `max_age`-style parameter | |
| | Any preference beyond theme | It passes §5.4 | |
| | Billing, plans, quotas | Somebody has to pay | |
| +| **Mail and Files behind suite SSO** | Keymail or Slopbox verifies assertions per §5.3 in its own repository. Until then they are `integrated: false` registry entries rendered as outbound links (§11, ruling 17) | |
| | A support console | Never, under §0 | |
| --- |
| @@ -753,4 +1012,15 @@ ships**, because call 2's person ref is what their grant rows key on. |
| - **`carlos/platform` #4** — the machine credential. |
| - **`carlos/pegamento` #1** — the shared-instance ruling, and whether §9 |
| protects the credential or the knowledge. |
| -- **A real suite domain** — triggers §5.7. |
| +- **`design-activity-pull`'s own review pass.** Ruling 18 approves the |
| + shape; the design carrying it is explicitly unapproved — its §12 says the |
| + review AGENTS.md requires has not run. §2.2's verb does not change until |
| + it does. Also open there: the per-instance deadline, the fan-out budget, |
| + the RAM cache TTL and the body cap are all named as decisions and none is |
| + chosen. |
| +- ~~Class or attribute spelling — §11 needs a re-ruling~~ — **closed by |
| + ruling 20**, 2026-09-05: the app writes attributes, and the seven |
| + unpaired utilities keep `class`. |
| +- ~~A real suite domain — triggers §5.7~~ — closed by ruling 16. §5.7 is |
| + titled for the case it does not apply to: the `oficina.works` move needs |
| + no RP rename drill. |