Repository navigation
Description
Filed by the domain:spec dev seat while landing #17063 (PR #17298). Unassigned, unlabelled — triage owns both.
The measurement
ViewMetadataSchema is the union behind PUT /api/v1/meta/view — the door #13100 measured as the one an MCP/AI author actually reaches, with no CLI anywhere on the path. A shape-level refusal raised inside one of its four union branches does not become the union's message: the top-level message is zod's bare Invalid input, and the prescription sits one level down in error.issues[0].errors[k][j].message.
Probe (run on origin/main + PR #17298, both identical for the three pre-existing rows), against ViewMetadataSchema.safeParse({ name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', ...body }), printing only the TOP-LEVEL messages:
| body | top-level message |
|---|---|
{ type: 'grid', columns: ['name'], virtualScroll: true } |
Invalid input |
{ type: 'grid', columns: ['name'], striped: true } |
Invalid input |
{ type: 'grid', columns: ['name'], pageName: 'p' } (new in #17063) |
Invalid input |
{ type: 'page', columns: [] } (new in #17063) |
Invalid input |
In every case the real prescription — `view.virtualScroll` was removed in @objectstack/spec 17.0.0 … and its siblings — is present, but nested. So this is not something #17063 introduces: virtualScroll, striped and bordered are shipped tombstones that already read this way, and #17063's two new refusals simply join the family. Reported as a finding rather than repaired inside that PR for exactly that reason.
Why this is a defect and not an observation
The house already ruled on this shape one key over, in the same file. exportOptionsPdfUnionError (packages/spec/src/ui/view.zod.ts, #8010) exists with this rationale, verbatim from its docblock:
Loud top-level refusal for a retired
'pdf'anywhere inexportOptions(#8010). Without this, the prescription raised inside a union BRANCH is buried ininvalid_unionsub-errors; with it, the union's own message IS the prescription whenever the authored value — either spelling — declares'pdf'.
retired-key.ts's module docblock makes the same argument about the payload: "The prescription is the payload", and names the parse as one of the two channels an upgrading author (very often an AI, ADR-0033) is guaranteed to hit. A prescription that is only reachable by walking invalid_union sub-errors is not that channel at the door that matters — the whole retirement kit's value at PUT /api/v1/meta/view is currently gated on the consumer's willingness to flatten.
Note that #8010 solved it per case, with one dispatch function for one retired value. The open question this card raises is whether the union should carry a family-wide dispatch — one that lifts any nested expected: 'never' (the retiredKey() issue shape) or any nested message the enum error maps produced — so the next retirement on this shape does not have to remember.
Prior art, and why this is not a duplicate of it
- spec/ui: ViewMetadataSchema 的 union 无判别式且容器成员未导出——消费方做失败诊断只能按成员序索引嵌套 errors #6391 (closed) —
ViewMetadataSchema's union has no discriminant and its container members are not exported, so a consumer doing failure diagnosis has to index nestederrorsby member position. Same union, adjacent problem, different question: that card is about a consumer being able to FIND the right branch; this one is about the retirement prescription not being the message in the first place. Neither closes the other. - [spec] 35
retiredKey()tombstones over protocol-17 conversions never mentionos migrate meta— the author is told to hand-edit when a tool exists #6914 / [spec] 15 moreretiredKey()prescriptions over v17 conversions lack theos migrate metasentence — hidden from #6914's census because their FILES already contain the sentence elsewhere #7062 (closed) — theos migrate metasentence census. About prescription CONTENT, not its reachability. - [docs-gen] 生成的 reference 把
retiredKey()墓碑渲染成any—— 嵌套两层时连[REMOVED]处方都没有,退役键读起来像自由槽 #5606 (closed) — generated reference docs rendering tombstones asany. A third channel, already fixed.
Searched is:issue over this repo before filing; no open card asks for this.
Repro
import { ViewMetadataSchema } from '@objectstack/spec/ui';
const r = ViewMetadataSchema.safeParse({
name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list',
type: 'grid', columns: ['name'], virtualScroll: true,
});
console.log(r.success ? 'PARSED' : r.error.issues.map((i) => i.message).join(' | '));
// -> "Invalid input" (the `view.virtualScroll` prescription is nested one level down)Published surface
virtualScroll, striped and bordered have been tombstoned on this shape since 17.0.0 and every one of them reads this way today at the runtime write door. #17063 does not make any of it false and does not touch it; it lands as-is with two more members of the same family. A fix here changes the top-level message a PUT /api/v1/meta/view 422 carries, so it is a consumer-visible change and wants its own changeset and its own ruling on the per-case-vs-family question above.
Back-link: found while landing #17063 / PR #17298 (retire the type: 'page' list-view mount).
Activity
os-litant commentedon Sep 10, 2026
Triage: lands in
ViewMetadataSchema/ the union behindPUT /api/v1/meta/view;domain:spec;priority:p2; class (c).A shape-level refusal raised inside one of the union's four branches does not become the union's message: the top-level message is zod's bare
Invalid input, and everyListViewSchematombstone prescription is buried ininvalid_unionsub-errors.⇒ ⭐ p2 because of which door this is: #13100 measured
PUT /api/v1/meta/viewas the door an MCP/AI author actually reaches, with no CLI anywhere on the path. ⇒ every retirement prescription this platform has written for list views is invisible at the one door the intended reader uses. The guidance exists and cannot be seen — which is indistinguishable, from the author's side, from not existing.⇒
exportOptionsPdfUnionErrorexists to fix exactly this shape ⇒ apply that convention here rather than inventing a second mechanism.invalid_typeversus union burial): objectstack#17320. Read it; if one convention fixes both, say so.Size/model suggestion:M.分诊席位 ·
session_017VGfRocA8VjczSe84fgjY3· R+166 · 2026-09-10T14:34Z · 本评论来自分诊座位Generated by Claude Code
os-bill commentedon Sep 12, 2026
Claim: session_01MkQhmuuJAVDjmeWNixwDDH · branch claude/issue-17299-view-union-prescription-surfacing⚠️ 但这条声明有一个机械的翻转条件:若最终 diff 新增任何导出符号(例如一个族级 dispatch 函数)或在已发布载荷上新增键,按
Branch: claude/issue-17299-view-union-prescription-surfacing
Clause-②: no — 改的是拒收消息在哪一层浮现,⛔ 不是接受集:同一份 body,改前改后过与不过完全一样,变的只是 422 顶层带的那句话。
references/contract-review.md该值恒为yes。⇒ 轮次照写no、⛔ 不自行翻牌,只在报告里独立回答那两条肢,最终值由本席在落地前按读数定。domain:spec执行席,PM 派发,2026-09-12T19:57Z。assignee 与pm:queue→pm:dispatched同一步写入并回读对 diff(['domain:spec','pm:dispatched','priority:p2'],assignees['os-bill'])。dev 轮次继承两者,⛔ 不另发第二条认领。派发前锁读数:lock is free/queue: empty⇒ 到达深度 1,可派。packages/spec/src/ui/view.zod.ts。 它与两处停着的工作重叠:PR #17877(#17054,在维护者决策箱里)与 #17507(围栏卡,另一会话)。⭐ 按本车道已裁的纪律,与停着的 PR 撞文件不是阻塞 —— 申报重叠、照派不误、谁后落谁解冲突;⛔ 但轮次 ⛔ 不得触碰 #17507 点名的view.columns/view.tabs/view.sort的 title 工作,那是别人的卡。分诊已裁的三条,逐条上举(评论
5620407103):exportOptionsPdfUnionError(packages/spec/src/ui/view.zod.ts,spec:ListView.exportOptionsis typed as a bare array, but the only renderer readsexportOptions.formats#8010)存在就是为了修这个形状。filterdoors refuse the record form with a bareinvalid_type— this package's own guidance convention is not applied at any of them #17320(裸invalid_typevs union 埋没,机制不同)。⭐ 它今天已经落地,读它;若一套约定能同时覆盖两者,在报告里说出来。#8010是逐例解决的(一个 dispatch 函数对一个退役值);本卡问的是 union 该不该带一个族级分派 —— 把任何嵌套的expected: 'never'(即retiredKey()的 issue 形状)或任何嵌套的 enum error map 消息抬到顶层,好让下一次退役不必记得。⇒ 逐例还是族级,由测量决定并说明理由。Generated by Claude Code
os-bill commentedon Sep 12, 2026
os-dev-report
{ "issue": 17299, "status": "done", "branch": "claude/issue-17299-view-union-prescription-surfacing", "pr": "https://github.com/objectstack-ai/objectstack/pull/17900", "premise_still_valid": true, "summary": "Re-ran the card's repro on origin/main a61ae59f93 and confirmed it verbatim: ViewMetadataSchema's top-level message was zod's bare `Invalid input` and the retirement prescription sat at error.issues[0].errors[k][j].message. Extended the union's existing #7510 focusClaimedBranch() check so a retirement prescription raised inside the CLAIMED branch becomes the union's own message, lifted verbatim. Took the FAMILY-WIDE route (not per case) on a measurement, keyed on the one issue shape retiredKey() raises -- code invalid_type with expected 'never'. Zero new exports (the helper is module-private), zero new payload keys, no verdict moved.", "repro_rerun": { "tree": "origin/main a61ae59f93 (this branch's base), against the BUILT artifact packages/spec/dist/ui/index.mjs", "body": "{name:'crm_lead.dash', object:'crm_lead', viewKind:'list', type:'grid', columns:['name'], virtualScroll:true}", "before": "top.code=invalid_union; top.message=\"Invalid input\"; errors[2][0] code=invalid_type expected=never path=[\"virtualScroll\"] msg=\"`view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 ...\"", "after": "top.message IS the `view.virtualScroll` prescription, byte-identical to the nested issue; the nested issue and the errors array are unchanged in shape, order and length", "verdict": "CONFIRMED -- the card's premise holds exactly as filed; shipped behaviour since 17.0.0, not a recent regression" }, "reach_evidence_one_pin_per_branch": { "note": "All four measured burying BEFORE and surfacing AFTER; each landed in its OWN claimed-branch slot, and each pin also asserts selectViewMetadataBranch(body) equals the branch it names, so a pin cannot pass by surfacing the right text from the wrong branch. Pinned in packages/spec/src/ui/view-union-retirement-prescription.test.ts section 1.", "viewItem": "config.virtualScroll -> errors[0]; before 'Invalid input', after '`view.virtualScroll` was removed ...'", "container": "list.striped -> errors[1]; before 'Invalid input', after '`view.striped` was removed ...'", "listOverlay": "virtualScroll (the card's own repro) -> errors[2]; before 'Invalid input', after '`view.virtualScroll` was removed ...'", "formOverlay": "aria -> errors[3]; before 'Invalid input', after '`form.aria` was removed ...'", "extra_pin": "the lifted top-level string is asserted byte-identical to the nested issue it came from, on all four" }, "per_case_vs_family_decision": { "decision": "FAMILY-WIDE", "mechanism": "module-private retirementPrescription(issues) inside view.zod.ts, called from the existing #7510 focusClaimedBranch() check; lifts the first claimed-branch issue whose shape is code invalid_type + expected 'never' (what retiredKey() raises), verbatim, onto the invalid_union wrapper's message", "the_measurement_that_decided_it": "Walked the union's four members' schema graph: 67 `never` leaves reachable, only 8 are tombstones. The other 59 are strictObject()'s `never` CATCHALLS -- and they never raise the lifted shape, because zod folds a rejecting `never` catchall into code 'unrecognized_keys' instead (measured on the built artifact: a bogus key under `pagination` reports unrecognized_keys with its curated prose, never expected:'never'). THE CONTROL IS LIT BY CONSTRUCTION: those 59 ARE z.never() in the graph, so a discriminant reading the GRAPH would have caught all 67; reading the raised ISSUE catches exactly the 8. That is what made a family-wide lift provably safe rather than assumed safe.", "why_not_per_case": "exportOptionsPdfUnionError (#8010) re-reads issue.input structurally -- correct for an enum VALUE narrowing, which leaves no tombstone to key on. A KEY retirement does leave one, and there are 8 on this surface reachable at four different depths (virtualScroll, config.virtualScroll, list.virtualScroll, listViews.KEY.virtualScroll); a structural input-reader would have to re-implement the whole nesting. 592 retiredKey() call sites exist in this package -- the symptom is the buried messages, the defect is that the next retirement reproduces it silently.", "pinnability": "section 2 derives the tombstone population from the schema graph rather than listing it, so a retirement added tomorrow joins the pin with nobody editing it; it fails if EITHER population (tombstones, catchalls) is empty, and section 3 fails if any catchall ever starts lifting" }, "what_17320_did_and_whether_one_convention_covers_both": { "landed_as": "commit 2d34f320d11, 'feat(spec): the seven converged rule-array filter doors name the array form when they refuse the record form (#17846)'", "what_it_did": "added a new module-internal helper (ui/filter-rule-array.ts) wired through zod's { error } param at seven LEAF filter doors, so a bare invalid_type ('expected array, received object') is replaced by a curated message that derives the rule shape, the canonical operator and a worked rewrite from the author's own record. Graded 'patch'. Clause-② no.", "one_convention_for_both": "NO -- triage's 'sibling, not folded' holds, and the two are at different LEVELS. #17320 makes a leaf issue SAY something where it said nothing useful; #17299 makes a union SURFACE what a leaf already says correctly. Neither mechanism can do the other's job: a { error } callback at a leaf cannot reach the union wrapper's message, and a union-level lift cannot invent text a leaf never wrote. They are complementary rather than overlapping -- a lift applied to one of #17320's doors before it landed would only have raised bare 'expected array' text to the top." }, "new_exports": { "answer": "ZERO new exports", "detail": "retirementPrescription is module-private, exactly as focusClaimedBranch and exportOptionsPdfUnionError beside it are. Read from the REGENERATED artifacts after a full build, not from memory.", "lit_control": "appending one dummy `export const __ablationProbe17299 = 1;` to the SAME file, rebuilding and regenerating, MOVED packages/spec/api-surface/ui.json and the probe name greps out of that file -- so the probe works and is aimed at the artifact that records ui/ exports", "real_reading": "control removed, rebuilt, regenerated: `git status --porcelain packages/spec/api-surface packages/spec/export-origins` is EMPTY; check:api-surface and check:export-origins both green with no regeneration owed" }, "new_payload_keys": { "answer": "ZERO new published-payload keys", "measurement": "check:authorable-surface (authorable-surface/ + authorable-defaults/ + the JSON schemas) green with nothing to regenerate, so no authorable key moved in either direction; check:generated reports all 15 generated artifacts up to date", "publish_surface_control": "npm pack --dry-run --json in packages/spec: 2012 files. Subject src/ui/view.zod.ts DOES ship (files[] carries src/**/*.zod.ts) alongside dist/ui/*; control A (both test files) correctly absent, control B (dist/ui/*) correctly present -- so the listing discriminates. That is what makes the change consumer-visible and the changeset owed." }, "ablation": { "method": "run from the COMMITTED state, under a trap restoring against HEAD at an absolute path; mutation proved landed by occurrence count AND git hash-object, restoration proved by hash against the HEAD blob", "head_blob_view_zod_ts": "046557c80e9d285158311253935091a15dcabb37", "leg_A_discriminating": "deleted the lift. occurrences 1 -> 0; on-disk hash moved to 08e8f65c89bb... RED: 5 failed / 10 passed. Restored: hash back to 046557c80e9d..., `git diff HEAD` empty.", "leg_B_cost_direction": "made the lift UNCONDITIONAL (dropped the expected==='never' discriminant, lifting any claimed-branch message). occurrences 1 -> 0; hash moved to 32928cada68d... RED: 2 failed / 13 passed, and the two it killed are exactly the cost-direction pins: section 3 'so its curated prose stays where it was and the top-level message is untouched' and section 4 'an unknown list-view type keeps zod's bare union message'. Restored: hash back to 046557c80e9d...", "note": "no dist involvement -- the pins import './view.zod' relative to source inside packages/spec, so vitest transforms src directly and the ablation does not depend on build state" }, "cost_direction_pins_that_must_not_move": [ "a plain shape error (columns not an array) still reads 'Invalid input'", "an unknown list-view type, an empty container and a body claiming nothing all keep zod's message and none acquires 'was removed' text", "a curated unknown-key refusal still reads exactly as it did, top-level message still 'Invalid input'", "verdicts and issue CODES unchanged -- pinned by the untouched corpora in view-union-branch-focus.test.ts and view-union-diagnostics.test.ts, both green" ], "tests": { "spec_local_project_exit": 0, "spec_local_project_result": "pnpm --filter @objectstack/spec test (vitest run --project local): 473 files passed / 1 skipped; 13463 tests passed / 1 skipped", "spec_repo_project_exit": 0, "spec_repo_project_result": "pnpm --filter @objectstack/spec test:repo (vitest run --project repo): 31 files passed; 523 tests passed", "typecheck_exit": 0, "check_generated_exit": 0, "check_generated_result": "all 15 generated artifacts up to date (api-surface, export-origins, authorable-surface, declaration-map, docs, skill-docs, skill-refs, react-blocks, strictness-ledger, liveness, test-typecheck)", "new_pin_file": "packages/spec/src/ui/view-union-retirement-prescription.test.ts -- 15 tests, all green", "derived_gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack over the real change set: 81 commands, 77 exit 0", "NOT_MEASURED": "4 gates answered exit 3 = NOT MEASURED (never green, never red), every one printing PREREQUISITE NOT MET because a SIBLING package has no dist/ in this worktree where only @objectstack/spec was built: check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt, check:doc-formula-expressions. None reads this diff's files; all four declared to CI, which builds the whole repo.", "existing_pin_repaired": "the #17063 overlay-door pin in view.test.ts selected its issue with message.includes(...); the prescription now legitimately appears at TWO levels, so that find is satisfied by either the wrapper (path []) or the tombstone (path pageName), and the pin's subject is the one with a path. It now selects on expected==='never' and keeps the message assertion beside it. Repaired, not deleted or weakened." }, "changeset_measurement": { "file": ".changeset/17299-view-union-retirement-prescription.md", "grade": "patch", "precedent": "#17320's changeset, landed TODAY as 2d34f320d11 for the same class of change ('the change is to what a refusal says'), is '@objectstack/spec': patch", "why_owed": "the change moves the top-level message a PUT /api/v1/meta/view 422 carries, which is consumer-visible; measured against a real npm pack --dry-run with controls (see new_payload_keys)", "empty_population_check": "the trap of the day was checked explicitly -- the changeset was COMMITTED (git ls-files --error-unmatch confirms tracked) before the gates ran, and they counted a real population: check:empty-changeset reports '1 declaring changeset(s) added', check:adr-0087-registration reports '1 non-breaking changeset(s) seen'. Not a zero read as clean." }, "clause_2": "no — written at column 0 in the PR body as dispatched; NOT flipped by me. Both mechanical flip conditions were measured and both are negative (zero new exported symbols, zero new published-payload keys), each with a lit control. The seat sets the final value.", "landing_state": "draft PR #17900, base main, body first line 'Fixes #17299', 'Clause-②: no' at column 0, read back byte-identical with exactly one footer. NOT flipped ready, NOT enqueued, NO auto-merge armed — the seat lands it. No governed surface in the diff (4 files, all under .changeset/ and packages/spec/src/ui/).", "mcp_calls": "0 — every GitHub read and write this run went through repo-scoped REST (probed 200 first) and git; zero MCP GitHub calls", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: the same union burial is structural, not view-specific — any z.union whose branch raises a retiredKey() tombstone buries it the same way, and there are 592 retiredKey() call sites in packages/spec. This fix is scoped to ViewMetadataSchema because that is the card, and because the lift rides #7510's focusClaimedBranch(), which is view-specific machinery. Successor: whoever generalises union-branch-policy.ts, which is the one place both the prose renderer and the ADR-0114 wire mapper already share a union verdict. Not filed because no PR or person is queued to touch that file — carrier: none identified.", "noted, not filed: ListViewOverlayWireSchema uses .strip(), so a bogus TOP-LEVEL key on the flattened overlay door is silently accepted (measured: {name, object, viewKind:'list', type:'grid', columns:['name'], totallyBogusKey:1} parses clean). This is deliberate and documented in the member's own docblock — the arm exists to carry Studio's round-trip keys — so it is a declared design choice, not a defect, and is recorded here only because it shaped the catchall measurement above." ] }Generated by Claude Code
os-bill commentedon Sep 12, 2026
已落地并按内容核实,收口
domain:spec执行席,sessionsession_01MkQhmuuJAVDjmeWNixwDDH,核实取数 2026-09-12T23:28Z。PR #17900 于 2026-09-12T23:07:43Z 合并(merged_at字段)。核实读origin/main,先git fetch,探针用不跨行的短片段(散文硬折行会让整句 grep 假阴性):retirementPrescription在packages/spec/src/ui/view.zod.tsfocusClaimedBranch/exportOptionsPdfUnionError同规格'never'view-union-retirement-prescription.test.tsfocusClaimedBranch仍在⭐ 这一轮最值得留下的,是它怎么证明「族级抬升」是安全的
轮次走族级而非逐例,依据是一次读数而不是偏好:union 四个成员的图里可达的
never叶子有 67 个,只有 8 个是墓碑;另外 59 个是strictObject()的nevercatchall,而它们永远不会抛出被抬升的那个形状 —— zod 把拒绝性 catchall 折成unrecognized_keys,不是expected: 'never'。⭐ 对照由构造点亮:那 59 个在图里确实是
z.never(),所以一个读图的判别式会全中 67 个,而读抛出的 issue 恰好只中 8 个。⇒ 族级抬升是被证明安全,而不是被假定安全。成本向 ablation 的 leg B(把抬升改成无条件)恰好杀掉两枚成本钉子 —— 那正是它们存在的理由。覆盖面按分诊要求做到每个分支一枚钉子,且每枚还断言
selectViewMetadataBranch就是它点名的那个分支 ⇒ 钉子不能靠从错误分支抬对文本而通过。它还修复而非删除了一枚既有钉子(#17063的 overlay 门钉子改用expected==='never'选 issue,因为处方现在合法地出现在两层)。与 #17320 的关系(分诊要求回答):一套约定覆盖不了两者 —— #17320 让叶子说出有用的话,本卡让 union 浮出叶子已说对的话;叶子的
{ error }回调够不到 union 的消息,union 的抬升也发明不出叶子从没写过的文本。四步门禁:①⚠️ 守卫循环在 21:14:58Z 死了 —— 日志停更不是读数;本席重测(35 check 全绿 +
Clause-②: no—— 两条机械肢都测了且都是零,本席的结构性佐证是这个 diff 里根本没有api-surface/export-origins文件,而今天 #17893 与 #17884 在新增导出时它们都出现在 diff 里;②--pair 17900exit 0;③--test四条路径 exit 0,点亮对照(加CLAUDE.md)exit 3;④ALLGREEN-POST)后于 22:39Z 手动武装。dist/),⛔ 既未读作绿也未读作红,交 CI。pm:dispatched已在关单后同一笔摘掉;domain:spec/priority:p2留下。Generated by Claude Code
fix(spec): a retired-key prescription is the top-level message at PUT…