Skip to content
4 changes: 2 additions & 2 deletions .changeset/24190-line-items-hydrated-check.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/spec': minor
---
Expand All @@ -12,7 +12,7 @@

**`@objectstack/spec`**

- **`defineStack`'s cross-reference check** now reaches a `record:line_items` block in a page region's components, in a container's component children and in a slot. A block under a `page:tabs` item's `children` is not yet judged. It judges a column that declares no `type` as the type it renders, and reports the column schema's own refusal (`STACK_CROSS_REFERENCE_INVALID`, 422). Today that means `scale` on an identity-only column over a `currency` field of the child object. The child object is the one the panel lists: the object `dataSource` binding's `object` when the node carries one, else `childObject`. 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.
- **`defineStack`'s cross-reference check** now reaches a `record:line_items` block in a page region's components, in a container's component children, in a slot and, since #24349, in a `page:tabs` or `page:accordion` item's `children`. It judges a column that declares no `type` as the type it renders, and reports the column schema's own refusal (`STACK_CROSS_REFERENCE_INVALID`, 422). Today that means `scale` on an identity-only column over a `currency` field of the child object. The child object is the one the panel lists: the object `dataSource` binding's `object` when the node carries one, else `childObject`. 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.
- The step-18 migration entry `ui-record-line-items-props-closed` now says so, and no longer says that this block draws a column exactly as declared.

## FROM → TO
Expand All @@ -25,4 +25,4 @@

## Who is affected, measured

On `origin/main` `ec749fbb44`: two authored `record:line_items` blocks in the examples (the showcase project detail page, five typed columns, its block under a `page:tabs` item, so not yet judged; the showcase invoice document page, four `{ name, label }` columns, two of them over `currency` fields) and one in the published skills (four `{ name }` columns). None carries `scale`, so no example, skill or fixture outside this change's own pins changes verdict. Deployed metadata was not measured.
On `origin/main` `ec749fbb44`: two authored `record:line_items` blocks in the examples (the showcase project detail page, five typed columns, its block under a `page:tabs` item; the showcase invoice document page, four `{ name, label }` columns, two of them over `currency` fields) and one in the published skills (four `{ name }` columns). None carries `scale`, so no example, skill or fixture outside this change's own pins changes verdict. Deployed metadata was not measured.
31 changes: 31 additions & 0 deletions .changeset/24349-page-walk-panel-children.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/spec': minor
---

fix(spec)!: `defineStack`'s page cross-reference checks judge a component inside a `page:tabs` or `page:accordion` item's `children`, as they judge one at region level (#24349)

Clause-②: yes (narrowing)

<!-- adr-0087: registered ui-page-panel-children-cross-references-judged -->

**BREAKING** — an accept-set narrowing of what `defineStack` and `objectstack validate` publish, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. `defineStack` reads a page's components through one traversal, shared by its inline-action target check and its inline grid column check. A tab or accordion item is a panel record (`{ label, value?, children }`), not a component, and the traversal stopped at it, so nothing inside a tab or an accordion panel was judged: a button whose modal target names no page built green in a tab and was refused one level up.

**`@objectstack/spec`**

- **`defineStack`'s page cross-reference checks** now reach a component in a `page:tabs` or `page:accordion` item's `children`, at any depth (tabs in an accordion panel, a panel in a container or under a slot). The traversal descends the panel positions the component rows declare (`pageComponentSlotPositions`), the list the other page walks already read, so it follows the declared shapes rather than naming tabs. A record under a key no row declares a panel list is not descended.
- A component in a panel gets the region-level verdict and message, located through the panel: `STACK_CROSS_REFERENCE_INVALID` (422), for example `Inline action 'open_detail' on page 'invoice_record' (regions.0.components.0.properties.items.1.children.0) references page 'no_such_modal' (via modal target) which is not defined in pages.`
- Two checks reach further: an inline action's `type: 'modal'` / `type: 'flow'` `target`, and an identity-only column (one that declares no `type`) carrying `scale` over a `currency` child field, on an `object-master-detail-form` block's `details[].columns` or a `record:line_items` block's `columns`. That includes a `record:line_items` block in a tab or accordion panel, a position the block's identity-only check did not reach before this change.
- New step-18 migration entry `ui-page-panel-children-cross-references-judged`; the entry `ui-record-line-items-props-closed` now names the panel positions in its reach.

## FROM → TO

| you wrote, inside a `page:tabs` or `page:accordion` item's `children` | write instead |
|:--|:--|
| `{ type: 'element:button', properties: { action: { type: 'modal', target: 'no_such_page' } } }` | a `target` naming a page the stack declares (or a declared flow, for `type: 'flow'`) |
| `{ type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'amount', scale: 2 }] } }` where `amount` is a `currency` field of `invoice_line` | the same block with `columns: [{ name: 'amount' }]` |

The one-line fix: give the component inside the panel the same fix it would need at region level. Nothing about the panel or its items changes.

## Who is affected, measured

On `origin/main` `31e7d0ba2`: four authored pages nest components under a panel item (the showcase project detail page, whose `record:line_items` block declares a `type` on all five columns; the platform user and organization record pages; the approval request record page), one documentation example does (three tabs of record blocks), and the published skills carry none. None holds an inline action target or an identity-only column under a panel, so no example, skill or fixture outside this change's own pins changes verdict. Deployed metadata was not measured.
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// The reach of `defineStack`'s page cross-reference checks, extended into tab
// and accordion panels. Both checks (inline-action targets, inline grid
// columns) read a page through one traversal, which descended only arrays of
// component-shaped entries; a `page:tabs` / `page:accordion` item is a panel
// record, so the traversal stopped at it and every component inside a panel
// went unjudged. It now also descends the panel positions the component rows
// declare, the list the other page walks read. D3 only: the refusal is at
// authoring time, nothing on the metadata load path refuses the shape, and a
// dangling target or a refused column key has no lossless rewrite.
export const entry: SemanticMigration = {
id: 'ui-page-panel-children-cross-references-judged',
surface: 'page components under a `page:tabs` or `page:accordion` item\'s `children` '
+ '(`properties.items[].children[]`, at any depth), as `defineStack`\'s page cross-reference '
+ 'checks judge them: an inline action\'s `type: \'modal\'` or `type: \'flow\'` `target` '
+ '(`properties.action`), and an inline grid column that declares no `type` in an '
+ '`object-master-detail-form` block\'s `details[].columns[]` or a `record:line_items` block\'s '
+ '`columns[]`',
replacement: 'what the same component is held to at region level: an inline `type: \'modal\'` '
+ 'action\'s `target` names a page the stack declares and a `type: \'flow\'` action\'s `target` a '
+ 'flow it declares; an identity-only column over a `currency` field of the child object carries '
+ 'no `scale` — delete the key, nothing replaces it, the currency\'s ISO 4217 minor unit decides. '
+ 'The panel, its items and their keys do not change.',
reason: '`defineStack` judges a page\'s components through one traversal, shared by its '
+ 'inline-action target check and its inline grid column check. The traversal descended an '
+ 'array under `properties` only when its entries were component-shaped (an object carrying a '
+ 'string `type`), and a tab or accordion item is a panel record (`{ label, value?, children }`), '
+ 'not a component, so it stopped at the item and nothing inside a tab or an accordion panel was '
+ 'judged. A button whose modal target named no page built green inside a tab while the same '
+ 'button at region level was refused, and an identity-only `scale` column over a `currency` '
+ 'child field split the same way on both page blocks that draw an inline grid. The traversal '
+ 'now also descends the panel positions the component rows declare (`pageComponentSlotPositions` '
+ 'in `ui/component.zod.ts`; today `items[].children`, declared by `page:tabs` and '
+ '`page:accordion`), the list the exported page walk, the ADR-0087 conversion walker and the '
+ 'component-props gate already read, matched by shape on every component as they match it. A '
+ 'component in a panel is refused with the region-level message and status 422, located through '
+ 'the panel (`regions.0.components.0.properties.items.1.children.0`). A record under a key no '
+ 'row declares a panel list is not descended. No conversion is registered: the refusal is at '
+ 'authoring time (`defineStack`, and so `objectstack validate` and `objectstack build`), nothing '
+ 'on the metadata load path refuses the shape, and a dangling target has no right value to '
+ 'guess. Population measured at the change, on origin/main 31e7d0ba2: four authored pages nest '
+ 'components under a panel item (the showcase project detail page, whose `record:line_items` '
+ 'block declares a `type` on all five columns; the platform user and organization record pages; '
+ 'the approval request record page), one documentation example does (three tabs of record '
+ 'blocks), and the published skills carry none. None of them holds an inline action target or '
+ 'an identity-only column under a panel, so none changes verdict. Deployed metadata NOT MEASURED.',
acceptanceCriteria: '`objectstack validate` and `defineStack` report no cross-reference finding on '
+ 'a path through `properties.items.N.children`. Every inline `type: \'modal\'` action inside a tab '
+ 'or accordion panel names a page the stack declares, every `type: \'flow\'` action a declared '
+ 'flow, and no identity-only column there over a `currency` child field carries `scale` — the '
+ 'verdicts the same components get at region level.',
};
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,12 @@ export const entry: SemanticMigration = {
+ 'page component\'s `properties` is not parsed on the metadata load path, and the next save of the page meets the component-props gate at the metadata save door. The '
+ 'identity-only half is `defineStack`\'s cross-reference check: the panel hydrates a column '
+ 'that declares no `type` from the child object\'s field, with the same rule the other carriers '
+ 'use, so the check reaches the block in a page region\'s components, in a container\'s '
+ 'component children and in a slot, and refuses an identity-only column over a `currency` '
+ 'child field that carries `scale`, with the column schema\'s own message; reach: a block under '
+ 'a `page:tabs` item\'s `children` is not yet judged, the child object — the object '
+ '`dataSource` binding\'s `object` when the node carries one, else `childObject` — must be '
+ 'use, so the check reaches the block wherever a page carries it — in a page region\'s '
+ 'components, in a container\'s component children, in a `page:tabs` or `page:accordion` '
+ 'item\'s `children` and in a slot — and refuses an identity-only column over a `currency` '
+ 'child field that carries `scale`, with the column schema\'s own message; reach: the child '
+ 'object — the object `dataSource` binding\'s `object` when the node carries one, else '
+ '`childObject` — must be '
+ 'declared in the same stack, and a column the column schema refuses on its own is left to the '
+ 'component-props gate. No conversion is registered: '
+ 'nothing on the load path refuses the shape, and the one `field`-keyed producer was respelled '
Expand Down
23 changes: 19 additions & 4 deletions packages/spec/src/migrations/registry.ts.template
Original file line number Diff line number Diff line change
Expand Up @@ -3192,6 +3192,21 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [
+ '`os migrate meta` replay it. Its D3 record is the semantic entry '
+ '`ui-page-action-row-navigation-executor-refused`.',
},
{
id: 'ui-page-panel-children-cross-references-judged',
order: 103,
text:
'It also extends `defineStack`\'s page cross-reference checks into tab and accordion panels. The '
+ 'one traversal the inline-action target check and the inline grid column check share descended '
+ 'an array under `properties` only when its entries were components, and a `page:tabs` or '
+ '`page:accordion` item is a panel record, not a component, so nothing inside a panel was judged: '
+ 'a dangling inline modal target built green in a tab and was refused at region level. The '
+ 'traversal now also descends the panel positions the component rows declare, the list every '
+ 'other page walk reads, and a component in a panel is refused with the region-level message, '
+ 'located through the panel. The refusal is at authoring time and nothing stored is rewritten, '
+ 'so no conversion is registered. Its D3 record is the semantic entry '
+ '`ui-page-panel-children-cross-references-judged`.',
},
{
id: 'ui-record-json-retired',
order: 100,
Expand Down Expand Up @@ -3220,10 +3235,10 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [
+ 'other carriers do, so `defineStack`\'s identity-only check reaches the block, with the reach '
+ '`inline-grid-column-identity-only-currency-scale-refused` records for the first two '
+ 'carriers, against the child object the panel lists (the object `dataSource` binding\'s '
+ '`object` when the node carries one, else `childObject`). It judges the block in a page '
+ 'region\'s components, in a container\'s component children and in a slot; a block under a '
+ '`page:tabs` item\'s `children` is not yet judged. Its D3 record is the semantic entry '
+ '`ui-record-line-items-props-closed`.',
+ '`object` when the node carries one, else `childObject`). It judges the block wherever a page '
+ 'carries it: in a page region\'s components, in a container\'s component children, in a '
+ '`page:tabs` or `page:accordion` item\'s `children` and in a slot. Its D3 record is the '
+ 'semantic entry `ui-record-line-items-props-closed`.',
},
{
id: 'ui-report-joined-block-dataset-required',
Expand Down
Loading
Loading