0137 — The selection is a user setting in the database, not a URL parameter

Context

Today the org/property selection rides the URL. A signed-in user's address bar reads /dashboard?org=org_jpco&prop=*%40org_jpco — where *@ is WHOLE_ORG_PREFIX (src/lib/domain/scope/selection.ts), the "every building in this company" wildcard, leaking its internal encoding into a link a human reads. Gera raised it as a user-experience problem before raising it as an architecture one: "it's not a good experience."

Two facts already on the record frame it.

It costs a render. Board row ui-s-b (S-B · The pickers) records a measurement from PR #8301: mounting SelectionProvider made every signed-in page load pay an extra server render, because the selection is written back through router.replace. Measured three rounds per arm, deterministic — as shipped, 1 RSC nav fetch and 2 server renders of /dashboard; with history.replaceState instead, 0 and 1. The URL is not a free place to keep this.

The destination was already "one memory." The same row records the agreed end state: usePropertyFilter becomes a projection of Selection and its independent cookie and bridge are DELETED. This ADR does not change that; it says where the one memory lives.

Decision

1. The selection is a per-user setting, persisted server-side. Org and property selection are stored in the database against the user, the same way any other user setting is. Gera, verbatim: "the property and the org should just be … user settings. It would store in their database, so if they log out, log in, it's already chosen." Persistence across a logout is the acceptance test.

2. The URL does not carry the selection. Not as the source of truth and not as a mirror. Gera, verbatim: "we can all be on different profiles and we don't have to be storing that. I mean, we don't have to be sharing that in the URL. That kind of makes it a little … messy."

3. Writes are optimistic, and a failed write never blocks the screen. Selecting applies to the view immediately and the save goes out behind it. Gera, verbatim: "it should also be rendered real time. But it's not super durable. Like if you refresh and for some reason it messes up it shouldn't cause delays on the screen … it should just be like try to save it kind of energy." A save that fails leaves the previous stored value; it does not surface a blocking error, and it does not hold the render.

4. The reason the shareable-link argument loses, stated so nobody re-litigates it on instinct. A URL-borne selection has one genuine virtue: paste the link to a colleague and they see your view. That was put to Gera explicitly and he weighed it and declined: "most people will only have one org. Right? So it doesn't make sense to put it in the URL." Under one org the parameter is pure noise on every link, and the cross-user sharing case is rare. He also named the door back in — "maybe down the line we might change it" — so this is a decision with an explicit revisit condition, not a permanent bar. The revisit condition is a real multi-org customer with a demonstrated need to share a scoped link. Until that exists, no URL parameter.

Consequences

Resolution of the multi-tab hazard (2026-09-15, ruled)

The Consequences above record that per-user storage is COARSER than the cookie it replaces, and that the ruling was never weighed against the multi-tab cost. Gera, on being shown it: "yea, i see, think about it, cause we dont want that." He then asked for the decider. This section is that answer.

⚠️ ANSWERED BY ASTRA (gpt-6-astra), NOT BY FABLE. Gera asked for Fable specifically; Fable could not be reached — You've hit your weekly limit · resets Sep 17 at 7pm (America/Chicago) on both passes — and the ladder fell through to Astra, which answered under the same two-pass reversal discipline (confidence high / high, lower reported). Recorded because a ruling that says which model actually formed it is worth more later than one that implies two did. Receipt: ~/.claude/jobs/fable/ff2388d46.json, block b89509....

The shape

Three stores, three different jobs, and only one of them is ever trusted.

Where What it is
The default the user's row in the database what a FRESH tab, or a new login, starts on. This is the founder's acceptance test — log out, log in, already chosen.
The live value sessionStorage, per tab which selection THIS tab is looking at. Seeded from the default when the tab opens; rewritten when the picker is used in that tab. Invisible, survives refresh, never shared between tabs.
The authority neither see below.

Each data request STATES its scope explicitly (a client-attached header). The per-user row is written optimistically behind the render, per decision 3.

The four corrections the ruling makes to that shape

These are the reason this section exists; the table above was already the proposal, and on its own it is not safe.

  1. THE DEFAULT IS BOOTSTRAP-ONLY. A DATA READ WITH MISSING OR INVALID SCOPE MUST FAIL CLOSED — it must never select a default. The draft proposal had the per-user row serve as the fallback when a request said nothing. That recreates the bug: a fallback is still a guess, and a guess is what pairs one company's name with another company's money. The row answers "what should a new tab start on"; it never answers "what did this request mean". Note this also indicts deriveSelection's invalid-input defaulting on this path.

  2. A HEADER IS UNTRUSTED INTENT, NEVER AUTHORITY. It says which scope the tab wants; the server resolves the caller's grants and validates the request against them before reading anything. The machinery exists — src/lib/domain/scope/read-scope.ts mints grants and deriveSelection "re-validates every id in it against the minted grant" (its own words, pinned here because the claim is load-bearing). Treating a client header as authorization would expose data to a user who never had the grant, which is a strictly worse failure than the one this ADR set out to fix.

  3. A SCOPE-LESS SERVER RENDER MUST RETURN A NEUTRAL SHELL, and the tab's scope must be restored BEFORE any data is fetched. The draft proposed rendering the stored default and correcting on the client, mitigated by not painting money numbers until scope was confirmed. The ruling calls that insufficient: hiding the numbers is not the same as not having resolved the wrong scope. Render nothing scope-dependent until the tab has stated itself.

  4. KEY EVERY CACHE AND EVERY RESULT BY THE VALIDATED SCOPE, AND DISCARD STALE REPLIES. A response from a previous scope arriving after a switch is the same failure by a different route — so is a prefetch, an RSC payload, a mutation, or a cached page. Back is restored from history.state rather than re-derived. A save from one tab is persisted without being APPLIED to the others.

Scope of the work, corrected

The draft estimated a fetch wrapper and a storage hook. The ruling is explicit that this changes scope-dependent server rendering and is not merely a client-side addition. It also replaces the existing relay contract. Price it as that, not as the smaller thing.

Acceptance — two tabs, and the list is named

A two-tab test covering: refresh · in-app navigation and Back · responses arriving out of order · a failed save · logout then login · a grant revoked mid-session. These are the paths the ruling names as each able to pair one org's label with another org's money; a green suite that omits one of them is not evidence.

The other options, and why they lose

Alternatives considered

Companion rulings: ADR-0135, ADR-0136.