Skip to content

fix(spec): a joined report draws no chart — retire blocks[].chart and refuse a joined container chart (#20161) - #20238

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-20161-joined-report-chart-retired
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-20161-joined-report-chart-retired

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20161

Clause-②: no (narrowing)

A joined report draws each of its blocks as a table. Nothing ever drew a chart on one: not the container chart, and not a blocks[].chart. Both parsed green, passed the validate-chart-bindings lint, and plotted nothing. This PR retires the chart on a joined report, following triage's direction (retire, premise first; ADR-0049 enforce-or-remove):

Premise first: the census came back clean, so nothing stopped the retirement

Triage's stop condition was: "a joined report with a chart authored in examples/**, the showcase, or hotcrm". The instrument is structural. A tsx script imports each report module and walks the exported report objects, not their text, so a chart spread in from a variable would still be seen. It counts container and block charts on joined reports. As a positive control, it also counts chart on non-joined reports.

corpus (repo @ commit) reports joined joined container chart joined block chart control: non-joined with chart
objectstack examples/app-showcase + examples/app-todo @ a46e3cf53 9 1 0 0 1 (showcase_hours_by_status_chart)
hotcrm src/**/reports/*.report.ts @ 2f7b232 10 1 0 0 5

The other example apps (app-crm, app-multi-package, embed-objectql) author no reports. type: 'joined' has 1 hit in examples/ and 1 in hotcrm, and both are the reports counted above. Both joined reports still parse under the new schema.

Measured before changing anything

  1. Declarations at today's main (1c8b320a8, after PR fix(spec): a joined report refuses a top-level dataset / rows / columns / values, pointing each onto blocks[] #20160's 2bbebf532): JoinedReportBlockSchema.chart is at report.zod.ts:223-224, and ReportSchema.chart is at :400. The joined arm refused dataset / rows / columns / values / order, and not chart. The filer's :381 was pre-fix(spec): a joined report refuses a top-level dataset / rows / columns / values, pointing each onto blocks[] #20160 numbering.
  2. The renderer, at the .objectui-sha pin f8a9d0fb0596f4521076628e2bbfe27e6ce67d52 (a read-only shallow clone). DatasetReportRenderer.tsx:1462-1524 is the joined branch. It draws each block as a DatasetMatrixTable / DatasetReportTable and returns. The only report.chart read is at :1557-1560, after that return. git grep -F "block.chart" over objectui packages/ exits 1, while the control block.runtimeFilter hits (DatasetReportRenderer.tsx:1473). So the filer's reading holds.
  3. The lint. At base, validate-chart-bindings resolved a block chart's axes against the block's dataset. The test "checks a joined report block chart against the block dataset" asserted one finding at reports[0].blocks[0].chart.xAxis. It now checks chart only on a non-joined report. A block's own selection (dataset / rows / columns / values) is still resolved. The save door also runs this lint as an author-time check. When the door test below saved a joined report whose block dataset this stub could not resolve, the door refused it with chart-dataset-unknown. So what this lint says, or leaves unsaid, reaches the publish path.
  4. ADR-0087 route: D2 conversion plus an exact retired-key entry, and the family's own D3 entry (ruling B on [Decision] 一次退役,要写一条记录还是两条?—— 迁移条目的 D2/D3 约定,两处成文相互矛盾 #17152, added in patch round 2). A report is a stack collection that is stored as sys_metadata rows. The Studio report form offered a block chart input (reportForm's blocks repeater) until this change, so a stored row can carry one. applyConversionsToStoredItem replays retired entries, so a D2 strip heals such a row at rehydration instead of refusing it on its next save. chart-config-aria-removed made the same choice for the same carrier (report.blocks[].chart.aria). The delete is lossless, because neither value was ever drawn. The entry is retiredFromLoadPath: true, so a live author is refused at parse, never rewritten. The strict-route removal is proven by build-schemas check (c) proof 4, and the build log says: "ui/JoinedReportBlock:chart — def reachable from the metadata-type roots; writing 'chart' on it is REFUSED as an unrecognized key and the refusal carries the prescription its strictObject declaration owes it".

What changed

  • packages/spec/src/ui/report.zod.ts: removes the block chart and adds its guidance prescription. Adds the container refusal at path: ['chart']. Fixes the TSDoc rider: ReportSchema.blocks now describes the dataset-bound block (dataset / rows / columns / values / runtimeFilter / order, with the container runtimeFilter ANDed), not the pre-cutover object/filter shape, and the block docblock's filter is corrected the same way. ReportSchema.chart's .describe() now says it is refused on a joined report.
  • packages/spec/src/ui/report.form.ts: drops the block chart repeater column. The container chart field gets visibleWhen: "data.type != 'joined'".
  • ADR-0087: migrations/entries/retired-keys/18.ui__JoinedReportBlock__chart.ts, and the D2 conversion report-joined-chart-removed in conversions/registry.ts, wired into MIGRATIONS_BY_MAJOR[18].conversionIds with the step rationale extended. registry.ts was regenerated with gen:migration-registry. spec-changes.json and the upgrade guide are byte-identical, because major-18 entries do not project yet.
  • ADR-0087 D3 (patch round 2, owed under ruling B on [Decision] 一次退役,要写一条记录还是两条?—— 迁移条目的 D2/D3 约定,两处成文相互矛盾 #17152): migrations/entries/semantic/18.ui-report-joined-chart-retired.ts, id ui-report-joined-chart-retired. It names the D2 id report-joined-chart-removed as a whole id, says what the D2 already repairs, and says what the author still owes. It is a new family entry, not an extension of ui-report-joined-container-selection-refused: that entry is the enforce arm for four keys that stay declared, with no D2. The changeset marker now reads registered report-joined-chart-removed, ui-report-joined-chart-retired.
  • Fixture disjointness, forced: chart-config-aria-removed's fixture carried a blocks[].chart. The new entry strips a block's whole chart, so that fixture could no longer equal its own after under full-table replay. Its fixture drops the block leg (expectedNotices 3 → 2). Its apply is left as shipped. On a stored block chart carrying aria, it runs first and this entry then strips the whole chart, with the same compound result.
  • Generated: authorable-surface/ui.json drops ui/JoinedReportBlock:chart (proof 4 above). content/docs/references/ui/report.mdx is regenerated (check:generated --fix, only the one artifact it proved stale). The platform-objects metadata-form bundles drop blocks.chart in all four locales (pnpm i18n:extract, pure deletion).
  • packages/spec/liveness/report.json: the blocks row now names only what the joined branch reads, and states that there is no block chart. The chart row now says it is live on non-joined reports only. Both carry verifiedAt: 2026-09-27. The README row notes it.
  • packages/lint/src/validate-chart-bindings.ts: a drawsChart flag, false for a joined container and for every block.
  • content/docs/ui/reports.mdx: the joined section now lists what a block rejects (nested blocks, drilldown, protection), what the container refuses (dataset, and a non-empty rows / columns / values / order, plus chart), and what it reads (runtimeFilter, drilldown). The chart section says a joined report refuses chart.
  • .changeset/20161-joined-report-chart-retired.md: @objectstack/spec minor, @objectstack/lint patch and @objectstack/platform-objects patch. It carries the BREAKING banner, FROM → TO, the fix, the Clause-② line and the disposition registered report-joined-chart-removed.

Outside the claimed file surface, and why:

  • report.form.ts and the four generated bundles are the playbook's forms and i18n rows.
  • conversions/registry.ts is where a D2 entry lives.
  • Three pin files had to flip in the same round, each to the new count, with the reason written beside it:
    • lint/src/validate-predicate-path-refs.test.ts: the shipped-predicate census went 72 → 73, and the ==/!= literal comparisons went 52 → 53, from the new report :: chart gate. The corpus lost the block column, which carried no predicate.
    • platform-objects/.../report-form-echo-decisions.test.ts: the population went 45 → 44 leaves, 37 → 36 field leaves, and 39 → 38 negatives.
    • platform-objects/.../object-lifecycle-panel-echo-decisions.test.ts: the translated-label control went 609 → 608.
  • metadata-protocol/.../protocol.invalid-metadata-422-face-inventory.test.ts carries the door pin. It uses that file's already-pinned engine double, so the ledger is not touched.

Tests

Patch round 2, at the head 4aecf01d2 (dev report 5858814205): spec --project local 547 files / 16093 passed (2 todo); spec --project repo 33 / 604; lint 109 / 4234; platform-objects 55 / 911; metadata-protocol 189 (3 skipped) / 2706 (19 skipped).

The round-1 readings below are at 94a2c634d, unless noted.

  • @objectstack/spec, full --project local: 542 files / 15949 passed (2 todo). report.test.ts has a new describe with 9 tests: block refused as unrecognized_keys with the prescription; located at ['blocks', 1] under the report; container refused as custom at ['chart'] with no blocks[] pointer; one issue per key next to dataset / order; defineReport throws both; getMetadataTypeSchema('report') refuses both; a block under a non-joined container is refused too; chart on tabular / summary / matrix round-trips; and a chart-less joined report parses unchanged. The typecheck exits 0.
  • @objectstack/lint: 109 files / 4234 passed. The typecheck exits 0.
  • @objectstack/platform-objects: 55 files / 911 passed. The typecheck exits 0.
  • @objectstack/metadata-protocol: 189 passed, 3 skipped files / 2703 passed (19 skipped). The typecheck exits 0, and tsconfig includes src/**/*, so the test file is compiled. The new door pins: saveMetaItem({ type: 'report' }) answers INVALID_METADATA / 422 for a container chart (custom at chart) and for a block chart (unrecognized_keys at blocks.0), and stores nothing. A CONTROL without chart is stored.
  • Other importers of the report schema, as targeted runs (pre-merge head):
    • downstream-contract contract.test.ts passed. consumer-specifier-ledger.test.ts is NOT MEASURED: it refused because the @objectstack/cli dist was absent at that moment, which is a prerequisite and not a finding.
    • objectql: metadata-validation-sweep and overlay-precedence, 27 passed.
    • metadata: typescript-serializer-annotation, 7 passed.
    • rest: two meta read/diff org-scope files, 62 passed.

Ablations (one-off, scripts/ablation-replace.mjs, anchor-verified, and restored to the HEAD blob with an empty git diff HEAD; the subjects import src/ relatively, so no dist is involved):

mutation predicted observed
container refusal disabled: if (r.chart !== undefined) → if ((false as boolean) && …), blob f96fd4d6 → 8c02bbc8 the 4 container tests red report.test.ts 4 failed / 68 passed: exactly the container, all-keys, defineReport and save-door tests
block guidance entry deleted, blob f96fd4d6 → 632c8c5f the 3 prescription-asserting block tests red 3 failed / 69 passed
lint if (!drawsChart) return; deleted, blob 9ececb06 → 216e5e66 both new lint pins red first run: 1 failed. The joined-container pin stayed green because a joined container binds no dataset and returns before the chart question, so that pin could not fail. It was rewritten to bind a resolvable container dataset (commit 94a2c634d). Re-run: 2 failed / 45 passed.

The door pins in metadata-protocol resolve @objectstack/spec through its built dist, so ablating them needs two spec rebuilds. That ablation is NOT MEASURED. It sits on the same schema instance, getMetadataTypeSchema('report'), that ablation 1 turned red at the spec level.

Gates

Patch round 2, at the head 4aecf01d2: 115 derived, 115 run, every one exit 0 (--ran: 0 NOT-MEASURED, 0 UNRUN). origin/main fdb26698f was merged through scripts/pm/os-regen-merge.sh as 5977052dd, with two both-sides-append hunks resolved by hand in hand-authored regions: the CONVERSIONS_BY_MAJOR[18] tail, and the step-18 rationale plus the conversionIds tail. No generated region was hand-merged. check:generated reports 15/15 current at 4aecf01d2.

Round 1: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 94a2c634d derived 115 families. All 115 were run, each exit code was recorded, and every one is 0. The --ran reconciliation read "115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN". Three of the 115 needed a second run on the earlier pass, and the final pass ran them clean:

  • check:skill-examples and check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: unbuilt packages in this worktree). They were re-run after building those packages.
  • check:type-check-debt was run on its own, because of its roughly 5-minute runtime.

pnpm --filter @objectstack/spec check:generated reports all 15 artifacts up to date after the origin/main merge (a46e3cf53, through scripts/pm/os-regen-merge.sh). Sibling registry entries survived: automation-flow-list-route-retired, filter-ne-array-comparand-refused and api/FlowSummary are present at both HEAD and origin/main.

Acceptance notes


Generated by Claude Code

…he block chart row

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 27, 2026
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/lint, @objectstack/platform-objects, @objectstack/spec, touching 22 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/spec/authorable-surface/ui.json, packages/spec/liveness/README.md, packages/spec/liveness/report.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-handling-server.mdx (via task_count (literal, a string literal in fixture))
  • content/docs/data-modeling/analytics.mdx (via ReportSchema (symbol, a top-level const), est_hours (literal, a string literal in fixture))
  • content/docs/data-modeling/field-types.mdx (via task_count (literal, a string literal in fixture))
  • content/docs/deployment/cli.mdx (via task_count (literal, a string literal in fixture))
  • content/docs/deployment/validating-metadata.mdx (via est_hours (literal, a string literal in fixture))
  • content/docs/kernel/contracts/data-engine.mdx (via task_count (literal, a string literal in fixture))
  • content/docs/ui/dashboards.mdx (via task_count (literal, a string literal in fixture))
  • content/docs/ui/reports.mdx (via done_block (literal, a string literal in fixture), est_hours (literal, a string literal in fixture), open_block (literal, a string literal in fixture), task_count (literal, a string literal in fixture))
What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/authorable-surface/ui.json, packages/spec/liveness/README.md, packages/spec/liveness/report.json, …) — pages documenting those are invisible to this run
  • 10 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e46218674b530c67b1d744915c04a821ae33ed37 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 36f02eee19cbdf940470ac5990d8c3c597bb3605 — the merge of head 4aecf01d2f147e51001ff708889ff99112466473 into base e46218674b530c67b1d744915c04a821ae33ed37, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 36f02eee19cbdf940470ac5990d8c3c597bb3605 && git checkout 36f02eee19cbdf940470ac5990d8c3c597bb3605
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e46218674b530c67b1d744915c04a821ae33ed37 4aecf01d2f147e51001ff708889ff99112466473 && git checkout -B drift-repro e46218674b530c67b1d744915c04a821ae33ed37 && git merge --no-ff 4aecf01d2f147e51001ff708889ff99112466473

node scripts/docs-audit/affected-docs.mjs --json e46218674b530c67b1d744915c04a821ae33ed37

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e46218674b530c67b1d744915c04a821ae33ed37 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 94a2c634d613fd07d6fe5daf205c51db34abf9e7

Read: card #20161 (body; 5852548444, 5854612884, 5856190555); PR #20238 (object, body incl. Acceptance notes, 22-file list, full diff 3875ae6..94a2c63, 10 commits, 35 check-runs on the head); at the head: report.zod.ts, report.form.ts, report.test.ts, conversions/registry.ts (+walk.ts, apply.ts, stored.ts), migrations/registry.ts + types.ts, entries/retired-keys/18.ui__JoinedReportBlock__chart.ts, entries/semantic/18.ui-report-joined-container-selection-refused.ts, liveness/report.json + README row, authorable-surface/ui.json, validate-chart-bindings.ts + test, validate-predicate-path-refs.test.ts, the two platform-objects count pins + four bundles, the metadata-protocol door pin, reports.mdx, references/ui/report.mdx, the changeset, the headers of check-changeset-no-major.mjs / check-adr-0087-registration.mjs and pr-automation.yml WHICH LEVEL; #17152 ruling B (5615360777, 5634031140) and PR #20181; the spec-property-retirement skill. Ran, in a detached worktree at 94a2c63 (removed afterwards): pnpm install; spec build and the lint/metadata-protocol closure build (lock); a tsx probe importing src at the head AND at merge-base 3875ae6 (28-body corpus through ReportSchema / getMetadataTypeSchema('report') / defineReport; the D2 entry applied twice; the aria entry alone and the full stored chain; a structural census over the imported example and hotcrm report objects); vitest through the lock: spec 4 files / 440 passed, lint 2 / 101, metadata-protocol 1 / 14; a driverless bare-clone merge-tree of the head onto origin/main 3cb84d0. NOT MEASURED: anything inside objectui at pin f8a9d0fb (no repository access from this session, so the never-drawn premise and the ledger's line citations are taken as the card's premise); the Studio form visibleWhen in a browser; the 115 derived gate families (CI conclusions used, none re-run).

① Derived judgments

(a) Premise: HOLDS. Structural census (tsx walk over the exported report objects, parsed with the head's ReportSchema): objectstack examples/** @ 94a2c63 (examples untouched by the PR; diff vs merge-base empty) = 9 reports (showcase 4, app-todo 5), joined 1 (showcase_task_overview), joined container chart 0, joined block chart 0, any block chart 0, positive control non-joined-with-chart 1 (showcase_hours_by_status_chart). hotcrm origin/main @ 2f7b2326e (read-only, all five *.report.ts) = 10 reports, joined 1 (customer_churn_signals), 0 / 0 / 0, control 5 (opportunities_by_stage, won_opportunities_by_owner, opportunity_funnel_owner_stage, cases_by_status_priority, sla_performance). Text census agrees (type: 'joined' 1 hit in examples, 1 in hotcrm src; 9 + 10 report definitions). Both joined reports parse at the head. Zero authored instances against a lit control, so triage's stop condition never fired.

(b) Refusal correctness: HOLDS.

  • Block chart: JoinedReportBlockSchema.safeParse base ok=true, head unrecognized_keys at the block; message opens "report.blocks[].chart was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove)", carries "Delete the key." and the os migrate meta --from 17 sentence (the five-convention form; 17.5.0 is the version nine sibling tombstones at this head use, package at 17.4.0; no tracker number in either runtime string).
  • Container chart on type: 'joined': base ok=true, head custom@chart, message "a joined report draws no chart — …" with no blocks[] pointer; joins the selection refusals one issue per key (custom@dataset, custom@order, custom@chart).
  • Non-joined controls: tabular, summary, matrix with columns, matrix without columns, default type, each with and without chart — accepted at both trees, chart round-trips.
  • Doors: getMetadataTypeSchema('report') answers identically to ReportSchema on all 28 bodies (ok and issue set); defineReport throws on every refused body and returns on every accepted one; saveMetaItem({ type: 'report' }) is pinned at the head to code: 'INVALID_METADATA', status: 422, issues [custom, 'chart'] / [unrecognized_keys, 'blocks.0'], nothing stored, with a stored CONTROL — green here (14/14) and in CI Test Core.
  • Accept/refuse diff over the 28-body corpus, base 3875ae6 vs head 94a2c63: ACCEPT to REFUSE exactly 4 — joined_container_chart, joined_block_chart, joined_both_charts, summary_with_block_chart; refused-at-both with a changed issue set 3 — joined_dataset_order_chart (+custom@chart), joined_no_blocks_chart (+custom@chart), joined_block_chart_aria (base: axis and aria type errors, head: unrecognized_keys@blocks.0); unchanged 21, including every non-joined control, joined_plain, joined_empty_rows, chart: null, chartConfig on either level, and both authored reports. REFUSE to ACCEPT: none. The one non-joined body that moved (type: 'summary' carrying blocks: [{ chart }]) moves only through blocks[], the retired def ui/JoinedReportBlock:chart, which is one closed shape whatever the container's type (renderers ignore blocks off joined); its own top-level chart is untouched. Judged entailed by the ruled direction (remove the block key) and pinned by the head's own test; not a movement of any non-joined report's live chart. Recorded so the caller can re-grade.

(c) ADR-0087 route.

  • D2 report-joined-chart-removed (toMajor 18, retiredFromLoadPath, last in the step-18 list): on a 7-report stack it emitted exactly reports[0].chart, reports[0].blocks[0].chart (the joined report) and reports[5].blocks[0].chart (a summary carrying a block chart); the summary / matrix / tabular / default-type container charts, the chart-less joined report, dashboards and views came back by reference identity; a stack with no reports returns the same reference; the input is not mutated. Second apply: deep-equal AND the same reference, 0 notices — idempotent by construction (stripKeys). Fixture round-trip equals after with 2 notices. Lossless in rendered behaviour on the card's premise (objectui NOT MEASURED here).
  • Wiring: MIGRATIONS_BY_MAJOR[18].conversionIds ends with 'report-joined-chart-removed' and the step rationale is extended; RETIRED_KEYS_BY_MAJOR[18] carries 'ui/JoinedReportBlock:chart' via the entry file; authorable-surface/ui.json drops the line (strict route). migrations.test.ts chain replay green at the head.
  • chart-config-aria-removed: its apply is unchanged; applied ALONE to a stored joined row carrying chart.aria and blocks[].chart.aria, the result and the 2 notices (reports[0].chart.aria, reports[0].blocks[0].chart.aria) are IDENTICAL at base and head; alone on a summary row identical too. Full chain (applyConversionsToStoredItem('report', row)): the summary row is identical base vs head; the joined row differs only by the new entry's two strips running after the aria strips (head notices = base's 2 + report-joined-chart-removed at reports[0].chart and reports[0].blocks[0].chart), which is the dev's compound claim. The fixture edit (3 to 2 notices) is the disjointness contract: the old after kept a block chart the new entry now strips.
  • D2 rather than D3-only: justified — reportForm's blocks repeater offered { field: 'chart' } until this diff and reports are a stored sys_metadata collection, so a stored row can carry one; the same choice chart-config-aria-removed made on the same carrier.
  • Ruling B ([Decision] 一次退役,要写一条记录还是两条?—— 迁移条目的 D2/D3 约定,两处成文相互矛盾 #17152, 5615360777 reaffirmed 5634031140; its execution PR fix(spec): D3 gets one semantic entry per retirement family, even when D2 is lossless (#17152) #20181 merged 2026-09-27T09:31Z and IS an ancestor of this head): one D3 semantic entry per retirement family, even when a lossless D2 exists. At this head migrations/registry.ts lines 9-10 and migrations/types.ts lines 8-9, 26-28, 34-36, 70-71 state it verbatim. This family therefore owes a D3 entry. The PR carries none (no entries/semantic/18.* among the 22 files; the sibling ui-report-joined-container-selection-refused from fix(spec): a joined report refuses a top-level dataset / rows / columns / values, pointing each onto blocks[] #20160 names the four selection keys only, not chart) and the body never says why not — it argues D2 over D3-only and is silent on D2-plus-D3. Precedent is mixed (post-ruling chart-config-aria-removed and object-tenancy-organization-field-removed landed D2-only; translation-per-app-settings-removed and page-component-filter-record-to-rule-array carry paired D3s); no gate checks it, so this review is the check. The skill's §3 text predates the ruling (its rewrite is folded into governed ADR-0087 still defines each step's D3 semantic list as "the residue D2 cannot express losslessly", contradicting ruling B on #17152 (one D3 entry per retirement family, even when a lossless D2 conversion exists) #20188).

(d) Scope: every addition is forced or pin-forced except two judgments, neither a runtime expansion.

  • report.form.ts: the block chart column removal is the playbook's Forms row (forced). The container chart gate visibleWhen: "data.type != 'joined'" is not pin-forced; it is forced by the refusal (a control the door now answers 422) and uses the mechanism the Dataset binding and Joined blocks sections already use.
  • four *.metadata-forms.generated.ts: pnpm i18n:extract, a pure deletion of blocks.chart in 4 locales (playbook i18n row; check:i18n) — forced.
  • conversions/registry.ts: where a D2 entry lives; the aria fixture edit is forced by fixture disjointness (measured above) — forced.
  • three count pins: validate-predicate-path-refs.test.ts 72 to 73 and 52 to 53 follow the new != predicate; report-form-echo-decisions 45/37/39 to 44/36/38 and object-lifecycle-panel-echo-decisions 609 to 608 follow the removed row — pin-forced.
  • protocol.invalid-metadata-422-face-inventory.test.ts: +59 test-only lines; not gate-forced, justified by the card's reach: public door; rides the file's pinned engine double, no ledger edit.
  • Nothing else in the diff is outside the retirement: authorable-surface/ui.json (gen:schema), references/ui/report.mdx (gen:docs), the liveness/README.md row, the changeset.

(e) Riders: HOLD.

  • validate-chart-bindings: drawsChart is false for a joined container and for every block; the head pins that a block chart is silent while the same block's rows[0] still resolves, that a joined container chart with a RESOLVABLE dataset is silent (so the pin can fail), and that the same axes on a summary report still gate CHART_DIMENSION_UNKNOWN / CHART_MEASURE_UNKNOWN (101/101 green here).
  • liveness/report.json: the blocks row no longer names chart and states a block has none; the chart row reads live on NON-joined reports only; both carry verifiedAt: 2026-09-27; the cited objectui lines are NOT MEASURED here. Spec property liveness green.
  • ReportSchema.blocks TSDoc now describes dataset / rows / columns / values / runtimeFilter / order with the container runtimeFilter ANDed; the block docblock's filter sentence is corrected the same way.
  • content/docs/ui/reports.mdx lists what the joined container refuses (dataset, non-empty rows / columns / values / order, chart), the three keys a block rejects, quotes the refusal's first sentence verbatim to the runtime string, and its os:check example carries no chart.

② Semver level

Narrowing (Clause-②: no (narrowing) in body and changeset) is BREAKING; during the launch window check-changeset-no-major refuses major and its level axis stands down on a no declaration, so @objectstack/spec: minor is the correct level, with @objectstack/lint: patch (behaviour fix, no export change) and @objectstack/platform-objects: patch (generated deletion). The changeset carries the BREAKING banner, FROM to TO with the one-line fix and the migrate command, the Clause-② line, and exactly one disposition marker adr-0087: registered report-joined-chart-removed, an id that exists at the head and is new in the diff (the base has no such entry), which is what the gate's R3/R4 checks require. Check Changeset is success on the head.

③ Boundary flags

Blocking: the ruling-B D3 semantic entry for this retirement family is absent and unexplained (①(c)); every FAIL condition the review brief names otherwise passes, and this is the sole item the verdict turns on; remedy is one entries/semantic/18.<id>.ts (or extending ui-report-joined-container-selection-refused to name chart and the block-key removal) plus gen:migration-registry and check:generated, or a stated reason in the body.
Non-blocking: (1) type: 'matrix' with columns and a chart parses at base and head alike (measured; that accept set is unchanged) — same family, a fresh finding for routing, not this card's direction; (2) objectui's local JoinedReportBlock type still declares chart — a TS type only, and JoinedReportBlockSchema is annotated z.ZodTypeAny at the head, so no spec-derived type narrows and the pinned sibling cannot fail to compile on this removal (Console Pin Gate skipped by path filter; objectui itself NOT MEASURED); (3) ChartConfig.aria's prescription still lists report.blocks[].chart.aria — historically true, regenerated verbatim into the chart / dashboard / report reference pages; an author now meets the block refusal first. Merge-conflict risk: GitHub reports mergeable: false, dirty; the driverless merge-tree of the head onto origin/main 3cb84d0 conflicts in packages/spec/src/conversions/registry.ts (the CONVERSIONS_BY_MAJOR[18] tail: viewItemOwnerHiddenRemoved from #20227 vs reportJoinedChartRemoved) and packages/spec/src/migrations/registry.ts (the step-18 rationale tail and the conversionIds tail, same pair) — both-sides-append, ordinary concurrency, needs an os-regen-merge.sh re-merge and check:generated before queueing. Other open PRs: none of the 10 other open non-release PRs touches conversions/registry.ts; #20223 touches migrations/registry.ts and adds entries/semantic/18.inline-grid-column-currency-scale-refused.ts (the later lander regenerates); none touches report.zod.ts, liveness/report.json, validate-chart-bindings.ts, report.form.ts or reports.mdx.

CI at this head: 35 check-runs, 33 success, 2 skipped (Console Pin Gate path-filtered, Packed-tarball smoke (opt-in)); all seven required contexts success — Lint & Repo Gates, TypeScript Type Check, Test Core (and its 6 shards), Dogfood Regression Gate (and its 3 shards), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Closing keywords: Fixes #20161 only (#19856 and #20160 are cited without a keyword). No governed surface among the 22 files. The PR is a draft.

Implemented-by: claude/issue-20161-joined-report-chart-retired
Reviewed-by: session_01CiCTczDo7tGhafXjf61dUJ

VERDICT: FAIL

…ined-report-chart-retired

# Conflicts:
#	packages/spec/src/conversions/registry.ts
#	packages/spec/src/migrations/registry.ts
…g B)

ui-report-joined-chart-retired: the judgement half of
report-joined-chart-removed. The D2 strip is lossless; what it cannot
decide is whether the author meant a chart, which must then move to a
non-joined report of its own. The changeset's ADR-0087 marker names
both registrations.

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
The migrate-sentence class pin (repo project) judges semantic entries too:
the sentence must be the house form and close the string.

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4aecf01d2f147e51001ff708889ff99112466473

Delta from 94a2c63 (FAIL 5856458831, sole blocker: no ruling-B D3 entry): three commits — merge 5977052 (parents 94a2c63 and origin/main fdb2669), fdf804d (D3 entry entries/semantic/18.ui-report-joined-chart-retired.ts, its migrations/registry.ts mirror, the changeset's ADR-0087 bullet and marker), 4aecf01 (the entry's migrate sentence brought to the house form, in the entry and the mirror). Read: PR object, body, 23-file list, 13 commits, 35 check-runs on the head; #17152 ruling B 5615360777 and the #15954 ruling 5559778263 it cites; dev report 5858814205; #20255's head a930cac (its 32-file diff and the census pin in migrations.test.ts); at the head: the D3 entry, migrations/registry.ts (step-18 rationale, conversionIds, semantic mirror, generated markers), migrations/types.ts, conversions/registry.ts (step-18 tail and the D2 entry), conversions/stored.ts, report.zod.ts, report.form.ts diff, validate-chart-bindings.ts, the metadata-protocol 422 pin, .objectui-sha, the changeset, the sibling 18.ui-report-joined-container-selection-refused.ts, all 188 semantic/18.* files, retired-key-migrate-sentence.test.ts, conversions.test.ts, vitest.repo-tests.json, the header and registered branch of check-adr-0087-registration.mjs, .gitattributes merge drivers, lint.yml and pr-automation.yml gate steps. Ran: a driverless bare clone in the scratchpad for git merge-tree --write-tree of the merge's parents and of the head against origin/main ae8e3ca, #20255, #20244, #20286; a tree diff of the merge commit against its auto merge-tree; a normalised interdiff (old base 3875ae6 vs new base fdb2669); #20255's census matcher replayed over this head's and the head+#20255 union's semantic/18.*; a detached worktree at 4aecf01 (pnpm install, removed afterwards) with one locked run: a tsx probe (whole-table and single-entry replay of the three tail conversions' fixtures, idempotence, a compound joined row) then vitest conversions.test.ts + migrations.test.ts (local, 2 files / 350 passed) and retired-key-migrate-sentence.test.ts (repo, 1 file / 14 passed) — readings taken from the log's summary lines, because the lock's verdict was batch-last-exit 0 over a | tail pipeline. NOT MEASURED: objectui at pin f8a9d0fb (the entry's line citations 1462-1524 / 1557 taken as the card's premise); check:generated / check:migration-registry / the ADR-0087 registration gate (CI conclusions used, none re-run); the os validate / os build doors the entry names; the full spec suite; Studio in a browser.

① Derived judgments

(a) D3 entry ui-report-joined-chart-retired: TRUE under ruling B, every clause checked at the head.

  • Names the family: surface names both coordinates — report.blocks[].chart (REMOVED) and report.chart on type: 'joined' (REFUSED). Code: JoinedReportBlockSchema carries no chart and its guidance table answers chart with JOINED_BLOCK_CHART_RETIRED (report.zod.ts:257); the joined arm adds { code: 'custom', path: ['chart'] } (:528-529); ReportSchema.chart stays declared (:454). The 422 pin holds both (protocol.invalid-metadata-422-face-inventory.test.ts:398-416: custom@chart, unrecognized_keys@blocks.0, INVALID_METADATA / 422, nothing stored).
  • States what the D2 already repairs: "report-joined-chart-removed already REPAIRS THE DATA: strips both from authored sources on a chain replay and from stored sys_metadata rows at rehydration, a lossless delete". Code: the D2 is toMajor: 18, retiredFromLoadPath: true, strips chart on r.type === 'joined' and on every block (conversions/registry.ts:10940-); MIGRATIONS_BY_MAJOR[18].conversionIds ends ..., 'view-item-owner-hidden-removed', 'report-joined-chart-removed' (chain replay); stored.ts:17 "the full chain replays, including retiredFromLoadPath entries". Probe: alone on its fixture it emits exactly reports[0].chart, reports[0].blocks[0].chart.
  • States the owed judgment: whether the chart was wanted; if so, a non-joined report of its own binding the block's dataset, chart at its top level where xAxis / yAxis resolve. Code: chart round-trips on non-joined reports (pinned in report.test.ts); validate-chart-bindings resolves axes only where drawsChart is true (:551,570), i.e. on a non-joined container. "The Studio report form offered a block chart input until this change": report.form.ts diff removes { field: 'chart', label: 'Chart' } from the blocks repeater. .objectui-sha at the head is f8a9d0fb0596f4521076628e2bbfe27e6ce67d52, the pin the entry cites (its line numbers NOT MEASURED). Census "one and five" equals the prior review's measured lit controls. Ends on the exact house sentence "Run os migrate meta --from 17 to list the mechanical edits for existing sources; apply them by hand." (4aecf01 fixed fdf804d's "list the mechanical strips" spelling, which retired-key-migrate-sentence.test.ts — a repo-project test — rejected in CI at fdf804d; 14/14 green here).
  • Whole id: report-joined-chart-removed occurs as a whole id at entry lines 5 (comment) and 37 (reason). fix(spec): one D3 entry per major-18 retirement family — the census and the 25 missing entries (#20201) #20255's pin lifted verbatim ((?<![a-z0-9-])id(?![a-z0-9-]) over the full text of every semantic/18.*.ts, for every id in MIGRATIONS_BY_MAJOR[18].conversionIds): at this head report-joined-chart-removed pairs with 18.ui-report-joined-chart-retired.ts; the two controls hit their files. 24 other step-18 ids are unnamed at this head (chart-config-aria-removed, view-item-owner-hidden-removed, …) — all pre-existing, the backlog fix(spec): one D3 entry per major-18 retirement family — the census and the 25 missing entries (#20201) #20255 back-fills; over the head+fix(spec): one D3 entry per major-18 retirement family — the census and the 25 missing entries (#20201) #20255 union tree the pin's unnamed set is EMPTY and the semantic-18 mirror is sorted with 216 ids equal to the 216 files.
  • Own family, not an extension: judged right. The sibling ui-report-joined-container-selection-refused is the enforce arm for dataset / rows / columns / values, which stay declared, with no D2 and stored rows "not rewritten"; this family removes a declared def key, refuses a container key and is paired with a D2. Ruling B counts families by retirement, and fix(spec): one D3 entry per major-18 retirement family — the census and the 25 missing entries (#20201) #20255's pin pairs each graduated D2 with a D3 of its step — so a family with its own D2 owes its own naming entry, and extending fix(spec): a joined report refuses a top-level dataset / rows / columns / values, pointing each onto blocks[] #20160's landed entry would fuse a no-D2 enforce arm with a D2-paired removal and edit another PR's entry.
  • Five SemanticMigration fields (id, surface, replacement, reason, acceptanceCriteria) present and non-empty (probe); the migrations/registry.ts mirror is byte-identical modulo indentation (51/51 body lines), sits before ui-report-joined-container-selection-refused in id order, inside <os-generated semantic:18>; check:migration-registry runs in Lint & Repo Gates, green at the head.

(b) Merge 5977052dd: HOLDS.

  • Auto merge-tree of its parents (bare clone, no merge.os-regen driver) conflicts in exactly conversions/registry.ts and migrations/registry.ts; the merge commit's tree differs from that auto tree in exactly those two files, and inside them only at the conflict hunks (unified diff of auto-tree vs commit shows nothing else).
  • CONVERSIONS_BY_MAJOR[18] tail reads viewItemOwnerHiddenRemoved, then reportJoinedChartRemoved,. Step-18 rationale: main's view-item paragraph ends "...this retirement does not touch. '" (re-punctuated from "touch.',") and this PR's #20161 paragraph follows; conversionIds tail 'view-item-owner-hidden-removed', 'report-joined-chart-removed'.
  • Nothing of main's lost: merge-vs-main touches exactly the PR's 22 files (set-equal to the old-base diff); merge-vs-PR-parent touches 150 files, every one in main's 170-file movement, the 20 absent being the PR-only files main never touched.
  • No generated region hand-merged: both hunks are in hand-authored regions (rationale / conversionIds precede the <os-generated semantic:18> marker; the conversions tail carries no marker); the merge=os-regen paths this PR touches (authorable-surface/ui.json, references/ui/report.mdx) auto-merged because main did not move them; fdf804d's mirror is gen:migration-registry output (verbatim check above). Lint & Repo Gates (check:migration-registry, check:spec-changes, check:upgrade-guide, check:authorable-surface, check:generated --reconcile-only) success at the head — not re-run.
  • Fixture disjointness under whole-table replay (collectConversionNotices(before, { includeRetired: true }), the fixture test's own call): chart-config-aria-removed → equals after, 2/2 notices, no foreign id; view-item-owner-hidden-removed → 3/3; report-joined-chart-removed → 2/2; each alone also equals after; replaying each after is a no-op with 0 notices. A compound joined row carrying chart.aria at both coordinates: aria strips at reports[0].chart.aria, reports[0].blocks[0].chart.aria, then chart strips at reports[0].chart, reports[0].blocks[0].chart; both charts gone. conversions.test.ts + migrations.test.ts: 350/350.

(c) Nothing else moved: HOLDS. git diff --stat 94a2c634d..head = 152 files, 148 of them main's. Normalised interdiff (index lines and hunk headers stripped) between the PR's patch against 3875ae6 (at 94a2c63) and against fdb2669 (at the head) differs in exactly four files: the new entry file; migrations/registry.ts — 64 added lines (the mirror plus the re-punctuated joint) and 18 context lines (the rationale joint moving from the page-component paragraph to main's view-item paragraph, the conversionIds neighbour, and the mirror hunk's context); conversions/registry.ts — 2 context lines (the neighbour row); the changeset — the marker line AND three prose lines in the ADR-0087 bullet naming the D3 entry. The prose lines are the changeset describing the entry, recorded here because the brief's list named only the marker.

② Semver level

Unchanged: @objectstack/spec: minor with the BREAKING banner, @objectstack/lint: patch, @objectstack/platform-objects: patch, Clause-②: no (narrowing) in body and changeset. Marker at the head: <!-- adr-0087: registered report-joined-chart-removed, ui-report-joined-chart-retired --> — exactly one adr-0087: marker (the parser refuses two); the registered branch splits ids on [,\s]+; extractIds reads id: '...' lines from LEDGER_SOURCES = migrations/registry.ts + conversions/registry.ts, so the semantic id resolves through its mirror and the conversion id through its entry (R3); both are absent at merge-base fdb2669 and present at the head (R4, "at least one NEW"). Check Changeset (pr-automation.yml, which runs this gate) success at the head; not re-run.

③ Boundary flags

Blocking: none.
Non-blocking: (1) Merge risk now: head onto current origin/main ae8e3ca (21 commits past merge-base fdb2669) — CLEAN; onto #20255 a930cac — CLEAN (and the union passes #20255's census pin with a sorted mirror); onto #20244 755acf3 — CLEAN; onto #20286 2a40c10 — CONFLICT in conversions/registry.ts (tail reportJoinedChartRemoved vs viewOverlayOwnerHiddenRemoved) and migrations/registry.ts (the step-18 rationale joint — #20286 also rewrites the end of main's view-item sentence — and the conversionIds tail), both-sides-append in hand-authored regions, no generated region; the later lander re-merges through os-regen-merge.sh and regenerates. (2) The prior record's three items are unchanged and still non-blocking: the matrix-with-columns chart still parses (not this card's direction, no code in that path moved); objectui's local JoinedReportBlock type still declares chart (TS only, JoinedReportBlockSchema still z.ZodTypeAny, Console Pin Gate still path-skipped); ChartConfig.aria's prescription still lists report.blocks[].chart.aria (historical). (3) The D3 entry's census names the in-repo example apps and hotcrm only; the sibling entry also names objectui fixtures and the cloud repo, and the #15954 ruling's connector text asked for a NOT-MEASURED out-of-repo statement — #20255's own ruling-B entries carry none either, so this is house-form variance, not a defect. (4) PR body stale — NOT blocking: the body is prose, no gate reads it, the closing keyword is right, and the disposition the gate reads lives in the changeset. What must change: (i) "All readings are at 94a2c634d" and the Tests counts → the head 4aecf01 (dev report: spec local 547 files / 16093, spec repo 33 / 604, lint 109 / 4234, platform-objects 55 / 911, metadata-protocol 189 / 2706); (ii) Gates: "at 94a2c634d derived 115 families" and "after the origin/main merge (a46e3cf53...)" → the 5977052 merge onto fdb2669 with its two hand-resolved hunks, and check:generated 15/15 at 4aecf01; (iii) "What changed" ADR-0087 bullet must list migrations/entries/semantic/18.ui-report-joined-chart-retired.ts (id ui-report-joined-chart-retired) as the ruling-B family entry, new rather than an extension of ui-report-joined-container-selection-refused; (iv) "Measured" item 4's "D2 conversion plus an exact retired-key entry, not D3 only" → D2 plus D3; (v) the changeset bullet's disposition now names both ids. The census table's a46e3cf53 and the ablation row's 94a2c634d are historical reading locations and may stand.

CI at this head: 35 check-runs, 33 success, 2 skipped (Console Pin Gate path-filtered, Packed-tarball smoke (opt-in)), 0 failure, none in progress; all seven required contexts success — Lint & Repo Gates, TypeScript Type Check, Test Core (+6 shards), Dogfood Regression Gate (+3 shards), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard; Check Changeset, Spec property liveness, the four Type Check gates, the three claim guards success. GitHub: mergeable: true, clean, draft. Closing keywords: Fixes #20161 only; #19856 and #20160 are cited without a keyword.

Implemented-by: claude/issue-20161-joined-report-chart-retired
Reviewed-by: session_01CiCTczDo7tGhafXjf61dUJ

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 19:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 6a6a17b Sep 27, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20161-joined-report-chart-retired branch September 27, 2026 19:49
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…(Parsed) name their shapes, not unknown (objectstack-ai#19920) (objectstack-ai#20260)

Part of objectstack-ai#19920

Clause-②: no

Three of the four sites objectstack-ai#19920 names now resolve to the shape their
TSDoc promises. `JoinedReportBlock` is the remainder: its region of
`report.zod.ts` is held by the open PR objectstack-ai#20238 (objectstack-ai#20161), and the dispatch
put this site after that PR merges. objectstack-ai#19920 remains open for it. The
measured options for it are under "The remainder" below.

## What changed

Types only. No schema, no parse, no export and no declared type of any
schema moves. Each alias was derived from a schema whose own static type
erases to `unknown`, so any value type-checked against it. Each is now
derived from the member schema the parse actually runs, the way PR
objectstack-ai#19919 re-derived `ViewMetadata`.

| name | FROM (all `unknown` at `e0f17a37`) | TO |
|:--|:--|:--|
| `InlineAction` (`ui/action.zod.ts`) | `z.input` of `typeof
InlineActionSchema`. The schema is a `z.preprocess`, whose input type is
the preprocess function's `unknown` parameter. | `z.input` of `(typeof
InlineActionSchema)['out']`: the pipe's `out` member, the `.pick()`ed
action object. |
| `ViewMetadataParsed` (`ui/view.zod.ts`) | `z.infer` of `typeof
ViewMetadataSchema`. The union's members are cast to `z.ZodTypeAny`
where it is built. | `z.infer` over `(typeof
VIEW_METADATA_MEMBERS)[ViewMetadataBranch]`: the members' OUTPUT union,
the same record `ViewMetadata` reads its input types from. |
| `AssembledViewArtifact` (`ui/assembled-views.zod.ts`) | `z.input` of
`typeof AssembledViewArtifactSchema`. Same cast. | `z.input` over the
`VIEW_METADATA_MEMBERS` entries minus `container`: the three members
that schema's union is mapped from. |
| `AssembledViewArtifactParsed` | `z.infer` of the same schema. Same
cast. | `z.infer` over the same three members. |

- `diagnoseViewMetadata`: the one edit the new type forces. Its success
branch returned `data: parsed.data`, and `parsed.data` is `unknown` for
the same member cast. Without an edit that line is TS2322 (measured
below). It now asserts `parsed.data` to `ViewMetadataParsed`, with a
comment saying why that holds. At runtime the union's output IS the
accepting member's output, since the union's `.check()` transforms
nothing. No value changes. A new test asserts `diagnosis.data`
deep-equals the member's own parse output for every member.
- Every changed TSDoc states what the type does NOT express. The schema
is still the only judge: the preprocess folds and strips, and
refinements are not types. For `InlineAction`, the legacy `type:
'navigation'` and `to` spellings are refused by the type while the door
still folds them. That is pinned in both directions.
- The `JoinedReportBlockSchema` `z.ZodTypeAny` annotation and the member
casts inside `ViewMetadataSchema` / `AssembledViewArtifactSchema` are
all untouched.
- Changeset `.changeset/19920-exported-types-not-unknown.md`: `minor` on
`@objectstack/spec`, `Clause-②: no (narrowing)` with the BREAKING banner
(a narrowing of published TYPES; the runtime accept set does not move)
and the ADR-0087 `not-required (no-migration-prescription)` disposition,
matching the objectstack-ai#19919 precedent; FROM and TO per name, plus the "if your
code stops compiling" instruction.
- `.changeset/view-metadata-type-not-unknown.md` is the unreleased
objectstack-ai#19919 entry. It said "`ViewMetadataParsed` is not changed by this
release: it is still `unknown`", which this branch makes false if both
entries ship in one release. It now reads "`ViewMetadataParsed` is not
changed by this change. It is re-derived from the same members, as their
output types, by its own entry (objectstack-ai#19920)." That holds whichever release
carries either entry. My own entry's `JoinedReportBlock` sentence is
worded the same way.

## Confirmation needed: a pending release note is corrected on purpose
(`Check Changeset` stays red)

`check-empty-changeset` refuses this PR because it changes
`.changeset/view-metadata-type-not-unknown.md`, a changeset it did not
add. This is the gate's DELIBERATE CORRECTION class, not a filename
collision:
- **Note:** the pending (unreleased) objectstack-ai#19919 entry for `ViewMetadata`.
- **What changed under it:** this PR re-derives `ViewMetadataParsed`, so
the note's sentence "`ViewMetadataParsed` is not changed by this
release: it is still `unknown`" would ship false in any release that
carries both entries.
- **The rewrite:** that one sentence now reads "`ViewMetadataParsed` is
not changed by this change. It is re-derived from the same members, as
their output types, by its own entry (objectstack-ai#19920)." That holds whichever
release carries either entry. Nothing else in the note moves.

Per the gate's own prescription, the file is ⛔ not restored from the
base, which would put the false sentence back. `Check Changeset` stays
red until the correction is confirmed here in writing. It is not a
required context. If a release consumes the objectstack-ai#19919 entry before this PR
lands, the correction becomes moot: the merge resolves by keeping
`main`'s deletion.

## Measurements

All readings are TypeScript compiler-API reads of the package's own
`tsconfig.json` / `tsconfig.test.json`, unless named otherwise.

1. **The premise holds.** At base `e0f17a37`: `ViewMetadataParsed`,
`InlineAction`, `AssembledViewArtifact`, `AssembledViewArtifactParsed`
and `JoinedReportBlock` all have type flag `Unknown`. The control
`ViewMetadata` (re-derived in PR objectstack-ai#19919) is NOT unknown, and
`InlineActionParsed` was never unknown. At head, the four changed names
are not unknown, and `JoinedReportBlock` still is.
2. **Declaration cost.** No cast is removed; each alias is emitted
verbatim. `pnpm --filter @objectstack/spec build` was run on both trees
in one lock turn:
- total `.d.ts` bytes: 30,830,702 at `e0f17a37` → 30,836,475 at
`61b382d9` (+5,773, +0.019%, TSDoc and alias text);
- affected chunks: `view.zod` 499,735 → 500,541, `action.zod` 76,621 →
77,606, `page.zod` 304,750 → 305,845;
   - `TS7056` occurrences in the build log: 0 on both.

The casts are real declaration-size dodges, which is why this PR derives
from the members and leaves the casts alone. In-memory declaration emit,
replacing each union's `z.ZodTypeAny` tuple with the real member tuple:
- `view.zod.d.ts`: 500,881 → 663,301 bytes (+32%);
`ViewMetadataSchema`'s own declaration grows 323 → 162,743 bytes (3,995
lines);
- `assembled-views.zod.d.ts`: 8,824 → 64,776 bytes (×7.3); the schema's
declaration grows 306 → 56,258 bytes.

Neither emits TS7056. The same root fix would also have removed the
`diagnoseViewMetadata` assertion, so that assertion is the cheaper of
the two ways to type `data`.
3. **What the type change forces.** Census, whole tree, `git grep -w`
excluding `.md`/`.mdx`: outside `packages/spec`, nothing names the four
types or `diagnoseViewMetadata`. Inside, only `view.zod.ts` itself and
six test files do. An in-memory ablation removing the assertion yields
exactly one diagnostic: `view.zod.ts` TS2322 "Type 'unknown' is not
assignable to type 'ViewMetadataParsed'". The six census test files
carry 8 diagnostics under `tsconfig.test.json`, identical on base and
head apart from line numbers, all in the ledgered `view.test.ts` debt.
4. **Consumer compile.**
- objectui at the pinned `f8a9d0fb` names none of the three changed
types. It names `JoinedReportBlock`, which this PR leaves alone.
- cloud (local checkout `48d7066`) names none of the four. The control
leg (`defineStack`) hits 18 files.
- No consumer package in this repo imports them, so there is no consumer
suite to run beyond `@objectstack/spec`'s own.

## Reverse verification

In-memory ablation: each alias reverted to its base spelling through a
compiler-host override, with the anchor matched exactly once and nothing
written to disk. The pin file is then compiled under
`tsconfig.test.json`, and every `@ts-expect-error` pin turns red:

| alias reverted | pin file | result |
|:--|:--|:--|
| `InlineAction` | `inline-action-type.test.ts` | 4 × TS2578 (unused
directive) |
| `ViewMetadataParsed` | `view-metadata-type.test.ts` | 3 × TS2578 |
| `AssembledViewArtifact` | `assembled-view-artifact-type.test.ts` | 3 ×
TS2578 |
| `AssembledViewArtifactParsed` | `assembled-view-artifact-type.test.ts`
| 1 × TS2578 |

With the fix in place the three pin files compile with 0 diagnostics.
`tsc -p tsconfig.test.json --listFilesOnly` lists all three, among 523
test files.

## Tests

Final head `f8792c93`. Its code is identical to `61b382d9`; `cf123662`
and `f8792c93` touch only `.changeset/`. Round 2 (`f8792c93`, the
changeset level / arm / banner / marker only) re-ran the 17 derived
families that read `.changeset` plus `check:spec-changes`:
`check-adr-0087-registration` exit 0 (`[BREAKING+clause-②-narrowing]
not-required (no-migration-prescription)`), `check-changeset-no-major`
exit 0 (including the level axis driven with this PR's payload), and
`check-empty-changeset` exit 1 on the deliberate correction only. The
other 66 stand at their `cf123662` reading. Heavy runs went through
`scripts/pm/os-verify-lock.sh`, and each exit code was written to disk
before it was read.

- **Build, typecheck and tests** at `61b382d9` (lock turn 1):
  - `pnpm --filter @objectstack/spec build`: exit 0, TS7056 ×0;
- `pnpm --filter @objectstack/spec typecheck`: exit 0,
"check:test-typecheck: OK — @objectstack/spec's test layer compiles
under packages/spec/tsconfig.test.json; 53 file(s) / 255 error(s) / 142
pinned signature(s) held in test-typecheck-debt.json";
- `pnpm --filter @objectstack/spec test`: "Test Files 550 passed (550)",
"Tests 16120 passed | 2 todo (16122)".
- **Generated artifacts and pins** at `cf123662` (lock turn 2):
- `pnpm --filter @objectstack/spec check:generated`: exit 0, "All 15
generated artifacts are up to date";
- the three pin files by name, `vitest run --project local
--maxWorkers=2`: "Test Files 3 passed (3)", "Tests 17 passed (17)".
- **Derived gate union** at `cf123662`: `node
scripts/pm/dispatch-gates.mjs --commands`, 83 commands, all run.
  - 80 exit 0. Among them:
- `check:api-surface`: "public API surface + factory signatures
unchanged";
- `check:exported-any`: "no exported type resolves to `any`: 2376 types
+ 1452 schemas";
- `check:export-origins`: "5213 exports across 18 entry points resolve
exactly as recorded";
    - `check:docs`: "226 generated files in sync";
- `check:dual-source-exports`, `check:entry-nameability`,
`check:liveness`, `check:spec-parsed-alias`, `check:test-source-alias`,
`check:issue-citations`, `check:nul-bytes`;
- `check:lean-entry-closure` and `check:doc-formula-expressions`,
measured after building their closures.
- `check-empty-changeset --base origin/main`: exit 1, the deliberate
correction above, red on purpose.
- `check:dual-build-cjs-loads` and `check:type-check-debt`: exit 3,
PREREQUISITE NOT MET. NOT MEASURED: both need the whole `./packages/*`
build closure, which CI builds.
- `dispatch-gates.mjs --ran` over the exit-coded record: "83 derived, 81
run, 2 NOT-MEASURED, 0 UNRUN".
- **Lint, narrowed and proven.** Repo-wide `pnpm lint` is CI's. I ran
`eslint --no-inline-config --format json` over the six changed `.ts`
files:
  - the JSON counts 6 files, 0 errors, 0 warnings;
- the population is `eslint.config.mjs`'s own TS/JS globs, so the two
`.changeset/*.md` files are outside it;
- that config "never enables type-aware linting (no
`parserOptions.project`, no typed `@typescript-eslint` rules) for ANY
file" (`eslint.config.mjs:327`), so this diff cannot move the verdict on
any untouched file.
- **Consumer suites:** none owed. No package outside `@objectstack/spec`
imports the four types or `diagnoseViewMetadata` (census above).
- **NOT MEASURED, left to CI:**
  - the Type Check workspace and consumer lanes;
  - Test Core shards;
  - Dogfood;
  - Build Core;
  - the two gates above.

## The remainder: `JoinedReportBlock`

- **Why it is not here.** PR objectstack-ai#20238 (objectstack-ai#20161, +77/−14 in `report.zod.ts`)
edits the joined-report block schema this type is derived from, and it
is still open (draft). The dispatch fixed the order: this site lands on
the merged `main`.
- **Measured fork, for whoever takes it.** `JoinedReportBlockSchema` is
annotated `z.ZodTypeAny`. I did an in-memory declaration emit of
`report.zod.ts` on current `main`, with the annotation removed. It
produced no TS7056 and no other diagnostic. `report.zod.d.ts` grows
18,608 → 34,390 bytes (×1.85): the block schema's declaration grows to
7,582 bytes, and `ReportSchema` doubles (8,677 → 16,937) because
`blocks:` now inlines the block type. So the annotation buys declaration
size, not an escape from TS7056. The two options are:
  - remove the annotation and derive the type from the schema;
- keep the annotation and derive the type from an explicitly named
shape.

  Both need re-measuring on the post-objectstack-ai#20238 tree.
- **objectui tripwire.** objectui at the pin holds an inverted pin,
`true satisfies IsUnknown` of the spec's `JoinedReportBlock`, in
`packages/types/src/__tests__/report-chart-query-spec-parity.test.ts`.
Its docblock says the day the spec types this, the pin stops compiling,
and "the failure is the instruction: re-run the triage and burn it
down". The Console Pin Gate only builds objectui (the `types` build
config excludes `__tests__/`), so that pin reds objectui's own
`type-check` on its next spec bump, not this repo's CI. Measured for the
follow-up; this PR does not move it.

## Acceptance notes

- `check:spec-parsed-alias` recognises a bare alias only in the spelling
`z.input` of `typeof` the schema. Its population drops 1443 → 1441 bare
aliases (paired 657 → 655), because `InlineAction` and
`AssembledViewArtifact` now use member derivations, as `ViewMetadata`
has since objectstack-ai#19919. Both `…Parsed` siblings still exist; the gate just no
longer sees the pairs. Noted, not filed. Carrier: none.
- Same family, nested: `viewItemArmShape(viewKind, config:
z.ZodTypeAny)` makes `config` `unknown` on both members of `ViewItem`
and `ViewItemWire` (measured). So it is `unknown` on the `viewItem`
member of every union above too. Reported to the dispatching seat to
fold into this family's closing card; not changed here.
- Zone 3's "a value missing a required key is a type error" pin cannot
be expressed for `InlineAction`: every key of its input is optional
(`type` has a default; `name` and `label` are `.partial()`). Its pins
are `unknown`, the two legacy spellings, and a scalar.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…nd the 25 missing entries (objectstack-ai#20201) (objectstack-ai#20255)

Fixes objectstack-ai#20201
Clause-②: no

## What

Ruling B on objectstack-ai#17152 (director `5615360777`, restated `5634031140`, on the
maintainer's objectstack-ai#15954 authority `5559778263`): every retirement family
carries ONE ADR-0087 D3 (`semantic`) entry, even when a lossless D2
conversion repairs its data; D2 carries the mechanical repair only. This
PR takes the major-18 family census the card asks for, adds the 25 D3
entries it found missing, corrects the prose that justified their
absence, and pins the census in the registry's own test.

- **25 new D3 entries** under
`packages/spec/src/migrations/entries/semantic/18.*.ts`, one per
D2-backed family that had none. Each names its family and its D2
conversion id, says what D2 already repairs, and says what judgment the
consumer still owes (the `reason`), with an `acceptanceCriteria` the
consumer can check. None is a placeholder: every one states a residue
specific to its family (a unit only the author knows, a belief the
platform never honoured, a shape the conversion deliberately leaves
alone, code the chain cannot reach).
- **Prose corrected** at the sites of `5854110917` and `5854456343`,
plus step 18's rationale and step 17's docblock fact (list below).
- **Census pin** in the existing
`packages/spec/src/migrations/migrations.test.ts` (registry integrity).
No new check script.
- `MIGRATIONS_BY_MAJOR[18].semantic` 186 → 211 entries at the census
base; after merging `main` (four sibling D3 entries landed meanwhile,
none with a D2 conversion) the generated region holds 215.

## The census (the card's main deliverable)

**Tree.** `objectstack-ai/objectstack` at `3cb84d084` (this branch's
fork point; it already contains objectstack-ai#20227's view-item retirement). Major-18
population there: **185** `retired-keys`, **146** `retired-defs`,
**186** `semantic` entry files, and **36** D2 conversions graduated into
step 18. (The card measured 181 / 130 / 175 at `d7c024133e`.)

**Grouping rule (ruling B's unit, held constant).** Where a D2
conversion exists, the conversion is the family: every retired key or
def it repairs belongs to it, and a D3 entry may cover more than one
conversion only where one judgment covers them (the pre-existing
`element-filter-and-form-node-refused` covers `element-filter-removed`
and `element-form-removed`). Every new entry here covers exactly one
conversion. Where no conversion exists, the records are grouped by the
D3 entry that names them.

**Method.**
1. Mechanical pass (scratch scripts, not committed): for each of the 36
conversions, the major-18 `semantic/` entry files that name its id as a
whole id (comment or field); for each retired key, the conversion its
own comment names, else the major-18 entries naming it as `cat/Def:key`,
`Def.path` / `Def:path`, or the def name plus the leaf key; for each
retired def, the entries naming the def.
2. Reading pass, where a string match cannot decide: (a) ownership: an
entry that names a conversion only in passing is not that family's entry
(this is how `metric-filters-removed` was classed missing although
`analytics-authorable-unknown-keys-refused` names it); (b) 14 records
matched by more than one entry, each placed by its own comment (for
example `kernel/PluginStartupResult:plugin`, which goes to
`startup-orchestrator-retired`); (c) 9 records whose comment names no
conversion but which belong to a D2 family
(`integration/DeclarativeConnectorEntry:connectionTimeoutMs` and
`:errorMapping`, the three error-mapping defs, the four responsive-shape
defs), plus `integration/Connector:connectionTimeoutMs`, whose comment
names two conversion ids and belongs to
`connector-connection-timeout-ms-removed` (it names the permission
conversion only as a comparison; round 1's Table 1 placed it wrongly,
corrected in patch round 1); (d) 5 theme sub-block defs, named by the
theme family's entry as its sub-blocks.

**Control.** The pairing sees a family that has its entry: 11 of the 36
conversions pair with a pre-existing entry, among them
`cube-join-sql-and-relationship-removed` with
`cube-join-sql-and-relationship-retired` (which the pin's own control
test also asserts), and the whole-id matcher refuses a prefix
(`record-chatter-position-vocabulary` is a prefix of its entry's own id
and matches only the entry's real citation). Evaluated at the base with
the pin's logic: **24** conversions named by no major-18 entry; the
reading pass adds `metric-filters-removed` for **25**. Evaluated at this
head: **0**.

**Result.** 331 records (185 keys + 146 defs) plus 36 conversions:

- **36 D2-backed families** covering 58 records: 11 had their D3 entry,
**25 had none**. Table 1.
- **273 D2-less records** in 68 groups: every one is named by an
existing D3 entry. Table 2. None missing, as expected: before ruling B,
a retirement with no conversion needed a D3 entry anyway.

The card's grep for 「lossless」 found the `tenancy.organizationField`
site and step 17. The census finds 25 major-18 families, most of them
with no 「lossless」 wording at all.

### Table 1 — D2-backed families (step 18 `conversionIds`, in order)

| # | D2 conversion (the family) | registered records | D3 entry at base
| D3 entry after |
|---|---|---|---|---|
| 1 | `field-malformed-scale-precision-removed` | none (value or
strict-key retirement, not in the two tables) |
`field-scale-precision-integer-refused` | unchanged |
| 2 | `record-chatter-position-vocabulary` | none (value or strict-key
retirement, not in the two tables) |
`record-chatter-position-vocabulary-converged` | unchanged |
| 3 | `element-input-target-variable-removed` |
`ui/ElementRecordPickerProps:targetVariable`,
`ui/ElementTextInputProps:targetVariable` | MISSING |
`element-input-target-variable-retired` (new) |
| 4 | `element-filter-removed` | `ui/ElementFilterProps:aria`,
`ui/ElementFilterProps:fields`, `ui/ElementFilterProps:layout`,
`ui/ElementFilterProps:object`, `ui/ElementFilterProps:showSearch`,
`ui/ElementFilterProps:targetVariable` |
`element-filter-and-form-node-refused` | unchanged |
| 5 | `element-form-removed` | `ui/ElementFormProps:aria`,
`ui/ElementFormProps:fields`, `ui/ElementFormProps:mode`,
`ui/ElementFormProps:object`, `ui/ElementFormProps:onSubmit`,
`ui/ElementFormProps:submitLabel` |
`element-filter-and-form-node-refused` | unchanged |
| 6 | `field-column-lists-canonicalized` | none (value or strict-key
retirement, not in the two tables) | MISSING |
`field-inline-and-related-list-columns-closed` (new) |
| 7 | `metric-filters-removed` | `data/Metric:filters` | MISSING (named
only in passing by `analytics-authorable-unknown-keys-refused`) |
`cube-metric-filters-retired` (new) |
| 8 | `cube-sub-day-granularities-removed` | none (value or strict-key
retirement, not in the two tables) |
`time-update-interval-sub-day-retired` | unchanged |
| 9 | `cube-join-sql-and-relationship-removed` |
`data/CubeJoin:relationship`, `data/CubeJoin:sql` |
`cube-join-sql-and-relationship-retired` | unchanged |
| 10 | `record-highlights-field-icon-removed` |
`ui/RecordHighlightsField:icon` | MISSING |
`record-highlights-field-icon-retired` (new) |
| 11 | `mapping-lookup-params-removed` | none (value or strict-key
retirement, not in the two tables) | MISSING |
`mapping-lookup-params-retired` (new) |
| 12 | `translation-component-submit-label-removed` | none (value or
strict-key retirement, not in the two tables) | MISSING |
`translation-component-submit-label-retired` (new) |
| 13 | `page-component-responsive-removed` |
`ui/PageComponent:responsive`, `ui/BreakpointColumnMap`,
`ui/BreakpointName`, `ui/BreakpointOrderMap`, `ui/ResponsiveConfig` |
MISSING | `page-component-responsive-retired` (new) |
| 14 | `object-grid-default-sort-removed` |
`ui/ObjectGridProps:defaultSort` | MISSING |
`object-grid-default-sort-retired` (new) |
| 15 | `object-kanban-quick-add-removed` |
`ui/ObjectKanbanProps:quickAdd` | MISSING |
`object-kanban-quick-add-retired` (new) |
| 16 | `permission-allow-restore-purge-removed` |
`security/EffectiveObjectPermission:allowPurge`,
`security/EffectiveObjectPermission:allowRestore`,
`security/ObjectPermission:allowPurge`,
`security/ObjectPermission:allowRestore` | MISSING |
`permission-restore-purge-bits-retired` (new) |
| 17 | `form-view-option-default-removed` | none (value or strict-key
retirement, not in the two tables) | MISSING |
`form-view-option-default-retired` (new) |
| 18 | `field-reference-to-alias` | none (value or strict-key
retirement, not in the two tables) | MISSING |
`field-reference-to-spelling-retired` (new) |
| 19 | `connector-error-mapping-removed` |
`integration/Connector:errorMapping`,
`integration/DeclarativeConnectorEntry:errorMapping`,
`integration/ConnectorErrorCategory`, `integration/ErrorMappingConfig`,
`integration/ErrorMappingRule` | MISSING |
`connector-error-mapping-retired` (new) |
| 20 | `connector-connection-timeout-ms-removed` |
`integration/Connector:connectionTimeoutMs`,
`integration/DeclarativeConnectorEntry:connectionTimeoutMs` |
`connector-provider-context-connection-timeout-ms-retired` | unchanged |
| 21 | `hook-timeout-to-timeout-ms` | none (value or strict-key
retirement, not in the two tables) | MISSING |
`hook-timeout-unit-in-key` (new) |
| 22 | `job-timeout-to-timeout-ms` | `system/Job:timeout` | MISSING |
`job-timeout-unit-in-key` (new) |
| 23 | `api-endpoint-cache-ttl-to-cache-ttl-seconds` |
`api/ApiEndpoint:cacheTtl` | MISSING |
`api-endpoint-cache-ttl-unit-in-key` (new) |
| 24 | `dashboard-refresh-interval-to-refresh-interval-seconds` |
`ui/Dashboard:refreshInterval` | MISSING |
`dashboard-refresh-interval-unit-in-key` (new) |
| 25 | `connector-health-and-trigger-durations-unit-in-key` |
`integration/CircuitBreakerConfig:monitoringWindow`,
`integration/ConnectorTrigger:interval` | MISSING |
`connector-resilience-durations-unit-in-key` (new) |
| 26 | `memory-persistence-auto-save-interval-to-ms` |
`data/AutoPersistenceConfig:autoSaveInterval`,
`data/FilePersistenceConfig:autoSaveInterval` | MISSING |
`memory-persistence-auto-save-interval-unit-in-key` (new) |
| 27 | `turso-config-timeout-to-timeout-ms` | `data/TursoConfig:timeout`
| MISSING | `turso-config-timeout-unit-in-key` (new) |
| 28 | `view-page-mount-removed` | `ui/ListView:pageName`,
`ui/ObjectListView:pageName` | MISSING | `list-view-page-mount-retired`
(new) |
| 29 | `list-view-sort-string-clause-to-array` | none (value or
strict-key retirement, not in the two tables) | MISSING |
`list-view-sort-string-clause-retired` (new) |
| 30 | `page-assigned-profiles-removed` | `ui/Page:assignedProfiles` |
`page-assigned-profiles-audience-to-permission-set` | unchanged |
| 31 | `chart-config-aria-removed` | `ui/ChartConfig:aria`,
`ui/ReportChart:aria` | MISSING | `chart-config-aria-retired` (new) |
| 32 | `dashboard-widget-chart-config-structure-removed` |
`ui/DashboardWidgetChartConfig:series`,
`ui/DashboardWidgetChartConfig:type`,
`ui/DashboardWidgetChartConfig:xAxis`,
`ui/DashboardWidgetChartConfig:yAxis` |
`dashboard-widget-chart-config-structure-refused` | unchanged |
| 33 | `translation-per-app-settings-removed` | none (value or
strict-key retirement, not in the two tables) |
`translation-per-app-settings-platform-only` | unchanged |
| 34 | `object-tenancy-organization-field-removed` |
`data/TenancyConfig:organizationField` | MISSING |
`object-tenancy-organization-field-retired` (new) |
| 35 | `page-component-filter-record-to-rule-array` | none (value or
strict-key retirement, not in the two tables) |
`element-data-source-and-object-block-filter-rule-array`,
`object-grid-default-filters-rule-array` | unchanged |
| 36 | `view-item-owner-hidden-removed` | `ui/ViewItemWire:hidden`,
`ui/ViewItemWire:owner`, `ui/ViewItem:hidden`, `ui/ViewItem:owner` |
MISSING | `view-item-owner-hidden-retired` (new) |

### Table 2 — D2-less records, grouped by the existing D3 entry that
names them

| D3 entry (existing) | records it names |
|---|---|
| `advanced-plugin-lifecycle-config-retired` |
`kernel/AdvancedPluginLifecycleConfig`, `kernel/GracefulDegradation`,
`kernel/PluginUpdateStrategy` |
| `ai-conversation-analytics-duration-unit-in-key` |
`ai/ConversationAnalytics:duration` |
| `api-error-retry-after-unit-in-key` |
`api/EnhancedApiError:retryAfter` |
| `api-runtime-config-durations-unit-in-key` |
`api/DataLoaderConfig:cacheTtl`, `api/RouteDefinition:timeout` |
| `automation-flow-list-route-retired` | `api/FlowSummary`,
`api/ListFlowsRequest`, `api/ListFlowsResponse` |
| `automation-runs-cursor-retired` | `api/ListRunsRequest:cursor` |
| `branded-identifier-schemas-retired` | `shared/AppName`,
`shared/FieldName`, `shared/FlowName`, `shared/ObjectName`,
`shared/RoleName`, `shared/ViewName` |
| `change-management-duration-keys-retired` |
`system/ChangeImpact:downtime.durationMinutes`,
`system/ChangeRequest:implementation.steps.estimatedMinutes`,
`system/RollbackPlan:steps.estimatedMinutes` |
| `change-management-family-retired` | `system/ChangeImpact`,
`system/ChangePriority`, `system/ChangeRequest`, `system/ChangeStatus`,
`system/ChangeType`, `system/RollbackPlan` |
| `cli-command-contribution-retired` | `kernel/CLICommandContribution` |
| `cloud-subpath-retired` | 62 records, all `cloud/` defs |
| `data-file-value-duration-unit-in-key` | `data/FileValue:duration` |
| `data-nosql-query-options-timeout-unit-in-key` |
`data/NoSQLQueryOptions:timeout` |
| `device-request-response-interval-unit-in-key` |
`api/DeviceRequestResponse:interval` |
| `driver-options-timeout-to-timeout-ms` | `data/DriverOptions:timeout`
|
| `epoch-instant-keys-renamed` | `api/SimplePresenceState:lastSeen`,
`api/WebSocketEvent:timestamp`, `kernel/HealthStatus:timestamp`,
`kernel/KernelContext:startTime`,
`kernel/TenantRuntimeContext:startTime` |
| `esignature-config-deadline-keys-retired` |
`data/ESignatureConfig:expirationDays`,
`data/ESignatureConfig:reminderDays` |
| `event-name-schema-retired` | `shared/EventName` |
| `export-job-family-retired` | 13 records, all `api/`, `automation/`
defs |
| `hot-reload-inert-state-strategies-retired` |
`kernel/DistributedStateConfig` |
| `hot-reload-watch-placeholder-retired` |
`kernel/HotReloadConfig:watchPatterns` |
| `identity-api-key-schema-retired` | `identity/ApiKey` |
| `incident-response-deadline-keys-retired` |
`system/IncidentNotificationMatrix:escalationTimeoutMinutes`,
`system/IncidentNotificationRule:regulatorDeadlineHours`,
`system/IncidentNotificationRule:withinMinutes`,
`system/IncidentResponsePhase:targetHours`,
`system/IncidentResponsePolicy:retentionDays`,
`system/IncidentResponsePolicy:triageDeadlineHours` |
| `incident-response-family-retired` | `system/Incident`,
`system/IncidentCategory`, `system/IncidentNotificationMatrix`,
`system/IncidentNotificationRule`, `system/IncidentResponsePhase`,
`system/IncidentResponsePolicy`, `system/IncidentSeverity`,
`system/IncidentStatus` |
| `kernel-compatibility-matrix-estimated-migration-time-unit-in-key` |
`kernel/CompatibilityMatrixEntry:estimatedMigrationTime` |
| `kernel-context-preview-mode-retired` |
`kernel/KernelContext:previewMode`, `kernel/PreviewModeConfig`,
`kernel/TenantRuntimeContext:previewMode` |
| `kernel-event-bus-retention-unit-in-key` |
`kernel/EventPersistence:retention`,
`kernel/EventSourcingConfig:retention` |
| `kernel-health-check-and-hot-reload-durations-unit-in-key` |
`kernel/HotReloadConfig:debounceDelay`,
`kernel/PluginHealthCheck:interval`, `kernel/PluginHealthCheck:timeout`
|
| `kernel-package-lifecycle-durations-unit-in-key` |
`kernel/MultiVersionSupport:rollout.duration`,
`kernel/PackageDependencyResolutionResult:resolvedIn`,
`kernel/UpgradePlan:estimatedDuration` |
| `kernel-plugin-health-report-durations-unit-in-key` |
`kernel/PluginHealthReport:metrics.responseTime`,
`kernel/PluginHealthReport:metrics.uptime` |
| `kernel-plugin-security-durations-unit-in-key` |
`kernel/KernelSecurityPolicy:auditLog.retention`,
`kernel/KernelSecurityPolicy:authentication.tokenExpiration`,
`kernel/PluginSecurityManifest:vulnerabilityDisclosure.responseTime` |
| `kernel-runtime-config-timeout-unit-in-key` |
`kernel/RuntimeConfig:resourceLimits.timeout`,
`kernel/SandboxConfig:process.timeout` |
| `kernel-startup-orchestrator-durations-unit-in-key` |
`kernel/PluginStartupResult:duration`, `kernel/StartupOptions:timeout`,
`kernel/StartupOrchestrationResult:totalDuration` |
| `list-view-navigation-view-retired` | `ui/NavigationConfig:view` |
| `logging-durations-unit-in-key` |
`system/HttpDestinationConfig:batch.flushInterval`,
`system/HttpDestinationConfig:retry.initialDelay`,
`system/HttpDestinationConfig:timeout`,
`system/LoggingConfig:buffer.flushInterval` |
| `metadata-changed-event-payload-retired` |
`kernel/MetadataChangeOperation`, `kernel/MetadataChangedEventPayload` |
| `metadata-customization-protocol-retired` | 13 records, all `api/`,
`kernel/` defs |
| `metadata-manager-config-cache-ttl-unit-in-key` |
`kernel/MetadataManagerConfig:cache.ttl` |
| `metadata-manager-config-inert-cache-keys-retired` |
`kernel/MetadataManagerConfig:cache.enabled`,
`kernel/MetadataManagerConfig:cache.maxSize`,
`kernel/MetadataManagerConfig:cache.ttlSeconds` |
| `metadata-plugin-additional-types-retired` |
`kernel/MetadataPluginConfig:additionalTypes` |
| `package-rollback-response-retired` | `api/PackageRollbackResponse` |
| `packages-list-pagination-retired` |
`api/ListInstalledPackagesRequest:cursor`,
`api/ListInstalledPackagesRequest:limit` |
| `plugin-auto-restart-never-reinitialised` |
`kernel/PluginHealthCheck:autoRestart`,
`kernel/PluginHealthCheck:maxRestartAttempts`,
`kernel/PluginHealthCheck:restartBackoff` |
| `plugin-manifest-contributes-dead-members-retired` |
`kernel/Manifest:contributes.actions`,
`kernel/Manifest:contributes.commands`,
`kernel/Manifest:contributes.drivers`,
`kernel/Manifest:contributes.events`,
`kernel/Manifest:contributes.fieldTypes`,
`kernel/Manifest:contributes.functions`,
`kernel/Manifest:contributes.menus`,
`kernel/Manifest:contributes.themes`,
`kernel/Manifest:contributes.translations` |
| `plugin-manifest-contributes-routes-retired` |
`kernel/Manifest:contributes.routes` |
| `plugin-manifest-dead-containers-retired` |
`kernel/Manifest:capabilities`, `kernel/Manifest:configuration`,
`kernel/Manifest:extensions` |
| `plugin-manifest-kind-globs-retired` |
`kernel/Manifest:contributes.kinds.globs` |
| `plugin-security-scan-result-surface-retired` |
`kernel/KernelSecurityScanResult`, `kernel/KernelSecurityVulnerability`,
`kernel/PluginQualityMetrics:securityScan`,
`kernel/PluginSecurityManifest:scanResults`,
`kernel/PluginSecurityManifest:vulnerabilities` |
| `rest-api-endpoint-handler-status-retired` | `api/HandlerStatus`,
`api/RestApiEndpoint:handlerStatus`, `api/RouteCoverageEntry`,
`api/RouteCoverageReport` |
| `rest-api-plugin-durations-unit-in-key` |
`api/RestApiEndpoint:cacheTtl`, `api/RestApiEndpoint:timeout`,
`api/RestApiPluginConfig:performance.defaultCacheTtl` |
| `rest-server-config-dead-keys-retired` | 11 records, all `api/` defs |
| `session-user-language-retired` | `api/SessionUser:language` |
| `stack-themes-carrier-retired` | `ui/BorderRadius`, `ui/ColorPalette`,
`ui/Shadow`, `ui/Theme`, `ui/ThemeMode`, `ui/Typography` |
| `startup-orchestrator-retired` | `kernel/HealthStatus`,
`kernel/PluginStartupResult:health`,
`kernel/PluginStartupResult:plugin`,
`kernel/PluginStartupResult:startTime`, `kernel/StartupOptions`,
`kernel/StartupOrchestrationResult` |
| `system-cache-durations-unit-in-key` |
`system/CacheAvalanchePrevention:circuitBreaker.resetTimeout`,
`system/CacheTier:ttl` |
| `system-collaboration-durations-unit-in-key` |
`system/CollaborationSessionConfig:idleTimeout`,
`system/CollaborationSessionConfig:snapshot.interval` |
| `system-failover-health-check-interval-unit-in-key` |
`system/FailoverConfig:healthCheckInterval` |
| `system-metrics-jsdoc-durations-unit-in-key` |
`system/MetricDefinition:summary.maxAge`,
`system/MetricExportConfig:interval`,
`system/MetricsConfig:collectionInterval`,
`system/MetricsConfig:retention.period`,
`system/ServiceLevelObjective:errorBudget.burnRateWindows.window` |
| `system-metrics-window-durations-unit-in-key` |
`system/MetricAggregationConfig:window.size`,
`system/ServiceLevelIndicator:window.size`,
`system/ServiceLevelObjective:period.duration` |
| `system-object-storage-durations-unit-in-key` |
`system/AccessControlConfig:maxAge`, `system/StorageConnection:timeout`
|
| `system-registry-config-durations-unit-in-key` |
`system/RegistryConfig:cache.ttl`,
`system/RegistryUpstream:syncInterval`,
`system/RegistryUpstream:timeout` |
| `system-tracing-otel-exporter-durations-unit-in-key` |
`system/OpenTelemetryCompatibility:exporter.batch.exportTimeout`,
`system/OpenTelemetryCompatibility:exporter.batch.scheduledDelay`,
`system/OpenTelemetryCompatibility:exporter.timeout`,
`system/TracingConfig:performance.exportInterval` |
| `system-tracing-span-duration-unit-in-key` | `system/Span:duration` |
| `system-worker-queue-rate-limit-duration-unit-in-key` |
`system/QueueConfig:rateLimit.duration` |
| `tenant-schema-cache-ttl-unit-in-key` |
`system/SchemaLevelIsolationStrategy:performance.schemaCacheTTL` |
| `training-deadline-keys-retired` |
`system/TrainingCourse:durationMinutes`,
`system/TrainingCourse:validityDays`,
`system/TrainingPlan:gracePeriodDays`,
`system/TrainingPlan:recertificationIntervalDays`,
`system/TrainingPlan:reminderDaysBefore` |
| `training-family-retired` | `system/TrainingCategory`,
`system/TrainingCompletionStatus`, `system/TrainingCourse`,
`system/TrainingPlan`, `system/TrainingRecord` |
| `websocket-durations-unit-in-key` |
`api/WebSocketConfig:pingInterval`,
`api/WebSocketConfig:reconnectInterval`, `api/WebSocketConfig:timeout`,
`api/WebSocketServerConfig:heartbeatInterval` |

## Prose corrected (the single-entry sites of `5854110917` /
`5854456343`, and the rationale sentences)

| site (at this head) | was | now |
|---|---|---|
| `packages/spec/src/migrations/registry.ts:78–86` (step 17 docblock,
fact correction only) | 「Mechanical, and mechanical only … there is no
semantic residue and the `semantic` list is deliberately empty」 | the
three renames replay losslessly as D2; they carry no D3 entry because
step 17 shipped before the rule and was not back-filled; the `semantic`
list is NOT empty. ⛔ No step-17 entry added. |
| `packages/spec/src/migrations/registry.ts:5256` (step 18 rationale,
`tenancy.organizationField`) | 「The conversion is a lossless delete and
there is no semantic residue」 | a lossless delete still leaves the
author a judgment, carried by
`object-tenancy-organization-field-retired` |
| `packages/spec/src/conversions/registry.ts:3460`
(`datasource-driver-mongo-to-mongodb`, protocol 17) | 「Why D2 and not
D3」 | 「Why the data repair is D2」, plus: losslessness does not decide
whether a family owes D3; this one is protocol 17 and has none |
| `packages/spec/src/conversions/registry.ts:9312`
(`api-endpoint-cache-ttl-to-cache-ttl-seconds`) | 「gets a conversion
rather than a semantic entry」 | 「also gets a conversion」, and names its
D3 entry |
| `packages/spec/src/conversions/registry.ts:9804`
(`list-view-sort-string-clause-to-array`) | 「which is why this is a D2
conversion rather than a semantic TODO」 | the data repair is D2; the
family's D3 entry carries the clauses the rewrite leaves alone |
|
`packages/spec/src/migrations/entries/retired-keys/18.api__ApiEndpoint__cacheTtl.ts:11–19`
| 「a D2 CONVERSION rather than a semantic entry」 | also a D2 conversion,
and names the D3 entry |
|
`packages/spec/src/migrations/entries/semantic/18.metadata-endpoints-switch-radius-repartitioned.ts:11–13`
| 「exactly the residue D2 cannot express, which is why this is a
semantic entry」 | that residue is why there is no D2 at all; the D3
entry is owed either way |
| `packages/spec/scripts/build-migration-registry.ts:276` | 「a major
whose semantic residue is genuinely nil (protocol 14)」 | an empty region
is a real state (a freshly opened step, or protocol 14's, which predated
the rule) |

⛔ Not touched: the governed texts (ADR-0087,
`.claude/skills/spec-property-retirement/SKILL.md` §3), which are
objectstack-ai#20188's.

## The census pin

**Where:** `packages/spec/src/migrations/migrations.test.ts` › `registry
integrity`: `from protocol 18 on, every graduated D2 conversion is named
by a D3 entry of its own step (ruling B)`, plus a control test. It reads
the major's `entries/semantic/` files (comment and literal) inside its
own package; `check:migration-registry` already proves those files and
the generated region are one set.

**What it asserts:** for every step whose `toMajor` is 18 or later,
every id in `conversionIds` appears as a whole id in at least one
`semantic/` entry of that major. A new major-18 (or later) retirement
that lands a D2 conversion with no D3 entry naming it goes red, naming
the conversion.

**What it cannot see**, stated so a green run is not over-read: (1)
whether the naming entry is that family's OWN (a passing mention
satisfies it; the census judged ownership by reading); (2) a family
retired with no conversion at all (no machine-readable link joins a
retired key or def to its D3 entry; the census paired those by reading,
and found none missing). Protocol 17 is outside the pin by design:
measured with the same logic, 53 of its 57 graduated conversions are
named by no step-17 entry, and step-17 backfill is out of scope (triage
`5854164872`).

**Reverse verification (one-shot, no permanent test file).** At
`c8656ad35`, with the entries committed: deleted
`18.object-tenancy-organization-field-retired.ts` (absence confirmed on
disk before the run), ran the pin: `× from protocol 18 on …` with `+
"protocol 18: object-tenancy-organization-field-removed"`, `Tests 1
failed | 140 skipped`. Restored with `git checkout HEAD -- PATH` (that
path) inside a `trap … EXIT INT TERM`: blob `2cf3007d9fff` equals
HEAD's, `git diff HEAD` empty. Direction observed: red, the expected
one. No build involved: the test imports `src/` and reads the entry
files directly.

## Patch round 1 (contract review `5857834457`: FAIL at `3197fce29`)

**Blocking: fixed.** `Lint & Repo Gates` step 189
(`check-issue-citations.mjs`, judging pass) was red. Four bare citations
this PR added answer 404 on the board: objectstack-ai#10329, objectstack-ai#10926, objectstack-ai#12868 and
objectstack-ai#14676. Each was in an entry's leading comment, and again in the
regenerated region. `--probe-cause` classes all four as **deleted** (the
web endpoint also answers 404, so none was transferred), so none of them
is a reference to another repository to qualify. Each comment now
anchors to the commit in this repository's history that retired the
family, and says in words what that commit decided. That is the
precedent of commit `66e266c93` (ruling C+D on objectstack-ai#19123). Every sha is an
ancestor of `origin/main`:

| entry | was | now anchored to |
|---|---|---|
| `mapping-lookup-params-retired` | objectstack-ai#10329 | commit `15d58dbf1` (the
import path never read the four lookup steering params) |
| `translation-component-submit-label-retired` | objectstack-ai#10926 | commit
`d173125fb` (the copy key left with its only declarer, `element:form`) |
| `form-view-option-default-retired` | objectstack-ai#12868 | commit `c459da6bc` (the
ruled narrowing: the form-view face drops per-option `default`, the
object-field face keeps it enforced) |
| `connector-error-mapping-retired` | objectstack-ai#14676 | commit `13c48c2a5`
(eleven inert keys, one spelled like the live `userMessage` channel) |

Only the comments changed; no string an author is shown moves. The
region was regenerated with `gen:migration-registry`. The round-1
report's `pnpm check:issue-citations :: exit 0` was the package script,
which runs only the `--self-test`. The judging pass CI runs was exit 2
at `3197fce29` (8 findings = 4 numbers × 2 sites) and is exit 0 now
(below).

**Pin message.** The census pin's assertion now names the unnamed
`protocol N: conversion-id` pairs and the remedy: add a D3 `semantic`
entry of that step whose text names the conversion id as a whole word.
Its logic and scope are unchanged. Shown firing at `21418c4d2` with one
entry removed (trap-guarded restore, blob equal to HEAD's, `git diff
HEAD` empty): `AssertionError: graduated D2 conversion(s) named by no D3
entry of their own step: protocol 18:
object-tenancy-organization-field-removed. Remedy: add a D3 semantic
entry of that step …`, `Tests 1 failed | 140 passed`.

**Body.** Table 1: `integration/Connector:connectionTimeoutMs` moved
from row 16 to row 20. Its own comment names
`connector-connection-timeout-ms-removed`; the permission id appears
there only as a comparison. The code was already right.

## Sibling PRs

- **PR objectstack-ai#20238** (objectstack-ai#20161) has since LANDED as `6a6a17b62`, with the D2
conversion `report-joined-chart-removed` and
`18.ui-report-joined-chart-retired.ts`, which names that id. The union
of this head with `main` at `6a6a17b62` is clean and passes the pin (37
pairs, 0 unnamed; delta review `5859315908`).
- `main` was merged three times with `os-regen-merge.sh`, and never by
hand in a generated region: at `a70cd62e5` (objectstack-ai#20223 and objectstack-ai#20245), at
`21418c4d2` (`cel-predicate-one-value-comparand-refused` and
`filter-query-face-comparands-refused-at-save`) and at `a930cacea`
(step-17 rationale prose from objectstack-ai#20268). Each is D3-only or prose, with no
new step-18 conversion. Regeneration produced a commit only after the
first merge (`3197fce29`) and changed nothing after the other two. Every
sibling entry id was verified present.

## Verification (head `a930cacea`)

- `pnpm check:issue-citations && node
scripts/check-issue-citations.mjs`, exactly as CI runs it (base
`origin/main`): **exit 0**, 112 citations across 29 files: 106 resolve,
6 cross-repo unjudged, 0 findings. At `3197fce29` the same command
exited 2.
- `pnpm --filter @objectstack/spec build` under the verify lock: ok.
`check:generated`: all 15 generated artifacts up to date.
`check:migration-registry`: current (292 semantic, 214 retired-key, 199
retired-def). `spec-changes.json` and `docs/protocol-upgrade-guide.md`
do not move: they project up to the current protocol major, and step 18
is beyond it.
- `pnpm --filter @objectstack/spec exec vitest run --project local`:
**552 files, 16257 passed, 1 todo**. The `src/migrations/` directory
alone: 3 files, 151 passed.
- `pnpm --filter @objectstack/spec typecheck` (tsc, scripts, test layer)
at `21418c4d2`, the head before the last merge, which brought only
another PR's prose into this diff's files: exit 0, test-typecheck debt
unchanged (53 files / 255 errors / 142 signatures).
- `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at
`a930cacea`, every command run and its exit recorded, reconciled with
`--ran`: **89 derived, 87 run (all exit 0), 2 NOT MEASURED**.
`check:dual-build-cjs-loads` and `check:type-check-debt` exited 3
(PREREQUISITE NOT MET: the full 86-package workspace build does not fit
the foreground cap on this shared box). CI runs both.
`check:pm-dispatch-gates` finished this time: exit 0, in 907.6 s.
- ESLint, narrowed and proven: all 31 changed `.ts` files,
`--no-inline-config --format json`: 0 errors, 0 warnings, none reported
ignored. `eslint.config.mjs` enables no type-aware linting (its own
statement at `eslint.config.mjs:326–328`), so this diff cannot move any
untouched file's verdict.
- Changeset: `@objectstack/spec` `patch`. The published registry text
changes; no accept set moves.

## Acceptance notes (observed, not filed)

- `registry.ts:108` (released step-17 text) still says the sharing-rule
`full` conversion 「leaves no semantic residue」: triage scoped step-17
backfill out, so it is left as is.
- New entries keep tracker numbers in their `//` comments only, never in
the strings an author is shown (AGENTS.md runtime-strings rule). Several
older entries do cite numbers in `reason`; not touched.
- `dashboard-refresh-interval-unit-in-key` states the console renderer's
release lag as a verification step, not as a present fact: this
container has no objectui checkout at the pin to measure it.
- `main` moved after the last merge (`a930cacea`). objectstack-ai#20285 (`2aa25efb4`,
prose in five semantic entries) and objectstack-ai#20238 (`6a6a17b62`, a new step-18
conversion with its entry) landed under `migrations/` and
`conversions/`. The delta review merged this head onto `6a6a17b62`:
clean, with all six regions still mirrored. The queue verifies the
merged generation. (Corrected by the seat at 2026-09-27T19:57Z; the
earlier wording said nothing under those paths had moved.)

---
_Generated by [Claude
Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…s (ADR-0049) (objectstack-ai#20286)

Fixes objectstack-ai#20230
Clause-②: no (narrowing)

## What this does

Retires the flattened view overlay's `owner` and `hidden` keys under
ADR-0049 enforce-or-remove. Triage direction on the card (comment
5856621469), verbatim: 「follow objectstack-ai#20085's disposition for the same key
pair」. PR objectstack-ai#20227 retired the same pair on the view item record; this PR
retires it on the other door, with the same prescription texts.

The overlay door is the lean `PUT /api/v1/meta/view/:name` body with no
`config`: members 3 and 4 of the `view` union
(`VIEW_METADATA_MEMBERS.listOverlay` / `.formOverlay`), built from
`flattenedViewOverlayFields()` in `packages/spec/src/ui/view.zod.ts`. It
declared both keys, the write door accepted them, and `saveMetaItem`
stored them verbatim. Nothing read either one. After this PR, every door
that parses an overlay refuses both keys with the prescription. A stored
overlay row that holds either one is stripped on read:
- a row with other view keys is valid again and re-saves;
- a hide-only row (`{ object, viewKind, hidden: true }`) is left
identity-only, which the door refuses. It is badged invalid, refused on
a whole-row re-save, and reported `failed` by `os migrate meta --stored
--apply` until it is deleted or given the setting its author meant.

The D2 docblock, the D3 entry and the changeset all state this, and it
is pinned.

## Stop valve: the writer census, taken first

Taken before any edit, each reading with a lit control on the same ref.
No real writer was found, so the retirement proceeds.

| where | ref | writers of overlay `owner` / `hidden` | lit control |
|---|---|---|---|
| objectui at the `.objectui-sha` pin | `f8a9d0fb` | 0. All 12 view
write call sites were read one by one (the `persistViewPatch` toolbar
path, `updateView` / `updateViewConfig` / `createView` in the data
adapter, `viewEnvelope` saves, and the two `PublicFormsPage` saves).
None writes either key. The toolbar's overlay keys are
`VIEW_OVERLAY_OWNED_KEYS` = `rowHeight`, `sort`, `hiddenFields`,
`columnState`, `inlineEdit`. The switcher writes `label` and `isPinned`.
| 8 `persistViewPatch` call sites writing the owned keys; 2 `updateView`
row-key writes |
| objectui `main` | `6cf5999` | 0 (same 12 call sites, same reading) | 7
`persistViewPatch` call sites; 2 row-key writes |
| objectstack `packages/**`, `examples/**` | `e46218674` | 0. Examples
author no `viewKind` at all, and no view-level `hidden` / `owner` in 10
view files. No framework source writes an overlay body with either key.
| `label:` 88 times in the same 10 example view files |
| HotCRM | `2f7b2326` (= its remote `main`) | 0. No view write call, and
no view-level `hidden` / `owner` in 14 view files. | `label:` 159 times
in those files |
| cloud | not reachable | NOT MEASURED. REST read answered 403 and
`add_repo` was refused for this session. objectstack-ai#20227's census at cloud
`48d70663` recorded no code writer, and one test double that pins a lean
`{hidden:true}` PUT as accepted. That is a test fixture, not a writer.
It goes red on cloud's next spec bump only if it parses through the spec
schema. | — |

Readers, re-checked: `.hidden` / `.owner` reads on a view in
`rest-server.ts` = 0/0 and in `metadata-manager.ts` = 0/0. The 5
`.hidden` reads in `metadata-protocol/src/protocol.ts` are all
field-level. Control: `.order` is read 2 / 1 / 2 times in the same three
files.

## Dispatch assumptions, measured

1. The two keys were at `view.zod.ts:5284-5285` on `e46218674`, and
`flattenedViewOverlayFields(kind)` takes a `kind` argument. **Held.**
Only those two keys move.
2. The `retiredKey()` tombstone applies. **Held.** Both overlay members
`.strip()`, and a `z.never()` member refuses loudly instead of
stripping: the pins below assert issue code `invalid_type` at path
`[key]`, carrying the prescription.
3. A D2 conversion is owed. **Held, and the view-item entry does not
cover it.** `view-item-owner-hidden-removed` skips any body without a
`config` dict, and its own fixture pinned an overlay's `hidden: true` as
kept. This PR adds a separate entry, disjoint by `config`.
4. Other `view.zod.ts` regions were not touched: no edit in
`FormViewSchema.layout`, `ViewMetadataParsed` or `diagnoseViewMetadata`.

## The route

- **Tombstones.** `owner: retiredKey(VIEW_ITEM_OWNER_RETIRED)` and
`hidden: retiredKey(VIEW_ITEM_HIDDEN_RETIRED)` in
`flattenedViewOverlayFields()`. These are the view item's own constants,
so both doors answer with the same text, as the order asked. A pin
asserts the overlay's issue message is byte-equal to the view item's.
- **D2 `view-overlay-owner-hidden-removed`** (`toMajor: 18`,
`retiredFromLoadPath: true`, lossless `stripKeys`). Scope: the flattened
spelling, meaning a body with no `config` and no container slot. It
walks `views` (stack sources, and every stored row, which
`convertStoredItem` replays before serving or badging) and `viewItems`
(the assembled channel). It does NOT require `viewKind`: a flat row
stored before the objectstack-ai#7741 binding has none until the write path heals it
in, and then the save would refuse the key it still held. It is wired
into `MIGRATIONS_BY_MAJOR[18]`, and the step rationale is extended.
- **Why the D2 matters at runtime, and what it cannot do.** objectui's
`updateView` is a read-merge-write, and `buildPersistedViewBody`
re-sends a saved view whole. A stored row served WITH `hidden` would
make the next toolbar toggle a 422, so the read path strips first.
  - For a content-bearing row, that is the whole story.
- For a hide-only row, the strip leaves identity only, and the door
refuses that (the identity precondition: only identity fields). The
badge turns invalid, a whole-row re-save or a rename (`label` is
identity) answers 422, and `--apply` reports `failed` and leaves the row
as stored. A toggle that adds a real key saves.
  - Remedy: delete the row, or add the setting its author meant.
- **D3 `view-overlay-owner-hidden-retired`** (ruling B on objectstack-ai#17152). It
names its conversion by id in `reason`, which is the shape objectstack-ai#20255's
census pin reads. That pin is now live on `main` and green here. Its
`acceptanceCriteria` state both classes, the hide-only row included. The
view item's pair is a separate family with its own D2 and its own D3
(`18.view-item-owner-hidden-retired.ts`, from objectstack-ai#20255). This PR corrects
that entry's one stale sentence (amendment `5859181450`).
- **`RETIRED_KEYS_BY_MAJOR[18]`**: `ui/ViewMetadata:owner` and
`ui/ViewMetadata:hidden`. The overlay members are not exported.
`ui/ViewMetadata` is the exported door they are reached through, and it
is listed in `unemitted-schemas.baseline.json`, so these rows are
declared, not judged. The retirement test pins them.
- **No liveness row.** The `view` ledger walks the container keys only
(`name`, `label`, `object`, `list`, `form`, `listViews`, `formViews`),
so a row would be an ORPHAN. `check:liveness` is green without one.
- **Generated artefacts.** `check:generated`: all 15 were current, and
there was nothing to regenerate. The four surface ratchets are
byte-identical, which is expected on this route: the def is unemitted.
`spec-changes.json` and the upgrade guide project up to protocol 17, so
no major-18 entry shows there either (the same reading as PR objectstack-ai#20227).
- **Forms / examples / skills / docs.** No form offers either key. There
are zero authorings in `examples/`, `skills/` and `content/docs/`. The
tree-scoped pin below holds that.
- **Changeset.** `@objectstack/spec: minor`, `**BREAKING**`, FROM → TO,
the one-line fix, `Clause-②: no (narrowing)`, ADR-0087 disposition
`registered view-overlay-owner-hidden-removed,
view-overlay-owner-hidden-retired`.

## Pins

The new file is
`packages/spec/src/ui/view-overlay-owner-hidden-retirement.test.ts`
(in-package, local project):

- Both overlay members refuse each key at its path: `invalid_type`, the
path, and the prescription. The `view` door
(`getMetadataTypeSchema('view')`) refuses with `invalid_union`, the
prescription surfaces as the union's message, and the claimed member
locates the key. The assembled channel refuses too.
- CONTROL: the same overlays without the keys pass every door, with
`isDefault` / `order` / `scope` intact and no key grown. The view item
door refuses the pair as well, so the family is closed on both doors.
`defineView` is the container door, not an overlay door.
- D2: a stored row rehydrates clean and then parses at the door, while
the unconverted row is refused. A `viewKind`-less flat row is stripped.
The `viewItems` channel is reached, with each door's key stripped by its
own entry. Containers are left alone. Idempotence: the second replay has
0 notices and returns the same reference. Load path: a live author is
refused, not rewritten.
- Registration: the two keys, the chain id, and one D3 record for the
conversion. Any other entry naming the conversion must also name
`view-overlay-owner-hidden-retired`, so it is a pointer, never a second
record.
- **Hide-only residue** (patch round 1):
- A stored `hidden`, `owner` or both row strips to identity only. The
door refuses it with the identity precondition's own text, as one custom
issue at the root rather than the prescription.
  - A rename (`label`) is refused.
  - Controls `isDefault` / `order` / `columnState` save.

The ADR-0112 envelope is pinned at the door that produces it.
`packages/metadata-protocol/src/protocol.save-union-issues.test.ts` adds
a describe block over the existing stub-engine harness (no new double).
For each key and each family, `saveMetaItem` rejects with `code`
`INVALID_METADATA` and `status` `422`, persists 0 rows, and carries an
issue located at the key with the prescription. CONTROL: the same bound
overlays without the keys save, 1 row each.

Patch round 1 adds two pins here:
- **Save door:** a whole-row PUT of the stripped hide-only row answers
`INVALID_METADATA` / 422, with 0 rows and "only identity fields". The
same row plus `isDefault` saves.
- **Read path:** `getMetaItem` over a seeded row serves a stored overlay
without `owner` / `hidden`. `_diagnostics` is valid when the row carries
content and invalid when it is hide-only. The existing harness gains an
optional seed; its default is unchanged.

## Flipped pins: repo-wide sweep, each one load-bearing

The sweep grepped every test file that spells `viewKind` beside `hidden`
/ `owner`, in all packages.

| pin | before | after |
|---|---|---|
| `spec/ui/view-item-owner-hidden-retirement.test.ts` BOUNDARY | an
overlay with the keys parses | refused, with the SAME prescription |
| same file, conversion test | overlays left alone | the overlay key is
stripped by `view-overlay-owner-hidden-removed`, the record key by the
view-item entry (asserted as pairs) |
| same file, tree-scoped matcher | record spelling only | both spellings
(`viewKind` + a retired key); anti-vacuity cases for an overlay (TS,
YAML) and a container |
| `spec/conversions/registry.ts` view-item fixture | overlay neighbour
kept `hidden: true` | the neighbour carries neither key, which keeps the
fixtures disjoint once the overlay entry replays |
| `spec/ui/view-metadata-schema.test.ts` | `a hide PUT` accepted;
`identity + hidden` accepted | the hide PUT is refused at the member
with the prescription (not by the precondition); identity + a live key
is accepted, identity + `hidden` refused |
| `spec/ui/view-union-diagnostics.test.ts` | `overlay.list.aux` (with
`hidden`) and `put.hidden` ACCEPTED | moved to REFUSED, plus
`put.owner`; a new test asserts those rows are refused BY the tombstone
(the prescription, `invalid_type` at the key) |
| `spec/conversions/view-spelling-walk.test.ts` | the overlay's `owner`
survives conversion | `owner` stripped, with the notice under the
overlay entry; every binding key still survives |
| `metadata-protocol/src/metadata-diagnostics.union-issues.test.ts` |
`{hidden, object, viewKind}` badged `valid: true` | badged invalid, with
the prescription at `hidden`; a live key is badged valid |

## Verification

**Patch round 1, final head `a05b32f8b`**, merged with `origin/main` at
`4e0f72e8d`, which carries objectstack-ai#20238, objectstack-ai#20255 and objectstack-ai#20244 (dev report
`5860055944`):
- spec `--project local`, full: 553 files / 16341 tests.
- The touched pins plus `migrations.test.ts`, with the census pin shown
verbosely: 6 / 530.
- The repo view-item pin: 18/18.
- `turbo build rest^...`: 24/24.
- metadata-protocol save-door + diagnostics: 2 / 40.
- Typecheck spec + metadata-protocol: exit 0.
- `check:generated`: 15/15 current.
- Gates: 88 derived, 86 run and exit 0, 2 NOT-MEASURED
(`check:dual-build-cjs-loads`, `check:type-check-debt`: exit 3,
PREREQUISITE NOT MET), 0 UNRUN.
- Ablation (round 1, at `e38a8027b`): the overlay strip replaced by
`return view` → 6 red (the residue, stored-row, `viewKind`-less and
`viewItems` pins) / 18 green. The restore was proven by blob == HEAD and
an empty `git diff HEAD`.

The round-0 readings below are at `2a40c104c`.

Round 0: final head **`2a40c104c`**. That is after merging `origin/main`
at `17bd3187`, which carried objectstack-ai#19920's `view.zod.ts` /
`assembled-views.zod.ts` type change. Heavy runs went through
`scripts/pm/os-verify-lock.sh`, and every exit code was written to disk
before its log was read. The box was shared, with lock waits of 3–9 min,
so wall-clock readings are contended.

| run | head | reading |
|---|---|---|
| `turbo run build --filter='@objectstack/rest^...'` (spec + the
consumer closure) | `2a40c104c` | exit 0, 24/24 tasks |
| `pnpm --filter @objectstack/spec check:generated` | `2a40c104c` | exit
0, all 15 artifacts current; nothing regenerated |
| spec `--project local`, full | `2a40c104c` | 553 files / 16275 tests
passed |
| spec `--project repo`, `view-item-owner-hidden-retirement.test.ts`
(tree-scoped pin) | `2a40c104c` | 18/18 passed |
| metadata-protocol, the edited files +
`view-write-path-identity.test.ts` | `2a40c104c` | 3 files / 41 tests
passed |
| typecheck: spec (`tsc` + scripts + `check:test-typecheck`), lint,
metadata-protocol | `2a40c104c` | exit 0 ×3 |
| consumers, full: metadata-protocol / lint / metadata; objectql and
rest (their 24 / 13 view files) | `cbc81c574` | 189 files / 2720 tests
(3 skipped) · 110 / 4262 · 54 / 821 · 24 / 380 · 13 / 191, all exit 0 |

**Reverse verification** (a one-shot probe removed by an EXIT trap,
verified absent afterwards):
`packages/lint/src/zz-issue20230-dts-probe.ts` typed `{ object,
viewKind: 'list', hidden: true }` as `ViewMetadata`, against the REBUILT
spec `.d.ts`. `@objectstack/lint` `tsc --noEmit` exited 2:
`src/zz-issue20230-dts-probe.ts(2,14): error TS2322: Type '{ object:
string; viewKind: "list"; hidden: boolean; }' is not assignable to type
'ViewMetadata'.` With the probe removed, `git status` showed 0 lines and
`lint typecheck` exited 0. Predicted direction: red. Observed: red.

**Ablation** (`scripts/ablation-replace.mjs`, on committed state, wrap
mode). The mutation swapped the overlay's `hidden:
retiredKey(VIEW_ITEM_HIDDEN_RETIRED),` for `hidden:
z.boolean().optional(),`: anchor 1 → 0, blob `1f93b520` → `e9ad3dec`.
Three spec files then read 10 failed / 139 passed, and the 10 are
exactly the overlay `hidden` pins: both members, the same-text pin, the
door, the assembled channel, the hide-PUT refusal, the identity pin, and
the three union-diagnostics rows. The `owner` pins stayed green, as they
should. The restore brought the blob back to HEAD `1f93b520`, with `git
diff HEAD` at 0 bytes and `git status --porcelain` at 0 lines. Predicted
direction: red. Observed: red. (The metadata-protocol save-door pins
resolve spec through `dist/`, so they were not part of this ablation.)

**Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `2a40c104c` derived 88 commands. All 88
ran with the exit code captured before any pipe, and were reconciled
with `--ran`: **88 derived, 86 run, 2 NOT-MEASURED, 0 UNRUN**. All 86
measured commands exited 0. That includes `check-adr-0087-registration`
(`registered view-overlay-owner-hidden-removed,
view-overlay-owner-hidden-retired (new here …)`,
`[BREAKING+bang+clause-②-narrowing]`), `check-changeset-no-major`,
`check-empty-changeset`, `check:nul-bytes`,
`check:cross-package-test-inputs`, `check:doc-authoring`, and the spec
`check:*` family (`check:authorable-surface`, `check:liveness`,
`check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`,
`check:api-surface`, `check:docs`).

NOT MEASURED (exit 3, `PREREQUISITE NOT MET`; each reads built output of
the whole workspace, which was not built locally): `pnpm
check:dual-build-cjs-loads`, `pnpm check:type-check-debt`. This diff
touches no package entry point, export or tsconfig. CI's build lanes
measure both. Also owned by CI: `pnpm lint`, the remaining objectql /
rest suites, and the lanes `dispatch-gates` lists outside the derived
total. The CLI `integration` tier does not apply (no `packages/cli`
change).

## Acceptance notes (observed, not fixed here)

_The seat updated this body at 2026-09-27T21:40Z after patch round 1,
per dev report `5860055944`. Reviews: `5859174998` (FAIL at
`2a40c104c`)._


1. **One family or two for D3, and the overlap with PR objectstack-ai#20255.** PR
objectstack-ai#20255 (objectstack-ai#20201, not merged when this opened) adds
`18.view-item-owner-hidden-retired.ts` as the view item family's D3
entry, and a census pin requiring every major-18 conversion to be named
by a D3 entry of its step. This PR's conversion is separate, disjoint by
`config`, so it carries its own D3 entry naming it. That keeps one
record per conversion and no second file under objectstack-ai#20255's filename, which
would be an add/add collision. objectstack-ai#20255 has since landed (`f415bcf18`),
and the census pin is green here at `a05b32f8b`. Its sentence 「A
flattened view overlay keeps its own `owner` and `hidden` …」 is replaced
in this PR (amendment `5859181450`): the overlay pair is a separate
family, with its own D2 `view-overlay-owner-hidden-removed` and D3
`view-overlay-owner-hidden-retired`.
2. **This PR supersedes one sentence of objectstack-ai#20227's pending changeset.**
`.changeset/view-item-owner-hidden-retired.md` says an overlay "still
parses". It is left as landed, because `check-empty-changeset` refuses
an edit to another PR's release note. This PR's changeset states the
supersession instead. The release compiler should read the two together.
3. **Cloud is NOT MEASURED** (above). If its mock-protocol double parses
`{hidden:true}` through the spec, it goes red at cloud's spec bump. That
is a fixture edit there. Carrier: cloud, at its next `@objectstack/spec`
bump.
4. **The assembled channel's refusal loses the branch diagnostics**
(objectstack-ai#20227's acceptance note 4, pre-existing):
`AssembledViewArtifactSchema` is a plain `z.union`. Carrier: none.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… save, and every charted presentation filter judges its nested relations (objectstack-ai#20116) (objectstack-ai#20325)

Fixes objectstack-ai#20116
Clause-②: no (narrowing)

**BREAKING** (an accept-set narrowing at the save doors; the changeset
carries the ADR-0087 disposition `registered
filter-comparand-types-and-widget-nested-slots-refused-at-save`).

Stage 2 of the save-door ↔ query-face parity collector: the two open
members of the seat's release `5857575995`. With the report half folded
in (seat answer `5859432781`, after PR objectstack-ai#20238 landed as `6a6a17b6`),
both members are done, and so is the collector. The objectui producer
stage 1 found is carried by objectui#10790, not by this card.

- **M-type — done.** `FilterConditionSchema` now asks the comparand-TYPE
face (`normalizeFilterComparandTypes`) read-only, after the
comparand-shape face, inside the one judge stage 1 built
(`reportQueryFaceRefusals`,
`packages/spec/src/data/filter-save-door-refusals.ts`). A plain object
where a single value belongs, a `Map`, a class instance, a function, a
Symbol, `undefined` and a bigint beyond ±2^53 are refused on save — as
the comparand, an implicit-equality comparand or a list member — at
every reach the save doors have, and nothing the face passes is refused.
- **M-widget — done.** `DashboardWidgetSchema.filter`,
`ReportSchema.runtimeFilter` and `JoinedReportBlockSchema.runtimeFilter`
declare the analytics-carrier filter the two dataset carriers declare,
so each judges the slots INSIDE a nested relation the way the analytics
`where` door does. objectstack-ai#20207's refinement was inline and module-private in
`dataset.zod.ts`; it moved verbatim into
`packages/spec/src/ui/analytics-carrier-filter.ts` (not in the `ui`
barrel), byte-neutral for `Dataset` (measured below). In `report.zod.ts`
only the two `runtimeFilter` lines (and the import) change.

## Zone 2, measured

### 1. Re-probe (base `origin/main` `17bd3187`; spec doors from `src`,
the analytics door from `service-analytics` `src` over a fresh spec
`dist`)

| member | FilterCondition | Dataset.filter | Measure.filter |
Widget.filter | Report.runtimeFilter | JoinedBlock.runtimeFilter | type
face (top) | analytics door |
|:--|:--|:--|:--|:--|:--|:--|:--|:--|
| `{ stage: { $eq: { a: 1 } } }` | ACCEPT | ACCEPT | ACCEPT | ACCEPT |
ACCEPT | ACCEPT | refuse 400 | refuse 400 |
| `{ stage: { $in: [{ a: 1 }] } }` | ACCEPT | ACCEPT | ACCEPT | ACCEPT |
ACCEPT | ACCEPT | refuse 400 | refuse 400 |
| `{ stage: { $eq: Map } }` | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT
| ACCEPT | refuse 400 | refuse 400 |
| `{ stage: Map }` | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT
| refuse 400 | refuse 400 |
| each of the four under `{ acct: … }` | ACCEPT | ACCEPT | ACCEPT |
ACCEPT | ACCEPT | ACCEPT | accept (not descended) | refuse 400 |
| `{ acct: { stage: { $in: ['won', null] } } }` | ACCEPT | refuse |
refuse | ACCEPT | ACCEPT | ACCEPT | accept | refuse 400 |
| `{ acct: { region: ['a'] } }`, `{ acct: { region: { $eq: ['a'] } } }`
(objectstack-ai#20080) | ACCEPT | refuse | refuse | ACCEPT | ACCEPT | ACCEPT | accept
| refuse 400 |
| controls: `$gt: { $field }`, `$gt: Date`, `'{current_user_id}'`, `{
acct: { region: 'NA' } }` | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT |
ACCEPT | accept | accept |

After (src, same probe): every M-type cell is refused at the top level
on all six carriers, at the slot (`stage.$eq`) or the member
(`stage.$in.0`); every nested M-type and M-widget cell is refused on all
five analytics carriers (`widgets.0.filter.acct.stage.$in.1`,
`runtimeFilter.acct.stage.$in.1`,
`blocks.0.runtimeFilter.acct.stage.$in.1`); bare `FilterConditionSchema`
still accepts the nested cells (the face's reach); every control is
accepted.

### 2. One judge

The type face is asked inside `reportQueryFaceRefusals`, the function
both walks call, exactly where stage 1 asks the shape face — so it
reaches the shared walk (`checkFilterConditionComparands`) and the
analytics carriers' nested walk at once, with no second walk. The judge
raises ONE issue per slot, the first the query doors give in their order
(shape face → type face → flag rule; `parseFilterAST`, the engine seam
and `normalizeWhereComparands` all run them in that order). At the top
level and in the combinators, where a face refuses a slot, the shared
walk's own `objectstack-ai#19514` `$icontains` and `objectstack-ai#8793` preset arms stay silent on
that slot. Inside a nested relation on an analytics carrier they do not:
those two arms still judge nested slots beside the carrier walk's faces,
so a nested `$icontains` with a type-refused comparand, or a nested
one-bound `$between` of a preset name, carries two issues (the review
measured 303 such cells new at the head). No verdict moves either way;
the changeset and the entry say exactly this.

What the type face "judges only at request time" was measured: nothing.
The face is context-free (`context` is a message prefix only). The
request-time values are `{ $field }` references (the face steps around
them), `Date`s (accepted), and `{placeholder}` strings such as
`{current_user_id}` / `{today}` — strings at save time, resolved by
`resolveWhereTokens` only AFTER both faces have run on the engine seam.
Each is pinned accepted-and-kept (§4 controls), plus a bigint within
2^53, which the save door keeps as written (the face would narrow it on
a query).

Two classifications follow the type face rather than restating it: a
field value is a comparand unless it is a PLAIN object (prototype
`Object.prototype` / `null`), so `{ stage: new Map() }` reaches the face
instead of being walked as an empty nested relation; and the whole field
entry is shown to the face first, so a spec it classifies as a `{ $field
}` reference is stepped around whole, as the face does (pinned as a
control).

### 3. The dashboard and report carriers — extraction, byte-neutral

- The move is its own commit (`dfa424f6`), verbatim; `DatasetSchema` /
`DatasetMeasureSchema` call `analyticsCarrierFilter()` as before.
- `z.toJSONSchema` of `DatasetSchema`, `DatasetMeasureSchema`,
`DashboardSchema` and `DashboardWidgetSchema`: sha256
`246850f2b9efdf1b…` at base `17bd3187`, at the extraction commit, and
after the widget carrier — byte-identical. With `ReportSchema` and
`JoinedReportBlockSchema` added, the six projections hash
`35ba34f964bb8fd6…` both before the report fold (`6a0cfb05`) and after
it. The parse probe is identical base vs extraction.
- `dropped-refinements.baseline.json`: the `ui/Dataset` and
`ui/DatasetMeasure` rows are unchanged. The merge of `6a6a17b6`
conflicted only in this ledger's measured header; main's side was taken
and the branch's sites re-added from build-schemas' own printed
"corrected entries" (no hand-picked site): `ui/DashboardWidget`
`filter`, `ui/Dashboard` `widgets.element.filter`, `ui/Report`
`runtimeFilter` and `blocks.element.runtimeFilter`,
`ui/JoinedReportBlock` `runtimeFilter`, and the same three positions in
the four installed-package envelopes — +17 sites, 0 removed,
`measured.droppedRefinementSites` 605 → 622.
- Pin: §7 of the parity test asserts the carrier projects to exactly
`FilterConditionSchema.optional()`'s JSON Schema, and that the widget
filter and the report `runtimeFilter` keep their published descriptions.

### 4. The enumerating pin

`filter-save-door-face-parity.test.ts`, extended, not duplicated:

- §1 `queryFacesRefuse` now asks all three rules. The operator arms
still derive from `FieldOperatorsSchema`'s keys, and a new assertion
requires the type face to judge EVERY declared operator over the battery
(its own test reconciles its scalar/list split against the same
vocabulary). The battery gained the type face's shapes and neighbours
(Map, class instance, function, Symbol, `{ $field: 5 }`, `{}`, bigints
within and beyond 2^53, lists holding a plain object / Map / `undefined`
/ big bigint, a plain-object `$between` bound, bigint pairs).
- §5 runs the operator × comparand table and the implicit slot, one and
two hops down, on every analytics carrier — dataset `filter`, measure
`filter`, dashboard widget `filter`, report `runtimeFilter`, joined
block `runtimeFilter` (the two report rows were `EXPECTED_OPEN` until
the fold and now sit in `CARRIERS`), plus a pin that the carrier list is
exactly those five.
- §6 walks the type face's own conformance table
(`FILTER_COMPARAND_TYPE_CASES`): every `door-refusal` row is refused on
save; every `matches` / `compiles` row is accepted AND kept as written.

### 5. Producer census (narrowing), with lit controls — 0 hits

| corpus | object in a scalar slot | object list member | bigint literal
| `undefined` under an operator | `new X` under an operator | nested
relation holding a list / operator map in a filter |
|:--|:--|:--|:--|:--|:--|:--|
| objectstack `examples/**` @ `eaf7a925` | 0 | 0 (control: `$in` lists
3) | 0 | 0 | 0 | 0 (control: filters with an operator-map first entry
14) |
| objectstack non-test `packages/**` @ `eaf7a925` | 25 raw, all prose /
driver `case` labels / the type face's own table (control: `$field` in a
scalar slot 67) | 26 raw, all prose / `$field` members / `{placeholder}`
strings (control 238) | 3, prose | 31, prose (control: `null` 83) | 6,
`new Date` / the table | 0 (control 162) |
| objectui @ pin `f8a9d0fb05` | 0 (control 1) | 0 (control 12) | 0 | 1,
a comment | 1, a refusal message | 0 (control 13) |
| cloud `main` @ `96eb092fbf` | 0 (control 2) | 1, a comment (control
11) | 0 | 0 | 0 | 0 (control 14) |

Plus a runtime walk of every value under a `filter` / `runtimeFilter` /
`where` / `having` / `relatedListFilter` key in the loaded example
stacks, old door vs new door on each: `app-crm` 8, `app-todo` 15,
`app-multi-package` 0, `app-showcase` 20 (its metadata modules; its
config needs connector builds) — 0 refused by the new door alone. Lit
control: a planted `{ stage: { $eq: { a: 1 } } }` and a planted nested
`$in` null member fire the detector in every run. No ADR-0087 D2
conversion: nothing to convert.

## The words (changed or new refusal text)

A type-face cell reads the face's own sentence less its ` at where.SLOT`
clause — nothing restated:

> `Filter comparand is a plain object ({"a":1}), which no driver can
compare. A comparison value must be a string, number, bigint, boolean,
null or Date. Refusing rather than guessing: the backends disagreed on
this input (crash / zero rows / silently edited query). The filter was
NOT applied, and an unapplied filter would have returned the UNFILTERED
result set.`

at `filter.stage.$eq`, or at the member (`filter.stage.$in.1`).
`undefined` gets the face's own sentence (`Filter comparand is
undefined. { key: undefined } cannot be told apart from an omitted key,
… Write the null predicate — {"$eq": null} / {"$ne": null} — or omit the
key. …`), a bigint beyond 2^53 its (`Filter comparand is the bigint …n,
whose magnitude exceeds 2^53 — …`). The clause removed is the one the
face was handed (`where` plus this slot), so nothing is parsed out of
the text; if the face ever spells its location differently, the whole
message is reported location included, and §2's "no ` at where.`" pin
goes red.

Nested cells on the widget and report carriers print the same sentence
as their top-level form (stage 1 and objectstack-ai#20207's rule): e.g.
`widgets.0.filter.acct.stage.$in.1` and `runtimeFilter.acct.stage.$in.1`
carry the enforced `$in` slot's null-member sentence.

## Behaviour changes, each pinned

| change | pin |
|:--|:--|
| type-face cells refused on save, top level + combinators, every
`FilterCondition` carrier | parity §1 (all positions), §3 (six
carriers), §6 |
| type-face cells + stage-1 cells + objectstack-ai#20080 lists refused INSIDE a
relation on the widget filter and both report `runtimeFilter`s | parity
§5 (all five carriers) |
| one issue per slot at the top level and in the combinators, shape →
type → flag; the `$icontains` / preset arms silent on a face-refused
slot there (not inside a relation on an analytics carrier) — a dedupe,
no verdict moves; stated at that reach in the changeset | parity §6 "one
slot, one issue" (top level) |
| `POST /analytics/dataset/query` `selection.runtimeFilter` and `POST
/analytics/query` `where` with `{ stage: { $eq: { a: 1 } } }` / `{
stage: { $in: ['won', { a: 1 }] } }`: `400 INVALID_FILTER` → `400
VALIDATION_FAILED` located on the member |
`packages/rest/src/analytics-filter-refusal-envelope.test.ts`
`AT_THE_DOOR` rows + the sibling-schema control |
| request-time values accepted and kept | parity §4 |

## Pin sweep

① Every refusal code and message this touches was grepped repo-wide. The
type face's text is unchanged (only called). The HTTP-door code move has
two routes and both are pinned in the rest file above; no rest / runtime
test sent a type-face cell through a schema door before (grep over
`packages/**` tests outside spec: the plain-object / Map / `undefined` /
bigint comparand hits live in the analytics door's, the drivers',
objectql's engine and read-scope suites, which call the faces directly,
not a schema). Spec pins whose words could move — a flag with an object
/ `undefined` comparand, `$icontains` with a type-refused comparand, a
preset endpoint on a malformed `$between` — have no existing pin. ② The
flipped rest rows assert the substance: status 400, `VALIDATION_FAILED`,
exactly one `details.fields[]` entry at the member, the sentence, and no
` at where.`.

## Tests

Final head `830a071a` (merges `origin/main` `dfd8e398`, then corrects
the dedupe sentence of the changeset and the semantic entry — text
only), everything through `scripts/pm/os-verify-lock.sh`, `VERDICT
command-exit 0`:

- `@objectstack/spec` at `830a071a`: build 0; `check:generated` 0 ("All
15 generated artifacts are up to date"); `typecheck` 0; full suite 587
files / 17031 passed / 1 todo; the parity pin alone 153 passed.
`check-adr-0087-registration` 0 (`[BREAKING+bang+clause-②-narrowing]
registered
filter-comparand-types-and-widget-nested-slots-refused-at-save`) and
`check-changeset-no-major` 0 at `830a071a`.
- At `e9f93902` (review head): spec full suite 585 files / 16988 passed
/ 1 todo.
- At `3d9621a8` (the fold plus the ledger, before the second `main`
merge, which touches no file of this diff's behaviour): spec 585 / 16955
passed; consumer closures built (exit 0); `service-analytics` 129 files
/ 3041 passed; `lint` 111 / 4297; `rest` (`--project local`) 202 / 3664
passed / 1 skipped. `rest` typecheck 0 at `54b99f3c` (the rest file is
unchanged since). The two new rest rows ran by name (`a plain object
where a single value belongs → 400 VALIDATION_FAILED, located on the
member`, `a plain object as an $in member → …`).
- Ablation, through `scripts/ablation-replace.mjs` (WRAP), each anchor
x1 → x0 on disk with the blob changed, each restore proven blob == HEAD
blob and `git diff HEAD` empty (script trap restores on EXIT/INT/TERM).
Parity pin:
- at `3d9621a8`, 153 tests — **report-carrier arm**:
`ReportSchema.runtimeFilter` carrier stripped
(`analyticsCarrierFilter().unwrap().optional()`) → 18 failed;
`JoinedReportBlockSchema.runtimeFilter` stripped → 18 failed; restored →
153 passed (`unwrap` markers on disk after restore: 0);
- at `54b99f3c`, 152 tests — type face off → 48 failed; widget carrier
off → 18; shared walk's Map / class-instance classification off → 6;
nested walk's classification off → 3; one-issue-per-slot `continue` off
→ 1 (direction: MORE diagnostics — two issues at `name.$icontains`);
restored → 152 passed.
  - No leg needs a build: the pin imports spec `src`.
- Gates at `830a071a`: `dispatch-gates --commands` derived 90 on the
actual paths (the 89 of the review head plus `node
scripts/check-issue-citations.mjs`, which main's `7338efe0` now runs
locally); 88 exit 0 — `check:doc-formula-expressions` and
`check:lean-entry-closure` first answered exit 3 (their `formula` /
`objectql` builds were absent in the re-created worktree) and exit 0
after that closure was built under the lock;
`check:dual-build-cjs-loads` and `check:type-check-debt` exit 3
(PREREQUISITE NOT MET: whole-repo build) = NOT MEASURED; `--ran` with
recorded codes: 90 accounted, 88 run, 2 NOT MEASURED.
- ESLint, narrowed and proven, at `830a071a`: `eslint --no-inline-config
--format json` over the 10 changed `.ts` files (count read from the
JSON) → 0 errors, 0 warnings. The population is `eslint.config.mjs`,
which "never enables type-aware linting (no `parserOptions.project`, no
typed `@typescript-eslint` rules) for ANY file", so the diff cannot move
a verdict on an untouched file. Repo-wide `pnpm lint` is CI's.
- `main` moved after `830a071a` too (2 commits at the gate derivation,
none touching what this answer derives from, as `dispatch-gates` read
it).
- NOT MEASURED: `objectql`, `metadata-protocol`, `runtime` and example
suites (no fixture carries a refused shape through a schema door by the
sweep above; CI runs them).

## Hand-written docs the drift check named (github-actions comment
`5860213966`)

Each re-read against this PR's behaviour — the type-face refusals at
save, the widget / report `runtimeFilter` nested-relation refusals, and
the analytics routes' code move for the type face's JSON cells:

| page | verdict | why |
|:--|:--|:--|
| `content/docs/api/data-api.mdx` | unchanged | Its `INVALID_FILTER`
sentences are about the data routes' `?filter` (the engine's normalizer,
not a `FilterConditionSchema` parse), and the `/analytics/query` section
says only that `where` is the `FilterCondition` `find()` accepts — still
true; no sentence names the code a type-face cell gets. |
| `content/docs/api/error-catalog.mdx` | unchanged | `INVALID_FILTER` /
`VALIDATION_FAILED` are defined generically; no sentence claims the
analytics routes answer `INVALID_FILTER` for a plain-object comparand. |
| `content/docs/data-modeling/analytics.mdx` | unchanged | The widget
`filter` and report `runtimeFilter` examples (`$nin` list,
`{current_quarter_start}` placeholder) are accepted by the new doors;
the placeholder paragraph and the "one author-facing shape" section stay
true; nothing says a nested-relation filter saves on those carriers. |
| `content/docs/protocol/objectql/query-syntax.mdx` | unchanged | Its
`FilterConditionSchema` / field-reference / relation-traversal text
stays true: `{ $field: 'col' }` is still accepted, and the page makes no
claim about a plain-object, `Map` or `undefined` comparand passing
validation. |

No release page (`content/docs/releases/**`) names these shapes; none
was touched.

## Acceptance notes

- **Request doors keep two codes for one family.**
`DatasetSelectionSchema.runtimeFilter` /
`AnalyticsQueryRequestSchema.where` carry the shared reach, so a
top-level refused slot answers `VALIDATION_FAILED` at the schema door
while the same slot inside a relation answers `INVALID_FILTER` from the
analytics normalizer. Both 400, both located; not a save door, so not
this collector's. Noted, not filed (carrier: none).
- **File surface**, as amended by the seat (`5859432781`):
`report.zod.ts` (the two `runtimeFilter` carriers only); and, accepted
as the order's own mechanism, `data/filter-save-door-refusals.ts` (stage
1's judge), `ui/analytics-carrier-filter.ts` + `ui/dataset.zod.ts` (Zone
2.3's extraction),
`packages/rest/src/analytics-filter-refusal-envelope.test.ts` (the
HTTP-door pin).
- `GlobalFilterOptionsFromSchema.filter` (a dashboard's options source)
is an engine query, not charted through the analytics door, so it keeps
the shared reach.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…bled (ADR-0049) (objectstack-ai#20343)

Fixes objectstack-ai#20295

Clause-②: no (narrowing)

## Summary

Retires two keys of `RestServerConfig.api` (`RestApiConfigSchema`) under
ADR-0049 enforce-or-remove — triage's grade, verbatim: 「Verdict:
**RETIRE** the 4 keys, by the maintainer's criterion」.

- `api.responseFormat` — the whole block (`envelope`, `includeMetadata`,
`includePagination`): one `retiredKey()` tombstone for the container.
- `api.documentation.enabled` — a tombstone inside the still-live
`documentation` block. Its other members (`title`, `description`,
`version`, `termsOfService`, `contact`, `license`) are untouched: they
belong to the sibling card objectstack-ai#20294.

Both keys were parsed, defaulted and copied into `RestServer`'s config
by `normalizeConfig`, and nothing read them back. `envelope: false`
unwrapped no response. `documentation.enabled: false` turned no document
off, because `api.enableOpenApi` decides that mount. Now each key
carries its prescription at the schema, `RestServer` construction
refuses it (so does the REST plugin's `start`), and `normalizeConfig`
neither forwards nor re-defaults it.

## Accept / refuse changes — every one pinned

| Door | Input | Before | After | Pin |
|:--|:--|:--|:--|:--|
| `RestApiConfigSchema` | `responseFormat: {…}` — `{ envelope: false }`,
the old defaults, `{ includePagination: false }`, `{}` | accepted, inner
defaults filled | refused: `invalid_type` at `['responseFormat']`,
message = prescription below |
`packages/spec/src/api/rest-api-config-dead-keys-retirement.test.ts` |
| `RestServerConfigSchema` | `api.responseFormat` | accepted | refused
at `api.responseFormat`, same prescription | same file |
| `RestApiConfigSchema` | `documentation.enabled: false` / `true` (the
old default) | accepted (default `true`) | refused: `invalid_type` at
`['documentation', 'enabled']`, prescription below | same file |
| `RestServerConfigSchema` | `api.documentation.enabled` | accepted |
refused at `api.documentation.enabled` | same file |
| `new RestServer(…)` and `createRestApiPlugin(…).start` | either key |
constructed; the key was ignored | throws; the message names
`api.responseFormat` / `api.documentation.enabled`,
`RestApiConfigSchema` and the prescription |
`packages/rest/src/rest-api-config-dead-keys-refused.test.ts` |
| `RestApiConfigSchema` | `documentation: {}` | parsed to `{ enabled:
true, title }` | parsed to `{ title }` | spec retirement test (control),
`rest-server.test.ts`, `rest-config-parse-not-cast.test.ts` §D |
| TypeScript `RestApiConfig` (input) | either key | object / boolean |
`never` (a tsc error at the authoring site) | two `@ts-expect-error`
pins, held by `check:test-typecheck` |

Unchanged, pinned as controls: every live key of the `api` block parses
as before, including `documentation`'s other members; a config without
the two keys mounts the same route set (`mounted({ documentation: {
title, description } })` equals `mounted({})`); `enableOpenApi: false`
removes exactly `GET /api/v1/docs` and `GET /api/v1/openapi.json`.

### Refusal texts (verbatim; both are also the new `.describe()` text,
prefixed `[REMOVED] `)

> `api.responseFormat` was removed in @objectstack/spec 17.5.0 (ADR-0049
enforce-or-remove) — nothing ever read it: `envelope`, `includeMetadata`
and `includePagination` were parsed, defaulted and copied into the REST
server's config and never consulted, so `envelope: false` unwrapped no
response. Delete the key. Response shapes are fixed, not a server-wide
option: each route answers in the response schema
`@objectstack/spec/api` declares for it, which is what the client SDK
parses and the served /openapi.json describes, so no configuration
changes them.

> `api.documentation.enabled` was removed in @objectstack/spec 17.5.0
(ADR-0049 enforce-or-remove) — nothing ever read it: whether the server
publishes its OpenAPI document is decided by the sibling
`api.enableOpenApi` at the mount, so `enabled: false` turned nothing
off. Delete the key; `api.enableOpenApi: false` is the switch that
leaves the `/openapi.json` document and its `/docs` viewer unmounted.

Construction refusal (unchanged envelope, new lines): ``REST API
configuration is invalid: `api` does not satisfy `RestApiConfigSchema`
(@objectstack/spec/api), the schema that declares it.`` followed by ` -
api.responseFormat: PRESCRIPTION` (or `api.documentation.enabled`).
Describes removed with the keys: "Response format options", "Wrap
responses in standard envelope", "Include response metadata (timestamp,
requestId)", "Include pagination info in list responses", "Enable API
documentation".

No `os migrate meta` sentence in either prescription, on purpose: there
is no D2 conversion for the command to list (see below), matching the
sibling `crud.*` / `metadata.*` / `batch.*` tombstones on this same
file.

## Premises measured before editing (origin/main 4e0f72e)

1. **Nothing reads the four keys.** `packages/**` non-test code: 0 reads
— the only code sites were `NormalizedRestServerConfig`'s type and
`normalizeConfig`'s own write. Lit control on the same instrument:
`enableOpenApi` finds its read at `registerRoutes`. A spread /
whole-block-destructure sweep over `packages/rest/src` (non-test)
returns only the parse call and `const { enableProjectScoping,
projectResolution } = this.config.api`. objectui at its pin `f8a9d0fb`:
0 for `RestApiConfig|RestServerConfig`, `responseFormat`,
`includePagination`, `enableOpenApi` (control `basePath` = 184). cloud
at `96eb092`: 0 authoring sites for the same terms and
`documentation.enabled` (control `createRestApiPlugin` = 11; every call
forwards the stack's own top-level `api:` block, whose schema carries
neither key).
2. **Producers.** None outside the kit: the in-repo authors were four
spec test cases, two rest test cases and the `@example` in the schema
docblock; all are converted (the example now shows `enableOpenApi`). No
example app, skill, form, i18n bundle or hand-written doc authors either
key.
3. **Route.** `RestApiConfigSchema` and its inline `documentation`
object are non-strict `z.object()`s ⇒ `retiredKey()` tombstones (a bare
deletion would strip the key in silence, ADR-0104). Ledger rows stay
`dead` with a REMOVED note; `responseFormat`'s three child rows collapse
into its one row, because the tombstone is a leaf and child rows would
report ORPHAN (the `crud.patterns` precedent).
4. **`normalizeConfig`** no longer lists `responseFormat`;
`documentation` still passes through, so the retired `enabled` is
neither forwarded nor re-defaulted (pinned).

## Choices settled here (four axes)

- **`responseFormat` retired as one container tombstone, not three
member tombstones.** Business need: no author and no reader of any
member. Long-term: a container whose every member is retired would keep
accepting `responseFormat: {}` — an empty knob a reader takes for a
capability. AI-safety: one refusal on the key an author actually types;
`{}` is refused too. Scope: one tombstone, one ledger row (the
`crud.patterns` precedent, which also collapsed child rows).
- **The server REFUSES (the `crud.patterns` posture), it does not
`.omit()` and ignore (the `requireAuth` posture).** No boot path or
shipped config writes either key, so nothing chose warn-and-ignore for
them; a silent `.omit()` would recreate the strip this retirement
removes. Ablation C below shows the difference is measurable.
- **A tree-scoped absence pin was added** (the retirement playbook's
default sweep), over the radius `@objectstack/spec` already declares in
`scripts/cross-package-test-inputs.mjs`; no new declaration was needed
(`check:cross-package-test-inputs` green). Its matcher is structural so
that the agent alias map's `responseFormat: 'structuredOutput'` and an
OpenAI-style `responseFormat: { type }` never match.

## The retirement kit

- **Schema** `packages/spec/src/api/rest-server.zod.ts` — two tombstones
with in-schema comments; docblock example converted.
- **REST server** `packages/rest/src/rest-server.ts` — only the
`NormalizedRestServerConfig.api` type (near the old `:1105`) and the
`parseDeclaredApiConfig` / `normalizeConfig` region. None of the regions
objectstack-ai#20319 edited were touched; this branch was rebuilt on main after objectstack-ai#20319
landed.
- **ADR-0087** — `RETIRED_KEYS_BY_MAJOR[18]` gains
`api/RestApiConfig:responseFormat` and
`api/RestApiConfig:documentation.enabled` (one entry file each); one D3
entry for the family, `rest-api-config-dead-keys-retired` (ruling B on
objectstack-ai#17152); no D2 conversion, because a `RestServerConfig` is plugin TS
configuration, never a stack collection member or a stored row. The
generated regions of `registry.ts` are regenerated.
- **Ledger** `packages/spec/liveness/rest_api.json` (both rows REMOVED,
`cross-repo` scope, `_note` addendum), `liveness/README.md` row,
generated `state-counts.md` (`rest_api` 14 → 12 dead, 26 → 24
classified).
- **Generated** `authorable-surface/api.json` (`responseFormat` →
`[RETIRED]`), `content/docs/references/api/rest-server.mdx`,
`docs/audits/2026-07-unknown-key-strictness-ledger.counts.md` (`api/`
432 → 431 sites: the inline `responseFormat` object left).
- **Tests** — the two new pin files above (the spec one runs in the
`repo` project); converted cases in `rest-server.test.ts`,
`rest-config-parse-not-cast.test.ts` §D and one comment in
`rest-api-config-defaults-follow-spec.pin.test.ts`.
- **Changeset** `.changeset/20295-rest-api-config-dead-keys-retired.md`
— `@objectstack/spec` and `@objectstack/rest` `minor`, BREAKING banner,
FROM → TO with the one-line fix, and the ADR-0087 disposition
`registered rest-api-config-dead-keys-retired`.

## Verification — at HEAD `6f07f0c1`

All heavy runs went through `scripts/pm/os-verify-lock.sh`; every exit
code below was captured to disk before any pipe. `6f07f0c1` is
`origin/main` `a78f731a` merged in (after objectstack-ai#20319 landed) through
`scripts/pm/os-regen-merge.sh`, then the rest dependency closure
rebuilt.

- `pnpm --filter @objectstack/rest exec vitest run --project local` —
exit 0, 204 files, 3680 passed, 1 skipped.
- `pnpm --filter @objectstack/spec exec vitest run --project local` —
exit 0, 554 files, 16353 passed, 1 todo.
- `pnpm --filter @objectstack/spec exec vitest run --project repo` —
exit 0, 34 files, 620 passed (the new tree-scoped pin runs here).
- `pnpm --filter @objectstack/spec run typecheck` and `pnpm --filter
@objectstack/rest run typecheck` — both exit 0 (`tsc --noEmit` plus
`check:test-typecheck`; the spec one also `check:scripts-typecheck`).
`check:test-typecheck` green is what proves the two `@ts-expect-error`
pins bite: an unused one would be a new TS2578 signature.
- `pnpm --filter @objectstack/spec run check:generated` — exit 0, all 15
generated artifacts current at this head.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 115 commands for this diff at this
head; `--ran` reconciliation: **115 accounted — 113 run, all exit 0; 2
NOT MEASURED; 0 unrun.**
- NOT MEASURED `pnpm check:dual-build-cjs-loads` — exit 3, PREREQUISITE
NOT MET: it reads every package's `dist/`, and a whole-tree build is
outside this card's local scope (CI's Build Core builds it).
- NOT MEASURED `pnpm check:type-check-debt` — its `--re-measure`
rebuilds every package and re-runs tsc over every DEBT / EXEMPT entry;
this diff touches no DEBT or EXEMPT package (CI's Lint job). `pnpm
check:type-check-coverage` ran, exit 0.
- Consumer fixture triage: the tree-scoped absence pin is the sweep — it
walks `packages`, `examples`, `skills`, `content` and `scripts` and
finds no `api` block authoring either key anywhere (anti-vacuity: more
than 1000 files visited, more than 5 of them spell `responseFormat`).
The near consumers that parse or type this config were also run:
`@objectstack/core` `src/qa/http-adapter.test.ts` (it parses
`RestApiConfigSchema.parse({})` for the route prefix) — exit 0, 32
passed; `@objectstack/client` `client.data-prefix.test.ts` +
`client.metadata-prefix.test.ts` (they type a `RestServerConfig`) — exit
0, 13 passed. The rest of the downstream closure (`pnpm --filter
'...^@objectstack/rest'`: cli, runtime, hono, the examples, …) was NOT
rebuilt or run locally, by the retirement playbook's rule that the
absence pin, not a consumer-closure rebuild, is the sweep; CI runs them.

### Reverse verification (one-time; each mutation through
`scripts/ablation-replace.mjs`, restored and proven by blob == HEAD and
an empty `git diff HEAD`)

Run at `2e575f15` (this branch before the second merge of main; the
merge touched none of the three mutated files). Every direction observed
is the expected one: red.

- **A — the schema tombstone.** `responseFormat: retiredKey(` →
`responseFormat: z.any().optional() ?? retiredKey(` in
`rest-server.zod.ts` (anchor 1 → 0, blob `32ebfe55` → `bfbb9755`). Spec
retirement test: **6 failed | 10 passed** — the four `responseFormat`
door pins, the whole-config door, and the tsc pin's parse leg. Restored
(blob == HEAD); control rerun 16/16.
- **B — the tree-scoped pin.** First attempt was a NO-OP and is not
counted: its replacement contained the anchor, so `ablation-replace`
refused (anchor 1 → 1), restored, and never ran the test. Redone with a
disjoint spelling: `responseFormat: { envelope: false } as never,`
planted before `enableDiscovery: true,` in
`packages/spec/src/api/rest-server.test.ts` (anchor 1 → 0, blob
`794c1326` → `0766f4e0`). **1 failed | 15 passed** — the absence pin,
naming `packages/spec/src/api/rest-server.test.ts:644`. Restored (blob
== HEAD).
- **C — the server's parse.** `RestApiConfigSchema.omit({ requireAuth:
true })` → `.omit({ requireAuth: true, responseFormat: true })` in
`rest-server.ts` — the silent-strip posture (anchor 1 → 0, blob
`abf25fa0` → `e93b894a`). Rest refusal test: **3 failed | 5 passed** —
the `responseFormat` refusal, its no-`api.version` positive control and
the plugin path; the `documentation.enabled` pins stay green, as they
must. Restored (blob == HEAD); control rerun 8/8.

After all four legs: `git diff HEAD` empty, each blob equal to its HEAD
blob. The rest test's subject is `./rest-server.ts` (source, no alias
hop), and the spec test's subject is `./rest-server.zod.ts` (source), so
no `dist/` preflight applies.

## Acceptance notes

- **`packages/rest/CHANGELOG.md`** is in the card's file surface but is
release-owned (AGENTS.md Documentation Guardrails): not edited; the
changeset is its input.
- **`.changeset/14640-rest-api-liveness-ledger.md`** (pending, another
card's) says `documentation` and `responseFormat` "are accepted,
validated and normalized, and change nothing" — true when it landed; not
edited here. The release compiles it next to this changeset.
- **Clause-② value.** The claim carried `Clause-②: yes`, from triage's
execution note. Measured on this diff: no accept set widens and no
export is added; the only additions are ADR-0087 ledger registrations,
which the two nearest retirements (objectstack-ai#20227, objectstack-ai#20238) declared under `no`.
On that measurement the seat answered `no (narrowing)`, and this body
and the changeset carry it. The gate readings are unchanged (`minor`,
declared breaking, `registered`).
- **Observed, not filed (dormant):**
`RestApiPluginConfigSchema.responseEnvelope` in
`packages/spec/src/api/plugin-rest-api.zod.ts` is a second declared
envelope toggle. The spec schema has no runtime parser (`packages/rest`
declares its own `RestApiPluginConfig` interface) and is not enrolled in
any liveness ledger. No reach was measured, so nothing is filed.
Carrier: none.
- No governed surface is touched (`docs/audits/**` is not on the
register).

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/l tests tooling

Projects

None yet

2 participants