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(types): the ADR-0049 z.never() retirement tombstones refuse by name but their .describe() remediation text never reaches the author — runtime message is zod's generic "expected never" #6105
Found while implementing #5852 (out of scope there; recorded, not touched).
Measured on origin/main at 0c282d979, against built dist/
This package's ADR-0049 retirement convention writes a tombstone as z.never().optional().describe('RETIRED (objectui#NNNN) — <why, and what to use instead>'). packages/types/src/zod/data-display.zod.ts carries nine of them on StaticTableColumnSchema (minWidth, align, fixed, type, sortable, filterable, resizable, editable, cell), and its doc block states the
intent:
The z.never().optional() members are ADR-0049 retirement tombstones (the
convention crud.zod.tsconfirm set): an authored value is REFUSED at
parse time with the key named in the error path, instead of being silently
stripped the way an undeclared key would be. Loud refusal is the ruled
outcome — these keys were accepted-and-inert for as long as the static
table shared the rich column type.
The refusal half is real and works. The remediation text does not reach the
author at runtime. Measured:
The carefully written .describe() string — "RETIRED (objectui#5474) — never
read by the static table; use data-table, or a cellClassName like text-right" —
appears nowhere in the issue. .describe() is schema metadata (it surfaces
in generated JSON-Schema / docs), not an error message, so an author who trips
the tombstone is told only that the key expected never. That names which key
is wrong via the path, but says nothing about why it was retired or what to
use instead, which is the part the convention's doc block was written to
deliver.
Why this is worth recording
The whole point of choosing loud refusal over silent stripping is that the author
learns something. Half the payload currently gets dropped: an AI or human author
who writes align: 'right' on a static table column sees expected never, received string and has no path to cellClassName: 'text-right' without reading
the source.
Candidate remedy (not applied here)
zod 4 (this repo pins zod@4.4.3) accepts a custom error on z.never(), and it
does reach the issue. Verified in this repo's own node_modules:
z.never({ error: 'RETIRED (objectui#5852) — delete the key.' })
-> path: ["k"], message: "RETIRED (objectui#5852) — delete the key."
So the fix shape is to carry the text in both places — z.never({ error: '…' }).optional().describe('…') — keeping .describe() for
generated docs while making the runtime refusal explanatory. #5852 lands one
instance of that spelling (DashboardConfigSchema.aria), which is what surfaced
the gap in the older ones.
Scope, honestly stated: this is a message-quality finding, not a
correctness one. Every one of these tombstones already refuses the right keys at
the right paths; no validation outcome is wrong. Sizing the sweep (nine keys in data-display.zod.ts plus the crud.zod.tsconfirm set that established the
convention, and any others) is triage's call, not measured here.
Triage: labeled finding + domain:ui (routing only — awaiting first-touch grading). Observation class: the ADR-0049 z.never() tombstones' .describe() remediation text never reaches the author at runtime (zod emits its generic "expected never"). Real DX gap, no user-blocking defect today. Grading note for first touch: the fix likely lives in a shared error-map / refusal-presentation layer, and whether it belongs here or in the spec's error-guidance face (objectstack packages/spec) should be settled by reading which package owns the parse call — if it lands spec-side, this transfers per file-at-destination.
Concentrated triage batch: finding → pm:queue + domain:ui, Task, M — the ADR-0049 tombstone convention's remediation text (.describe('RETIRED (objectui#NNNN) — use X instead')) never reaches the author: the runtime message is zod's generic "expected never". The ruled outcome is LOUD refusal; a refusal that names the key but not the remedy is half of it. Fix: carry the describe text into the issue message (error map scoped to the tombstone members, or a refine-based tombstone helper) — one shared mechanism, all nine members, pinned on the message content; accept set unchanged.
{
"issue": 6105,
"status": "done",
"branch": "claude/issue-6105-tombstone-refusal-message",
"pr": "https://github.com/objectstack-ai/objectui/pull/6930",
"premise_still_valid": true,
"summary": "ROUTING SETTLED HERE, BY MEASUREMENT, BEFORE ANY CODE: the parse producing the author-visible message for these keys is owned by @object-ui/types, not @objectstack/spec. Three readings: (1) StaticTableColumnSchema is declared only in packages/types/src/zod/data-display.zod.ts, and spec declares no schema with these members (grep minWidth over packages/spec/src/ui/*.zod.ts is empty; accessorKey appears there only in a prose comment in conversions/registry.ts); (2) spec's ui component-type vocabulary has no 'table' entry at all — the static table is ObjectUI-native, and spec's own retiredKey tombstones cover object-grid, a different component; (3) the author-visible channel is this repo's CLI — packages/cli/src/commands/validate.ts and check.ts call safeValidateSchema from @object-ui/types/zod and print issue.message, issue.path and issue.code verbatim. IMPLEMENTATION: one shared mechanism, retirementTombstone() in the new packages/types/src/zod/tombstone.zod.ts, taking the guidance string ONCE into both z.never({ error }) (the parse message) and .describe() (JSON-Schema/docs) so the two cannot drift. Applied to exactly the nine triage named; every describe string carried through byte-identical (the diff changes only the call wrapper). Not re-exported from index.zod.ts, so no new public API. PREMISE RE-MEASURED on merge-base e3d117ae1 and still valid: all nine emitted zod's generic 'Invalid input: expected never, received TYPE', with a declared-key control parsing green in the same run. zod pin confirmed 4.4.3 (resolved from node_modules/.pnpm/zod@4.4.3), and z.never({ error }) still reaches the issue. Note the five later tombstones on the SAME shape (#6424/#6425) and hoverable/striped were left on the bare spelling per scope, and are pinned as an explicit scope-boundary test so the unconverted half is a recorded decision, not an oversight.",
"tests": "MODULE RESOLUTION STATED BEFORE MEASURING: the before/after probe imports the SOURCE module packages/types/src/zod/data-display.zod.ts via tsx — never dist; packages/types/dist does not exist in a fresh worktree, so no stale artefact can be read. CLAUSE 2 PROVED, not assumed — same probe before and after, all nine members plus a declared-key control: success false/false for all nine, issue path identical (['minWidth'] etc.), issue code identical ('invalid_type') for all nine; ONLY message differs (e.g. align: 'Invalid input: expected never, received string' -> 'RETIRED (objectui#5474) — never read by the static table; use data-table, or a cellClassName like text-right'). Control parsed GREEN both runs with identical parsed output. The refine route was NOT taken precisely because it reports code 'custom'; z.never({ error }) keeps invalid_type, so there is no code change to report. .describe() SURVIVES: all 19 members' .description compared before/after — byte-identical, zero diffs. ABLATION, direction predicted IN WRITING BEFORE THE RUN: predicted RED in exactly 2 of the 3 new tests (the nine-member guidance loop failing on align, and the align literal pin), with the scope-boundary test AND the pre-existing 'REFUSES a column authoring align' test staying GREEN since the accept set is untouched. Mutated the FACT (align back to the bare z.never().optional().describe() with the same text), not the assertion, under trap EXIT INT TERM with absolute paths from git rev-parse --show-toplevel. Mutation proved on disk: anchored counts both directions (injected 1, removed 0) and git hash-object moved 74c7bfa6 -> 4140e557. No rebuild leg applies and that is proved not assumed: the suite imports '../zod/data-display.zod' by relative SOURCE path and packages/types/dist does not exist. OBSERVED: 'Tests 2 failed | 32 passed (34)' — exactly the two predicted tests by name. Restore proved BOTH ways: git diff HEAD empty for the file AND hash-object back to the HEAD blob 74c7bfa6, restored with git checkout HEAD -- plus the absolute path (never the bare form). GATES, all on final HEAD 494a66e0, exit codes captured before any pipe (redirect-then-capture): pnpm --filter @object-ui/types type-check (hyphenated; script echoed: 'tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json') exit 0 — no false red, and @object-ui/types has ZERO workspace deps so the empty '^...' closure build ('No projects matched the filters', exit 0) is the truthful answer, not the zero-match trap. NOT-MEASURED trap checked: tsc --listFiles shows tombstone.zod.ts and data-display.zod.ts in the main program and static-table-narrow-surface.test.ts + tombstone.zod.ts in the tsconfig.test.json program, so the type-check really does cover the edited files. Root-form vitest over packages/types (the --filter forms are guard-refused here): BEFORE 75 files / 858 tests exit 0, AFTER 75 files / 861 tests exit 0 — +3 tests, no file lost. The BEFORE reading was taken by reverting the three paths to the merge-base under the same trap/absolute-path/hash discipline, with the revert proved on disk and the restore proved both ways. pnpm exec eslint . in packages/types (plain form, no --no-inline-config) exit 0: 139 files linted per eslint's own --format json count, 0 errors, 259 pre-existing warnings, my three files 0 errors. node scripts/check-changeset-presence.mjs exit 0, its own verdict line: '2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'. GATE FAMILY RE-DERIVED for my actual diff (objectui has no scripts/pm/dispatch-gates.mjs — that script lives in objectstack and answers only about its own tree, so it was NOT used): check:control-bytes, check:self-import, check:esm-specifiers, check:node-esm-load, check:phantom-deps, check:pre-install-import-graph, check:readme-exports, check:spec-symbols, check:vi-mock-specifiers, check:vi-mock-inherit, check:side-effects-array — all exit 0. ONE RED, and it is not mine: check:published-dist exit 1, finding '@object-ui/fields [tooling-in-published-output] packages/fields/dist/__tests__/numberInputBrowserReadings.d.ts'. My branch changes 4 files, all under packages/types and .changeset (git diff --name-only merge-base..HEAD), and the offending source exists at the merge-base. Already tracked as objectui#6861. Heavy runs went through scripts/pm/os-verify-lock.sh (VERDICT lines read, not bare $?); the one exception is the 11s BEFORE vitest run, which I ran unlocked — declaring it rather than leaving it implicit.",
"mcp_calls": "2 — one search_issues that 422'd on punctuation in the query, then one that returned. Everything else (issue body, both comments, PR create, PR body read-back, report comment) went over plain REST, and the duplicate-search channel switch is declared below.",
"open_questions": [],
"out_of_scope_findings": [
"NOT re-filed, already tracked as objectui#6861: check:published-dist is red on main — @object-ui/fields emits dist/__tests__/numberInputBrowserReadings.d.ts into its published tarball. Cause is the exact name-vs-directory mismatch the gate's own message names: packages/fields/tsconfig.json excludes '**/*.test.ts' by NAME but not the __tests__ DIRECTORY, so a helper without '.test.' in its name is emitted. Duplicate search: repo-scoped REST GET /search/issues is 403 for this seat (plain repo reads are 200), so I switched to ONE targeted MCP search_issues — declared channel switch; it returned non-empty, so no vacuity control was needed.",
"REPORTED, NOT FILED (the dispatch instructed 'report the list', and card scoping is the PM's): the tombstone population is 21 declarations over packages/types/src/zod/** (24 z.never( grep hits, 3 of them prose in doc comments) — data-display.zod.ts 17, overlay.zod.ts 2, complex.zod.ts 1, crud.zod.ts 1. Of those, complex.zod.ts DashboardConfigSchema.aria ALREADY carries { error } (objectui#5852) and nine are converted here, leaving 11 still emitting zod's generic message: 5 on StaticTableColumnSchema itself (headerIcon, fitContent, format, options, currency — objectui#6424/#6425), hoverable + striped on TableSchema, timeScale on the gantt shape, the 2 command-item tombstones in overlay.zod.ts, and crud.zod.ts confirm (objectui#4314 — the convention's origin). All 11 are one-line conversions now that retirementTombstone() exists; say the word and I will queue a card."
]
}
Activity
os-zhuang commentedon Aug 24, 2026
Triage: labeled
finding+domain:ui(routing only — awaiting first-touch grading). Observation class: the ADR-0049z.never()tombstones'.describe()remediation text never reaches the author at runtime (zod emits its generic "expected never"). Real DX gap, no user-blocking defect today. Grading note for first touch: the fix likely lives in a shared error-map / refusal-presentation layer, and whether it belongs here or in the spec's error-guidance face (objectstackpackages/spec) should be settled by reading which package owns the parse call — if it lands spec-side, this transfers per file-at-destination.Generated by Claude Code
claude commentedon Aug 25, 2026
Concentrated triage batch:
finding→pm:queue+domain:ui, Task, M — the ADR-0049 tombstone convention's remediation text (.describe('RETIRED (objectui#NNNN) — use X instead')) never reaches the author: the runtime message is zod's generic "expected never". The ruled outcome is LOUD refusal; a refusal that names the key but not the remedy is half of it. Fix: carry the describe text into the issue message (error map scoped to the tombstone members, or a refine-based tombstone helper) — one shared mechanism, all nine members, pinned on the message content; accept set unchanged.Generated by Claude Code
claude commentedon Aug 31, 2026
os-dev-report
{ "issue": 6105, "status": "done", "branch": "claude/issue-6105-tombstone-refusal-message", "pr": "https://github.com/objectstack-ai/objectui/pull/6930", "premise_still_valid": true, "summary": "ROUTING SETTLED HERE, BY MEASUREMENT, BEFORE ANY CODE: the parse producing the author-visible message for these keys is owned by @object-ui/types, not @objectstack/spec. Three readings: (1) StaticTableColumnSchema is declared only in packages/types/src/zod/data-display.zod.ts, and spec declares no schema with these members (grep minWidth over packages/spec/src/ui/*.zod.ts is empty; accessorKey appears there only in a prose comment in conversions/registry.ts); (2) spec's ui component-type vocabulary has no 'table' entry at all — the static table is ObjectUI-native, and spec's own retiredKey tombstones cover object-grid, a different component; (3) the author-visible channel is this repo's CLI — packages/cli/src/commands/validate.ts and check.ts call safeValidateSchema from @object-ui/types/zod and print issue.message, issue.path and issue.code verbatim. IMPLEMENTATION: one shared mechanism, retirementTombstone() in the new packages/types/src/zod/tombstone.zod.ts, taking the guidance string ONCE into both z.never({ error }) (the parse message) and .describe() (JSON-Schema/docs) so the two cannot drift. Applied to exactly the nine triage named; every describe string carried through byte-identical (the diff changes only the call wrapper). Not re-exported from index.zod.ts, so no new public API. PREMISE RE-MEASURED on merge-base e3d117ae1 and still valid: all nine emitted zod's generic 'Invalid input: expected never, received TYPE', with a declared-key control parsing green in the same run. zod pin confirmed 4.4.3 (resolved from node_modules/.pnpm/zod@4.4.3), and z.never({ error }) still reaches the issue. Note the five later tombstones on the SAME shape (#6424/#6425) and hoverable/striped were left on the bare spelling per scope, and are pinned as an explicit scope-boundary test so the unconverted half is a recorded decision, not an oversight.", "tests": "MODULE RESOLUTION STATED BEFORE MEASURING: the before/after probe imports the SOURCE module packages/types/src/zod/data-display.zod.ts via tsx — never dist; packages/types/dist does not exist in a fresh worktree, so no stale artefact can be read. CLAUSE 2 PROVED, not assumed — same probe before and after, all nine members plus a declared-key control: success false/false for all nine, issue path identical (['minWidth'] etc.), issue code identical ('invalid_type') for all nine; ONLY message differs (e.g. align: 'Invalid input: expected never, received string' -> 'RETIRED (objectui#5474) — never read by the static table; use data-table, or a cellClassName like text-right'). Control parsed GREEN both runs with identical parsed output. The refine route was NOT taken precisely because it reports code 'custom'; z.never({ error }) keeps invalid_type, so there is no code change to report. .describe() SURVIVES: all 19 members' .description compared before/after — byte-identical, zero diffs. ABLATION, direction predicted IN WRITING BEFORE THE RUN: predicted RED in exactly 2 of the 3 new tests (the nine-member guidance loop failing on align, and the align literal pin), with the scope-boundary test AND the pre-existing 'REFUSES a column authoring align' test staying GREEN since the accept set is untouched. Mutated the FACT (align back to the bare z.never().optional().describe() with the same text), not the assertion, under trap EXIT INT TERM with absolute paths from git rev-parse --show-toplevel. Mutation proved on disk: anchored counts both directions (injected 1, removed 0) and git hash-object moved 74c7bfa6 -> 4140e557. No rebuild leg applies and that is proved not assumed: the suite imports '../zod/data-display.zod' by relative SOURCE path and packages/types/dist does not exist. OBSERVED: 'Tests 2 failed | 32 passed (34)' — exactly the two predicted tests by name. Restore proved BOTH ways: git diff HEAD empty for the file AND hash-object back to the HEAD blob 74c7bfa6, restored with git checkout HEAD -- plus the absolute path (never the bare form). GATES, all on final HEAD 494a66e0, exit codes captured before any pipe (redirect-then-capture): pnpm --filter @object-ui/types type-check (hyphenated; script echoed: 'tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json') exit 0 — no false red, and @object-ui/types has ZERO workspace deps so the empty '^...' closure build ('No projects matched the filters', exit 0) is the truthful answer, not the zero-match trap. NOT-MEASURED trap checked: tsc --listFiles shows tombstone.zod.ts and data-display.zod.ts in the main program and static-table-narrow-surface.test.ts + tombstone.zod.ts in the tsconfig.test.json program, so the type-check really does cover the edited files. Root-form vitest over packages/types (the --filter forms are guard-refused here): BEFORE 75 files / 858 tests exit 0, AFTER 75 files / 861 tests exit 0 — +3 tests, no file lost. The BEFORE reading was taken by reverting the three paths to the merge-base under the same trap/absolute-path/hash discipline, with the revert proved on disk and the restore proved both ways. pnpm exec eslint . in packages/types (plain form, no --no-inline-config) exit 0: 139 files linted per eslint's own --format json count, 0 errors, 259 pre-existing warnings, my three files 0 errors. node scripts/check-changeset-presence.mjs exit 0, its own verdict line: '2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'. GATE FAMILY RE-DERIVED for my actual diff (objectui has no scripts/pm/dispatch-gates.mjs — that script lives in objectstack and answers only about its own tree, so it was NOT used): check:control-bytes, check:self-import, check:esm-specifiers, check:node-esm-load, check:phantom-deps, check:pre-install-import-graph, check:readme-exports, check:spec-symbols, check:vi-mock-specifiers, check:vi-mock-inherit, check:side-effects-array — all exit 0. ONE RED, and it is not mine: check:published-dist exit 1, finding '@object-ui/fields [tooling-in-published-output] packages/fields/dist/__tests__/numberInputBrowserReadings.d.ts'. My branch changes 4 files, all under packages/types and .changeset (git diff --name-only merge-base..HEAD), and the offending source exists at the merge-base. Already tracked as objectui#6861. Heavy runs went through scripts/pm/os-verify-lock.sh (VERDICT lines read, not bare $?); the one exception is the 11s BEFORE vitest run, which I ran unlocked — declaring it rather than leaving it implicit.", "mcp_calls": "2 — one search_issues that 422'd on punctuation in the query, then one that returned. Everything else (issue body, both comments, PR create, PR body read-back, report comment) went over plain REST, and the duplicate-search channel switch is declared below.", "open_questions": [], "out_of_scope_findings": [ "NOT re-filed, already tracked as objectui#6861: check:published-dist is red on main — @object-ui/fields emits dist/__tests__/numberInputBrowserReadings.d.ts into its published tarball. Cause is the exact name-vs-directory mismatch the gate's own message names: packages/fields/tsconfig.json excludes '**/*.test.ts' by NAME but not the __tests__ DIRECTORY, so a helper without '.test.' in its name is emitted. Duplicate search: repo-scoped REST GET /search/issues is 403 for this seat (plain repo reads are 200), so I switched to ONE targeted MCP search_issues — declared channel switch; it returned non-empty, so no vacuity control was needed.", "REPORTED, NOT FILED (the dispatch instructed 'report the list', and card scoping is the PM's): the tombstone population is 21 declarations over packages/types/src/zod/** (24 z.never( grep hits, 3 of them prose in doc comments) — data-display.zod.ts 17, overlay.zod.ts 2, complex.zod.ts 1, crud.zod.ts 1. Of those, complex.zod.ts DashboardConfigSchema.aria ALREADY carries { error } (objectui#5852) and nine are converted here, leaving 11 still emitting zod's generic message: 5 on StaticTableColumnSchema itself (headerIcon, fitContent, format, options, currency — objectui#6424/#6425), hoverable + striped on TableSchema, timeScale on the gantt shape, the 2 command-item tombstones in overlay.zod.ts, and crud.zod.ts confirm (objectui#4314 — the convention's origin). All 11 are one-line conversions now that retirementTombstone() exists; say the word and I will queue a card." ] }Generated by Claude Code
Generated by Claude Code