oficina / home Public

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>
Paul Campbell pushed by paul@keymail.dev 84a6108a6cf233de3bb9d5d1679a70e8bde928f7 parent b92c9b8
1 file changed, +121 −16
  • docs/superpowers/specs/2026-08-30-home-design.md +121 −16

CI passed — run details

Intent

Human

<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=&lt;sqid&gt;` 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/&lt;sqid&gt;` → `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=&lt;sqid&gt;` — 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.