oficina / home Public

Ruling 22: the team selection is navigation state, and lives in the URL

Ruling 17 put it in `?team=`. Two drafts moved it elsewhere and both
were wrong; both are recorded here, because each looked right when
written and each will be proposed again.

The first said a URL fragment "cannot work" scriptlessly. It can — CSS
`:target` switches on one with no request at all. What actually rules
it out is narrower and stronger: the server never learns which team you
are looking at, so nothing server-rendered can be team-scoped, and
§13 step 10's activity feed is per-team.

The second moved it into a session cookie, on the reasoning that
`?team=` puts an account-authored name in referrers, history and
platform logs. False three ways. §10 gives every team a sqid, which is
opaque, so nothing account-authored is in that URL. Referrers send only
the origin cross-site. And no platform request log carrying paths was
found when it was checked — a claim §5.3 also makes about "edge logs",
now corrected there too, since its history and referrer reasons stand
without it.

With the leak argument gone the cookie lost on its own merits, and a
second opinion from GPT-5.3 via Codex supplied the two that decide it.
History does not restore a cookie, so Back can show Acme's page while
the next request from it resolves to Beta — display and server silently
disagreeing. And a shared cookie couples every tab on every request: an
earlier draft patched that by putting the sqid on the New button, which
fixes exactly one link, when every team-dependent link and form has the
same problem — and fixing them all means putting the team in the URL,
at which point the cookie does nothing.

Two smaller things fall out. Both shapes need Cache-Control: no-store
regardless, so the cookie bought nothing there. And §8's refusal of a
state-changing GET was about mutating a stored preference; selecting a
team mutates nothing, so it never applied.

`/` stays a generic entry: it picks a team and 302s to the explicit
URL, so "take me home" is one clean link for everybody while every team
view is addressable. The cost is that a shared team link is
team-specific — a 404 for someone not in it, per gate 4 — and `/` is
the link to share when you mean the suite.

The switcher goes back to rastrillo's zero-JS <details> dropdown of
plain GET links, rather than the form of submit buttons a cookie would
have required.

Also rebuilds §1's table, which two reorder passes had broken: a blank
line ended it early, and a later pass moved the section separator
inside it, so the newest rulings rendered as loose paragraphs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev f9ad2cf854042a5d621d4caef9ce94d8be2f1072 parent 5a42f2d
1 file changed, +69 −9
  • docs/superpowers/specs/2026-08-30-home-design.md +69 −9

CI passed — run details

Intent

Human

<task-notification> <task-id>a2123abc18b656902</task-id> <tool-use-id>toolu_019NGZX472Qgh5TBjfuTL9Vg</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina-home/d63f5ac7-6b42-48c5-a13d-34e6faf8e44c/tasks/a2123abc18b656902.output</output-file> <status>completed</status> <summary>Agent "Fable review 7: signin plan" 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** — this merges. ## What I verified **Merge state.** `HEAD` is `4aca0f9`; `merge-base main HEAD` is `5a42f2d` = `main`, so the branch sits directly on top of it. `git merge-tree --write-tree main HEAD` exits 0 with tree `57d4a05`. Diff against `main` is exactly `AGENTS.md`, the plan, and the spec. **B6 (the one block).** Landed. The AGENTS.md Data bullet (lines 170-183) carries both `main`'s `auth_sessions` correction and this branch's signin-not-auth one, and names `Links: &amp;linkStore{db: cfg.DB}` as the reason. That claim is true at the pin: `auth/auth.go:203` hardwires it, `auth/store.go:81` writes `address` into `auth_links`, and `auth/migrations/0001_init.sql:3,10` declares it `TEXT NOT NULL`. Spec §5.1 (lines 318-327) is corrected the same way. The plan's three "pending" statements (lines 7, 96, 1380) now say landed. The rulings-table blank line between rows 20 and 21 is gone, so ruling 21 renders as a row. **The nine follow-ups.** Eight confirmed in the text: Task 2's `git add` (line 551) has `app.go` and `testapp_test.go`; Task 4's (811) has `go.mod`/`go.sum`/`testapp_test.go`; `render.go` is created in Task 5 (820) and modified in Task 7 (1211); `CanaryAddress` must be in `Canaries` (347-349); `scriptSrcs` has one return (114, 1348); the `App` retains its `passkey.Config` (1352-1353); no stranded table row and no empty code fence anywhere in the file; `signIn` sends `Origin` (112, 132, 1041); the `logtestImport` const-to-slice wording (340-342) matches the real `const logtestImport` at `internal/guardstest/logtest_containment_test.go:12`. The ninth is not fully done — see follow-up 1. **Every source claim I could check at the pin holds.** `signin` v0.1.1: `Begin(ctx, address, clientIP string, force Method)`, IP budget skipped only when `clientIP == ""`, classification only when `Classifier` and `Keymail` are both non-nil, defaults 20/5/15min, `Key [32]byte`, `RedirectPath` default `/auth/callback`, `Class{Keymail, Server, Reauth}`, `CompleteMagicLink` sets only `At`, `SplitAddress` rejects a second `@`. rastrillo v0.22.0: `sessions.Config{DB, Origin, TTL, SigninPath}`, `Fresh` falls back to `At`, `(*Sessions).Sweep(now)`, `RequireFresh(maxAge)`; `passkey.Config` has no `RPID` field, derives it from `Origin`'s hostname, `ConfirmPath` default `/passkey/confirm`, `cfg` unexported, `Gate(w, r, sess) (bool, error)`, ten codes, `PostFormValue("code")`, `+passkey` vs `"passkey"`, `Sweep(db, now)`; `db.Open`'s DSN carries exactly the three pragmas and opens a writer capped at 1 plus 4+ readers, with `Ping` inside; `csrf.Protect` gates POST/PUT/PATCH/DELETE via `Sec-Fetch-Site`/`Origin`/`Referer`; `mail.Logged` logs `"to", to`; `auth.Verify` redeems on GET with `token`; `auth.Callback` redirects to `?force=1&amp;err=keymail`; `clientIP` returns `RemoteAddr` unchanged when `SplitHostPort` fails; default CSP is `default-src 'self'` with `unsafe-inline` only on `style-src`; `webauthn.JS()` exists and `register()` takes `{challenge, rpId, rpName, userId, userName, prfSalt}`; `webauthn/authtest` exists; `ui/partials` class-spelling count is 6. `modernc.org/sqlite@v1.55.0` has `RegisterConnectionHook`. carlos edge has `stripForwardedHeaders` and `SetXForwarded` in `internal/edge/homemap.go`. sheets-core's `seal`, `sealtest` (`Canaries [][]byte`, uppercase, `bytes.Contains`, `leaked`/`findLeak` split), `LoadSealer`, `LoadAuthEnv` and `render.go` are all as described. Read once more as the executor: every task has a failing test, a stated failure reason, an implementation, and a `git add` whose file list matches the files the task creates. Nothing would stop me cold. ## Follow-ups for the executor, in flight 1. **File Structure still misplaces `rpid_test.go`.** Line 210 lists it under `internal/guardstest/`; Task 7 (line 1212) and its commit (line 1360) put it in `internal/home/`, with the reason. Task 7 wins — fix the tree diagram when touching the file. 2. **`TestServedCSPIsRastrillosDefault` cannot reference `defaultCSP`** — it is an unexported const in `serve.go`. Assert the app leaves `Options.CSP == ""` and that the served header's `default-src`/`script-src` carries no `'unsafe-inline'`. Note for the executor: spec §11 says the CSP "moves to `script-src 'self'`", and the default already delivers that through `default-src 'self'`, so no override is needed. 3. **`mail.Sender` and `signin.Mailer` have identical method sets**, so the capturing sender plugs straight into `Flow.Mailer` with no adapter. Worth a one-line comment where it is wired. 4. `TestFailedClassificationStillSendsAMagicLink` passes `clientIP ""`, which skips the IP budget by design — that is fine for the unit, but the X-Forwarded-For test in Task 5 step 3 is the one that proves the budget is per-client. Keep both. 5. Task 1 step 5c and Task 2 step 5 both specify the `sync.Once` around `RegisterConnectionHook` before `db.Open`. Harmless duplication; the Task 2 version is the authoritative one.</result> <usage><subagent_tokens>125963</subagent_tokens><tool_uses>22</tool_uses><duration_ms>208595</duration_ms></usage> </task-notification>

Human

But maybe gross check that with codex if ?team= is better

diff --git a/docs/superpowers/specs/2026-08-30-home-design.md b/docs/superpowers/specs/2026-08-30-home-design.md
index d254bc7..7aabbd1 100644
--- a/docs/superpowers/specs/2026-08-30-home-design.md
+++ b/docs/superpowers/specs/2026-08-30-home-design.md
@@ -1,6 +1,6 @@
# 🤖 The Oficina home — design
-**Status:** design, revised **2026-09-05** (rulings 17-21, and four claims
+**Status:** design, revised **2026-09-05** (rulings 17-22, 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);
@@ -62,8 +62,9 @@ blind, replaceable*.
| 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 |
+| 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 a cookie, so Back shows one team while the next request resolves another; and a shared cookie couples every tab on every request, not just New); a URL fragment read by CSS `:target` (the server never learns the team, so §13 step 10's per-team feed cannot work); a `prefs` row (§14 — and it would roam across devices, which a browser-local choice should not) | Paul, 2026-09-05, after a second opinion from GPT-5.3 via Codex |
+
---
## 2. What the home is
@@ -392,7 +393,11 @@ valid at team B, so B never needs to know A consumed it. §10's
one.
**Delivery is an auto-POST, not a query string**, so assertions stay out of
-edge logs, browser history and referrers.
+browser history and referrers. (An earlier draft also said "edge logs". No
+platform request log carrying paths was found when this was checked on
+2026-09-05 — the edge records per-host counts and bytes, not URLs. The
+history and referrer reasons stand on their own and are sufficient; the
+third was inherited rather than verified.)
### 5.4 The boundary test for claims
@@ -885,12 +890,67 @@ 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.
+`<details>`/`<summary>` disclosure whose items are ordinary GET links to
+`/?team=<sqid>` — rastrillo v0.22.0 ships exactly that as
+`ui/partials/dropdown.html`, and ruling 22 keeps the switcher able to use it
+rather than needing a form of submit buttons. A `<select>` with an
+`onchange` would need script and gate 6 would fail it.
+**The selection lives in the URL** — `?team=<sqid>` on every team view —
+**because it is navigation state, not session state** (ruling 22). Which
+team you are looking at is *where you are*, and a URL is what a browser,
+its history, its tabs and another person all already understand that to
+mean.
+
+**`/` stays a generic entry.** It picks a team — the one you have, or the
+lowest sqid if several — and `302`s to the explicit URL, so "take me home"
+is still one clean link that works for everybody, while every actual team
+view is addressable. If you have no teams it renders the empty state rather
+than redirecting.
+
+**Two rulings were drafted before this one and both were wrong. Recorded,
+because each looked right.**
+
+An earlier draft said a **URL fragment** "cannot work" scriptlessly. It can:
+CSS `:target` switches on it with no request at all — `href="#acme"` and
+`#acme:target { display: block }`. What actually rules it out is narrower:
+the **server never learns which team you are looking at**, so nothing
+server-rendered can be team-scoped, and §13 step 10's activity feed is
+per-team (ruling 18).
+
+A second draft moved the selection into a **session cookie**, on the
+reasoning that `?team=` "puts an account-authored name in referrers, history
+and platform logs". False three ways: §10 gives every team a **sqid**, which
+is opaque, so nothing account-authored is in that URL at all; referrers send
+only the origin cross-site; and no platform request log carrying paths was
+found when this was checked. The cookie then lost on its own merits:
+
+- **History does not restore a cookie.** Press Back and the page you see may
+ be Acme's while the next request from it resolves to Beta — display and
+ server silently disagreeing, which is the worst shape a bug can take.
+- **A shared cookie couples every tab, on every request.** An earlier draft
+ patched this by putting the sqid on the New button; that fixes one link.
+ Every team-dependent link and form has the same problem, and fixing them
+ all means putting the team in the URL — at which point the cookie does
+ nothing.
+- **It buys nothing on caching.** Both shapes need `Cache-Control:
+ no-store`; a cookie does not protect a response from the platform edge.
+- **GET is the right verb here.** §8 refused a state-changing GET for
+ *theme*, which mutates a stored preference. Selecting a team mutates
+ nothing, so §8's rule does not reach it, and a cookie would have
+ introduced mutable state to express a location.
+
+**The cost, stated rather than discovered.** A shared front-door link is
+team-specific: send someone `/?team=<sqid>` and they land on that team, or
+on a **404 if they are not in it** (gate 4 — 404, never 403). That is the
+right behaviour, and `/` is the link to share when you mean "the suite".
+
+**No-store is route-wide and is not new.** Every authenticated response on
+both origins sends `Cache-Control: no-store`, `/`'s personalised redirect
+included, asserted by a test. The platform edge caches any 2xx GET carrying
+a positive `max-age`, keyed on host, path and query, shared across every
+visitor, and it does **not** refuse on a request `Cookie` — so this matters,
+and it mattered before ruling 22, since the front door has always rendered
+the viewer's own rows.
**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)`