fix(spec): a joined report draws no chart — retire blocks[].chart and refuse a joined container chart (#20161) - #20238
Conversation
…er refused) Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…geset Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…proof 4) Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…s.chart Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…ort's chart Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…he block chart row Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…ined-report-chart-retired
… fail Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: 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 (b) Refusal correctness: HOLDS.
(c) ADR-0087 route.
(d) Scope: every addition is forced or pin-forced except two judgments, neither a runtime expansion.
(e) Riders: HOLD.
② Semver levelNarrowing ( ③ Boundary flagsBlocking: the ruling-B D3 CI at this head: 35 check-runs, 33 success, 2 skipped ( Implemented-by: 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>
Contract reviewServed-tier: 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 ① Derived judgments(a) D3 entry
(b) Merge
(c) Nothing else moved: HOLDS. ② Semver levelUnchanged: ③ Boundary flagsBlocking: none. CI at this head: 35 check-runs, 33 success, 2 skipped ( Implemented-by: VERDICT: PASS |
…(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>
…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>
…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>
… 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>
…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>
Fixes #20161
Clause-②: no (narrowing)
A
joinedreport draws each of its blocks as a table. Nothing ever drew a chart on one: not the containerchart, and not ablocks[].chart. Both parsed green, passed thevalidate-chart-bindingslint, and plotted nothing. This PR retires the chart on a joined report, following triage's direction (retire, premise first; ADR-0049 enforce-or-remove):JoinedReportBlockSchema.chartis removed from the closed block shape. Writing it is answered with the upgrade prescription through the block'sguidancetable.charton atype: 'joined'report is refused by the joined arm ofReportSchema's refinement, next to the five keys PR fix(spec): a joined report refuses a top-level dataset / rows / columns / values, pointing each onto blocks[] #20160 refuses. No pointer ontoblocks[]is given, because a block has no chart either.charton every non-joined report is untouched. It is that report's live embedded chart.Premise first: the census came back clean, so nothing stopped the retirement
Triage's stop condition was: "a joined report with a
chartauthored inexamples/**, the showcase, or hotcrm". The instrument is structural. Atsxscript 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 countscharton non-joined reports.chartchartchartexamples/app-showcase+examples/app-todo@a46e3cf53showcase_hours_by_status_chart)src/**/reports/*.report.ts@2f7b232The other example apps (
app-crm,app-multi-package,embed-objectql) author no reports.type: 'joined'has 1 hit inexamples/and 1 in hotcrm, and both are the reports counted above. Both joined reports still parse under the new schema.Measured before changing anything
main(1c8b320a8, after PR fix(spec): a joined report refuses a top-level dataset / rows / columns / values, pointing each onto blocks[] #20160's2bbebf532):JoinedReportBlockSchema.chartis atreport.zod.ts:223-224, andReportSchema.chartis at:400. The joined arm refuseddataset/rows/columns/values/order, and notchart. The filer's:381was pre-fix(spec): a joined report refuses a top-level dataset / rows / columns / values, pointing each onto blocks[] #20160 numbering..objectui-shapinf8a9d0fb0596f4521076628e2bbfe27e6ce67d52(a read-only shallow clone).DatasetReportRenderer.tsx:1462-1524is the joined branch. It draws each block as aDatasetMatrixTable/DatasetReportTableand returns. The onlyreport.chartread is at:1557-1560, after that return.git grep -F "block.chart"over objectuipackages/exits 1, while the controlblock.runtimeFilterhits (DatasetReportRenderer.tsx:1473). So the filer's reading holds.validate-chart-bindingsresolved 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 atreports[0].blocks[0].chart.xAxis. It now checkschartonly 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 withchart-dataset-unknown. So what this lint says, or leaves unsaid, reaches the publish path.sys_metadatarows. The Studio report form offered a blockchartinput (reportForm's blocks repeater) until this change, so a stored row can carry one.applyConversionsToStoredItemreplays retired entries, so a D2 strip heals such a row at rehydration instead of refusing it on its next save.chart-config-aria-removedmade the same choice for the same carrier (report.blocks[].chart.aria). The delete is lossless, because neither value was ever drawn. The entry isretiredFromLoadPath: true, so a live author is refused at parse, never rewritten. The strict-route removal is proven bybuild-schemascheck (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 itsstrictObjectdeclaration owes it".What changed
packages/spec/src/ui/report.zod.ts: removes the blockchartand adds itsguidanceprescription. Adds the container refusal atpath: ['chart']. Fixes the TSDoc rider:ReportSchema.blocksnow describes the dataset-bound block (dataset/rows/columns/values/runtimeFilter/order, with the containerruntimeFilterANDed), not the pre-cutoverobject/filtershape, and the block docblock'sfilteris 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 blockchartrepeater column. The containerchartfield getsvisibleWhen: "data.type != 'joined'".migrations/entries/retired-keys/18.ui__JoinedReportBlock__chart.ts, and the D2 conversionreport-joined-chart-removedinconversions/registry.ts, wired intoMIGRATIONS_BY_MAJOR[18].conversionIdswith the step rationale extended.registry.tswas regenerated withgen:migration-registry.spec-changes.jsonand the upgrade guide are byte-identical, because major-18 entries do not project yet.migrations/entries/semantic/18.ui-report-joined-chart-retired.ts, idui-report-joined-chart-retired. It names the D2 idreport-joined-chart-removedas 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 ofui-report-joined-container-selection-refused: that entry is the enforce arm for four keys that stay declared, with no D2. The changeset marker now readsregistered report-joined-chart-removed, ui-report-joined-chart-retired.chart-config-aria-removed's fixture carried ablocks[].chart. The new entry strips a block's wholechart, so that fixture could no longer equal its ownafterunder full-table replay. Its fixture drops the block leg (expectedNotices3 → 2). Itsapplyis left as shipped. On a stored block chart carryingaria, it runs first and this entry then strips the whole chart, with the same compound result.authorable-surface/ui.jsondropsui/JoinedReportBlock:chart(proof 4 above).content/docs/references/ui/report.mdxis regenerated (check:generated --fix, only the one artifact it proved stale). Theplatform-objectsmetadata-form bundles dropblocks.chartin all four locales (pnpm i18n:extract, pure deletion).packages/spec/liveness/report.json: theblocksrow now names only what the joined branch reads, and states that there is no block chart. Thechartrow now says it is live on non-joined reports only. Both carryverifiedAt: 2026-09-27. The README row notes it.packages/lint/src/validate-chart-bindings.ts: adrawsChartflag, false for a joined container and for every block.content/docs/ui/reports.mdx: the joined section now lists what a block rejects (nestedblocks,drilldown,protection), what the container refuses (dataset, and a non-emptyrows/columns/values/order, pluschart), and what it reads (runtimeFilter,drilldown). The chart section says a joined report refuseschart..changeset/20161-joined-report-chart-retired.md:@objectstack/specminor,@objectstack/lintpatch and@objectstack/platform-objectspatch. It carries the BREAKING banner, FROM → TO, the fix, theClause-②line and the dispositionregistered report-joined-chart-removed.Outside the claimed file surface, and why:
report.form.tsand the four generated bundles are the playbook's forms and i18n rows.conversions/registry.tsis where a D2 entry lives.lint/src/validate-predicate-path-refs.test.ts: the shipped-predicate census went 72 → 73, and the==/!=literal comparisons went 52 → 53, from the newreport :: chartgate. 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.tscarries 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 report5858814205): spec--project local547 files / 16093 passed (2 todo); spec--project repo33 / 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.tshas a new describe with 9 tests: block refused asunrecognized_keyswith the prescription; located at['blocks', 1]under the report; container refused ascustomat['chart']with noblocks[]pointer; one issue per key next todataset/order;defineReportthrows both;getMetadataTypeSchema('report')refuses both; a block under a non-joined container is refused too;chartontabular/summary/matrixround-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, andtsconfigincludessrc/**/*, so the test file is compiled. The new door pins:saveMetaItem({ type: 'report' })answersINVALID_METADATA/ 422 for a containerchart(customatchart) and for a blockchart(unrecognized_keysatblocks.0), and stores nothing. A CONTROL withoutchartis stored.downstream-contractcontract.test.tspassed.consumer-specifier-ledger.test.tsis NOT MEASURED: it refused because the@objectstack/clidist was absent at that moment, which is a prerequisite and not a finding.objectql:metadata-validation-sweepandoverlay-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 theHEADblob with an emptygit diff HEAD; the subjects importsrc/relatively, so nodistis involved):if (r.chart !== undefined)→if ((false as boolean) && …), blobf96fd4d6→8c02bbc8report.test.ts4 failed / 68 passed: exactly the container, all-keys,defineReportand save-door testsguidanceentry deleted, blobf96fd4d6→632c8c5fif (!drawsChart) return;deleted, blob9ececb06→216e5e66dataset(commit94a2c634d). Re-run: 2 failed / 45 passed.The door pins in
metadata-protocolresolve@objectstack/specthrough its builtdist, 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/mainfdb26698fwas merged throughscripts/pm/os-regen-merge.shas5977052dd, with two both-sides-append hunks resolved by hand in hand-authored regions: theCONVERSIONS_BY_MAJOR[18]tail, and the step-18 rationale plus theconversionIdstail. No generated region was hand-merged.check:generatedreports 15/15 current at4aecf01d2.Round 1:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat94a2c634dderived 115 families. All 115 were run, each exit code was recorded, and every one is 0. The--ranreconciliation 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-examplesandcheck:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET: unbuilt packages in this worktree). They were re-run after building those packages.check:type-check-debtwas run on its own, because of its roughly 5-minute runtime.pnpm --filter @objectstack/spec check:generatedreports all 15 artifacts up to date after theorigin/mainmerge (a46e3cf53, throughscripts/pm/os-regen-merge.sh). Sibling registry entries survived:automation-flow-list-route-retired,filter-ne-array-comparand-refusedandapi/FlowSummaryare present at bothHEADandorigin/main.Acceptance notes
charton atype: 'matrix'report withcolumnsparses:ReportSchema.safeParsereturnssuccess: true, probed against the builtdistat94a2c634d. It is never drawn. At the pin,DatasetReportRenderer.tsx:1533returns theDatasetMatrixTablebefore the onlyreport.chartread at:1557. A matrix withoutcolumnsdegrades to the summary branch and does draw it.Seam: spec:ReportSchema.chart (matrix with columns) → renderer:DatasetReportRenderer (matrix cross-tab branch). This is reported to the seat for routing, not fixed, because the direction (draw it on the pivot, or refuse it there) is a separate decision. Filed as [finding] Atype: 'matrix'report withcolumnsand achartparses, and the pinned renderer never draws the chart #20293.JoinedReportBlockinterface (packages/types/src/spec-report.ts:297) still declareschart, along with the pre-cutoverobjectName/groupingsDown/filtershape. It is a TypeScript type, not stored metadata, and the spec'sJoinedReportBlockit would replace is stillunknown(z.ZodTypeAny), which objectui pins. Nobody is assigned to carry this fix.ChartConfig.aria's prescription (ui/chart.zod.ts) still listsreport.blocks[].chart.ariaamong the places the key was authored. That is historically true, but an author who writes it now meets the blockchartrefusal first. Left as shipped. Nobody is assigned to carry this fix.chartcontrol'svisibleWhenrelies on objectui honouring a field-levelvisibleWheninreportForm, the same mechanism thecolumnsrow already uses. This was not browser-verified.type: 'joined'report accepts top-leveldataset/rows/columns/values, and the renderer never reads them; the refinement refuses onlyorder#19856 are cited for context only and remain as they are.type: 'matrix'report withcolumnsand achartparses, and the pinned renderer never draws the chart #20293). The at-tier reviews are5856458831(FAIL at94a2c634d) and5859051314(delta PASS at4aecf01d2).Generated by Claude Code