Repository navigation
feat(spec)!: an object-master-detail-form block's detail entries are a strict shape whose columns are the inline grid column contract (#20928) - #21215
Conversation
…a strict shape whose columns are the inline grid column contract (WIP) ComponentPropsMap['object-master-detail-form'].details becomes a strict entry of the twelve keys objectui's MasterDetailForm reads, with columns referencing InlineGridColumnSchema; defineStack's identity-only column check reaches the block on a page; field-no-consumers reads a detail entry as a child collection. Generated artefacts follow. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…moves dropped-refinements baseline gains the one new site the column schema's refinement drops in ui/ObjectMasterDetailFormProps; api-surface, export-origins, the component reference page and the strictness ledger counts regenerated by check:generated --fix. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…azy body so the alias walk reaches it A standalone lazySchema element is a function-typed proxy the alias-integrity graph walk does not descend into; the subforms entry's inline construction is the shape that walk reaches. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…ster-detail-details-strict
…l entry read set origin/main moved `.objectui-sha` to 31971ff1e28f; MasterDetailForm.tsx is byte-identical at e420df310f5b and 31971ff1e28f, so the twelve-key read set holds at the pin this branch now builds against. Post-merge regeneration: gen:migration-registry and check:generated report no drift. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 7 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 — 137 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 e7e00637e6f3b0e5f5990cb12a9b923e57e10bdc && git checkout e7e00637e6f3b0e5f5990cb12a9b923e57e10bdc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e18fea6dcdf6af5619cae05eb37c38417230d83b bfb26d52e9a14b9f3808510e00aa6400f5c7f9fd && git checkout -B drift-repro e18fea6dcdf6af5619cae05eb37c38417230d83b && git merge --no-ff bfb26d52e9a14b9f3808510e00aa6400f5c7f9fd
node scripts/docs-audit/affected-docs.mjs --json e18fea6dcdf6af5619cae05eb37c38417230d83b
|
Contract reviewServed-tier: Inputs, and nothing else: card #20928 (body, triage 5919655566, claim 5934221277, dev report 5936596153), PR #21215 (body, its 15-file list, the net diff of the branch at the head against ① Derived judgments
② Semver level
③ Boundary flagsDev
Check-runs on the head, read 2026-10-01T17:27Z (the fourth read of this round; the first was 2026-10-01T17:16Z): 28 success, 2 skipped ( Note, not verdict-bearing: the block-level docblock on Implemented-by: VERDICT: PASS Recorded 2026-10-01T17:28Z. Generated by Claude Code |
… is the spec's closed entry, by reference (objectui#11396) `@objectstack/spec` 17.6.0 judges `ComponentPropsMap['object-master-detail-form'].details` as an array of closed entries (objectstack-ai/objectstack#21215): `childObject` required, `columns` the spec's inline grid column, an undeclared key refused by name. `@object-ui/types` already took the whole props row by reference, so both published zod faces give the spec's verdict on an entry; the TypeScript face in `@object-ui/plugin-form`, `MasterDetailDetailConfig`, restated the entry by hand. - `MasterDetailDetailConfig` is derived from the spec's entry type (its `z.input` authoring face) by a type-only import, minus the one member the renderer does not read: the spec still declares `sortField`, which objectui#11070 round 9 retired here; the omission is stated on the type and pinned, and the spec-side retirement stays objectstack's half. Member for member the type is the shape it was, so the published accept set does not move (empty-frontmatter changeset). - The `object-master-detail-form` registration declares `of: 'object'` for `details`, derived from the now single-kind member contract, and a description. - `masterDetailDetailsMembers-8071.test.tsx` holds the config `Equal` to the spec entry minus `sortField`, pins that the spec still declares `sortField`, and that a `columns` entry is the grid's `GridColumn`; its header no longer says the spec row is `z.array(z.unknown())`. - New `packages/types` pin: both faces refuse an undeclared entry key, a missing `childObject`, a bare-string column and an undeclared column key, accept `{ childObject }` and `sortField`, with one verdict across the two doors. - The console parity row for `object-master-detail-form.details` describes the closed entry, the derivation and the `of`. Claude-Session: https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ Co-authored-by: Claude <noreply@anthropic.com>
… is the spec's closed entry, by reference (objectui#11396) (objectstack-ai#11624) Fixes objectstack-ai#11396 Clause-②: no — the published `MasterDetailDetailConfig` keeps its accept set: the derived type is `Equal` to the hand-written interface it replaces (a one-off compile below proves the FROM and TO shapes identical, member for member), so only the type's definition moves, and the changeset is the empty-frontmatter declaration. `@objectstack/spec` 17.6.0 judges `ComponentPropsMap['object-master-detail-form'].details` as an array of CLOSED entries (objectstack-ai/objectstack#21215, `a29a0ea555`): `childObject` required, `columns` the spec's inline grid column, an undeclared key refused by name. `@object-ui/types` already took the whole props row by reference, so both published zod faces give the spec's verdict on an entry. The TypeScript face in `@object-ui/plugin-form`, `MasterDetailDetailConfig`, restated the entry by hand, and the console parity row still said the spec row is `z.array(z.unknown())`. This PR makes the TypeScript face follow the spec entry by reference, makes the prose true, and pins both. Dispatched under claim `5982160146` (`domain:spec`, size S; a re-dispatch of the same claim). Session: `https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ`. ## What changes - `packages/plugin-form/src/MasterDetailForm.tsx`: `MasterDetailDetailConfig` was an `interface` with eleven hand-written members. It is now a type DERIVED from the spec's entry — the element type of `ObjectMasterDetailFormProps['details']`, which is the spec's `z.input` authoring face (what a document writes), imported type-only from `@objectstack/spec/ui` — with ONE member omitted by name, `sortField` (the fork, below). The docblock states the fork and where each member lands in the renderer. The two imports only the interface used (`GridColumn` from `@object-ui/fields`, `InlineMode` from `./deriveMasterDetail`) are gone from this file; every other line is unchanged. The `amountField` → grid `totalField` hand-off is untouched (the same six-key grid object, pinned by row 2 of the members test). - `packages/plugin-form/src/index.tsx`: the `details` registration goes from `{ name, type: 'array', required: true }` to the same plus `of: 'object'` and a description.⚠️ This file is outside the claim's file surface; it is here because the dispatch asked for the `of` decision and the declaration lives on the registration, not on the parity row. The `required: true` is not touched (Acceptance note 1). - `packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx`: the header no longer says the spec row is `z.array(z.unknown())`; the compile-time block gains two rows — `derivedFromSpec` (the config `Equal`s the spec entry with `sortField` omitted, on 17.6.0 and on objectstack `main` alike: the `Omit` subtracts the key whether the entry declares it as a string or as a `retiredKey()` tombstone) and `columnsAreTheGridColumn` (a `columns` entry `Equal`s `GridColumn`) — and keeps `memberList` (the eleven keys spelled out) and the `@ts-expect-error` row on a written `sortField`. Patch round 1 (`999aecb6`) dropped a third row, `specStillDeclaresSortField`, which pinned the 17.6.0 shape and was red against objectstack `main` in the `Spec Main Shape Gate`. The runtime rows are unchanged. - `packages/types/src/__tests__/object-master-detail-form-details-entry-11396.test.ts` (new; 7 `it` blocks, 19 tests: six rows on each of two faces, six cross-door rows, one non-vacuity row): a type-level row that the block's `properties.details` entry `Equal`s the spec's entry; on the tolerant face (`safeValidateSchema`, which `objectui validate` runs) and on the strict authoring face, for a node in the spec's `{ type, properties }` form: `{ childObject }` accepted (control); an undeclared entry key refused at `properties.details.0` with one `unrecognized_keys` naming it; no `childObject` refused with `invalid_type` at that member; a bare-string column refused at `properties.details.0.columns.0`; a column with an undeclared key refused there naming it; `sortField` ACCEPTED (the fork, recorded so that closing it is a deliberate flip). Plus one verdict across the two doors: for each probe, `safeValidateSchema` and the spec's own props row agree on success and on the issue codes at the same paths under `properties`. - `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`: the `object-master-detail-form.details` member-pin prose. Every true sentence is kept, including the camelCase grid sentence objectui#11610 wrote; the last two sentences (the `z.array(z.unknown())` row, the five-of-eleven count, "declares no `of`") are replaced by the closed entry, the derivation, the fork and the `of`. - `.changeset/11396-master-detail-details-entry.md`: empty frontmatter, with a body saying why nothing is released. ## Premise readings (this worktree, base `9dfaca65`, which is `origin/main` at PR time) - After `pnpm install`, `packages/types` and `packages/plugin-form` both resolve `@objectstack/spec` at `17.6.0`; both declare `^17.6.0`. - `ObjectMasterDetailFormPropsSchema.shape.details`, read off the installed schema: an OPTIONAL array of a `z.object` whose catchall is `never`. The entry's keys, in the schema's own order: `childObject` (string, required), then `relationshipField`, `columns`, `formFields`, `inlineMode`, `amountField`, `sortField`, `totalField`, `title`, `minRows`, `maxRows`, `addLabel`, each optional. `{}` is refused with `invalid_type` at `childObject`; `{ childObject: 'x', bogus: 1 }` is refused with `unrecognized_keys` naming `bogus`. - `columns`: an array of a strict object whose key set equals that of `InlineGridColumnSchema` from `@objectstack/spec/data`, and whose `z.input` type `Equal`s `GridColumn` (which IS that `InlineGridColumn`), judged by `tsc`.⚠️ Runtime identity does not hold across the published entry points: the entry's column element is not `===` `InlineGridColumnSchema` as `@objectstack/spec/data` exports it, and `@objectstack/spec/ui`'s own `InlineGridColumnSchema` export is not `===` the `data` one either — the `ui` bundle carries its own instance. The card's "by identity" is a statement about objectstack's source. Nothing here depends on identity (Acceptance note 2). - `sortField` is NOT retired at 17.6.0: `{ childObject: 'x', sortField: 'a' }` parses with success and the value kept. objectstack `main` has retired it since — a `retiredKey()` tombstone on the entry (objectstack-ai/objectstack#21589, PR objectstack-ai/objectstack#21632, `6ec54f00`): `git merge-base --is-ancestor` answers 0 for that commit against objectstack `origin/main` and 1 against the `@objectstack/spec@17.6.0` tag, with `a29a0ea555` as the control answering 0 on the same tag — so the retirement is unreleased after 17.6.0. The fork therefore stands on the installed spec; the runtime pin records it, and the compile-time rows hold on both shapes. - `inlineMode` is the enum `grid | form`, the same two values `InlineMode` declares; `formFields` is `string[]`; `title` and `addLabel` are plain strings (not `I18nLabel`); `minRows` / `maxRows` are numbers. So the derived members are the hand-written members' types. - `ObjectMasterDetailFormPropsParsed` is exported alongside `ObjectMasterDetailFormProps` (the `z.input` face). The authoring face is the one callers write, so it is the one derived from; the parsed face differs only inside a column (`readonlyWhen` / `requiredWhen` normalised to Expression envelopes). - `packages/types` needed no edit: `ObjectMasterDetailFormBlockSchema`'s `properties` is `propsBag(... stripImportedDefaults(SpecObjectMasterDetailFormPropsSchema))`, and the new pin measures a bogus entry key refused on both faces with the spec's own code at the spec's own path. ## The fork: `sortField`, reported and not absorbed The spec entry declares `sortField`; `MasterDetailForm` reads no such member (objectui#11070 round 9 retired the authored override — the line-position field is DERIVED from the child object by `deriveDetail` — and row 2c of the members test pins that a written one reaches nothing); `MasterDetailDetailConfig` leaves it off; `objectui validate` ACCEPTS it, because `@object-ui/types` takes the row by reference. Per the seat ruling this is reported with its measurement, not widened or narrowed here: - widening the TypeScript face to the full spec entry would declare, on the authoring face, a key the runtime does not honour; - narrowing the zod face would be a consumer-side departure from the spec row the mirror exists to take by reference. The spec-side retirement has landed on objectstack `main` (objectstack-ai/objectstack#21589, PR objectstack-ai/objectstack#21632, `6ec54f00`) and is unreleased after 17.6.0 (the card's section 3 recorded it as owed on objectstack-ai/objectstack#6017 before it landed). The fork closes at objectui's bump to the first `@objectstack/spec` release that carries it. One row reds at that bump and says what to do: the `sortField` ACCEPTED rows in the new types pin. It is a RUNTIME row on purpose: the `Spec Main Shape Gate` compiles this repository against objectstack `main`, so a compile-time row pinning the 17.6.0 shape is red there today — which is what patch round 1 corrected. ## The `of` decision The parity file's own rule (`member declarations are derived from single-kind member contracts`): a key is declared with `of` when the contract accepts exactly ONE coarse member kind in its member position, and left alone when it accepts several. On 17.5.0 the `details` member position accepted every kind (`z.unknown()`), so no `of` was derivable. On 17.6.0 it accepts `object` alone — the string, number, boolean and array probes are refused with `invalid_type` at the entry — so `of: 'object'` is derivable, and the registration now declares it. The gate's member direction witnesses it against the spec row (the `object-master-detail-form.details:of=object` judgement is `witnessed`, and the single-kind row reads `contract accepts {object}`; the parity run below is green with the declaration in place). Precedent: `record:details.sections` declares `of: 'object'` for its strict spec entry. `object-kanban.columns` does not and is not this card. ## FROM → TO, proven once A temporary test file (archived in the dispatch scratchpad, deleted before the commit) copied the HEAD interface byte for byte as `OldMasterDetailDetailConfig` and asserted `Equal` of OLD and NEW `= true`, beside a non-vacuity row asserting `Equal` of OLD and NEW-minus-`title` is `false`. `pnpm --filter @object-ui/plugin-form run type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`) exited 0 with that file in the test program (`tsc -p tsconfig.test.json --listFiles` names `masterDetailDetailsMembers-8071.test.tsx`, `MasterDetailForm.tsx` and the spec's `dist/ui/index.d.ts`). That is the whole of the `Clause-②: no`. ## Tests and the ablation - `pnpm exec vitest run packages/plugin-form/ apps/console/src/__tests__/registry-inputs-spec-parity.test.ts --maxWorkers=2` (under `os-verify-lock`): `Test Files 166 passed (166)`, `Tests 2190 passed | 1 skipped (2191)`, `VERDICT command-exit 0`. - `pnpm exec vitest run packages/types/ --maxWorkers=2` (under the lock): `Test Files 358 passed (358)`, `Tests 9519 passed (9519)`, `VERDICT command-exit 0`. - The three touched test files by name (under the lock): `Test Files 3 passed (3)`, `Tests 283 passed (283)`. - `pnpm --filter @object-ui/plugin-form run type-check` (both programs): `VERDICT command-exit 0`. `pnpm --filter @object-ui/console run type-check` (`tsc --noEmit && tsc -b tsconfig.node.json --force`, after building the console's dependency closure with pnpm): `VERDICT command-exit 0`. The first console run was refused on an unbuilt `@object-ui/plugin-tree` (`TS2882`, outside the doc-snippets closure); building `plugin-tree` and `test-support` and rerunning gave the green above. - **Ablation** (the fix committed first, as `a4ac1f38`; objectstack's `scripts/ablation-replace.mjs` in WRAP mode over `MasterDetailForm.tsx`, child command `tsc -p tsconfig.test.json --noEmit`): - A first attempt was a NO-OP by the tool's own evidence and is reported as such: anchor `'sortField'` with replacement `'sortField' | 'addLabel'` left the anchor count at 1 → 1, so the tool refused the mutation before running the child and restored (blob == HEAD). No reading was taken from it. - The real leg: anchor `'sortField'` → replacement `'addLabel'` (the derivation now omits `addLabel` instead). `anchor 1 -> 0`, `blob 2fa79a3 -> 165e0f7923a5`, mutation proven on disk. The child went RED with five errors: the `derivedFromSpec` row and the `memberList` row (`Type 'true' is not assignable to type 'false'`), the `retiredSortField` row (`Unused '@ts-expect-error' directive`), and two renderer reads of `d.addLabel` in the grid hand-off (`Property 'addLabel' does not exist on type 'MasterDetailDetailConfig'`). Restore: `blob after restore 2fa79a3`, `blob at HEAD 2fa79a3`, `git diff HEAD empty`, `git status` clean. The rows and the renderer both hang off the derivation. - Resolution path: the test program compiles `../MasterDetailForm` from source and reads the spec from its installed `.d.ts`; no `dist/` of this package is in the path, so no dist preflight applies. ## Gate readings, at head `a4ac1f38` - `eslint --no-inline-config --format json` over the five touched TypeScript files: 5 files, 0 errors. Warnings: 0 on the parity file and on the new pin; on `MasterDetailForm.tsx`, the members test and `index.tsx` every warning is `no-explicit-any` on a pre-existing `any` line, `react-hooks/exhaustive-deps` or `react-refresh/only-export-components` on lines this diff does not touch. - This is a declared narrowing of `pnpm lint`. Population: `eslint.config.js` lints `**/*.{ts,tsx}`; the changeset `.md` is outside it. File count: 5, from the JSON output. Invariance: the config sets no `parserOptions.project` and no `projectService`, so type-aware linting is off and this diff cannot move the verdict on any untouched file. The full run belongs to CI. - `check:doc-snippets`: `Semantic phase: 777 of 777 block(s) judged, 0 failed`. - `check:new-line-citations`: `0 new citation(s)`. `check:control-bytes`: OK. - Changeset checkers, exit 0 each: `changeset:check` (fixed and no-major), `check:changeset-claims`, `check:pending-changeset-literals`, `check-changeset-presence` ("Every one of them has an EMPTY frontmatter — declared as releasing nothing"), `check-changeset-overwrite`. - Exit 0 each: `check:spec-symbols`, `check:spec-floors` (first refused `no-artifact` on the unbuilt `plugin-tree`; green after the build above), `check:installed-pin-claims`, `check:phantom-deps`, `check:unused-deps`, `check:readme-exports`, `check:test-path-roots`, `check:component-surface-parity`, `check:doc-types`. - `check-governed-queue-guard --test` over the six paths: NOT GOVERNED. - NOT MEASURED, left to CI: the full `pnpm test`, the tree-wide `pnpm lint`, `check:published-dist`. ## Acceptance notes 1. **`details` is optional on the spec row and required on the registration and the TypeScript face.** The spec's `details` is `z.ZodOptional`; the registration declares `required: true` (kept as it was), `MasterDetailFormSchema.details` is required, and the renderer itself tolerates absence (`schema.details || []`). objectui#11605 records the mechanism by which a `required: true` the spec row does not carry refuses a node at the page compile ("its only requiredness check is `input.required && !(input.name in node)`"); that card's rule is about binding-supplied keys, which this one is not. Not measured through the page compile here, and not this card. Carrier: none. 2. **Two instances of `InlineGridColumnSchema` across `@objectstack/spec`'s published entry points** (Premise readings). Any objectui pin that asserts cross-entry-point identity would be false; none does today, and this PR adds none. Carrier: none. 3. **The registration's new description** is authoring help for the manifest and the designer; the member SHAPE is carried by `of` and by the pins, not by that prose. It names the two things the spec's description cannot tell an author: `amountField` is the child column summed and `totalField` the parent field it is saved to, and `sortField` is read by nothing here. ## Patch round 1 — `999aecb6` (seat ruling `5986267745`) The `Spec Main Shape Gate` was red on `a4ac1f38` with one attributed diagnostic: `masterDetailDetailsMembers-8071.test.tsx`, TS2322, compiled against `@objectstack/spec` built from objectstack `ebfe658c7241`. Confirmed: objectstack `main` carries `sortField: retiredKey(MASTER_DETAIL_DETAIL_SORT_FIELD_RETIRED)` on the detail entry (`retiredKey()` is `z.never().optional()`), from objectstack-ai/objectstack#21589 / PR #21632 / `6ec54f00`, which is an ancestor of `origin/main` and not of the `@objectstack/spec@17.6.0` tag (control: `a29a0ea555` is an ancestor of the tag). The row `specStillDeclaresSortField` pinned the 17.6.0 shape and failed there. - Rows: `specStillDeclaresSortField` dropped. `derivedFromSpec`, `columnsAreTheGridColumn`, `memberList` and the `@ts-expect-error` row stay, and hold on both shapes (measured below). The forward tripwire is the runtime `sortField` ACCEPTED pin in `packages/types`, renamed to say so; it flips at the bump. - Sentences: the `MasterDetailForm.tsx` docblock, the members-test header, the types-pin header, the registration description, the console parity row and the changeset body now say the retirement landed on objectstack `main`, is unreleased after 17.6.0, and that the fork closes at objectui's bump to the first release carrying it. - Local reproduction of the gate, with the spec built from objectstack `main` `e83c9f615441` in a scratch worktree (packed with `npm pack`, injected with `scripts/spec-main-shape-gate.mjs inject`; `no dependency substitution`), `TURBO_FORCE=true` and a scratch `--cache-dir`, under `os-verify-lock`: - RED (declared narrowing: turbo filtered to `@object-ui/plugin-form`, the attributed test file checked out from `a4ac1f38` while HEAD held the fix): `turbo run type-check --filter=@object-ui/plugin-form --concurrency=2 --continue` exited 2; the gate's `report` printed `1 diagnostic(s) in 1 objectui file(s)` — the members test, TS2322 `Type 'true' is not assignable to type 'false'`. Restore proven: blob == HEAD, `git diff HEAD` empty. - GREEN (the gate's full step, on `999aecb6`): `pnpm type-check --continue --concurrency=2` — `Tasks: 81 successful, 81 total`, `Cached: 0 cached`, 9m26s; `report`: `✅ objectui type-checks against @objectstack/spec at that commit`. - Back on 17.6.0 (reinstalled; `0` inject markers in the store): plugin-form closure rebuilt, `pnpm --filter @object-ui/plugin-form run type-check` exit 0, the three touched test files `3 passed`, `283` tests, `pnpm --filter @object-ui/console run type-check` exit 0. - `Clause-②: no` re-measured on `999aecb6`: the pre-PR interface at `9dfaca65`, byte-copied, `Equal`s the derived type (`tsc -p tsconfig.test.json` exit 0, temp file deleted before push). - Ablation redone on `999aecb6` (anchor `'sortField'` → `'addLabel'`): mutation landed (`anchor 1 -> 0`, blob changed), five errors — `derivedFromSpec`, `memberList`, the unused `@ts-expect-error`, two renderer reads of `d.addLabel` — restore proven (blob == HEAD, `git diff HEAD` empty). - Gates on `999aecb6`, exit 0 each: eslint over the five touched TypeScript files (0 errors), `check:installed-pin-claims`, `check:spec-symbols`, `check:control-bytes`, `check:new-line-citations` (0 new), `changeset:check`, `check:changeset-claims`, `check:pending-changeset-literals`, `check-changeset-presence`, `check-changeset-overwrite`. Serial: objectui#11170 was dispatched in this lane during this round and shares no file with this PR. `origin/main` has not moved since this branch was fast-forwarded to `9dfaca65`, so the merge before pushing was a no-op in both rounds. --- _Generated by [Claude Code](https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…n key, its per-row expand form, a detail entry's formFields and a record:line_items block against the child (objectstack-ai#21091) (objectstack-ai#21256) Fixes objectstack-ai#21091 Clause-②: yes ## What changes `field-no-consumers` (`packages/lint/src/validate-field-consumers.ts`) called several kinds of in-use child field "inert". This PR corrects them. The per-row expand form goes through a new derivation the spec owns, as `deriveInlineGridColumns` (PR objectstack-ai#21089) did for the grid. 1. **Position 1: a `lookup`'s inline-grid join key.** A `lookup` or `master_detail` field that sets `inlineEdit` (with a resolvable `reference`) is now recorded as a behaviour read at its `inlineEdit`, whether the grid's columns are authored or derived. The renderer loads the child rows filtered on it and stamps it on save (objectui `MasterDetailForm.tsx` 1321 and 552, at the `.objectui-sha` pin `31971ff1e28f`). `master_detail` was already exempt; `lookup` now reads the same. 2. **Position 2: the derived per-row expand form.** Two new `@objectstack/spec/data` exports live in `packages/spec/src/data/inline-grid-columns.ts`. They sit in the same module as `deriveInlineGridColumns` because they share its system-name and sort-name sets. - `deriveInlineRowFormFields(def, { relationshipField?, exclude? }): string[]` is objectui's `deriveFormFields` stated as the spec's rule. It skips the same names as the grid, plus the relationship field, `exclude`, `system` / `hidden` fields and the computed types (`formula`, `summary`, `rollup`, `autonumber`, `auto_number`). It keeps `readonly` fields and every type a cell cannot edit. - `isInlineRowFormOffered({ inlineMode?, formFields?, columns? }): boolean` is the renderer's offer condition at `MasterDetailForm.tsx:847`: `inlineMode === 'form'`, or more form fields than grid columns. - The lint credits the derived row form wherever it credits the derived grid: an inline relationship field with no authored `inlineColumns`, or a `subforms` / `details` entry with no `columns`. A `details` entry is excluded when it authors `formFields`, because an authored list replaces the derived one. No copy of objectui's rule lives in the lint. 3. **Position 3 (pointer `5936875973`): a detail entry's authored `formFields`.** These names are read against the entry's `childObject`; the general walk no longer reads them against the parent. `isInlineRowFormOffered` decides whether the list is drawn, and a list the form is never offered for is a carrier. The renderer resolves an entry one of two ways, and the lint feeds the predicate what each way feeds the expand control (round 2, F1): - **Kept as authored:** the entry names both `relationshipField` and at least one column (`MasterDetailForm.tsx` 967, 1048–1052). Nothing is derived. The form factor is the declared `inlineMode`, or none at all, so the predicate decides exactly. With an omitted `inlineMode`, the form is offered only when the list is longer than the grid. - **Derived:** anything else (1055–1066). A declared `inlineMode` is kept. An omitted one is resolved from the relationship's `inlineEdit`, else from the child's shape. The lint does not reproduce that resolution, so with an omitted mode the list is credited as drawn. With a declared mode, the predicate decides whenever the grid can be counted. 4. **Position 4 (pointer `5940763140`): a `record:line_items` block.** Its raw `properties` are read as one child entry: authored `columns[].name`, `relationshipField` and `amountField` against `childObject`, with `totalField` left on the parent. objectui `LineItemsPanel.tsx` at the pin reads these keys this way. It derives no grid and offers no row form. `RecordLineItemsProps` is not imported. **Round 2, flag B:** the block's `sort` and `filter` are now walked in the `childObject`'s context. `LineItemsPanel` applies them to the child query (366–379, 516–521). Since PR objectstack-ai#21244 landed `RecordLineItemsProps`, the contract declares `filter` as the ViewFilterRule array. The panel's lowering also takes the field-keyed map, and the lint reads whichever is authored. Both forms are pinned. **Fixture triage (round 1).** Six tests in the `[objectstack-ai#20951]` site-2 block pinned that a derived carrier leaves the `json` and `readonly` child fields inert. The derived row form now draws them, so their expected sets were re-judged: `DERIVED` keeps only the `hidden` field, and `NO_ROW_FORM` keeps the old set for the three cases that draw no derived row form. ## Round 2: the contract review `5942628181` (FAIL) and what this head does about it - **F1, fixed.** The round-1 lint credited an authored `formFields` list as drawn whenever `inlineMode` was omitted. On the kept-as-authored path that is false: the renderer leaves the mode undefined, and line 847's count decides. The lint now decides that path with `isInlineRowFormOffered({ inlineMode: undefined, formFields, columns })`. The docblock and test titles state both paths. The test's own fixture (`relationshipField` and two columns, one form field) now pins `itm.notes` as `carrier-only`. - **Flag B, measured and closed.** See position 4. The probe confirmed it: the three child fields read only by a block's `sort` / `filter` were inert, and the same-named parent fields were credited in their place. It is pinned with two enumeration rows (`sort[].field`, `filter[].field`) and three unit tests. - **Flag A, measured; not closed on this surface.** Reading below. ### Flag A: a row form opened with no field list This happens when an authored grid is in the `form` factor and has no `formFields`. That covers authored `inlineColumns` with `inlineEdit: 'form'`, or with `inlineEdit: true` and a child the smart default sends to `form`, and a detail entry kept as authored with `inlineMode: 'form'`. The renderer then opens the child's `ObjectForm` with no `fields` (`MasterDetailForm.tsx` 1821). That form draws the child's generated field set (`ObjectForm.tsx` 961) through `filterSystemFields` (`autoLayout.ts` 231): every field except the server-owned names, `hidden` fields and `readonly` fields, laid out by `fieldGroups` when the child declares any. **Probe reading (all three heads below):** `pg_line.note_g`, `ph_line.body_h`, `ph_line.note_h` and `pi_line.note_i` are reported inert, and the renderer draws them. `pg_line.ro_g` (`readonly`) and `pg_line.hid_g` (`hidden`) are reported inert, and the renderer does not draw them either. The reach is confirmed. **Why it does not close here:** 1. Crediting it needs a spec-owned statement of the default object form's field set: `ObjectForm`'s generated set, the server-owned roster from objectui `sanitize.ts`, the `hidden` and `readonly` filters, and the `fieldGroups` layout. That is a new cross-repo contract with its own differential and its own objectui consumer. 2. The `inlineEdit: true` arm also needs the smart default (`resolveInlineMode`: the form-only types, the two-rich-field threshold and the eight-field threshold) promoted to the spec. 3. It meets this rule's documented posture. The default layout is never a site (`creditFieldGroupLayout`: only a KEYED section counts), because the platform's default form draws every visible field of every object. The probe's own control `pa_order.buyer` is drawn by `pa_order`'s default form and reported by design. Crediting the same form when a parent opens it as a row editor makes the verdict depend on which door opens it. That is a decision about the rule's contract, not an omission in this diff. So the module note and a pinned boundary test state the position: an authored grid in the `form` factor with no `formFields` keeps those child fields reported. The enumeration pin's sentence now reads "the form the spec derives, and an authored `formFields` list the form is offered for". The position goes to a point card the seat files. The report carries the options. ## The spec functions against objectui's rule (round 1, unchanged) The differential ran the spec functions against `deriveFormFields` and line 847's expression, both read from the pinned files (`deriveMasterDetail.ts` blob `90aa44c9`, `MasterDetailForm.tsx` blob `7a96a130`). The offer expression was evaluated from the source text. - **`deriveInlineRowFormFields`: 100,004 cases, 0 mismatches.** The cases were objectui's 4 fixtures plus 100,000 random definitions: null and string field definitions, array-shaped `fields`, non-spec type names, truthy and falsy flags, prototype-ish names, and random `relationshipField` / `exclude`. - **`isInlineRowFormOffered`: 300,012 cases, 0 mismatches.** - **Subset property: 0 violations.** The derived grid is always a subset of the derived form. - **Lit control: 648 of 2,000 mismatches.** The same harness was run against a function that is not the rule, so the harness can fail. ## Evidence **The door: `os validate --json` on a `defineStack` probe stack.** Three heads were measured, each built from source: - `a7d9768e`, the card's base, in a separate worktree; - `1d1258a5`, the round-1 head; - `a87f03e1`, this head. All three were run with the same probe file (its `filter` blocks in the rule-array form). All three exit 0 with `valid: true`. `field-no-consumers` findings: 32, 17, 17. | field | a7d9768 | 1d1258a | a87f03e | position | |:--|:--|:--|:--|:--| | `pa_order_note.order` / `pa_ticket_line.ticket` / `pb_case_comment.case_ref` / `ph_line.header` (`lookup` + `inlineEdit`) | inert | — | — | 1 | | `pb_invoice_line.notes` / `.config` / `.frozen`, `pb_case_comment.body`, `pb_memo_line.long_note` | inert | — | — | 2 | | `pc_line.memo` (detail `formFields`, `inlineMode: 'form'`) | inert | — | — | 3 | | `pc_header.memo` (parent twin) | — | inert | inert | 3: was credited in the child's place | | `pd_line.memo2` (declared `grid`, 1 field vs 2 columns) | inert | carrier-only | carrier-only | 3 | | `pf_line.memo_f` (kept as authored, no `inlineMode`, 1 field vs 2 columns) | inert | — | carrier-only | 3, F1 | | `pe_line.qty_e` / `.note_e` / `.header` / `.amt` (`record:line_items` columns and keys) | inert | — | — | 4 | | `pe_header.amt` (parent twin) | — | inert | inert | 4 | | `pk_line.srt_k` / `.flt_k` / `.flt2_k` (block `sort`, two blocks' `filter`) | inert | inert | — | 4, flag B | | `pk_header.srt_k` / `.flt_k` (parent twins) | — | — | inert | 4, flag B: were credited in the child's place | | `pg_line.note_g`, `ph_line.body_h` / `.note_h`, `pi_line.note_i` (default form) | inert | inert | inert | flag A: not credited, see above | | `pg_line.ro_g` / `.hid_g` (`readonly` / `hidden`) | inert | inert | inert | flag A: not drawn either | | `pc_line.position` (detail `sortField`) | inert | inert | inert | no lint read; see notes | | `pa_order.buyer`, `pb_invoice_line.secret`, `pe_line.unused_e`, `pk_line.unused_k` | inert | inert | inert | controls | (— means not reported.) **A real producer: `examples/app-showcase`.** There are 52 findings at `a7d9768e` and 52 at `a87f03e1`, with identical verdict sets. PR objectstack-ai#21244 changed its `record:line_items` page in between, and that block has no `sort` or `filter`. **Tests at `a87f03e1`** (the head of this PR): - `pnpm --filter @objectstack/lint exec vitest run`: 119 files, 5,572 tests passed. The `validate-field-consumers.test.ts` file has 126 tests, including the `[objectstack-ai#21091]` block: positions 1 to 4, the flag-A boundary, and the enumeration pin's 13 rows, each paired with a control. - `pnpm --filter @objectstack/spec exec vitest run --project local`: 597 files, 17,483 passed and 1 todo. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 243 files, 3,439 passed, with the CLI closure built with declarations. The integration tier is declared to CI. - `pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck`: both exit 0, and `check:test-typecheck` is OK for both. - Filter direction: `@objectstack/spec`, `@objectstack/lint`, and the downstream lint consumer `@objectstack/cli`. **Reverse verification and ablations.** Each was committed first, made through `scripts/ablation-replace.mjs` or a blob restore, and restored to the HEAD blob with `git diff HEAD` empty. All were predicted red, and all were red. - Round 1: the lint source restored to the base blob `3efd1236` failed 29 of 115 tests. The spec row form made to drop `readonly` failed 2 of 20. - Round 2, at `a87f03e1`, flag B: the panel's `sort` / `filter` read switched off failed exactly the 5 flag-B tests (3 tests and 2 pin rows). - Round 2, at `a87f03e1`, F1: the kept-as-authored decision switched off failed exactly the F1 carrier test. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` derived 8 paths and 86 commands at `a87f03e1`. Every one was run. `--ran` reports "86 derived, 86 run, 0 NOT-MEASURED, 0 UNRUN", and all 86 exited 0. `check:generated`: all 15 artefacts are up to date. The two spec shards gain exactly the two names each. **Base.** `origin/main` moved under generated files three times and was merged each time through `scripts/pm/os-regen-merge.sh`: at `1d1258a5`, `ee505255` and `6084ce01`. The last merge brought PR objectstack-ai#21244's `RecordLineItemsProps`. No merge owed a regeneration, and the delta against `origin/main` is exactly this PR's 8 paths. Since then, `origin/main` has moved by 4 commits, none of which touches a generated artefact or one of the 8 paths. ## Acceptance notes - **Exports.** There are two new names, both functions: `deriveInlineRowFormFields` and `isInlineRowFormOffered`. No schema accepts or refuses anything new. - **For the objectui ④ child:** - `deriveFormFields(childSchema, opts)` equals `deriveInlineRowFormFields(childSchema, opts)` on every measured input. - Line 847's expression equals `isInlineRowFormOffered({ inlineMode: d.inlineMode, formFields: d.formFields, columns: d.columns })`. - The verdicts are above. - **`sortField` (pointer position 3), probe reading.** `pc_line.position` is inert at all three heads. At the pin the renderer only stamps it (`GridField.tsx:735`). It loads rows with `$filter` and `$top` and no ordering, so it never reads the field. objectui `0a3e5409f` retired the authored key after the pin, and no lint read was added. The general walk still reads `details[].sortField` against the parent. That reading leaves with the key at the next `.objectui-sha` bump. - **Flag A** goes to a point card the seat files. The pin sentence and a boundary test state what this PR covers. - **Kept as stated:** an omitted `inlineMode` on the DERIVED path (the renderer's smart default), and a derived grid with no `relationshipField`, both credit an authored list as drawn. - **"Not in this card"** stays out: the explicit `form.subforms` override, and a `subforms` entry with no `relationshipField`. - **Changeset.** `@objectstack/spec: minor`, because `Clause-②: yes` takes at least minor. `@objectstack/lint: patch` follows PR objectstack-ai#21089 and PR objectstack-ai#21215. The lint bullets now state the round-2 reads. The rule's message and hint text are unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20928
Clause-②: yes
The third carrier of the inline grid column is brought to the contract the other two already hold.
ComponentPropsMap['object-master-detail-form'].detailswasz.array(z.unknown()); each entry is now a strict shape of the keys objectui'sMasterDetailFormreads, and itscolumnsreferencesInlineGridColumnSchema.defineStack's identity-only check (collectHydratedInlineColumnErrors) now reaches the block on a page, andfield-no-consumersreads a detail entry as a child collection. Governed by ruling5791803339(#19629, letter B) and ruling5805782503(#19910, letter 乙); routed by triage5919655566.What changed
packages/spec/src/ui/component.zod.ts:detailsisz.array(masterDetailDetailEntry()). The entry is astrictObjectwithchildObject(required),relationshipField,columns(z.array(InlineGridColumnSchema), the same object, not a copy),formFields,inlineMode(gridorform),amountField,sortField,totalField,title,minRows,maxRows,addLabel. The keys shared with a form view'ssubforms[]entry take that entry's types and alias table. The entry is built inside the props schema's own lazy body, as thesubforms[]entry is: a standalonelazySchemaelement is a function-typed proxy the alias-integrity graph walk never descends into (measured:alias-integrity.test.tswent red on it, see commite734f15e). New typeObjectMasterDetailFormPropsParsed: the props schema's input and output now differ, because the column schema's are different.packages/spec/src/stack.zod.ts: the page-component traversal thatcollectInlinePageActionsowned (regions, slots, nested container arrays) is extracted intoforEachPageComponent, unchanged, and the collector walks it forobject-master-detail-formblocks, judgingdetails[j].columnsagainstdetails[j].childObject. A column is judged only once it parses as a column WITHOUT the resolved type. On the two parsed carriers this was already true. On the page carrier, whosepropertiesthe stack parse never reaches, it keeps every finding one that the resolved type brings; a column with its own defects is left to the component-props gate.packages/lint/src/validate-field-consumers.ts:CHILD_COLLECTION_KEYSgainsdetails. A detail entry's columnname,amountFieldandrelationshipFieldcredit the child's field,totalFieldthe parent's, and an entry withoutcolumnscredits the derived grid. The page slot map's owndetailskey holds components, which carry none of an entry's keys at their own level. A pinned control shows such a component is read the same underslots.detailsas underslots.header.ui-object-master-detail-form-details-closed, plus a step-18 rationale fragment (order 56).gen:migration-registryregenerated the registry. No D2 conversion, per the census below.content/docs/protocol/objectui/layout-dsl.mdx: the master-detail example'scolumns: [product, quantity, unit_price](bare strings) becomes threename:columns. The strict entry refuses bare strings, and the renderer could not draw them either:hydrateColumnsreturns a string column unchanged, and the grid readscol.name.dropped-refinements.baseline.json(one new site,ui/ObjectMasterDetailFormPropsatdetails.element.columns.element, plus its two counts),api-surface/ui.json,export-origins/ui.json,content/docs/references/ui/component.mdx,docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md.type-alias-convention.pin.test.tsdrops the props schema's isomorphism pin (779 to 778) for the newXParsedalias, the route that file prescribes..changeset/20928-master-detail-details-closed.md:@objectstack/specminor (BREAKING,Clause-②: yes (narrowing), markerregistered ui-object-master-detail-form-details-closed),@objectstack/lintpatch.Measurements
Premise (
origin/main2c1cef3345at dispatch, re-read at this head):details: z.array(z.unknown()).optional()sat atcomponent.zod.ts:4776. Premise holds.The read set, from objectui
packages/plugin-form/src/MasterDetailForm.tsxat the.objectui-shapin31971ff1e28f. The file is byte-identical at the dispatch pine420df310f5b.MasterDetailDetailConfigis declared at:64-93. Each of its twelve keys has a live read:childObject:986,:1003;relationshipField:1033,:1312;columns:858,:928,:1034;formFields:847,:1063;inlineMode:850,:1064;amountField:861,:1035,:1550;sortField:862,:1038;totalField:1549;title:755;minRows:863;maxRows:864;addLabel:865. A grep of everyd.X,detail.Xande.config.Xmember read in the file finds no thirteenth key;detail.savinganddetail.addare i18n keys.deriveDetailhydrates an authored list withhydrateColumnsand derives withderiveColumns, the same rulederiveInlineGridColumnsreproduces.Census (
origin/mainebdb6f2aca, the branch point): blocks typedobject-master-detail-formappear in the showcaseproject-workspace.page.ts(one entry,{ title, childObject, addLabel }, no columns), in one doc example (three bare-string columns, rewritten here), and in the lint and spec test fixtures (copies of the showcase entry).sdui.manifest.jsonis objectui's registry, not a producer.field-keyed detail columns: 0. The regexdetails:.*field:does match three API-errordetailspayloads elsewhere, so the matcher is live. Control: one authoredinlineColumnsblock inexamples/(showcaseinvoice.object.ts). So only a narrowing marker is owed. Page-componentpropertiesis not parsed on the save or load path, so no stored row is ever refused, and no conversion is registered.Collector reach:
config.pagesis reachable from the collector, and the existing page walk already reached componentpropertieswith hard cross-reference errors (inline actions). Measured below.Field-consumer scan: before this change it did not read
details[].columns[].name(CHILD_COLLECTION_KEYSwassubformsonly). Ablation A4 below confirms it.Pinned objectui (
31971ff1e28f,git grep): the only non-test importer ispackages/types/src/zod/objectql.zod.ts:52/:3913, which takesObjectMasterDetailFormPropsSchemaby reference throughstripImportedDefaults, a generic walk. The testsregistered-type-arms-10859-b2.test.tscompare the node's props toSpecObjectMasterDetailFormPropsby reference on both sides, and their showcase fixture{ title, childObject, addLabel }stays valid. No value the narrowed type refuses was found at that pin. objectui's own suites against this spec: NOT MEASURED (objectui coordination, below).Public door (
os validate --json, CLI built atbfb26d52, a probe stack with a currency child field):valid{ name: 'amount', type: 'currency', scale: 2 }component-props-invalidatproperties.details.0.columns.1.scale, the ruled first sentence{ zzz_not_a_key: 1 }component-props-unknown-key(zzz_not_a_key) andcomponent-props-invalid(namerequired){ name: 'amount', scale: 2 }over acurrencyfieldSTACK_CROSS_REFERENCE_INVALID:Page 'probe_invoice_entry' (regions.0.components.0) object-master-detail-form details[0].columns[1].scale: column 'amount' declares no type, so it renders as a currency column …{ name: 'quantity' }, { name: 'amount' }(control)field-no-consumersreports neither child fieldVerification (at
bfb26d52, after mergingorigin/main0d421041throughscripts/pm/os-regen-merge.sh)@objectstack/specvitest run --project local, two shards: 297 + 297 files passed, 8915 + 8511 tests passed, 1 todo.--project reposubset (step18-rationale-merge,conversions-major18-merge,retired-key-migrate-sentence): 35 passed.pnpm --filter @objectstack/spec typecheck: exit 0.tsc -p tsconfig.test.json --listFilesincludes both edited test files, and none of the errors held in the debt ledger names them.@objectstack/lintvitest run: 118 files / 5494 passed.typecheck: exit 0.--filter PKG, not a closure):@objectstack/sdui-parser13 files / 217 passed;@objectstack/metadata-protocol200 passed, 3 skipped files / 2973 passed, 19 skipped. Packages whose fixtures write the block: spec and lint only, covered above.pnpm --filter @objectstack/spec check:generated: all 15 artifacts up to date after a fresh build.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 114 derived, 114 run, all exit 0.--ranreconciliation: "0 NOT-MEASURED (a DERIVED zero)". Three first exited 3 (PREREQUISITE NOT MET:check:doc-formula-expressions,check:doc-security-posture,check:skill-examples) and were green afterturbo run buildover./packages/*and./packages/*/*. Five roster gates marked as sharing a directory with this diff (check-changeset-fixed,check:meta-url-spelling,check:authz-resolver,check:error-code-casing,check:filter-alias-parity) andcheck-sdui-manifest: exit 0.eslint --no-inline-config --format jsonover the 8 changed.tsfiles reports files 8, errors 0, warnings 0. The population iseslint.config.mjs:971,**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}. That config never enables type-aware linting (its comment at:326-328: noparserOptions.project, no typed rules), so this diff cannot move the verdict on any untouched file.scripts/ablation-replace.mjsfrom committed state (bfb26d52), each restored with blob equal to HEAD andgit diff HEADempty. A first pass at074d6ca4, before the entry became a factory, gave the same four counts. The subjects resolve tosrcthrough relative imports, so no dist leg was owed.details: z.array(masterDetailDetailEntry())→z.array(z.unknown())(blob72d6abd987ce→242fee1c1fb5): carriers pin8 failed | 28 passed (36). All seven props-half refusals went red, plus the first assertion of the "left to the props half" pin. I predicted 7; the eighth is that pin's own props-half check.aa6368d1f138→318e3e0ee35f):2 failed | 34 passed. Those are the region refusal and the nested and slot refusal.aa6368d1f138→047cac280736):1 failed | 35 passed. The red run printed the misattribution the precondition exists to stop: "column 'amount' declares no type, so it renders as a currency column … Unrecognized key(s) on this inline grid column: zzz_not_a_key".CHILD_COLLECTION_KEYSback tosubformsonly (3efd1236571e→7b8b0ab03798):3 failed | 66 passed (69). Those are the authored column,amountField, and the derived grid.b6df7f7748e0→2aef1e440273):check:yaml-examplesexit 1, "1 of 19 tagged YAML block(s) do not hold their claim … expected object, received string" atlayout-dsl.mdx:774.Acceptance notes
validate-component-props.tsheader). So the card's typed and bogus columns move from silent green tovalid: truewith advisory findings. The identity-only column is a hard cross-reference error, like the inline-action targets already judged on the same pages. Upgrading the props gate is that gate's own step.propertiesis not parsed there. This is the same posture as every page-component narrowing in step 18. NOT MEASURED at the door.field-no-consumersstill reads a detail entry'ssortFieldandformFieldsin the parent's context, through the general walk. Not changed here. finding(lint): field-no-consumers still calls two in-use child-context fields "inert" — a lookup's inline-grid join key, and the fields an inline grid's per-row expand form draws (the family's closeout after #20951) #21091's positions are not touched.layout-dsl.mdx(a producer the census found, whichcheck:yaml-examplesreds on, see A5),type-alias-convention.pin.test.tsanddropped-refinements.baseline.json(both forced by referencing the column schema).conversions/registry.tsand the showcase page are untouched, because nothing needed converting or rewriting.inline-grid-column-identity-only-currency-scale-refusedstill names its two carriers. The new entry records the third rather than editing a step-18 text that other open PRs hold.origin/mainmoved after the merge (e18fea6d). Its only spec file isdata/filter.zod.ts, which this diff does not touch. Merge-queue revalidation covers it.MasterDetailFormmirror and its spec-parity probes over this row are not in this PR. The seat files the objectui coordination child.Generated by Claude Code