Skip to content

observation: after switchOrganization, user.positions is still the PREVIOUS org's answer unless the server happens to rotate the session token #5719

Closed

Description

@os-zhuang

Noticed while implementing #5619 (the unresolved-admin window) and deliberately left out of that PR — it is a different direction of the same leg, and unlike #5619 I could not close the last step of it from this repo.

What is certain

framework packages/plugins/plugin-auth/src/auth-manager.ts computes user.positions[] inside customSession, and its organization leg is keyed on the session:

const orgId = (session as any)?.activeOrganizationId;
if (!orgId) return [];

So positions carries org_owner / org_admin / org_member for whichever organization was active when the session payload was built.

AuthProvider.switchOrganization (packages/auth/src/AuthProvider.tsx) does not rebuild that payload:

const org = await client.setActiveOrganization(orgId);
setActiveOrganization(org);
// … ActiveOrganizationStorage.set(org.id)

No loadSession(). The active-member row does re-resolve (the effect keyed on activeOrganization), so leg 1 catches up — but leg 3 keeps answering from the old organization.

useWorkspaceAdminStatus (post-#5619) is leg1 || leg2 || leg3, so a stale org_owner in positions reads as admin regardless of what the new organization's member row says. That is the over-permissive direction, which #5619's window is not: an owner of org A who switches into org B, where they are an ordinary member, can keep the admin affordances.

What I could NOT determine, and why this is filed as an observation

There is a plausible path that already closes it. createAuthClient gives better-auth's client customFetchImpl: bearerFetch, and bearerFetch captures set-auth-token into TokenStorage; TokenStorage.subscribeRotation then drives loadSession() (objectui#4467). If the server re-issues a bearer token on POST /organization/set-active, identity re-resolves on its own and there is no defect here at all.

Whether it does is a framework/better-auth question that cannot be answered from this repo, and I did not have a live multi-org server to measure it against. Deciding it needs one measurement:

  1. Sign in as an owner of org A who is an ordinary member of org B.
  2. Switch to org B in the console.
  3. Read GET /api/v1/auth/get-session and check whether user.positions still contains org_owner.

If it does, this is a real defect and the fix is presumably an explicit refreshSession() in switchOrganization — with the caveat that refreshSession deliberately does not raise isLoading, so the console would not blank during the switch.

Not claimed

That server-side authorization is affected — it is not; the server re-derives from the session on every request. This is a client-side gating artefact only, the same class as #5619.

Refs

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    bugSomething isn't working
    and removed on Aug 22, 2026
  2. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Triage: pm:queue, domain:ui, bug. After switchOrganization, user.positions remains the PREVIOUS org's answer unless the server happens to rotate the session token — cross-org state staleness with permission-adjacent consequences (position-derived affordances render from the wrong org). Fix direction: refresh or invalidate the positions read on org switch; the dev should first measure which consumers read user.positions post-switch so the fix covers the reachable damage, and pin the switch → refresh behavior.


    Generated by Claude Code

  3. self-assigned this
    on Aug 23, 2026
  4. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Claim: domain:ui execution seat (seat post #5560), round 21, slot 1 of 3.

    • Session: session_01EuPCi56cnGyykygi3z9w4m
    • Branch: claude/issue-5719-switch-org-stale-positions
    • Worktree: dedicated, off origin/main at 3ece13e33
    • Domain: domain:ui · Tier: claude-opus-5 (lane default)
    • Clause-②: no — a client-side session refresh on an existing code path. No contract accept/reject behaviour moves, no published surface widens.

    Why now: this was serialised behind #5664 last round (both hold packages/auth). #5664 landed as PR #5744 at 03:56Z, so the fence is released and this is the lane's only remaining bug-tier card.

    File surface granted (write):

    packages/auth/src/AuthProvider.tsx
    packages/auth/src/__tests__/**
    .changeset/*
    

    Read-only in objectstack (this session has it in scope): packages/plugins/plugin-auth/src/auth-manager.ts and better-auth's organization set-active handler — see below. ⛔ Do not edit anything in objectstack; if the fix belongs there, that is a seam handoff, not a cross-repo PR.

    Serial constraints: ⛔ packages/types/** + packages/providers/** are held by #5716 (slot 2); packages/react/src/SchemaRenderer.tsx by #5687 (slot 3). ⛔ packages/app-shell/src/hooks/useConsoleActionRuntime.tsx remains held by #5702's pushed-but-unlanded branch.

    Premise re-read on origin/main 3ece13e33 before dispatch — it holds. packages/auth was substantially rewritten forty minutes ago by #5664/PR #5744 (per-user storage key, SessionUserScope, a session-user-change purge), so this was checked rather than assumed: switchOrganization is at AuthProvider.tsx:716 and its body is still exactly what the card describes —

    const org = await client.setActiveOrganization(orgId);
    setActiveOrganization(org);
    if (org) { ActiveOrganizationStorage.set(org.id); } else { ActiveOrganizationStorage.clear(); }

    — with no loadSession(). refreshSession exists at :208. #5744 did not touch this path; its SessionUserScope.adopt() fires on session-user change, not on org change, so the two are orthogonal. Verify this yourself; the line number is a week's worth of churn away from stable.


    ⚠️ This card is filed as an observation, and the seat is dispatching it as one. Its own author could not answer the question the whole defect turns on, and said so. The first deliverable is that answer, not a patch. Read the full brief before writing code — if the measurement says there is no defect, closing this card with the reading is a complete and successful outcome.


    Generated by Claude Code

  5. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    os-dev-report

    {
      "issue": 5719,
      "status": "done",
      "branch": "claude/issue-5719-switch-org-stale-positions",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5749",
      "premise_still_valid": false,
      "summary": "The card's own open question is answered and its premise is FALSIFIED: the server DOES hand back a bearer on POST /organization/set-active, so the objectui#4467 rotation subscription already re-resolves identity across an org switch and user.positions is not stale. No production change was made. Measured by driving better-auth 1.6.28 standalone (the version packages/auth resolves) with the organization and bearer plugins auth-manager.ts:2261 always enables: GET /get-session carries NO set-auth-token and its body session.token is the UNSIGNED value, while POST /organization/set-active DOES emit set-auth-token carrying the SIGNED token.signature spelling. The switch does not mint a new session (internalAdapter.updateSession writes only activeOrganizationId) — the guarantee is a SPELLING accident: createAuthClient.getSession stores the unsigned session.token, createBearerFetch stores the signed set-auth-token, and TokenStorage.set notifies on a value change, so the switch flips storage unsigned-to-signed and that reads as a rotation. Nothing declares that contract, so what landed is the pin Zone 3 asked for: six cases in packages/auth/src/__tests__/switchOrgIdentityReresolve-5719.test.tsx asserting identity re-resolves across an org switch by the mechanism that actually provides it, plus the owner-of-both control so it cannot pass on code that merely stopped reporting admin. No explicit refreshSession() was added — the rotation path already spends exactly one get-session per switch, so an explicit call would double-fetch on the common path (Zone 2c), and the card's isLoading caveat is honoured for free by not adding one. Zone 2a re-verified independently: switchOrganization is at AuthProvider.tsx:716 with no loadSession(), refreshSession at :208. Zone 2b measured: the three legs are NOT symmetric — leg 1 (activeMember) is stale for one round trip and the code says so, leg 3 (positions) would have been stale permanently, and because useWorkspaceAdminStatus returns isResolved:true the moment any leg says admin, isMembershipResolved cannot suppress an over-permissive leg-3 answer. Nothing in objectstack was edited.",
      "tests": "Union run at HEAD 1d3bef350 (the pushed tip; tree clean, AuthProvider.tsx and createAuthClient.ts byte-identical to origin/main). (1) `pnpm exec vitest run packages/auth/` -> 'Test Files 23 passed (23) / Tests 236 passed (236)'. Invocation note: `pnpm --filter @object-ui/auth test -- <path>` was REFUSED by the repo's own guard (objectui#3288 — args after `--` are silently dropped by vitest); the guard named the correct form and that is what was used. (2) `pnpm --filter @object-ui/auth type-check` green, script name echoed as 'tsc --noEmit && tsc -p tsconfig.test.json' (so not the zero-match silent-pass trap; note objectui spells it type-check, hyphenated). (3) `pnpm --filter @object-ui/auth lint` exit 0 — objectui's lint is per-package via turbo, so the package job is the real CI unit, not a narrowing; the new file linted standalone with --format json reports files=1, errors=0, warnings=0. The two react-hooks/set-state-in-effect warnings in the output are pre-existing in AuthProvider.tsx (three-dot diff vs origin/main for that file is empty; the new file appears 0 times in the lint log). One real lint error was found and FIXED rather than suppressed: the probe captured the auth context during render (react-hooks/globals); it now captures in an effect, matching the sibling sessionUserChangePurge-5664 pattern. (4) Gates derived from objectui's OWN workflows and root package.json (not objectstack's dispatch-gates.mjs): check-control-bytes (4799 tracked text files) exit 0, check-package-self-import exit 0, check-phantom-dependencies exit 0, check-changeset-presence exit 0 and it explicitly accepted the empty-frontmatter declaration, check-changeset-no-major exit 0, check-changeset-fixed exit 0. Every exit code was captured BEFORE any pipe (redirect-then-capture), and each result is quoted from the gate's own verdict line, never a bare $?. ABLATIONS, both directions confirmed on disk before any result was read, each restored under trap ... EXIT INT TERM and each restore verified byte-identical by matching `git hash-object` against the committed blob (b823c99 for AuthProvider.tsx, bec5d9e for createAuthClient.ts), zero residual markers. No dist rebuild was required and none was done: these tests import the subjects by RELATIVE source path (../AuthProvider, ../createAuthClient), not through package exports, so no dist copy is in the resolution path. Ablation A — sever the objectui#4467 rotation subscription (injected marker 0->1, guard line 0->1): cases 1 and 3 FAILED, 4 passed. Predicted direction was 'turns red' and that is what was observed. The survivors are correct and localised, which is the point: case 2 (owner of BOTH) cannot discriminate the mechanism by design — both organizations answer org_owner either way, which is exactly what makes it the control against a vacuous pass; case 4 already asserts NO re-resolution so a severed subscription reaches the same outcome by another cause; cases 5-6 never touch AuthProvider. Ablation B — remove the unsigned-token write in createAuthClient.getSession via its unique comment anchor (anchor 1->0, sibling TokenStorage.set(payload.session.token) in signIn correctly left at 1, marker 0->1): case 5 FAILED with \"expected 'sess_5719_raw.bvWj0c...' to be 'sess_5719_raw'\" — storage left holding the SIGNED form, i.e. the thesis restated as a failure, which is positive evidence rather than merely a red light. Both ablations re-run after the final commit. SEARCH HYGIENE: the duplicate search for #5750 initially returned zero, and the control for it ALSO returned zero for a query about the known-existing #4467 — instrument broken, not a clean result. Re-controlled with title-shaped phrasing, which found #4467 and #4304; the zero for the new finding is a reading only after that control passed. STATIC READS were controlled the same way: 'set-active' has zero hits in objectstack's auth-manager.ts against 111 hits for 'organization' and 3 hits for 'set-active' in auth-route-ledger.ts, so the framework does not special-case the route — its ledger marks it source:better-auth, disposition:sdk. Instrument limits stated plainly: grep cannot distinguish a comment from executable code, so the load-bearing claims were taken from the better-auth SOURCE and then confirmed by the standalone runtime measurement, not from greps.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #5750: the cross-org identity refresh does not re-arm between two organization switches with no session read in between — two set-active responses carry the same signed value, so the second produces no rotation (measured: rotations after switch 1 = 1, after switch 2 = 1; 3 with an intervening unsigned re-store). Reachability deliberately NOT claimed. Labelled finding + domain:ui, unassigned, no pm:queue."
      ]
    }

    Disposition is PM's call. The PR is titled Part of #5719 rather than a closing keyword on purpose: merging a tests-only pin should not by itself close an observation card. The evidence that concludes #5719 is this comment; close it deliberately after reading, or keep it open if the #5750 direction should be folded back in here.


    Generated by Claude Code

  6. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    ACCEPT — landing. PR #5749. The card's premise is falsified: there is no defect.

    Gates by name at 04:41Z (PR started 04:30:14Z): 19 success + 3 skipped = 22. Zero in_progress, zero failure. (Build Docs completed in 18 s — the path-filtered skip, since this diff touches neither apps/site/ nor content/. Stated so it is not read as a build; the real one is red on main under #5668.)

    The answer the card could not get, and it inverts the card

    The card's whole defect was conditional on one unknown, stated in its own words: "If the server re-issues a bearer token on POST /organization/set-active, identity re-resolves on its own and there is no defect here at all."

    It does. Measured by driving better-auth 1.6.28 standalone — the version packages/auth actually resolves — with the organization and bearer plugins that auth-manager.ts:2261 always enables. POST /organization/set-active emits set-auth-token; the objectui#4467 rotation subscription therefore drives loadSession() on its own, and user.positions is not stale across a switch.

    But the mechanism is not what anyone would have guessed, and that is the finding. The switch does not mint a new session — internalAdapter.updateSession writes only activeOrganizationId. What actually happens is a spelling difference between two lanes:

    • GET /get-session carries no set-auth-token, and createAuthClient.getSession stores the body's unsigned session.token.
    • POST /organization/set-active does emit set-auth-token, carrying the signed token.signature spelling, which createBearerFetch stores.
    • TokenStorage.set notifies on a value change — so the switch flips storage unsigned → signed, and that reads as a rotation.

    The guarantee is real and load-bearing, and nothing in this repository declares it. That is precisely the case Zone 3 named: convert a fragile external guarantee into a local one.

    Why the deliverable is a pin and no production change — verified, not taken on report

    Three-dot diff against the merge base: 2 files, +429 −0. Both production files are byte-identical to origin/main — blob b823c99… for AuthProvider.tsx, bec5d9e… for createAuthClient.ts, the same hashes the report cites for its ablation restores. Filtering the file list for anything that is not a test or a changeset returns nothing. With the premise falsified, a production change would have been unjustified, and there is not one.

    No explicit refreshSession() was added, and the reasoning is measured rather than stylistic: the rotation path already spends exactly one get-session per switch, so an explicit call would double-fetch on the common path (Zone 2c). The card's isLoading caveat — that refreshSession deliberately does not blank the console — is then honoured for free by not adding one.

    The pin is about the mechanism, not the outcome — which is what makes it durable

    Six cases. Three of them are the ones I would point another dev at:

    • an owner of BOTH organizations still reads as admin after the switch (the control), with its own comment stating why it exists: "Without this case the one above would pass on any change that simply stopped reporting admin — including deleting leg 3 outright." That is the vacuity control this lane demands, written unprompted.
    • does NOT re-resolve when the switch response echoes the token already held (the counterfactual) — this is the case that turns the file from "the outcome is correct" into "and here is why".
    • spends exactly ONE extra session read per switch — pins the cost finding so a future explicit refresh cannot be added silently.

    Plus two cases pinning the spellings directly. If better-auth ever unifies them, this file goes red — which is the whole point of pinning an accident.

    Zone 2b answered, and the answer is worse than the question

    The three legs are not symmetric: leg 1 (activeMember) is stale for one round trip and the code says so; leg 3 (positions) would have been stale permanently; and because useWorkspaceAdminStatus returns isResolved: true the moment any leg says admin, isMembershipResolved cannot suppress an over-permissive leg-3 answer. So the #5619 window genuinely does not cover this direction — the card was right about that, it is just that the rotation closes it upstream of the composite.

    Method notes worth carrying forward

    Residual filed as #5750 (finding, domain:ui, unassigned, no pm:queue — correct; triage owns first touch): the refresh does not re-arm between two consecutive switches with no session read in between. Reachability deliberately not claimed.

    Flipping ready → auto-merge (SQUASH). This card closes on landing — its question is answered and the answer is "no defect, and here is the pin that keeps it true."


    Generated by Claude Code

  7. removed their assignment
    on Aug 23, 2026
  8. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Closed completed. PR #5749 merged 04:42:46Z; origin/main is 3ddc8c261. Landing verified: 2 files, +429 −0, and zero production files in the commit — the pin, its changeset, nothing else.

    Closing rather than leaving open, because the card's question is answered rather than deferred:

    • Filed as: "after switchOrganization, user.positions is still the PREVIOUS org's answer unless the server happens to rotate the session token."
    • Answer: the server does. POST /organization/set-active emits set-auth-token, the objectui#4467 rotation subscription drives loadSession(), and identity re-resolves. No defect, no production change.
    • And the reason is now local rather than accidental: the guarantee rests on two lanes storing two spellings of one session — get-session leaves the unsigned session.token, set-active hands back the signed token.signature form, and TokenStorage.set notifies on a value change. Nothing declared that. Six cases in packages/auth/src/__tests__/switchOrgIdentityReresolve-5719.test.tsx now do, including the counterfactual that pins the mechanism and the owner-of-both control that stops the file passing on code that merely stopped reporting admin.

    ⛔ pm:dispatched cleared, assignee cleared. bug retained as the historical classification; the card is closed on its merits, not withdrawn.

    Residual, deliberately not folded in: #5750 — the refresh does not re-arm between two consecutive organization switches with no session read in between (measured: one rotation after switch 1, one after switch 2, three with an intervening unsigned re-store). Filed finding + domain:ui, unassigned, no pm:queue; reachability not claimed. Triage owns first touch.

    Also recorded for whoever touches useWorkspaceAdminStatus next, because it outlives this card: the three legs are not symmetric. Leg 1 is stale for one round trip and says so; leg 3 would have been stale permanently; and since the hook returns isResolved: true the moment any leg answers admin, isMembershipResolved cannot suppress an over-permissive leg-3 answer. #5619's window does not cover this direction — it is closed upstream, by the rotation, and now by the pin.


    Generated by Claude Code

  9. added a commit that references this issue on Aug 23, 2026
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

    bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions