Skip to content

fix(app-shell): make buildExpressionUser's parameter the session contract - #6676

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6559-expression-user-input-contract
Aug 28, 2026
Merged

fix(app-shell): make buildExpressionUser's parameter the session contract#6676
os-sales merged 1 commit into
mainfrom
claude/issue-6559-expression-user-input-contract

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#6559

buildExpressionUser's parameter narrows from unknown to
ExpressionUserSession | null | undefined, so the input contract objectui#6551 declared is
now checked at every call site instead of being satisfied vacuously.

Maintainer ruling 2026-08-27 (decision-inbox batch 7), option A. ⛔ B (keep unknown) and
⛔ C (a second, wider entry point) were declined.

What was wrong

objectui#6551 narrowed the CAST the normaliser read its input through, so the module began
DECLARING what a signed-in session is. The parameter behind that cast stayed unknown:

exportfunctionbuildExpressionUser(user: unknown): Record<string,unknown>{constu=userasExpressionUserSession|null|undefined;

A cast binds nothing. Every call site satisfied the declaration vacuously, and
buildExpressionUser({ name: 'B', email: 'b@c.d' }) still compiled — pinned as a deliberate
control case in expressionUser.sessionContract.types.test.ts. A declaration nothing checks
is indistinguishable from no declaration at all (AGENTS.md #0.1).

The call-site census, as measured on this branch

Enumerated with git grep -n buildExpressionUser over all tracked files, with a positive
control in the same query shape (git grep -n ExpressionProvider, 20 hits) so a zero would
have been distinguishable from a broken query. Line numbers are current: the card's census
was taken before PR #6657 moved providers/ExpressionProvider.tsx, so two of them have
shifted.

production call sitewhat it passestypes after the narrowing
packages/app-shell/src/console/AppContent.tsx:660useAuth().userAuthUser | nullclean
packages/app-shell/src/console/AppContent.tsx:921useAuth().userAuthUser | nullclean
packages/app-shell/src/views/RecordFormPage.tsx:187useAuth().userAuthUser | nullclean
apps/console/src/components/InternalFormRoute.tsx:78useAuth().userAuthUser | nullclean

All four pass the same thing. @object-ui/auth's AuthUser extends the spec's AuthUser,
which declares id: string; email: string; name: string, so all four satisfy the contract
today and none needed an edit.

The stop condition was checked and not hit. The narrowing refuses no input any live call
site passes. The only compile error the change produced anywhere was in this repo's own pin
file — expressionUser.sessionContract.types.test.ts:333, the deliberate as unknown
objectui#6551 wrote to document the very gap this PR fills:

src/providers/expressionUser.sessionContract.types.test.ts(333,39): error TS2345:
Argument of type 'unknown' is not assignable to parameter of type
'ExpressionUserSession | null | undefined'.

The narrowed shape, and why it is the one objectui#6551 declared

The type is not new and was not re-derived here. ExpressionUserSession is the shape
objectui#6551 already wrote and the ruling already settled — id / name / email
required, role optional, index signature retained. This PR does not restate it; it deletes
the cast and puts that same name on the parameter, so the declaration and the check are one
statement rather than two that merely agree. The body is otherwise untouched: same keys,
same ?? defaults, same anonymous branch.

⛔ No consumer-side fallback was added. id: u.id ?? null remains the rejected shape (triage
ruling 2026-08-26, carried in-source), and the runtime fence in
expressionUser.sessionContract.types.test.ts that mechanises it is unchanged and still
green.

One thing the measurement turned up

The SPEC's AuthUser is an interface with no index signature, and TypeScript infers an
implicit index signature for type ALIASES only — never for interfaces — so the bare spec
principal is not assignable to a contract declaring an index signature of
key: string to unknown. My first draft used it as the positive control and got
TS2345: Argument of type 'AuthUser' is not assignable, which would have been a false alarm
about the parameter. @object-ui/auth's AuthUser extends the spec type and ADDS that index
signature, which is exactly what all four call sites pass, so the control is that one. The
fact is recorded in the pin's header and in the changeset for external callers.

The pin — the load-bearing half

packages/app-shell/src/providers/expressionUser.parameterContract.types.test.ts, compiled
by the package's tsconfig.test.json, which is chained off type-check (the script CI's
Type Check job runs). Two instruments, failing in different directions:

  • An Assert / Equal type equation over Parameters of buildExpressionUser, index 0,
    reds if the parameter becomes anything but the contract, unknown included.
  • Three @ts-expect-error directives red via TS2578 the moment a refusal stops biting: a
    widened parameter accepts the argument again, the suppressed error vanishes, and the
    directive itself becomes the error. This is why a green type-check is the proof and a
    green test run is not — every assertion here is erased before vitest sees it.

--listFiles proof the pin is in the program (tsc -p tsconfig.test.json --listFiles,
4479 files):

packages/app-shell/src/providers/expressionUser.parameterContract.types.test.ts 1 hit
packages/app-shell/src/providers/expressionUser.sessionContract.types.test.ts 1 hit (positive control, file known present)
packages/core/src/index.ts 0 hits (negative control)
grep -c "app-shell/dist" 0

The last two lines matter as much as the first. The negative control shows a 1 is
meaningful rather than an artefact of a project that swallows everything, and the zero
app-shell/dist hits show the pin reads the module's SOURCE — no build artefact sits between
the edit and the assertion, so nothing stale can degrade a type to any and turn a refusal
green.

Freshness route. A fresh object literal is refused by excess-property checking regardless
of what the parameter declares, so every refusal case is routed through a NON-FRESH value — a
const of a declared type, passed by name:

constUNCHECKED: unknown={id: 'u_1',name: 'Ada',email: 'a@e.d'};constNO_ID: {name: string;email: string}={name: 'B',email: 'b@c.d'};constOPTIONAL_ID: {id?: string;name: string;email: string}={id: 'u_1',name: 'B',email: 'b@c.d'};

UNCHECKED is the card in one line: an unknown the caller has not narrowed, which is
precisely what all four call sites were free to pass before this change.

Ablation — the pin bites, and only where it should

Widened the parameter back to unknown on disk (the pre-PR shape: input: unknown plus the
cast pushed back inside, body untouched), under
trap 'git -C REPO_ROOT checkout HEAD -- ABSOLUTE_PATH' EXIT INT TERM with absolute paths
resolved from git rev-parse --show-toplevel.

Mutation proved on disk before anything was read — anchored grep counts plus a moved blob
hash, never an editor exit code:

HEAD_BLOB=13ac3e3c8dd11f80d349f24864ca819c4a034f55
BEFORE =13ac3e3c8dd11f80d349f24864ca819c4a034f55 (tree confirmed at HEAD before mutating)
INJECTED_COUNT=1 (want 1) DELETED_COUNT=0 (want 0)
AFTER =2134ff28399e740a56e480c8b129a8ef46f8ef23 (differs — MUTATION CONFIRMED ON DISK)

Result — six reds, every one of them an assertion about the parameter:

expressionUser.parameterContract.types.test.ts(99,3): error TS2344 <- _ParamIsTheDeclaredContract
expressionUser.parameterContract.types.test.ts(101,39): error TS2344 <- _ParamIsNoLongerUnknown
expressionUser.parameterContract.types.test.ts(125,44): error TS2344 <- _UncheckedIsNotAcceptedInput
expressionUser.parameterContract.types.test.ts(150,5): error TS2578: Unused '@ts-expect-error' directive.
expressionUser.parameterContract.types.test.ts(162,5): error TS2578: Unused '@ts-expect-error' directive.
expressionUser.parameterContract.types.test.ts(178,5): error TS2578: Unused '@ts-expect-error' directive.
tsc: Command failed with exit code 2

Not a uniform red, which is what makes it evidence. Green throughout the ablation: the
four IsAny probe-guards, and all three overshoot controls
(_BrowserPrincipalIsAcceptedInput, _NullIsAcceptedInput, _UndefinedIsAcceptedInput) —
so the pin is not satisfied by a parameter that simply refuses everything. The whole
type-level side of sessionContract.types.test.ts stayed green too; its single runtime
casualty was exactly the control case this PR flips:

Tests 1 failed | 14 passed (15)
x the exported function refuses an unchecked input — the parameter is a call-site check

Restoration proved by observation, not by an exit code — restored with an explicit
git checkout HEAD -- ABSOLUTE_PATH (the bare form restores from the index the mutation is
already in):

git diff HEAD -> empty
git status -> empty
on-disk hash -> 13ac3e3c8dd11f80d349f24864ca819c4a034f55 (back to the HEAD blob)

No rebuild leg is reported because none applies: the pin resolves the module through a
relative SOURCE import, evidenced by the zero app-shell/dist hits in --listFiles above.

The control case objectui#6551 left behind

Per the ruling, that card's control case flips from "still accepts an unchecked input" to
"refuses an unchecked input". Its discrimination leg cannot move it — that leg re-widens a
local type ALIAS while the parameter reads the real module's declaration — so the case is now
rejected on both legs there, and the parameter's own discrimination lives in the new file.
Both facts are written into the two headers rather than left for a reader to infer.

Gate verdicts — exit code captured before any pipe, each gate's own verdict line quoted

Union run at f0585e87, the final commit.

gateexitits own verdict line
pnpm --filter @object-ui/app-shell type-check0VERDICT command-exit 0 — no error TS
vitest, 5 suites from repo root0Test Files 5 passed (5) · Tests 40 passed (40)
pnpm --filter @object-ui/console type-check0VERDICT command-exit 0
check:control-bytes0✅ check-control-bytes: OK (scanned 5517 tracked text file(s); skipped 85 binary).
check:type-check-coverage0✅ test type-check coverage: 41/41 packages compile their tests, 0 declared debt
check:phantom-deps0✅ Every in-scope import is declared by the package that publishes it.
check:spec-symbols0✅ spec symbol derivation: 1316 files scanned against 4959 spec export names
check:self-import0✅ No package names itself inside its own src/.
check:entry-guard0✓ check:entry-guard: 50 scripts/ file(s) — no entry guard outside the baseline
check:vi-mock-specifiers0✅ check-vi-mock-specifiers: OK (3889 tracked source file(s) …)
check:readme-exports0✅ check-readme-exports: OK (… 0 unbuilt …)
check:eager-closure0✅ Console eager closure is 3236.9 KB gzipped … (budget: 3266.6 KB, headroom: 29.7 KB)
check-changeset-no-major.mjs0✅ No changeset declares a major bump.
check-changeset-fixed.mjs0✅ All workspace packages are in the changeset fixed group.
eslint --no-inline-config, 3 changed files00 errors, 0 warnings

Two gates were NOT MEASURED on their first run and are reported as such rather than as
reds
, both for a missing precondition, both green once the thing they named was built:

  • check:readme-exports first exited 1 with
    its type entry ./dist/index.d.ts is not on disk -- run pnpm build first for
    packages/cli and packages/plugin-ai — unrelated to this diff. After building those two:
    exit 0, 0 unbuilt.
  • check:eager-closure first exited 2 with
    No eager-closure report at apps/console/dist/eager-closure.json … This is a broken gauge, not 3 budgets that all passed. After pnpm --filter @object-ui/console build: exit 0,
    all three per-chunk ceilings green.

Declared narrowing (lint). Repo-wide pnpm lint is CI's run; locally I linted the 3
changed files and prove the narrowing excluded nothing: (1) the population comes from
eslint's own config, which declares no projectService / parserOptions.project /
tsconfigRootDir — type-aware linting is not enabled; (2) the file count is read from
--format json (3 files, 0 errors, 0 warnings each); (3) with no type-aware linting
configured, a change in one file cannot move another file's verdict, so no untouched file's
result can have shifted.

Scope and fences

Four files, all inside the claimed surface packages/app-shell/src/providers/** plus a
changeset:

.changeset/6559-expression-user-input-contract.md
packages/app-shell/src/providers/expressionUser.ts
packages/app-shell/src/providers/expressionUser.parameterContract.types.test.ts (new)
packages/app-shell/src/providers/expressionUser.sessionContract.types.test.ts

⛔ Untouched, as fenced: AppContent.tsx, the packages/app-shell barrel export list, and
all of apps/console/src/** (held by #6535, same batch), plus packages/plugin-grid,
packages/types, renderers/complex/data-table.tsx, packages/sdui-parser,
packages/plugin-list, packages/i18n, examples/schema-catalog.

One dispatch constraint turned out not to be owed — flagged rather than acted on. The
maintainer ruling directed that the deliberate unknown mock at
apps/console/src/__tests__/internalFormShell.test.tsx:95
(buildExpressionUser: (user: unknown) => user) be updated in the same stroke. That file is
inside the apps/console/src/** fence, so I measured instead of editing:
pnpm --filter @object-ui/console type-check is exit 0 with the narrowed parameter, over
a fully built dependency closure. The mock needs no change — a vi.mock factory is not
checked against the real module's shape, and a function taking unknown stays compatible
with a narrower parameter by contravariance anyway. So no fence breach was necessary and none
was made.

Changeset

minor, not major: objectui's major tracks @objectstack's, so its own breaking changes
ship as a minor with the break written down (scripts/check-changeset-no-major.mjs). The
changeset states the compile-time break in words, names who it can affect (external callers
passing unchecked or under-declared values), and records the spec-interface index-signature
note above.


Generated by Claude Code

…ract
objectui#6551 narrowed the cast the normaliser read its input through, so the
module DECLARED what a signed-in session is. The parameter behind that cast
stayed `unknown`, so the declaration bound nothing: every call site satisfied it
vacuously and `buildExpressionUser({ name: 'B', email: 'b@c.d' })` compiled. A
declaration nothing checks is indistinguishable from no declaration at all.
The cast is gone; the shape is stated once, on the parameter, so the declaration
and the check are the same statement. Maintainer ruling 2026-08-27 (option A).
All four in-repo production call sites pass `useAuth().user` (`AuthUser | null`)
and type cleanly, measured — the narrowing refuses no input any live call site
passes today. Runtime output is unchanged for every input a producer can supply,
and no consumer-side fallback was added.
Pinned by expressionUser.parameterContract.types.test.ts, compiled by the
package's tsconfig.test.json: its refusals are `@ts-expect-error` directives, so
a re-widened parameter makes them unused and TS2578 turns the type-check red.
Every refusal is routed through a non-fresh value, so what is measured is the
parameter and not excess-property freshness.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_8ca04858-ea8e-5b85-9182-de59aa49e00c
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3236.9 KB3266.6 KB
Main entry chunk (gzip)157.3 KB350 KB
Entry fileindex-DvJ3b_7D.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.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
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)509.24KB115.61KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)239.05KB60.06KB
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.85KB12.89KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.43KB34.48KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.01KB32.23KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.51KB54.54KB
plugin-kanban (index.js)53.11KB14.62KB
plugin-list (index.js)112.86KB27.54KB
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.44KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)85.87KB21.12KB
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)65.97KB21.98KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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-sales
os-sales added this pull request to the merge queueAug 28, 2026
Merged via the queue into main with commit a4d39a8Aug 28, 2026
30 checks passed
@os-sales
os-sales deleted the claude/issue-6559-expression-user-input-contract branch August 28, 2026 14:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(app-shell): buildExpressionUser's parameter is still unknown, so the input contract objectui#6551 declared is enforced at no call site

2 participants

@os-sales@claude