Ruling 19 gets an enforcement shape, and the argument it rested on was wrong
The review's sharpest finding, and it was right. §7 defended the reservation with "the home decides who may take a name, never who may read a document". That distinction is not what §2.2 turns on. §2.2 says never holds an authorization model, full stop — and the spec's own precedent proves it, because ruling 15 rejected a per-team 2FA floor, which has no content in it anywhere, on exactly these grounds. The wrong argument is now recorded as wrong rather than deleted, since it is the one somebody will make again. The real problem it was papering over: the first claim of a free label is a decision about a resource, which is fine, but "only Acme may later create acme.sheets" needs a principal, and the two obvious implementations — read the requester's directory kind, or store a first-claimant person ref — both have the home holding a person-to-resource entitlement. That is §2.2 and ruling 9 breached, quietly, by an implementation nobody would think to question. Paul ruled both paths. An existing instance can expand its own team: the call arrives under acme.docs's consumer credential and the home checks only whether that origin is already in team_instances for the team — a row it wrote itself at the drain. Who counts as an owner stays inside the instance, which is §2.2's second never-do working as designed. Everyone else falls to the drain, where a human decides and the requester's directory rows are a hint in ruling 9's sense and nothing more. So the home's entire vocabulary here is two facts about its own records: is this label free, and is this calling origin already an instance of this team. It never forms an opinion about a person's entitlement. That is a much narrower claim than the one it replaces, and it is the one that is actually true. Two consequences written down rather than left to be found: §14's auto-drain row gains a third decision, because automating the drain also automates the fallback adjudication and a machine credential does not supply judgement; and a purged or timed-out queue entry must release its label, or a request that never became a team holds a name nobody can claim. Display name ownership, which the §10 split left undefined: the home owns it, set once at provisioning by the operator who already sees every team. Call 2's sealed name is advisory and never overwrites, because otherwise a team renamed in Docs and not Sheets flaps with whichever instance woke last. Also: §11 cited ruling 18 as the reason there is no activity feed, which now reads the ruling backwards; ruling 17's rejected column gave a reason ruling 19 had made impossible; §9's "a team is deleted" is an instance being deleted after the split; and the Pair/Allocate claim is true of source files, not of the tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/superpowers/specs/2026-08-30-home-design.md+65 −23
CI passed — run details
Intent
<task-notification> <task-id>a227859a2f5b707bb</task-id> <tool-use-id>toolu_015BYRaDUccDXiWCvGomDCFk</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina-home/d63f5ac7-6b42-48c5-a13d-34e6faf8e44c/tasks/a227859a2f5b707bb.output</output-file> <status>completed</status> <summary>Agent "Fable review 3: gate 9" 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>I have everything I need; no further checks. Verdict follows. **VERDICT: CHANGES NEEDED** The redesign itself is right and I would not ask for a fourth shape of the scanner. Two things remain, both small and both provable by running code: one assertion whose stated reason is false for the likeliest route step 2 will add, and one transformed-leak encoding the branch's own principle says is a leak. Everything else claimed in the commit and the package doc checked out at source. ## Required changes ### 1. `internal/home/logs_test.go:132-148` — the route assertion's reason is false for a handler that answers 404 itself The comment (line 132) and the error text (line 145) both say "the route is declared but its handler never ran, so gate 9 did not scan it". I planted `r.Get("/teams/{team}", ...)` whose handler logs `chi.URLParam(r,"team")` through `slog.Default()` and then calls `http.NotFound`. Result, same run: ``` logs_test.go:145: GET /teams/CANARY-TEAM-NAME-7c2e answered 404: the route is declared but its handler never ran, so gate 9 did not scan it. ... logs_test.go:151: gate 9: "CANARY-TEAM-NAME-7c2e" reached a log line. ``` The handler ran, gate 9 scanned it and caught the leak, and the assertion said neither happened and pointed at `fillParams`. A directory route that 404s an unknown slug is the first real route this app will have, so this fires on day one of step 2 with a wrong diagnosis. It fails loud rather than silent, which is why it is not a scanner bug — but the repo's rule is that a stated reason must be true, and this one is not. Fix, verified against chi v5.3.2: attach a route context before serving and test whether chi dispatched, rather than what the handler answered. ```go rctx := chi.NewRouteContext() req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) router.ServeHTTP(w, req) if declared[tg] && rctx.RoutePattern() == "" { t.Errorf(...) } ``` Probe results: `GET /teams/CANARY -> 404 routed="/teams/{team}"`, `GET /no-such -> 404 routed=""`, `POST /healthz -> 405 routed=""`, `GET / -> 200 routed="/"`. `Mux.ServeHTTP` reuses a context already on the request, and `FindRoute` sets the pattern only on a match. The doc comment at lines 37-40 and the commit message's "answers 404 or 405 never ran its handler" need the same correction. ### 2. `internal/home/logtest/logtest.go:178-181` — `encodings` misses the form both handlers give a nested `[]byte` Every form in my previous table is now FOUND (full list below). Three related forms are MISSED, all the queue-payload canary: | value | TextHandler prints | JSONHandler prints | |---|---|---| | `struct{Payload []byte}` | `{Payload:[67 65 78 65 82 89 ...]}` | `{"Payload":"Q0FOQVJZLVFVRVVFLVBBWUxPQUQtOTNiZA=="}` | | `[]any{[]byte(...)}` | `[[67 65 78 ...]]` | `["Q0FO..."]` | | `map[string][]byte` | `map[p:[67 65 78 ...]]` | `{"p":"Q0FO..."}` | Both handlers print the content; the scanner does not recognise decimal bytes or base64. Production is `slog.Default()` unconfigured (see below), which uses the text path, so `[67 65 78 ...]` is exactly what reaches the platform log. A job struct with a `Payload []byte` field logged whole via `slog.Any("job", job)` is more plausible than logging the bare slice, and the test file's own comment says a payload "is the sink most likely to arrive as a byte slice"; `encodings`' own rule is "a transformed leak is still a leak", which is the reason percent-encoding is there. Add `base64.StdEncoding`, `base64.RawStdEncoding`, `base64.URLEncoding` and `fmt.Sprint([]byte(c))` forms, with a regression test for the struct-field case. (Hex `%x` and an explicit base64 string were also missed; those need a caller to encode deliberately, so I list them for completeness, not as required.) ## Verified **1. Fan-out catches the prior table.** All FOUND: plain struct with redacting `String`, nested struct, `[]tm` and `map[string]tm` of TextMarshaler (and map keyed by one), pointer-receiver `MarshalJSON` as pointer and as value (value leaks via `%+v` of the unexported field), LogValuer resolving to a struct, `time.Time` and `time.Duration` fields, `[]any{struct}`, `*string`, `%+v`-only Formatter, `MarshalText` error and `MarshalJSON` error (both via `!ERROR:` text), named byte slice, `json.RawMessage` (top level and as a struct field), TextMarshaler, group name with no attrs, inline group key, wrapped error, `With`-bound attr, `http.Header`, `url.URL` and `*url.URL`, `slog.GroupValue` inside `Any`, nested `map[string]any`. Nil `*T` with value-receiver `MarshalText` prints `<nil>`/`null` with no panic; a panicking `MarshalText` prints `!PANIC: boom`. **2. Mechanics.** `Record.Attrs` (record.go) only reads `front`/`back`, so two handlers on one record is sound; a `LogValuer` is resolved once per handler, and my flaky-valuer probe shows the union still catches a first-call-only leak. `recorderWriter` is correct: each stock handler builds a full line and issues one `Write` under its own mutex; `recorderWriter` takes `rec.mu` per `Write`, so interleaving is at line granularity. `-race` with 50 goroutines x 200 records: 50,000 lines, none split, no data race. `slog.Level(math.MinInt32)` is inert rather than wrong: `commonHandler.handle` never consults `enabled`, and the wrapper's `Enabled` returns true, so nothing is filtered at any level. `WithGroup("")` returns the receiver at the wrapper; stock `TextHandler.WithGroup` has no empty check at all (text_handler.go:54), so the wrapper's check is what preserves the contract. `WithAttrs` copies `groups`. **3. Escaping.** The premise in the question is wrong at source: `json_handler.go:152` sets `SetEscapeHTML(false)`, so `<`, `>`, `&` are not escaped, and `encoding/json` never escapes valid non-ASCII. Probe with `CANARY-ADDRESS-é-<&>@example.invalid`: both handlers print it verbatim, found by the raw form. `needsQuoting` (text) and `appendEscapedJSONString` (JSON) escape `\n \t " \\` identically and both write U+2028 as `\u2028`; they differ only on other control characters (`\u00XX` vs `\xXX`), which no canary carries. The `strconv.Quote` form is therefore sufficient for the canaries used; the comment at logtest.go:176-177 ("what JSONHandler does to one carrying an escape") is over-broad for control characters, a nit. **4. Route assertion.** `declared` is populated with the filled path and the declared method only, and the same key is compared after serving. Planted `{id:[0-9]+}` fails as claimed; planted DELETE-only passes. Also passed clean: `/files/*` wildcard, `r.Handle` (Walk yields all nine methods incl. CONNECT/TRACE, all routed), `r.Head`, `Mount` of a plain handler, `Mount` of a sub-router with `/`, `/{team}` and a nested `Route`. The nested-brace `{id:[0-9]{2}}` fails as the commit says (`fillParams` yields `/nb/{id:[0-9]CANARY...}`). The false trip is item 1 above. **5. Mutation.** All seven new tests fail against HEAD~1's `logtest.go` (including both panic subtests); the older tests still pass there, so the seven are the discriminating ones. **6. `make test`** passes under `CGO_ENABLED=0` with `GOCACHE` redirected: vet, gofmt, build, and all three test packages ok. **7. Comment accuracy at source.** - JSONHandler calls `json.Marshal` on the whole value, not `MarshalJSON` directly: true (`appendJSONMarshal` uses a `json.Encoder`); the error special case (`err.Error()` unless it is also a `json.Marshaler`) is at json_handler.go:128-130. - TextHandler uses `%+v`: true (text_handler.go:117), after TextMarshaler and byte-slice checks. - Panic recovery, `<nil>` for nil pointer else `!PANIC:`: true (handler.go `appendValue`). - Marshal failure prints `!ERROR:`: true, but the literal is `"!ERROR:%v"` with no space; the doc and commit write "!ERROR: …". Nit. - Empty groups dropped: true (`appendAttr` skips `len(attrs)==0`; `appendNonBuiltIns` opens no groups when `NumAttrs()==0`). Probe confirms the group name appears only via the wrapper's explicit write. - "The production sinks are these two handlers": loose. `main.go` uses `slog.Default()` unconfigured and rastrillo only falls back to `slog.Default()`, so production is slog's `defaultHandler` (`commonHandler{json:false}` via `log`). Its value rendering is TextHandler's, so the scan is a superset; worth one sentence. - Rastrillo resolves `slog.Default()` at construction: true (serve.go:290, `recoverPanics` captures it). `slog.SetDefault` calls `log.SetOutput`/`SetFlags(0)` and skips that for a `defaultHandler`: true. chi `middleware.Logger` uses `log.New(os.Stdout, ...)` (logger.go:177), `Recoverer` writes `os.Stderr`: true. - Not-covered list: accurate. rastrillo exports no metrics (the only "metric" grep hit is "symmetric"); `jobs/jobs.go:199` does `j.finish(job, Failed, err.Error())` as described; `main.go` has exactly two `logger.Error` calls. Spec citations (§3.4, §12 gate 9, §13 step 1) and the AGENTS.md three-vs-four sink count against `fix/agents-md-matches-spec` are all as stated. - `logs_test.go:31` says "Two things are still on the author of step 2" and lists three bullets. Nit. **Merge readiness.** Not yet, but close: the gate no longer claims more than the scanner delivers on value rendering, which was the substance of the two prior reviews. What is still over-claimed is the route assertion's reason, and the one encoding gap is on the canary the branch itself singled out. Fix those two and this is mergeable; the nits can ride along or not.</result> <usage><subagent_tokens>113273</subagent_tokens><tool_uses>18</tool_uses><duration_ms>612348</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 357e53a..37b1dff 100644 |
| --- a/docs/superpowers/specs/2026-08-30-home-design.md |
| +++ b/docs/superpowers/specs/2026-08-30-home-design.md |
| @@ -58,9 +58,10 @@ 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, which merges two unrelated teams that picked the same word | Paul, 2026-09-05 | |
| +| 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. Name custody is not an authorization model** | 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"`** — §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 | |
| +| 19 | **A team name is reserved suite-wide. An existing instance may expand its own team; anyone else falls to the drain. 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 | |
| --- |
| @@ -125,8 +126,8 @@ Each is something somebody will propose in good faith. |
| 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, in RAM, addressed to the person entitled to it, does not make |
| - the home a place documents live. The verb changes when that design passes |
| + 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. |
| @@ -550,13 +551,45 @@ 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. |
| -**And this is not the authorization model §2.2 forbids.** The distinction is |
| -exact and worth keeping exact, because it is the one somebody will blur: the |
| -home decides who may take a **name**, and never who may read a **document**. |
| -A reservation is custody of a DNS label, adjudicated once at creation. |
| -Authorization is a per-request question about content, and it stays with the |
| -instance, which re-checks it on every request (§6). A home that answered the |
| -second would be holding the thing ruling 15 and §2.2 exist to keep out. |
| +**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: |
| + |
| +- **Fast path — an existing instance expands 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?** That is a row the home itself wrote at the drain, not a |
| + judgement about a person. 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. |
| +- **Fallback — everyone else goes to the drain.** A request for a reserved |
| + label from anywhere else is enqueued, and a human decides. The home may |
| + render the requester's own directory rows beside it as a **hint** (ruling |
| + 9), and that is all they are: the operator adjudicates, the home does not. |
| + |
| +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 |
| @@ -680,7 +713,12 @@ ceremony. |
| - `directory` — person ref, app, team sqid, host, kind, updated_at. |
| - `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. |
| + 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 §9's rename event would mean 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 |
| @@ -717,9 +755,10 @@ against **536** `.rst-` class occurrences. The twins arrived in **v0.21.0**, whi |
| is what this repo's first `go.mod` pinned — so the claim was falsified |
| before the skeleton existed, not by a later bump. |
| -**The rule survives its evidence, but its evidence is now gone twice over: |
| -write `class="rst-*"` because this section rules it, and for no other |
| -reason.** Two reasons died. "The attribute grammar is unreleased" died at |
| +**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 |
| @@ -727,16 +766,18 @@ 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 this ruling is itself due a re-ruling, and §15 carries it.** rastrillo |
| -§6-v3 rules one spelling 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 this rule wanted to avoid. It is |
| -cheap to change now and expensive after the first template. |
| +**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` do remain in zero files. |
| +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 |
| @@ -792,8 +833,9 @@ 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 (ruling 18, §15), no template gallery, no upsell chrome, |
| -no waffle. |
| +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. |
| --- |