Repository navigation
observation: after switchOrganization, user.positions is still the PREVIOUS org's answer unless the server happens to rotate the session token #5719
Description
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:
- Sign in as an owner of org A who is an ordinary member of org B.
- Switch to org B in the console.
- Read
GET /api/v1/auth/get-sessionand check whetheruser.positionsstill containsorg_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
useIsWorkspaceAdmincollapses "not resolved yet" intofalse, so an org-role admin renders once as a non-admin — every admin-gated surface can refuse or hide from a real admin #5619 — the same leg, the other direction (unresolved rather than stale); its PR addedisMembershipResolved, which already reports "unresolved" across a switch for leg 1, but cannot speak for leg 3.- objectui#4467 — the rotation subscription that may or may not already cover this.
Activity
os-zhuang commentedon Aug 23, 2026
Triage:
pm:queue,domain:ui, bug. AfterswitchOrganization,user.positionsremains 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 readuser.positionspost-switch so the fix covers the reachable damage, and pin the switch → refresh behavior.Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
Claim:
domain:uiexecution seat (seat post #5560), round 21, slot 1 of 3.session_01EuPCi56cnGyykygi3z9w4mclaude/issue-5719-switch-org-stale-positionsorigin/mainat3ece13e33domain:ui· Tier:claude-opus-5(lane default)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 remainingbug-tier card.File surface granted (write):
Read-only in objectstack (this session has it in scope):
packages/plugins/plugin-auth/src/auth-manager.tsand better-auth's organizationset-activehandler — 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.tsxby #5687 (slot 3). ⛔packages/app-shell/src/hooks/useConsoleActionRuntime.tsxremains held by #5702's pushed-but-unlanded branch.Premise re-read on
origin/main3ece13e33before dispatch — it holds.packages/authwas 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:switchOrganizationis atAuthProvider.tsx:716and its body is still exactly what the card describes —— with no
loadSession().refreshSessionexists at:208. #5744 did not touch this path; itsSessionUserScope.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.Generated by Claude Code
test(auth): pin that identity re-resolves across an org switch (#5719)
os-zhuang commentedon Aug 23, 2026
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 #5719rather 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
os-zhuang commentedon Aug 23, 2026
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+ 3skipped= 22. Zeroin_progress, zerofailure. (Build Docscompleted in 18 s — the path-filtered skip, since this diff touches neitherapps/site/norcontent/. 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/authactually resolves — with the organization and bearer plugins thatauth-manager.ts:2261always enables.POST /organization/set-activeemitsset-auth-token; the objectui#4467 rotation subscription therefore drivesloadSession()on its own, anduser.positionsis 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.updateSessionwrites onlyactiveOrganizationId. What actually happens is a spelling difference between two lanes:GET /get-sessioncarries noset-auth-token, andcreateAuthClient.getSessionstores the body's unsignedsession.token.POST /organization/set-activedoes emitset-auth-token, carrying the signedtoken.signaturespelling, whichcreateBearerFetchstores.TokenStorage.setnotifies 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— blobb823c99…forAuthProvider.tsx,bec5d9e…forcreateAuthClient.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 oneget-sessionper switch, so an explicit call would double-fetch on the common path (Zone 2c). The card'sisLoadingcaveat — thatrefreshSessiondeliberately 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 becauseuseWorkspaceAdminStatusreturnsisResolved: truethe moment any leg says admin,isMembershipResolvedcannot 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
set-activehas zero hits in objectstack'sauth-manager.tsagainst 111 fororganization, so the framework does not special-case the route — its ledger marks itsource:better-auth. And the load-bearing claims were taken from better-auth's source plus a runtime measurement, not from greps, with the grep's comment-vs-code blindness stated.git hash-object.Residual filed as #5750 (
finding,domain:ui, unassigned, nopm: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
os-zhuang commentedon Aug 23, 2026
Closed completed. PR #5749 merged 04:42:46Z;
origin/mainis3ddc8c261. 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:
switchOrganization,user.positionsis still the PREVIOUS org's answer unless the server happens to rotate the session token."POST /organization/set-activeemitsset-auth-token, the objectui#4467 rotation subscription drivesloadSession(), and identity re-resolves. No defect, no production change.get-sessionleaves the unsignedsession.token,set-activehands back the signedtoken.signatureform, andTokenStorage.setnotifies on a value change. Nothing declared that. Six cases inpackages/auth/src/__tests__/switchOrgIdentityReresolve-5719.test.tsxnow 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:dispatchedcleared, assignee cleared.bugretained 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, nopm:queue; reachability not claimed. Triage owns first touch.Also recorded for whoever touches
useWorkspaceAdminStatusnext, 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 returnsisResolved: truethe moment any leg answers admin,isMembershipResolvedcannot 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
fix(auth): fix TS2349 never-narrowing in the new #5750 pin's test setup
test(auth): pin that identity re-resolves across an organization swit…
fix(auth): declare cross-org identity re-resolution in switchOrganiza…