Uh oh!
There was an error while loading. Please reload this page.
fix(app-shell): the first-boot metadata seed is adopted onto the resolved org scope (#5243) - #5278
Merged
os-support-ai merged 3 commits intoAug 19, 2026
Conversation
…org scope The seed entry a never-signed-in browser writes lands under the no-org scope, because `activeOrgScope()` reads `ActiveOrganizationStorage` and AuthProvider only stamps it after getSession -> listOrganizations -> getActiveOrganization. The eager `app` fetch is one round trip and wins, so every later boot computes the real org id, never reads that entry, and the seed delivers nothing on the boot right after a first login -- leaving an orphaned `@none` entry until the tab closes. Move the label at the moment the org resolves, from the live cache entry. Sound because the entry is correctly scoped and merely mislabelled: the first request carries no X-Tenant-ID, and the server does not read that header for tenant scoping -- resolveAuthzContext takes tenantId from session.activeOrganizationId alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
os-support-ai
marked this pull request as ready for review
August 19, 2026 00:11
Uh oh!
There was an error while loading. Please reload this page.
os-support-ai
deleted the
claude/issue-5243-metadata-seed-org-scope-race
branch
August 19, 2026 00:11
This was referenced Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#5243
The metadata seed cache delivers nothing on the boot right after a first login, and leaves an orphaned no-org entry behind. Verification union below ran on
174f330fa.Re-derivation on current
origin/main(the card was ~3h old)The card was filed off a measurement taken during #5198, whose PR #5242 has since merged and touched these very providers. Re-derived at
87d9202b1; all three readings still hold.activeOrgScope()still readsActiveOrganizationStorage.get() || NO_ORG_SCOPEpackages/app-shell/src/providers/MetadataProvider.tsx:107-112AuthProviderresolves the org only aftergetSessionpackages/auth/src/AuthProvider.tsx:173setsuser; the org effect at:647-651is gated onuser;refreshOrganizationsstamps at:598appfetch still starts at mountMetadataProvider.tsx:816mount effect walksEAGER_TYPES(:51), deps carry nothing org-derivedPR #5242 did not close this window. It appended
:${principalScope()}to the key and leftactiveOrgScope()untouched —sessionKeyForwent from…:${activeOrgScope()}to…:${activeOrgScope()}:${principalScope()}.A grep for any pre-existing re-key / adopt / defer mechanism returned zero hits, counter-probed against terms known present in the same file (
sessionKeyFor3,NO_ORG_SCOPE4).One correction to the card's mechanics, which matters for the choice:
saveToSessionis called from the fetch's.then()(:648), so the key is computed when the response lands, not when the request goes out. The defect is therefore precisely "response lands before the org is stamped".The
X-Tenant-IDprobe — the check triage folded inThat first-login request does go out with no tenant header (
createAuthenticatedFetch.ts:115-118omits it entirely when storage is empty). It changes nothing about the response, on both paths a tenant could enter:resolveAuthzContextderivestenantIdfromsession.activeOrganizationIdand nothing else (frameworkpackages/core/src/security/resolve-authz-context.ts:173). A grep for any tenant-header read in that file returns zero, counter-probed (tenantId20 hits,headers7 hits in the same file). The contract is already pinned:packages/verify/src/harness.org-context.test.ts:145-148— "session.activeOrganizationIdis the ONE fieldresolveAuthzContextreads intotenantId".resolveRequestEnvironmentIdreads the hostname andX-Environment-Id;extractProjectIdHeader(rest-server.ts:2640-2647) reads onlyx-environment-id, neverx-tenant-id.Within the framework,
X-Tenant-IDappears only in the CORS allow-list, and plugin-sharing documents that trusting it as identity was a vulnerability the secure default removed.So the entry cached under
@noneis correctly scoped and merely mislabelled — it holds exactly the data of the organizationAuthProvideris about to stamp, because the server computed it from the same session. That is what makes moving the label the right repair instead of a relabelling of someone else's answer.The stop clause did not fire: this is a definitive, test-pinned contract answer, not an open contract question — no ambiguity needing a maintainer ruling.
Bounding the one sequencing nuance (the ADR-0081 single-membership repair)
refreshOrganizationshas a legacy branch (AuthProvider.tsx:588-596) that callssetActiveOrganization(orgs[0].id), which mutates the session's tenant after the fetch was served. Adoption there would carry an org-less response onto a real org id.It is not reachable in the window adoption fires: framework #8247/#8245 guarantees a user's first session carries
activeOrganizationId(membership settles before the session is minted —first-session-membership-ordering.test.ts), and that branch is documented as repairing sessions "created before the server-side active-org stamp existed". Reaching it needs an emptyActiveOrganizationStoragetogether with a pre-#8247 token, but both live in the samelocalStorage, so a browser holding the token has already been stamped by an earlier boot. In the residual case the outcome is a same-user, fail-closed-filtered list that self-heals on the next fetch — never cross-tenant.Mechanism chosen, and the measurement that decided it
Adopt/re-key the entry onto the resolved org scope at the moment the org resolves.
Defer is ruled out by a measurement already recorded in this file — the
activeOrgScopedocblock (:101-105) states that the async context value "resolves ASYNCHRONOUSLY … so at seed time — the whole point of this cache — it is stillnulland every boot would miss its own entry". Measured against the round-trip depth: the org costs three sequential trips (getSession→listOrganizations→getActiveOrganization) against the app fetch's one, so a seed gated on it can never beat the fetch it exists to preempt.Adopt-at-read fixes the miss but leaves the orphan the card explicitly names, and makes an unlabelled entry adoptable by whatever org mounts next — weakening the #4486 invariant the counter-pin protects.
The trade I made: one
sessionStoragewrite plus one remove, executed in an effect that blocks no render, in exchange for keeping the seed's benefit on boot 2 and removing the orphan. I spend neither a request nor a render — the items are taken from the live cache entry rather than re-parsed from storage or refetched, so the #4042 request budget is untouched (pinned by a test).Region-level file surface
packages/app-shell/src/providers/MetadataProvider.tsxsessionKeyFor→ split intosessionKeyForOrg+sessionKeyFor;saveToSessiongains an optional explicit org scope; newremoveNoOrgSeedEntry; newunscopedSeedTypesRefbeside the provider's other refs; thetype === 'app'write site insideensureType's.then(); the first-resolution branch of the existingactiveOrgIdeffectpackages/app-shell/src/providers/__tests__/MetadataProvider.firstBootOrgScope.test.tsx.changeset/first-boot-seed-org-scope-5243.mdpatchNo
packages/app-shell/src/views/**and nopackages/data-objectstack— #5233's surface, untouched. Serialization disciplines: file surface declared to the region level;mainmerged before opening (174f330fa), which also picked up #5233 after it landed as #5272; conflicts go to the merge queue.Verification — all on
174f330fapnpm exec vitest run packages/app-shell/src/providers/— 9 files, 57 tests passedpnpm exec vitest run packages/app-shell/ packages/auth/— 463 files, 4498 passed, 1 skippedpnpm --filter '@object-ui/app-shell' type-check— pass;lint— 0 errorspnpm --filter '@object-ui/app-shell^...' build— passcheck-changeset-presence/check-changeset-no-major/check:control-bytes/check:self-import/check:esm-specifiers/check:phantom-deps— passReverse verification (predicted before running)
Restored
MetadataProvider.tsxtoorigin/main, kept the new tests. No rebuild needed — the test imports../MetadataProviderby relative source path, so vitest reads source, notdist.expected '' to be 'setup,crm'expected [ Array(1) ] to deeply equal [], keyobjectui:metadata:app:@none:0cw83dw1k1dbloPredicted 2 red / 2 green; observed exactly that. Fix restored byte-identically afterwards.
The new test models a genuinely first-boot browser —
firstBoot()assertsActiveOrganizationStorage.get()is null both at mount and after the app fetch lands, then stamps the org. Written the way the existingprimeCacheForpins are written (org stamped before render) it would model a returning browser and pass against the bug.Generated by Claude Code
Generated by Claude Code