oficina / home Public

Fold the approving review: gate 6 names its allowlist, and call 4b exists

Approved by Fable with one should-fix and three nits, folded here
rather than into another round.

Gate 6 was the real one. §11 and AGENTS.md both hung ruling 21 on "the
no-script test", and §12 had no such gate — so the rule said "a rule
with a list is checkable" and then put the list nowhere in the testing
section. Gate 6 now names the allowlist, and says why 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 regress on the one screen where
scriptlessness matters most.

"Nothing has to be written" overstated. webauthn.mjs exports bare
navigator.credentials wrappers and makes no network call, so the app
writes glue: fetch begin, call the wrapper, POST finish, follow the
returned target. Said plainly, with the consequence — the glue is a
static same-origin file, never inline, so the credential origin moves
from the script-src none a zero-JS RP could have had to script-src
self. Gate 5 is untouched: module bytes and app source are not
account-authored strings.

Call 4b was in §7 prose and not in §9's contract table. Docs and Sheets
implement from that table, so a call that lives only in prose is a call
nobody builds.

Rename now says it renames the display name only — a relabel is a
hostname move for every instance, which is a new team — and that 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. It is built
with the operator tooling in step 6.

Also: §10 still referred to a rename event §9 no longer has, §5.1 cited
auth_sessions as it was on 2026-08-30 rather than at the pin, and the
ruling table had 21 above 20.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 64cf1ab3306cd2338ba8f6a268187e2e23c7a2f0 parent fb42ef0
1 file changed, +45 −10
  • docs/superpowers/specs/2026-08-30-home-design.md +45 −10

CI passed — run details

Intent

Human

<task-notification> <task-id>a50435a02628965ff</task-id> <tool-use-id>toolu_013E9bSxxqv4trkSrKah2aUD</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina-home/d63f5ac7-6b42-48c5-a13d-34e6faf8e44c/tasks/a50435a02628965ff.output</output-file> <status>completed</status> <summary>Agent "Fable review 4: AGENTS.md" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>**VERDICT: APPROVE** Mergeable. One wording fix I would make in the merge commit (it is one clause and errs in the safe direction, so it does not block), plus cosmetic nits. Everything else verified at source. Housekeeping first: `git diff main..HEAD` shows gate 9's six test files as deletions — that is the merge-base artifact (branch cut from `462df84`, main since merged `9162ee5`). `git diff main...HEAD` touches only `AGENTS.md` (+138/−32). Nothing to worry about. ## 1. Utility carve-out — correct and complete **Method.** Stripped comments from `ui/tokens.css` at `github.com/carlosframework/rastrillo@v0.22.0`, extracted every `.rst-*` class selector (148 distinct) and every `[rst-*]` attribute selector (129 bare + 15 valued), then mapped each class to its attribute twin under rastrillo's grammar (`.rst-x` → `[rst-x]`, `.rst-x__y` → `[rst-x-y]`, `.rst-x--v` → `[rst-x~="v"]`, `.rst-x__y--v` → `[rst-x-y~="v"]`) and listed classes whose twin is absent. **Result:** exactly seven — `rst-sr-only` (5 class / 0 attr), `rst-mono` (2/0), `rst-m-hide` (1/0), `rst-grow` (2/0), `rst-nm` (5/0), `rst-danger` (5/0), `rst-cell-mut` (1/0). No eighth. Cross-checked against upstream's own authority: `internal/markup/markup.go:55` `var Utilities` is precisely these seven, and `ui/markup_v3_test.go` `TestClassesThatKeepTheirSpellingAreStillReal` asserts none has grown an attribute form. The rest of the paragraph's evidence also holds: the six surviving `class=` in v0.22.0 partials are `rst-danger` (bulk-bar), `rst-sr-only` ×3 (choice-field, field-daterange, list-bar-search), `rst-mono` ×2 (detail-list, error-page) — utilities, as claimed. 124→6 reproduces only with template comments stripped (raw is 128), so the stated method is load-bearing and correct. `callout.html` is `&lt;div rst-callout rst-tone="…"&gt;`. Header quotes are verbatim; "every *component* rule" is the header's own word; the "every shipped partial writes [classes] today" sentence is still there at line 24 and is indeed stale upstream. v0.20.0 and both 2026-08-30 pseudo-versions are 528/0; v0.21.0 (tagged 2026-08-30T23:14Z) is 536/468; first pin was v0.21.0 (`ef8a30f`). `rastrillo doctor` exists in v0.21.0's `cmd/rastrillo/doctor.go`, not v0.20.0. Nothing elsewhere in the file contradicts the carve-out. ## 2. Ruling 21 — one should-fix Everything matches the spec except one clause. Spec §11 and the ruling row say the screens "may load **script**"; AGENTS.md line 244-245 says they "may load **rastrillo's own `webauthn.JS()`**". That licenses *less*, not more — but less than the ceremony needs. `webauthn.JS()` is a library module exposing `register()`/`authenticate()`; rastrillo's own docs (`passkey/passkey.go` package doc, `docs/site/passkeys.md:68`) say the confirm page "runs `webauthn.mjs`'s `authenticate()`", i.e. the page needs its own script to import, call, and POST the result. Read literally, AGENTS.md forbids that glue on the only screens where script is allowed, while its own next sentence describes an allowlist *by screen*. Fix: mirror the spec — "may load script; rastrillo ships the client half (`webauthn.JS()`)". Spec-side note: the spec's "so nothing has to be written" slightly overstates too, for the same reason. Also: no no-`&lt;script&gt;` test exists in the repo yet, and spec §12 has no numbered gate for it (gate 6 is the affordance diff, still worded "every page"). AGENTS.md mirrors the spec faithfully; the gap is the spec's. ## 3. Staleness items - §11 opening line: fixed on the spec branch (`fb42ef0`) — now "the **attribute** vocabulary … (ruling 20)". - §15 re-ruling entry: fixed — struck through, "closed by ruling 20". - §5.1 `auth_sessions.address TEXT NOT NULL`: untouched and still stale. At v0.22.0 the table is created by `auth/migrations/0001_init.sql` but `0002_sessions_backfill.sql` copies its rows into `sessions` and empties it; `auth/store.go:26` says it is no longer written; the readable address now lands in `sessions.subject` via `handlers.go:136` `Subject: id.Address`, and in `auth_links.address` (`store.go:82`). AGENTS.md's Data bullet is the accurate one. Does it matter? Not for the ruling — ruling 14's grounds survive on `auth_links` and the Subject. It matters only in that the two files now cite different tables for the same fact; a one-line fix on the spec branch when next touched. ## 4. Whole-file correspondence Checked every citation against the spec branch: §0 wording, §2.1 (hibernate, verbatim), §2.2 four never-dos, §3.2/3.3/3.4 (log rule incl. "job status message"), §4 RP ID reasoning, §5.1, §5.3 (120s, `jti`, audience), §5.4 two-sided test and both retractions, §5.7 `LegacyRPID`, §6 quote (exact), §9.1 prune rule, §10, §12 gates 1/2/9 wording, §13 step 1. All match. Source claims: `passkey.Config` has no RP ID field, `RPID: u.Hostname()` (`passkey.go:161`), `New` validates only that `Origin` is absolute — "accepted silently" holds; `webauthn.go:122` `allowLegacy` only for assertions; `cmd/home/main.go` emits `logger.Error` on both exit paths; `CGO_ENABLED=0` in the Makefile; no `internal/hometest/`, no `static/`, `page.go` links no stylesheet. Two things AGENTS.md does not carry and need not for this branch: (a) never-do #1 is "under amendment" in spec §2.2 (ruling 18) but the spec says it stands as written until `design-activity-pull` passes review — AGENTS.md is currently right and gains a future trigger; (b) rulings 17-19's mechanical rules (compiled-in registry, `?team=` in the URL, label released on purge). Nothing contradicts them. ## 5. Overstated Nothing beyond item 2. Every count, version, and quote I could check reproduced. ## Nits (cosmetic, non-blocking) - Lines 33 and 41 both say "§13 step 1 lists all three"; line 41 is an unwrapped ~110-char line. - Lines 171-173 say "via `Subject: id.Address`" twice in one sentence. - Spec-side: §1 table orders ruling 21 above 20.</result> <usage><subagent_tokens>144783</subagent_tokens><tool_uses>30</tool_uses><duration_ms>446992</duration_ms></usage> </task-notification>

diff --git a/docs/superpowers/specs/2026-08-30-home-design.md b/docs/superpowers/specs/2026-08-30-home-design.md
index f9a6b69..d254bc7 100644
--- a/docs/superpowers/specs/2026-08-30-home-design.md
+++ b/docs/superpowers/specs/2026-08-30-home-design.md
@@ -61,9 +61,9 @@ blind, replaceable*.
| 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 |
-| 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 |
| 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
@@ -291,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.
@@ -689,6 +694,15 @@ verb exists before launch rather than after the first leak.
**Bootstrap:** a fresh instance gets its token as platform config and its
allowlist entry from the drain, in the step that creates it.
+**Call 4b — sibling instance request** (ruling 19's fast path) is
+instance → home, not person → home, and belongs in the table above rather
+than only in §7's prose: an instance asks for `<team>.<app>` under its own
+consumer credential, the home checks the calling origin against
+`team_instances` for that team, and enqueues the request with the label
+question settled. Docs and Sheets implement from that table, so a call that
+exists only in §7 is a call nobody builds. Named for `carlos/pegamento`'s
+re-review alongside the rest of §9.
+
**Call 2 drives two events**, not one: a grant changes; and an **instance**
is deleted (instance-scoped revoke — after §10's split this removes that
`team_instances` row and its directory rows, and leaves `teams` and its
@@ -702,7 +716,13 @@ 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.
+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
@@ -751,7 +771,7 @@ ceremony.
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 §9's rename event would mean two different things.
+ 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
@@ -837,9 +857,16 @@ 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 client half (`webauthn.JS()` embeds
-`js/webauthn.mjs`), so nothing has to be written — the only question was
-whether one screen may require it, and the answer is yes.
+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
@@ -912,7 +939,15 @@ 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.