Repository navigation
Description
Coding brief — read first. This body is a starting point, not a spec. It was written from a
read of the shipped code on 2026-08-25 with every claim anchored to apath:line; verify those
anchors still hold before planning against them. Decide the "Which product is this?" question below
before writing any code — the two answers are different features with different blast radii.
Follow-up to epic #530 (Phase 1.6 — Multi-farm tenancy). Related to, but not the same as, #539.
The ask
A farm switcher in the SPA, so someone who works for two farms does not have to log out and back in.
What already works today — more than you would expect
Two farms are already live simultaneously, in separate tabs. That is the shipped design, not a
workaround:
- Refresh cookies are per farm —
cluckwork_rt_{accountId:N}(src/Cluckwork.Api/Endpoints/Auth/AuthCookies.cs:26).
They coexist in one browser. - The access token is memory-only, and
cluckwork.boundAccountIdlives in sessionStorage
(web/src/auth/tokenStore.ts:44) — so the binding is per tab. X-Cluckwork-Account(AuthCookies.cs:44) tells the server which farm's cookie to read. Present and
parseable, it reads only that farm's cookie (AuthEndpoints.cs:325-337).
The server already enumerates which farms this browser holds a session for:
AuthCookies.RefreshCookieAccounts(request) (AuthCookies.cs:90-100) walks the cookie names.
And it already has the "choose one" state: with no header and several cookies, refresh returns
Auth.FarmSelectionRequired (AuthEndpoints.cs:145,352) with the comment "Several means do not guess —
the SPA sends the user to login". The UX this issue asks for is the branch that code already
anticipates and currently punts on.
And #586 shipped the device-local roster: cluckwork.farmCodes (web/src/auth/farmCodeCache.ts:9),
plus per-farm palettes keyed by slug — which is what a switcher would render.
So the switch primitive exists. What is missing is a UI, and one concrete data gap (below).
The decision this issue turns on: which product is it?
(a) Session switcher — small, no schema change
"Switch between sessions this device already holds." Clear the tab's binding, send
X-Cluckwork-Account: <farm B>, refresh against B's cookie. Any farm whose cookie has expired drops to
the login form, farm code and all.
Honest label matters here: this is not "farms you belong to". It is "sessions on this device". Two
people sharing a browser produce a switcher listing both their farms.
(b) True account switcher — this is #539, and it is a much bigger change
One human, one identity, many farms. Needs the AccountMembership table #539 describes, because
ApplicationUser.AccountId is a single scalar and the JWT's account_id is a straight passthrough —
nothing ever chooses an account.
Do not drift from (a) into (b). (b) reverses epic #530's decision 3, and that decision has teeth:
with a shared user row, Farm A's owner pressing Set password would learn and control the password that
person uses at Farm B. Separate rows remove that by construction.
That is sharper than it reads, because of something found while writing the #530 decision record: on the
HTTP path an administrator-set password is not temporary — CreateUserHandler deliberately
leaves mustChangePassword false (src/Cluckwork.Application/Features/Users/CreateUser/CreateUserHandler.cs:29,
per #339) and the admin reset clears it (src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs:1024,1098).
Under separate rows that is contained to one farm. Under shared identity it would not be. Whoever specs
(b) must answer the password question first. See
docs/decisions/530-multi-farm-tenancy.md §9.
Concrete blocker for (a): the switcher has GUIDs, the UI needs names
RefreshCookieAccounts returns account GUIDs. The SPA's roster holds slugs. There is no
client-side mapping between them, and the Account DTO carries no slug
(web/src/api/cluckwork.ts, the Account interface). This is the same wall #586 hit: the account GUID
is structurally unavailable to the pre-session SPA.
Options, in rough order of cost:
- Add
slug(and display name) to the/accountresponse, and have the switcher render the
device-localcluckwork.farmCodesroster rather than the cookie list. Cheapest; the roster is
already slug-keyed and already user-curatable via SPA: let an operator forget a remembered farm code #587's Forget. Downside: the roster and the cookie
set can disagree — a remembered farm whose cookie expired, or a cookie for a farm the user forgot. - A small unauthenticated-ish endpoint that maps "cookies this browser presents" →
{slug, name}.
More accurate, but it discloses the farm roster to anyone holding the cookies, and needs its own
rate-limit and abuse thinking. - Put the slug in the refresh response.
Whichever is chosen, state what happens when the roster and the cookie set disagree — that
divergence is the switcher's main source of confusing behaviour.
Things to get right (learned the hard way in #586 / PR #600)
- A switch is a tenant boundary crossing. Anything cached per farm must be re-resolved, not carried:
the farm palette,cluckwork.lastFlockId, and any in-flight request. PR feat(web): key the farm brand palette per farm (#586) #600 shipped exactly this bug
in miniature — a/accountresponse fetched under farm A was cached under farm B after a switch, and
the fix was to capture the farm binding before the await and apply nothing if it changed
(web/src/lib/brand.ts,applyBrand'sboundAtguard). A switcher makes that race routine rather
than rare. Use the same intent-ticket shape;usePagedListis the canonical implementation. TenantContextis single-assignment and throws on a differing re-resolve
(src/Cluckwork.Infrastructure/Persistence/TenantContext.cs:22-35). Per-request, so a switch is fine —
but do not try to make one request serve two farms.- Suspension is checked per request (
CredentialEpochMiddleware.cs:54-57). Switching into a
suspended farm must fail cleanly, not blank the UI. - Disclosure: a switcher makes the device's farm list visible on screen. EPIC: Phase 1.6 — Multi-farm tenancy #530 already accepts the
device-local roster as a disclosure on a shared device; a switcher raises its prominence. Worth a
deliberate call rather than an accident.
Explicit non-goals
- Not One user in many farms: membership table + account switcher #539's membership table, and not cross-farm identity, unless the owner decides otherwise.
- Not cross-farm data views. One request, one tenant.
- Not a replacement for the multi-tab workflow, which already works and should keep working.
Suggested first step
A spike of (a) against the existing roster, to find out whether "sessions on this device" is a
convincing product or whether the honest version of it is confusing enough that only #539 is worth
building. That answer should come before a spec, not after.
Activity
mforce commentedon Sep 13, 2026
Closing as won't-fix — epic #530 answered the question this issue was filed to ask.
The issue asked to "decide session-switch vs. cross-farm identity before speccing". Both halves are decided:
So there is nothing left to spec. Reopen only by reopening decision 3.