From 8fa336ecc1ed8d88bdc91d756e81e315932e6206 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 23:55:30 +0000 Subject: [PATCH 1/6] wip(spec): converge dataSource.filter and the four object-* filter doors onto the ViewFilterRule array Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- ...r-orthography-binding-and-object-blocks.md | 60 ++++++++++ examples/app-showcase/src/ui/pages/index.ts | 2 +- .../app-showcase/src/ui/pages/my-work.page.ts | 15 ++- .../test/my-work-visibility.test.ts | 5 +- .../lint/src/validate-component-props.test.ts | 8 +- .../src/validate-empty-combinators.test.ts | 19 +-- ...urce-and-object-block-filter-rule-array.ts | 75 ++++++++++++ packages/spec/src/migrations/registry.ts | 71 +++++++++++ .../src/type-alias-convention.pin.test.ts | 30 +++-- packages/spec/src/ui/component.test.ts | 92 +++++++++++++- packages/spec/src/ui/component.zod.ts | 93 +++++++++++++- packages/spec/src/ui/page.test.ts | 113 +++++++++++++++++- packages/spec/src/ui/page.zod.ts | 31 ++++- 13 files changed, 576 insertions(+), 38 deletions(-) create mode 100644 .changeset/filter-orthography-binding-and-object-blocks.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts diff --git a/.changeset/filter-orthography-binding-and-object-blocks.md b/.changeset/filter-orthography-binding-and-object-blocks.md new file mode 100644 index 0000000000..a9f2068f7a --- /dev/null +++ b/.changeset/filter-orthography-binding-and-object-blocks.md @@ -0,0 +1,60 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: the binding-level `dataSource.filter` and the four `object-*` `filter` doors converge onto the `ViewFilterRule` array form — one filter orthography platform-wide reaches the family (#15442, #15449; objectui#6206-B, decision batch #55 option A) + + + +**BREAKING** accept-set change at five doors — `ElementDataSourceSchema.filter` +(the `dataSource` binding every data-bound page component carries) and +`ComponentPropsMap['object-grid' | 'object-metric' | 'object-kanban' | +'object-calendar'].filter` — shipped as `minor` under the repo's launch-window +convention for breaking changes; the migration prescription is registered under +protocol major 18 as ONE entry for the family. + +One filter orthography platform-wide (maintainer batch adjudication 2026-08-25, +verbatim 「同意」; reached these two locations on 2026-09-06, decision batch #55, +verbatim 「同意」, option A: converge family-wide). Until this release the +binding alone declared the MongoDB-style record (`FilterConditionSchema`) — so it +refused the array the consumer's own pins author at that key, and +`element:record_picker` carried two orthographies at two keys resolved through +one `??` in the renderer — while the four `object-*` doors declared `z.unknown()` +and took the record, the ObjectQL AST tuple array and the rule array alike, +silently. All five now declare `z.array(ViewFilterRuleSchema)`, the form every +other `filter` door in the map already carried; the `FilterConditionSchema` +import that existed in `page.zod.ts` for this one site leaves with it. + +Sequenced measurement-first, as the family had to be: at the objectui pin +`a472b07` the `object-metric` aggregate path posted an array `where` that +`POST /analytics/query` refused (400 on every array form, #15828), so the +converge was parked behind the pin bump #16626. At the pin this repo builds +against (`53ded82b`, objectui#7754) the adapter lowers an authored array through +`translateFilterArray` and the spec's own `parseFilterAST` sink before the +wire; `ObjectGrid` lowers a rule array through `toFilterNode`; `ObjectKanban` / +`ObjectCalendar` hand it verbatim to `$filter`, where `convertQueryParams` +lowers it; the binding's composition seam AND-combines it with the named view's +rules through `mergeFilterNodes`. Nothing on those paths parses the value +against the installed spec. + +**Migration** (`element-data-source-and-object-block-filter-rule-array` — +listed by `os migrate meta --from 17` once the protocol major is 18): a +record-form `filter: { status: 'active' }` becomes +`filter: [{ field: 'status', operator: 'equals', value: 'active' }]`; an +operator object `{ status: { $ne: 'done' } }` becomes +`[{ field: 'status', operator: 'not_equals', value: 'done' }]`; several keys +become several rules (they AND); an AST tuple array +`[['owner_id', '=', '{current_user_id}']]` becomes +`[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` — +placeholders and date macros are unchanged. The record form is refused at +`filter` (`invalid_type`, expected array); the tuple array is refused at +`filter.0` (expected object). The dashboard widget `filter` +(`dashboard.zod.ts`) is a different family and is unchanged by this release +(#15829); `object-grid.defaultFilters` is a different key, not named by the +ruling, and is unchanged. + +In-repo authors migrated in the same change: four spec test fixtures at the +binding, five showcase authors (`my-work.page.ts`, `index.ts`) and three lint +fixtures. Type aliases: `ElementDataSourceParsed`, `ObjectMetricPropsParsed`, +`ObjectKanbanPropsParsed` and `ObjectCalendarPropsParsed` are declared (ADR-0122: +`operator` normalizes on parse, so input ≠ infer at these five schemas now). diff --git a/examples/app-showcase/src/ui/pages/index.ts b/examples/app-showcase/src/ui/pages/index.ts index 0d6a02e4c1..560c1c36ca 100644 --- a/examples/app-showcase/src/ui/pages/index.ts +++ b/examples/app-showcase/src/ui/pages/index.ts @@ -75,7 +75,7 @@ export const ComponentGalleryPage = definePage({ { type: 'object-metric', properties: { objectName: 'showcase_project', label: 'Projects', icon: 'folder-kanban', colorVariant: 'blue', description: 'active & planned', aggregate: { field: 'id', function: 'count' } } }, { type: 'object-metric', properties: { objectName: 'showcase_task', label: 'Tasks', icon: 'check-square', colorVariant: 'purple', description: 'all states', aggregate: { field: 'id', function: 'count' } } }, { type: 'object-metric', properties: { objectName: 'showcase_account', label: 'Accounts', icon: 'building', colorVariant: 'teal', description: 'customers', aggregate: { field: 'id', function: 'count' } } }, - { type: 'object-metric', properties: { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'warning', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: { status: { $ne: 'done' } } } }, + { type: 'object-metric', properties: { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'warning', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'status', operator: 'not_equals', value: 'done' }] } }, ], }, }, diff --git a/examples/app-showcase/src/ui/pages/my-work.page.ts b/examples/app-showcase/src/ui/pages/my-work.page.ts index e6e1143b37..96db9507a9 100644 --- a/examples/app-showcase/src/ui/pages/my-work.page.ts +++ b/examples/app-showcase/src/ui/pages/my-work.page.ts @@ -36,9 +36,9 @@ export const MyWorkPage = definePage({ columns: 3, gap: 4, children: [ - { type: 'object-metric', properties: { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'blue', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: { status: { $ne: 'done' } } } }, - { type: 'object-metric', properties: { objectName: 'showcase_task', label: 'In Review', icon: 'eye', colorVariant: 'warning', description: 'awaiting review', aggregate: { field: 'id', function: 'count' }, filter: { status: 'in_review' } } }, - { type: 'object-metric', properties: { objectName: 'showcase_project', label: 'At-Risk Projects', icon: 'alert-triangle', colorVariant: 'danger', description: 'health red', aggregate: { field: 'id', function: 'count' }, filter: { health: 'red' } } }, + { type: 'object-metric', properties: { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'blue', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'status', operator: 'not_equals', value: 'done' }] } }, + { type: 'object-metric', properties: { objectName: 'showcase_task', label: 'In Review', icon: 'eye', colorVariant: 'warning', description: 'awaiting review', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'status', operator: 'equals', value: 'in_review' }] } }, + { type: 'object-metric', properties: { objectName: 'showcase_project', label: 'At-Risk Projects', icon: 'alert-triangle', colorVariant: 'danger', description: 'health red', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'health', operator: 'equals', value: 'red' }] } }, ], }, }, @@ -51,12 +51,19 @@ export const MyWorkPage = definePage({ // this line used to carry has ZERO read points in the renderer, so it // was accepted and then dropped — the queue silently listed every row // (objectstack#7750). + // + // And the `ViewFilterRule` ARRAY form, `[{ field, operator, value }]`: + // the one filter orthography every `filter` door declares since + // #15442 / #15449 (ui#6206-B). The ObjectQL AST tuple this line used + // to carry (`[['owner_id', '=', '{current_user_id}']]`) is refused at + // `filter.0` now; `toFilterNode` lowers the rule to the same AST node + // before the wire, and `{current_user_id}` resolves exactly as before. { type: 'object-grid', properties: { objectName: 'showcase_task', columns: ['title', 'project', 'status', 'priority', 'due_date'], - filter: [['owner_id', '=', '{current_user_id}']], + filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], }, }, ], diff --git a/examples/app-showcase/test/my-work-visibility.test.ts b/examples/app-showcase/test/my-work-visibility.test.ts index 558f2f80a6..030c9c74f2 100644 --- a/examples/app-showcase/test/my-work-visibility.test.ts +++ b/examples/app-showcase/test/my-work-visibility.test.ts @@ -186,8 +186,11 @@ describe('My Work — the personal queue declares its filter in the key object-g it('still scopes the queue to the signed-in user', () => { // The spelling is only half the fix; the predicate is what makes the page // personal at all. `{current_user_id}` is the page-level identity token. + // Authored in the `ViewFilterRule` array form — the one filter orthography + // `object-grid.filter` declares since #15449 (ui#6206-B); the AST tuple + // this pin used to carry is refused at `filter.0` by the spec now. expect(workQueueGrid()!.properties!.filter).toEqual([ - ['owner_id', '=', '{current_user_id}'], + { field: 'owner_id', operator: 'equals', value: '{current_user_id}' }, ]); }); }); diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 74c046dbc6..776b8b641d 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -480,12 +480,16 @@ describe('validateComponentProps — object-* blocks are dispatched (#7751)', () properties: { objectName: 'showcase_task', columns: ['title', 'project', 'status', 'priority', 'due_date'], - filter: [['owner_id', '=', '{current_user_id}']], + // The rule-array form the four `object-*` doors declare since + // #15449 (ui#6206-B, one filter orthography); the AST tuple this + // pin used to carry is refused at `filter.0` now, so the corpus + // shape it mirrors (`my-work.page.ts`) moved with it. + filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], }, }, { type: 'object-metric', - properties: { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'blue', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: { status: { $ne: 'done' } } }, + properties: { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'blue', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'status', operator: 'not_equals', value: 'done' }] }, }, { type: 'object-metric', diff --git a/packages/lint/src/validate-empty-combinators.test.ts b/packages/lint/src/validate-empty-combinators.test.ts index 5382134a9f..087e5d227a 100644 --- a/packages/lint/src/validate-empty-combinators.test.ts +++ b/packages/lint/src/validate-empty-combinators.test.ts @@ -228,18 +228,22 @@ describe('validateEmptyCombinators — the surfaces it walks', () => { expect(findings[0].message).toContain('matches NO row'); }); - it('covers objects, views, reports, datasets, pages and apps', () => { + it('covers objects, views, reports, datasets and apps', () => { + // No `pages` row any more: the page-level filter doors — the four + // `object-*` `filter` props and the binding-level `dataSource.filter` — + // carry `z.array(ViewFilterRuleSchema)` since #15442 / #15449 (ui#6206-B, + // one filter orthography), and this rule judges Filter Protocol NODES + // only (`isFilterNode` excludes arrays). The `object-grid { $or: [] }` + // this row used to author is refused by the spec at `filter` now, so a + // page row here could only pin an off-spec fixture. `pages` stays in + // `EMPTY_COMBINATOR_SURFACES`: the walker is key-name based, and an author + // still writing the retired record form gets this located error beside + // the schema refusal rather than nothing. const findings = validateEmptyCombinators({ objects: [{ name: 'lead', listViews: { mine: { filter: { $or: [] } } } }], views: [{ name: 'all', list: { filter: { $and: [] } } }], reports: [{ name: 'weekly', runtimeFilter: { $not: {} } }], datasets: [{ name: 'cases', filter: {} }], - pages: [ - { - name: 'home', - regions: [{ components: [{ type: 'object-grid', properties: { filter: { $or: [] } } }] }], - }, - ], apps: [{ name: 'crm', navigation: [{ id: 'n', type: 'object', filters: { $and: [] } }] }], }); @@ -247,7 +251,6 @@ describe('validateEmptyCombinators — the surfaces it walks', () => { 'app "crm"', 'dataset "cases"', 'object "lead"', - 'page "home"', 'report "weekly"', 'view "all"', ]); diff --git a/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts new file mode 100644 index 0000000000..48c3e5af03 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'element-data-source-and-object-block-filter-rule-array', + surface: + 'Page-component `dataSource.filter` (`ElementDataSourceSchema`, the binding every ' + + 'data-bound element carries) and the `filter` prop of the four `object-*` blocks in ' + + '`ComponentPropsMap` — `object-grid`, `object-metric`, `object-kanban`, `object-calendar` ' + + '(the FORM: the MongoDB-style `FilterConditionSchema` record at the binding, and the ' + + 'accept-anything `z.unknown()` at the four block doors, vs the `ViewFilterRule` array)', + replacement: + '`z.array(ViewFilterRuleSchema)` at all five doors — the rule array ' + + '`[{ field, operator, value }, ...]` every other `filter` door in the map already carries ' + + '(`record:related_list`, its Add-affordance picker, `element:number`, ' + + '`element:record_picker`). A record-form filter `{ status: \'active\' }` becomes ' + + '`[{ field: \'status\', operator: \'equals\', value: \'active\' }]`; an operator object ' + + '`{ status: { $ne: \'done\' } }` becomes ' + + '`[{ field: \'status\', operator: \'not_equals\', value: \'done\' }]`; several keys become ' + + 'several rules (they AND). An ObjectQL AST tuple array ' + + '`[[\'owner_id\', \'=\', \'{current_user_id}\']]` — which the `z.unknown()` block doors ' + + 'also took — becomes `[{ field: \'owner_id\', operator: \'equals\', value: ' + + '\'{current_user_id}\' }]`; the value placeholders and date macros are unchanged. Legacy ' + + 'operator shorthands (`eq`, `ne`, `gt`, `notIn`, …) are accepted and normalized on parse. ' + + 'The dashboard widget `filter` (`dashboard.zod.ts`) is a different family and is not moved ' + + 'by this entry (#15829); `object-grid.defaultFilters` is a different key and is not named ' + + 'by the ruling this entry records.', + reason: + 'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication ' + + '2026-08-25, verbatim 「同意」, Option B) reached two more locations the ComponentPropsMap ' + + 'census could not see (#15442 anchor, #15449 member; decision batch #55, 2026-09-06, ' + + 'verbatim 「同意」, option A: converge family-wide, one entry). The binding-level ' + + '`dataSource.filter` alone still said `FilterConditionSchema`: it refused the array the ' + + 'consumer\'s own pins author at that key, and `element:record_picker` carried two ' + + 'orthographies at two keys (`properties.filter` the rule array, `dataSource.filter` the ' + + 'record) resolved through one `??` in the renderer — the shape in which a dropped or ' + + 'misread filter returns the wrong rows without an error. The four `object-*` doors said ' + + '`z.unknown()`: a read-point record derived from the renderers on 2026-08-13 (#7751), ' + + 'twelve days before the ruling, not an exception to it — so an author following the ' + + 'showcase wrote the record and an author following the manifest wrote an array, and each ' + + 'got a silent success receipt while the html tier already declared `array` for the grid ' + + 'and the metric. The record\'s `$and` / `$or` / `$not` keys were misread by every gate ' + + 'block anyway (objectui#6948), so the exception would have preserved a capability the ' + + 'consumer does not honour. Sequenced measurement-first, as the family had to be: at the ' + + 'objectui pin `a472b07` the `object-metric` aggregate path posted an array `where` that ' + + '`POST /analytics/query` refused with 400 on every array form (#15828), so the converge ' + + 'was parked behind the pin bump #16626; at the pin this repo builds against (`53ded82b`, ' + + 'objectui#7754) the adapter lowers an authored array through `translateFilterArray` and ' + + 'the spec\'s own `parseFilterAST` sink before the wire, `ObjectGrid.tsx` lowers a rule ' + + 'array through `toFilterNode`, `ObjectKanban.tsx` / `ObjectCalendar.tsx` hand it verbatim ' + + 'to `$filter` where `convertQueryParams` lowers it, and the binding\'s composition seam ' + + 'AND-combines it with the named view\'s rules through `mergeFilterNodes`. The ruled ' + + 'migration check ran with the change: the in-repo sweep found four spec test fixtures ' + + 'at the binding (`page.test.ts`, all record form), five showcase authors at the block ' + + 'doors (`my-work.page.ts`, `index.ts`: four records on `object-metric`, one AST tuple ' + + 'array on `object-grid`) and three lint fixtures — every one rewritten to the rule array ' + + 'in the same change, and zero outside those files; this entry carries the prescription ' + + 'for authors outside the repo.', + acceptanceCriteria: + '`ElementDataSourceSchema.safeParse({ object, filter: [{ field: \'status\', operator: ' + + '\'equals\', value: \'active\' }] })` succeeds and the parsed `filter` is the same rule ' + + 'array; `ComponentPropsMap[\'object-grid\' | \'object-metric\' | \'object-kanban\' | ' + + '\'object-calendar\'].safeParse({ filter: })` raises no issue at `filter`; ' + + 'a record-form `filter: { status: \'active\' }` is refused at the `filter` path of all ' + + 'five doors (`invalid_type`, expected array), and an AST tuple array is refused at ' + + '`filter.0` (expected object). No `filter` door in `ComponentPropsMap` accepts the ' + + 'record any more (the twin of the #14406 census pin). At runtime each block and the ' + + 'binding select exactly the rows the array selects — the same filter a list view ' + + 'renders — including the `object-metric` aggregate tile, whose analytics `where` is the ' + + 'lowered condition. Downstream (objectui, after a released spec version reaches the ' + + 'pin): the seventeen `dataSource.filter` test authors at the pin (fifteen tuple arrays, ' + + 'two records) become off-spec fixtures and `ElementDataSourceConfig.filter`\'s ' + + '"three shapes" note narrows — objectui cards filed by the seat, not blocked on here.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 811080f111..1c6278389d 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6893,6 +6893,77 @@ const step18: MigrationStep = { + 'and every other declared key — `url`, `authToken`, `encryptionKey`, `concurrency`, `syncUrl`, ' + '`sync`, `timeoutMs` — keeps its bound, default and optionality.', }, + { + id: 'element-data-source-and-object-block-filter-rule-array', + surface: + 'Page-component `dataSource.filter` (`ElementDataSourceSchema`, the binding every ' + + 'data-bound element carries) and the `filter` prop of the four `object-*` blocks in ' + + '`ComponentPropsMap` — `object-grid`, `object-metric`, `object-kanban`, `object-calendar` ' + + '(the FORM: the MongoDB-style `FilterConditionSchema` record at the binding, and the ' + + 'accept-anything `z.unknown()` at the four block doors, vs the `ViewFilterRule` array)', + replacement: + '`z.array(ViewFilterRuleSchema)` at all five doors — the rule array ' + + '`[{ field, operator, value }, ...]` every other `filter` door in the map already carries ' + + '(`record:related_list`, its Add-affordance picker, `element:number`, ' + + '`element:record_picker`). A record-form filter `{ status: \'active\' }` becomes ' + + '`[{ field: \'status\', operator: \'equals\', value: \'active\' }]`; an operator object ' + + '`{ status: { $ne: \'done\' } }` becomes ' + + '`[{ field: \'status\', operator: \'not_equals\', value: \'done\' }]`; several keys become ' + + 'several rules (they AND). An ObjectQL AST tuple array ' + + '`[[\'owner_id\', \'=\', \'{current_user_id}\']]` — which the `z.unknown()` block doors ' + + 'also took — becomes `[{ field: \'owner_id\', operator: \'equals\', value: ' + + '\'{current_user_id}\' }]`; the value placeholders and date macros are unchanged. Legacy ' + + 'operator shorthands (`eq`, `ne`, `gt`, `notIn`, …) are accepted and normalized on parse. ' + + 'The dashboard widget `filter` (`dashboard.zod.ts`) is a different family and is not moved ' + + 'by this entry (#15829); `object-grid.defaultFilters` is a different key and is not named ' + + 'by the ruling this entry records.', + reason: + 'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication ' + + '2026-08-25, verbatim 「同意」, Option B) reached two more locations the ComponentPropsMap ' + + 'census could not see (#15442 anchor, #15449 member; decision batch #55, 2026-09-06, ' + + 'verbatim 「同意」, option A: converge family-wide, one entry). The binding-level ' + + '`dataSource.filter` alone still said `FilterConditionSchema`: it refused the array the ' + + 'consumer\'s own pins author at that key, and `element:record_picker` carried two ' + + 'orthographies at two keys (`properties.filter` the rule array, `dataSource.filter` the ' + + 'record) resolved through one `??` in the renderer — the shape in which a dropped or ' + + 'misread filter returns the wrong rows without an error. The four `object-*` doors said ' + + '`z.unknown()`: a read-point record derived from the renderers on 2026-08-13 (#7751), ' + + 'twelve days before the ruling, not an exception to it — so an author following the ' + + 'showcase wrote the record and an author following the manifest wrote an array, and each ' + + 'got a silent success receipt while the html tier already declared `array` for the grid ' + + 'and the metric. The record\'s `$and` / `$or` / `$not` keys were misread by every gate ' + + 'block anyway (objectui#6948), so the exception would have preserved a capability the ' + + 'consumer does not honour. Sequenced measurement-first, as the family had to be: at the ' + + 'objectui pin `a472b07` the `object-metric` aggregate path posted an array `where` that ' + + '`POST /analytics/query` refused with 400 on every array form (#15828), so the converge ' + + 'was parked behind the pin bump #16626; at the pin this repo builds against (`53ded82b`, ' + + 'objectui#7754) the adapter lowers an authored array through `translateFilterArray` and ' + + 'the spec\'s own `parseFilterAST` sink before the wire, `ObjectGrid.tsx` lowers a rule ' + + 'array through `toFilterNode`, `ObjectKanban.tsx` / `ObjectCalendar.tsx` hand it verbatim ' + + 'to `$filter` where `convertQueryParams` lowers it, and the binding\'s composition seam ' + + 'AND-combines it with the named view\'s rules through `mergeFilterNodes`. The ruled ' + + 'migration check ran with the change: the in-repo sweep found four spec test fixtures ' + + 'at the binding (`page.test.ts`, all record form), five showcase authors at the block ' + + 'doors (`my-work.page.ts`, `index.ts`: four records on `object-metric`, one AST tuple ' + + 'array on `object-grid`) and three lint fixtures — every one rewritten to the rule array ' + + 'in the same change, and zero outside those files; this entry carries the prescription ' + + 'for authors outside the repo.', + acceptanceCriteria: + '`ElementDataSourceSchema.safeParse({ object, filter: [{ field: \'status\', operator: ' + + '\'equals\', value: \'active\' }] })` succeeds and the parsed `filter` is the same rule ' + + 'array; `ComponentPropsMap[\'object-grid\' | \'object-metric\' | \'object-kanban\' | ' + + '\'object-calendar\'].safeParse({ filter: })` raises no issue at `filter`; ' + + 'a record-form `filter: { status: \'active\' }` is refused at the `filter` path of all ' + + 'five doors (`invalid_type`, expected array), and an AST tuple array is refused at ' + + '`filter.0` (expected object). No `filter` door in `ComponentPropsMap` accepts the ' + + 'record any more (the twin of the #14406 census pin). At runtime each block and the ' + + 'binding select exactly the rows the array selects — the same filter a list view ' + + 'renders — including the `object-metric` aggregate tile, whose analytics `where` is the ' + + 'lowered condition. Downstream (objectui, after a released spec version reaches the ' + + 'pin): the seventeen `dataSource.filter` test authors at the pin (fifteen tuple arrays, ' + + 'two records) become off-spec fixtures and `ElementDataSourceConfig.filter`\'s ' + + '"three shapes" note narrows — objectui cards filed by the seat, not blocked on here.', + }, { id: 'element-number-filter-rule-array', surface: diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 9a6b63e569..0f02049470 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -275,7 +275,7 @@ import type * as M184 from './shared/value-domain.zod.js'; import type * as M185 from './shared/epoch.zod.js'; // --------------------------------------------------------------------------- -// 815 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 811 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1333,7 +1333,11 @@ export type Iso692 = Assert, z.infer< typeof M163.PageTypeSchema > >>; -export type Iso694 = Assert, z.infer< typeof M163.ElementDataSourceSchema > >>; +// `ElementDataSourceSchema` (Iso694) left the family on #15442 — the ui#6206-B +// filter orthography reaching the binding-level `dataSource.filter`: it now +// carries `z.array(ViewFilterRuleSchema)`, whose own input ≠ infer (`operator` +// is normalized on parse), so `ElementDataSourceParsed` is declared and this +// pin deleted. // ui/report.zod.ts export type Iso695 = Assert, z.infer< typeof M164.JoinedReportBlockSchema > >>; @@ -1587,9 +1591,11 @@ export type Iso849 = Assert, z.inf // `ObjectGridPropsSchema` (Iso839) left the family exactly that way on the // ui#6207 convergence: its `data` now carries `ViewDataSchema`, whose own // input ≠ infer, so `ObjectGridPropsParsed` is declared and the pin deleted. -export type Iso840 = Assert, z.infer< typeof M170.ObjectMetricPropsSchema > >>; -export type Iso841 = Assert, z.infer< typeof M170.ObjectKanbanPropsSchema > >>; -export type Iso842 = Assert, z.infer< typeof M170.ObjectCalendarPropsSchema > >>; +// `ObjectMetricPropsSchema` (Iso840), `ObjectKanbanPropsSchema` (Iso841) and +// `ObjectCalendarPropsSchema` (Iso842) left the same way on #15449 — the +// ui#6206-B filter orthography reaching the four `object-*` `filter` doors: +// each now carries `z.array(ViewFilterRuleSchema)` (input ≠ infer), so the +// three `XParsed` aliases are declared and the three pins deleted. export type Iso843 = Assert, z.infer< typeof M170.ObjectFormPropsSchema > >>; export type Iso844 = Assert, z.infer< typeof M170.ObjectMasterDetailFormPropsSchema > >>; @@ -1686,7 +1692,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 815 isomorphic pins', () => { + it('still declares all 811 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2172,7 +2178,17 @@ describe('ADR-0122 type-alias convention', () => { // `AnalyticsDateRangeSchema` (its union with `z.array(z.string())`) — no // default, no transform on either arm, two new pins (`Iso869` / `Iso870`). // +2 added. - expect(pins).toHaveLength(815); + // + // 815 -> 811 is the ui#6206-B filter-orthography family convergence + // (#15442 + #15449, decision batch #55, option A): `ElementDataSourceSchema` + // (the binding-level `dataSource.filter`) and `ObjectMetricPropsSchema` / + // `ObjectKanbanPropsSchema` / `ObjectCalendarPropsSchema` (three of the four + // `object-*` `filter` doors; `object-grid` had already left on ui#6207) now + // carry `z.array(ViewFilterRuleSchema)`, whose own input ≠ infer, so each + // left the isomorphic family the way ADR-0122 prescribes: the `XParsed` + // alias declared, the pin deleted. -4 converted to `XParsed` pairs; the + // Iso numbers stay vacant (ids are claims about pins, not positions). + expect(pins).toHaveLength(811); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 49a20ae875..ba1c3c5fc6 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -1901,10 +1901,11 @@ describe("element:record_picker `filter` — one filter orthography platform-wid // Asserted over the WHOLE map by shape rather than over the entries named // above, so a future entry declaring `FilterConditionSchema` at `filter` // (which refuses an array outright, `invalid_type`) is caught here by - // name. Doors that declare `filter` as `z.unknown()` (the `object-*` - // blocks hand it to the wire verbatim) accept both forms and are not - // holdouts of this census; the holdout shape is exactly "declares - // `filter`, refuses the array". + // name. The holdout shape is exactly "declares `filter`, refuses the + // array". A door declaring `z.unknown()` accepted both forms and was never + // a holdout of THIS census by construction — which is why the four + // `object-*` doors needed the complementary pin below (#15449): "every + // `filter` door refuses the record". type Door = { shape?: Record; safeParse: (v: unknown) => ParseResult }; const doors = (Object.entries(ComponentPropsMap) as Array<[string, unknown]>) .filter(([, schema]) => { @@ -1923,6 +1924,89 @@ describe("element:record_picker `filter` — one filter orthography platform-wid }); }); +// --------------------------------------------------------------------------- +// The four `object-*` `filter` doors — the ViewFilterRule ARRAY orthography +// (ui#6206-B reaching the object-* family: #15449, folded into #15442, +// decision batch #55, option A: family-wide, one ADR-0087 D3 entry) +// --------------------------------------------------------------------------- +describe('the four `object-*` `filter` doors — one filter orthography platform-wide (ui#6206-B, #15449)', () => { + const OBJECT_DOORS = ['object-grid', 'object-metric', 'object-kanban', 'object-calendar'] as const; + const RULES = [{ field: 'status', operator: 'not_equals', value: 'done' }]; + const RECORD_FORM = { status: { $ne: 'done' } }; + /** The showcase's `object-grid` used to author THIS — an ObjectQL AST tuple array. */ + const TUPLE_ARRAY = [['owner_id', '=', '{current_user_id}']]; + type ParseResult = { success: boolean; data?: { filter?: unknown }; error?: { issues: Array<{ path: PropertyKey[]; code: string }> } }; + type Door = { shape?: Record; safeParse: (v: unknown) => ParseResult }; + const door = (type: string) => ComponentPropsMap[type as keyof typeof ComponentPropsMap] as unknown as Door; + const issuesAtPath = (r: ParseResult, path: string) => + r.success ? [] : r.error!.issues.filter((i) => i.path.join('.') === path); + + it.each(OBJECT_DOORS)('%s accepts a ViewFilterRule[] filter and echoes it — the acceptance criterion', (type) => { + // Measured at the objectui pin `53ded82b` before the declarations moved: + // grid lowers the rule array through `toFilterNode`; kanban and calendar + // hand it verbatim to `$filter`, where `convertQueryParams` lowers it; the + // metric's aggregate path lowers it through `translateFilterArray` and + // `parseFilterAST` before `POST /analytics/query` (objectui#7754 — the + // door the family was sequenced behind, #15828 / #16626). + const r = door(type).safeParse({ objectName: 'showcase_task', filter: RULES }); + expect(r.success).toBe(true); + expect(r.data!.filter).toEqual(RULES); + }); + + it.each(OBJECT_DOORS)('%s carries the REAL ViewFilterRuleSchema: a legacy operator spelling normalizes on parse', (type) => { + // `z.unknown()` echoed `ne` back unchanged; the real rule schema lowers it. + const r = door(type).safeParse({ objectName: 'showcase_task', filter: [{ field: 'status', operator: 'ne', value: 'done' }] }); + expect(r.success).toBe(true); + expect((r.data!.filter as Array<{ operator: string }>)[0].operator).toBe('not_equals'); + }); + + it.each(OBJECT_DOORS)('%s REFUSES the MongoDB-style record at the `filter` path — what `z.unknown()` used to take silently', (type) => { + // Reverse verification on the issue envelope: located at `filter`, kind + // named. Before #15449 this exact value parsed with zero issues on every + // one of the four doors (measured, census report on #15442). Migration: + // `element-data-source-and-object-block-filter-rule-array`. + const r = door(type).safeParse({ objectName: 'showcase_task', filter: RECORD_FORM }); + expect(r.success).toBe(false); + const atFilter = issuesAtPath(r, 'filter'); + expect(atFilter).toHaveLength(1); + expect(atFilter[0].code).toBe('invalid_type'); + expect(atFilter[0]).toMatchObject({ expected: 'array' }); + const plain = door(type).safeParse({ objectName: 'showcase_task', filter: { status: 'done' } }); + expect(issuesAtPath(plain, 'filter').map((i) => i.code)).toEqual(['invalid_type']); + }); + + it.each(OBJECT_DOORS)('%s REFUSES the ObjectQL AST tuple array at `filter.0` — the other shape `z.unknown()` took', (type) => { + // The showcase's work-queue grid authored this until #15442 migrated it to + // the rule object; the container is right and the element is the wrong + // kind, so the refusal sits one hop deeper than the record's. + const r = door(type).safeParse({ objectName: 'showcase_task', filter: TUPLE_ARRAY }); + expect(r.success).toBe(false); + expect(issuesAtPath(r, 'filter')).toEqual([]); + expect(issuesAtPath(r, 'filter.0').map((i) => i.code)).toEqual(['invalid_type']); + }); + + it('every `filter` door in ComponentPropsMap refuses the record — the twin of the #14406 census pin', () => { + // The #14406 pin above asks "does any `filter` door refuse the ARRAY?" and + // cannot see an accept-anything door by construction. This is the other + // half of "one filter orthography": asked over the WHOLE map by shape, so + // a future entry declaring `filter` as `z.unknown()` or as the record is + // caught here by name. Guarded the same way — the doors pinned above must + // be found, or the shape read has gone wrong and the loop is vacuous. + const doors = (Object.entries(ComponentPropsMap) as Array<[string, unknown]>) + .filter(([, schema]) => { + const shape = (schema as Door).shape; + return !!shape && 'filter' in shape; + }) + .map(([type]) => type); + expect(doors).toEqual(expect.arrayContaining([...OBJECT_DOORS, 'element:record_picker', 'element:number', 'record:related_list'])); + const recordTakers = doors.filter((type) => { + const r = door(type).safeParse({ filter: RECORD_FORM }); + return issuesAtPath(r, 'filter').length === 0; + }); + expect(recordTakers).toEqual([]); + }); +}); + // --------------------------------------------------------------------------- // Interactive Elements — element:text_input // --------------------------------------------------------------------------- diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index cf728dec07..4b888d0400 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2463,8 +2463,26 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ .describe('Columns: field names or column definition objects'), fields: z.array(z.unknown()).optional() .describe('Field list fallback used when `columns` is absent'), - filter: z.unknown().optional() - .describe('Base query filter (ObjectQL filter array/AST) — lowered to the wire `$filter`. THE key, singular — not the plural misspelling'), + /** + * Base query filter — the `ViewFilterRule` ARRAY form, + * `[{ field, operator, value }, ...]`, the one filter orthography every + * `filter` door in this map shares (ui#6206-B; reached the four `object-*` + * doors and the binding-level `dataSource.filter` on #15442 / #15449, + * decision batch #55, verbatim 「同意」, option A: family-wide). The + * `z.unknown()` this door carried was a read-point record written twelve + * days before that ruling (#7751), not an exception to it: it accepted the + * MongoDB-style record, the AST tuple array and the rule array alike, so + * an author following the showcase and an author following the manifest + * each got a silent success receipt for a different shape. Measured at the + * objectui pin `53ded82b` before the declaration moved: `ObjectGrid.tsx` + * lowers `schema.filter` through `toFilterNode`, whose rule-array arm maps + * each rule to an AST node before `$filter` — the door every saved view's + * stored rules already take. The record and tuple forms are refused at + * `filter`; the migration prescription is the + * `element-data-source-and-object-block-filter-rule-array` semantic entry. + */ + filter: z.array(ViewFilterRuleSchema).optional() + .describe('Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), defaultFilters: z.unknown().optional() .describe('Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter`'), sort: z.unknown().optional().describe('Initial sort (array of { field, order })'), @@ -2621,7 +2639,25 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({ .optional().describe('Icon container color variant'), aggregate: z.unknown().optional() .describe('Aggregation config ({ field, function, groupBy? }) run against the object'), - filter: z.unknown().optional().describe('Filter the aggregation is scoped by'), + /** + * Filter the aggregation is scoped by — the `ViewFilterRule` ARRAY form, + * the one filter orthography every `filter` door in this map shares (#15449, + * the family entry above on `object-grid` carries the ruling). This door was + * the one the family had to be sequenced behind: with `aggregate` the widget + * posts the filter as the `where` of `POST /analytics/query`, whose request + * schema takes only a `FilterCondition`, and at the pin `a472b07` the adapter + * posted an array verbatim — a 400 on every array form (#15828). At the pin + * this repo builds against (`53ded82b`, objectui#7754) the adapter lowers an + * authored array through `translateFilterArray` and the spec's own + * `parseFilterAST` sink before the wire (`lowerAnalyticsFilterForWire`), so + * the rule array reaches the analytics door as the condition it declares; + * `resolveFilterPlaceholders` walks arrays and objects alike, so the date + * macros and `{current_user_id}` still resolve. The record form is refused + * at `filter`; see migration + * `element-data-source-and-object-block-filter-rule-array`. + */ + filter: z.array(ViewFilterRuleSchema).optional() + .describe('Filter the aggregation is scoped by — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), format: z.string().optional().describe("Number format pattern (e.g. '0,0', '$0,0', '0%')"), currency: z.string().optional().describe("ISO currency code (e.g. 'USD') — enables currency formatting"), prefix: z.string().optional().describe('Static prefix before the formatted value'), @@ -2636,6 +2672,14 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({ })); /** Author state (ADR-0122: the bare name is the author state). */ export type ObjectMetricProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state on exactly one + * key — `filter` carries `z.array(ViewFilterRuleSchema)` (the ui#6206-B family + * convergence, #15449), whose own input ≠ infer (`operator` is normalized on + * parse). So `object-metric` leaves the type-alias convention pin's default-free + * family the way `object-grid` did, taking the `ObjectGridPropsParsed` route. + */ +export type ObjectMetricPropsParsed = z.infer; /** * `object-kanban` (objectui `plugin-kanban/src/ObjectKanban.tsx` + @@ -2669,7 +2713,19 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ groupBy: z.string().optional().describe('Field whose values become the board columns'), columns: z.array(z.unknown()).optional() .describe('Swimlane definitions ({ id, title } per `groupBy` value, or bare value strings) — NOT a field projection'), - filter: z.unknown().optional().describe('Base query filter, handed to the wire `$filter`'), + /** + * Base query filter — the `ViewFilterRule` ARRAY form, the one filter + * orthography every `filter` door in this map shares (#15449; the family + * entry on `object-grid` carries the ruling). Measured at the objectui pin + * `53ded82b` before the declaration moved: `ObjectKanban.tsx` hands + * `schema.filter` verbatim to `$filter`, and `ObjectStackAdapter.convertQueryParams` + * lowers a rule array through `translateFilterArray` — the same door every + * list view's stored rule array takes. The record form is refused at + * `filter`; see migration + * `element-data-source-and-object-block-filter-rule-array`. + */ + filter: z.array(ViewFilterRuleSchema).optional() + .describe('Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), /** * Row cap (#16503 — the spec half of objectui#8172; decision batch #68, * 2026-09-07, option A: the contract declares the capability that already @@ -2718,6 +2774,14 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ })); /** Author state (ADR-0122: the bare name is the author state). */ export type ObjectKanbanProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state on exactly one + * key — `filter` carries `z.array(ViewFilterRuleSchema)` (the ui#6206-B family + * convergence, #15449), whose own input ≠ infer (`operator` is normalized on + * parse). So `object-kanban` leaves the type-alias convention pin's default-free + * family the way `object-grid` did, taking the `ObjectGridPropsParsed` route. + */ +export type ObjectKanbanPropsParsed = z.infer; /** * The flat per-field spellings `ObjectCalendar` keeps reading as a @@ -2760,7 +2824,18 @@ export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({ calendar: z.unknown().optional() .describe('Calendar field config: { startDateField, endDateField?, titleField?, colorField?, allDayField? }'), defaultView: z.enum(['month', 'week', 'day']).optional().describe('Initial view mode'), - filter: z.unknown().optional().describe('Base query filter'), + /** + * Base query filter — the `ViewFilterRule` ARRAY form, the one filter + * orthography every `filter` door in this map shares (#15449; the family + * entry on `object-grid` carries the ruling). Measured at the objectui pin + * `53ded82b` before the declaration moved: `ObjectCalendar.tsx` hands + * `schema.filter` verbatim to `$filter` and the adapter lowers a rule array + * exactly as it does for the kanban. The record form is refused at + * `filter`; see migration + * `element-data-source-and-object-block-filter-rule-array`. + */ + filter: z.array(ViewFilterRuleSchema).optional() + .describe('Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), sort: z.unknown().optional().describe('Sort for the fetched events'), data: z.array(z.unknown()).optional().describe('Pre-fetched records — skips the internal fetch'), staticData: z.array(z.unknown()).optional().describe('Static inline records'), @@ -2769,6 +2844,14 @@ export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({ })); /** Author state (ADR-0122: the bare name is the author state). */ export type ObjectCalendarProps = z.input; +/** + * ADR-0122: the parsed state differs from the authored state on exactly one + * key — `filter` carries `z.array(ViewFilterRuleSchema)` (the ui#6206-B family + * convergence, #15449), whose own input ≠ infer (`operator` is normalized on + * parse). So `object-calendar` leaves the type-alias convention pin's default-free + * family the way `object-grid` did, taking the `ObjectGridPropsParsed` route. + */ +export type ObjectCalendarPropsParsed = z.infer; /** * `object-form` (objectui `plugin-form/src/ObjectForm.tsx` @ `eb7f586b`, plus diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 68996732c9..4370e540d0 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -630,7 +630,7 @@ describe('ElementDataSourceSchema', () => { const ds = ElementDataSourceSchema.parse({ object: 'invoice', view: 'pending_review', - filter: { status: 'pending' }, + filter: [{ field: 'status', operator: 'equals', value: 'pending' }], sort: [{ field: 'created_at', order: 'desc' }], limit: 50, }); @@ -653,6 +653,111 @@ describe('ElementDataSourceSchema', () => { }); }); +// --------------------------------------------------------------------------- +// ElementDataSourceSchema `filter` — the ViewFilterRule ARRAY orthography +// (ui#6206-B reaching the binding: #15442, decision batch #55, option A) +// --------------------------------------------------------------------------- +describe('ElementDataSourceSchema `filter` — one filter orthography platform-wide (ui#6206-B, #15442)', () => { + const RULES = [{ field: 'status', operator: 'equals', value: 'active' }]; + const RECORD_FORM = { status: 'active' }; + /** The objectui pin's own test authors write THIS at `dataSource.filter` — an AST tuple array. */ + const TUPLE_ARRAY = [['amount', '>', 100]]; + type ParseResult = { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; code: string }> } }; + /** The issues a parse raised under `key` (top-level), whatever else it raised. */ + const issuesUnder = (r: ParseResult, key: string) => + r.success ? [] : r.error!.issues.filter((i) => i.path[0] === key); + + it('accepts a ViewFilterRule[] filter — the acceptance criterion', () => { + // Before #15442 this exact value was REFUSED here (`invalid_type`, expected + // record): the binding alone said `FilterConditionSchema` while every + // `filter` door in ComponentPropsMap took the array. Measured at the + // objectui pin `53ded82b` before the declaration moved: the composition + // seam AND-combines the binding filter with the named view's rules through + // `mergeFilterNodes`, whose `toFilterNode` lowers a rule array to AST nodes. + const r = ElementDataSourceSchema.safeParse({ object: 'account', filter: RULES }); + expect(r.success).toBe(true); + expect(r.data!.filter).toEqual(RULES); + }); + + it('the array carries the REAL ViewFilterRuleSchema, not a lookalike: operators normalize, value shapes are checked', () => { + // `ne` is a legacy spelling `normalizeFilterOperator` lowers to `not_equals` + // — a plain `z.array(z.object(...))` would have echoed it back unchanged. + const legacy = ElementDataSourceSchema.safeParse({ + object: 'account', + filter: [{ field: 'status', operator: 'ne', value: 'done' }], + }); + expect(legacy.success).toBe(true); + expect(legacy.data!.filter![0].operator).toBe('not_equals'); + // `in` takes an array; a scalar is refused at `filter.0.value` by the rule's + // own superRefine — the value-shape check rides in with the schema. + const scalarIn = ElementDataSourceSchema.safeParse({ + object: 'account', + filter: [{ field: 'status', operator: 'in', value: 'active' }], + }); + expect(scalarIn.success).toBe(false); + expect(scalarIn.error!.issues.map((i) => i.path.join('.'))).toContain('filter.0.value'); + }); + + it('the MongoDB-style record form — what this key alone used to accept — is REFUSED at the `filter` path', () => { + // Reverse verification of the convergence, asserted on the issue envelope + // rather than on a bare `success === false`: the refusal is located at + // `filter` and names the expected kind. Migration: + // `element-data-source-and-object-block-filter-rule-array`. + const r = ElementDataSourceSchema.safeParse({ object: 'account', filter: RECORD_FORM }); + expect(r.success).toBe(false); + const atFilter = issuesUnder(r, 'filter'); + expect(atFilter).toHaveLength(1); + expect(atFilter[0].code).toBe('invalid_type'); + expect(atFilter[0]).toMatchObject({ expected: 'array', path: ['filter'] }); + // An operator-object record and a `$or` group are the same form and get + // the same verdict — no arm accepts any spelling of the record. + const opRecord = ElementDataSourceSchema.safeParse({ object: 'account', filter: { amount: { $gt: 100 } } }); + expect(issuesUnder(opRecord, 'filter').map((i) => i.code)).toEqual(['invalid_type']); + const group = ElementDataSourceSchema.safeParse({ object: 'account', filter: { $or: [{ status: 'active' }] } }); + expect(issuesUnder(group, 'filter').map((i) => i.code)).toEqual(['invalid_type']); + }); + + it('the ObjectQL AST tuple array is refused too — at `filter.0`, not at `filter`', () => { + // The consumer's pinned tests author `[['amount', '>', 100]]` at this key + // (objectui `record-picker-element-data-source.test.tsx`). That shape was + // refused before (`invalid_type` at `filter`, expected record) and stays + // refused now — one hop deeper, because the array is the right container + // and the element is the wrong kind. The prescription is the rule object + // `{ field: 'amount', operator: 'greater_than', value: 100 }`; the + // seventeen off-spec authors at the pin are the seat's objectui follow-up. + const r = ElementDataSourceSchema.safeParse({ object: 'account', filter: TUPLE_ARRAY }); + expect(r.success).toBe(false); + expect(issuesUnder(r, 'filter')).toEqual([]); + const atElement = r.error!.issues.filter((i) => i.path.join('.') === 'filter.0'); + expect(atElement.map((i) => i.code)).toEqual(['invalid_type']); + }); + + it('shares the array orthography with the props-map `filter` doors — one value, two keys, the same verdicts', () => { + // `element:record_picker` was the node that carried two orthographies at + // two keys (`properties.filter` the array, `dataSource.filter` the record) + // resolved through one `??` in the renderer. The same rule array now raises + // no issue at either key, and the same record is refused at both with the + // same code — measured through the real `PageComponentSchema`, the door an + // authored page actually passes. + const both = PageComponentSchema.safeParse({ + type: 'element:record_picker', + properties: { object: 'account', filter: RULES }, + dataSource: { object: 'account', filter: RULES }, + }); + expect(both.success).toBe(true); + const bothRecords = PageComponentSchema.safeParse({ + type: 'element:record_picker', + properties: { object: 'account', filter: RECORD_FORM }, + dataSource: { object: 'account', filter: RECORD_FORM }, + }); + expect(bothRecords.success).toBe(false); + const codesAt = (path: string) => + bothRecords.error!.issues.filter((i) => i.path.join('.') === path).map((i) => i.code); + expect(codesAt('dataSource.filter')).toEqual(['invalid_type']); + expect(codesAt('properties.filter')).toEqual(codesAt('dataSource.filter')); + }); +}); + // --------------------------------------------------------------------------- // PageComponent dataSource integration // --------------------------------------------------------------------------- @@ -663,7 +768,7 @@ describe('PageComponent dataSource integration', () => { properties: { object: 'order', aggregate: 'sum', field: 'total' }, dataSource: { object: 'order', - filter: { status: 'completed' }, + filter: [{ field: 'status', operator: 'equals', value: 'completed' }], limit: 100, }, }); @@ -759,12 +864,12 @@ describe('Page end-to-end', () => { { type: 'element:number', properties: { object: 'order', aggregate: 'count' }, - dataSource: { object: 'order', filter: { status: 'pending' } }, + dataSource: { object: 'order', filter: [{ field: 'status', operator: 'equals', value: 'pending' }] }, }, { type: 'element:number', properties: { object: 'order', aggregate: 'sum', field: 'total', format: 'currency', prefix: '$' }, - dataSource: { object: 'order', filter: { status: 'completed' } }, + dataSource: { object: 'order', filter: [{ field: 'status', operator: 'equals', value: 'completed' }] }, }, { type: 'element:divider', diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 496bbb40a5..4f30ce88c0 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -6,7 +6,6 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; import { normalizeVisibleWhen } from '../shared/visibility'; import { VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary'; import { SortItemSchema } from '../shared/enums.zod'; -import { FilterConditionSchema } from '../data/filter.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { ResponsiveStylesSchema } from './responsive.zod'; import { retiredKey } from '../shared/retired-key'; @@ -166,7 +165,27 @@ export const ElementDataSourceSchema = lazySchema(() => strictObject({ }, { object: z.string().describe('Object to query'), view: z.string().optional().describe('Named view to apply'), - filter: FilterConditionSchema.optional().describe('Additional filter criteria'), + /** + * Additional filter — the `ViewFilterRule` ARRAY form, + * `[{ field, operator, value }, ...]`, the one filter orthography every + * `filter` door in `ComponentPropsMap` declares (`record:related_list`, its + * Add-affordance picker, `element:number`, `element:record_picker`, and the + * four `object-*` blocks). Until the ui#6206-B principle reached this key + * (#15442, decision batch #55, verbatim 「同意」, option A: family-wide) the + * binding alone said `FilterConditionSchema`, the MongoDB-style record — so + * `element:record_picker` carried two orthographies at two keys resolved by + * one `??` in the renderer, and the consumer's own pins authored an array + * here that this declaration refused. Measured at the objectui pin + * `53ded82b` before the declaration moved: the composition seam + * (`core/src/data-scope/element-data-source.ts`) types the key `unknown` + * and AND-combines it with the named view's own rule array through + * `mergeFilterNodes`, whose `toFilterNode` lowers a rule array to AST + * nodes — the same door every saved view's stored rules take. The record + * form is refused at `filter`; the migration prescription is the + * `element-data-source-and-object-block-filter-rule-array` semantic entry. + */ + filter: z.array(ViewFilterRuleSchema).optional() + .describe('Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), sort: z.array(SortItemSchema).optional().describe('Sort order'), limit: z.number().int().positive().optional().describe('Max records to display'), })); @@ -812,6 +831,14 @@ export type PageVariable = z.input; /** Post-parse shape of {@link PageVariable} — defaults applied, transforms run (ADR-0122). */ export type PageVariableParsed = z.infer; export type ElementDataSource = z.input; +/** + * Post-parse shape of {@link ElementDataSource} — defaults applied, transforms + * run (ADR-0122). Declared on #15442: `filter` carries + * `z.array(ViewFilterRuleSchema)`, whose own input ≠ infer (`operator` is + * normalized on parse), so the binding left the type-alias convention pin's + * isomorphic family the way `element:number` and `element:record_picker` did. + */ +export type ElementDataSourceParsed = z.infer; export type InterfacePageConfig = z.input; /** Post-parse shape of {@link InterfacePageConfig} — defaults applied, transforms run (ADR-0122). */ export type InterfacePageConfigParsed = z.infer; From e47157c02a5f6a6c31c9021a2bd102ca9ecae2d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:06:44 +0000 Subject: [PATCH 2/6] wip(spec): regenerate api-surface, export-origins and docs references for the filter converge Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- content/docs/references/ui/component.mdx | 48 ++++++++++++++++++++++-- content/docs/references/ui/page.mdx | 18 +++++++-- packages/spec/api-surface/ui.json | 4 ++ packages/spec/export-origins/ui.json | 4 ++ 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index eabf540fc4..07fcd74f14 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -311,13 +311,23 @@ Sort field and direction pair | **objectName** | `string` | optional | Object this calendar binds to. Optional because the component-level `dataSource` binding can supply the object instead | | **calendar** | `any` | optional | Calendar field config: `{ startDateField, endDateField?, titleField?, colorField?, allDayField? }` | | **defaultView** | `Enum<'month' \| 'week' \| 'day'>` | optional | Initial view mode | -| **filter** | `any` | optional | Base query filter | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `any` | optional | Sort for the fetched events | | **data** | `any[]` | optional | Pre-fetched records — skips the internal fetch | | **staticData** | `any[]` | optional | Static inline records | | **locale** | `string` | optional | Locale override for the calendar chrome | | **loading** | `boolean` | optional | External loading state (honoured only alongside `data`) | +### Nested Shape: `ObjectCalendarProps.filter[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | + --- @@ -381,7 +391,7 @@ Sort field and direction pair | **title** | `string \| Record` | optional | Fallback for `label` (the renderer reads `label \|\| title`) | | **columns** | `any[]` | optional | Columns: field names or column definition objects | | **fields** | `any[]` | optional | Field list fallback used when `columns` is absent | -| **filter** | `any` | optional | Base query filter (ObjectQL filter array/AST) — lowered to the wire `$filter`. THE key, singular — not the plural misspelling | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **defaultFilters** | `any` | optional | Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter` | | **sort** | `any` | optional | Initial sort (array of `{ field, order }`) | | **defaultSort** | `never` | optional | [REMOVED] `object-grid` property `defaultSort` was removed in @objectstack/spec 17 (ADR-0049) — it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when `sort` was absent, so one intent had two spellings and a grid authoring both silently ignored this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` becomes `sort: [{ field, order }]`); the pair itself is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | @@ -414,6 +424,16 @@ Sort field and direction pair | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema). Static inline rows live at `{ provider: 'value', items: [...] }`; the bare-array shortcut is refused — see migration `object-grid-data-view-data-converged` | | **staticData** | `any[]` | optional | Deprecated bare-array static-rows shortcut the renderer still reads. Prefer `data: { provider: 'value', items: [...] }` | +### Nested Shape: `ObjectGridProps.filter[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | + ### Nested Shape: `ObjectGridProps.data[provider='object']` | Property | Type | Required | Description | @@ -456,7 +476,7 @@ Sort field and direction pair | **objectName** | `string` | optional | Object this board binds to. Optional because the component-level `dataSource` binding can supply the object instead | | **groupBy** | `string` | optional | Field whose values become the board columns | | **columns** | `any[]` | optional | Swimlane definitions (`{ id, title }` per `groupBy` value, or bare value strings) — NOT a field projection | -| **filter** | `any` | optional | Base query filter, handed to the wire `$filter` | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **limit** | `integer` | optional | Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills it only when unset | | **data** | `any[]` | optional | Static inline cards — bypasses the object query | | **cardTitle** | `string` | optional | Field rendered as each card title | @@ -468,6 +488,16 @@ Sort field and direction pair | **coverImageField** | `string` | optional | Image field rendered as the card cover | | **conditionalFormatting** | `any` | optional | Card conditional formatting rules | +### Nested Shape: `ObjectKanbanProps.filter[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | + --- @@ -508,7 +538,7 @@ Sort field and direction pair | **icon** | `string` | optional | Lucide icon name drawn in the metric tile header, inside the `colorVariant`-tinted square. Read on this component — `ObjectMetricWidget` forwards it to `MetricWidget`, which resolves it with `getLazyIcon` (the `LazyIcon` module: kebab-case or PascalCase, degrading to the `Database` glyph on an unknown name). | | **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Icon container color variant | | **aggregate** | `any` | optional | Aggregation config (`{ field, function, groupBy? }`) run against the object | -| **filter** | `any` | optional | Filter the aggregation is scoped by | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter the aggregation is scoped by — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **format** | `string` | optional | Number format pattern (e.g. '0,0', '$0,0', '0%') | | **currency** | `string` | optional | ISO currency code (e.g. 'USD') — enables currency formatting | | **prefix** | `string` | optional | Static prefix before the formatted value | @@ -520,6 +550,16 @@ Sort field and direction pair | **drillDown** | `any` | optional | Click-through drill config — opens the underlying records | | **compareTo** | `any` | optional | Period-over-period comparison (`{ kind: 'previousPeriod' \| 'previousYear' }`) | +### Nested Shape: `ObjectMetricProps.filter[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | + --- diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 738a271786..73ef09ad6d 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -29,10 +29,20 @@ const result = ElementDataSourceSchema.parse(data); | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object to query | | **view** | `string` | optional | Named view to apply | -| **filter** | `any` | optional | Additional filter criteria | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order | | **limit** | `integer` | optional | Max records to display | +### Nested Shape: `ElementDataSource.filter[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | + ### Nested Shape: `ElementDataSource.sort[number]` Sort field and direction pair @@ -245,7 +255,7 @@ View filter rule | **responsiveStyles** | `{ large?: Record; medium?: Record; small?: Record; xsmall?: Record }` | optional | Per-breakpoint scoped style maps (ADR-0065) | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `app`, `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. "page.selectedProjectId != ''" | | **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | -| **dataSource** | `{ object: string; view?: string; filter?: any; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages | +| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages | | **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | @@ -264,7 +274,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object to query | | **view** | `string` | optional | Named view to apply | -| **filter** | `any` | optional | Additional filter criteria | +| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order | | **limit** | `integer` | optional | Max records to display | @@ -343,7 +353,7 @@ View filter rule | **responsiveStyles** | `{ large?: Record; medium?: Record; small?: Record; xsmall?: Record }` | optional | Per-breakpoint scoped style maps (ADR-0065) | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `app`, `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. "page.selectedProjectId != ''" | | **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | -| **dataSource** | `{ object: string; view?: string; filter?: any; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages | +| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages | | **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 750cc34cd6..0507096117 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -138,6 +138,7 @@ "EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT (const)", "ElementButtonPropsSchema (const)", "ElementDataSource (type)", + "ElementDataSourceParsed (type)", "ElementDataSourceSchema (const)", "ElementFilterPropsSchema (const)", "ElementFormPropsSchema (const)", @@ -251,6 +252,7 @@ "NotificationType (type)", "NotificationTypeSchema (const)", "ObjectCalendarProps (type)", + "ObjectCalendarPropsParsed (type)", "ObjectCalendarPropsSchema (const)", "ObjectFormProps (type)", "ObjectFormPropsSchema (const)", @@ -258,11 +260,13 @@ "ObjectGridPropsParsed (type)", "ObjectGridPropsSchema (const)", "ObjectKanbanProps (type)", + "ObjectKanbanPropsParsed (type)", "ObjectKanbanPropsSchema (const)", "ObjectListViewSchema (const)", "ObjectMasterDetailFormProps (type)", "ObjectMasterDetailFormPropsSchema (const)", "ObjectMetricProps (type)", + "ObjectMetricPropsParsed (type)", "ObjectMetricPropsSchema (const)", "ObjectNavItem (type)", "ObjectNavItemParsed (type)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index e0f1611736..17b87ed2e5 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -134,6 +134,7 @@ "EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT": "src/ui/expression-bindable-text-keys.zod.ts#EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT (const)", "ElementButtonPropsSchema": "src/ui/component.zod.ts#ElementButtonPropsSchema (const)", "ElementDataSource": "src/ui/page.zod.ts#ElementDataSource (type)", + "ElementDataSourceParsed": "src/ui/page.zod.ts#ElementDataSourceParsed (type)", "ElementDataSourceSchema": "src/ui/page.zod.ts#ElementDataSourceSchema (const)", "ElementFilterPropsSchema": "src/ui/component.zod.ts#ElementFilterPropsSchema (const)", "ElementFormPropsSchema": "src/ui/component.zod.ts#ElementFormPropsSchema (const)", @@ -247,6 +248,7 @@ "NotificationType": "src/ui/notification.zod.ts#NotificationType (type)", "NotificationTypeSchema": "src/ui/notification.zod.ts#NotificationTypeSchema (const)", "ObjectCalendarProps": "src/ui/component.zod.ts#ObjectCalendarProps (type)", + "ObjectCalendarPropsParsed": "src/ui/component.zod.ts#ObjectCalendarPropsParsed (type)", "ObjectCalendarPropsSchema": "src/ui/component.zod.ts#ObjectCalendarPropsSchema (const)", "ObjectFormProps": "src/ui/component.zod.ts#ObjectFormProps (type)", "ObjectFormPropsSchema": "src/ui/component.zod.ts#ObjectFormPropsSchema (const)", @@ -254,11 +256,13 @@ "ObjectGridPropsParsed": "src/ui/component.zod.ts#ObjectGridPropsParsed (type)", "ObjectGridPropsSchema": "src/ui/component.zod.ts#ObjectGridPropsSchema (const)", "ObjectKanbanProps": "src/ui/component.zod.ts#ObjectKanbanProps (type)", + "ObjectKanbanPropsParsed": "src/ui/component.zod.ts#ObjectKanbanPropsParsed (type)", "ObjectKanbanPropsSchema": "src/ui/component.zod.ts#ObjectKanbanPropsSchema (const)", "ObjectListViewSchema": "src/ui/view.zod.ts#ObjectListViewSchema (const)", "ObjectMasterDetailFormProps": "src/ui/component.zod.ts#ObjectMasterDetailFormProps (type)", "ObjectMasterDetailFormPropsSchema": "src/ui/component.zod.ts#ObjectMasterDetailFormPropsSchema (const)", "ObjectMetricProps": "src/ui/component.zod.ts#ObjectMetricProps (type)", + "ObjectMetricPropsParsed": "src/ui/component.zod.ts#ObjectMetricPropsParsed (type)", "ObjectMetricPropsSchema": "src/ui/component.zod.ts#ObjectMetricPropsSchema (const)", "ObjectNavItem": "src/ui/app.zod.ts#ObjectNavItem (type)", "ObjectNavItemParsed": "src/ui/app.zod.ts#ObjectNavItemParsed (type)", From 6274af36505521730cd0da2383d19dbdc3854a88 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:29:01 +0000 Subject: [PATCH 3/6] wip(spec): migrate the two object-* fixtures in component.test.ts; ask each key at its own door in the binding pins Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- packages/spec/src/ui/component.test.ts | 17 ++++++++---- packages/spec/src/ui/page.test.ts | 38 +++++++++++++++----------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index ba1c3c5fc6..21e11472da 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -2653,12 +2653,16 @@ describe('#7751 — object-* block props schemas', () => { }); it('the corrected #7750 node (my-work.page.ts, post-fix) parses GREEN and retains its filter', () => { + // The node as the showcase authors it since #15442 / #15449: the + // `ViewFilterRule` array (ui#6206-B). The AST tuple this pin carried + // before is refused at `filter.0` now — pinned in the object-* filter + // describe below. const parsed = ComponentPropsMap['object-grid'].parse({ objectName: 'showcase_task', columns: ['title', 'project', 'status', 'priority', 'due_date'], - filter: [['owner_id', '=', '{current_user_id}']], + filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], }); - expect(parsed.filter).toEqual([['owner_id', '=', '{current_user_id}']]); + expect(parsed.filter).toEqual([{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]); }); it("object-grid `data` takes the ViewDataSchema provider object — the ui#6207 convergence (Option A)", () => { @@ -2733,10 +2737,13 @@ describe('#7751 — object-* block props schemas', () => { it('every object-metric node of the showcase corpus parses GREEN (the clean-corpus control)', () => { // Copies of all three my-work.page.ts metrics + the command-center shape // (variant/format) — the exact nodes the lint must NOT start warning on. + // The three filters are the `ViewFilterRule` arrays the showcase authors + // since #15442 / #15449 (ui#6206-B); the records they replaced are refused + // at `filter` now (pinned in the object-* filter describe above). const nodes = [ - { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'blue', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: { status: { $ne: 'done' } } }, - { objectName: 'showcase_task', label: 'In Review', icon: 'eye', colorVariant: 'warning', description: 'awaiting review', aggregate: { field: 'id', function: 'count' }, filter: { status: 'in_review' } }, - { objectName: 'showcase_project', label: 'At-Risk Projects', icon: 'alert-triangle', colorVariant: 'danger', description: 'health red', aggregate: { field: 'id', function: 'count' }, filter: { health: 'red' } }, + { objectName: 'showcase_task', label: 'Open Tasks', icon: 'list-checks', colorVariant: 'blue', description: 'not done', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'status', operator: 'not_equals', value: 'done' }] }, + { objectName: 'showcase_task', label: 'In Review', icon: 'eye', colorVariant: 'warning', description: 'awaiting review', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'status', operator: 'equals', value: 'in_review' }] }, + { objectName: 'showcase_project', label: 'At-Risk Projects', icon: 'alert-triangle', colorVariant: 'danger', description: 'health red', aggregate: { field: 'id', function: 'count' }, filter: [{ field: 'health', operator: 'equals', value: 'red' }] }, { objectName: 'showcase_task', label: 'Tasks', colorVariant: 'purple', variant: 'bare', aggregate: { field: 'id', function: 'count' }, format: '0,0' }, ]; for (const node of nodes) { diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 4370e540d0..9dd4c60086 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -15,6 +15,7 @@ import { type ElementDataSource, type InterfacePageConfig, } from './page.zod'; +import { ComponentPropsMap } from './component.zod'; describe('PageComponentSchema', () => { it('should accept valid minimal component', () => { @@ -666,6 +667,9 @@ describe('ElementDataSourceSchema `filter` — one filter orthography platform-w /** The issues a parse raised under `key` (top-level), whatever else it raised. */ const issuesUnder = (r: ParseResult, key: string) => r.success ? [] : r.error!.issues.filter((i) => i.path[0] === key); + /** The issues located EXACTLY at `path` (dotted) — `filter` is not `filter.0`. */ + const issuesAt = (r: ParseResult, path: string) => + r.success ? [] : r.error!.issues.filter((i) => i.path.join('.') === path); it('accepts a ViewFilterRule[] filter — the acceptance criterion', () => { // Before #15442 this exact value was REFUSED here (`invalid_type`, expected @@ -727,34 +731,36 @@ describe('ElementDataSourceSchema `filter` — one filter orthography platform-w // seventeen off-spec authors at the pin are the seat's objectui follow-up. const r = ElementDataSourceSchema.safeParse({ object: 'account', filter: TUPLE_ARRAY }); expect(r.success).toBe(false); - expect(issuesUnder(r, 'filter')).toEqual([]); - const atElement = r.error!.issues.filter((i) => i.path.join('.') === 'filter.0'); - expect(atElement.map((i) => i.code)).toEqual(['invalid_type']); + expect(issuesAt(r, 'filter')).toEqual([]); + expect(issuesAt(r, 'filter.0').map((i) => i.code)).toEqual(['invalid_type']); + expect(issuesAt(r, 'filter.0')[0]).toMatchObject({ expected: 'object' }); }); it('shares the array orthography with the props-map `filter` doors — one value, two keys, the same verdicts', () => { // `element:record_picker` was the node that carried two orthographies at // two keys (`properties.filter` the array, `dataSource.filter` the record) - // resolved through one `??` in the renderer. The same rule array now raises - // no issue at either key, and the same record is refused at both with the - // same code — measured through the real `PageComponentSchema`, the door an - // authored page actually passes. - const both = PageComponentSchema.safeParse({ + // resolved through one `??` in the renderer. Each key is asked at ITS + // door: the binding through the real `PageComponentSchema` (which parses + // `dataSource` and leaves `properties` a bag — the props-map dispatch is + // the lint's, warning tier), and the props key through the picker's own + // `ComponentPropsMap` entry. The same rule array raises no issue at either; + // the same record is refused at both with the same code. + const binding = PageComponentSchema.safeParse({ type: 'element:record_picker', properties: { object: 'account', filter: RULES }, dataSource: { object: 'account', filter: RULES }, }); - expect(both.success).toBe(true); - const bothRecords = PageComponentSchema.safeParse({ + expect(binding.success).toBe(true); + const bindingRecord = PageComponentSchema.safeParse({ type: 'element:record_picker', - properties: { object: 'account', filter: RECORD_FORM }, + properties: { object: 'account', filter: RULES }, dataSource: { object: 'account', filter: RECORD_FORM }, }); - expect(bothRecords.success).toBe(false); - const codesAt = (path: string) => - bothRecords.error!.issues.filter((i) => i.path.join('.') === path).map((i) => i.code); - expect(codesAt('dataSource.filter')).toEqual(['invalid_type']); - expect(codesAt('properties.filter')).toEqual(codesAt('dataSource.filter')); + expect(issuesAt(bindingRecord, 'dataSource.filter').map((i) => i.code)).toEqual(['invalid_type']); + const picker = ComponentPropsMap['element:record_picker']; + expect(issuesAt(picker.safeParse({ object: 'account', filter: RULES }), 'filter')).toEqual([]); + expect(issuesAt(picker.safeParse({ object: 'account', filter: RECORD_FORM }), 'filter').map((i) => i.code)) + .toEqual(issuesAt(bindingRecord, 'dataSource.filter').map((i) => i.code)); }); }); From 3d7f4eda031a0a2c70635c24811a4c1c7410ca34 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 02:02:28 +0000 Subject: [PATCH 4/6] fix(spec,lint): state the at-rest disposition on the D3 entry; restore the deleted `pages` pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract review findings F1 and F3 on this PR. F1 — the D3 entry stated the sweep and the objectui route but was silent on metadata AT REST, the one population its TODO cannot reach: no author runs `os migrate meta` over `sys_metadata`. Measured, all three legs: a `SemanticMigration` converts nothing by its own type (`migrations/types.ts`), this disposition adds no D2 conversion (`packages/spec/src/conversions/` is untouched by this branch), and `applyConversionsToStoredItem` (`database-loader.ts:831`) replays the full chain without validating, by its own contract. So a stored row carrying the record form keeps loading, is still rendered by objectui at the pin, and is refused only on its next save. One sentence added to `reason`, on the register of `17.view-filter-rule-value-shaped-by-operator.ts:38-47`, which states the same disposition for its own family. `registry.ts` follows by `gen:migration-registry` — that one sentence is its whole delta. F2 and the partial-D2 direction question are deliberately NOT in this commit: F2 is family-wide (it also reaches `element:number` and `element:record_picker`) and the D2 direction is the ruler's, not a dev's. The new sentence therefore states that this disposition adds no D2 conversion, and does not claim none is possible. F3 — this branch had dropped the only `pages` row from the "covers ..." case while `pages` remains in `EMPTY_COMBINATOR_SURFACES`, leaving that surface declared and unmeasured. Restored byte-identical to the base tree, plus a comment recording that the row authors the retired record form on purpose: this rule never parses a fixture against the spec schema — `walkAuthoredFilters` visits any `FILTER_KEYS` key it reaches, whatever its shape — so an author still writing the record form is judged here and gets a located error beside the schema refusal. Ablation: dropping `{ key: 'pages', kind: 'page' }` from `EMPTY_COMBINATOR_SURFACES` turns the case red losing exactly `page "home"` (21 passed -> 1 failed | 20 passed); the rule file was restored to its HEAD blob and verified by `git hash-object`. No generated artefact moved beyond `registry.ts`: `check:generated` reports all 15 up to date, and `spec-changes.json` / the upgrade guide correctly show no diff because both projections fold only to the current PROTOCOL_MAJOR (17) and this is a major-18 entry — measured against a lit control (the major-17 entry `view-filter-rule-value-shaped-by-operator` is present in both; the neighbouring major-18 `ui-record-blocks-unknown-keys-refused` is absent from both). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .../src/validate-empty-combinators.test.ts | 30 ++++++++++++------- ...urce-and-object-block-filter-rule-array.ts | 9 +++++- packages/spec/src/migrations/registry.ts | 9 +++++- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/lint/src/validate-empty-combinators.test.ts b/packages/lint/src/validate-empty-combinators.test.ts index 087e5d227a..0b9e482954 100644 --- a/packages/lint/src/validate-empty-combinators.test.ts +++ b/packages/lint/src/validate-empty-combinators.test.ts @@ -228,22 +228,29 @@ describe('validateEmptyCombinators — the surfaces it walks', () => { expect(findings[0].message).toContain('matches NO row'); }); - it('covers objects, views, reports, datasets and apps', () => { - // No `pages` row any more: the page-level filter doors — the four - // `object-*` `filter` props and the binding-level `dataSource.filter` — - // carry `z.array(ViewFilterRuleSchema)` since #15442 / #15449 (ui#6206-B, - // one filter orthography), and this rule judges Filter Protocol NODES - // only (`isFilterNode` excludes arrays). The `object-grid { $or: [] }` - // this row used to author is refused by the spec at `filter` now, so a - // page row here could only pin an off-spec fixture. `pages` stays in - // `EMPTY_COMBINATOR_SURFACES`: the walker is key-name based, and an author - // still writing the retired record form gets this located error beside - // the schema refusal rather than nothing. + it('covers objects, views, reports, datasets, pages and apps', () => { + // The `pages` row authors the RETIRED record form on purpose: since + // #15442 / #15449 (ui#6206-B, one filter orthography) the `object-*` + // `filter` doors carry `z.array(ViewFilterRuleSchema)`, so the spec now + // refuses `{ $or: [] }` there. That is exactly what this row measures. + // This rule never parses a fixture against the spec schema — the walk is + // key-name based (`walkAuthoredFilters` visits any `FILTER_KEYS` key it + // reaches, whatever its shape) — so the off-spec author is still judged + // here, and gets this located error beside the schema refusal rather than + // nothing. It is also the only measurement of the page walk that `pages` + // in `EMPTY_COMBINATOR_SURFACES` declares: drop it and that surface is + // declared and unmeasured. const findings = validateEmptyCombinators({ objects: [{ name: 'lead', listViews: { mine: { filter: { $or: [] } } } }], views: [{ name: 'all', list: { filter: { $and: [] } } }], reports: [{ name: 'weekly', runtimeFilter: { $not: {} } }], datasets: [{ name: 'cases', filter: {} }], + pages: [ + { + name: 'home', + regions: [{ components: [{ type: 'object-grid', properties: { filter: { $or: [] } } }] }], + }, + ], apps: [{ name: 'crm', navigation: [{ id: 'n', type: 'object', filters: { $and: [] } }] }], }); @@ -251,6 +258,7 @@ describe('validateEmptyCombinators — the surfaces it walks', () => { 'app "crm"', 'dataset "cases"', 'object "lead"', + 'page "home"', 'report "weekly"', 'view "all"', ]); diff --git a/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts index 48c3e5af03..5297262afc 100644 --- a/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts +++ b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts @@ -56,7 +56,14 @@ export const entry: SemanticMigration = { + 'doors (`my-work.page.ts`, `index.ts`: four records on `object-metric`, one AST tuple ' + 'array on `object-grid`) and three lint fixtures — every one rewritten to the rule array ' + 'in the same change, and zero outside those files; this entry carries the prescription ' - + 'for authors outside the repo.', + + 'for authors outside the repo. ' + + '⚠️ Metadata AT REST is deliberately NOT rewritten, and this disposition adds no D2 ' + + 'conversion — a SemanticMigration converts nothing by its own type, and no author runs ' + + '`os migrate meta` over `sys_metadata`. The read path does not re-validate stored rows ' + + '(`applyConversionsToStoredItem` replays the full chain without validating, by its own ' + + 'contract), so a stored page or block carrying the record form keeps loading unchanged ' + + 'and is still rendered by objectui at the pinned `.objectui-sha`; what changes is that ' + + 'RE-SAVING it is refused at the `filter` door, on its next save and not before.', acceptanceCriteria: '`ElementDataSourceSchema.safeParse({ object, filter: [{ field: \'status\', operator: ' + '\'equals\', value: \'active\' }] })` succeeds and the parsed `filter` is the same rule ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 1c6278389d..b5c633446f 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6947,7 +6947,14 @@ const step18: MigrationStep = { + 'doors (`my-work.page.ts`, `index.ts`: four records on `object-metric`, one AST tuple ' + 'array on `object-grid`) and three lint fixtures — every one rewritten to the rule array ' + 'in the same change, and zero outside those files; this entry carries the prescription ' - + 'for authors outside the repo.', + + 'for authors outside the repo. ' + + '⚠️ Metadata AT REST is deliberately NOT rewritten, and this disposition adds no D2 ' + + 'conversion — a SemanticMigration converts nothing by its own type, and no author runs ' + + '`os migrate meta` over `sys_metadata`. The read path does not re-validate stored rows ' + + '(`applyConversionsToStoredItem` replays the full chain without validating, by its own ' + + 'contract), so a stored page or block carrying the record form keeps loading unchanged ' + + 'and is still rendered by objectui at the pinned `.objectui-sha`; what changes is that ' + + 'RE-SAVING it is refused at the `filter` door, on its next save and not before.', acceptanceCriteria: '`ElementDataSourceSchema.safeParse({ object, filter: [{ field: \'status\', operator: ' + '\'equals\', value: \'active\' }] })` succeeds and the parsed `filter` is the same rule ' From 816f181e11e87b4151d63803ad9a4bbb8d367ede Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 02:44:56 +0000 Subject: [PATCH 5/6] =?UTF-8?q?fix(spec):=20correct=20the=20at-rest=20clau?= =?UTF-8?q?se=20on=20the=20D3=20filter=20entry=20=E2=80=94=20`--stored`=20?= =?UTF-8?q?does=20exist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry said "no author runs `os migrate meta` over `sys_metadata`". That is false: `os migrate meta --stored` has exactly that subject (packages/cli/src/commands/migrate/ meta.ts — module doc "`--stored`: the same chain, over data at rest", the flag, `runStored`, `protocol.migrateStoredMetadata(...)`). The true and narrower fact the clause was reaching for: the stored pass replays ADR-0087 D2 conversions only — `migrateStoredMetadata` converts through `applyConversionsToStoredItem`, i.e. `applyConversions`, and emits no D3 TODOs (`todos` is 7x in the authored-mode half of meta.ts and 0x from `runStored` to EOF) — so with no D2 conversion added by this disposition it has nothing to rewrite for this shape. The scoped "this disposition adds no D2 conversion" is unchanged and stays scoped: the partial-D2 direction question is the ruler's, not this entry's. `registry.ts` follows by `gen:migration-registry`; no other sentence of the entry moved. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude --- ...lement-data-source-and-object-block-filter-rule-array.ts | 6 ++++-- packages/spec/src/migrations/registry.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts index 5297262afc..38dd0e63c2 100644 --- a/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts +++ b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts @@ -58,8 +58,10 @@ export const entry: SemanticMigration = { + 'in the same change, and zero outside those files; this entry carries the prescription ' + 'for authors outside the repo. ' + '⚠️ Metadata AT REST is deliberately NOT rewritten, and this disposition adds no D2 ' - + 'conversion — a SemanticMigration converts nothing by its own type, and no author runs ' - + '`os migrate meta` over `sys_metadata`. The read path does not re-validate stored rows ' + + 'conversion — a SemanticMigration converts nothing by its own type, and ' + + '`os migrate meta --stored` (the pass over a deployment\'s `sys_metadata` rows) replays ' + + 'D2 conversions only, so it has nothing to rewrite here. The read path does not ' + + 're-validate stored rows ' + '(`applyConversionsToStoredItem` replays the full chain without validating, by its own ' + 'contract), so a stored page or block carrying the record form keeps loading unchanged ' + 'and is still rendered by objectui at the pinned `.objectui-sha`; what changes is that ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index b5c633446f..e37b324a27 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6949,8 +6949,10 @@ const step18: MigrationStep = { + 'in the same change, and zero outside those files; this entry carries the prescription ' + 'for authors outside the repo. ' + '⚠️ Metadata AT REST is deliberately NOT rewritten, and this disposition adds no D2 ' - + 'conversion — a SemanticMigration converts nothing by its own type, and no author runs ' - + '`os migrate meta` over `sys_metadata`. The read path does not re-validate stored rows ' + + 'conversion — a SemanticMigration converts nothing by its own type, and ' + + '`os migrate meta --stored` (the pass over a deployment\'s `sys_metadata` rows) replays ' + + 'D2 conversions only, so it has nothing to rewrite here. The read path does not ' + + 're-validate stored rows ' + '(`applyConversionsToStoredItem` replays the full chain without validating, by its own ' + 'contract), so a stored page or block carrying the record form keeps loading unchanged ' + 'and is still rendered by objectui at the pinned `.objectui-sha`; what changes is that ' From ad7dcbc0ffaf0dc70d7d0bad05c7c7bedcc8af97 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 03:00:28 +0000 Subject: [PATCH 6/6] fix(spec): scope the at-rest stored-pass clause to this shape, not to protocol 18 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `conversionIds` on step 18 is NOT empty — it carries the major's other D2 conversions — so "nothing to rewrite here" could be read as "the stored pass rewrites nothing at protocol 18", which is false. The true scope is this entry's disposition, which adds no D2 conversion of its own (its id is 0x in `conversions/registry.ts`, control `action-execute-to-target` lit at 1x). `registry.ts` follows by `gen:migration-registry`. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude --- ....element-data-source-and-object-block-filter-rule-array.ts | 4 ++-- packages/spec/src/migrations/registry.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts index 38dd0e63c2..06fb47f3e0 100644 --- a/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts +++ b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts @@ -60,8 +60,8 @@ export const entry: SemanticMigration = { + '⚠️ Metadata AT REST is deliberately NOT rewritten, and this disposition adds no D2 ' + 'conversion — a SemanticMigration converts nothing by its own type, and ' + '`os migrate meta --stored` (the pass over a deployment\'s `sys_metadata` rows) replays ' - + 'D2 conversions only, so it has nothing to rewrite here. The read path does not ' - + 're-validate stored rows ' + + 'D2 conversions only, so it has nothing to rewrite for this shape. The read path ' + + 'does not re-validate stored rows ' + '(`applyConversionsToStoredItem` replays the full chain without validating, by its own ' + 'contract), so a stored page or block carrying the record form keeps loading unchanged ' + 'and is still rendered by objectui at the pinned `.objectui-sha`; what changes is that ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index e37b324a27..ab1cd97668 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6951,8 +6951,8 @@ const step18: MigrationStep = { + '⚠️ Metadata AT REST is deliberately NOT rewritten, and this disposition adds no D2 ' + 'conversion — a SemanticMigration converts nothing by its own type, and ' + '`os migrate meta --stored` (the pass over a deployment\'s `sys_metadata` rows) replays ' - + 'D2 conversions only, so it has nothing to rewrite here. The read path does not ' - + 're-validate stored rows ' + + 'D2 conversions only, so it has nothing to rewrite for this shape. The read path ' + + 'does not re-validate stored rows ' + '(`applyConversionsToStoredItem` replays the full chain without validating, by its own ' + 'contract), so a stored page or block carrying the record form keeps loading unchanged ' + 'and is still rendered by objectui at the pinned `.objectui-sha`; what changes is that '