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 stay on the record, 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 rules it out is narrower and needs no future feature to make the case: 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 for a team you are not in. 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. Referrers send only the origin cross-site. And the platform keeps no per-request log carrying paths — its only per-request record is per-host counts and bytes — a claim §5.3 also made about "edge logs", corrected there too, since 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: an earlier draft patched that by putting the sqid on the New button, which fixes exactly one link. That second opinion is attributed to GPT-6 Astra, from Codex's own rollout record. An earlier draft of this row credited GPT-5.3, which is what was ASKED for and refused — every named model 400s on this account, and the answer came from the default. AGENTS.md says a review nobody can attribute is a review nobody can check, so the model is named from the record rather than from the request. `/` stays a generic entry: it picks the oldest team, by the integer the sqid encodes rather than the sqid's string order — which is why §10's `teams` now carries that id. Three costs are written down: a shared team link 404s for someone not in that team, a multi-team person always lands on their oldest, and a shared link followed while signed out must survive sign-in. That last one is a mechanism, not a cost, 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; nothing had written it down. Ruling 18 gains the scope Paul ruled on 2026-09-05: 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 caught and 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 three passes, each returning CHANGES NEEDED.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/superpowers/specs/2026-08-30-home-design.md+121 −17
CI passed — run details
Intent
<task-notification> <task-id>aa2400b77c5f8d95d</task-id> <tool-use-id>toolu_014twE1UEzTcwrb7x1QUPBep</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina-home/d63f5ac7-6b42-48c5-a13d-34e6faf8e44c/tasks/aa2400b77c5f8d95d.output</output-file> <status>completed</status> <summary>Agent "Fable review 4: 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>Everything I needed is in hand; nothing further to fetch. The edge source settles the last open claim exactly as the spec states it. **VERDICT: APPROVE** ## Verified at source **Git.** HEAD `c555788`, cut from `5a42f2d`; `main` `b92c9b8`. `git merge-tree --write-tree main HEAD` exits 0 (tree `dec7bbb`). One file changed: `/home/paulca/amadan.net/oficina/home/.claude/worktrees/team-session/docs/superpowers/specs/2026-08-30-home-design.md` (+117/-17). **All seven claimed fixes are in the file** (line numbers in the worktree copy): 1. §11 fragment paragraph (925-931): no activity-feed mention; rejects on grid, `aria-current`, gate 4's 404; no "(ruling 18)" citation anywhere near it. 2. Attribution (12, 69): GPT-6 Astra via Codex; `gpt-5.3-codex`/`gpt-5.3-sol` refused; name from Codex's rollout record. 3. §10 `teams` (781-783): integer id the sqid encodes, tied to the "oldest" rule. 4. §5.6 (466-471): return URL preserves query; gate 3 (1021) asserts it; §11 (965-968) defers to §5.6 rather than asserting a mechanism. 5. Ruling 18 row (65): "scoped PER TEAM, following `?team=`"; cross-team merged list in Rejected. 6. §13 step 10 (1073-1076): "not a merge-logic tweak", §2.1 re-justifying, §5.4 reopening revisiting. 7. Gate 10 (1040-1045) leads with what the test does; status line (10-12) records three Fable rounds and the Codex second opinion. **§1 table:** 22 rows, contiguous (header at 46, rows 48-69, no blank inside), four cells each. The stray blank between rows 20 and 21 on `main` is gone. **External citations resolve.** `design-activity-pull` (branch, file `2026-09-05-activity-pull-design.md`): its §2.1 really does carry the "only the home can answer *my things* rather than *this team's things*" argument (lines 141-144), its §6 is "Fan-out", its §12 says "Not run. This spec is not approved", and it describes the reopening of home's §5.4 (line 103) and a merged-across-instances list (488). rastrillo v0.22.0 `ui/partials/dropdown.html` is a `<details rst-dropdown>` of plain GET links with `aria-current="true"` on the current item, exactly as §11 says. **The edge-cache fact is true as written.** `carlos/platform/internal/edge/edge.go:1101-1140` (`maybeStore`): caches 2xx with a positive `max-age`; key is Host+Method+Path+RawQuery (`cache.go:33-40`); shared per host; refuses on response `Set-Cookie`, `Cache-Control: private`, `Vary: Cookie`, `Vary: *`, and a *request* `Authorization` without `public`. There is no check on a request `Cookie` anywhere in the package. So "it does not refuse on a request `Cookie`" is precisely right, and gate 10 is needed for the reason given. **AGENTS.md on `main`:** says nothing about team selection, cookies or `Cache-Control`; its "spec wins" clause covers the rest. No disagreement. (The branch's own copy of AGENTS.md is the pre-correction wording because it was cut before `main`'s fix, but the branch does not touch it and it merges clean.) **Provenance of ruling 22:** honestly stated. The row credits Paul with the ruling and Codex with a second opinion, and hedges the model name. The §11 narrative does not credit Codex with the decision. ## Nits (none blocking) 1. **Narrative gap, not falsehood.** §11 says "two rulings were drafted before this one and both were wrong" but never says `?team=` was already the position in the 2026-08-30 draft on `main` ("The selection lives in the URL (`?team=…`)"). Ruling 22 is a reaffirmation with reasons and the `/`-redirect mechanism added, after two detours. One clause would make that visible. 2. **Status line** (10): "Rulings 22 and its predecessors" should be "Ruling 22". And on merge it will read as three CHANGES NEEDED passes with no fourth; worth appending "fourth pass approved" when merging. 3. **Edge-cache fact stated twice** almost verbatim (§11 974-977, gate 10 1042-1045). Gate 10 could point at §11 instead. Also line 974 is unwrapped. 4. **"referrers send only the origin cross-site"** (936) is true under the browser default `strict-origin-when-cross-origin`; a builder should set `Referrer-Policy` explicitly rather than inherit a default. One clause. 5. **§10 cites "ruling 22's 'oldest team' rule"** but the §1 row does not contain "oldest"; the rule lives in §11's `/` paragraph. A reader finds it, but the citation is loose. 6. **"Oldest" means lowest `teams.id`**, which is monotonic only if the column is `AUTOINCREMENT` (plain SQLite rowid can reuse a deleted max). Worth one word when the schema is written. 7. **Gate 3** is titled "Assertion abuse suite" and now holds a functional, non-abuse assertion. Fine as a home for it since §5.6 lives there. 8. Cosmetic: chained em-dashes in the "No-store is route-wide" sentence (970-972).</result> <usage><subagent_tokens>104802</subagent_tokens><tool_uses>20</tool_uses><duration_ms>222613</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 d254bc7..788afab 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,11 +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 |
| @@ -392,7 +396,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 |
| @@ -455,7 +463,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 |
| @@ -764,7 +778,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 |
| @@ -885,12 +901,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)` |
| @@ -933,8 +1023,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 |
| @@ -953,6 +1043,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. |
| @@ -973,8 +1067,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. |