Skip to content

Commit a29a0ea

Browse files
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)
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'].details` was `z.array(z.unknown())`; each entry is now a strict shape of the keys objectui's `MasterDetailForm` reads, and its `columns` references `InlineGridColumnSchema`. `defineStack`'s identity-only check (`collectHydratedInlineColumnErrors`) now reaches the block on a page, and `field-no-consumers` reads a detail entry as a child collection. Governed by ruling `5791803339` (#19629, letter B) and ruling `5805782503` (#19910, letter 乙); routed by triage `5919655566`. ## What changed - **`packages/spec/src/ui/component.zod.ts`**: `details` is `z.array(masterDetailDetailEntry())`. The entry is a `strictObject` with `childObject` (required), `relationshipField`, `columns` (`z.array(InlineGridColumnSchema)`, the same object, not a copy), `formFields`, `inlineMode` (`grid` or `form`), `amountField`, `sortField`, `totalField`, `title`, `minRows`, `maxRows`, `addLabel`. The keys shared with a form view's `subforms[]` entry take that entry's types and alias table. The entry is built inside the props schema's own lazy body, as the `subforms[]` entry is: a standalone `lazySchema` element is a function-typed proxy the alias-integrity graph walk never descends into (measured: `alias-integrity.test.ts` went red on it, see commit `e734f15e`). New type `ObjectMasterDetailFormPropsParsed`: 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 that `collectInlinePageActions` owned (regions, slots, nested container arrays) is extracted into `forEachPageComponent`, unchanged, and the collector walks it for `object-master-detail-form` blocks, judging `details[j].columns` against `details[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, whose `properties` the 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_KEYS` gains `details`. A detail entry's column `name`, `amountField` and `relationshipField` credit the child's field, `totalField` the parent's, and an entry without `columns` credits the derived grid. The page slot map's own `details` key 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 under `slots.details` as under `slots.header`. - **ADR-0087**: new D3 semantic entry `ui-object-master-detail-form-details-closed`, plus a step-18 rationale fragment (order 56). `gen:migration-registry` regenerated the registry. No D2 conversion, per the census below. - **`content/docs/protocol/objectui/layout-dsl.mdx`**: the master-detail example's `columns: [product, quantity, unit_price]` (bare strings) becomes three `name:` columns. The strict entry refuses bare strings, and the renderer could not draw them either: `hydrateColumns` returns a string column unchanged, and the grid reads `col.name`. - Generated: `dropped-refinements.baseline.json` (one new site, `ui/ObjectMasterDetailFormProps` at `details.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.ts` drops the props schema's isomorphism pin (779 to 778) for the new `XParsed` alias, the route that file prescribes. - Changeset `.changeset/20928-master-detail-details-closed.md`: `@objectstack/spec` minor (BREAKING, `Clause-②: yes (narrowing)`, marker `registered ui-object-master-detail-form-details-closed`), `@objectstack/lint` patch. ## Measurements **Premise** (`origin/main` `2c1cef3345` at dispatch, re-read at this head): `details: z.array(z.unknown()).optional()` sat at `component.zod.ts:4776`. Premise holds. **The read set**, from objectui `packages/plugin-form/src/MasterDetailForm.tsx` at the `.objectui-sha` pin `31971ff1e28f`. The file is byte-identical at the dispatch pin `e420df310f5b`. `MasterDetailDetailConfig` is 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 every `d.X`, `detail.X` and `e.config.X` member read in the file finds no thirteenth key; `detail.saving` and `detail.add` are i18n keys. `deriveDetail` hydrates an authored list with `hydrateColumns` and derives with `deriveColumns`, the same rule `deriveInlineGridColumns` reproduces. **Census** (`origin/main` `ebdb6f2aca`, the branch point): blocks typed `object-master-detail-form` appear in the showcase `project-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.json` is objectui's registry, not a producer. `field`-keyed detail columns: 0. The regex `details:.*field:` does match three API-error `details` payloads elsewhere, so the matcher is live. Control: one authored `inlineColumns` block in `examples/` (showcase `invoice.object.ts`). So only a narrowing marker is owed. Page-component `properties` is not parsed on the save or load path, so no stored row is ever refused, and no conversion is registered. **Collector reach**: `config.pages` is reachable from the collector, and the existing page walk already reached component `properties` with hard cross-reference errors (inline actions). Measured below. **Field-consumer scan**: before this change it did not read `details[].columns[].name` (`CHILD_COLLECTION_KEYS` was `subforms` only). Ablation A4 below confirms it. **Pinned objectui** (`31971ff1e28f`, `git grep`): the only non-test importer is `packages/types/src/zod/objectql.zod.ts:52` / `:3913`, which takes `ObjectMasterDetailFormPropsSchema` by reference through `stripImportedDefaults`, a generic walk. The tests `registered-type-arms-10859-b2.test.ts` compare the node's props to `SpecObjectMasterDetailFormProps` by 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 at `bfb26d52`, a probe stack with a currency child field): | probe column(s) | exit | `valid` | finding | |:--|:--|:--|:--| | `{ name: 'amount', type: 'currency', scale: 2 }` | 0 | true | advisory `component-props-invalid` at `properties.details.0.columns.1.scale`, the ruled first sentence | | `{ zzz_not_a_key: 1 }` | 0 | true | advisory `component-props-unknown-key` (`zzz_not_a_key`) and `component-props-invalid` (`name` required) | | `{ name: 'amount', scale: 2 }` over a `currency` field | 1 | false | `STACK_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) | 0 | true | none; and `field-no-consumers` reports neither child field | ## Verification (at `bfb26d52`, after merging `origin/main` `0d421041` through `scripts/pm/os-regen-merge.sh`) - `@objectstack/spec` `vitest run --project local`, two shards: 297 + 297 files passed, 8915 + 8511 tests passed, 1 todo. `--project repo` subset (`step18-rationale-merge`, `conversions-major18-merge`, `retired-key-migrate-sentence`): 35 passed. `pnpm --filter @objectstack/spec typecheck`: exit 0. `tsc -p tsconfig.test.json --listFiles` includes both edited test files, and none of the errors held in the debt ledger names them. - `@objectstack/lint` `vitest run`: 118 files / 5494 passed. `typecheck`: exit 0. - Consumers, each named package exactly (`--filter PKG`, not a closure): `@objectstack/sdui-parser` 13 files / 217 passed; `@objectstack/metadata-protocol` 200 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. - Derived gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 114 derived, 114 run, all exit 0. `--ran` reconciliation: "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 after `turbo run build` over `./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`) and `check-sdui-manifest`: exit 0. - ESLint, a narrowed run, stated as such. `eslint --no-inline-config --format json` over the 8 changed `.ts` files reports files 8, errors 0, warnings 0. The population is `eslint.config.mjs:971`, `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`. That config never enables type-aware linting (its comment at `:326-328`: no `parserOptions.project`, no typed rules), so this diff cannot move the verdict on any untouched file. - Ablations, each through `scripts/ablation-replace.mjs` from committed state (`bfb26d52`), each restored with blob equal to HEAD and `git diff HEAD` empty. A first pass at `074d6ca4`, before the entry became a factory, gave the same four counts. The subjects resolve to `src` through relative imports, so no dist leg was owed. - A1 `details: z.array(masterDetailDetailEntry())` → `z.array(z.unknown())` (blob `72d6abd987ce` → `242fee1c1fb5`): carriers pin `8 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. - A2 the collector's page loop emptied (`aa6368d1f138` → `318e3e0ee35f`): `2 failed | 34 passed`. Those are the region refusal and the nested and slot refusal. - A3 the valid-without-type precondition deleted (`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". - A4 `CHILD_COLLECTION_KEYS` back to `subforms` only (`3efd1236571e` → `7b8b0ab03798`): `3 failed | 66 passed (69)`. Those are the authored column, `amountField`, and the derived grid. - A5 the doc's old bare-string columns restored (`b6df7f7748e0` → `2aef1e440273`): `check:yaml-examples` exit 1, "1 of 19 tagged YAML block(s) do not hold their claim … expected object, received string" at `layout-dsl.mdx:774`. ## Acceptance notes - **Two enforcement tiers on one carrier.** The entry and column half is read where every page component's props are read: the component-props gate, advisory by its own declared posture (`validate-component-props.ts` header). So the card's typed and bogus columns move from silent green to `valid: true` with 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. - **Save door**: a page saved through the metadata door keeps a failing detail entry, because `properties` is not parsed there. This is the same posture as every page-component narrowing in step 18. NOT MEASURED at the door. - **Scan, adjacent position**: `field-no-consumers` still reads a detail entry's `sortField` and `formFields` in the parent's context, through the general walk. Not changed here. #21091's positions are not touched. - **Files beyond the claim's list**: `layout-dsl.mdx` (a producer the census found, which `check:yaml-examples` reds on, see A5), `type-alias-convention.pin.test.ts` and `dropped-refinements.baseline.json` (both forced by referencing the column schema). `conversions/registry.ts` and the showcase page are untouched, because nothing needed converting or rewriting. - The D3 entry `inline-grid-column-identity-only-currency-scale-refused` still names its two carriers. The new entry records the third rather than editing a step-18 text that other open PRs hold. - `origin/main` moved after the merge (`e18fea6d`). Its only spec file is `data/filter.zod.ts`, which this diff does not touch. Merge-queue revalidation covers it. - objectui's `MasterDetailForm` mirror and its spec-parity probes over this row are not in this PR. The seat files the objectui coordination child. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e2ed61a commit a29a0ea

15 files changed

Lines changed: 730 additions & 75 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': patch
4+
---
5+
6+
feat(spec)!: an `object-master-detail-form` block's detail entries are a strict shape, and their columns are the inline grid column contract (#20928)
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: registered ui-object-master-detail-form-details-closed -->
11+
12+
**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The console's master-detail grid reads one column shape from three carriers: a relationship field's `inlineColumns`, a form view's `subforms[].columns`, and an `object-master-detail-form` page block's `details[].columns`. The first two were judged; the third was `z.array(z.unknown())`.
13+
14+
**`@objectstack/spec`**
15+
16+
- **`ComponentPropsMap['object-master-detail-form'].details`** is now an array of strict detail entries: `childObject` (required), `relationshipField`, `columns`, `formFields`, `inlineMode` (`grid` | `form`), `amountField`, `sortField`, `totalField`, `title`, `minRows`, `maxRows` and `addLabel` — the keys the console's `MasterDetailForm` reads off an entry. An unknown key is named, with a rename for the near-misses a form view's `subforms[]` entry also answers (`foreignKey` → `relationshipField`, `object` → `childObject`, …).
17+
- **`details[].columns`** references `InlineGridColumnSchema`, the strict, name-keyed column the other two carriers take. Every rule the column schema holds applies here too, with its own message: an unknown key is named; the retired `field` spelling (and `fieldName`, `key`) is refused with the prescription naming `name`; a column without `name` is refused; `scale` on a column declaring `type: 'currency'` is refused with the currency ruling's remedy. Page-component `properties` is read by the component-props gate, so `objectstack validate`, `build` and `lint` report these as advisory `component-props-unknown-key` / `component-props-invalid` findings; a stored page still saves and loads, because `properties` is not parsed on the metadata save or load path.
18+
- **`defineStack`'s cross-reference check** now reaches the block wherever a page carries it (a region, a container's children, a slot) and judges a detail column that declares no `type` as the type it renders, as it already does on the other two carriers: an identity-only column over a `currency` field of the entry's `childObject` that carries `scale` is refused with the column schema's own message (`STACK_CROSS_REFERENCE_INVALID`, 422). A child object the stack does not declare, a column naming no field of it, and a column the column schema refuses on its own (left to the component-props gate) are not judged there.
19+
- **New type `ObjectMasterDetailFormPropsParsed`** — the post-parse shape of `ObjectMasterDetailFormProps`. The two now differ, because a column's `readonlyWhen` / `requiredWhen` bare-string predicate normalizes to an Expression envelope at parse.
20+
21+
**`@objectstack/lint`**
22+
23+
- **`field-no-consumers`** reads an `object-master-detail-form` detail entry as the child collection it is: a column `name`, `amountField` and `relationshipField` credit the field of the entry's `childObject`, `totalField` the parent's, and an entry with no `columns` credits the columns the child derives. A child field drawn only by a master-detail block's grid was reported inert.
24+
25+
## FROM → TO
26+
27+
| you wrote | write instead |
28+
|:--|:--|
29+
| `details: [{ title: 'Lines' }]` | `details: [{ title: 'Lines', childObject: 'invoice_line' }]` |
30+
| `details: [{ childObject: 'invoice_line', columns: ['product', 'quantity'] }]` | `details: [{ childObject: 'invoice_line', columns: [{ name: 'product' }, { name: 'quantity' }] }]` |
31+
| `details: [{ childObject: 'invoice_line', columns: [{ field: 'quantity' }] }]` | `details: [{ childObject: 'invoice_line', columns: [{ name: 'quantity' }] }]` |
32+
| `details: [{ childObject: 'invoice_line', columns: [{ name: 'amount', type: 'currency', scale: 2 }] }]` | `details: [{ childObject: 'invoice_line', columns: [{ name: 'amount', type: 'currency' }] }]` |
33+
| `columns: [{ name: 'amount', scale: 2 }]` where `amount` is a `currency` field of the entry's `childObject` | `columns: [{ name: 'amount' }]` |
34+
| a detail entry or column carrying a key its shape does not declare | the entry or column without that key |
35+
36+
The one-line fix: give every detail entry its `childObject`, write each column as `{ name, … }` using only the keys a relationship field's `inlineColumns` accepts, and delete `scale` from any column that renders as a currency column, whether it declares `type: 'currency'` or takes it from a `currency` child field. Nothing replaces `scale` there: the currency's ISO 4217 minor unit decides the displayed decimals.
37+
38+
## Who is affected, measured
39+
40+
On `origin/main` `ebdb6f2aca`: one authored `object-master-detail-form` block in the examples (the showcase project workspace, one entry `{ title, childObject, addLabel }`, no columns), which parses unchanged, and one documentation example whose three bare-string columns are rewritten as `{ name }` columns in this change. Zero `field`-keyed detail columns. Deployed metadata was not measured.

‎content/docs/protocol/objectui/layout-dsl.mdx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -765,7 +765,10 @@ properties:
765765

766766
A parent record edited alongside its child collections is the declared
767767
`object-master-detail-form` block. `details` entries derive the foreign key and
768-
columns from the child object's metadata when you omit them.
768+
columns from the child object's metadata when you omit them. An authored
769+
`columns` list takes the same name-keyed column a relationship field's
770+
`inlineColumns` takes: `{ name }` alone fills the label and cell type from the
771+
child field.
769772

770773
{/* os:check-yaml PageComponentSchema */}
771774
```yaml
@@ -779,7 +782,10 @@ properties:
779782
- title: Line Items
780783
childObject: invoice_line
781784
relationshipField: invoice_id
782-
columns: [product, quantity, unit_price]
785+
columns:
786+
- name: product
787+
- name: quantity
788+
- name: unit_price
783789
```
784790

785791
For the *master/detail browsing* pattern — pick a record on the left, show it on

‎content/docs/references/ui/component.mdx‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -950,7 +950,7 @@ Sort field and direction pair
950950
| **formType** | `Enum<'simple' \| 'tabbed'>` | optional | Parent form presentation — the two variants the renderer honours for the parent half |
951951
| **sections** | `any[]` | optional | Parent form sections |
952952
| **fields** | `any[]` | optional | Parent fields shown |
953-
| **details** | `any[]` | optional | Detail collections (`{ title, childObject, addLabel?, columns?, relationshipField? }` — FK and columns auto-derive from child metadata) |
953+
| **details** | `{ childObject: string; relationshipField?: string; columns?: object[]; formFields?: string[]; … }[]` | optional | Detail collections — each a strict entry (`{ childObject, title?, addLabel?, columns?, relationshipField?, … }`) whose `columns` are the inline grid columns a relationship field's `inlineColumns` takes; the FK and columns auto-derive from child metadata when omitted |
954954
| **title** | `string \| Record<string, string>` | optional | Form title |
955955
| **submitText** | `string \| Record<string, string>` | optional | Submit button label |
956956
| **cancelText** | `string \| Record<string, string>` | optional | Cancel button label |
@@ -959,6 +959,23 @@ Sort field and direction pair
959959
| **initialData** | `Record<string, any>` | optional | Alternate spelling of `initialValues` the renderer also reads |
960960
| **taxRateField** | `string` | optional | Child field holding the per-line tax rate (line-items totals) |
961961

962+
### Nested Shape: `ObjectMasterDetailFormProps.details[number]`
963+
964+
| Property | Type | Required | Description |
965+
| :--- | :--- | :--- | :--- |
966+
| **childObject** | `string` | ✅ | Child object whose records are entered inline |
967+
| **relationshipField** | `string` | optional | FK on the child pointing back to the parent (auto-detected from the child's master_detail/lookup field when omitted) |
968+
| **columns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Editable grid columns (derived from the child object when omitted). Each entry is the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. |
969+
| **formFields** | `string[]` | optional | Child field names for the per-row expand form (derived from the child object's editable fields when omitted) |
970+
| **inlineMode** | `Enum<'grid' \| 'form'>` | optional | Inline-edit form factor: 'grid' = editable cells; 'form' = read-only list + per-row full form. Resolved from the relationship field's `inlineEdit` when omitted |
971+
| **amountField** | `string` | optional | Numeric child column summed for the running total |
972+
| **sortField** | `string` | optional | Child field holding the line sort position, stamped on drag-reorder (derived from a `position` / `sort_order` / … field when omitted) |
973+
| **totalField** | `string` | optional | Parent field to receive the rolled-up sum |
974+
| **title** | `string` | optional | Section title |
975+
| **minRows** | `number` | optional | Minimum number of rows |
976+
| **maxRows** | `number` | optional | Maximum number of rows |
977+
| **addLabel** | `string` | optional | Add-row button label |
978+
962979

963980
---
964981

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th
2121

2222
| Dir | Sites | strict | passthrough | catchall | strip |
2323
|---|---|---|---|---|---|
24-
| `ui/` | 188 | 178 | 3 | 0 | 7 |
24+
| `ui/` | 189 | 179 | 3 | 0 | 7 |
2525

2626
## `ui/` — sites
2727

@@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
3636
| `app.zod.ts` | 19 |
3737
| `bulk-action.zod.ts` | 4 |
3838
| `chart.zod.ts` | 8 |
39-
| `component.zod.ts` | 56 |
39+
| `component.zod.ts` | 57 |
4040
| `dashboard.zod.ts` | 11 |
4141
| `dataset.zod.ts` | 4 |
4242
| `i18n.zod.ts` | 1 |
@@ -46,23 +46,23 @@ classify and is not listed (it becomes reportable the day it grows its first sit
4646
| `sharing.zod.ts` | 1 |
4747
| `view.zod.ts` | 62 |
4848
| `widget.zod.ts` | 1 |
49-
| **total** | **188** |
49+
| **total** | **189** |
5050

5151
## `ui/` — open
5252

5353
Per file, how many of its sites still silently discard unknown keys. The `Class`
5454
column that decides the bucket split is hand-written in the ledger; the arithmetic
5555
over it is here.
5656

57-
**7 strip of 188**, in 4 file(s).
57+
**7 strip of 189**, in 4 file(s).
5858

5959
| File | Strip | Sites |
6060
|---|---|---|
6161
| `action-params.zod.ts` | 1 | 1 |
6262
| `app.zod.ts` | 1 | 19 |
6363
| `view.zod.ts` | 4 | 62 |
6464
| `widget.zod.ts` | 1 | 1 |
65-
| **total** | **7** | **188** |
65+
| **total** | **7** | **189** |
6666

6767
| Bucket | Sites |
6868
|---|---|

‎packages/lint/src/validate-field-consumers.test.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,3 +835,92 @@ describe('[#20951] validateFieldConsumers — a child collection credits its chi
835835
});
836836
});
837837
});
838+
839+
/**
840+
* [#20928] The third carrier of an inline grid: an `object-master-detail-form`
841+
* page block's `details` entries. Each entry names its child in `childObject`
842+
* and its grid in `columns`, exactly as a `subforms` entry does, so it is read
843+
* as a child collection: a column `name` and `amountField` /
844+
* `relationshipField` against the child, `totalField` against the parent the
845+
* block's `objectName` binds, and an entry with no `columns` credits the grid
846+
* the child derives.
847+
*
848+
* `inv` is the parent and `line` the child; each declares a field the other's
849+
* key names, so a key read against the wrong object shows up as the wrong one
850+
* of the pair going quiet. `memo` is named nowhere: the control.
851+
*/
852+
describe('[#20928] validateFieldConsumers — an `object-master-detail-form` detail entry is a child collection', () => {
853+
const data = { provider: 'object', object: 'inv' };
854+
855+
const stack = (details: unknown[], slots?: AnyRec): AnyRec => ({
856+
objects: [
857+
{ name: 'inv', fields: { name: { type: 'text' }, qty: { type: 'number' }, total: { type: 'number' } } },
858+
{
859+
name: 'line',
860+
fields: {
861+
name: { type: 'text' },
862+
invoice: { type: 'master_detail', reference: 'inv' },
863+
qty: { type: 'number' },
864+
total: { type: 'number' },
865+
line_total: { type: 'number' },
866+
memo: { type: 'text' },
867+
},
868+
},
869+
],
870+
views: [{ list: { type: 'grid', data, columns: [{ field: 'name' }] } }],
871+
pages: [{
872+
name: 'inv_entry',
873+
regions: [{
874+
name: 'main',
875+
components: [{ type: 'object-master-detail-form', properties: { objectName: 'inv', details } }],
876+
}],
877+
...(slots ? { kind: 'slotted', slots } : {}),
878+
}],
879+
});
880+
881+
/** `object.field` → verdict, for every field the rule reports. */
882+
const verdicts = (s: AnyRec): Record<string, string> =>
883+
Object.fromEntries(validateFieldConsumers(s).map((f) => [`${f.object}.${f.field}`, f.verdict]));
884+
885+
/** Every non-name field of both objects, reported: what a block with no detail entry leaves. */
886+
const NOTHING_READ = {
887+
'inv.qty': 'inert',
888+
'inv.total': 'inert',
889+
'line.qty': 'inert',
890+
'line.total': 'inert',
891+
'line.line_total': 'inert',
892+
'line.memo': 'inert',
893+
};
894+
895+
it('baseline: a block with no detail entry reads no field of either object', () => {
896+
expect(verdicts(stack([]))).toEqual(NOTHING_READ);
897+
});
898+
899+
it("an authored column credits the CHILD's field, not the parent's same-named one", () => {
900+
const { 'line.qty': _credited, ...rest } = NOTHING_READ;
901+
expect(verdicts(stack([{ childObject: 'line', columns: [{ name: 'qty' }] }]))).toEqual(rest);
902+
});
903+
904+
it("`amountField` credits the CHILD's field and `totalField` the PARENT's — each same-named counterpart stays reported", () => {
905+
const entry = { childObject: 'line', columns: [{ name: 'qty' }], amountField: 'line_total', totalField: 'total' };
906+
expect(verdicts(stack([entry]))).toEqual({
907+
'inv.qty': 'inert',
908+
'line.total': 'inert',
909+
'line.memo': 'inert',
910+
});
911+
});
912+
913+
it('an entry with no `columns` credits the columns its child derives', () => {
914+
// `deriveInlineGridColumns` draws every editable child field but the
915+
// relationship back to the parent, so only the parent's two stay reported.
916+
expect(verdicts(stack([{ childObject: 'line' }]))).toEqual({ 'inv.qty': 'inert', 'inv.total': 'inert' });
917+
});
918+
919+
it("control: the page slot map's own `details` key holds components, and one read as an entry is read exactly as anywhere else", () => {
920+
const component = { type: 'record:details', properties: { objectName: 'line', fields: ['memo'] } };
921+
const elsewhere = verdicts(stack([], { header: [component] }));
922+
// Non-vacuity: the component does read a field, so a slot that skipped it would show.
923+
expect(elsewhere['line.memo']).toBeUndefined();
924+
expect(verdicts(stack([], { details: [component] }))).toEqual(elsewhere);
925+
});
926+
});

‎packages/lint/src/validate-field-consumers.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,19 @@ const WRITE_KEYS: ReadonlySet<string> = new Set([
283283
/**
284284
* [#20929] Keys whose array entries are INLINE CHILD COLLECTIONS: each entry
285285
* names its child object in `childObject` and the child's grid in `columns`.
286-
* Today that is a form view's `subforms` (`FormViewSchema.subforms`, on a view
287-
* container's `form` and on every `formViews` entry), whose `columns` is the
288-
* same `InlineGridColumnSchema` a relationship field's `inlineColumns` takes.
286+
* That is a form view's `subforms` (`FormViewSchema.subforms`, on a view
287+
* container's `form` and on every `formViews` entry) and, since #20928, an
288+
* `object-master-detail-form` page block's `details`
289+
* (`ComponentPropsMap['object-master-detail-form']`). On both, `columns` is the
290+
* same `InlineGridColumnSchema` a relationship field's `inlineColumns` takes,
291+
* and `amountField` / `relationshipField` / `totalField` mean the same thing.
292+
*
293+
* A page's slot map also has a `details` key, whose value is one page
294+
* component or an array of them. A component carries none of a child entry's
295+
* keys at its own level (they sit under its `properties`), so reading it as an
296+
* entry credits nothing and skips nothing.
289297
*/
290-
const CHILD_COLLECTION_KEYS: ReadonlySet<string> = new Set(['subforms']);
298+
const CHILD_COLLECTION_KEYS: ReadonlySet<string> = new Set(['subforms', 'details']);
291299

292300
/**
293301
* [#20951] Keys of a child collection entry whose value names a field of the

‎packages/spec/api-surface/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@
297297
"ObjectMapPropsParsed (type)",
298298
"ObjectMapPropsSchema (const)",
299299
"ObjectMasterDetailFormProps (type)",
300+
"ObjectMasterDetailFormPropsParsed (type)",
300301
"ObjectMasterDetailFormPropsSchema (const)",
301302
"ObjectMetricProps (type)",
302303
"ObjectMetricPropsParsed (type)",

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.",
33
"measured": {
44
"zod": "4.4.3",
5-
"publishedSchemasWithDroppedRefinements": 212,
6-
"droppedRefinementSites": 617,
5+
"publishedSchemasWithDroppedRefinements": 213,
6+
"droppedRefinementSites": 618,
77
"refinementSitesThatDidProject": 369,
88
"refinementSitesWithNoJsonFormToCompare": 0
99
},
@@ -1354,6 +1354,11 @@
13541354
"filter.element"
13551355
]
13561356
},
1357+
"ui/ObjectMasterDetailFormProps": {
1358+
"sites": [
1359+
"details.element.columns.element"
1360+
]
1361+
},
13571362
"ui/ObjectMetricProps": {
13581363
"sites": [
13591364
"filter.element"

‎packages/spec/export-origins/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@
293293
"ObjectMapPropsParsed": "src/ui/component.zod.ts#ObjectMapPropsParsed (type)",
294294
"ObjectMapPropsSchema": "src/ui/component.zod.ts#ObjectMapPropsSchema (const)",
295295
"ObjectMasterDetailFormProps": "src/ui/component.zod.ts#ObjectMasterDetailFormProps (type)",
296+
"ObjectMasterDetailFormPropsParsed": "src/ui/component.zod.ts#ObjectMasterDetailFormPropsParsed (type)",
296297
"ObjectMasterDetailFormPropsSchema": "src/ui/component.zod.ts#ObjectMasterDetailFormPropsSchema (const)",
297298
"ObjectMetricProps": "src/ui/component.zod.ts#ObjectMetricProps (type)",
298299
"ObjectMetricPropsParsed": "src/ui/component.zod.ts#ObjectMetricPropsParsed (type)",

0 commit comments

Comments
 (0)