Skip to content

SPA: farm switcher — decide session-switch vs. cross-farm identity before speccing #602

Closed as not planned

Description

@mforce

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 a path: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.boundAccountId lives 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:

  1. Add slug (and display name) to the /account response, and have the switcher render the
    device-local cluckwork.farmCodes roster 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.
  2. 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.
  3. 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 /account response 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's boundAt guard). A switcher makes that race routine rather
    than rare. Use the same intent-ticket shape; usePagedList is the canonical implementation.
  • TenantContext is 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

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

  1. mforce commented on Sep 13, 2026

    @mforce
    OwnerAuthor

    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:

    • Cross-farm identity is rejected by decision 3 — separate user rows per farm, no membership table (One user in many farms: membership table + account switcher #539, also closed). There is no supported way to correlate one human across farms, stated as product contract.
    • Switching is answered by decision 9: the SPA caches every successfully-used farm code per device and offers a picker. That shipped. A signed-in switcher would need cross-farm identity, which decision 3 declined.

    So there is nothing left to spec. Reopen only by reopening decision 3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions