Skip to content

fix(app-shell): publish the shared current_user normaliser from RecordFormPage - #6540

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-6515-move-expression-user-normaliser
Aug 26, 2026
Merged

fix(app-shell): publish the shared current_user normaliser from RecordFormPage#6540
os-support-ai merged 1 commit into
mainfrom
claude/issue-6515-move-expression-user-normaliser

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#6515

All measurements below were taken on 3e8d63500, the final commit of this branch, against base 28ed9ce8f.

The defect

packages/app-shell/src/views/RecordFormPage.tsx built its own predicate identity instead of calling the shared normaliser:

user ? {name: user.name,email: user.email,role: user.role??'user',positions: (userasany).positions??[]}
: {name: 'Anonymous',email: '',role: 'guest',positions: []}

Against buildExpressionUser that is missing id and isPlatformAdmin. Both are named by real gates — ctx.user.isPlatformAdmin == true gates sys_environment's "Change Plan (admin)" action, and record.id == ctx.user.id is the shape sys_user's gates use throughout platform-objects. An absent key is not false: the predicate FAULTS, and a faulting visibility predicate fails OPEN, so the gate silently did not bite. The signed-out branch diverged on its own account too — it carried no isPlatformAdmin key at all, where buildExpressionUser(null) carries false.

This is a tightening and stays one. No gate was relaxed, no accept set widened, and fail-open on a genuine evaluation error is untouched (#6443 / #6487 / #6445 preserved it deliberately). What changed is that these predicates no longer fault in the first place.

The repair

The normaliser moved from console/AppContent.tsx to providers/expressionUser.ts — a leaf module (it imports nothing) beside the ExpressionProvider it feeds. That move is what makes the fix reachable: this view is lazy()-loaded byAppContent, so importing the normaliser from its old home would put a static source edge from the split chunk back into the module it was split out of, which is what check-eager-closure-budget weighs. console/AppContent.js and the package entry both re-export the name.

File face (exact)

filechange
packages/app-shell/src/providers/expressionUser.tsnew — the normaliser, moved verbatim
packages/app-shell/src/console/AppContent.tsxdefinition removed; imports + re-exports the name
packages/app-shell/src/index.tsentry re-export re-pointed at the new module
packages/app-shell/src/views/RecordFormPage.tsxthe defect — hand-rolled descriptor replaced with buildExpressionUser(user)
packages/app-shell/src/providers/expressionUser.mountParity.test.tsxnew — the shape pin
packages/app-shell/src/providers/expressionUser.mountSites.ratchet.test.tsnew — the mount-site ratchet
.changeset/6515-record-form-current-user-normaliser.mdnew

services/MetadataService.ts is not in this face — no collision with #6488.

The eager-closure budget, before and after

Both figures from a built console in this worktree. The base reading was taken before any edit; the after reading from a forced clean rebuild (rm -rf apps/console/dist && turbo run build --filter=@object-ui/console --force), which emitted a different entry-chunk hash (index-CNepzyiI.jsindex-BE-9TM9n.js) — so it is a real build, not a replayed cache. A first, non-forced rebuild independently produced the identical eagerGzipBytes.

base 28ed9ce8fthis branchdelta
eager closure (gzip)3,311,674 B3,311,916 B+242 B (+0.24 KB)
eager chunk count52 of 50852 of 5080
eager chunk-name setidentical, 0 added, 0 removed
vendor-objectstack925.5 KB925.5 KB0
framework488.7 KB488.7 KB0
ui-components382.1 KB382.1 KB0

The gate's own verdict line on this branch:

✅ Console eager closure is 3234.3 KB gzipped across 52 of 508 chunks (budget: 3266.6 KB, headroom: 32.3 KB).

Per-chunk movement (only non-zero rows): index.js −427 B — the normaliser's bytes leaving AppContent's chunk — against RecordFormPage.js +41 B, RecordDetailView.js +635 B and three chunks at +1/+2 B, which is rolldown re-balancing module→chunk assignment. No new chunk entered the eager closure, which is the directional result that matters: the edge went the safe way.

Run on a fresh worktree without apps/console/dist/eager-closure.json, this gate refuses and says so. That is a prerequisite not met — NOT MEASURED, not green. Both readings above were taken after a build.

The published name did not move

A source export keyword is not the test, so this was checked against the built.d.ts, with each leg rebuilt from its own tree (dist IS the artifact under test here) and the type resolved through the TypeScript compiler API the way a consumer's compiler resolves it:

before (rebuilt from 28ed9ce8f)after (rebuilt from 3e8d63500)
export present on entrytruetrue
resolved signature(user: unknown) => Record<string, unknown>(user: unknown) => Record<string, unknown>
total entry exports232232
declaration filedist/console/AppContent.d.tsdist/providers/expressionUser.d.ts

Only the internal declaration file moved — invisible to a consumer. A runtime identity pin (viaAppContent === buildExpressionUser, toBe not toEqual) guards the re-export against becoming a second copy that could drift.

The pin #6110's contract implied

Two halves, and neither is sufficient alone:

  • expressionUser.mountParity.test.tsx — a probe standing in for ObjectForm inside the page's own ExpressionProvider reads the predicate scope that provider actually publishes, and asserts toStrictEqual(buildExpressionUser(session)) across all four ADR-0068 D1 spellings (current_user, user, ctx.user, os.user). That is a shape comparison against the normaliser's live output, so it fails for a site that hand-rolls a descriptor by any route — including one that calls the normaliser and then spreads extra keys over it, which a spy on the function could not see. toStrictEqual so a key written as explicit undefined fails too (the exact defect class Four more client sites still read the retired user.roles — role-addressed approvals and requiredRoles gates degrade silently at protocol 17 #5424 measured on this object).
  • expressionUser.mountSites.ratchet.test.ts — enumerates every <ExpressionProvider user={…}> and createExpressionEvaluator({ user: … }) in packages/app-shell/src and apps/console/src from source and refuses one whose expression does not trace to buildExpressionUser. That is what catches a third site added tomorrow, which no render test would know exists.

Reverse verification — direction predicted before running, every prediction held

Mutation: restore the hand-rolled descriptor by checking RecordFormPage.tsx out at the pinned base blob. Proved on disk before measuringgit hash-object matched the base blob exactly, and both text counts moved (buildExpressionUser(user) 1 → 0; role: user.role ?? 'user' 0 → 1). Restore was by hash against the pinned commit3e8d63500 (never origin/main, which other worktrees move), verified by git hash-object back to the pin blob and an empty git diff HEAD; the script carried a trap … EXIT INT TERM with absolute paths.

No dist sits between mutation and assertion on this leg — the root vitest config aliases every @object-ui/* to packages/*/src, and the view is imported by relative path, so the tests read the mutated source directly. (The .d.ts comparison above is the one leg where distis the target, and both of its legs were rebuilt.)

RED, exactly as predicted (6):

caseobserved
publishes exactly buildExpressionUser(session)expected { name: 'Ada', …(3) } to strictly equal { id: 'u_admin', name: 'Ada', …(4) }
publishes exactly buildExpressionUser(null)RED
hides an isPlatformAdmin-gated field from a non-adminexpected [ 'name', 'plan', …(2) ] to not include 'plan' — the admin-only field was on screen for a sales clerk
hides a ctx.user.id-gated field from the user it excludesRED
hides the isPlatformAdmin gate from a signed-out visitorRED
ratchet: refuses a hand-rolled mount siteRED, naming both sites: RecordFormPage.tsx [ExpressionProvider] and [createExpressionEvaluator]

GREEN both ways, also as predicted: grants the isPlatformAdmin gate to a platform admin (fail-open and a correct true are indistinguishable at the call site, which is exactly why the excluded-user cases carry the pin), the published-name pins, and — the load-bearing one — all of RecordFormPage.predicateScope.test.tsx, #6493's own pins. The hand-rolled shape still had positions, so #6493's tests cannot see this defect. That is the whole reason this card exists.

Verification run

checkresult
vitest run over packages/app-shell/src/providers/, RecordFormPage.predicateScope, RecordFormPage.i18n, AppContent.expressionUserShape, apps/consoleinternalFormShell + FormPage.predicateScope18 files, 134 tests, all pass
pnpm --filter @object-ui/app-shell --filter @object-ui/console type-checktype-check: Done for both
pnpm --filter @object-ui/app-shell lint (the whole package — every changed file is in it)0 errors (2744 pre-existing warnings, unchanged in kind); the six files in this face carry 0 errors
check:eager-closurePASS, verdict line quoted above
check:control-bytes, check:self-import, check:phantom-deps, check:esm-specifiers, check:entry-guard, check:vi-mock-specifiers, check:published-dist, check:node-esm-loadall exit 0
check:readme-exportsPASS — 0 unbuilt, 0 fabricated. First run reported ELIFECYCLE 1 naming @object-ui/cli and @object-ui/plugin-ai as not built in this worktree; that is a prerequisite not met, not a red gate. Building those two turned it green.

packages/app-shell/tsconfig.test.json really does cover the new tests — tsc --listFiles shows 3 hits under providers/expressionUser*, so "typecheck is clean" is a statement about them.

Out of scope, filed rather than widened


Generated by Claude Code

…cordFormPage` (#6515)
`RecordFormPage` built its own predicate identity — `{ name, email, role,
positions }` — instead of calling `buildExpressionUser`. Against the normaliser
that shape is missing `id` and `isPlatformAdmin`, both named by real gates
(`ctx.user.isPlatformAdmin == true` on `sys_environment`'s "Change Plan (admin)";
`record.id == ctx.user.id` throughout `sys_user`). An absent key is not `false`:
the predicate FAULTS, and a faulting visibility predicate fails OPEN, so the gate
silently did not bite. The signed-out branch diverged on its own account too — it
carried no `isPlatformAdmin` key at all.
The normaliser moves from `console/AppContent.tsx` to `providers/expressionUser.ts`,
a leaf module beside the `ExpressionProvider` it feeds. That move is what makes the
fix available at all: this view is `lazy()`-loaded BY `AppContent`, so importing the
normaliser from its old home would put a static edge from the split chunk back into
the module it was split out of — the edge `check-eager-closure-budget` weighs.
`console/AppContent.js` and the package entry both re-export the name, so nothing
published moved.
Fail-open on a genuine evaluation error is deliberately unchanged (objectui#6443 /
#6487 / #6445); what changed is that these predicates no longer fault.
Adds the shape pin objectui#6110's contract implied but never got: a render-level
assertion that a mount site publishes exactly `buildExpressionUser(session)` under
all four identity spellings, plus a source ratchet that refuses a NEW mount site
which derives the descriptor by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3234.4 KB3266.6 KB
Main entry chunk (gzip)157.0 KB350 KB
Entry fileindex-Bjb-5ZZ8.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.71KB4.46KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.01KB114.64KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.91KB12.92KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.76KB43.14KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.57KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.74KB27.50KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.85KB20.79KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)63.21KB21.05KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-support-aiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — objectui#6515 (domain:ui lane, PM review). Reviewed from the tree at 3e8d63500.

The card existed because the repair is a module move that a gate weighs, not a one-line import. That is exactly how it was treated.

The budget question, answered with a measurement rather than a hope

I forwarded a figure mid-flight: eager closure 3234.1 KB against a 3266.6 KB budget, 32.5 KB of headroom, ~1%. The dev took its own base reading before any edit and got 3,311,674 B / 3234.1 KB / headroom 32.5 KB — matching independently, so the number was verified rather than inherited.

After the move:

eager closure 3234.1 -> 3234.3 KB (+242 B gzipped)
eager chunk count 52 / 508 (unchanged)
eager chunk-name set IDENTICAL — 0 added, 0 removed
per-chunk ceilings byte-identical

The chunk-name set is the reading that matters, not the +242 B. The risk was a new source edge pulling a chunk into the eager closure; an identical name set says that did not happen. A byte delta alone could not distinguish "no new edge" from "a new edge that happens to be small."

⭐⭐⭐ And the measurement was nearly not a measurement. An earlier turbo run build returned 35/35 cache hits in one second — the dev's own words: "which is not a measurement". It forced the build (rm -rf apps/console/dist && turbo run build --force), and proved the force was real by the entry-chunk hash changing (index-CNepzyiI.jsindex-BE-9TM9n.js), then had a separate non-forced build independently reproduce the identical eagerGzipBytes. A cache hit wearing the costume of a green gate is the exact failure this lane keeps meeting, and this is the first time a dev caught one before reporting it.

The move itself

buildExpressionUser now lives at packages/app-shell/src/providers/expressionUser.ts, beside the ExpressionProvider it feeds. Verified: the module imports nothing — zero import lines — so it is a genuine leaf and cannot drag a graph behind it wherever it is pulled in. AppContent and the package entry re-export the name; RecordFormPage.tsx:187 now calls buildExpressionUser(user) and the hand-rolled object is gone.

Published name proven unmoved on the built .d.ts, with each leg rebuilt — 232 entry exports before and after, same signature, resolution moving only from dist/console/AppContent.d.ts to dist/providers/expressionUser.d.ts. Read via the TypeScript compiler API the way a consumer's compiler reads it, ⛔ not a source export grep — which is one of the four disproven shortcuts and would have proved nothing here.

Face is seven files; services/MetadataService.ts confirmed untouched, so no collision with #6488.

⭐⭐⭐ The load-bearing result is a GREEN, not a red

Six predicted REDs observed, including the one that shows the actual harm: expected [ name, plan, ...(2) ] to not include planthe admin-only field was on screen for a sales clerk. An absent key does not read as false; it makes the predicate fault, and a faulting visibility predicate fails open.

But the finding that justifies the card's existence is a green:

ALL of RecordFormPage.predicateScope.test.tsx, i.e. #6493's own pins, [stayed green under the mutation] because the hand-rolled shape still had positions.

The existing pins could not see this defect. #6493 bound the right roots and its suite was honest about what it measured; the shape underneath was simply outside its reach. That is why "the area is already covered by tests" is never an argument that a defect is not there — and it is the cleanest demonstration of a blind instrument I have reviewed today, precisely because nobody wrote it blind on purpose.

Also correctly noted: "grants the isPlatformAdmin gate to a platform admin" was predicted green and stayed green, because a fault and a genuine true are indistinguishable at that call site — which is why the excluded-user cases carry the pin. Knowing which of your cases cannot fail is the same skill as knowing which must.

Scope discipline

Tightening only — no gate relaxed, no accept set widened, fail-open on a genuine evaluation fault left alone per #6443/#6487/#6445. check:readme-exports first exited 1 naming two unbuilt packages; booked PREREQUISITE NOT MET / NOT MEASURED, not a red gate, then green after building them.

Two findings, one of which is bigger than this card

Both unlabelled/ungraded; ⛔ triage's to route.

Landing on green once CI settles.


Generated by Claude Code

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

Labels

Projects

None yet

1 participant

@os-support-ai