diff --git a/.changeset/24190-line-items-hydrated-check.md b/.changeset/24190-line-items-hydrated-check.md new file mode 100644 index 00000000000..620e32ec808 --- /dev/null +++ b/.changeset/24190-line-items-hydrated-check.md @@ -0,0 +1,28 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec)!: `defineStack` judges a `record:line_items` column that declares no `type` as the type it renders, as it already does on the master-detail carriers (#24190) + +Clause-②: yes (narrowing) + + + +**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. The console's `record:line_items` panel fills a column that declares no `type` from the child object's field, with the same rule as the master-detail grid. `defineStack`'s cross-reference check judged that resolved type on the three master-detail carriers and not on this block, so the same column got two answers. + +**`@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. +- 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 + +| you wrote | write instead | +|:--|:--| +| `{ 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: delete `scale` from a `record:line_items` 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. + +## 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. diff --git a/packages/spec/src/inline-grid-column-carriers.test.ts b/packages/spec/src/inline-grid-column-carriers.test.ts index 8672d4dbc7b..6d23071154e 100644 --- a/packages/spec/src/inline-grid-column-carriers.test.ts +++ b/packages/spec/src/inline-grid-column-carriers.test.ts @@ -613,11 +613,11 @@ describe('defineStack judges an identity-only detail column by the type it rende * the showcase project page's five `field`-keyed columns published green over * a grid of empty cells. * - * One difference from the master-detail block is pinned on purpose: this - * panel hands `columns` to the grid as authored, with no hydration from the - * child object's field. So an identity-only column has no resolved type for - * `defineStack` to judge, and the cross-reference check does not reach this - * block — the last test below is that control. + * The panel hydrates an identity-only column from the child object's field + * through the same `hydrateColumns` the master-detail block calls (#24140, + * objectstack-ai/objectui#12854), so `defineStack` judges its resolved type as + * it does on the other three carriers (#24190): the last section below, whose + * first test holds the two page blocks to the same answer for the same column. */ const LINE_ITEMS_PROPS = ComponentPropsMap['record:line_items']; @@ -737,22 +737,149 @@ describe('the line-items block is strict, and its columns are the column contrac expect(parseLineItems(viaBinding).success).toBe(true); }); - it('CONTROL — defineStack does not judge an identity-only line-items column: the panel draws it unhydrated', () => { - const page = { - name: 'crm_invoice_record', - label: 'Invoice', - type: 'record' as const, - object: 'crm_invoice', - regions: [{ - name: 'main', - components: [{ - type: 'record:line_items', - properties: { childObject: 'crm_invoice_line', relationshipField: 'invoice', columns: [IDENTITY_ONLY_WITH_SCALE] }, - }], - }], - }; - expect(() => build({ manifest, objects: [PARENT, childObject()], pages: [page] })).not.toThrow(); - // The same column under the master-detail block IS judged — the reach is the carrier's. - expect(() => build(stackWithMasterDetailColumns([IDENTITY_ONLY_WITH_SCALE]))).toThrow(); +}); + +// --------------------------------------------------------------------------- +// #24190 — `defineStack` judges the fourth carrier's identity-only columns. +// --------------------------------------------------------------------------- + +type LineItemsBlockOptions = { + /** The block's `childObject`; `null` leaves the key out. */ + childObject?: string | null; + /** The node-level `dataSource` binding, when the block carries one. */ + dataSource?: Record; +}; + +const lineItemsBlock = ( + columns: unknown[], + { childObject: child = 'crm_invoice_line', dataSource }: LineItemsBlockOptions = {}, +) => ({ + type: 'record:line_items', + ...(dataSource ? { dataSource } : {}), + properties: { ...(child === null ? {} : { childObject: child }), relationshipField: 'invoice', columns }, +}); + +/** A stack whose one record page carries `block` in the given position. */ +const stackWithLineItemsBlock = (block: Record, position: PagePosition = 'region') => { + const page = { + name: 'crm_invoice_record', + label: 'Invoice', + type: 'record' as const, + object: 'crm_invoice', + regions: [{ + name: 'main', + components: position === 'region' ? [block] + : position === 'nested' ? [{ type: 'page:card', properties: { children: [block] } }] + : [], + }], + ...(position === 'slot' ? { kind: 'slotted', slots: { details: [block] } } : {}), + }; + return { manifest, objects: [PARENT, childObject()], pages: [page] }; +}; + +const stackWithLineItemColumns = (columns: unknown[], position: PagePosition = 'region') => + stackWithLineItemsBlock(lineItemsBlock(columns), position); + +const LINE_ITEMS_AT = "Page 'crm_invoice_record' (regions.0.components.0) record:line_items columns"; +const MASTER_DETAIL_AT = "Page 'crm_invoice_entry' (regions.0.components.0) object-master-detail-form details[0].columns"; + +/** A finding with its location cut off: what the column and its child field make of it. */ +const findingPast = (refusal: Refusal, location: string): string => { + const finding = String(refusal.issues![0]); + expect(finding.startsWith(location), finding).toBe(true); + return finding.slice(location.length); +}; + +describe('defineStack judges an identity-only line-items column by the type it renders as', () => { + it('PARITY — the line-items block and the master-detail block give the same answer for the same column', () => { + for (const columns of [[IDENTITY_ONLY_WITH_SCALE], [{ name: 'quantity' }, IDENTITY_ONLY_WITH_SCALE]]) { + const lineItems = refusalOf(stackWithLineItemColumns(columns)); + const masterDetail = refusalOf(stackWithMasterDetailColumns(columns)); + for (const refusal of [lineItems, masterDetail]) { + expect(refusal.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refusal.status).toBe(422); + expect(refusal.issues).toHaveLength(1); + } + expect(findingPast(lineItems, LINE_ITEMS_AT)).toBe(findingPast(masterDetail, MASTER_DETAIL_AT)); + } + // …and the same column without the refused key builds under both. + expect(() => build(stackWithLineItemColumns([{ name: 'amount' }]))).not.toThrow(); + expect(() => build(stackWithMasterDetailColumns([{ name: 'amount' }]))).not.toThrow(); + }); + + it('refuses the card\'s identity-only column over a currency child field, naming the column and the child field\'s type', () => { + expectHydratedCurrencyRefusal( + stackWithLineItemColumns([{ name: 'quantity' }, IDENTITY_ONLY_WITH_SCALE]), + `${LINE_ITEMS_AT}[1].scale`, + ); + }); + + it('refuses it inside a container and under a slot too — the other positions the page walk visits', () => { + expectHydratedCurrencyRefusal( + stackWithLineItemColumns([IDENTITY_ONLY_WITH_SCALE], 'nested'), + "Page 'crm_invoice_record' (regions.0.components.0.properties.children.0) record:line_items columns[0].scale", + ); + expectHydratedCurrencyRefusal( + stackWithLineItemColumns([IDENTITY_ONLY_WITH_SCALE], 'slot'), + "Page 'crm_invoice_record' (slots.details.0) record:line_items columns[0].scale", + ); + }); + + it('judges the column against the child object the renderer lists: an object `dataSource` binding wins over `childObject`', () => { + const bound = { object: 'crm_invoice_line' }; + // The binding alone supplies the object. + expectHydratedCurrencyRefusal( + stackWithLineItemsBlock(lineItemsBlock([IDENTITY_ONLY_WITH_SCALE], { childObject: null, dataSource: bound })), + `${LINE_ITEMS_AT}[0].scale`, + ); + // The binding is written over an authored `childObject` this stack does not declare. + expectHydratedCurrencyRefusal( + stackWithLineItemsBlock(lineItemsBlock([IDENTITY_ONLY_WITH_SCALE], { childObject: 'ext_line', dataSource: bound })), + `${LINE_ITEMS_AT}[0].scale`, + ); + // The other direction: the panel lists the bound object, which this stack + // does not declare, so the declared `childObject` is not the column's child. + expect(() => build(stackWithLineItemsBlock( + lineItemsBlock([IDENTITY_ONLY_WITH_SCALE], { dataSource: { object: 'ext_line' } }), + ))).not.toThrow(); + }); + + it('judges it against `childObject` under an API binding, which names no object', () => { + const api = { provider: 'api', read: { url: '/api/v1/cloud/home' } }; + expectHydratedCurrencyRefusal( + stackWithLineItemsBlock(lineItemsBlock([IDENTITY_ONLY_WITH_SCALE], { dataSource: api })), + `${LINE_ITEMS_AT}[0].scale`, + ); + }); + + it('leaves a column that is invalid on its own to the props half: no finding the resolved type does not bring', () => { + const column = { ...IDENTITY_ONLY_WITH_SCALE, zzz_not_a_key: 1 }; + expect(parseLineItemColumns([column]).success).toBe(false); + expect(() => build(stackWithLineItemColumns([column]))).not.toThrow(); + }); + + it('CONTROLS — valid columns build, and so do the typed and bogus-key columns, which only the props half reads', () => { + const columns = [ + { name: 'quantity', scale: 1 }, + { name: 'amount' }, + { name: 'amount', prefix: 'US$' }, + { name: 'amount', type: 'number', scale: 2 }, + { name: 'not_a_child_field', scale: 2 }, + ]; + expect(() => build(stackWithLineItemColumns(columns))).not.toThrow(); + expect(() => build(stackWithLineItemColumns([TYPED_CURRENCY_WITH_SCALE, BOGUS_KEY_ONLY]))).not.toThrow(); + }); + + it('CONTROL — an unresolved child object is not judged: one this stack does not declare, or none at all', () => { + expect(() => build(stackWithLineItemsBlock( + lineItemsBlock([IDENTITY_ONLY_WITH_SCALE], { childObject: 'ext_line' }), + ))).not.toThrow(); + expect(() => build(stackWithLineItemsBlock( + lineItemsBlock([IDENTITY_ONLY_WITH_SCALE], { childObject: null }), + ))).not.toThrow(); + }); + + it('following the remedy builds: the refused column with `scale` deleted and nothing added', () => { + expect(() => build(stackWithLineItemColumns([{ name: 'amount' }]))).not.toThrow(); }); }); diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts index 0b2840e0736..e8cebee6bf5 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts @@ -13,21 +13,26 @@ import type { SemanticMigration } from '../../types.js'; // never refused at load (its next save meets the component-props gate at the // metadata save door) and there is no load-path refusal for a conversion to // pre-empt; the one `field`-keyed producer the census found is respelled in the -// same change. +// same change. #24190 — once the panel hydrated an identity-only column from the +// child field, `defineStack`'s identity-only check reached this carrier too, the +// reach `inline-grid-column-identity-only-currency-scale-refused` records for +// the relationship field and the form view. export const entry: SemanticMigration = { id: 'ui-record-line-items-props-closed', surface: 'page `record:line_items` components — `properties` (which used to accept any key) and ' - + '`properties.columns[]` (its inline grid columns)', + + '`properties.columns[]` (its inline grid columns), including `scale` on a column that ' + + 'declares no `type` and whose `name` is a `currency` field of the block\'s child object', replacement: 'the declared shape the renderer reads: `{ childObject?, relationshipField, columns, ' + 'parentObject?, parentId?, recordId?, amountField?, totalField?, title?, readonly?, minRows?, ' + 'maxRows?, filter?, sort?, limit? }`, with `filter` the ViewFilterRule array, `sort` the SortItem ' + 'array and `limit` a positive integer; `childObject` may come from the component-level ' + '`dataSource` binding instead. `columns` is required and holds at least one column, each the ' + 'strict, name-keyed inline grid column a relationship field\'s `inlineColumns` takes — ' - + '`{ name, label?, type?, options?, … }`. Write `name` where a column said `field` (or ' - + '`fieldName`, `key`); declare `label`, `type` and `options` on the column, because this block ' - + 'draws a column exactly as declared and hydrates nothing from the child object\'s field; ' - + 'delete `scale` from a column declaring `type: \'currency\'`; delete `addLabel`, `formFields` ' + + '`{ name, label?, type?, options?, … }`, where `{ name }` alone hydrates the rest from the ' + + 'child object\'s field. Write `name` where a column said `field` (or `fieldName`, `key`); ' + + 'delete `scale` from a column that renders as a currency column, whether it declares ' + + '`type: \'currency\'` or takes it from a `currency` child field — nothing replaces it, the ' + + 'currency\'s ISO 4217 minor unit decides; delete `addLabel`, `formFields` ' + 'and `inlineMode`, which belong to an `object-master-detail-form` detail entry and are not read ' + 'here, `sortField`, which no block takes (the detail entry derives the line-position field from ' + 'the child object), and any other key the shape does not declare.', @@ -43,19 +48,32 @@ export const entry: SemanticMigration = { + 'component-props gate reports a failing key or column as a ' + '`component-props-unknown-key` / `component-props-invalid` error on `objectstack validate`, ' + '`objectstack build` and `objectstack lint`, and a stored page still loads, because a ' - + '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. `defineStack`\'s ' - + 'identity-only column check does not reach this block: the panel hands its columns to the grid ' - + 'as authored, so there is no hydrated type to judge. No conversion is registered: nothing on the ' - + 'load path refuses the shape, and the one `field`-keyed producer was respelled in the same ' - + 'change. Population measured at the change, on origin/main 1ecb871beb: one authored block in ' - + 'the examples (the showcase project detail page, five `field`-keyed columns, respelled `name`), ' - + 'zero in the documentation, against eight authored `record:*` blocks of other types through ' - + 'the same matcher as the control. ' - + 'Deployed metadata NOT MEASURED.', + + '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 ' + + '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 ' + + 'in the same change. Population measured at the change, on origin/main 1ecb871beb: one ' + + 'authored block in the examples (the showcase project detail page, five `field`-keyed columns, ' + + 'respelled `name`), zero in the documentation, against eight authored `record:*` blocks of ' + + 'other types through the same matcher as the control. Population for the identity-only reach, ' + + 'measured on origin/main ec749fbb44: two authored 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, none carrying ' + + '`scale`. Deployed metadata NOT MEASURED.', acceptanceCriteria: 'Every `record:line_items` node validates: `objectstack validate` reports no ' - + '`component-props-unknown-key` / `component-props-invalid` finding on its `properties` path. ' + + '`component-props-unknown-key` / `component-props-invalid` finding on its `properties` path ' + + 'and no cross-reference finding on a `columns[].scale` path. ' + 'Every node carries `relationshipField` and at least one column, every column is an object ' - + 'carrying `name`, no column carries `field`, `fieldName` or `key`, and no key outside the ' + + 'carrying `name`, no column carries `field`, `fieldName` or `key`, no column that renders as a ' + + 'currency column carries `scale`, and no key outside the ' + 'declared shape is present. The showcase project detail page\'s block parses with its five ' + '`name`-keyed columns, and its grid renders a value — not a blank cell — in each column for a ' + 'row that has one.', diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index fc10316eeb9..53a93fc2127 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -3216,8 +3216,13 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'green over a grid of empty cells. The row declares the fifteen keys the renderer ' + 'reads, requires `relationshipField` and at least one column, and its `columns` references ' + '`InlineGridColumnSchema`; the showcase columns are respelled `name` in the same change. The ' - + 'panel draws its columns as authored, with no hydration from the child object\'s field, so ' - + '`defineStack`\'s identity-only check does not reach it. Its D3 record is the semantic entry ' + + 'panel hydrates a column that declares no `type` from the child object\'s field, as the ' + + '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`.', }, { diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 1f202d3edb6..ab9c960b1b4 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -2387,7 +2387,8 @@ const isComponentNode = (value: unknown): value is Record => /** * Visit every component a page carries, with its locatable path, parent before * children — the one traversal of a page's component tree the cross-reference - * checks share (inline actions, #6889; master-detail detail columns, #20928). + * checks share (inline actions, #6889; master-detail detail columns, #20928; + * line-items columns, #24190). * `visit` receives the node and its `properties` record; a node without one is * not visited, since every check here reads under `properties`. * @@ -3551,13 +3552,13 @@ const hasOwnKey = (record: object, key: string): boolean => Object.prototype.has * raises is reported, with the schema's own message. A column is judged only * once it is a valid column WITHOUT the type, so an issue here is one the * resolved type brings. On the two carriers the stack's parse reaches, it - * already passed that parse; on the page carrier, whose `properties` the + * already passed that parse; on the two page carriers, whose `properties` the * stack's parse never reaches, the column is parsed alone first and a column * that fails is left to the component-props gate (`@objectstack/lint`'s * `validateComponentProps`), which reports its own defects against * `ComponentPropsMap`. * - * All three carriers of the column are walked: + * All four carriers of the column are walked: * * - a relationship field's `inlineColumns` — the field sits on the CHILD * object, so a column names a field of the object that owns the field; @@ -3566,7 +3567,13 @@ const hasOwnKey = (record: object, key: string): boolean => Object.prototype.has * `childObject`; * - [#20928] an `object-master-detail-form` page block's `details[].columns`, * wherever the block sits on a page ({@link forEachPageComponent}) — a column - * names a field of the detail entry's `childObject`. + * names a field of the detail entry's `childObject`; + * - [#24190] a `record:line_items` page block's `columns`, in the positions + * {@link forEachPageComponent} visits — a region's components, a + * container's component children, a slot; a block under a `page:tabs` + * item's `children` is not yet visited, so not yet judged — a column names + * a field of the child object the panel lists, resolved the way the + * renderer resolves it ({@link lineItemsChildObject}). * * Resolution is against the stack's own `objects`: a `childObject` this stack * does not declare, or a column naming no field of it, is not judged here — an @@ -3630,26 +3637,62 @@ function collectHydratedInlineColumnErrors(config: ObjectStackDefinition): strin } } - // [#20928] The page carrier: `properties` is an open record the stack's parse - // never reaches, so `details` is read raw, entry by entry. + // [#20928] The page carriers: `properties` is an open record the stack's + // parse never reaches, so `details` and `columns` are read raw. for (const page of config.pages ?? []) { if (!isRecord(page)) continue; const pageName = typeof page.name === 'string' ? page.name : '(unnamed)'; forEachPageComponent(page, (node, props, path) => { - if (node.type !== 'object-master-detail-form' || !Array.isArray(props.details)) return; - props.details.forEach((detail: unknown, j: number) => { - if (!isRecord(detail) || typeof detail.childObject !== 'string') return; - judge( - detail.columns, - detail.childObject, - `Page '${pageName}' (${path}) object-master-detail-form details[${j}].columns`, - ); - }); + if (node.type === 'object-master-detail-form' && Array.isArray(props.details)) { + props.details.forEach((detail: unknown, j: number) => { + if (!isRecord(detail) || typeof detail.childObject !== 'string') return; + judge( + detail.columns, + detail.childObject, + `Page '${pageName}' (${path}) object-master-detail-form details[${j}].columns`, + ); + }); + return; + } + // [#24190] The fourth carrier: one grid per block, over the child object + // the panel lists. + if (node.type === 'record:line_items') { + const childObject = lineItemsChildObject(node, props); + if (childObject === undefined) return; + judge(props.columns, childObject, `Page '${pageName}' (${path}) record:line_items columns`); + } }); } return errors; } +/** + * [#24190] The child object a `record:line_items` block lists, resolved the + * way objectui resolves it before `LineItemsPanel` hydrates its columns from + * that object's schema — so the column is judged against the field it will + * render. + * + * The block's `childObject` is optional because the node's object + * `dataSource` binding can supply the object instead (`RecordLineItemsProps`), + * and the block's binding mapping writes the binding's `object` onto + * `childObject`: a binding that names an object wins over an authored + * `childObject`. An API binding (`provider: 'api'`) names no object, and the + * panel then reads `childObject` as authored. The `isRecord` / `typeof` + * guards are this rule's own type guards, as in the rest of this page walk: + * the stack's parse has already judged the node's `dataSource`, and it never + * reaches `properties`. + */ +function lineItemsChildObject( + node: Record, + props: Record, +): string | undefined { + const binding = node.dataSource; + if (isRecord(binding) && typeof binding.object === 'string' && binding.object.length > 0) { + return binding.object; + } + return typeof props.childObject === 'string' ? props.childObject : undefined; +} + /** * A job's `pull.mapping` must name a mapping this stack declares, and that * mapping must carry a `connectorSource` (ruling Q1-B on the connector-sync @@ -3784,8 +3827,8 @@ function validateCrossReferences( } // Validate identity-only inline grid columns against the child field's type - // (#20901) — every carrier (#20928 added the page block's), the column - // schema's own verdict. + // (#20901) — every carrier (#20928 added the master-detail page block's, + // #24190 the line-items block's), the column schema's own verdict. errors.push(...collectHydratedInlineColumnErrors(config)); // Validate seed data → object references. ARTIFACT-SCOPED (#18202). diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 2365c6def4c..6b7722a5965 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2515,17 +2515,16 @@ const RECORD_LINE_ITEMS_HISTORY = * column takes the child field's label (and with it that label's * translation), its cell type and its options; declaring a `type` opts the * column out. The renderer half is objectstack-ai/objectui#12854: - * `LineItemsPanel` reuses `hydrateColumns`, not a second resolver. Until the - * `.objectui-sha` pin carries it, the panel hands `columns` to the grid - * through the field-security pass alone, and an identity-only column draws as - * a text cell headed by its name. + * `LineItemsPanel` reuses `hydrateColumns`, not a second resolver, and the + * `.objectui-sha` pin carries it. * * `defineStack`'s resolved-type check (`collectHydratedInlineColumnErrors` in - * `stack.zod.ts`) walks the three master-detail carriers and not this block, - * so an identity-only column here that hydrates to a type carrying a - * type-conditional column rule (today `currency`, which refuses `scale`) is - * not judged at publish. Closing that gap changes what publishes, so it is a - * change of its own, not part of this block's describe. + * `stack.zod.ts`) walks this block as it walks the three master-detail + * carriers (#24190): an identity-only column here that hydrates to a type + * carrying a type-conditional column rule (today `currency`, which refuses + * `scale`) is refused at publish, judged against the field of the child + * object the panel lists — the object `dataSource` binding's `object` when the + * node carries one, else `childObject`. * * The keys this block shares with an `object-master-detail-form` detail entry * take that entry's types and alias table, so one concept is spelled one way