Ruling 22: the team selection is navigation state, and lives in the URL
This is where the 2026-08-30 draft already had it. Ruling 17 said "the selection lives in the URL (?team=…)", and ruling 22 reaffirms that with the reasons supplied and /'s redirect added — after two detours that were both wrong, and which stay on the record because each looked right. The first said a URL fragment "cannot work" scriptlessly. It can: CSS :target switches on one with no request at all. What rules it out is narrower and needs no future feature — the server never learns which team you are looking at, so nothing server-rendered can be team-scoped: not the grid, not the switcher's aria-current, not gate 4's 404. The second 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. Referrers send only the origin cross-site under the browser default. And the platform keeps no per-request log carrying paths — its only per-request record is per-host counts and bytes. §5.3 had made the same "edge logs" claim since the original draft and is corrected too; its history and referrer reasons stand without it. With the leak argument gone the cookie lost on its merits, and a second opinion from Codex supplied the two that decide it: history does not restore a cookie, so Back has no correct behaviour; and a shared cookie couples every same-origin team-dependent request across tabs, which an earlier draft patched by putting the sqid on the New button — fixing exactly one link. That second opinion is attributed to GPT-6 Astra from Codex's own rollout record, not to the model that was asked for. Every named model was refused by the account and the default answered. AGENTS.md says a review nobody can attribute is a review nobody can check. / stays a generic entry: it picks the oldest team — the lowest integer id, which §10's teams table now carries and which must be AUTOINCREMENT, since a reused rowid would make "oldest" silently stop meaning oldest. Three costs are written down, and the third turned out to be a mechanism rather than a cost: a shared team link followed while signed out must survive sign-in, so §5.6 now requires the return URL to preserve its query and gate 3 asserts it. A step-3 builder reads §5.6, not §11's cost paragraph. Gate 10 is new: drive every route on both origins and assert each authenticated response carries no-store, the personalised redirect included. The need predates this ruling — the front door has always rendered the viewer's own rows — but nothing had written it down, and the platform edge caches any 2xx GET with a positive max-age, shared across every visitor, without refusing on a request Cookie. Ruling 18 gains the scope Paul ruled: the feed is per team, following ?team=. design-activity-pull specifies a cross-team merged list and argues for it, so that design must be revised — and not as a merge-logic tweak, since per-team retires its headline argument that only the home can answer "my things". One more invented citation removed: an earlier draft said §8 "refused a state-changing GET for theme". §8 says no such thing — it chose a redirect-based POST over a cross-origin credentialed fetch. Selecting a team mutates nothing, so GET is simply what HTTP means. Also rebuilds §1's table, which two reorder passes had broken. Reviewed by Fable across four passes; the fourth approved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/superpowers/specs/2026-08-30-home-design.md+121 −16
CI passed — run details
Intent
<task-notification> <task-id>a83cedddf66090a87</task-id> <tool-use-id>toolu_01U4pbMHofgKiZM4i7DeQ8HA</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina-home/d63f5ac7-6b42-48c5-a13d-34e6faf8e44c/tasks/a83cedddf66090a87.output</output-file> <status>completed</status> <summary>Agent "Fable review: ruling 22" 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: CHANGES NEEDED** The table rebuild is correct and the cookie *conclusion* is defensible, but ruling 22 rests on three reasons that are false or overstated — exactly the failure this spec keeps retracting — and it does not say how a scriptless switcher sets the cookie, which is where the "not the URL" framing quietly breaks. File: `/home/paulca/amadan.net/oficina/home/.claude/worktrees/team-session/docs/superpowers/specs/2026-08-30-home-design.md` (row 22 at line 66; §11 prose at lines 892–917). ## What holds - **Table rebuild.** 22 rows, contiguous, 5 pipes each, header and separator intact, `---` now after the blank line. On `main` the blank line ended the table at row 20 and row 21 followed by `---` rendered as a setext H2. Fixed correctly, nothing lost. - **"A fragment never reaches the server"** — true (RFC 3986 §3.5). - **`no-store` is sufficient at the edge.** `parseMaxAge` (`carlos/platform/internal/edge/edge.go:1275`) returns not-ok on `no-store`, `no-cache`, `private`, *and* on a missing `max-age`. The edge refuses `Set-Cookie` responses and `Vary: Cookie`, and does not refuse on a request `Cookie` — so a signed-in 2xx GET with any positive `max-age` is served to every visitor. The test is worth having. - **Not a §14 preference** — the distinction is real, but stated with the wrong test (below). ## Reasons that are false or overstated 1. **"Cannot work" for the fragment is false.** CSS `:target` reads the fragment with zero script: render every one of the viewer's team grids, `#acme:target { display:block }`, dropdown items are `href="#acme"`, default via `:not(:has(:target))`. That is a legitimate scriptless design — no request, no cookie, no log line, bookmarkable. It may still be the wrong trade (all grids in one page, CSS-only tab mechanics, `aria-current` via `:has`), but the row must say *that*, not "a scriptless page cannot use it". 2. **"The platform's own request logs" — I found no such log.** The edge writes no per-request line with a path or query (grep of `internal/edge`, `usage`, `applog`); `usage.EdgeRow` is per-host request counts and bytes; `carlos logs` is app stdout. Note §5.3 already says "edge logs" for the auto-POST decision — that inherited claim looks equally unsupported. The *real* §3.4 vector is the home's own stdout: if any request-logging middleware ever prints `GET /?team=acme`, gate 9 fires. That is a sound, narrower reason — but it applies equally to a `GET /switch?team=acme` link (see 4). 3. **§3.4 does not cover URLs, and team names already reach the platform.** §3.4's text is "no address, team name, or queue payload in any log line, error string, job status message or metric label" — the home's own emissions. Ruling 16 puts the label in every instance hostname, so it sits in the platform's per-host usage rows, TLS certs and DNS regardless. Referrers: default `strict-origin-when-cross-origin` sends only the origin cross-site, and the destination is `acme.docs.oficina.works` anyway. History: same. The row's "puts an account-authored name in referrers, history and platform logs" is wrong on all three. And §10 already has a **team sqid**: `?team=<sqid>` carries no account-authored string at all, which dissolves this objection to `?team=` entirely. The one surviving reason against `?team=` is the preference that front-door links not be team-specific — say that alone. 4. **The switcher mechanics are unspecified, and they put the team back in a URL.** `dropdown.html` (`rastrillo@v0.22.0/ui/partials/dropdown.html`) requires an `Href` per item — every item is a GET. A scriptless page cannot set a cookie without a request, so each item must be `GET /team/<sqid>` → `Set-Cookie` + `303 → /`. That is implementable, but: (a) the team is in a URL for that hop, so "not the URL" is true only of the front door's own URL; (b) it is a state-changing GET, which §8 refused for theme ("redirect-based POST"), and it is trivially cross-site triggerable (harmless, but the inconsistency should be ruled, not discovered); (c) the value must be the sqid, not the label, or reason 2 bites; (d) the endpoint must 404 on a sqid outside the viewer's rows (gate 4) and be on gate 6's list. None of this is in the spec. 5. **The §14 argument uses the wrong test.** "Dies with the session, survives no sign-out" — there is no sign-out (§14 lists it as not built), and a browser "session cookie" survives Chrome's restore-on-restart; if the directory session lives weeks, so does the selection and to the person it *is* "remembered". The distinction that actually holds is §8's: a preference is per person, suite-level, rides the assertion, and roams. The selection is per browser, never in the assertion, never in `prefs`. Say that. 6. **The "cost" is not new.** The front door already depended on the directory session cookie and rendered the viewer's own rows ("tiles come from the viewer's own rows"), so it could never be edge-cached before ruling 22 either. And it is not front-door-only: every authenticated response on both origins needs `no-store`/`private`. Make the test route-wide, not one URL. ## Consequences to write down - **New button:** the cookie is shared across tabs; a tab rendered for team A, after another tab switches to B, must not provision in B. New's target must carry the team sqid explicitly — i.e. that action stays in the URL. - **Fallbacks:** no cookie (one team → it; many → first/most recent, ruled), cookie naming a team the viewer no longer has, zero teams. - **Bookmarking a team's front door is lost** — state it as accepted. - §4 hostnames and tile links: unaffected. AGENTS.md: no mention of the selection, nothing to sync. **Minimum to approve:** rewrite row 22's "Rejected" column and the §11 paragraphs with the true reasons (fragment: `:target` trade rejected; `?team=`: link-shape preference only, since a sqid answers the leak claim); specify the switch endpoint (method, sqid value, cookie attributes, 404 rule, gate 6 listing); replace the §14 argument with the per-browser/never-in-assertion test; make the `no-store` test cover every non-static route on both origins and drop "now". Alternatively, given that the sqid kills the strongest objection, Paul may prefer to keep ruling 17's `?team=<sqid>` — the cookie design costs an endpoint and a state-changing GET to avoid a team-shaped link.</result> <usage><subagent_tokens>77757</subagent_tokens><tool_uses>14</tool_uses><duration_ms>268063</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 b3ee301..42c35d3 100644 |
| --- a/docs/superpowers/specs/2026-08-30-home-design.md |
| +++ b/docs/superpowers/specs/2026-08-30-home-design.md |
| @@ -1,13 +1,16 @@ |
| # 🤖 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 five 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). 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. |
| +all applied. **Ruling 22 reviewed by Fable across four passes |
| +(2026-09-05) — three returning CHANGES NEEDED, the fourth approving; the |
| +`?team=` decision took a second opinion from GPT-6 Astra via Codex.** |
| +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. |
| @@ -59,10 +62,12 @@ blind, replaceable*. |
| | 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 | |
| +| 18 | **Activity is approved in principle, pulled at render, sequenced last, and scoped PER TEAM, following `?team=`** — the front door shows a real feed with titles, the home holds none of it at rest, and its slot is a step 10 after step 4 — `design-activity-pull` | Push into the home; a contentless heat map; a cross-team merged list (which `design-activity-pull` specifies and argues for, so that design must be revised); 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 one, so Back has no correct behaviour; and a shared cookie couples every same-origin team-dependent request across tabs, not just New); a URL fragment read by CSS `:target` (the server never learns the team, so nothing server-rendered can be team-scoped — not the grid, not the switcher's `aria-current`, not gate 4's 404); a `prefs` row (§14) | Paul, 2026-09-05, after a second opinion from **GPT-6 Astra via Codex** (its default; `gpt-5.3-codex` and `gpt-5.3-sol` were both refused by the account, so the model is named from Codex's own rollout record rather than from what was asked for) | |
| + |
| --- |
| ## 2. What the home is |
| @@ -398,7 +403,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 |
| @@ -461,7 +470,13 @@ Absent from the first draft, and the abuse suite did not test it. |
| browser that began the flow. Without it an attacker can fix *their own* |
| signed assertion onto a victim's browser — login CSRF, and the victim then |
| works inside the attacker's account. |
| -- Both are in gate 3. |
| +- **The return URL preserves its query string**, validated host and all. |
| + Ruling 22 puts the team in `?team=<sqid>`, so a shared team link followed |
| + while signed out otherwise returns the person to a bare `/` and silently |
| + redirects them to a different team than the one they were sent. §11 states |
| + this as a cost; it is a mechanism, and it lives here because a step-3 |
| + builder reads §5.6, not §11. |
| +- All three are in gate 3. |
| ### 5.7 The RP rename drill — not needed for the `oficina.works` move |
| @@ -770,7 +785,9 @@ 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` — **the suite-wide team** (ruling 17): team sqid, **label** (the |
| +- `teams` — **the suite-wide team** (ruling 17): team **id** (the integer the |
| + sqid encodes — ruling 22's `/` entry picks the oldest team (§11) and needs it, and a sqid sorts |
| + by string not by age), 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 |
| @@ -891,12 +908,86 @@ 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 |
| +**oldest** if several — the lowest *id*, the integer the sqid encodes, |
| +not the sqid's string order. The column must be `AUTOINCREMENT`: a plain |
| +SQLite rowid can be reused after the highest row is deleted, and then |
| +"oldest" silently stops meaning oldest — 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. |
| + |
| +**This is where the 2026-08-30 draft already had it.** Ruling 17 said "the |
| +selection lives in the URL (`?team=…`)", and ruling 22 is a reaffirmation |
| +with the reasons supplied and `/`'s redirect added — after two detours that |
| +were both wrong. Recorded, because each looked right when written. |
| + |
| +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 — not the grid (which tiles, which New |
| +buttons), not the switcher's own `aria-current`, and not gate 4's 404 for a |
| +team you are not in. |
| + |
| +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 under the browser default |
| +`strict-origin-when-cross-origin`, which a builder should set explicitly |
| +rather than inherit; 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.** Selecting a team **mutates nothing** — it |
| + is a read of a different view — so GET is what HTTP means by it. A cookie |
| + would have introduced mutable state to express a location. (An earlier |
| + draft cited §8 as having "refused a state-changing GET for theme". It says |
| + no such thing: §8 chose a redirect-based POST over a *cross-origin |
| + credentialed fetch*, on CORS, SameSite and zero-JS grounds. The |
| + distinction that matters is real — a theme write mutates a stored |
| + preference and this does not — but the citation was invented, which is |
| + the failure this file keeps having to retract.) |
| + |
| +**Three costs, 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). `/` is the |
| +link to share when you mean "the suite". A person in several teams always |
| +lands on their oldest from `/`, which is stable rather than clever, and the |
| +switcher is one click away. And **a shared team link followed while signed |
| +out must survive sign-in** — §5.6 makes the return URL carry the query, and |
| +gate 3 asserts it; without that the person arrives at a bare `/` and is |
| +silently redirected to a different team than the one they were sent. |
| + |
| +**No-store is route-wide, and this is the first place the spec says so** — |
| +the *need* predates ruling 22 — the front door has always rendered the |
| +viewer's own rows — but nothing had written it down. Every authenticated |
| +response on both origins sends `Cache-Control: no-store`, `/`'s personalised |
| +redirect included, asserted by **gate 10**. 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` — which is why |
| +this needs a gate rather than a convention. |
| **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)` |
| @@ -939,8 +1030,8 @@ No template gallery, no upsell chrome, no waffle. |
| sign-in plus directory upsert plus rekey leaves no readable address |
| anywhere, WAL included. |
| 3. **Assertion abuse suite** — audience confusion, replay, expiry, algorithm |
| - confusion, signature stripping, **open redirect**, **login CSRF**, and |
| - **unknown `kid`**. |
| + confusion, signature stripping, **open redirect**, **login CSRF**, |
| + **unknown `kid`**, and **query preservation on the return URL** (§5.6). |
| 4. **Two-person isolation** — one person's directory never contains |
| another's rows; 404, not 403. |
| 5. **Origin separation** — no account-authored string reachable on the |
| @@ -959,6 +1050,10 @@ No template gallery, no upsell chrome, no waffle. |
| against a written RTO. |
| 9. **No-content-in-logs** — scan log lines, error strings and metric labels |
| for addresses, team names and queue payloads (§3.4). |
| +10. **No-store** — drive every route on both origins and assert each |
| + authenticated response carries `Cache-Control: no-store`, the |
| + personalised `/` redirect included (ruling 22). It exists for the edge-cache |
| + reason §11 sets out. |
| Review on a deployed canary, never localhost. Verify with |
| `X-Carlos-Version` on the canonical host after the watch. |
| @@ -979,8 +1074,18 @@ 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 |
| +10. **Activity on the front door** (ruling 18, `design-activity-pull`). |
| + **Per team, following `?team=` — RULED 2026-09-05 by Paul.** |
| + `design-activity-pull` as written specifies a *cross-team* merged list |
| + and argues for it explicitly, so **that design must be revised to |
| + match**; it is recorded here rather than left to whoever reads the two |
| + documents next. Per-team also shrinks the fan-out §6 there worries |
| + about, from every instance a person can reach to the apps in one team. |
| + **This is not a merge-logic tweak.** Per-team retires that design's |
| + headline argument — that only the home can answer "my things" rather |
| + than "this team's things" — so its §2.1 needs re-justifying and its |
| + §5.4 reopening revisiting, not just its query changing. |
| + 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. |