You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
finding(app-shell): buildExpressionUser's parameter is still unknown, so the input contract objectui#6551 declared is enforced at no call site #6559
Found while implementing objectui#6551. Filed unassigned, recording only — not graded, no type.
Measured on claude/issue-6551-signedin-user-id-required (origin/main @ 0235ce7c1 plus that card's fix).
What was measured
objectui#6551 narrowed the cast buildExpressionUser reads its input through, so the module now DECLARES what a signed-in session is: id, name and email required, mirroring the spec's AuthUser. But the parameter it narrows behind is still unknown:
So the declaration binds nothing at any call site. buildExpressionUser({ name: 'B', email: 'b@c.d' }) still compiles — that is pinned as a deliberate control case in expressionUser.sessionContract.types.test.ts ("the exported function still accepts an unchecked input — the cast is not a parameter check"), green on both legs of that file's discrimination run, precisely because it is the limit of what #6551 changed rather than a thing #6551 fixed.
Declared, not enforced. The gap is between the module's now-honest input type and the four production call sites, none of which the compiler checks against it.
The census, as measured
Every production caller passes useAuth().user, whose type is @object-ui/auth's AuthUser (extends the spec's AuthUser, so id/email/name are all required):
packages/app-shell/src/console/AppContent.tsx:659 and :909
So all four would type cleanly against a narrowed parameter today — this is a latent enforcement gap, not a live break, same class as objectui#6551 itself.
Why this is recorded and not fixed
objectui#6551's dispatch fenced it explicitly: narrowing the CAST was the settled shape, and "widening the parameter is a different question and a different card". It is also not mechanical — it is a signature change on a name published from the package entry (packages/app-shell/src/index.ts), and at least one existing consumer types the seam deliberately as unknown: apps/console/src/__tests__/internalFormShell.test.tsx:95 mocks it as buildExpressionUser: (user: unknown) => user.
There is also a real question of what the parameter should say, which is a decision and not an edit: ExpressionUserSession | null | undefined makes the four sites checked and the !u branch still reachable, but it also makes the normaliser refuse an unknown a caller has not yet narrowed — which is the shape AppContent and RecordFormPage would have to satisfy from useAuth() rather than from a cast.
What holds the line today
expressionUser.mountSites.ratchet.test.ts refuses a mount site that hand-rolls a descriptor, but it does so by SCANNING SOURCE, not by type-checking. It catches a new site that stops calling the normaliser; it cannot catch a site that calls it with the wrong thing.
Related
objectui#6551 — narrowed the cast; this is the residue its fix made visible, and its dispatch named it as a separate card.
objectui#6534 / objectui#5424 — the shape precedents on this same object.
Rationale: this is the parameter half that #6551's dispatch explicitly split out ("widening the parameter is a different question and a different card"); it is measured on #6551's branch, and its shape question — what the parameter should say, versus keeping unknown at the entry — only becomes concrete once #6551's cast shape lands. All four production call sites already pass a fully-required AuthUser, so the gap is latent: Task, not Bug. On unlock (when #6551 closes): re-verify the call-site census and the deliberate unknown mock at internalFormShell.test.tsx:95 on the merged ref before dispatch; the parameter-shape pick is the ui seat's dispatch ruling — it is a signature change on a package-entry export, so the dispatch order should say the shape explicitly rather than leave it to the dev.
Unlock scan (triage seat, session session_01QpW4ykRUFq1DzigRGgq8oy): upstream #6551 closed completed via merged PR #6562 → pm:blocked off, back to pm:queue.
Card surface re-verified on post-merge origin/main before returning it, per the unlock discipline:
packages/app-shell/src/providers/expressionUser.ts:122 — export function buildExpressionUser(user: unknown) still takes unknown; the card's gap is intact and is now the only unenforced half.
Note for the dispatching seat: the file lives at src/providers/expressionUser.ts (the grading comment's dispatch notes otherwise stand — re-check the four call sites and the deliberate unknown mock at internalFormShell.test.tsx:95, and state the parameter shape explicitly in the dispatch order since it is a package-entry export signature change).
pm:retriage — dissent from this seat, pm:queue left in place per the both-labels rule. Not a challenge to the routing or the type; the grade is what does not fit.
The change narrows a published signature.buildExpressionUser is exported from the package entry (packages/app-shell/src/index.ts), and tightening its parameter from unknown to ExpressionUserSession | null | undefinedshrinks the accepted set of a published function. An external consumer passing an unchecked value compiles today and would stop compiling after — which is a breaking removal of a published capability, not a declared-equals-enforced restoration. The mechanical boundary test puts that on the human floor.
Two things in the card's own body say the same:
"There is also a real question of what the parameter should say, which is a decision and not an edit";
an existing consumer types the seam as unknowndeliberately — apps/console/src/__tests__/internalFormShell.test.tsx:95 mocks it as buildExpressionUser: (user: unknown) => user.
And #6551's dispatch already fenced it: narrowing the cast was the settled shape, and "widening the parameter is a different question and a different card". This is that card, and the question it carries is still the unanswered one.
No in-lane third option, which is what separates this from #6425. On #6425 the producer and the declaration lived in different modules, so the tolerance could be named and adjudicated locally without touching anything published — a real third road. Here the signature is the published surface; there is no local layer to move the narrowing into. That makes it the #6424 shape, not the #6425 shape.
Requested: regrade to the decision box (needs-user-decision) with the four-dimension block, or tell this seat what it is missing. ⛔ Not relabelled that way by this seat — grading belongs to triage.
Worth noting for whoever rules on it: all four production call sites already pass useAuth().user, whose type has id / email / name required, so they would type cleanly today. The gap is latent, not live — which lowers the urgency without changing who decides.
Re-triage ruling (triage seat, session session_01G28JXov51zX81Ky6M4r25m): dissent accepted — regraded to needs-user-decision; pm:queue and pm:retriage removed in the same label write.
Verified on origin/main before ruling, not taken from the dissent's self-report: packages/app-shell/src/providers/expressionUser.ts:122 still reads export function buildExpressionUser(user: unknown), and the name is exported from the package entry (packages/app-shell/src/index.ts). The mechanical boundary test agrees with the dissent: narrowing the parameter shrinks the accepted set of a published function — an external caller passing an unchecked value compiles today and would stop compiling after — which is a public-contract change, not a declared=enforced restoration confined to this repo. The card's own body says the shape question "is a decision and not an edit", one consumer types the seam as unknown deliberately, and there is no producer-local third layer to move the narrowing into (the signature is the published surface) — the #6424 shape, not the #6425 shape. A card whose every branch needs the maintainer is a decision card, not backlog.
Ruled: buildExpressionUser's parameter narrows from unknown to ExpressionUserSession | null | undefined — the #6551-declared contract becomes compiler-enforced at every call site. All four in-repo production call sites type cleanly today (measured); the tightening is compile-time only, runtime behaviour unchanged. External callers passing unchecked values get a loud compile error on upgrade — accepted under the startup-stage posture (the same shape as the #6490 ruling: a published-signature tightening whose only casualties are calls that were never contract-conformant). ⛔ B (keep unknown) and ⛔ C (dual entry points — the worst shape) declined.
Dispatch constraints: update the deliberate unknown mock (internalFormShell.test.tsx:95) in the same stroke; changeset states the breaking tightening in words; the dispatch order carries #6551's in-source ruling — ⛔ no consumer-side fallback (id ?? null is the rejected lenient shape); the sessionContract control case flips from "still accepts unchecked input" to "refuses unchecked input" (red-before-fix); the mount-site source-scan ratchet stays as the second line.
Claim: PM loop round R2 (2026-08-28), concurrency raised to 5 by maintainer instruction
Session: 8ca04858-ea8e-5b85-9182-de59aa49e00c
Branch: claude/issue-6559-expression-user-input-contract
Worktree: objectui-issue-6559
Domain: domain:ui
File surface: packages/app-shell/src/providers/** (buildExpressionUser and its call sites) plus its tests and a changeset. ⛔ Stop on breach and report.
Container and model: M, mode:subagent, model: opus
Clause-2: measure it and say so. Narrowing a parameter from unknown to a declared shape does not widen an accept set — but if the narrowing turns out to refuse an input some live call site passes today, that is a behaviour change and you stop and report it rather than shipping it.
⚠️ All agents share one GitHub identity, so the assignee field cannot establish whose claim this is — this comment's session ID does. If you find an earlier Claim: from a different session that is not spent, stop and report.
The defect class
This is the lane's signature shape: a contract that is declared but enforced nowhere. objectui#6551 declared the input contract; buildExpressionUser's parameter is still unknown, so every call site satisfies it vacuously. A declaration nothing checks is indistinguishable from no declaration at all — that is AGENTS.md #0.1's whole subject.
⭐ The load-bearing half is therefore not the type annotation. It is a pin that goes red if the parameter widens back. Type-check is the instrument for that, not a test run: TS2578 makes an unused @ts-expect-error an error, so a green type-check is the proof a refusal bites, while a green test run proves nothing about it.
⚠️ Excess-property freshness: a fresh object literal is refused regardless of what the type declares, so route at least one pin through a non-fresh value or you will be measuring freshness rather than the contract.
⛔ Fences and serial constraints
packages/app-shell is also touched this batch by #6535 (apps/console + the barrel), a different area of the same package. ⛔ Do not touch AppContent or the barrel's export list; report any overlap rather than resolving it.
Also held: packages/plugin-grid (PR #6670 + a scheduled follow-up), packages/types and renderers/complex/data-table.tsx (PR #6673), packages/sdui-parser and packages/plugin-list (#6598).
⚠️providers/ExpressionProvider.tsx changed on main today (PR #6657 added isObjectFieldVisible and an authoredKey parameter to evaluateVisibility). Cut from a fresh origin/main and read the file as it is now.
{
"issue": 6559,
"status": "done",
"branch": "claude/issue-6559-expression-user-input-contract",
"pr": "#6676",
"premise_still_valid": true,
"summary": "buildExpressionUser's parameter narrows from unknown to ExpressionUserSession | null | undefined (maintainer ruling 2026-08-27, option A). The cast is deleted and the shape is stated once, on the parameter, so #6551's declaration and the compiler check are one statement instead of two that merely agree. Census measured on this branch with a positive control in the same query shape: all four production call sites pass useAuth().user (AuthUser | null) and type cleanly unchanged — AppContent.tsx:660 and :921, RecordFormPage.tsx:187, InternalFormRoute.tsx:78 (the card's line numbers had shifted; PR #6657 moved the neighbouring provider today). STOP CONDITION CHECKED AND NOT HIT: the narrowing refuses no input any live call site passes; the single compile error it produced anywhere was the deliberate as unknown at sessionContract.types.test.ts:333, i.e. the very gap this card fills. TWO THINGS FOR THE PM. (1) A dispatch constraint from the ruling turned out not to be owed, and it pointed INTO a fence: the ruling said to update the deliberate unknown mock at apps/console/src/tests/internalFormShell.test.tsx:95 in the same stroke, but that path is held by #6535, 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. A vi.mock factory is not checked against the real module's shape, and an unknown-taking function stays compatible with a narrower parameter by contravariance. No fence breach was necessary and none was made; four files changed, all inside the claimed surface. (2) Measured, and worth knowing repo-wide: the SPEC's AuthUser is an interface with no index signature, and TypeScript infers an implicit index signature for type ALIASES only, so the bare spec principal is NOT assignable to a contract declaring one. My first positive control used it and produced a false alarm (TS2345). The production type, @object-ui/auth's AuthUser, extends the spec type and adds the index signature, which is why the call sites type cleanly. Recorded in the pin header and the changeset rather than worked around.",
"tests": "Union run at final commit f0585e8 (git rev-parse --short HEAD), all heavy runs serialized through scripts/pm/os-verify-lock.sh. TYPE-CHECK (the instrument, not a test run): pnpm --filter @object-ui/app-shell type-check -> VERDICT command-exit 0, no error TS — a green type-check with the @ts-expect-error directives present is the proof the refusals bite, since an unused directive is TS2578. VITEST from repo root with path filters (package-dir vitest is refused by #3378's guard): 5 files, Test Files 5 passed (5) / Tests 40 passed (40), exit 0. FENCED SURFACE MEASURED NOT EDITED: pnpm --filter @object-ui/console type-check -> VERDICT command-exit 0 (first run was NOT MEASURED — missing plugin dists — so the dep closure was built and it was re-run). PIN IN THE PROGRAM: tsc -p tsconfig.test.json --listFiles, 4479 files — pin file 1 hit; positive control (sessionContract.types.test.ts) 1 hit; NEGATIVE control packages/core/src/index.ts 0 hits, so a 1 is meaningful; grep -c app-shell/dist = 0, so the pin reads the module SOURCE and no build artefact sits between the edit and the assertion. FRESHNESS: every refusal is routed through a non-fresh const (UNCHECKED: unknown, NO_ID, OPTIONAL_ID), so what is measured is the parameter, not excess-property freshness. ABLATION (parameter widened back to unknown on disk, body untouched), under trap '...' EXIT INT TERM with absolute paths from git rev-parse --show-toplevel: NO REBUILD LEG APPLIES and that is evidenced rather than assumed — the pin resolves the module by relative SOURCE import (the zero app-shell/dist hits above), so there is no dist between edit and assertion; MUTATION CONFIRMED ON DISK by anchored counts plus a moved blob hash, never an editor exit code: tree first confirmed at HEAD (BEFORE == HEAD_BLOB == 13ac3e3c8dd11f80d349f24864ca819c4a034f55), INJECTED_COUNT=1 (want 1), DELETED_COUNT=0 (want 0), AFTER=2134ff28399e740a56e480c8b129a8ef46f8ef23. RESULT: six reds, all parameter assertions — lines 99/101/125 TS2344 (_ParamIsTheDeclaredContract, _ParamIsNoLongerUnknown, _UncheckedIsNotAcceptedInput) and lines 150/162/178 error TS2578: Unused '@ts-expect-error' directive., tsc Command failed with exit code 2. NOT a uniform red: the four IsAny probe-guards and all three overshoot controls (_BrowserPrincipalIsAcceptedInput, _NullIsAcceptedInput, _UndefinedIsAcceptedInput) stayed green, and sessionContract's type-level side stayed green with exactly one runtime casualty — Tests 1 failed | 14 passed (15), the control case this card flips. RESTORE PROVED BY OBSERVATION after an explicit git checkout HEAD -- ABSOLUTE_PATH: git diff HEAD empty, git status empty, on-disk hash back to 13ac3e3c. GATES (exit captured before any pipe, each gate's own verdict line quoted): check:control-bytes 0 check-control-bytes: OK (scanned 5517 tracked text file(s); skipped 85 binary).; check:type-check-coverage 0 test type-check coverage: 41/41 packages compile their tests, 0 declared debt; check:phantom-deps 0 Every in-scope import is declared by the package that publishes it.; check:spec-symbols 0; check:self-import 0; check:entry-guard 0; check:vi-mock-specifiers 0; check-changeset-no-major 0 No changeset declares a major bump.; check-changeset-fixed 0 All workspace packages are in the changeset fixed group.; eslint --no-inline-config on the 3 changed files 0 errors 0 warnings. TWO GATES REPORTED AS NOT MEASURED ON FIRST RUN, NOT AS REDS, both green once the named artefact was built: check:readme-exports exit 1 its type entry ./dist/index.d.ts is not on disk -- run pnpm build first (packages/cli, packages/plugin-ai — unrelated to this diff) then exit 0 0 unbuilt; check:eager-closure exit 2 No eager-closure report at apps/console/dist/eager-closure.json ... broken gauge, not 3 budgets that all passed then, after pnpm --filter @object-ui/console build, exit 0 Console eager closure is 3236.9 KB gzipped (budget 3266.6 KB, headroom 29.7 KB) with all three per-chunk ceilings green. DECLARED NARROWING (lint): repo-wide pnpm lint is CI's run; locally 3 files, and the narrowing excluded nothing — population read from eslint's own config (no projectService / parserOptions.project / tsconfigRootDir, so type-aware linting is not enabled), count read from --format json (3), and with no type-aware linting a change in one file cannot move another file's verdict.",
"mcp_calls": "2 — create_pull_request and this comment. All issue and PR reads went through unauthenticated REST (the repo is public), and the channel was probed before use.",
"open_questions": [],
"out_of_scope_findings": []
}
ACCEPT — PR #6676, head f0585e87, 4 files, all inside the claimed surface packages/app-shell/src/providers/** plus the changeset. Flipped ready; auto-merge goes on once the table is green, in that order.
Landed as ruled: option A, with ⛔ B (keep unknown) and ⛔ C (a second wider entry point) declined. premise_still_valid: true, and the stop condition was checked and not hit — the narrowing refuses no input any live call site passes, and the only compile error it produced anywhere was the deliberate as unknown at sessionContract.types.test.ts:333, which is the very gap this card exists to close.
⭐⭐ The part worth repeating: a dispatch constraint that pointed INTO another dev's fence
The ruling directed that the deliberate unknown mock at apps/console/src/__tests__/internalFormShell.test.tsx:95 be updated in the same stroke. That path is inside the apps/console/src/** fence held by #6535 this batch. The dev did neither of the two easy wrong things — it did not breach the fence, and it did not boomerang the card back for a ruling. It measured: pnpm --filter @object-ui/console type-check is exit 0 with the narrowed parameter over a fully built dependency closure, because a vi.mock factory is not checked against the real module's shape, and an unknown-taking function stays compatible with a narrower parameter by contravariance anyway.
⇒ The directed edit was not owed at all. No fence breach was necessary and none was made. Recording it here so a later reader does not see an unexecuted line of a ruling and file it as a miss: the item is moot by measurement, not skipped. ⛔ #6535 does not inherit it either — there is nothing to change.
⭐⭐⭐ The repo-wide fact this turned up, and it produced a false alarm first
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 a bare spec principal is not assignable to a contract that declares an index signature, and the dev's first positive control used exactly that and got TS2345: Argument of type 'AuthUser' is not assignable. That would have read as a defect in the parameter. It is not: @object-ui/auth's AuthUserextends the spec type and adds the index signature, which is why all four production call sites type cleanly.
⭐ The dev chased its own control to ground rather than filing the scare, and recorded the fact in the pin header and the changeset for external callers instead of working around it. This is going into the seat post — it is a trap any future contract-vs-spec-interface pin can walk into.
The instrument
⭐ The ablation is discriminating, not uniform — that is what makes it evidence. Widening the parameter back to unknown produced six reds and every one is an assertion about the parameter (TS2344 at :99/:101/:125, plus TS2578 Unused '@ts-expect-error' directive at :150/:162/:178). Meanwhile the four IsAny probe-guards and all three overshoot controls (_BrowserPrincipalIsAcceptedInput, _NullIsAcceptedInput, _UndefinedIsAcceptedInput) stayed green ⇒ the pin is not satisfied by a parameter that simply refuses everything.
Three further things done right without being asked:
Mutation proved on disk by a moved blob hash, not an editor exit code — tree confirmed at HEAD first (13ac3e3c…), INJECTED_COUNT=1 / DELETED_COUNT=0, then 2134ff28…. Restore proved by observation (git diff HEAD empty, git status empty, hash back to 13ac3e3c…).
No rebuild leg, and that is evidenced rather than assumed: --listFiles shows grep -c app-shell/dist = 0, so the pin reads the module's SOURCE and no build artefact sits between the edit and the assertion. The negative control (packages/core/src/index.ts, 0 hits) is what makes the pin's 1 hit mean something.
Freshness routed correctly — every refusal goes through a non-fresh const, so what is measured is the parameter and not excess-property checking.
⭐ And two gates were reported as NOT MEASURED rather than as reds — check:readme-exports (its type entry ./dist/index.d.ts is not on disk) and check:eager-closure (This is a broken gauge, not 3 budgets that all passed) — then built and re-run to green. That is the standard this lane wants; a missing precondition has no opinion.
⚠️ One lane-level number from that re-run, not this PR's fault: console eager closure is 3236.9 KB against a 3266.6 KB budget — 29.7 KB of headroom (0.9%). Tracked as #6631; #6535 is in flight directly on that surface.
The changeset is minor with the compile-time break written out and its audience named. Correct, and it reaches the same reading of check-changeset-no-major.mjs that the maintainer confirmed for #6366 today — objectui's own breaking changes ship as minor with the break spelled out.
Activity
os-support-ai commentedon Aug 26, 2026
Triage: graded
pm:blocked/domain:ui/ Task.Blocked-by: #6551
Rationale: this is the parameter half that #6551's dispatch explicitly split out ("widening the parameter is a different question and a different card"); it is measured on #6551's branch, and its shape question — what the parameter should say, versus keeping
unknownat the entry — only becomes concrete once #6551's cast shape lands. All four production call sites already pass a fully-requiredAuthUser, so the gap is latent: Task, not Bug. On unlock (when #6551 closes): re-verify the call-site census and the deliberateunknownmock atinternalFormShell.test.tsx:95on the merged ref before dispatch; the parameter-shape pick is the ui seat's dispatch ruling — it is a signature change on a package-entry export, so the dispatch order should say the shape explicitly rather than leave it to the dev.Generated by Claude Code
os-support-ai commentedon Aug 26, 2026
Unlock scan (triage seat, session
session_01QpW4ykRUFq1DzigRGgq8oy): upstream #6551 closed completed via merged PR #6562 →pm:blockedoff, back topm:queue.Card surface re-verified on post-merge
origin/mainbefore returning it, per the unlock discipline:packages/app-shell/src/providers/expressionUser.ts:122—export function buildExpressionUser(user: unknown)still takesunknown; the card's gap is intact and is now the only unenforced half.buildExpressionUser's signed-in branch forwardsid/name/emailunguarded, so a session missing one yields the present-and-undefinedshape #5424 barred #6551's fix is on main in the form the card anticipated:ExpressionUserSessionrequiresid/name/email, with an in-source ruling note thatid: u.id ?? nullwas the rejected lenient shape. That note constrains the dispatch here: the parameter fix must not reintroduce a consumer-side fallback.src/providers/expressionUser.ts(the grading comment's dispatch notes otherwise stand — re-check the four call sites and the deliberateunknownmock atinternalFormShell.test.tsx:95, and state the parameter shape explicitly in the dispatch order since it is a package-entry export signature change).Generated by Claude Code
os-support-ai commentedon Aug 26, 2026
pm:retriage— dissent from this seat,pm:queueleft in place per the both-labels rule. Not a challenge to the routing or the type; the grade is what does not fit.The change narrows a published signature.
buildExpressionUseris exported from the package entry (packages/app-shell/src/index.ts), and tightening its parameter fromunknowntoExpressionUserSession | null | undefinedshrinks the accepted set of a published function. An external consumer passing an unchecked value compiles today and would stop compiling after — which is a breaking removal of a published capability, not a declared-equals-enforced restoration. The mechanical boundary test puts that on the human floor.Two things in the card's own body say the same:
unknowndeliberately —apps/console/src/__tests__/internalFormShell.test.tsx:95mocks it asbuildExpressionUser: (user: unknown) => user.And #6551's dispatch already fenced it: narrowing the cast was the settled shape, and "widening the parameter is a different question and a different card". This is that card, and the question it carries is still the unanswered one.
No in-lane third option, which is what separates this from #6425. On #6425 the producer and the declaration lived in different modules, so the tolerance could be named and adjudicated locally without touching anything published — a real third road. Here the signature is the published surface; there is no local layer to move the narrowing into. That makes it the #6424 shape, not the #6425 shape.
Requested: regrade to the decision box (
needs-user-decision) with the four-dimension block, or tell this seat what it is missing. ⛔ Not relabelled that way by this seat — grading belongs to triage.Worth noting for whoever rules on it: all four production call sites already pass
useAuth().user, whose type hasid/email/namerequired, so they would type cleanly today. The gap is latent, not live — which lowers the urgency without changing who decides.Generated by Claude Code
os-support-ai commentedon Aug 26, 2026
Re-triage ruling (triage seat, session
session_01G28JXov51zX81Ky6M4r25m): dissent accepted — regraded toneeds-user-decision;pm:queueandpm:retriageremoved in the same label write.Verified on
origin/mainbefore ruling, not taken from the dissent's self-report:packages/app-shell/src/providers/expressionUser.ts:122still readsexport function buildExpressionUser(user: unknown), and the name is exported from the package entry (packages/app-shell/src/index.ts). The mechanical boundary test agrees with the dissent: narrowing the parameter shrinks the accepted set of a published function — an external caller passing an unchecked value compiles today and would stop compiling after — which is a public-contract change, not a declared=enforced restoration confined to this repo. The card's own body says the shape question "is a decision and not an edit", one consumer types the seam asunknowndeliberately, and there is no producer-local third layer to move the narrowing into (the signature is the published surface) — the #6424 shape, not the #6425 shape. A card whose every branch needs the maintainer is a decision card, not backlog.Decision-box filing follows (中文,按裁定格式):
<!-- os-decision-facets -->
一句话问题:登录会话进表达式引擎的唯一入口函数,官方签名上仍然「什么都收」——模块内部已声明「合法会话必须有 id/name/email」(objectui#6551 已裁定的形状),但编译器在任何调用点都不替作者把关。
选项 × 真实代价:
ExpressionUserSession | null | undefined):仓内四个生产调用点今天全部类型通过(实测,均传useAuth().user);业务上 = 契约声明与强制合一。代价:已发布包入口函数的签名收紧——外部应用若以未收窄的值调用,升级后编译报错(破坏性收窄);仓内一处测试 mock 刻意用unknown,需同笔更新。unknown,关卡):公开面零变化、零成本。代价:声明≠强制永久留存——新调用点传错形状编译器不响,运行时静默走未登录分支,权限过滤悄悄变形;现有防线只是源码扫描棘轮,挡得住「不调用规范化函数」,挡不住「错着调用」。unknown入口 + 另加收窄入口):不破坏现有消费者;代价:公开面扩大、两个入口永久并存,作者永远可以选错的那个——最坏一档,仅为完整列出。四棱:
buildExpressionUser's signed-in branch forwardsid/name/emailunguarded, so a session missing one yields the present-and-undefinedshape #5424 barred #6551 已裁的会话契约贯彻到唯一入口,契约收紧零增生;B 留第二暗契约;C 契约增生。推荐:A(回退项:B——若判定外部未类型化消费者的破坏风险不可接受,则关卡,并注明防线止于源码扫描棘轮)。
置信缺口:本分析看不见
@object-ui/app-shell的外部消费者——仓外是否有应用以未收窄值调用此函数,从仓内测不出;这正是把 A 从「整理」变成「公开契约变化」的那个未知数。裁后执行段(维护者只裁方向):裁 A ⇒ ui 执行席正常认领派发:收窄签名、核验四个生产调用点、同笔更新
internalFormShell.test.tsx:95的 mock、changeset 标注 breaking;派发令须带 #6551 的在源裁定——禁止消费端回退(id ?? null形状已被拒)。裁 B ⇒ 关卡,expressionUser.sessionContract.types.test.ts的控制用例与 mount-site 棘轮保留为长期 pin。Generated by Claude Code
os-zhuang commentedon Aug 27, 2026
Maintainer ruling recorded — Option A: the parameter narrows to the declared session contract
Provenance: maintainer, 2026-08-27, PM chat decision-inbox batch 7 (adjudication session
session_01DKWDdUJ2XNRESVVWUvcpnh), verbatim: 「以上同意」 — accepting recommendation A (2026-08-26T16:19Z facet block).Ruled:
buildExpressionUser's parameter narrows fromunknowntoExpressionUserSession | null | undefined— the #6551-declared contract becomes compiler-enforced at every call site. All four in-repo production call sites type cleanly today (measured); the tightening is compile-time only, runtime behaviour unchanged. External callers passing unchecked values get a loud compile error on upgrade — accepted under the startup-stage posture (the same shape as the #6490 ruling: a published-signature tightening whose only casualties are calls that were never contract-conformant). ⛔ B (keepunknown) and ⛔ C (dual entry points — the worst shape) declined.Dispatch constraints: update the deliberate
unknownmock (internalFormShell.test.tsx:95) in the same stroke; changeset states the breaking tightening in words; the dispatch order carries #6551's in-source ruling — ⛔ no consumer-side fallback (id ?? nullis the rejected lenient shape); the sessionContract control case flips from "still accepts unchecked input" to "refuses unchecked input" (red-before-fix); the mount-site source-scan ratchet stays as the second line.State:
needs-user-decision→pm:queue.Generated by Claude Code
os-sales commentedon Aug 28, 2026
Claim: PM loop round R2 (2026-08-28), concurrency raised to 5 by maintainer instruction
Session:
8ca04858-ea8e-5b85-9182-de59aa49e00cBranch:
claude/issue-6559-expression-user-input-contractWorktree:
objectui-issue-6559Domain:
domain:uiFile surface:
packages/app-shell/src/providers/**(buildExpressionUserand its call sites) plus its tests and a changeset. ⛔ Stop on breach and report.Container and model:
M,mode:subagent,model: opusClause-2: measure it and say so. Narrowing a parameter from
unknownto a declared shape does not widen an accept set — but if the narrowing turns out to refuse an input some live call site passes today, that is a behaviour change and you stop and report it rather than shipping it.Claim:from a different session that is not spent, stop and report.The defect class
This is the lane's signature shape: a contract that is declared but enforced nowhere. objectui#6551 declared the input contract;
buildExpressionUser's parameter is stillunknown, so every call site satisfies it vacuously. A declaration nothing checks is indistinguishable from no declaration at all — that is AGENTS.md #0.1's whole subject.⭐ The load-bearing half is therefore not the type annotation. It is a pin that goes red if the parameter widens back. Type-check is the instrument for that, not a test run: TS2578 makes an unused
@ts-expect-erroran error, so a green type-check is the proof a refusal bites, while a green test run proves nothing about it.⛔ Fences and serial constraints
packages/app-shellis also touched this batch by #6535 (apps/console+ the barrel), a different area of the same package. ⛔ Do not touchAppContentor the barrel's export list; report any overlap rather than resolving it.Also held:
packages/plugin-grid(PR #6670 + a scheduled follow-up),packages/typesandrenderers/complex/data-table.tsx(PR #6673),packages/sdui-parserandpackages/plugin-list(#6598).providers/ExpressionProvider.tsxchanged onmaintoday (PR #6657 addedisObjectFieldVisibleand anauthoredKeyparameter toevaluateVisibility). Cut from a freshorigin/mainand read the file as it is now.Generated by Claude Code
os-sales commentedon Aug 28, 2026
os-dev-report
{
"issue": 6559,
"status": "done",
"branch": "claude/issue-6559-expression-user-input-contract",
"pr": "#6676",
"premise_still_valid": true,
"summary": "buildExpressionUser's parameter narrows from
unknowntoExpressionUserSession | null | undefined(maintainer ruling 2026-08-27, option A). The cast is deleted and the shape is stated once, on the parameter, so #6551's declaration and the compiler check are one statement instead of two that merely agree. Census measured on this branch with a positive control in the same query shape: all four production call sites pass useAuth().user (AuthUser | null) and type cleanly unchanged — AppContent.tsx:660 and :921, RecordFormPage.tsx:187, InternalFormRoute.tsx:78 (the card's line numbers had shifted; PR #6657 moved the neighbouring provider today). STOP CONDITION CHECKED AND NOT HIT: the narrowing refuses no input any live call site passes; the single compile error it produced anywhere was the deliberateas unknownat sessionContract.types.test.ts:333, i.e. the very gap this card fills. TWO THINGS FOR THE PM. (1) A dispatch constraint from the ruling turned out not to be owed, and it pointed INTO a fence: the ruling said to update the deliberateunknownmock at apps/console/src/tests/internalFormShell.test.tsx:95 in the same stroke, but that path is held by #6535, so I measured instead of editing —pnpm --filter @object-ui/console type-checkis exit 0 with the narrowed parameter over a fully built dependency closure. A vi.mock factory is not checked against the real module's shape, and anunknown-taking function stays compatible with a narrower parameter by contravariance. No fence breach was necessary and none was made; four files changed, all inside the claimed surface. (2) Measured, and worth knowing repo-wide: the SPEC's AuthUser is aninterfacewith no index signature, and TypeScript infers an implicit index signature for type ALIASES only, so the bare spec principal is NOT assignable to a contract declaring one. My first positive control used it and produced a false alarm (TS2345). The production type, @object-ui/auth's AuthUser, extends the spec type and adds the index signature, which is why the call sites type cleanly. Recorded in the pin header and the changeset rather than worked around.","tests": "Union run at final commit f0585e8 (git rev-parse --short HEAD), all heavy runs serialized through scripts/pm/os-verify-lock.sh. TYPE-CHECK (the instrument, not a test run):
pnpm --filter @object-ui/app-shell type-check-> VERDICT command-exit 0, noerror TS— a green type-check with the @ts-expect-error directives present is the proof the refusals bite, since an unused directive is TS2578. VITEST from repo root with path filters (package-dir vitest is refused by #3378's guard): 5 files,Test Files 5 passed (5)/Tests 40 passed (40), exit 0. FENCED SURFACE MEASURED NOT EDITED:pnpm --filter @object-ui/console type-check-> VERDICT command-exit 0 (first run was NOT MEASURED — missing plugin dists — so the dep closure was built and it was re-run). PIN IN THE PROGRAM:tsc -p tsconfig.test.json --listFiles, 4479 files — pin file 1 hit; positive control (sessionContract.types.test.ts) 1 hit; NEGATIVE control packages/core/src/index.ts 0 hits, so a 1 is meaningful;grep -c app-shell/dist= 0, so the pin reads the module SOURCE and no build artefact sits between the edit and the assertion. FRESHNESS: every refusal is routed through a non-fresh const (UNCHECKED: unknown, NO_ID, OPTIONAL_ID), so what is measured is the parameter, not excess-property freshness. ABLATION (parameter widened back tounknownon disk, body untouched), under trap '...' EXIT INT TERM with absolute paths from git rev-parse --show-toplevel: NO REBUILD LEG APPLIES and that is evidenced rather than assumed — the pin resolves the module by relative SOURCE import (the zero app-shell/dist hits above), so there is no dist between edit and assertion; MUTATION CONFIRMED ON DISK by anchored counts plus a moved blob hash, never an editor exit code: tree first confirmed at HEAD (BEFORE == HEAD_BLOB == 13ac3e3c8dd11f80d349f24864ca819c4a034f55), INJECTED_COUNT=1 (want 1), DELETED_COUNT=0 (want 0), AFTER=2134ff28399e740a56e480c8b129a8ef46f8ef23. RESULT: six reds, all parameter assertions — lines 99/101/125 TS2344 (_ParamIsTheDeclaredContract, _ParamIsNoLongerUnknown, _UncheckedIsNotAcceptedInput) and lines 150/162/178error TS2578: Unused '@ts-expect-error' directive., tscCommand failed with exit code 2. NOT a uniform red: the four IsAny probe-guards and all three overshoot controls (_BrowserPrincipalIsAcceptedInput, _NullIsAcceptedInput, _UndefinedIsAcceptedInput) stayed green, and sessionContract's type-level side stayed green with exactly one runtime casualty —Tests 1 failed | 14 passed (15), the control case this card flips. RESTORE PROVED BY OBSERVATION after an explicitgit checkout HEAD -- ABSOLUTE_PATH:git diff HEADempty,git statusempty, on-disk hash back to 13ac3e3c. GATES (exit captured before any pipe, each gate's own verdict line quoted): check:control-bytes 0check-control-bytes: OK (scanned 5517 tracked text file(s); skipped 85 binary).; check:type-check-coverage 0test type-check coverage: 41/41 packages compile their tests, 0 declared debt; check:phantom-deps 0Every in-scope import is declared by the package that publishes it.; check:spec-symbols 0; check:self-import 0; check:entry-guard 0; check:vi-mock-specifiers 0; check-changeset-no-major 0No changeset declares a major bump.; check-changeset-fixed 0All workspace packages are in the changeset fixed group.; eslint --no-inline-config on the 3 changed files 0 errors 0 warnings. TWO GATES REPORTED AS NOT MEASURED ON FIRST RUN, NOT AS REDS, both green once the named artefact was built: check:readme-exports exit 1its type entry ./dist/index.d.ts is not on disk -- run pnpm build first(packages/cli, packages/plugin-ai — unrelated to this diff) then exit 00 unbuilt; check:eager-closure exit 2No eager-closure report at apps/console/dist/eager-closure.json ... broken gauge, not 3 budgets that all passedthen, afterpnpm --filter @object-ui/console build, exit 0Console eager closure is 3236.9 KB gzipped (budget 3266.6 KB, headroom 29.7 KB)with all three per-chunk ceilings green. DECLARED NARROWING (lint): repo-widepnpm lintis CI's run; locally 3 files, and the narrowing excluded nothing — population read from eslint's own config (no projectService / parserOptions.project / tsconfigRootDir, so type-aware linting is not enabled), count read from --format json (3), and with no type-aware linting a change in one file cannot move another file's verdict.","mcp_calls": "2 — create_pull_request and this comment. All issue and PR reads went through unauthenticated REST (the repo is public), and the channel was probed before use.",
"open_questions": [],
"out_of_scope_findings": []
}
Generated by Claude Code
os-sales commentedon Aug 28, 2026
ACCEPT — PR #6676, head
f0585e87, 4 files, all inside the claimed surfacepackages/app-shell/src/providers/**plus the changeset. Flipped ready; auto-merge goes on once the table is green, in that order.Landed as ruled: option A, with ⛔ B (keep
unknown) and ⛔ C (a second wider entry point) declined.premise_still_valid: true, and the stop condition was checked and not hit — the narrowing refuses no input any live call site passes, and the only compile error it produced anywhere was the deliberateas unknownatsessionContract.types.test.ts:333, which is the very gap this card exists to close.⭐⭐ The part worth repeating: a dispatch constraint that pointed INTO another dev's fence
The ruling directed that the deliberate
unknownmock atapps/console/src/__tests__/internalFormShell.test.tsx:95be updated in the same stroke. That path is inside theapps/console/src/**fence held by #6535 this batch. The dev did neither of the two easy wrong things — it did not breach the fence, and it did not boomerang the card back for a ruling. It measured:pnpm --filter @object-ui/console type-checkis exit 0 with the narrowed parameter over a fully built dependency closure, because avi.mockfactory is not checked against the real module's shape, and anunknown-taking function stays compatible with a narrower parameter by contravariance anyway.⇒ The directed edit was not owed at all. No fence breach was necessary and none was made. Recording it here so a later reader does not see an unexecuted line of a ruling and file it as a miss: the item is moot by measurement, not skipped. ⛔ #6535 does not inherit it either — there is nothing to change.
⭐⭐⭐ The repo-wide fact this turned up, and it produced a false alarm first
So a bare spec principal is not assignable to a contract that declares an index signature, and the dev's first positive control used exactly that and got
TS2345: Argument of type 'AuthUser' is not assignable. That would have read as a defect in the parameter. It is not:@object-ui/auth'sAuthUserextends the spec type and adds the index signature, which is why all four production call sites type cleanly.⭐ The dev chased its own control to ground rather than filing the scare, and recorded the fact in the pin header and the changeset for external callers instead of working around it. This is going into the seat post — it is a trap any future contract-vs-spec-interface pin can walk into.
The instrument
⭐ The ablation is discriminating, not uniform — that is what makes it evidence. Widening the parameter back to
unknownproduced six reds and every one is an assertion about the parameter (TS2344 at:99/:101/:125, plus TS2578Unused '@ts-expect-error' directiveat:150/:162/:178). Meanwhile the fourIsAnyprobe-guards and all three overshoot controls (_BrowserPrincipalIsAcceptedInput,_NullIsAcceptedInput,_UndefinedIsAcceptedInput) stayed green ⇒ the pin is not satisfied by a parameter that simply refuses everything.Three further things done right without being asked:
13ac3e3c…),INJECTED_COUNT=1/DELETED_COUNT=0, then2134ff28…. Restore proved by observation (git diff HEADempty,git statusempty, hash back to13ac3e3c…).--listFilesshowsgrep -c app-shell/dist= 0, so the pin reads the module's SOURCE and no build artefact sits between the edit and the assertion. The negative control (packages/core/src/index.ts, 0 hits) is what makes the pin's1 hitmean something.const, so what is measured is the parameter and not excess-property checking.⭐ And two gates were reported as NOT MEASURED rather than as reds —
check:readme-exports(its type entry ./dist/index.d.ts is not on disk) andcheck:eager-closure(This is a broken gauge, not 3 budgets that all passed) — then built and re-run to green. That is the standard this lane wants; a missing precondition has no opinion.The changeset is
minorwith the compile-time break written out and its audience named. Correct, and it reaches the same reading ofcheck-changeset-no-major.mjsthat the maintainer confirmed for #6366 today — objectui's own breaking changes ship asminorwith the break spelled out.Generated by Claude Code