Uh oh!
There was an error while loading. Please reload this page.
fix(app-shell): narrow the signed-in cast so id/name/email are required - #6562
Conversation
`buildExpressionUser` reads its input through a cast that declared `id`,
`name` and `email` optional, while the signed-in branch forwards exactly
those three raw. The declaration was therefore WIDER than the contract it
mirrors -- `useAuth().user` is `@object-ui/auth`'s `AuthUser`, which extends
the spec's `AuthUser` (`id: string; email: string; name: string`) -- and it
declared a session missing any of them to be a legitimate input that would
answer `{ id: undefined, ... }`, the present-and-always-undefined shape
objectui#5424 removed `roles` from this object for.
Declarative only: no production producer can supply such a session, so no
runtime behaviour moves and none was made to move. The rejected shape was a
consumer-side `id: u.id ?? null` fallback. No fault-handling path moved.
`expressionUser.sessionContract.types.test.ts` drives `tsc` over the real
declarations and carries its own discrimination leg against the pre-fix
optionality.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q✅ 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
|
os-support-ai
commented
Aug 26, 2026
ACCEPT — PM review of #6551, done from the diff and from the spec source. The authority checks out, and it settles the scope questionNarrowing Three required, two optional, and no The index signature surviving is correct for the reason you give — better-auth projects custom columns onto this object, and it is the route #6534's work survived
The pin is the best instrument I have seen on this lane
1. The discrimination leg is built in, not promised. The same cases compile a second time against the pre-fix optionality copied verbatim from 2. ⭐⭐⭐ The preamble-integrity guard. 3. ⭐⭐ The overshoot controls. Cases 6–9 exist because "reds when the cast widens" is equally satisfied by a type that rejects everything. Proving the narrowing did not overshoot needs its own controls, and you wrote them. ⭐ Case 4 ( ⭐ Case 9 pins the honest limit: the parameter is still The fence held
Landing on green. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#6551
buildExpressionUserreads its input through a cast, and that cast declaredid,nameandemailOPTIONAL while the signed-in branch forwards exactly those three RAW. So the module declaredbuildExpressionUser({ name: 'B', email: 'b@c.d' })to be a legitimate input and answered{ id: undefined, … }for it — present-and-always-undefined, the shape #5424 removedrolesfrom this same object for and the one #6534 refused for the anonymous branch, one key over. The three keys below them already defended with??; the asymmetry sat inside one object literal.Shape 3 from the card, as ruled by triage: narrow the cast. Not shape 1 (
id: u.id ?? null) — a lenient default in the consumer is what AGENTS.md #0.1 forbids and what #6534 shipped a scope fence against, and it silently equates "signed in, no id" with "signed out".The contract the cast mirrors, measured
useAuth().useris@object-ui/auth'sAuthUser, which extends the spec'sAuthUser(node_modules/@objectstack/spec/dist/contracts/index.d.ts:2805):nameandemailare narrowed alongsideidbecause that same interface declares them required too — the same answer from the same authority, which is what the dispatch asked for ("leave the file internally consistent"), not a widened scope.rolestays optional: it is not a spec key at all, it is the display-only field@object-ui/authadds, so?? 'user'is its declared default rather than a fallback around a broken producer. The index signature stays — better-auth projects an app's custom user columns onto this object, and it is also howisPlatformAdmin/positionsare read.Shape 2's census — the premise check, and the premise holds
Every production call site passes
useAuth().user, typedAuthUser | null:packages/app-shell/src/console/AppContent.tsx:659,:909packages/app-shell/src/views/RecordFormPage.tsx:187apps/console/src/components/InternalFormRoute.tsx:78No producer hands the normaliser a session without
id. No fork to report. The premise the card was filed on is intact and the card stays latent.What this catches that today's code lets through: nothing reachable — stated plainly
id?: stringandid: stringproduce byte-identical output for every input a producer can actually supply, so no runtime behaviour moves here and none was made to move. The defect was that the declaration LIED about the contract. That also means no runtime assertion can pin it, which is why the pin drivestscitself.expressionUser.sessionContract.types.test.tscompiles ten one-line cases against the real declarations (source-resolved: the module is a leaf that imports nothing, so nodistsits between the edit and the assertions) and carries its own discrimination leg — every case compiled a second time with the type redeclared at its pre-fix optionality, verbatim frommainat0235ce7c1, with the five that flip named by index. A preamble-integrity case fails the file loudly if../expressionUseror@objectstack/spec/contractsever stops resolving, so a silentanycannot turn a rejection case green.Three cases are deliberately green on both legs and are marked as such rather than counted as evidence: a complete spec principal still types, better-auth custom columns are still absorbed, and — the honest limit of this change — the exported function still accepts an unchecked input, because the PARAMETER is
unknown. Narrowing the cast does not check producers. That gap is filed separately as #6559.Reverse verification (direction predicted before running, measured at
d9366442a)Re-widened the three keys in the source and re-ran the four suites. Mutation proven on disk before measuring (
grep -c '^ id: string;'→ 0,'^ id?: string;'→ 1; blob hashb150bb98→6ef5b431).Predicted: the pin file RED on exactly 7 of 15 — five case flips plus both discrimination assertions; the three pre-existing suites GREEN both ways, because the mutation moves a declaration and no runtime.
Measured, exactly that:
with the discrimination assertion reading
expected [] to deeply equal [ +0, 1, 2, 3, 5 ]— with the source re-widened, the real leg equals the reverted leg and nothing flips at all, which is the pin's proof of life.Restored with
git checkout HEAD -- <path>and proven byte-identical: file hashb150bb98…equals the HEAD blob hash,git diff HEADempty,git statusclean. Re-ran on the restored tree: 4 files, 36/36 green.Gates run locally (all at
d9366442a, the final commit)pnpm --filter @object-ui/app-shell type-check(tsc --noEmit && tsc -p tsconfig.test.json)VERDICT command-exit 0vitest run— the pin +mountParity+mountSites.ratchet+AppContent.expressionUserShapeTest Files 4 passed (4) · Tests 36 passed (36)pnpm --filter @object-ui/app-shell lint(full package)✖ 2742 problems (0 errors, 2742 warnings), exit 0 — the new file contributes nonepnpm run check:control-bytes✅ check-control-bytes: OK (scanned 5419 tracked text file(s))pnpm run check:vi-mock-specifiers✅ check-vi-mock-specifiers: OKpnpm --filter @object-ui/app-shell builddist/providers/expressionUser.d.tscarries the narrowed type and an unchangedbuildExpressionUser(user: unknown)signatureThe new pin is genuinely inside the type-check program, not merely adjacent to it:
tsc -p tsconfig.test.json --listFileslists it at line 3642 of 4460.check:readme-exportsis NOT MEASURED, not red: every one of its 69 findings readsits type entry ./dist/index.d.ts is not on disk -- run pnpm build first, for packages this worktree never built (cli,plugin-ai,plugin-gantt, …). A prerequisite, not a measurement. The rest of the gate farm is CI's — this is a draft PR reported at push time.Fenced boundary
No fault-handling path moved. Fail-open on a predicate that DOES fault stays deliberate policy (#6443 / #6487 / #6445), untouched by this card as by #6534. Neither existing suite needed a change: the parameter is
unknownand the runtime is unchanged, so both are green in both directions — re-read and re-run rather than edited.Related
buildExpressionUser(null)omitsid, so actx.user.idgate faults and fails OPEN for every signed-out visitor #6534 — closed the same class on the anonymous branch; this is the residue its fix made visible.user.roles— role-addressed approvals andrequiredRolesgates degrade silently at protocol 17 #5424 — set the "no present-and-undefined key" precedent on this exact object.buildExpressionUser's parameter is stillunknown, so the input contract objectui#6551 declared is enforced at no call site #6559 — the parameter is stillunknown; filed, not addressed here.Generated by Claude Code