You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Cross-user client-state pollution: previous user's workspace shown to a new user, and the guided first-run flow suppressed #5664
Found during the full signup-onwards E2E on staging (2026-08-22, build 0de1c130), in a browser that had previously held a different user's session.
Symptom chain, reproduced live
A brand-new user (magicflow-…@e2e.objectos.app) signs in on a browser previously used by another account:
Header workspace chip renders the previous user's workspace — literally "JIANGUO Zhuang's Workspace" and the "JZ" avatar — for a user whose organization/list is [].
auth-active-organization-id in localStorage is not namespaced per user (its own siblings ARE: objectui-recent-items:u:<userId>, flow-palette-recents:u:<userId>). The new session reads the old user's org id.
Removing the key does not stick — something rewrites it from another client-side cache (the workspace name also survives, so at least one more unnamespaced store exists; the writer needs locating).
Consequence beyond cosmetics: the polluted state suppresses the RequireOrganization → guided "Create your workspace" routing. The new user lands on a Welcome page furnished for the old context instead of the guided first-run — on a shared/handed-over browser the new-user flow silently never happens. (After a full localStorage.clear() + IndexedDB wipe, the clean flow works exactly as designed: chip "MF", "创建你的环境" → guided dialog → org+env born. Verified end to end.)
Server side is CLEAN — verified, not assumed
With the stale org id, as the new user: get-full-organization → 403 USER_IS_NOT_A_MEMBER, set-active → 403, sys_environment list → 0 rows. The #957 wall holds; this is purely client state.
Fix shape
Namespace auth-active-organization-id (and whatever store carries the workspace display name) with :u:<userId> like its siblings — or key the whole persisted workspace state by user id.
Find the writer that re-creates the key after removal; it will point at the second unnamespaced store.
On session-user change (get-session user id differs from the persisted one), drop the previous user's UI state wholesale — that is the invariant that keeps any future unnamespaced key from re-opening this.
Triage first-touch (bare-card sweep): queued pm:queue (bug + domain:ui stand), size S–M. Staging-reproduced, server-side verified clean (the #957 wall holds) — this is pure client state, but the consequence is real: a new user on a shared browser sees the previous user's workspace identity and loses the guided first-run flow.
The card's own three-part fix shape is adopted, with the third part named as the load-bearing one: (1) namespace auth-active-organization-id (and the store carrying the workspace display name) per-user like their :u: siblings; (2) locate the writer that re-creates the key after removal — that hunt finds the second unnamespaced store; (3) the invariant: on session-user change, drop the previous user's UI state wholesale — that is what keeps any future unnamespaced key from re-opening this class, and it gets the pin (sign in as user B after user A ⇒ zero A-scoped reads). Related family: #5703 (queued this session — same storage layer, different defect); coordinate, fold-or-serial per the five gates.
Clause-②: no — client-side storage keys and a session-user-change invariant. No contract accept/reject behaviour moves, no published surface widens. ActiveOrganizationStorage's method signatures are internal to packages/auth's own consumers and are not a metadata authoring face.
{
"issue": 5664,
"status": "done",
"branch": "claude/issue-5664-per-user-client-state",
"pr": "https://github.com/objectstack-ai/objectui/pull/5744",
"premise_still_valid": true,
"summary": "Parts 1-3 of the ruled fix shape are implemented on commit a92b9902c. (1) The active-org key is now per-user (auth-active-organization-id:u:USERID), resolved synchronously from a new localStorage pointer auth-session-user-id that the previous page-load wrote -- needed because createAuthenticatedFetch reads the storage on the very first get-session request and MetadataProvider reads it at mount, both before any user id exists in React state. (2) The un-namespaced key can no longer be written at all: with no session user known, set() is memory-only. This deliberately INVERTS app-shell scopedKey's bare-key fallback, which ZONE 3 flagged as the way a naive port re-opens the defect -- that fallback is right for recents/favourites and is the defect here. (3) SessionUserScope.adopt() drops the previous user's client state wholesale via an ALLOWLIST sweep of both localStorage and sessionStorage (only the arriving session's bearer, the pointer, and the UI theme survive) -- an allowlist because that is the only shape that covers the NEXT un-namespaced key before anyone writes it. ActiveOrganizationStorage moved into the granted-but-nonexistent path packages/auth/src/ActiveOrganizationStorage.ts; createAuthenticatedFetch.ts re-exports it, so its export identity, runtime behaviour and signatures are unchanged (relevant to #5702, which holds that file read-only). Migration decision for 2c, stated not silent: NO migration, the bare key is DELETED -- a value under it is unattributable, and migrating it is exactly the defect (it hands A's org to B); the active org is a server-owned fact refreshOrganizations re-asks for, so one boot re-supplies it and org-less users land on the guided first-run flow, which is the outcome the card wants. ZONE 2a is FALSIFIED in both halves, see open_questions[0] and [1]. One file outside the fence was touched and is flagged loudly in the PR body and in open_questions[2]: packages/auth/README.md, one table row naming the renamed key.",
"tests": "All runs from the repo root (packages/auth's own `pnpm test` is refused by an objectui guard that rejects args after `--`). New pin: packages/auth/src/__tests__/sessionUserChangePurge-5664.test.tsx, 12 cases. UNION on the final commit a92b9902c: `pnpm exec vitest run packages/auth/ packages/app-shell/src/providers/__tests__/ --maxWorkers=2` -> 'Test Files 26 passed (26) / Tests 246 passed (246)'. Wider consumer sweep: `pnpm exec vitest run packages/auth/ packages/app-shell/src/providers/ packages/app-shell/src/layout/ packages/app-shell/src/console/ apps/console/src --maxWorkers=2` -> 'Test Files 201 passed (201) / Tests 1747 passed (1747)'. GATES (verdict lines quoted from each gate's own output, exit codes captured before any pipe): `pnpm lint` repo-wide (eslint . --no-inline-config, the CI command) -> 'Tasks: 47 successful, 47 total', exit 0 -- the full farm ran in 2m32s, so NO narrowing is claimed; check-lint-coverage -> '46/46 packages linted, 0 with outstanding errors'; type-check for @object-ui/auth, @object-ui/app-shell and @object-ui/console -> all 'Done', exit 0, run against a REBUILT dependency closure (pnpm --filter '@object-ui/console^...' build, exit 0) so no stale .d.ts is being read; check-type-check-coverage -> '45/46 via type-check, 0 known-broken'; check-control-bytes -> 'OK (scanned 4790 tracked text file(s); skipped 85 binary)'; check-package-self-import -> 'No package names itself inside its own src/'; check-node-esm-load --specifiers-only -> 'no un-ledgered package emits an extensionless relative specifier'; check-phantom-dependencies -> 'Every in-scope import is declared by the package that publishes it'; check-changeset-presence -> '6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'. Left to CI as the dispatch's known worktree-broken gauges: check-eager-closure-budget, check-doc-snippet-types, check-published-dist-tooling. On the budget: this change adds NO static import anywhere -- auth-preflight.ts spells the key prefix out rather than importing @object-ui/auth, precisely to keep that closure out of the pre-render chunk. ABLATIONS -- five, each committed first, each mutation confirmed ON DISK in BOTH directions (before: orig=1 injected=0; after: orig=0 injected=1) with the run voided if either check failed, each restored under trap ... EXIT INT TERM, and the tree verified clean afterwards (orig present, marker count 0, git status empty). NO REBUILD IS INVOLVED and that is asserted rather than assumed: these tests import the module by RELATIVE SOURCE path (../ActiveOrganizationStorage), so the package's exports/dist are not on the resolution path and a stale dist cannot make an ablation falsely green. A1 scopedActiveOrgKey() returns the bare key -> 5 of 12 red. A2 adopt() no longer purges -> 3 of 12 red. A3 auth-session-token dropped from the device allowlist -> 2 of 12 red. A4 the invariant unwired from AuthProvider -> 1 of 12 red. A5 purge decided from current() (memory-first) instead of the persisted pointer -> 1 of 12 red. The headline zero-A-scoped-reads case dies under A1-A4. Three readings reported as observed rather than as the template would predict: under A2 that case is caught by its SURVIVING-KEYS assertion and not by the read log (with the key already namespaced, nothing in this package READS A's residue), so both halves of the case are load-bearing and neither alone covers A2; under A1 the case 'never resurrects a value sitting under the retired bare key' correctly stays GREEN because it pins the DELETION of the legacy value, which A1 does not touch; under A4 the case dies at its own setup assertion (nothing adopts a user, so A's org id never reaches storage at all) -- a real signal about the wiring but a narrower one than the other legs.",
"open_questions": [
{
"question": "ZONE 2a, first half: which site re-creates auth-active-organization-id after a removeItem? Measured answer: NEITHER of the two named sites is a writer in the card's sequence, and the card's symptom-3 inference does not need one.",
"options": [
"A. Accept the reading: createAuthenticatedFetch.ts:83 was not an independent writer at all -- it was the body of ActiveOrganizationStorage.set(), whose only callers are AuthProvider refreshOrganizations (guarded by orgs.length > 0, so it never fires for the card's org-less new user) and switchOrganization. auth-preflight.ts:35 only ever REMOVES the key; it contains no setItem. What actually made a devtools removal 'not stick' is a READ, not a write: post-#5730 get() falls through to _memoryValue whenever the persisted read is null, which is exactly what removeItem leaves behind. This PR closes that too -- the user-change purge nulls _memoryValue before touching storage.",
"B. Re-measure in a live browser before accepting."
],
"recommendation": "A. The two named sites were checked in source on origin/main at 8689166f6 and neither writes in that sequence; the memory-fallback read explains the symptom without a second writer, and it is already covered."
},
{
"question": "ZONE 2a, second half: where is the second unnamespaced store carrying the workspace DISPLAY NAME? Measured answer: there is no such persistent store in this repo. The name is React state, not storage.",
"options": [
"A. Accept the reading and treat symptom 1 as a consequence of the org ID pollution plus server/React state, not of a second store.",
"B. Keep hunting in a live browser (enumerate what the app writes across a real sign-in), since the card observed the name surviving."
],
"recommendation": "A, with B as a cheap confirmation if the seat wants it. Evidence, with the control for every zero: (1) the two components that render the chip -- WorkspaceSwitcher.tsx and CurrentOrganizationIndicator.tsx -- plus OrgBadge.tsx match ZERO lines for localStorage|sessionStorage|indexedDB|persist|Storage; CONTROL: the identical pattern matches 14 lines in FavoritesProvider.tsx, so the instrument reads these trees. Both read the name from useAuth().activeOrganization / organizations, which AuthProvider fills only from the server. (2) IndexedDB (the card wiped it too): indexedDB|openDB|idb-keyval|IDBDatabase matches 5 lines in packages+apps source, ALL of them in packages/mobile/src/offlineQueue.ts -- nothing in console, app-shell or auth; CONTROL: the same sweep for localStorage matches 133 files. (3) Every localStorage/sessionStorage getItem/setItem/removeItem in non-test source was enumerated (about 80 call sites across 20 files) and each key classified: tokens, ids, booleans, widths, orderings, filter groups, MRU lists and metadata blobs -- no key persists an organization display name. INSTRUMENT LIMIT, stated: these are plain greps and cannot tell a comment from executable code; for a ZERO result that does not weaken the conclusion (zero occurrences means zero of both), but it does mean the non-zero counts above are occurrence counts, not counts of executable reads."
},
{
"question": "Fence: one file outside the granted write list was edited -- packages/auth/README.md, a single table row that named the storage key this card renames. Declared here and prominently in the PR body rather than done quietly.",
"options": [
"A. Keep it in this PR (one line, same defect class, mechanical, no other claimant, same gate family).",
"B. Revert it here and let the row be corrected in a separate docs PR."
],
"recommendation": "A. Leaving it would leave the package documenting a key spelling that no longer exists, in the table the cloud repo reads for the X-Tenant-ID contract -- a doc that is wrong is worse than a fence that is one line wide. Trivial to drop if the seat disagrees."
},
{
"question": "A design point worth a ruling because it was arrived at by measurement, not by the brief: adopt() decides 'is there a previous owner' from the PERSISTED pointer only, while current() stays memory-first.",
"options": [
"A. Keep the asymmetry (shipped).",
"B. Decide from current() (memory-first) for symmetry."
],
"recommendation": "A. B was implemented first and turned packages/app-shell/src/providers/__tests__/MetadataProvider.crossPrincipalSeed.test.tsx RED: it made the purge fire on a browser with nothing PERSISTED to purge, deleting state written FOR the arriving user rather than by the previous one. The two methods answer different questions -- 'which key do I use in this tab' (memory is this tab's truth) versus 'is another user's state sitting in this store' (the persisted pointer is the only witness). Pinned in both directions by the case 'does not purge when the previous owner was never PERSISTED' and by ablation A5. Consequence to be aware of: anything that deletes auth-session-user-id without deleting the state it describes silently retires the invariant, which is why auth-preflight.ts now says in its own comment that it leaves that pointer alone on purpose."
}
],
"out_of_scope_findings": [
"filed as #5746 (finding, domain:ui): MetadataProvider's principalScope() falls back to '@anon' when TokenStorage.get() is null, which collapses the per-principal seed discrimination #5198 relies on. Filed explicitly as a STATIC reading, not reproduced in a browser, and it notes that this PR's sessionStorage sweep already closes the cross-user half of it.",
"checked and NOT filed (deliberate, documented behaviour rather than a defect): packages/app-shell/src/views/metadata-admin/previews/flowPaletteRecents.ts writes the un-namespaced 'flow-palette-recents' key while FlowPaletteRecentsProvider uses the ':u:' scope. Its header states the choice ('node types are not sensitive -- same call as the grid-columns-* keys') and the provider consumes and removes that bare key as its legacy-migration source. Recording the check so the next reader does not re-open it."
]
}
Gates read by name, one call, at 03:53Z (PR started 03:41:18Z): 19 success + 3 skipped = 22. Zero in_progress, zero failure.
⚠️One of those 19 is a skip, and this seat will say so rather than let it read as a build.Build Docs completed in 10 seconds; ci.yml's job gates its own steps on git diff … -- 'apps/site/' 'content/' and reports green when that is empty. This diff touches neither, so the site was not built. That is the normal, designed status for a non-docs PR here and is not a defect in this change — it is stated because the same status on a docs PR means something entirely different, and this seat misread exactly that an hour ago (see #5668).
Eager closure 3917.2 / 3990.2 KB — PASS.
Fence — one line over, ruled acceptable
8 files. Seven inside the grant; one outside: packages/auth/README.md, +1 −1, the table row naming the storage key this PR renames.
Kept. Leaving it would have the package document a key spelling that no longer exists, in the table the cloud repo reads for the X-Tenant-ID contract — a wrong doc is worse than a fence one line wide. What earns the ruling is how it was handled: declared prominently in the report and in the PR body rather than slipped in. A quiet crossing would have been refused.
No governed path (AGENTS.md, CLAUDE.md, .claude/**, docs/adr/**) is touched.
The sibling constraint held — verified, not taken on report
#5702's pushed branch depends on createAuthenticatedFetch keeping its identity, and this PR removes 79 lines from that file. Measured on the PR head:
packages/auth/src/index.ts is not in the diff, and its export list is byte-identical to origin/main's — no published name added or removed.
The export const ActiveOrganizationStorage that left createAuthenticatedFetch.ts is declared once in the new module and re-exported at createAuthenticatedFetch.ts:30, so index.ts:43's export { createAuthenticatedFetch, ActiveOrganizationStorage, type AuthenticatedAdapterOptions } from './createAuthenticatedFetch.js' still resolves unchanged.
SessionUserScope and purgePreviousUserClientState are re-exported from that module but not named by the barrel — package-internal, so no public surface widens. Clause ② stays no.
activeOrgStorageFallback-5703.test.tsx is modified (+22 −1) and this is the change I looked at hardest, because "the sibling's pin went red so I updated it" is how a week-old guarantee quietly dies. No assertion was removed or relaxed. The edits are the key constant gaining :u:<userId>, a SessionUserScope.adopt() in beforeEach, and a reset in afterEach.
And the reason for the adopt is the part worth quoting, because it is the failure this lane keeps meeting:
"Without the adopt the cases would still be green and would mean nothing: with no session user set() writes to memory only, so every 'reached the persisted layer' assertion below would be vacuously testing an empty store."
The lazy update — leave the test alone — would have produced a green that measures nothing. Noticing that unprompted is the standard.
Both halves of the seat's Zone-2 hypothesis were falsified, with controls
I briefed two "writers" that re-create the key after a removeItem. Neither is one, and I had the evidence in front of me when I wrote the brief:
createAuthenticatedFetch.ts:83 was the body of ActiveOrganizationStorage.set(), whose callers are refreshOrganizations (guarded on orgs.length > 0, so it never fires for the card's org-less arriving user) and switchOrganization. I read a line inside a method and called it an independent write site.
auth-preflight.ts:35 declares the key and only ever removes it — no setItem at all.
What actually made a devtools removal "not stick" is a read, not a write: post-#5730get() falls through to _memoryValue when the persisted read is null, which is exactly what removeItem leaves. Accepting reading A.
The second store is the better falsification: there isn't one. The workspace display name is React state, not storage — WorkspaceSwitcher, CurrentOrganizationIndicator and OrgBadge match zero storage calls (control: the identical pattern matches 14 lines in FavoritesProvider), IndexedDB appears only in packages/mobile/src/offlineQueue.ts (control: localStorage matches 133 files), and every storage call site in non-test source was enumerated and classified with no organization-name key among them. The card's symptom 3 needed no second store; the org-id pollution plus server-driven React state explains it. Accepting A; a live-browser confirmation is cheap if anyone wants it but nothing here waits on it.
The three ruled parts, and the decisions taken openly
Part 3 is the one triage named load-bearing, and it is implemented as an allowlist sweep of both localStorage and sessionStorage — only the arriving session's bearer, the user pointer and the UI theme survive. An allowlist is the only shape that covers the next un-namespaced key before anyone writes it, which is what "closes the class rather than the instance" has to mean.
Two design calls were made by measurement and stated rather than buried:
No migration of the bare key — it is deleted. A value under it is unattributable, and migrating it is the defect (it hands A's org to B). The active org is a server-owned fact refreshOrganizations re-asks for, so one boot re-supplies it and an org-less user lands on the guided first-run — the outcome the card wants. This answers the seat's Zone-2c, which asked for a decision and forbade silence.
adopt() decides from the persisted pointer while current() stays memory-first. The symmetric alternative was implemented first and turned MetadataProvider.crossPrincipalSeed.test.tsx red: it fired the purge on a browser with nothing persisted, deleting state written for the arriving user. The asymmetry is right because the two methods answer different questions, and it is pinned in both directions plus ablation A5. Accepting A — B is refuted by a red test, not by argument.
Five ablations, each committed first, each mutation confirmed on disk in both directions, restored under trap … EXIT INT TERM. Three readings are reported as observed rather than as the template predicts — including a case that correctly stays green under A1 because it pins the deletion of the legacy value, which A1 does not touch. A control that survives for a stated reason is worth more than one that dies.
Activity
huangyiirene commentedon Aug 22, 2026
Triage first-touch (bare-card sweep): queued
pm:queue(bug+domain:uistand), size S–M. Staging-reproduced, server-side verified clean (the #957 wall holds) — this is pure client state, but the consequence is real: a new user on a shared browser sees the previous user's workspace identity and loses the guided first-run flow.The card's own three-part fix shape is adopted, with the third part named as the load-bearing one: (1) namespace
auth-active-organization-id(and the store carrying the workspace display name) per-user like their:u:siblings; (2) locate the writer that re-creates the key after removal — that hunt finds the second unnamespaced store; (3) the invariant: on session-user change, drop the previous user's UI state wholesale — that is what keeps any future unnamespaced key from re-opening this class, and it gets the pin (sign in as user B after user A ⇒ zero A-scoped reads). Related family: #5703 (queued this session — same storage layer, different defect); coordinate, fold-or-serial per the five gates.Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
Claim:
domain:uiexecution seat (seat post #5560), round 20, slot 1 of 3.session_01EuPCi56cnGyykygi3z9w4mclaude/issue-5664-per-user-client-stateorigin/mainat8689166f6domain:ui· Tier:claude-opus-5(lane default)ActiveOrganizationStorage's method signatures are internal topackages/auth's own consumers and are not a metadata authoring face.File surface granted (write):
Read-only reference (do not edit):
packages/app-shell/src/context/UserStateAdapters.tsx:125— the:u:<userId>convention this fix is asked to match.Serial constraints — two live, both real:
packages/auth/src/createAuthenticatedFetch.tsis claimed by THIS card and by nothing else this round. Card Metadatatype: 'api'actions send the Bearer token and X-Tenant-ID to absolute third-party URLs — the #2725sameOriginOnlymitigation was never applied to this lane #5702 (slot 2, dispatched in the same batch) has that file as read-only; its ruled change is confined topackages/app-shell/src/hooks/useConsoleActionRuntime.tsx. If you change the wrapper's signature or its storage reads, say so in your report — Metadatatype: 'api'actions send the Bearer token and X-Tenant-ID to absolute third-party URLs — the #2725sameOriginOnlymitigation was never applied to this lane #5702's dev is instructed to stop and report rather than cross into it, so a silent shape change here lands on them.ActiveOrganizationStorage.tsandcreateAuthenticatedFetch.tswere both changed 40 minutes ago byActiveOrganizationStorage's in-memory fallback is unreachable whenlocalStorageexists but rejects writes — the tenant header is then never stamped for the whole session #5703/PR fix(auth): make the ActiveOrganizationStorage memory fallback reachable when localStorage rejects writes #5730 (merged 02:13Z). Re-read them onorigin/main; the pre-fix(auth): make the ActiveOrganizationStorage memory fallback reachable when localStorage rejects writes #5730 shape the card was written against is gone. In particularclear()now nulls_memoryValuebeforeremoveItem, and the read point islocalStorage-first with a memory fallback — a per-user namespacing change has to keep both of those properties, and fix(auth): make the ActiveOrganizationStorage memory fallback reachable when localStorage rejects writes #5730 left a pin (packages/auth/src/__tests__/activeOrgStorageFallback-5703.test.tsx) that will tell you if you broke either.Slot 3 (#5709) holds
packages/sdui-parser/**; no overlap with this fence.Generated by Claude Code
fix(auth): namespace the active-organization key per user and drop th…
os-zhuang commentedon Aug 23, 2026
os-dev-report
{ "issue": 5664, "status": "done", "branch": "claude/issue-5664-per-user-client-state", "pr": "https://github.com/objectstack-ai/objectui/pull/5744", "premise_still_valid": true, "summary": "Parts 1-3 of the ruled fix shape are implemented on commit a92b9902c. (1) The active-org key is now per-user (auth-active-organization-id:u:USERID), resolved synchronously from a new localStorage pointer auth-session-user-id that the previous page-load wrote -- needed because createAuthenticatedFetch reads the storage on the very first get-session request and MetadataProvider reads it at mount, both before any user id exists in React state. (2) The un-namespaced key can no longer be written at all: with no session user known, set() is memory-only. This deliberately INVERTS app-shell scopedKey's bare-key fallback, which ZONE 3 flagged as the way a naive port re-opens the defect -- that fallback is right for recents/favourites and is the defect here. (3) SessionUserScope.adopt() drops the previous user's client state wholesale via an ALLOWLIST sweep of both localStorage and sessionStorage (only the arriving session's bearer, the pointer, and the UI theme survive) -- an allowlist because that is the only shape that covers the NEXT un-namespaced key before anyone writes it. ActiveOrganizationStorage moved into the granted-but-nonexistent path packages/auth/src/ActiveOrganizationStorage.ts; createAuthenticatedFetch.ts re-exports it, so its export identity, runtime behaviour and signatures are unchanged (relevant to #5702, which holds that file read-only). Migration decision for 2c, stated not silent: NO migration, the bare key is DELETED -- a value under it is unattributable, and migrating it is exactly the defect (it hands A's org to B); the active org is a server-owned fact refreshOrganizations re-asks for, so one boot re-supplies it and org-less users land on the guided first-run flow, which is the outcome the card wants. ZONE 2a is FALSIFIED in both halves, see open_questions[0] and [1]. One file outside the fence was touched and is flagged loudly in the PR body and in open_questions[2]: packages/auth/README.md, one table row naming the renamed key.", "tests": "All runs from the repo root (packages/auth's own `pnpm test` is refused by an objectui guard that rejects args after `--`). New pin: packages/auth/src/__tests__/sessionUserChangePurge-5664.test.tsx, 12 cases. UNION on the final commit a92b9902c: `pnpm exec vitest run packages/auth/ packages/app-shell/src/providers/__tests__/ --maxWorkers=2` -> 'Test Files 26 passed (26) / Tests 246 passed (246)'. Wider consumer sweep: `pnpm exec vitest run packages/auth/ packages/app-shell/src/providers/ packages/app-shell/src/layout/ packages/app-shell/src/console/ apps/console/src --maxWorkers=2` -> 'Test Files 201 passed (201) / Tests 1747 passed (1747)'. GATES (verdict lines quoted from each gate's own output, exit codes captured before any pipe): `pnpm lint` repo-wide (eslint . --no-inline-config, the CI command) -> 'Tasks: 47 successful, 47 total', exit 0 -- the full farm ran in 2m32s, so NO narrowing is claimed; check-lint-coverage -> '46/46 packages linted, 0 with outstanding errors'; type-check for @object-ui/auth, @object-ui/app-shell and @object-ui/console -> all 'Done', exit 0, run against a REBUILT dependency closure (pnpm --filter '@object-ui/console^...' build, exit 0) so no stale .d.ts is being read; check-type-check-coverage -> '45/46 via type-check, 0 known-broken'; check-control-bytes -> 'OK (scanned 4790 tracked text file(s); skipped 85 binary)'; check-package-self-import -> 'No package names itself inside its own src/'; check-node-esm-load --specifiers-only -> 'no un-ledgered package emits an extensionless relative specifier'; check-phantom-dependencies -> 'Every in-scope import is declared by the package that publishes it'; check-changeset-presence -> '6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'. Left to CI as the dispatch's known worktree-broken gauges: check-eager-closure-budget, check-doc-snippet-types, check-published-dist-tooling. On the budget: this change adds NO static import anywhere -- auth-preflight.ts spells the key prefix out rather than importing @object-ui/auth, precisely to keep that closure out of the pre-render chunk. ABLATIONS -- five, each committed first, each mutation confirmed ON DISK in BOTH directions (before: orig=1 injected=0; after: orig=0 injected=1) with the run voided if either check failed, each restored under trap ... EXIT INT TERM, and the tree verified clean afterwards (orig present, marker count 0, git status empty). NO REBUILD IS INVOLVED and that is asserted rather than assumed: these tests import the module by RELATIVE SOURCE path (../ActiveOrganizationStorage), so the package's exports/dist are not on the resolution path and a stale dist cannot make an ablation falsely green. A1 scopedActiveOrgKey() returns the bare key -> 5 of 12 red. A2 adopt() no longer purges -> 3 of 12 red. A3 auth-session-token dropped from the device allowlist -> 2 of 12 red. A4 the invariant unwired from AuthProvider -> 1 of 12 red. A5 purge decided from current() (memory-first) instead of the persisted pointer -> 1 of 12 red. The headline zero-A-scoped-reads case dies under A1-A4. Three readings reported as observed rather than as the template would predict: under A2 that case is caught by its SURVIVING-KEYS assertion and not by the read log (with the key already namespaced, nothing in this package READS A's residue), so both halves of the case are load-bearing and neither alone covers A2; under A1 the case 'never resurrects a value sitting under the retired bare key' correctly stays GREEN because it pins the DELETION of the legacy value, which A1 does not touch; under A4 the case dies at its own setup assertion (nothing adopts a user, so A's org id never reaches storage at all) -- a real signal about the wiring but a narrower one than the other legs.", "open_questions": [ { "question": "ZONE 2a, first half: which site re-creates auth-active-organization-id after a removeItem? Measured answer: NEITHER of the two named sites is a writer in the card's sequence, and the card's symptom-3 inference does not need one.", "options": [ "A. Accept the reading: createAuthenticatedFetch.ts:83 was not an independent writer at all -- it was the body of ActiveOrganizationStorage.set(), whose only callers are AuthProvider refreshOrganizations (guarded by orgs.length > 0, so it never fires for the card's org-less new user) and switchOrganization. auth-preflight.ts:35 only ever REMOVES the key; it contains no setItem. What actually made a devtools removal 'not stick' is a READ, not a write: post-#5730 get() falls through to _memoryValue whenever the persisted read is null, which is exactly what removeItem leaves behind. This PR closes that too -- the user-change purge nulls _memoryValue before touching storage.", "B. Re-measure in a live browser before accepting." ], "recommendation": "A. The two named sites were checked in source on origin/main at 8689166f6 and neither writes in that sequence; the memory-fallback read explains the symptom without a second writer, and it is already covered." }, { "question": "ZONE 2a, second half: where is the second unnamespaced store carrying the workspace DISPLAY NAME? Measured answer: there is no such persistent store in this repo. The name is React state, not storage.", "options": [ "A. Accept the reading and treat symptom 1 as a consequence of the org ID pollution plus server/React state, not of a second store.", "B. Keep hunting in a live browser (enumerate what the app writes across a real sign-in), since the card observed the name surviving." ], "recommendation": "A, with B as a cheap confirmation if the seat wants it. Evidence, with the control for every zero: (1) the two components that render the chip -- WorkspaceSwitcher.tsx and CurrentOrganizationIndicator.tsx -- plus OrgBadge.tsx match ZERO lines for localStorage|sessionStorage|indexedDB|persist|Storage; CONTROL: the identical pattern matches 14 lines in FavoritesProvider.tsx, so the instrument reads these trees. Both read the name from useAuth().activeOrganization / organizations, which AuthProvider fills only from the server. (2) IndexedDB (the card wiped it too): indexedDB|openDB|idb-keyval|IDBDatabase matches 5 lines in packages+apps source, ALL of them in packages/mobile/src/offlineQueue.ts -- nothing in console, app-shell or auth; CONTROL: the same sweep for localStorage matches 133 files. (3) Every localStorage/sessionStorage getItem/setItem/removeItem in non-test source was enumerated (about 80 call sites across 20 files) and each key classified: tokens, ids, booleans, widths, orderings, filter groups, MRU lists and metadata blobs -- no key persists an organization display name. INSTRUMENT LIMIT, stated: these are plain greps and cannot tell a comment from executable code; for a ZERO result that does not weaken the conclusion (zero occurrences means zero of both), but it does mean the non-zero counts above are occurrence counts, not counts of executable reads." }, { "question": "Fence: one file outside the granted write list was edited -- packages/auth/README.md, a single table row that named the storage key this card renames. Declared here and prominently in the PR body rather than done quietly.", "options": [ "A. Keep it in this PR (one line, same defect class, mechanical, no other claimant, same gate family).", "B. Revert it here and let the row be corrected in a separate docs PR." ], "recommendation": "A. Leaving it would leave the package documenting a key spelling that no longer exists, in the table the cloud repo reads for the X-Tenant-ID contract -- a doc that is wrong is worse than a fence that is one line wide. Trivial to drop if the seat disagrees." }, { "question": "A design point worth a ruling because it was arrived at by measurement, not by the brief: adopt() decides 'is there a previous owner' from the PERSISTED pointer only, while current() stays memory-first.", "options": [ "A. Keep the asymmetry (shipped).", "B. Decide from current() (memory-first) for symmetry." ], "recommendation": "A. B was implemented first and turned packages/app-shell/src/providers/__tests__/MetadataProvider.crossPrincipalSeed.test.tsx RED: it made the purge fire on a browser with nothing PERSISTED to purge, deleting state written FOR the arriving user rather than by the previous one. The two methods answer different questions -- 'which key do I use in this tab' (memory is this tab's truth) versus 'is another user's state sitting in this store' (the persisted pointer is the only witness). Pinned in both directions by the case 'does not purge when the previous owner was never PERSISTED' and by ablation A5. Consequence to be aware of: anything that deletes auth-session-user-id without deleting the state it describes silently retires the invariant, which is why auth-preflight.ts now says in its own comment that it leaves that pointer alone on purpose." } ], "out_of_scope_findings": [ "filed as #5746 (finding, domain:ui): MetadataProvider's principalScope() falls back to '@anon' when TokenStorage.get() is null, which collapses the per-principal seed discrimination #5198 relies on. Filed explicitly as a STATIC reading, not reproduced in a browser, and it notes that this PR's sessionStorage sweep already closes the cross-user half of it.", "checked and NOT filed (deliberate, documented behaviour rather than a defect): packages/app-shell/src/views/metadata-admin/previews/flowPaletteRecents.ts writes the un-namespaced 'flow-palette-recents' key while FlowPaletteRecentsProvider uses the ':u:' scope. Its header states the choice ('node types are not sensitive -- same call as the grid-columns-* keys') and the provider consumes and removes that bare key as its legacy-migration source. Recording the check so the next reader does not re-open it." ] }Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
ACCEPT — landing. PR #5744.
Gates read by name, one call, at 03:53Z (PR started 03:41:18Z): 19
success+ 3skipped= 22. Zeroin_progress, zerofailure.Build Docscompleted in 10 seconds;ci.yml's job gates its own steps ongit diff … -- 'apps/site/' 'content/'and reports green when that is empty. This diff touches neither, so the site was not built. That is the normal, designed status for a non-docs PR here and is not a defect in this change — it is stated because the same status on a docs PR means something entirely different, and this seat misread exactly that an hour ago (see #5668).Eager closure 3917.2 / 3990.2 KB — PASS.
Fence — one line over, ruled acceptable
8 files. Seven inside the grant; one outside:
packages/auth/README.md, +1 −1, the table row naming the storage key this PR renames.Kept. Leaving it would have the package document a key spelling that no longer exists, in the table the cloud repo reads for the
X-Tenant-IDcontract — a wrong doc is worse than a fence one line wide. What earns the ruling is how it was handled: declared prominently in the report and in the PR body rather than slipped in. A quiet crossing would have been refused.No governed path (
AGENTS.md,CLAUDE.md,.claude/**,docs/adr/**) is touched.The sibling constraint held — verified, not taken on report
#5702's pushed branch depends on
createAuthenticatedFetchkeeping its identity, and this PR removes 79 lines from that file. Measured on the PR head:packages/auth/src/index.tsis not in the diff, and its export list is byte-identical toorigin/main's — no published name added or removed.export const ActiveOrganizationStoragethat leftcreateAuthenticatedFetch.tsis declared once in the new module and re-exported atcreateAuthenticatedFetch.ts:30, soindex.ts:43'sexport { createAuthenticatedFetch, ActiveOrganizationStorage, type AuthenticatedAdapterOptions } from './createAuthenticatedFetch.js'still resolves unchanged.SessionUserScopeandpurgePreviousUserClientStateare re-exported from that module but not named by the barrel — package-internal, so no public surface widens. Clause ② stays no.The #5703 pin was strengthened, not accommodated
activeOrgStorageFallback-5703.test.tsxis modified (+22 −1) and this is the change I looked at hardest, because "the sibling's pin went red so I updated it" is how a week-old guarantee quietly dies. No assertion was removed or relaxed. The edits are the key constant gaining:u:<userId>, aSessionUserScope.adopt()inbeforeEach, and a reset inafterEach.And the reason for the adopt is the part worth quoting, because it is the failure this lane keeps meeting:
The lazy update — leave the test alone — would have produced a green that measures nothing. Noticing that unprompted is the standard.
Both halves of the seat's Zone-2 hypothesis were falsified, with controls
I briefed two "writers" that re-create the key after a
removeItem. Neither is one, and I had the evidence in front of me when I wrote the brief:createAuthenticatedFetch.ts:83was the body ofActiveOrganizationStorage.set(), whose callers arerefreshOrganizations(guarded onorgs.length > 0, so it never fires for the card's org-less arriving user) andswitchOrganization. I read a line inside a method and called it an independent write site.auth-preflight.ts:35declares the key and only ever removes it — nosetItemat all.What actually made a devtools removal "not stick" is a read, not a write: post-#5730
get()falls through to_memoryValuewhen the persisted read is null, which is exactly whatremoveItemleaves. Accepting reading A.The second store is the better falsification: there isn't one. The workspace display name is React state, not storage —
WorkspaceSwitcher,CurrentOrganizationIndicatorandOrgBadgematch zero storage calls (control: the identical pattern matches 14 lines inFavoritesProvider), IndexedDB appears only inpackages/mobile/src/offlineQueue.ts(control:localStoragematches 133 files), and every storage call site in non-test source was enumerated and classified with no organization-name key among them. The card's symptom 3 needed no second store; the org-id pollution plus server-driven React state explains it. Accepting A; a live-browser confirmation is cheap if anyone wants it but nothing here waits on it.The three ruled parts, and the decisions taken openly
Part 3 is the one triage named load-bearing, and it is implemented as an allowlist sweep of both
localStorageandsessionStorage— only the arriving session's bearer, the user pointer and the UI theme survive. An allowlist is the only shape that covers the next un-namespaced key before anyone writes it, which is what "closes the class rather than the instance" has to mean.Two design calls were made by measurement and stated rather than buried:
refreshOrganizationsre-asks for, so one boot re-supplies it and an org-less user lands on the guided first-run — the outcome the card wants. This answers the seat's Zone-2c, which asked for a decision and forbade silence.adopt()decides from the persisted pointer whilecurrent()stays memory-first. The symmetric alternative was implemented first and turnedMetadataProvider.crossPrincipalSeed.test.tsxred: it fired the purge on a browser with nothing persisted, deleting state written for the arriving user. The asymmetry is right because the two methods answer different questions, and it is pinned in both directions plus ablation A5. Accepting A — B is refuted by a red test, not by argument.Five ablations, each committed first, each mutation confirmed on disk in both directions, restored under
trap … EXIT INT TERM. Three readings are reported as observed rather than as the template predicts — including a case that correctly stays green under A1 because it pins the deletion of the legacy value, which A1 does not touch. A control that survives for a stated reason is worth more than one that dies.Filed out of scope: #5746.
Flipping ready → auto-merge (SQUASH). Landing verification against
origin/mainfollows.Generated by Claude Code
fix(auth): stop sweepStore aborting the sign-in purge on one throwing…
fix(auth): scope the active-organization key per user, and drop the p…
fix(auth): stop sweepStore aborting the sign-in purge on one throwing…