Uh oh!
There was an error while loading. Please reload this page.
fix(app-shell): make buildExpressionUser's parameter the session contract - #6676
Merged
Merged
Conversation
…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-de59aa49e00cContributor
✅ Console Performance Budget
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
Size Limits
|
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#6559
buildExpressionUser's parameter narrows fromunknowntoExpressionUserSession | null | undefined, so the input contract objectui#6551 declared isnow 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:A cast binds nothing. Every call site satisfied the declaration vacuously, and
buildExpressionUser({ name: 'B', email: 'b@c.d' })still compiled — pinned as a deliberatecontrol case in
expressionUser.sessionContract.types.test.ts. A declaration nothing checksis indistinguishable from no declaration at all (AGENTS.md #0.1).
The call-site census, as measured on this branch
Enumerated with
git grep -n buildExpressionUserover all tracked files, with a positivecontrol in the same query shape (
git grep -n ExpressionProvider, 20 hits) so a zero wouldhave 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 haveshifted.
packages/app-shell/src/console/AppContent.tsx:660useAuth().user—AuthUser | nullpackages/app-shell/src/console/AppContent.tsx:921useAuth().user—AuthUser | nullpackages/app-shell/src/views/RecordFormPage.tsx:187useAuth().user—AuthUser | nullapps/console/src/components/InternalFormRoute.tsx:78useAuth().user—AuthUser | nullAll four pass the same thing.
@object-ui/auth'sAuthUserextends the spec'sAuthUser,which declares
id: string; email: string; name: string, so all four satisfy the contracttoday 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 deliberateas unknownobjectui#6551 wrote to document the very gap this PR fills:
The narrowed shape, and why it is the one objectui#6551 declared
The type is not new and was not re-derived here.
ExpressionUserSessionis the shapeobjectui#6551 already wrote and the ruling already settled —
id/name/emailrequired,
roleoptional, index signature retained. This PR does not restate it; it deletesthe 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 ?? nullremains the rejected shape (triageruling 2026-08-26, carried in-source), and the runtime fence in
expressionUser.sessionContract.types.test.tsthat mechanises it is unchanged and stillgreen.
One thing the measurement turned up
The SPEC's
AuthUseris aninterfacewith no index signature, and TypeScript infers animplicit 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: stringtounknown. My first draft used it as the positive control and gotTS2345: Argument of type 'AuthUser' is not assignable, which would have been a false alarmabout the parameter.
@object-ui/auth'sAuthUserextends the spec type and ADDS that indexsignature, 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, compiledby the package's
tsconfig.test.json, which is chained offtype-check(the script CI'sType Check job runs). Two instruments, failing in different directions:
Assert/Equaltype equation overParametersofbuildExpressionUser, index0,reds if the parameter becomes anything but the contract,
unknownincluded.@ts-expect-errordirectives red via TS2578 the moment a refusal stops biting: awidened 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.
--listFilesproof the pin is in the program (tsc -p tsconfig.test.json --listFiles,4479 files):
The last two lines matter as much as the first. The negative control shows a
1ismeaningful rather than an artefact of a project that swallows everything, and the zero
app-shell/disthits show the pin reads the module's SOURCE — no build artefact sits betweenthe edit and the assertion, so nothing stale can degrade a type to
anyand turn a refusalgreen.
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
constof a declared type, passed by name:UNCHECKEDis the card in one line: anunknownthe caller has not narrowed, which isprecisely 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
unknownon disk (the pre-PR shape:input: unknownplus thecast pushed back inside, body untouched), under
trap 'git -C REPO_ROOT checkout HEAD -- ABSOLUTE_PATH' EXIT INT TERMwith absolute pathsresolved 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:
Result — six reds, every one of them an assertion about the parameter:
Not a uniform red, which is what makes it evidence. Green throughout the ablation: the
four
IsAnyprobe-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.tsstayed green too; its single runtimecasualty was exactly the control case this PR flips:
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 isalready in):
No rebuild leg is reported because none applies: the pin resolves the module through a
relative SOURCE import, evidenced by the zero
app-shell/disthits in--listFilesabove.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.pnpm --filter @object-ui/app-shell type-checkVERDICT command-exit 0— noerror TSTest Files 5 passed (5)·Tests 40 passed (40)pnpm --filter @object-ui/console type-checkVERDICT command-exit 0check:control-bytes✅ check-control-bytes: OK (scanned 5517 tracked text file(s); skipped 85 binary).check:type-check-coverage✅ test type-check coverage: 41/41 packages compile their tests, 0 declared debtcheck:phantom-deps✅ Every in-scope import is declared by the package that publishes it.check:spec-symbols✅ spec symbol derivation: 1316 files scanned against 4959 spec export namescheck:self-import✅ No package names itself inside its own src/.check:entry-guard✓ check:entry-guard: 50 scripts/ file(s) — no entry guard outside the baselinecheck:vi-mock-specifiers✅ check-vi-mock-specifiers: OK (3889 tracked source file(s) …)check:readme-exports✅ check-readme-exports: OK (… 0 unbuilt …)check:eager-closure✅ Console eager closure is 3236.9 KB gzipped … (budget: 3266.6 KB, headroom: 29.7 KB)check-changeset-no-major.mjs✅ No changeset declares a major bump.check-changeset-fixed.mjs✅ All workspace packages are in the changeset fixed group.eslint --no-inline-config, 3 changed files0 errors, 0 warningsTwo 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-exportsfirst exited 1 withits type entry ./dist/index.d.ts is not on disk -- run pnpm build firstforpackages/cliandpackages/plugin-ai— unrelated to this diff. After building those two:exit 0,
0 unbuilt.check:eager-closurefirst exited 2 withNo eager-closure report at apps/console/dist/eager-closure.json … This is a broken gauge, not 3 budgets that all passed.Afterpnpm --filter @object-ui/console build: exit 0,all three per-chunk ceilings green.
Declared narrowing (lint). Repo-wide
pnpm lintis CI's run; locally I linted the 3changed 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 lintingconfigured, 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 achangeset:
⛔ Untouched, as fenced:
AppContent.tsx, thepackages/app-shellbarrel export list, andall of
apps/console/src/**(held by #6535, same batch), pluspackages/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
unknownmock atapps/console/src/__tests__/internalFormShell.test.tsx:95(
buildExpressionUser: (user: unknown) => user) be updated in the same stroke. That file isinside the
apps/console/src/**fence, so I measured instead of editing:pnpm --filter @object-ui/console type-checkis exit 0 with the narrowed parameter, overa fully built dependency closure. The mock needs no change — a
vi.mockfactory is notchecked against the real module's shape, and a function taking
unknownstays compatiblewith a narrower parameter by contravariance anyway. So no fence breach was necessary and none
was made.
Changeset
minor, notmajor: objectui's major tracks@objectstack's, so its own breaking changesship as a minor with the break written down (
scripts/check-changeset-no-major.mjs). Thechangeset 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