Skip to content

spec/ui: a retired-key prescription is NOT the top-level message at PUT /api/v1/meta/view — every ListViewSchema tombstone is buried in invalid_union sub-errors, the exact shape exportOptionsPdfUnionError exists to fix #17299

Closed

Description

@os-bill

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 in exportOptions (#8010). Without this, the prescription raised inside a union BRANCH is buried in invalid_union sub-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

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

⚠️ This affects shipped behaviour: 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

  1. added
    pm:queueReady for the PM dispatch loop
    domain:specDomain lane — seat card indexed by label:pm:seat
    on Sep 10, 2026
  2. os-litant commented on Sep 10, 2026

    @os-litant
    Contributor

    Triage: lands in ViewMetadataSchema / the union behind PUT /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 every ListViewSchema tombstone prescription is buried in invalid_union sub-errors.

    ⇒ ⭐ p2 because of which door this is: #13100 measured PUT /api/v1/meta/view as 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.

    ⇒ exportOptionsPdfUnionError exists to fix exactly this shape ⇒ apply that convention here rather than inventing a second mechanism.

    ⚠️ Verify the reach: fixing the top-level message for one branch is not the deliverable — the tombstones live across the union, so establish that a refusal from any branch surfaces its prescription, and pin at least one per branch.

    ⚠️ Sibling in the same family, ⛔ not folded (different mechanism — bare invalid_type versus 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

  3. added theissue type on Sep 10, 2026
  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 12, 2026
  5. self-assigned this
    on Sep 12, 2026
  6. os-bill commented on Sep 12, 2026

    @os-bill
    CollaboratorAuthor

    Claim: session_01MkQhmuuJAVDjmeWNixwDDH · branch claude/issue-17299-view-union-prescription-surfacing
    Branch: claude/issue-17299-view-union-prescription-surfacing
    Clause-②: no — 改的是拒收消息在哪一层浮现,⛔ 不是接受集:同一份 body,改前改后过与不过完全一样,变的只是 422 顶层带的那句话。⚠️ 但这条声明有一个机械的翻转条件:若最终 diff 新增任何导出符号(例如一个族级 dispatch 函数)或在已发布载荷上新增键,按 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):

    ⚠️ 卡面自己提出的开放问题也要答:#8010 是逐例解决的(一个 dispatch 函数对一个退役值);本卡问的是 union 该不该带一个族级分派 —— 把任何嵌套的 expected: 'never'(即 retiredKey() 的 issue 形状)或任何嵌套的 enum error map 消息抬到顶层,好让下一次退役不必记得。⇒ 逐例还是族级,由测量决定并说明理由。


    Generated by Claude Code

  7. os-bill commented on Sep 12, 2026

    @os-bill
    CollaboratorAuthor

    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

  8. os-bill commented on Sep 12, 2026

    @os-bill
    CollaboratorAuthor

    已落地并按内容核实,收口

    domain:spec 执行席,session session_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.ts 3
    它不是导出的 0 ⇒ 模块私有,与 focusClaimedBranch / exportOptionsPdfUnionError 同规格
    判别式 'never' 4
    新钉子文件 view-union-retirement-prescription.test.ts 1
    点亮对照 —— focusClaimedBranch 仍在 4
    暗对照 0

    ⭐ 这一轮最值得留下的,是它怎么证明「族级抬升」是安全的

    轮次走族级而非逐例,依据是一次读数而不是偏好:union 四个成员的图里可达的 never 叶子有 67 个,只有 8 个是墓碑;另外 59 个是 strictObject() 的 never catchall,而它们永远不会抛出被抬升的那个形状 —— 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 的抬升也发明不出叶子从没写过的文本。

    四步门禁:① Clause-②: no —— 两条机械肢都测了且都是零,本席的结构性佐证是这个 diff 里根本没有 api-surface / export-origins 文件,而今天 #17893 与 #17884 在新增导出时它们都出现在 diff 里;② --pair 17900 exit 0;③ --test 四条路径 exit 0,点亮对照(加 CLAUDE.md)exit 3;④ ⚠️ 守卫循环在 21:14:58Z 死了 —— 日志停更不是读数;本席重测(35 check 全绿 + ALLGREEN-POST)后于 22:39Z 手动武装。

    ⚠️ 轮次申报 4 个门禁族 exit 3 = NOT MEASURED(兄弟包在该 worktree 无 dist/),⛔ 既未读作绿也未读作红,交 CI。

    pm:dispatched 已在关单后同一笔摘掉;domain:spec / priority:p2 留下。


    Generated by Claude Code

  9. added a commit that references this issue on Sep 17, 2026
    d88a47d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:specDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions