From c2d781ab1ee2ebb38fbd92af9c617d65ca1dbe7e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:16:55 +0000 Subject: [PATCH 01/16] wip(spec)!: retire the element-layer flat data-binding keys and object-grid defaultFilters (#11509) Tombstones, the two protocol-18 conversions, the registry entries and the absorbed step-18 narrowings. Tests and generated artifacts follow. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/spec/src/conversions/registry.ts | 816 ++++++++++++++++-- .../18.ui__ElementNumberProps__filter.ts | 9 + .../18.ui__ElementNumberProps__object.ts | 9 + ...18.ui__ElementRecordPickerProps__filter.ts | 9 + .../18.ui__ElementRecordPickerProps__limit.ts | 9 + ...18.ui__ElementRecordPickerProps__object.ts | 9 + .../18.ui__ElementRecordPickerProps__sort.ts | 9 + .../18.ui__ElementRepeaterProps__filter.ts | 9 + .../18.ui__ElementRepeaterProps__limit.ts | 9 + .../18.ui__ElementRepeaterProps__object.ts | 9 + .../18.ui__ElementRepeaterProps__sort.ts | 9 + .../18.ui__ObjectGridProps__defaultFilters.ts | 10 + .../18.element-flat-data-binding-retired.ts | 59 ++ .../18.element-number-filter-rule-array.ts | 62 -- ...element-record-picker-filter-rule-array.ts | 56 -- .../18.object-grid-default-filters-retired.ts | 39 + ....object-grid-default-filters-rule-array.ts | 81 -- packages/spec/src/migrations/registry.ts | 382 ++++---- packages/spec/src/ui/component.zod.ts | 340 ++++---- 19 files changed, 1340 insertions(+), 595 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__filter.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__object.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__filter.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__limit.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__object.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__sort.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__filter.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__limit.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__object.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__sort.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectGridProps__defaultFilters.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index aa4b6181256..b20a36960dc 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -5183,12 +5183,12 @@ const recordPickerDisplayFieldToLabelField: MetadataConversion = { { name: 'main', components: [ - { type: 'element:record_picker', properties: { object: 'showcase_project', displayField: 'title' } }, + { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: { displayField: 'title' } }, // Both spellings, SAME value: the redundant twin goes (#4923). - { type: 'element:record_picker', properties: { object: 'a', labelField: 'name', displayField: 'name' } }, + { type: 'element:record_picker', dataSource: { object: 'a' }, properties: { labelField: 'name', displayField: 'name' } }, // Both spellings, DIFFERENT fields: kept, so the author reconciles // the two rather than the loader picking a column. - { type: 'element:record_picker', properties: { object: 'b', labelField: 'name', displayField: 'title' } }, + { type: 'element:record_picker', dataSource: { object: 'b' }, properties: { labelField: 'name', displayField: 'title' } }, // `displayField` is a live LOOKUP-FIELD key elsewhere on the // surface — a different component's business, untouched here. // (The neighbor was `element:form` until #9249 retired that @@ -5202,7 +5202,7 @@ const recordPickerDisplayFieldToLabelField: MetadataConversion = { properties: { title: 'Link a project', children: [ - { type: 'element:record_picker', properties: { object: 'd', displayField: 'code' } }, + { type: 'element:record_picker', dataSource: { object: 'd' }, properties: { displayField: 'code' } }, ], }, }, @@ -5217,7 +5217,7 @@ const recordPickerDisplayFieldToLabelField: MetadataConversion = { regions: [], slots: { details: [ - { type: 'element:record_picker', properties: { object: 'e', displayField: 'label' } }, + { type: 'element:record_picker', dataSource: { object: 'e' }, properties: { displayField: 'label' } }, ], }, }, @@ -5231,16 +5231,16 @@ const recordPickerDisplayFieldToLabelField: MetadataConversion = { { name: 'main', components: [ - { type: 'element:record_picker', properties: { object: 'showcase_project', labelField: 'title' } }, - { type: 'element:record_picker', properties: { object: 'a', labelField: 'name' } }, - { type: 'element:record_picker', properties: { object: 'b', labelField: 'name', displayField: 'title' } }, + { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: { labelField: 'title' } }, + { type: 'element:record_picker', dataSource: { object: 'a' }, properties: { labelField: 'name' } }, + { type: 'element:record_picker', dataSource: { object: 'b' }, properties: { labelField: 'name', displayField: 'title' } }, { type: 'element:text', properties: { displayField: 'title' } }, { type: 'page:card', properties: { title: 'Link a project', children: [ - { type: 'element:record_picker', properties: { object: 'd', labelField: 'code' } }, + { type: 'element:record_picker', dataSource: { object: 'd' }, properties: { labelField: 'code' } }, ], }, }, @@ -5254,7 +5254,7 @@ const recordPickerDisplayFieldToLabelField: MetadataConversion = { regions: [], slots: { details: [ - { type: 'element:record_picker', properties: { object: 'e', labelField: 'label' } }, + { type: 'element:record_picker', dataSource: { object: 'e' }, properties: { labelField: 'label' } }, ], }, }, @@ -5306,7 +5306,8 @@ const recordPickerInertKeysRemoved: MetadataConversion = { components: [ { type: 'element:record_picker', - properties: { object: 'showcase_project', searchFields: ['name', 'code'], multiple: true }, + dataSource: { object: 'showcase_project' }, + properties: { searchFields: ['name', 'code'], multiple: true }, }, // `multiple` is a live FIELD key (lookup fields) — a different // surface entirely, and not this entry's business. (The @@ -5324,7 +5325,7 @@ const recordPickerInertKeysRemoved: MetadataConversion = { { label: 'Pick one', children: [ - { type: 'element:record_picker', properties: { object: 'b', multiple: true } }, + { type: 'element:record_picker', dataSource: { object: 'b' }, properties: { multiple: true } }, ], }, ], @@ -5340,7 +5341,7 @@ const recordPickerInertKeysRemoved: MetadataConversion = { kind: 'slotted', regions: [], slots: { - details: { type: 'element:record_picker', properties: { object: 'c', searchFields: ['name'] } }, + details: { type: 'element:record_picker', dataSource: { object: 'c' }, properties: { searchFields: ['name'] } }, }, }, ], @@ -5353,7 +5354,7 @@ const recordPickerInertKeysRemoved: MetadataConversion = { { name: 'main', components: [ - { type: 'element:record_picker', properties: { object: 'showcase_project' } }, + { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: {} }, { type: 'element:text', properties: { multiple: true } }, { type: 'page:tabs', @@ -5363,7 +5364,7 @@ const recordPickerInertKeysRemoved: MetadataConversion = { { label: 'Pick one', children: [ - { type: 'element:record_picker', properties: { object: 'b' } }, + { type: 'element:record_picker', dataSource: { object: 'b' }, properties: {} }, ], }, ], @@ -5378,7 +5379,7 @@ const recordPickerInertKeysRemoved: MetadataConversion = { kind: 'slotted', regions: [], slots: { - details: { type: 'element:record_picker', properties: { object: 'c' } }, + details: { type: 'element:record_picker', dataSource: { object: 'c' }, properties: {} }, }, }, ], @@ -6809,7 +6810,7 @@ const elementInputTargetVariableRemoved: MetadataConversion = { type: 'element:text_input', properties: { inputType: 'email', targetVariable: 'contact_email' }, }, - { type: 'element:record_picker', properties: { object: 'showcase_project', targetVariable: 'selected_id' } }, + { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: { targetVariable: 'selected_id' } }, // Negative control: a `targetVariable` on a type OUTSIDE this // conversion's dispatch proves the strip is scoped by the // component TYPE, not by the key name. It was originally an @@ -6826,7 +6827,7 @@ const elementInputTargetVariableRemoved: MetadataConversion = { properties: { title: 'Pick one', children: [ - { type: 'element:record_picker', properties: { object: 'b', targetVariable: 'picked' } }, + { type: 'element:record_picker', dataSource: { object: 'b' }, properties: { targetVariable: 'picked' } }, ], }, }, @@ -6863,14 +6864,14 @@ const elementInputTargetVariableRemoved: MetadataConversion = { type: 'element:text_input', properties: { inputType: 'email' }, }, - { type: 'element:record_picker', properties: { object: 'showcase_project' } }, + { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: {} }, { type: 'custom:legacy_input', properties: { targetVariable: 'active_filter' } }, { type: 'page:card', properties: { title: 'Pick one', children: [ - { type: 'element:record_picker', properties: { object: 'b' } }, + { type: 'element:record_picker', dataSource: { object: 'b' }, properties: {} }, ], }, }, @@ -7219,11 +7220,13 @@ const elementFilterRemoved: MetadataConversion = { }, }, // Key overlap on a DIFFERENT type rides through untouched — - // `object` is also a live `element:record_picker` prop, and - // the strip dispatches on the component type, not the key - // name. (The neighbor was `element:form` until #9249 retired - // that element whole; its own strip would now touch the node.) - { type: 'element:record_picker', properties: { object: 'order' } }, + // `object` is also a live `element:metadata_viewer` prop (its + // metadata owner), and the strip dispatches on the component + // type, not the key name. (The neighbor was `element:form` + // until #9249 retired that element whole, then + // `element:record_picker` until #11509 retired its flat + // `object`; either one's own conversion would now touch it.) + { type: 'element:metadata_viewer', properties: { type: 'state_machine', name: 'order_status', object: 'order' } }, // Nested one container down (#6775) — the walk descends. { type: 'page:card', @@ -7263,7 +7266,7 @@ const elementFilterRemoved: MetadataConversion = { name: 'main', components: [ { type: 'element:filter', properties: {} }, - { type: 'element:record_picker', properties: { object: 'order' } }, + { type: 'element:metadata_viewer', properties: { type: 'state_machine', name: 'order_status', object: 'order' } }, { type: 'page:card', properties: { @@ -7290,11 +7293,415 @@ const elementFilterRemoved: MetadataConversion = { ], }, // One per stripped key: 5 on the top-level node, 2 on the nested one, - // 3 on the slotted one. The `element:record_picker` neighbor is untouched. + // 3 on the slotted one. The `element:metadata_viewer` neighbor is untouched. expectedNotices: 10, }, }; +/** + * The element layer's retired flat data-binding keys, per element type — the + * keys {@link elementFlatDataBindingToDataSource} moves onto the node-level + * `dataSource`. Declared here rather than imported from + * `ui/component.zod.ts` (its `RETIRED_ELEMENT_FLAT_BINDING_KEYS`), because this + * module is kept free of the page-component schemas it would drag into every + * bundle of the `./shared` entry (see {@link CONVERSIONS_BY_MAJOR}); + * `element-flat-data-binding-to-data-source.test.ts` holds the two equal. + */ +const ELEMENT_FLAT_BINDING_KEYS_BY_TYPE: Readonly> = { + 'element:record_picker': ['object', 'filter', 'sort', 'limit'], + 'element:number': ['object', 'filter'], + 'element:repeater': ['object', 'filter', 'sort', 'limit'], +}; + +/** A rule array, as far as a mechanical append can tell: an array of plain objects. */ +function isRuleObjectArray(value: unknown): value is Dict[] { + return Array.isArray(value) && value.every(isDict); +} + +/** + * What {@link elementFlatDataBindingToDataSource} does with one flat key, by + * the element's OLD resolution of it — the rule each renderer applied before + * objectui#11880 moved all three onto `dataSource` alone: + * + * - `element:record_picker`, and `element:number`'s `object`: the binding (or + * the saved view it names) first, the flat key second + * (`composed?. ?? props.`); + * - `element:number`'s `filter`: AND-combined with the binding's; + * - `element:repeater`: the flat keys ONLY — the binding was not read at all. + */ +type FlatBindingDisposition = + | { kind: 'move' } + | { kind: 'drop' } + | { kind: 'append'; rules: Dict[] } + | { kind: 'todo'; reason: string }; + +function flatBindingDisposition( + type: string, + key: string, + value: unknown, + binding: Dict | undefined, +): FlatBindingDisposition { + const bound = binding !== undefined && binding[key] !== undefined; + const view = binding && typeof binding.view === 'string' && binding.view.length > 0 ? binding.view : undefined; + + if (type === 'element:repeater') { + if (bound) { + if (deepEqualAuthored(binding![key], value)) return { kind: 'drop' }; + return { + kind: 'todo', + reason: `\`dataSource.${key}\` is set to a different value than this flat \`${key}\`. The list read ` + + 'only its flat keys until it moved onto the binding, so the binding\'s value never applied; now it ' + + `is the only one read. Keep the value you mean in \`dataSource.${key}\` and delete this key.`, + }; + } + if (view !== undefined && key !== 'object') { + return { + kind: 'todo', + reason: `the binding names the saved view \`${view}\`, which the list did not read until it moved onto ` + + `the binding. Moved there, this \`${key}\` would combine with the view's own (a filter ANDs, a ` + + 'sort or a limit overrides it), which the list never did. Decide whether the list should apply the ' + + `view, then write the \`${key}\` you mean on \`dataSource\` and delete this key.`, + }; + } + return { kind: 'move' }; + } + + if (type === 'element:number' && key === 'filter') { + if (!bound) return { kind: 'move' }; + if (isRuleObjectArray(binding!.filter) && isRuleObjectArray(value)) { + return { kind: 'append', rules: [...binding!.filter, ...value] }; + } + return { + kind: 'todo', + reason: 'this flat `filter` and `dataSource.filter` both carry rules, and the element AND-combined ' + + 'them; one of the two is not a rule array, so they cannot be appended mechanically. Write the rules ' + + 'of both into `dataSource.filter` (they AND) and delete this key.', + }; + } + + // `element:record_picker`, and `element:number`'s `object`. + if (bound) return { kind: 'drop' }; + if (view !== undefined && key !== 'object') { + return { + kind: 'todo', + reason: `this flat \`${key}\` sits beside \`dataSource.view: '${view}'\`, and the binding sets no \`${key}\` ` + + 'of its own. It was read only when that view supplied none, so whether it ever applied depends on ' + + `the view, which no conversion reads. If the view sets no \`${key}\`, move this one to ` + + `\`dataSource.${key}\`; if it does, delete it.`, + }; + } + return { kind: 'move' }; +} + +/** + * The element layer's flat data-binding keys move onto the node-level + * `dataSource` binding (protocol 18, #11509 — ruling A-narrow: in v18 an + * element binds data through `dataSource` only, so one node carries one door + * with one precedence). + * + * Ten keys on three elements: `element:record_picker` `object` / `filter` / + * `sort` / `limit`, `element:number` `object` / `filter`, and + * `element:repeater` `object` / `filter` / `sort` / `limit`. Each was the + * same query as a key of `ElementDataSourceSchema`, resolved per renderer by + * three different rules, and objectui#11880 (objectui `5bc55c0c5a1e`) moved + * all three renderers onto the binding alone — the order the ruling set, so + * this rewrite never moves a working list's query into a position its + * renderer does not read. + * + * Mechanical where the OLD rule decides the answer + * ({@link flatBindingDisposition}): + * + * - the binding lacks the key → the value MOVES there, unchanged; + * - the record picker (and `element:number`'s `object`) where the binding + * already sets the key → the flat key is DELETED: the binding always won, + * so the flat value never applied; + * - `element:number`'s `filter` beside the binding's → APPENDED to it: the + * renderer AND-combined the two, and a rule array is an AND; + * - the repeater where the binding already holds the SAME value → deleted. + * + * Left as stored, and reported as a TODO, where it does not: a record-picker + * key the binding lacks beside a `dataSource.view` (whether the view's own key + * displaced it depends on the view, which no conversion reads); a repeater key + * the binding sets to a different value, or beside a `view` (the repeater read + * neither before, so moving it would combine it with what the list never + * applied); and an `element:number` filter pair that is not two rule arrays. A + * key left as stored no longer reaches a query, and its tombstone refuses it + * at the next parse with the same prescription. + * + * Runs BEFORE `page-component-filter-record-to-rule-array` (order 35.5, below + * its 36): a flat `filter` in the retired record form moves onto + * `dataSource.filter`, where that entry converts it like any other binding + * filter — so neither entry carries an element-layer `properties.filter` arm. + * + * Retired from the load path: an author writing a flat key is refused at the + * parse with the prescription; stored rows, artifacts and + * `os migrate meta --from 17` replay it. Idempotent by construction — a node + * with no flat key left is returned by reference. + */ +const elementFlatDataBindingToDataSource: MetadataConversion = { + id: 'element-flat-data-binding-to-data-source', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + surface: + 'page.component.element:record_picker.object / page.component.element:record_picker.filter / ' + + 'page.component.element:record_picker.sort / page.component.element:record_picker.limit / ' + + 'page.component.element:number.object / page.component.element:number.filter / ' + + 'page.component.element:repeater.object / page.component.element:repeater.filter / ' + + 'page.component.element:repeater.sort / page.component.element:repeater.limit', + summary: + "the element layer's flat data-binding keys removed — 'object' / 'filter' / 'sort' / 'limit' on " + + "element:record_picker and element:repeater, 'object' / 'filter' on element:number — each the " + + "same query as a key of the node-level 'dataSource' binding, the one door the element reads: a " + + 'key the binding lacks moves there unchanged, one the binding already set is deleted where the ' + + "binding always won (and element:number's filter is appended to the binding's, since the two " + + "always AND-combined); a key whose effect depended on a saved 'dataSource.view', or that " + + "disagrees with a binding the repeater never read, is left as stored and reported as a TODO", + apply(stack, emit, context) { + return mapPageComponents(stack, (component, path) => { + const type = component.type; + if (typeof type !== 'string' || !Object.prototype.hasOwnProperty.call(ELEMENT_FLAT_BINDING_KEYS_BY_TYPE, type)) return component; + const properties = component.properties; + if (!isDict(properties)) return component; + const keys = ELEMENT_FLAT_BINDING_KEYS_BY_TYPE[type]!.filter((key) => key in properties); + if (keys.length === 0) return component; + + const original = isDict(component.dataSource) ? component.dataSource : undefined; + let binding = original; + let props: Dict = properties; + const block = describeBlock(component); + + for (const key of keys) { + const value = props[key]; + const disposition = value === undefined + ? ({ kind: 'drop' } as const) + : flatBindingDisposition(type, key, value, binding); + switch (disposition.kind) { + case 'todo': + context?.reportTodo?.({ + path: `${path}.properties.${key}`, + from: JSON.stringify(value), + reason: `On ${block}, ${disposition.reason} Left as stored, this key reaches no query.`, + }); + continue; + case 'drop': + props = stripKeys(props, [key], emit, `${path}.properties`); + continue; + case 'append': { + const { [key]: _appended, ...rest } = props; + props = rest; + binding = { ...binding, filter: disposition.rules }; + emit({ from: `properties.${key}`, to: `dataSource.${key} (rules appended; they AND)`, path: `${path}.dataSource.${key}` }); + continue; + } + case 'move': { + const { [key]: _moved, ...rest } = props; + props = rest; + binding = { ...binding, [key]: value }; + emit({ from: `properties.${key}`, to: `dataSource.${key}`, path: `${path}.dataSource.${key}` }); + continue; + } + } + } + + if (props === properties) return component; + return binding === original + ? { ...component, properties: props } + : { ...component, dataSource: binding, properties: props }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'deal_desk', + regions: [ + { + name: 'main', + components: [ + // No binding: all four flat keys move onto a new one. + { + type: 'element:record_picker', + id: 'p1', + properties: { + object: 'deal', + labelField: 'name', + filter: [{ field: 'stage', operator: 'equals', value: 'open' }], + sort: [{ field: 'amount', order: 'desc' }], + limit: 20, + }, + }, + // The binding already names the object: the flat one never + // applied (the binding won) and is deleted; the binding lacks a + // limit, so the flat one moves. + { + type: 'element:record_picker', + id: 'p2', + dataSource: { object: 'deal' }, + properties: { object: 'lead', limit: 10 }, + }, + // Beside a saved view the binding sets no filter of its own: + // whether the flat filter ever applied depends on the view, so + // it is left as stored — a TODO, no notice. + { + type: 'element:record_picker', + id: 'p3', + dataSource: { object: 'deal', view: 'hot_deals' }, + properties: { filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }] }, + }, + { + type: 'page:card', + properties: { + children: [ + // Nested, no binding: `object` and `filter` move. + { + type: 'element:number', + id: 'n1', + properties: { + object: 'deal', + aggregate: 'count', + filter: [{ field: 'stage', operator: 'equals', value: 'won' }], + }, + }, + // Both filters set: the element AND-combined them, so the + // flat rules are appended to the binding's. + { + type: 'element:number', + id: 'n2', + dataSource: { object: 'deal', filter: [{ field: 'stage', operator: 'equals', value: 'won' }] }, + properties: { + aggregate: 'sum', + field: 'amount', + filter: [{ field: 'amount', operator: 'greater_than', value: 0 }], + }, + }, + ], + }, + }, + // `object` on an element outside the family (the metadata + // owner of a viewer) is not this entry's key. + { + type: 'element:metadata_viewer', + properties: { type: 'state_machine', name: 'deal_stage', object: 'deal' }, + }, + ], + }, + ], + }, + // The named-slot shape: a repeater, which read its flat keys alone. + { + name: 'deal_detail', + kind: 'slotted', + regions: [], + slots: { + side: { + type: 'element:repeater', + id: 'r1', + properties: { + object: 'deal_note', + titleField: 'subject', + filter: [{ field: 'pinned', operator: 'equals', value: true }], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 5, + }, + }, + }, + }, + ], + }, + after: { + pages: [ + { + name: 'deal_desk', + regions: [ + { + name: 'main', + components: [ + { + type: 'element:record_picker', + id: 'p1', + properties: { labelField: 'name' }, + dataSource: { + object: 'deal', + filter: [{ field: 'stage', operator: 'equals', value: 'open' }], + sort: [{ field: 'amount', order: 'desc' }], + limit: 20, + }, + }, + { + type: 'element:record_picker', + id: 'p2', + dataSource: { object: 'deal', limit: 10 }, + properties: {}, + }, + { + type: 'element:record_picker', + id: 'p3', + dataSource: { object: 'deal', view: 'hot_deals' }, + properties: { filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }] }, + }, + { + type: 'page:card', + properties: { + children: [ + { + type: 'element:number', + id: 'n1', + properties: { aggregate: 'count' }, + dataSource: { + object: 'deal', + filter: [{ field: 'stage', operator: 'equals', value: 'won' }], + }, + }, + { + type: 'element:number', + id: 'n2', + dataSource: { + object: 'deal', + filter: [ + { field: 'stage', operator: 'equals', value: 'won' }, + { field: 'amount', operator: 'greater_than', value: 0 }, + ], + }, + properties: { aggregate: 'sum', field: 'amount' }, + }, + ], + }, + }, + { + type: 'element:metadata_viewer', + properties: { type: 'state_machine', name: 'deal_stage', object: 'deal' }, + }, + ], + }, + ], + }, + { + name: 'deal_detail', + kind: 'slotted', + regions: [], + slots: { + side: { + type: 'element:repeater', + id: 'r1', + properties: { titleField: 'subject' }, + dataSource: { + object: 'deal_note', + filter: [{ field: 'pinned', operator: 'equals', value: true }], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 5, + }, + }, + }, + }, + ], + }, + // One per flat key moved, deleted or appended: 4 on `p1`, 2 on `p2`, 0 on + // `p3` (its TODO), 2 on `n1`, 1 on `n2`, 4 on `r1`. + expectedNotices: 13, + }, +}; + /** * `element:form` — the whole element retired (protocol 18, #9249, ADR-0049 * enforce-or-remove at ELEMENT grain). @@ -7373,9 +7780,12 @@ const elementFormRemoved: MetadataConversion = { }, }, // Key overlap on a DIFFERENT type rides through untouched — - // `object` is also a live `element:record_picker` prop, and - // the strip dispatches on the component type, not the key name. - { type: 'element:record_picker', properties: { object: 'lead' } }, + // `object` is also a live `element:metadata_viewer` prop (its + // metadata owner), and the strip dispatches on the component + // type, not the key name. (The neighbor was + // `element:record_picker` until #11509 retired its flat + // `object`; that retirement's conversion would now touch it.) + { type: 'element:metadata_viewer', properties: { type: 'state_machine', name: 'lead_status', object: 'lead' } }, // Nested one container down (#6775) — the walk descends. { type: 'page:card', @@ -7415,7 +7825,7 @@ const elementFormRemoved: MetadataConversion = { name: 'main', components: [ { type: 'element:form', properties: {} }, - { type: 'element:record_picker', properties: { object: 'lead' } }, + { type: 'element:metadata_viewer', properties: { type: 'state_machine', name: 'lead_status', object: 'lead' } }, { type: 'page:card', properties: { @@ -7442,7 +7852,7 @@ const elementFormRemoved: MetadataConversion = { ], }, // One per stripped key: 5 on the top-level node, 2 on the nested one, - // 3 on the slotted one. The `element:record_picker` neighbor is untouched. + // 3 on the slotted one. The `element:metadata_viewer` neighbor is untouched. expectedNotices: 10, }, }; @@ -9233,6 +9643,267 @@ const pageComponentResponsiveRemoved: MetadataConversion = { }, }; +/** + * A `filter` value the grid's lowering reads as NOTHING — absent, `null`, an + * empty rule array or an empty record — which is exactly when `object-grid` + * read `defaultFilters` instead. + */ +function gridFilterIsEmpty(value: unknown): boolean { + if (value === undefined || value === null) return true; + if (Array.isArray(value)) return value.length === 0; + return isDict(value) && Object.keys(value).length === 0; +} + +/** A `filter` value with rules (or record keys) in it — the grid read it, and never `defaultFilters`. */ +function gridFilterHasContent(value: unknown): boolean { + if (Array.isArray(value)) return value.length > 0; + return isDict(value) && Object.keys(value).length > 0; +} + +/** + * `object-grid`'s legacy base-filter fallback leaves the contract (protocol 18, + * #11509, ruling A-narrow, sub-question 1: "retires with a conversion in the + * shape `defaultSort`'s took"). + * + * `defaultFilters` was the second spelling of `filter`: the same rules, read by + * the grid only when `filter` lowered to nothing. #19514 narrowed it to the + * rule array and said, in as many words, that refusing it outright needed its + * own ruling; #11509 is that ruling. Its narrowing's D3 entry + * (`object-grid-default-filters-rule-array`, unreleased in any major) is + * absorbed into this retirement's, and so is the narrowing's D2 arm: the + * `properties.defaultFilters` reach of `page-component-filter-record-to-rule-array` + * is gone, because this entry runs BEFORE that one (order 35.5, below its 36) + * and leaves no `defaultFilters` for it to see — a record-form fallback it + * moves lands on `filter`, where that entry converts it like any other. + * + * The renderer's own precedence decides the rewrite, as it did for + * {@link objectGridDefaultSortRemoved}: + * + * - `filter` EMPTY (absent, `null`, `[]` or `{}`) — the fallback WAS the + * grid's filter, so it moves onto `filter`, unchanged; + * - `filter` WITH CONTENT — the fallback was never read, so it is deleted (a + * pure lossless delete), and so is an empty fallback beside anything; + * - `filter` a value no lowering reads (a bare string, a number, a boolean) — + * the grid fell back to `defaultFilters` there too, but moving the fallback + * would overwrite what the author wrote at `filter`, so the site is left as + * stored and reported as a TODO. + * + * Zero authored occurrences in this repository (the showcase pins it at zero), + * so the entry exists for stored `sys_metadata` rows and for authors outside + * the repo. Retired from the load path: an author writing the key is refused + * at the parse with the prescription. + */ +const objectGridDefaultFiltersRemoved: MetadataConversion = { + id: 'object-grid-default-filters-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + surface: 'page.component.object-grid.defaultFilters', + summary: + "object-grid component prop 'defaultFilters' removed (the legacy second spelling of 'filter', read " + + "only when 'filter' lowered to nothing; its rules move onto an empty 'filter', and the key is " + + "deleted beside a 'filter' that has content, which the grid always read instead)", + apply(stack, emit, context) { + return mapPageComponents(stack, (component, path) => { + if (component.type !== 'object-grid') return component; + const properties = component.properties; + if (!isDict(properties) || !('defaultFilters' in properties)) return component; + const fallback = properties.defaultFilters; + const filter = properties.filter; + if (gridFilterIsEmpty(fallback) || gridFilterHasContent(filter)) { + // Nothing to carry, or `filter` won: a lossless delete. + return { ...component, properties: stripKeys(properties, ['defaultFilters'], emit, `${path}.properties`) }; + } + if (gridFilterIsEmpty(filter)) { + // The fallback WAS the filter: it moves, unchanged. + const { defaultFilters, ...rest } = properties; + emit({ from: 'defaultFilters', to: 'filter', path: `${path}.properties.filter` }); + return { ...component, properties: { ...rest, filter: defaultFilters } }; + } + context?.reportTodo?.({ + path: `${path}.properties.defaultFilters`, + from: JSON.stringify(fallback), + reason: `On ${describeBlock(component)}, \`filter\` is ${JSON.stringify(filter)}, a value no filter ` + + 'lowering reads, so the grid fell back to `defaultFilters`; moving the fallback onto `filter` ' + + 'would overwrite what was written there. Write the rules the grid should apply at `filter` and ' + + 'delete `defaultFilters`. Left as stored, it no longer reaches the query.', + }); + return component; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'work_queue', + regions: [ + { + name: 'main', + components: [ + // No `filter`: the fallback WAS the filter, so it moves. + { + type: 'object-grid', + id: 'g1', + properties: { + objectName: 'crm_task', + defaultFilters: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], + }, + }, + // `filter` has rules: the fallback was never read — deleted. + { + type: 'object-grid', + id: 'g2', + properties: { + objectName: 'crm_task', + filter: [{ field: 'status', operator: 'equals', value: 'open' }], + defaultFilters: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], + }, + }, + // `filter: []` lowers to nothing: the grid read the fallback. + { + type: 'object-grid', + id: 'g3', + properties: { + objectName: 'crm_task', + filter: [], + defaultFilters: [{ field: 'priority', operator: 'equals', value: 'high' }], + }, + }, + // `defaultFilters` on a component that is not an object-grid — + // not this entry's key (scoped by component type, never by key + // name). + { + type: 'object-kanban', + id: 'k1', + properties: { + objectName: 'crm_task', + defaultFilters: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], + }, + }, + // Nested one container down: still a component. + { + type: 'page:card', + id: 'c1', + properties: { + children: [ + { + type: 'object-grid', + id: 'g4', + properties: { + objectName: 'crm_lead', + defaultFilters: [{ field: 'status', operator: 'equals', value: 'new' }], + }, + }, + ], + }, + }, + ], + }, + ], + }, + // The named-slot shape: `filter: {}` (an empty record) lowers to + // nothing too, so the fallback moves onto it. + { + name: 'work_queue_detail', + kind: 'slotted', + regions: [], + slots: { + details: { + type: 'object-grid', + id: 'g5', + properties: { + objectName: 'crm_task', + filter: {}, + defaultFilters: [{ field: 'status', operator: 'not_equals', value: 'done' }], + }, + }, + }, + }, + ], + }, + after: { + pages: [ + { + name: 'work_queue', + regions: [ + { + name: 'main', + components: [ + { + type: 'object-grid', + id: 'g1', + properties: { + objectName: 'crm_task', + filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], + }, + }, + { + type: 'object-grid', + id: 'g2', + properties: { + objectName: 'crm_task', + filter: [{ field: 'status', operator: 'equals', value: 'open' }], + }, + }, + { + type: 'object-grid', + id: 'g3', + properties: { + objectName: 'crm_task', + filter: [{ field: 'priority', operator: 'equals', value: 'high' }], + }, + }, + { + type: 'object-kanban', + id: 'k1', + properties: { + objectName: 'crm_task', + defaultFilters: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], + }, + }, + { + type: 'page:card', + id: 'c1', + properties: { + children: [ + { + type: 'object-grid', + id: 'g4', + properties: { + objectName: 'crm_lead', + filter: [{ field: 'status', operator: 'equals', value: 'new' }], + }, + }, + ], + }, + }, + ], + }, + ], + }, + { + name: 'work_queue_detail', + kind: 'slotted', + regions: [], + slots: { + details: { + type: 'object-grid', + id: 'g5', + properties: { + objectName: 'crm_task', + filter: [{ field: 'status', operator: 'not_equals', value: 'done' }], + }, + }, + }, + }, + ], + }, + // One per grid that carried the key: g1, g2, g3, g4, g5. The kanban + // neighbour is untouched. + expectedNotices: 5, + }, +}; + /** * `object-grid`'s legacy single-sort fallback leaves the contract (protocol 18, * #11805, ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, @@ -13424,16 +14095,13 @@ const RULE_ARRAY_FILTER_BLOCK_TYPES: ReadonlySet = new Set([ 'object-gantt', 'object-tree', 'object-timeline', - 'element:number', - 'element:record_picker', ]); - -/** - * The one component type whose `properties.defaultFilters` is a rule-array - * door of the same family (`object-grid-default-filters-rule-array`). Held - * against the schema by the same test. - */ -const RULE_ARRAY_DEFAULT_FILTERS_BLOCK_TYPES: ReadonlySet = new Set(['object-grid']); +// `element:number` and `element:record_picker` left this list, and the +// `object-grid` `properties.defaultFilters` arm left this entry, with #11509: +// those three flat keys retired in v18, and the two entries that remove them +// (`element-flat-data-binding-to-data-source`, `object-grid-default-filters-removed`) +// run before this one, so a record form they carry reaches this entry at the +// door it moved to — `dataSource.filter`, or the grid's own `filter`. /** One rule a legacy filter maps to — the rule array's authored element. */ interface MappedFilterRule { @@ -13763,7 +14431,7 @@ function describeBlock(component: Dict): string { * refusal lands differs by door, measured through `saveMetaItem` * (`protocol.stored-migration.test.ts`): `dataSource.filter` is a declared key * of the strict page-component schema, so the row's next save is refused - * there; `properties.filter` / `properties.defaultFilters` sit in the open + * there; `properties.filter` sits in the open * `properties` bag the runtime save does not refuse by component type, so there * the refusal is the component-props gate's (`@objectstack/lint`, advisory), * and a re-save goes through. @@ -13784,10 +14452,14 @@ function describeBlock(component: Dict): string { * * Every page component `mapPageComponents` visits (regions, slots, nested * containers): `dataSource.filter` on any component (`ElementDataSourceSchema`), - * `properties.filter` on {@link RULE_ARRAY_FILTER_BLOCK_TYPES}, and - * `properties.defaultFilters` on {@link RULE_ARRAY_DEFAULT_FILTERS_BLOCK_TYPES}. - * The `filter` of any other component type is not this entry's surface and is - * never touched. + * and `properties.filter` on {@link RULE_ARRAY_FILTER_BLOCK_TYPES}. The `filter` + * of any other component type is not this entry's surface and is never + * touched. Until #11509 the reach also took the `properties.filter` of + * `element:number` and `element:record_picker` and the `properties.defaultFilters` + * of `object-grid`; those keys retired in v18, and the entries that move them + * (`element-flat-data-binding-to-data-source`, `object-grid-default-filters-removed`) + * run first, so their values arrive here at `dataSource.filter` or at the + * grid's `filter`. * * Where a component's rows come from does not move the verdict. A block whose * rows ride on the node (`data: { provider: 'value' }`, a `data` array, @@ -13818,9 +14490,8 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { retiredFromLoadPath: true, retiredAfter: '17.4.0', surface: - 'page.component.dataSource.filter / page.component.properties.filter (the object-* blocks, ' - + 'element:number, element:record_picker) / page.component.properties.defaultFilters ' - + '(object-grid) — the record and single-level AST filter forms', + 'page.component.dataSource.filter / page.component.properties.filter (the object-* blocks) ' + + '— the record and single-level AST filter forms', summary: 'a record-form or single-level AST filter at a converged rule-array door becomes the ' + '`[{ field, operator, value }]` rule array wherever the mapping is lossless (flat keys → ' @@ -13871,9 +14542,6 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { if (RULE_ARRAY_FILTER_BLOCK_TYPES.has(type)) { props = rewrite(props, 'filter', `${path}.properties`); } - if (RULE_ARRAY_DEFAULT_FILTERS_BLOCK_TYPES.has(type)) { - props = rewrite(props, 'defaultFilters', `${path}.properties`); - } if (props !== properties) next = { ...next, properties: props }; } @@ -13890,8 +14558,8 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { { name: 'main', components: [ - // The binding and both grid doors at once: a flat record with - // two keys, an operator object, and an AST tuple array. + // The binding and the grid door at once: a flat record with + // two keys, and an operator object. { type: 'object-grid', dataSource: { @@ -13901,7 +14569,16 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { properties: { objectName: 'deal', filter: { amount: { $gt: 100, $lte: 5000 } }, - defaultFilters: [['owner_id', '=', '{current_user_id}']], + }, + }, + // An AST tuple array, at another block door. (It sat on the + // grid's `defaultFilters` until #11509 retired that key in v18 + // and took its door out of this entry's reach.) + { + type: 'object-calendar', + properties: { + objectName: 'deal', + filter: [['owner_id', '=', '{current_user_id}']], }, }, // A combinator is never flattened: left byte-identical. @@ -13931,18 +14608,20 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { }, }, // Nested inside a container: reached, and a legacy shorthand - // operator lands on its canonical spelling. + // operator lands on its canonical spelling — at the element's + // binding, the one door an element carries since #11509 + // retired its flat `filter`. { type: 'page:card', properties: { children: [ { type: 'element:number', - properties: { + dataSource: { object: 'deal', - aggregate: 'count', filter: { stage: { $nin: ['lost', 'void'] } }, }, + properties: { aggregate: 'count' }, }, ], }, @@ -13977,9 +14656,13 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { { field: 'amount', operator: 'greater_than', value: 100 }, { field: 'amount', operator: 'less_than_or_equal', value: 5000 }, ], - defaultFilters: [ - { field: 'owner_id', operator: 'equals', value: '{current_user_id}' }, - ], + }, + }, + { + type: 'object-calendar', + properties: { + objectName: 'deal', + filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], }, }, { @@ -14010,11 +14693,11 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { children: [ { type: 'element:number', - properties: { + dataSource: { object: 'deal', - aggregate: 'count', filter: [{ field: 'stage', operator: 'not_in', value: ['lost', 'void'] }], }, + properties: { aggregate: 'count' }, }, ], }, @@ -14025,8 +14708,9 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { }, ], }, - // One per converted door: the binding, the grid filter, the grid - // defaultFilters, the inline-row map's filter, the nested element:number. + // One per converted door: the grid's binding, the grid filter, the + // calendar's AST filter, the inline-row map's filter, the nested + // element:number's binding. expectedNotices: 5, }, }; @@ -15043,6 +15727,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: datasetCountMeasureEmptyFieldRemoved, order: 54 }, { conversion: declaredIndexUniqueScope, order: 61 }, { conversion: elementFilterRemoved, order: 4 }, + { conversion: elementFlatDataBindingToDataSource, order: 35.5 }, { conversion: elementFormRemoved, order: 5 }, { conversion: elementInputTargetVariableRemoved, order: 3 }, { conversion: elementTextVariantHeadingLevels, order: 59 }, @@ -15061,6 +15746,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: mappingLookupParamsRemoved, order: 11 }, { conversion: memoryPersistenceAutoSaveIntervalToMs, order: 27 }, { conversion: metricFiltersRemoved, order: 7 }, + { conversion: objectGridDefaultFiltersRemoved, order: 35.5 }, { conversion: objectGridDefaultSortRemoved, order: 14 }, { conversion: objectGridResizableColumnsRemoved, order: 57 }, { conversion: objectKanbanQuickAddRemoved, order: 15 }, diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__filter.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__filter.ts new file mode 100644 index 00000000000..6afc331ef76 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__filter.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `filter` on `element:number` was a flat second +// spelling of the node-level `dataSource.filter` binding (the element resolved `object` binding-first and AND-combined the two filters). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementNumberProps:filter'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__object.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__object.ts new file mode 100644 index 00000000000..126e11e6d77 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__object.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `object` on `element:number` was a flat second +// spelling of the node-level `dataSource.object` binding (the element resolved `object` binding-first and AND-combined the two filters). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementNumberProps:object'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__filter.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__filter.ts new file mode 100644 index 00000000000..4f6bddde60d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__filter.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `filter` on `element:record_picker` was a flat second +// spelling of the node-level `dataSource.filter` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRecordPickerProps:filter'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__limit.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__limit.ts new file mode 100644 index 00000000000..9afca13b318 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__limit.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `limit` on `element:record_picker` was a flat second +// spelling of the node-level `dataSource.limit` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRecordPickerProps:limit'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__object.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__object.ts new file mode 100644 index 00000000000..8659ff3ea0c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__object.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `object` on `element:record_picker` was a flat second +// spelling of the node-level `dataSource.object` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRecordPickerProps:object'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__sort.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__sort.ts new file mode 100644 index 00000000000..fc5b5ed73c0 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__sort.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `sort` on `element:record_picker` was a flat second +// spelling of the node-level `dataSource.sort` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRecordPickerProps:sort'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__filter.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__filter.ts new file mode 100644 index 00000000000..34c13911e5f --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__filter.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `filter` on `element:repeater` was a flat second +// spelling of the node-level `dataSource.filter` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRepeaterProps:filter'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__limit.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__limit.ts new file mode 100644 index 00000000000..fc2d07d545a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__limit.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `limit` on `element:repeater` was a flat second +// spelling of the node-level `dataSource.limit` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRepeaterProps:limit'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__object.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__object.ts new file mode 100644 index 00000000000..76ea8f58ad5 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__object.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `object` on `element:repeater` was a flat second +// spelling of the node-level `dataSource.object` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRepeaterProps:object'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__sort.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__sort.ts new file mode 100644 index 00000000000..5005a65425f --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__sort.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `sort` on `element:repeater` was a flat second +// spelling of the node-level `dataSource.sort` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRepeaterProps:sort'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectGridProps__defaultFilters.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectGridProps__defaultFilters.ts new file mode 100644 index 00000000000..89033d246bb --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectGridProps__defaultFilters.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow, sub-question 1) — `defaultFilters` on +// `object-grid` was the legacy second spelling of `filter`, read only when +// `filter` lowered to nothing; #19514 narrowed it to the rule array and left +// the removal to its own ruling, which this is. Retired in the shape +// `defaultSort`'s took (`ui/ObjectGridProps:defaultSort`, beside this entry): +// the D2 conversion `object-grid-default-filters-removed` moves the rules onto +// an empty `filter` and deletes the key beside a `filter` that has content. +export const entry = 'ui/ObjectGridProps:defaultFilters'; diff --git a/packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts b/packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts new file mode 100644 index 00000000000..134f7ead282 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #11509 (v18, ruling A-narrow) — the D3 entry of the +// `element-flat-data-binding-to-data-source` family: one entry for the ten +// keys, because they are one retirement (the element layer's second data door) +// and an upgrader moves them together. It absorbs the two protocol-18 +// narrowings of the element flat `filter` to the rule array +// (`element-number-filter-rule-array`, `element-record-picker-filter-rule-array`): +// the key they narrowed is gone in the same major, and the rule array they +// prescribed is the binding's own form. The conversion follows each element's +// old rule, so what it cannot follow is listed as a TODO and judged here. +export const entry: SemanticMigration = { + id: 'element-flat-data-binding-retired', + surface: + 'page.component properties of element:record_picker (object, filter, sort, limit), ' + + 'element:number (object, filter) and element:repeater (object, filter, sort, limit) — the flat ' + + 'data-binding keys beside the node-level dataSource', + replacement: + '`dataSource` on the component node — `{ object, view?, filter?, sort?, limit? }`, a sibling of ' + + '`type` rather than a key inside `properties` — the one binding each of the three elements reads. ' + + 'Each key moves unchanged: `properties: { object: \'deal\', limit: 20 }` becomes ' + + '`dataSource: { object: \'deal\', limit: 20 }`, and a filter keeps its rule-array form ' + + '`[{ field, operator, value }, ...]`. `element:number` reads `object` and `filter` only. With a ' + + '`view`, the view supplies the baseline, an explicit binding key overrides it, and the binding ' + + 'filter AND-combines with the view\'s.', + reason: + 'One node carried two doors onto one query, resolved by three different rules: the record picker ' + + 'let the binding win (its flat key was read only when the binding, or the saved view the binding ' + + 'named, supplied none), `element:number` resolved `object` binding-first and AND-combined the two ' + + 'filters, and the repeater read its flat keys alone and ignored the binding — while the ' + + 'component-props gate waived a missing flat `object` whenever `dataSource.object` was present, ' + + 'so a repeater bound only through `dataSource` passed validation and drew an empty list. The ' + + 'console moved all three elements onto the binding first, and in v18 the flat keys are ' + + 'refused. The D2 conversion `element-flat-data-binding-to-data-source` follows each element\'s ' + + 'old rule: a key the binding lacks moves there, a key the binding already set is deleted where ' + + 'the binding won, and `element:number`\'s filter is appended to the binding\'s. Three cases are ' + + 'left as stored and listed as TODOs, because only the author can decide them: a record-picker ' + + 'key beside a `dataSource.view` the binding sets no such key of its own for (the flat value ' + + 'applied only if the view supplied none, and no conversion reads the view); a repeater key the ' + + 'binding sets to a DIFFERENT value, or beside a `view` (the repeater never read either, so the ' + + 'list now applies something it did not before); and an `element:number` filter pair that is not ' + + 'two rule arrays. A repeater that carried a `dataSource` its list ignored now applies it — ' + + 'compare it with what the list showed. A flat filter in the retired record form moves to ' + + '`dataSource.filter` and is then converted there by `page-component-filter-record-to-rule-array` ' + + 'wherever the mapping is lossless; that entry lists the rest. Code that builds these props — a ' + + 'host, a generator, a designer — must write the binding, which no conversion reaches. And the ' + + 'gate now requires `dataSource.object` on all three elements: a node with none names no object ' + + 'and is reported.', + acceptanceCriteria: + 'No `element:record_picker`, `element:number` or `element:repeater` node carries `object`, ' + + '`filter`, `sort` or `limit` inside `properties`; the parse refuses each. Every such node has ' + + '`dataSource.object`, and `os validate` reports no missing-binding finding for it. In the running ' + + 'page each picker offers, each number aggregates and each repeater lists the records the author ' + + 'intends — checked first on every node the migration listed as a TODO, and on every repeater ' + + 'that already carried a `dataSource`.', + conversionIds: ['element-flat-data-binding-to-data-source'], +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts deleted file mode 100644 index cfa60fc34fd..00000000000 --- a/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'element-number-filter-rule-array', - surface: - "`element:number` component props — `filter` (the FORM: the MongoDB-style " - + '`FilterConditionSchema` record vs the `ViewFilterRule` array)', - replacement: - '`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` ' - + 'every other `filter` input in `ComponentPropsMap` already declares ' - + '(`record:related_list` and its Add-affordance picker). A record-form filter ' - + "`{ status: 'won' }` becomes `[{ field: 'status', operator: 'equals', value: 'won' }]`; " - + "an operator object `{ amount: { $gt: 100 } }` becomes " - + "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become " - + 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are ' - + 'accepted and normalized on parse', - reason: - 'One filter orthography platform-wide (the maintainer\'s 2026-08-25 ruling, option B: ' - + 'align the element to the `ViewFilterRule` array rather than keep it the record-shaped ' - + "exception). `ComponentPropsMap['element:number'].filter` " - + 'was the one `filter` input in the map declared as the MongoDB-style record ' - + '(`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so ' - + 'the filter a list view stores and renders was refused by the KPI element beside it, ' - + 'and the objectui parity gate had to carry a reasoned exemption to look away. The ' - + 'convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): ' - + 'the console adapter was changed so `ObjectStackAdapter.aggregate()` runs the same ' - + '`translateFilterArray` ' - + 'its `find()` path runs, and the objectui pin carrying it was re-measured before this ' - + 'entry moved — but that measurement named the wrong hop, and the runtime route\'s refusal ' - + 'of the array corrects it here. ' - + '`translateFilterArray` yields AST tuples, which are still a `FilterArray` — input-only ' - + 'sugar — so the real path is: authored array → `translateFilterArray` → lowered by ' - + '`parseFilterAST` (`@objectstack/spec/data`, the single sink the `FilterArray` docblock ' - + 'names, since the maintainer\'s 2026-08-04 ruling C declared the array input-only sugar ' - + 'with one lowering seam) in the adapter, BEFORE the wire → a `FilterCondition` on the ' - + 'body. The hop that decides it is the runtime route `POST /analytics/query`, which ' - + 'parses `where` with `AnalyticsQueryRequestSchema` — a `FilterCondition` and nothing ' - + 'else — so an un-lowered array is refused there before any service code runs. ' - + '`lowerAnalyticsWhere` (`service-analytics`), where that earlier measurement stopped, ' - + 'is the IN-PROCESS door (added when an array `where` was found silently dropped on the ' - + 'analytics path) for callers reaching `analyticsService.query` ' - + "directly, not the wire's; it too still refuses a RAW rule-object array by design. " - + 'The adapter-side lowering lands in the console\'s own repository. ' - + 'The ruled migration check ran with the change: the ' - + 'sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, ' - + 'packages/apps/, spec fixtures) found ONE `element:number` author writing a record-form ' - + '`filter` — a spec test fixture, rewritten to the array form in the same change — and ' - + 'zero outside the spec package; this entry carries the prescription for authors outside ' - + 'the repo.', - acceptanceCriteria: - "`ComponentPropsMap['element:number'].safeParse({ object, aggregate, filter: [{ field: " - + "'status', operator: 'equals', value: 'won' }] })` succeeds and the parsed `filter` is " - + "the same rule array; a record-form `filter: { status: 'won' }` is refused at the " - + '`filter` path (`invalid_type`, expected array). At runtime the element renders its ' - + 'aggregate on an analytics-capable deployment with the array filter applied — the same ' - + 'filter a list view renders. Downstream (objectui, after a released spec version reaches ' - + "the pin): the `element:number.filter:array` entry in `OFF_SPEC_ARM_EXEMPTIONS` " - + '(`registry-inputs-spec-parity.test.ts`) becomes deletable, which is what closes ' - + 'the console-side half of this convergence.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts deleted file mode 100644 index 6dc4604beb8..00000000000 --- a/packages/spec/src/migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'element-record-picker-filter-rule-array', - surface: - "`element:record_picker` component props — `filter` (the FORM: the MongoDB-style " - + '`FilterConditionSchema` record vs the `ViewFilterRule` array)', - replacement: - '`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` ' - + "the map's array-declared `filter` doors already carry (`record:related_list`, its nested " - + 'Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as ' - + '`z.unknown()`, a gap measured on its own). A record-form filter ' - + "`{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; " - + "an operator object `{ amount: { $gt: 100 } }` becomes " - + "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become " - + 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are ' - + 'accepted and normalized on parse. The binding-level `dataSource.filter` on the same node ' - + 'is a different key (`ElementDataSourceSchema`) and is not moved by this entry', - reason: - 'One filter orthography platform-wide (the maintainer\'s 2026-08-25 ruling, option B: ' - + 'every `filter` door takes the `ViewFilterRule` array rather than keeping record-shaped ' - + "exceptions). `ComponentPropsMap['element:record_picker'].filter` " - + 'was the LAST `filter` input in the map still declared as the MongoDB-style record ' - + '(`FilterConditionSchema`) after `element:number` converged: the three ' - + 'array-declared doors (`record:related_list`, its nested Add-affordance picker, ' - + '`element:number`) carried the `ViewFilterRule` array and the four `object-*` doors ' - + 'declare `z.unknown()`, so the filter a list view stores and renders was refused ' - + 'by the picker beside them, and a lone holdout is the state where the next author copies ' - + 'the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 ' - + 'Option-A ordering ruling: measure the consumer\'s read path before the contract moves): ' - + 'at the objectui pin `00d3f09c` the renderer hands ' - + '`filter` to `query.$filter` and calls `adapter.find()` ' - + '(`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` ' - + 'lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples ' - + '(`data-objectstack/src/index.ts`), the same door every list view\'s stored rule array ' - + 'already takes, and the engine lowers the tuples before the driver ' - + '(`engine-filter-array-lowering.test.ts`); nothing on that path parses `properties` ' - + 'against the installed spec. The pin and objectui `main` (`f7cf7e8`) are byte-identical on ' - + 'every read-path file. The ruled migration check ran with the change: the sweep of ' - + 'first-party corpora (examples/, skills/, content/docs/, docs/, packages/**, .changeset/) ' - + 'found ONE `element:record_picker` author writing a record-form `filter` — a spec test ' - + 'fixture, rewritten to the array form in the same change — and zero outside the spec ' - + 'package; this entry carries the prescription for authors outside the repo.', - acceptanceCriteria: - "`ComponentPropsMap['element:record_picker'].safeParse({ object, filter: [{ field: " - + "'status', operator: 'equals', value: 'active' }] })` succeeds and the parsed `filter` is " - + "the same rule array; a record-form `filter: { status: 'active' }` is refused at the " - + '`filter` path (`invalid_type`, expected array). At runtime the picker offers exactly the ' - + 'rows the array selects — the same filter a list view renders. Downstream (objectui, after ' - + "a released spec version reaches the pin): the registry's `inputs.filter` entry for " - + "`element:record_picker` (`type: 'object'`, `record-picker.tsx`) flips to the array arm and " - + 'the `record-picker-inputs-spec-parity.test.ts` pins that assert the record form follow — ' - + 'a console-side change filed in the objectui repository, blocked on that release.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-retired.ts b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-retired.ts new file mode 100644 index 00000000000..8eacd237f0b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-retired.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #11509 (v18, ruling A-narrow, sub-question 1) — the D3 entry of the +// `object-grid-default-filters-removed` family, in the shape the +// `object-grid-default-sort-retired` entry beside it took. It absorbs the +// protocol-18 narrowing of the same key (`object-grid-default-filters-rule-array`, +// never released in a major): #19514 narrowed `defaultFilters` to the rule array +// and left its removal to a ruling of its own, which this is. +export const entry: SemanticMigration = { + id: 'object-grid-default-filters-retired', + surface: + 'page.component.object-grid.defaultFilters — the legacy second spelling of the grid base filter', + replacement: + '`filter: [{ field, operator, value }, ...]` — the one base-filter key every read path honours; ' + + 'the same rules, unchanged.', + reason: + 'The grid read `defaultFilters` only when `filter` lowered to nothing, so one intent had two ' + + 'spellings on one block. The D2 conversion `object-grid-default-filters-removed` follows that ' + + 'precedence: where `filter` was empty (absent, null, `[]` or `{}`) the fallback WAS the grid\'s ' + + 'filter, so its rules move onto `filter`; where `filter` had rules the fallback was never read, ' + + 'so it is deleted. Both preserve what the grid showed, and the second is where the judgment ' + + 'sits: a grid that authored both keys has always listed the rows `filter` selects, while its ' + + 'author may believe `defaultFilters` applied. The conversion keeps the rows users have been ' + + 'seeing and discards the rules that were written; only the author can say which were meant. ' + + 'A `filter` that is neither empty nor rules (a bare string, a number) is left as stored and ' + + 'listed as a TODO: the grid fell back to `defaultFilters` there too, and moving it would ' + + 'overwrite what was written at `filter`. A fallback in the retired record form moves to `filter` ' + + 'and is then converted there by `page-component-filter-record-to-rule-array` wherever the mapping ' + + 'is lossless; that entry lists the rest. Code that builds object-grid props (a host, a ' + + 'generator) must also stop emitting the key, which no conversion reaches.', + acceptanceCriteria: + 'No `object-grid` component carries `defaultFilters`; the parse refuses it. Each grid\'s `filter` ' + + 'holds the rules the author intends, and the grid lists exactly the rows they select. For every ' + + 'grid that had authored both keys, the author has compared the discarded `defaultFilters` rules ' + + 'with the kept `filter` and confirmed the kept one.', + conversionIds: ['object-grid-default-filters-removed'], +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts deleted file mode 100644 index a4fa9b563fe..00000000000 --- a/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -// The key the one-filter-orthography convergence did not name. Its sibling -// entry element-data-source-and-object-block-filter-rule-array says so in as -// many words — 「object-grid.defaultFilters is a different key and is not named -// by the ruling this entry records」 — so this is the entry that names it. -export const entry: SemanticMigration = { - id: 'object-grid-default-filters-rule-array', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'the object-grid page block\'s defaultFilters property — the legacy base-filter fallback ' - + 'in ComponentPropsMap, which was z.unknown and therefore accepted a bare string, a ' - + 'number, a MongoDB-style record, an ObjectQL AST tuple array and a list of malformed ' - + 'rules alike', - replacement: - 'the same ViewFilterRule array form its sibling filter takes — ' - + '[{ field, operator, value }, ...]. A record-form fallback { status: "active" } becomes ' - + '[{ field: "status", operator: "equals", value: "active" }] and several record keys ' - + 'become several rules, which AND; an operator object { amount: { $gt: 100 } } lifts the ' - + 'operator into the rule, becoming ' - + '[{ field: "amount", operator: "greater_than", value: 100 }]; an AST tuple array ' - + '[["owner_id", "=", "{current_user_id}"]] becomes ' - + '[{ field: "owner_id", operator: "equals", value: "{current_user_id}" }], value ' - + 'placeholders and date macros unchanged. Legacy operator shorthands are accepted and ' - + 'normalized on parse. Better still, write the rules on filter and delete this key: it ' - + 'is read only when filter is absent, and its own description has prescribed filter all ' - + 'along', - reason: - 'The protocol half of the maintainer\'s ruling C-prime of 2026-09-20 on objectui\'s ' - + 'render-time filter converter — the protocol is the only refusal set, so a document it ' - + 'accepts never throws at render time — verbatim, untranslated: 「the differences are the ' - + 'protocol\'s to close」. This is the SAME value ' - + 'in the SAME role as filter — the key\'s own description says it is read only when ' - + 'filter is absent — and the consumer reads it through the SAME lowering sink, so every ' - + 'refusal that sink can give was reachable from a document the protocol had just ' - + 'accepted. filter converged on the rule array with the rest of its family; this key was ' - + 'not named by that ruling and kept the pre-convergence read-point shape, which left the ' - + 'block with one declared door and one undeclared door onto one seam. The parse receipt ' - + 'said nothing about what the grid would then do with the value, and in the objectui ' - + 'version this release pins that depended on the shape: ObjectGrid lowers defaultFilters ' - + 'through toFilterNode whenever filter lowers to nothing, so a record form and an AST ' - + 'tuple array were lowered and applied as declared; a bare string or a number was ' - + 'dropped without a word, so the grid sent no filter and listed its rows unfiltered; and ' - + 'a list of malformed rules was refused — on the wire with 400 INVALID_FILTER, or by the ' - + 'client before any request for the value shapes it judges itself. ' - + '⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key ' - + 'outright — the other arm the finding offered — removes an accepted shape and needs its ' - + 'own ruling; the deprecation already stated in the description is unchanged and still ' - + 'says to prefer filter. ' - + 'Metadata AT REST: the record form and the AST tuple array at this key are rewritten to ' - + 'the rule array by the same D2 conversion as its sibling filter, ' - + 'page-component-filter-record-to-rule-array, wherever the mapping is lossless — by ' - + 'os migrate meta --stored, and on every stored-row read until it runs. What it cannot ' - + 'map losslessly is left exactly as stored and keeps rendering as it does today — a ' - + 'combinator, a null value, an operator the rule vocabulary does not spell, or the bare ' - + 'string or number this key also took — and ' - + 'its door refuses such a value only as the component-props gate\'s advisory finding ' - + '(os validate, os build, os lint), since a re-save through the metadata API is not ' - + 'refused there: a record form with the message the filter door gives, a worked rewrite ' - + 'computed from the author\'s own keys and a pointer to this entry\'s conversion table, ' - + 'and a bare string or number or an AST tuple array with the schema\'s plain type ' - + 'refusal. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'Every object-grid node in your pages either omits defaultFilters or carries a ' - + 'ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is ' - + 'that array raises no issue at the key; a record form is refused AT defaultFilters with ' - + 'the conversion table and a worked rewrite built from the keys that were written, and ' - + 'an AST tuple array is refused one level in, at the first element. What to re-check ' - + 'depends on the shape that was there, as the objectui version this release pins treats ' - + 'it. A record form or an AST tuple array was lowered and applied, so for those the ' - + 'rewrite is a spelling change. A bare string or a number was dropped by that lowering, ' - + 'so the grid has been listing its rows unfiltered — decide which rows it is supposed to ' - + 'show before writing the rule that selects them. A list of malformed rules was refused ' - + 'when the grid loaded. Where both keys are authored, that grid reads defaultFilters only ' - + 'when filter lowers to nothing: beside a non-empty filter, deleting defaultFilters is ' - + 'the whole migration; beside filter: [] the grid reads defaultFilters, so move those ' - + 'rules onto filter rather than deleting them.', -}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 1ea24b09811..31102789a37 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5543,6 +5543,24 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'surfaces own their filtering: a view\'s `userFilters` quick-filter bar / the list ' + 'toolbar\'s filter builder.', }, + { + id: 'element-flat-data-binding-retired', + order: 90, + text: + 'It also retires the element layer\'s second data door (#11509, ruling A-narrow): the flat ' + + '`object` / `filter` / `sort` / `limit` keys of `element:record_picker` and ' + + '`element:repeater` and the flat `object` / `filter` of `element:number`, each the same query ' + + 'as a key of the node-level `dataSource` binding, resolved per element by three contradictory ' + + 'rules. The console moved all three elements onto the binding first, so from this major an ' + + 'element binds data through `dataSource` only; the keys are retiredKey tombstones, and the ' + + 'component-props gate requires `dataSource.object` on the three instead of waiving the flat ' + + 'key for them — which also closes the repeater that passed validation bound only through a ' + + 'binding it did not read. The D2 conversion `element-flat-data-binding-to-data-source` follows ' + + 'each element\'s old rule (move where the binding lacks the key, delete where the binding won, ' + + 'append `element:number`\'s filter, which AND-combined) and runs before the record-form filter ' + + 'conversion, which then converts a moved record form at `dataSource.filter`; what the old rule ' + + 'leaves undecided is a TODO, judged by the D3 entry.', + }, { id: 'element-form-retired', order: 8, @@ -5989,6 +6007,18 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'authoring it configured nothing. A kind enters the live set as a side effect of ' + 'registering an item of that kind.', }, + { + id: 'object-grid-default-filters-retired', + order: 90, + text: + 'It also retires `object-grid`\'s `defaultFilters` (#11509, ruling A-narrow, in the shape ' + + 'of the `defaultSort` retirement): the legacy second spelling of `filter`, read only when ' + + '`filter` lowered to nothing, which an earlier narrowing in this major had shaped as the rule ' + + 'array and this retirement absorbs. The mechanical conversion moves the rules onto an empty ' + + '`filter` and deletes the key beside a `filter` with content (the renderer never read it ' + + 'there); it runs before the record-form filter conversion, which then converts a moved record ' + + 'form at `filter`.', + }, { id: 'object-grid-default-sort-retired', order: 17, @@ -6104,9 +6134,8 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'spelling platform-wide, the rule array) its ' + 'mechanical half at rest (ruled 2026-09-12): the D2 conversion ' + '`page-component-filter-record-to-rule-array` rewrites a record-form or single-level ' - + 'AST `filter` at the converged rule-array doors — `dataSource.filter`, the ' - + '`object-*` / `element:number` / `element:record_picker` `filter` props and ' - + '`object-grid.defaultFilters` — to the rule array wherever the mapping is lossless, ' + + 'AST `filter` at the converged rule-array doors — `dataSource.filter` and the ' + + '`object-*` `filter` props — to the rule array wherever the mapping is lossless, ' + 'and leaves a filter carrying `$and` / `$or` / `$not` (or any part with no lossless ' + 'rule spelling) exactly as stored, because flattening a combinator changes which rows ' + 'a page selects. It is retired from the load path, so authors are still refused at ' @@ -11470,6 +11499,61 @@ const step18: MigrationStep = { + '`schemaValid: true` in `--json`, and the run closes with the schema-valid line ' + 'rather than the manual-changes warning', }, + // #11509 (v18, ruling A-narrow) — the D3 entry of the + // `element-flat-data-binding-to-data-source` family: one entry for the ten + // keys, because they are one retirement (the element layer's second data door) + // and an upgrader moves them together. It absorbs the two protocol-18 + // narrowings of the element flat `filter` to the rule array + // (`element-number-filter-rule-array`, `element-record-picker-filter-rule-array`): + // the key they narrowed is gone in the same major, and the rule array they + // prescribed is the binding's own form. The conversion follows each element's + // old rule, so what it cannot follow is listed as a TODO and judged here. + { + id: 'element-flat-data-binding-retired', + surface: + 'page.component properties of element:record_picker (object, filter, sort, limit), ' + + 'element:number (object, filter) and element:repeater (object, filter, sort, limit) — the flat ' + + 'data-binding keys beside the node-level dataSource', + replacement: + '`dataSource` on the component node — `{ object, view?, filter?, sort?, limit? }`, a sibling of ' + + '`type` rather than a key inside `properties` — the one binding each of the three elements reads. ' + + 'Each key moves unchanged: `properties: { object: \'deal\', limit: 20 }` becomes ' + + '`dataSource: { object: \'deal\', limit: 20 }`, and a filter keeps its rule-array form ' + + '`[{ field, operator, value }, ...]`. `element:number` reads `object` and `filter` only. With a ' + + '`view`, the view supplies the baseline, an explicit binding key overrides it, and the binding ' + + 'filter AND-combines with the view\'s.', + reason: + 'One node carried two doors onto one query, resolved by three different rules: the record picker ' + + 'let the binding win (its flat key was read only when the binding, or the saved view the binding ' + + 'named, supplied none), `element:number` resolved `object` binding-first and AND-combined the two ' + + 'filters, and the repeater read its flat keys alone and ignored the binding — while the ' + + 'component-props gate waived a missing flat `object` whenever `dataSource.object` was present, ' + + 'so a repeater bound only through `dataSource` passed validation and drew an empty list. The ' + + 'console moved all three elements onto the binding first, and in v18 the flat keys are ' + + 'refused. The D2 conversion `element-flat-data-binding-to-data-source` follows each element\'s ' + + 'old rule: a key the binding lacks moves there, a key the binding already set is deleted where ' + + 'the binding won, and `element:number`\'s filter is appended to the binding\'s. Three cases are ' + + 'left as stored and listed as TODOs, because only the author can decide them: a record-picker ' + + 'key beside a `dataSource.view` the binding sets no such key of its own for (the flat value ' + + 'applied only if the view supplied none, and no conversion reads the view); a repeater key the ' + + 'binding sets to a DIFFERENT value, or beside a `view` (the repeater never read either, so the ' + + 'list now applies something it did not before); and an `element:number` filter pair that is not ' + + 'two rule arrays. A repeater that carried a `dataSource` its list ignored now applies it — ' + + 'compare it with what the list showed. A flat filter in the retired record form moves to ' + + '`dataSource.filter` and is then converted there by `page-component-filter-record-to-rule-array` ' + + 'wherever the mapping is lossless; that entry lists the rest. Code that builds these props — a ' + + 'host, a generator, a designer — must write the binding, which no conversion reaches. And the ' + + 'gate now requires `dataSource.object` on all three elements: a node with none names no object ' + + 'and is reported.', + acceptanceCriteria: + 'No `element:record_picker`, `element:number` or `element:repeater` node carries `object`, ' + + '`filter`, `sort` or `limit` inside `properties`; the parse refuses each. Every such node has ' + + '`dataSource.object`, and `os validate` reports no missing-binding finding for it. In the running ' + + 'page each picker offers, each number aggregates and each repeater lists the records the author ' + + 'intends — checked first on every node the migration listed as a TODO, and on every repeater ' + + 'that already carried a `dataSource`.', + conversionIds: ['element-flat-data-binding-to-data-source'], + }, // #9198 (ADR-0049 enforce-or-remove) — the D3 entry of the // `element-input-target-variable-removed` family. Every retirement family // carries one D3 entry even when a lossless D2 conversion repairs its data @@ -11503,116 +11587,6 @@ const step18: MigrationStep = { + 'whatever consumes it on the page — returns the value entered. No component authors ' + '`targetVariable`; the parse refuses it by name.', }, - { - id: 'element-number-filter-rule-array', - surface: - "`element:number` component props — `filter` (the FORM: the MongoDB-style " - + '`FilterConditionSchema` record vs the `ViewFilterRule` array)', - replacement: - '`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` ' - + 'every other `filter` input in `ComponentPropsMap` already declares ' - + '(`record:related_list` and its Add-affordance picker). A record-form filter ' - + "`{ status: 'won' }` becomes `[{ field: 'status', operator: 'equals', value: 'won' }]`; " - + "an operator object `{ amount: { $gt: 100 } }` becomes " - + "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become " - + 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are ' - + 'accepted and normalized on parse', - reason: - 'One filter orthography platform-wide (the maintainer\'s 2026-08-25 ruling, option B: ' - + 'align the element to the `ViewFilterRule` array rather than keep it the record-shaped ' - + "exception). `ComponentPropsMap['element:number'].filter` " - + 'was the one `filter` input in the map declared as the MongoDB-style record ' - + '(`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so ' - + 'the filter a list view stores and renders was refused by the KPI element beside it, ' - + 'and the objectui parity gate had to carry a reasoned exemption to look away. The ' - + 'convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): ' - + 'the console adapter was changed so `ObjectStackAdapter.aggregate()` runs the same ' - + '`translateFilterArray` ' - + 'its `find()` path runs, and the objectui pin carrying it was re-measured before this ' - + 'entry moved — but that measurement named the wrong hop, and the runtime route\'s refusal ' - + 'of the array corrects it here. ' - + '`translateFilterArray` yields AST tuples, which are still a `FilterArray` — input-only ' - + 'sugar — so the real path is: authored array → `translateFilterArray` → lowered by ' - + '`parseFilterAST` (`@objectstack/spec/data`, the single sink the `FilterArray` docblock ' - + 'names, since the maintainer\'s 2026-08-04 ruling C declared the array input-only sugar ' - + 'with one lowering seam) in the adapter, BEFORE the wire → a `FilterCondition` on the ' - + 'body. The hop that decides it is the runtime route `POST /analytics/query`, which ' - + 'parses `where` with `AnalyticsQueryRequestSchema` — a `FilterCondition` and nothing ' - + 'else — so an un-lowered array is refused there before any service code runs. ' - + '`lowerAnalyticsWhere` (`service-analytics`), where that earlier measurement stopped, ' - + 'is the IN-PROCESS door (added when an array `where` was found silently dropped on the ' - + 'analytics path) for callers reaching `analyticsService.query` ' - + "directly, not the wire's; it too still refuses a RAW rule-object array by design. " - + 'The adapter-side lowering lands in the console\'s own repository. ' - + 'The ruled migration check ran with the change: the ' - + 'sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, ' - + 'packages/apps/, spec fixtures) found ONE `element:number` author writing a record-form ' - + '`filter` — a spec test fixture, rewritten to the array form in the same change — and ' - + 'zero outside the spec package; this entry carries the prescription for authors outside ' - + 'the repo.', - acceptanceCriteria: - "`ComponentPropsMap['element:number'].safeParse({ object, aggregate, filter: [{ field: " - + "'status', operator: 'equals', value: 'won' }] })` succeeds and the parsed `filter` is " - + "the same rule array; a record-form `filter: { status: 'won' }` is refused at the " - + '`filter` path (`invalid_type`, expected array). At runtime the element renders its ' - + 'aggregate on an analytics-capable deployment with the array filter applied — the same ' - + 'filter a list view renders. Downstream (objectui, after a released spec version reaches ' - + "the pin): the `element:number.filter:array` entry in `OFF_SPEC_ARM_EXEMPTIONS` " - + '(`registry-inputs-spec-parity.test.ts`) becomes deletable, which is what closes ' - + 'the console-side half of this convergence.', - }, - { - id: 'element-record-picker-filter-rule-array', - surface: - "`element:record_picker` component props — `filter` (the FORM: the MongoDB-style " - + '`FilterConditionSchema` record vs the `ViewFilterRule` array)', - replacement: - '`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` ' - + "the map's array-declared `filter` doors already carry (`record:related_list`, its nested " - + 'Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as ' - + '`z.unknown()`, a gap measured on its own). A record-form filter ' - + "`{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; " - + "an operator object `{ amount: { $gt: 100 } }` becomes " - + "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become " - + 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are ' - + 'accepted and normalized on parse. The binding-level `dataSource.filter` on the same node ' - + 'is a different key (`ElementDataSourceSchema`) and is not moved by this entry', - reason: - 'One filter orthography platform-wide (the maintainer\'s 2026-08-25 ruling, option B: ' - + 'every `filter` door takes the `ViewFilterRule` array rather than keeping record-shaped ' - + "exceptions). `ComponentPropsMap['element:record_picker'].filter` " - + 'was the LAST `filter` input in the map still declared as the MongoDB-style record ' - + '(`FilterConditionSchema`) after `element:number` converged: the three ' - + 'array-declared doors (`record:related_list`, its nested Add-affordance picker, ' - + '`element:number`) carried the `ViewFilterRule` array and the four `object-*` doors ' - + 'declare `z.unknown()`, so the filter a list view stores and renders was refused ' - + 'by the picker beside them, and a lone holdout is the state where the next author copies ' - + 'the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 ' - + 'Option-A ordering ruling: measure the consumer\'s read path before the contract moves): ' - + 'at the objectui pin `00d3f09c` the renderer hands ' - + '`filter` to `query.$filter` and calls `adapter.find()` ' - + '(`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` ' - + 'lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples ' - + '(`data-objectstack/src/index.ts`), the same door every list view\'s stored rule array ' - + 'already takes, and the engine lowers the tuples before the driver ' - + '(`engine-filter-array-lowering.test.ts`); nothing on that path parses `properties` ' - + 'against the installed spec. The pin and objectui `main` (`f7cf7e8`) are byte-identical on ' - + 'every read-path file. The ruled migration check ran with the change: the sweep of ' - + 'first-party corpora (examples/, skills/, content/docs/, docs/, packages/**, .changeset/) ' - + 'found ONE `element:record_picker` author writing a record-form `filter` — a spec test ' - + 'fixture, rewritten to the array form in the same change — and zero outside the spec ' - + 'package; this entry carries the prescription for authors outside the repo.', - acceptanceCriteria: - "`ComponentPropsMap['element:record_picker'].safeParse({ object, filter: [{ field: " - + "'status', operator: 'equals', value: 'active' }] })` succeeds and the parsed `filter` is " - + "the same rule array; a record-form `filter: { status: 'active' }` is refused at the " - + '`filter` path (`invalid_type`, expected array). At runtime the picker offers exactly the ' - + 'rows the array selects — the same filter a list view renders. Downstream (objectui, after ' - + "a released spec version reaches the pin): the registry's `inputs.filter` entry for " - + "`element:record_picker` (`type: 'object'`, `record-picker.tsx`) flips to the array arm and " - + 'the `record-picker-inputs-spec-parity.test.ts` pins that assert the record form follow — ' - + 'a console-side change filed in the objectui repository, blocked on that release.', - }, // #21015 — release 2 of objectui#7450's ruling B: `element:text` `variant` // refuses the pre-convergence spellings `heading` and `subheading` by name // (`enumWithRetiredValues`). The family's one D3 entry; the D2 half is @@ -16280,82 +16254,40 @@ const step18: MigrationStep = { + '`registry-inputs-spec-parity.test.ts` becomes deletable, which is what closes the ' + 'objectui finding that the two authorities disagreed.', }, - // The key the one-filter-orthography convergence did not name. Its sibling - // entry element-data-source-and-object-block-filter-rule-array says so in as - // many words — 「object-grid.defaultFilters is a different key and is not named - // by the ruling this entry records」 — so this is the entry that names it. + // #11509 (v18, ruling A-narrow, sub-question 1) — the D3 entry of the + // `object-grid-default-filters-removed` family, in the shape the + // `object-grid-default-sort-retired` entry beside it took. It absorbs the + // protocol-18 narrowing of the same key (`object-grid-default-filters-rule-array`, + // never released in a major): #19514 narrowed `defaultFilters` to the rule array + // and left its removal to a ruling of its own, which this is. { - id: 'object-grid-default-filters-rule-array', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. + id: 'object-grid-default-filters-retired', surface: - 'the object-grid page block\'s defaultFilters property — the legacy base-filter fallback ' - + 'in ComponentPropsMap, which was z.unknown and therefore accepted a bare string, a ' - + 'number, a MongoDB-style record, an ObjectQL AST tuple array and a list of malformed ' - + 'rules alike', + 'page.component.object-grid.defaultFilters — the legacy second spelling of the grid base filter', replacement: - 'the same ViewFilterRule array form its sibling filter takes — ' - + '[{ field, operator, value }, ...]. A record-form fallback { status: "active" } becomes ' - + '[{ field: "status", operator: "equals", value: "active" }] and several record keys ' - + 'become several rules, which AND; an operator object { amount: { $gt: 100 } } lifts the ' - + 'operator into the rule, becoming ' - + '[{ field: "amount", operator: "greater_than", value: 100 }]; an AST tuple array ' - + '[["owner_id", "=", "{current_user_id}"]] becomes ' - + '[{ field: "owner_id", operator: "equals", value: "{current_user_id}" }], value ' - + 'placeholders and date macros unchanged. Legacy operator shorthands are accepted and ' - + 'normalized on parse. Better still, write the rules on filter and delete this key: it ' - + 'is read only when filter is absent, and its own description has prescribed filter all ' - + 'along', - reason: - 'The protocol half of the maintainer\'s ruling C-prime of 2026-09-20 on objectui\'s ' - + 'render-time filter converter — the protocol is the only refusal set, so a document it ' - + 'accepts never throws at render time — verbatim, untranslated: 「the differences are the ' - + 'protocol\'s to close」. This is the SAME value ' - + 'in the SAME role as filter — the key\'s own description says it is read only when ' - + 'filter is absent — and the consumer reads it through the SAME lowering sink, so every ' - + 'refusal that sink can give was reachable from a document the protocol had just ' - + 'accepted. filter converged on the rule array with the rest of its family; this key was ' - + 'not named by that ruling and kept the pre-convergence read-point shape, which left the ' - + 'block with one declared door and one undeclared door onto one seam. The parse receipt ' - + 'said nothing about what the grid would then do with the value, and in the objectui ' - + 'version this release pins that depended on the shape: ObjectGrid lowers defaultFilters ' - + 'through toFilterNode whenever filter lowers to nothing, so a record form and an AST ' - + 'tuple array were lowered and applied as declared; a bare string or a number was ' - + 'dropped without a word, so the grid sent no filter and listed its rows unfiltered; and ' - + 'a list of malformed rules was refused — on the wire with 400 INVALID_FILTER, or by the ' - + 'client before any request for the value shapes it judges itself. ' - + '⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key ' - + 'outright — the other arm the finding offered — removes an accepted shape and needs its ' - + 'own ruling; the deprecation already stated in the description is unchanged and still ' - + 'says to prefer filter. ' - + 'Metadata AT REST: the record form and the AST tuple array at this key are rewritten to ' - + 'the rule array by the same D2 conversion as its sibling filter, ' - + 'page-component-filter-record-to-rule-array, wherever the mapping is lossless — by ' - + 'os migrate meta --stored, and on every stored-row read until it runs. What it cannot ' - + 'map losslessly is left exactly as stored and keeps rendering as it does today — a ' - + 'combinator, a null value, an operator the rule vocabulary does not spell, or the bare ' - + 'string or number this key also took — and ' - + 'its door refuses such a value only as the component-props gate\'s advisory finding ' - + '(os validate, os build, os lint), since a re-save through the metadata API is not ' - + 'refused there: a record form with the message the filter door gives, a worked rewrite ' - + 'computed from the author\'s own keys and a pointer to this entry\'s conversion table, ' - + 'and a bare string or number or an AST tuple array with the schema\'s plain type ' - + 'refusal. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'Every object-grid node in your pages either omits defaultFilters or carries a ' - + 'ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is ' - + 'that array raises no issue at the key; a record form is refused AT defaultFilters with ' - + 'the conversion table and a worked rewrite built from the keys that were written, and ' - + 'an AST tuple array is refused one level in, at the first element. What to re-check ' - + 'depends on the shape that was there, as the objectui version this release pins treats ' - + 'it. A record form or an AST tuple array was lowered and applied, so for those the ' - + 'rewrite is a spelling change. A bare string or a number was dropped by that lowering, ' - + 'so the grid has been listing its rows unfiltered — decide which rows it is supposed to ' - + 'show before writing the rule that selects them. A list of malformed rules was refused ' - + 'when the grid loaded. Where both keys are authored, that grid reads defaultFilters only ' - + 'when filter lowers to nothing: beside a non-empty filter, deleting defaultFilters is ' - + 'the whole migration; beside filter: [] the grid reads defaultFilters, so move those ' - + 'rules onto filter rather than deleting them.', + '`filter: [{ field, operator, value }, ...]` — the one base-filter key every read path honours; ' + + 'the same rules, unchanged.', + reason: + 'The grid read `defaultFilters` only when `filter` lowered to nothing, so one intent had two ' + + 'spellings on one block. The D2 conversion `object-grid-default-filters-removed` follows that ' + + 'precedence: where `filter` was empty (absent, null, `[]` or `{}`) the fallback WAS the grid\'s ' + + 'filter, so its rules move onto `filter`; where `filter` had rules the fallback was never read, ' + + 'so it is deleted. Both preserve what the grid showed, and the second is where the judgment ' + + 'sits: a grid that authored both keys has always listed the rows `filter` selects, while its ' + + 'author may believe `defaultFilters` applied. The conversion keeps the rows users have been ' + + 'seeing and discards the rules that were written; only the author can say which were meant. ' + + 'A `filter` that is neither empty nor rules (a bare string, a number) is left as stored and ' + + 'listed as a TODO: the grid fell back to `defaultFilters` there too, and moving it would ' + + 'overwrite what was written at `filter`. A fallback in the retired record form moves to `filter` ' + + 'and is then converted there by `page-component-filter-record-to-rule-array` wherever the mapping ' + + 'is lossless; that entry lists the rest. Code that builds object-grid props (a host, a ' + + 'generator) must also stop emitting the key, which no conversion reaches.', + acceptanceCriteria: + 'No `object-grid` component carries `defaultFilters`; the parse refuses it. Each grid\'s `filter` ' + + 'holds the rules the author intends, and the grid lists exactly the rows they select. For every ' + + 'grid that had authored both keys, the author has compared the discarded `defaultFilters` rules ' + + 'with the kept `filter` and confirmed the kept one.', + conversionIds: ['object-grid-default-filters-removed'], }, // #11805 (ADR-0049 enforce-or-remove) — the D3 entry of the // `object-grid-default-sort-removed` family (ruling B on #17152: one D3 entry @@ -27036,6 +26968,48 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the // component. 'ui/ElementFormProps:submitLabel', + // #11509 (v18, ruling A-narrow) — `filter` on `element:number` was a flat second + // spelling of the node-level `dataSource.filter` binding (the element resolved `object` binding-first and AND-combined the two filters). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementNumberProps:filter', + // #11509 (v18, ruling A-narrow) — `object` on `element:number` was a flat second + // spelling of the node-level `dataSource.object` binding (the element resolved `object` binding-first and AND-combined the two filters). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementNumberProps:object', + // #11509 (v18, ruling A-narrow) — `filter` on `element:record_picker` was a flat second + // spelling of the node-level `dataSource.filter` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRecordPickerProps:filter', + // #11509 (v18, ruling A-narrow) — `limit` on `element:record_picker` was a flat second + // spelling of the node-level `dataSource.limit` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRecordPickerProps:limit', + // #11509 (v18, ruling A-narrow) — `object` on `element:record_picker` was a flat second + // spelling of the node-level `dataSource.object` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRecordPickerProps:object', + // #11509 (v18, ruling A-narrow) — `sort` on `element:record_picker` was a flat second + // spelling of the node-level `dataSource.sort` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRecordPickerProps:sort', // #9198 — ADR-0049 enforce-or-remove. `targetVariable` on // `element:record_picker` was a declarative hint with zero readers: the picker // writes the selected record id through the reverse binding — the page @@ -27055,6 +27029,34 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // Sources are rewritten by the D2 conversion // `element-input-target-variable-removed`. 'ui/ElementRecordPickerProps:targetVariable', + // #11509 (v18, ruling A-narrow) — `filter` on `element:repeater` was a flat second + // spelling of the node-level `dataSource.filter` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRepeaterProps:filter', + // #11509 (v18, ruling A-narrow) — `limit` on `element:repeater` was a flat second + // spelling of the node-level `dataSource.limit` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRepeaterProps:limit', + // #11509 (v18, ruling A-narrow) — `object` on `element:repeater` was a flat second + // spelling of the node-level `dataSource.object` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRepeaterProps:object', + // #11509 (v18, ruling A-narrow) — `sort` on `element:repeater` was a flat second + // spelling of the node-level `dataSource.sort` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRepeaterProps:sort', // #9198 — ADR-0049 enforce-or-remove. `targetVariable` on `element:text_input` // was a declarative hint with zero readers: its own describe text said the // live binding "resolves via the variable whose `source` equals this component @@ -27153,6 +27155,14 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // stripped key does not record. So the prescription reaches consumers as that // semantic TODO plus this tombstone. 'ui/NavigationConfig:view', + // #11509 (v18, ruling A-narrow, sub-question 1) — `defaultFilters` on + // `object-grid` was the legacy second spelling of `filter`, read only when + // `filter` lowered to nothing; #19514 narrowed it to the rule array and left + // the removal to its own ruling, which this is. Retired in the shape + // `defaultSort`'s took (`ui/ObjectGridProps:defaultSort`, beside this entry): + // the D2 conversion `object-grid-default-filters-removed` moves the rules onto + // an empty `filter` and deletes the key beside a `filter` that has content. + 'ui/ObjectGridProps:defaultFilters', // #11805 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-25, // decision-inbox batch 4: 「#11805 退役 defaultSort,不需要major」; the producer // half of objectui#5861, under the objectui#4869 「接受所有」 direction). diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 0e8ed804c3d..8bf719d97d7 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -231,9 +231,15 @@ import { INLINE_GRID_SORT_FIELD_LIST } from '../data/inline-grid-sort-fields'; // — fell outside it and stayed undeclared. Commit 78f0be872 declared them on the same // #5611 rule (maintainer ruling 2026-08-08, direction A). The lesson for the // next divergence sweep: enumerate by the RENDERER'S read pattern, not by the -// key list a previous ruling happened to quote. Retiring the flat family -// wholesale in favour of `dataSource` is the standing alternative, deferred to -// v18 as #11509 — not rejected. +// key list a previous ruling happened to quote. Retiring the flat family in +// favour of `dataSource` was the standing alternative, deferred to v18 as +// #11509 and ruled there (A-narrow): in v18 the ELEMENT layer's flat binding +// keys retire — `element:record_picker` `object` / `filter` / `sort` / +// `limit`, `element:number` `object` / `filter`, `element:repeater` `object` / +// `filter` / `sort` / `limit` — together with `object-grid.defaultFilters`, +// and an element binds data through the node-level `dataSource` only. The +// `object-*` blocks keep their native keys (`dataSource` is an overlay one gate +// maps onto them), and the relationship-scoped blocks stay as declared. // // ── #5068: THE GATE IS WIRED — read the flip precisely ───────────────────── // @@ -2594,39 +2600,99 @@ export const ElementTextPropsSchema = lazySchema(() => strictObject({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); +/** + * The three elements whose flat data-binding keys retired in v18 (#11509, + * ruling A-narrow), and the keys each one carried. The single source for the + * tombstones below, the `element-flat-data-binding-to-data-source` conversion + * and the component-props gate's "no `dataSource.object`" refusal + * (`@objectstack/lint`), so the three cannot disagree about which element owes + * a binding. + */ +export const RETIRED_ELEMENT_FLAT_BINDING_KEYS = { + 'element:record_picker': ['object', 'filter', 'sort', 'limit'], + 'element:number': ['object', 'filter'], + 'element:repeater': ['object', 'filter', 'sort', 'limit'], +} as const satisfies Readonly>; + +/** An element whose query is the node-level `dataSource` binding only. */ +export type RetiredFlatBindingElementType = keyof typeof RETIRED_ELEMENT_FLAT_BINDING_KEYS; + +/** What the value of each retired key is — it moves unchanged. */ +const ELEMENT_FLAT_BINDING_VALUE = { + object: 'an object name', + filter: 'a ViewFilterRule array', + sort: 'a `[{ field, order }]` array', + limit: 'a positive integer', +} as const; + +/** + * One prescription per retired element-layer flat binding key. Generated from + * the element and the key rather than written ten times, so the ten strings + * differ only where the elements did: what the element read before, and what + * to do with a key the binding already sets — the record picker let the + * binding win, `element:number` AND-combined the two filters, and the repeater + * read the flat keys alone. + */ +const elementFlatBindingRetired = ( + type: RetiredFlatBindingElementType, + key: 'object' | 'filter' | 'sort' | 'limit', +): string => { + const why = type === 'element:repeater' + ? 'the list now reads its query from the node-level `dataSource` binding only' + : `it was a flat second spelling of the node-level \`dataSource.${key}\`, and the ` + + `${type === 'element:number' ? 'element' : 'picker'} now reads its query from \`dataSource\` only`; + const both = type === 'element:repeater' + ? 'where `dataSource` already sets it too, keep the value written here, which is the one the list honoured' + : type === 'element:number' && key === 'filter' + ? 'where `dataSource.filter` already has rules, append these to it, since the two always AND-combined' + : 'where `dataSource` already sets it, delete this one, since the binding\'s value always won'; + return `\`${type}\` property \`${key}\` was removed in @objectstack/spec 18 (ADR-0087 D2) — ` + + `${why}, so a value written here reaches no query. Use \`dataSource.${key}\` on the component ` + + 'node, a sibling of `type` rather than a key inside `properties`. Move the key; the value ' + + `(${ELEMENT_FLAT_BINDING_VALUE[key]}) is unchanged, and ${both}. ` + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; +}; + +/** + * The prescription for another spelling of a repeater query key (`objectName`, + * `where`, `top`, …): the key it meant is the binding's, one level up. + */ +const elementRepeaterBindingGuidance = ( + spelling: string, + key: 'object' | 'filter' | 'sort' | 'limit', +): string => + `\`${spelling}\` is not a prop of \`element:repeater\` — the list's query is the node-level ` + + `\`dataSource\` binding. Write it as \`dataSource.${key}\` on the component node, a sibling of ` + + '`type` rather than a key inside `properties`.'; + +/** + * `element:number` — one aggregate over one object's records. + * + * Its query is the node-level `dataSource` binding (`ElementDataSourceSchema`, + * page.zod.ts): `object`, an optional saved `view`, and `filter` rules, which + * AND with the view's. `sort` and `limit` mean nothing to an aggregate and are + * not read. A node with no `dataSource.object` names no object — the + * component-props gate (`@objectstack/lint`, `validate-component-props`) + * reports it, because the props schema below no longer can. + * + * REMOVED (#11509, v18, ruling A-narrow): the flat `object` / `filter` + * spellings of that binding. They were the same query read through a second + * door — `object` resolved `dataSource.object` first, while the flat `filter` + * AND-combined with the binding's (the OPPOSITE of the record picker's rule, a + * second dialect on one node) — and objectui reads `dataSource` only since + * objectui#11880. The protocol-18 conversion + * `element-flat-data-binding-to-data-source` moves both onto the binding. + */ export const ElementNumberPropsSchema = lazySchema(() => strictObject({ surface: 'this `element:number`', history: PROPS_HISTORY, guidanceSets: COMPONENT_LEVEL_GUIDANCE, }, { - object: z.string().describe('Source object'), + object: retiredKey(elementFlatBindingRetired('element:number', 'object')), field: z.string().optional().describe('Field to aggregate'), aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max']) .describe('Aggregation function'), - /** - * Filter rules narrowing the aggregate — the `ViewFilterRule` ARRAY form, - * `[{ field, operator, value }, ...]`, the one filter orthography every - * other `filter` input in this map already declares (`record:related_list` - * and its Add-affordance picker). Until the ui#6206 ruling (2026-08-25, - * Option B, verbatim 「同意」: one filter orthography platform-wide) this - * entry alone said `FilterConditionSchema`, the MongoDB-style record form — - * so the filter a list view stores and renders was refused by the KPI - * element beside it. Sequenced consumer-first (the 2026-08-25 Option-A - * ordering ruling): objectui#6828 made `ObjectStackAdapter.aggregate()` - * lower a rule array through the same `translateFilterArray` its `find()` - * path runs, and the pin carrying it (`d8ec8d6d`) was re-measured before - * this declaration moved — authored array → adapter lowering → filter AST → - * accepted at the analytics door (which still refuses a RAW rule-object - * array, by design). The record form is refused at `filter`; the migration - * prescription is the `element-number-filter-rule-array` semantic entry. - */ - filter: z.array(ViewFilterRuleSchema, { - error: ruleArrayFilterError({ - surface: 'this `element:number`', - migration: 'element-number-filter-rule-array', - }), - }).optional() - .describe('Filter rules narrowing the aggregate — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` input in this map shares. The MongoDB-style record form is refused — see migration `element-number-filter-rule-array`'), + filter: retiredKey(elementFlatBindingRetired('element:number', 'filter')), format: z.enum(['number', 'currency', 'percent']).optional().describe('Number display format'), prefix: z.string().optional().describe('Prefix text (e.g. "$")'), suffix: z.string().optional().describe('Suffix text (e.g. "%")'), @@ -2641,6 +2707,9 @@ export type ElementNumberProps = z.input; * `ViewFilterRuleParsed` exists). So `element:number` leaves the type-alias * convention pin's isomorphic family (the Iso818 line deleted with this * alias), taking the `ObjectGridPropsParsed` route its comment prescribes. + * That `filter` retired in v18 (#11509, a tombstone now); the alias stays, + * because deleting a published type name is an export removal of its own, + * which that retirement does not make. */ export type ElementNumberPropsParsed = z.infer; @@ -3006,13 +3075,29 @@ export const ElementFormPropsSchema = lazySchema(() => strictObject({ * for its own `sort` / `limit`, deliberately: they are the SAME contract read * through a second spelling, so a divergent shape here would be a third * dialect rather than a shorthand. + * + * REMOVED in v18 (#11509, ruling A-narrow): all four flat shorthands — + * `object`, `filter`, `sort`, `limit` — direction B, landed for the element + * layer. Declaring `sort` / `limit` closed the trapdoor; retiring the four + * closes the second door itself: one node, one binding, one precedence (the + * binding's own — its `view` supplies the baseline, an explicit binding key + * overrides it, and its `filter` AND-combines with the view's). objectui reads + * the picker's query from `dataSource` only since objectui#11880, so a flat + * key would reach no query. The protocol-18 conversion + * `element-flat-data-binding-to-data-source` moves each flat key onto the + * binding where the binding lacks it, deletes it where the binding already + * set it (the binding always won), and leaves it for the author, as a + * reported TODO, beside a `dataSource.view` — whether the view's own key + * displaced it depends on the view, which no conversion reads. A picker with + * no `dataSource.object` names no object, and the component-props gate + * (`@objectstack/lint`) reports it. */ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ surface: 'this `element:record_picker`', history: PROPS_HISTORY, guidanceSets: COMPONENT_LEVEL_GUIDANCE, }, { - object: z.string().describe('Object to pick records from'), + object: retiredKey(elementFlatBindingRetired('element:record_picker', 'object')), /** * Field rendered as each row's text. Defaults to `name`, which is what the * renderer falls back to (`props.labelField ?? 'name'`) — so this is @@ -3024,65 +3109,17 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ /** Control label rendered above the select. */ label: I18nLabelSchema.optional().describe('Control label rendered above the select'), /** - * Filter rules narrowing which records the picker offers — the - * `ViewFilterRule` ARRAY form, `[{ field, operator, value }, ...]`, the one - * filter orthography the map's array-declared `filter` doors share - * (`record:related_list`, its nested Add-affordance picker, and — since - * #12039 Key 2 — `element:number`; and, since #15449, the four `filter` - * doors of the six-entry `object-*` family — `object-grid`, - * `object-metric`, `object-kanban` and `object-calendar` — each of which - * declares this same `z.array(ViewFilterRuleSchema)`, while that family's - * remaining two entries, `object-form` and `object-master-detail-form`, - * declare no `filter` key at all). Until #14406 - * this entry alone still said `FilterConditionSchema`, the MongoDB-style - * record form: the last record-form `filter` in `ComponentPropsMap` after - * the ui#6206 ruling (2026-08-25, Option B, verbatim 「同意」: one filter - * orthography platform-wide). - * - * Sequenced measurement-first, as the `element:number` convergence had to - * be (the 2026-08-25 Option-A ordering ruling): the read path was measured - * at the objectui pin (`00d3f09c`) before this declaration moved. The - * renderer hands the value to `query.$filter` and calls `adapter.find()` - * (`components/src/renderers/basic/record-picker.tsx`); - * `ObjectStackAdapter.convertQueryParams` lowers an ARRAY `$filter` through - * `translateFilterArray` — `[{ field, operator, value }]` → filter AST - * tuples (`data-objectstack/src/index.ts`) — the same door every list view's - * stored rule array already takes, and the engine lowers the tuples before - * the driver (`objectql/src/engine-filter-array-lowering.test.ts`). Nothing - * on that path parses `properties` against the installed spec, so no - * refusal stands between an authored array and the query. The record form - * is refused at `filter`; the migration prescription is the - * `element-record-picker-filter-rule-array` semantic entry. - * - * The binding-level `dataSource.filter` this shorthand yields to - * (`ds.filter ?? props.filter`) is `ElementDataSourceSchema`'s key, not this - * entry's subject. + * REMOVED in v18 (#11509) with `object` above — the flat shorthands of + * `dataSource.filter` / `.sort` / `.limit`. The rule-array shape `filter` + * converged on (#14406, the `element-record-picker-filter-rule-array` entry + * this retirement absorbs) and the `sort` / `limit` declarations (commit + * 78f0be872) are history now: the binding carries the same shapes, and its + * `limit` falls back to the renderer's 50 when neither it nor its view caps + * the query. */ - filter: z.array(ViewFilterRuleSchema, { - error: ruleArrayFilterError({ - surface: 'this `element:record_picker`', - migration: 'element-record-picker-filter-rule-array', - }), - }).optional() - .describe('Filter rules narrowing which records the picker offers — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography the array-declared `filter` doors of this map share. The MongoDB-style record form is refused — see migration `element-record-picker-filter-rule-array`. The binding-level `dataSource.filter` wins outright when both are set'), - /** - * Row order (commit 78f0be872). The flat shorthand for `dataSource.sort`, and the same - * shape — `SortItemSchema[]`, the pairs the renderer forwards to the query as - * `$orderby`. `dataSource.sort` wins when both are written - * (`ds.sort ?? props.sort`). - */ - sort: z.array(SortItemSchema).optional() - .describe('Row order — synonym of the component-level `dataSource.sort`, which takes precedence when both are set'), - /** - * Row cap (commit 78f0be872). The flat shorthand for `dataSource.limit`, same shape. - * `dataSource.limit` wins when both are written, and with neither the - * renderer queries `$top: 50` (`ds.limit ?? props.limit ?? 50`) — that 50 is - * the renderer's fallback, not a schema default, so it is documented here - * rather than declared: declaring it would materialize a `limit: 50` on every - * parsed picker and turn an unset key into an authored one. - */ - limit: z.number().int().positive().optional() - .describe('Max records offered — synonym of the component-level `dataSource.limit`, which takes precedence when both are set (renderer default 50)'), + filter: retiredKey(elementFlatBindingRetired('element:record_picker', 'filter')), + sort: retiredKey(elementFlatBindingRetired('element:record_picker', 'sort')), + limit: retiredKey(elementFlatBindingRetired('element:record_picker', 'limit')), /** * REMOVED (#9198). ADR-0049 enforce-or-remove: a declarative hint with zero * readers — the live binding runs the other direction, resolved from the @@ -3121,8 +3158,8 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ '`element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 ' + '(ADR-0049) — the picker renders a plain single-select with no search input, so no ' + 'renderer ever read it and it narrowed nothing. Delete the key. To restrict which records ' - + 'the picker offers, use `filter` (or the component-level `dataSource.filter`), which the ' - + 'query path does apply. ' + + 'the picker offers, use the component-level `dataSource.filter`, which the query path ' + + 'does apply. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', ), /** @@ -3147,6 +3184,9 @@ export type ElementRecordPickerProps = z.input; @@ -4114,24 +4154,47 @@ export type ElementDefinitionListProps = z.input strictObject({ surface: 'this `element:repeater`', history: elementListHistory('element:repeater'), guidanceSets: COMPONENT_LEVEL_GUIDANCE, - // The same four query keys the element data-source binding declares, with - // its spellings for them (`ElementDataSourceSchema`, page.zod.ts), plus the - // `object-*` family's `objectName`. - aliases: { - objectName: 'object', filters: 'filter', where: 'filter', - orderBy: 'sort', sortBy: 'sort', top: 'limit', pageSize: 'limit', + // The binding's own spellings for its query keys (`ElementDataSourceSchema`, + // page.zod.ts), plus the `object-*` family's `objectName`. Until v18 these + // were aliases of the flat keys; those keys are tombstones now, so each + // spelling points at the binding instead (an alias may only name a key the + // shape accepts — `alias-integrity.test.ts`). + guidance: { + objectName: elementRepeaterBindingGuidance('objectName', 'object'), + filters: elementRepeaterBindingGuidance('filters', 'filter'), + where: elementRepeaterBindingGuidance('where', 'filter'), + orderBy: elementRepeaterBindingGuidance('orderBy', 'sort'), + sortBy: elementRepeaterBindingGuidance('sortBy', 'sort'), + top: elementRepeaterBindingGuidance('top', 'limit'), + pageSize: elementRepeaterBindingGuidance('pageSize', 'limit'), }, }, { - object: z.string() - .describe('Object whose records the list repeats over — required: without it the list never queries'), + object: retiredKey(elementFlatBindingRetired('element:repeater', 'object')), titleField: z.string().optional() .describe('Field shown first on each line, emphasized'), fields: z.array(z.union([ @@ -4148,12 +4211,9 @@ export const ElementRepeaterPropsSchema = lazySchema(() => strictObject({ }), ])).optional() .describe('Fields shown after the title on each line, in order — a bare field name, or `{ field }`'), - filter: z.array(ViewFilterRuleSchema).optional() - .describe('Filter rules narrowing the records — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` in this map shares'), - sort: z.array(SortItemSchema).optional() - .describe('Sort order — `[{ field, order }]`'), - limit: z.number().int().positive().optional() - .describe('Maximum records fetched and shown'), + filter: retiredKey(elementFlatBindingRetired('element:repeater', 'filter')), + sort: retiredKey(elementFlatBindingRetired('element:repeater', 'sort')), + limit: retiredKey(elementFlatBindingRetired('element:repeater', 'limit')), emptyText: z.string().optional() .describe('Copy shown when the query returns no records (renderer default: "No records"). A literal string — localize through the translation bundle entry for this component id'), divided: z.boolean().optional() @@ -4165,7 +4225,10 @@ export type ElementRepeaterProps = z.input; * ADR-0122: the parsed state differs from the authored state on exactly one * key — `filter` carries `ViewFilterRuleSchema`, whose `operator` is * normalized on parse (why `ViewFilterRuleParsed` exists), the route - * `element:number` and `element:record_picker` took. + * `element:number` and `element:record_picker` took. That `filter` retired in + * v18 (#11509, a tombstone now); the alias stays, because deleting a + * published type name is an export removal of its own, which that retirement + * does not make. */ export type ElementRepeaterPropsParsed = z.infer; @@ -4313,8 +4376,8 @@ const GridOperationsSchema = lazySchema(() => strictObject({ * `object-grid` (objectui `plugin-grid/src/ObjectGrid.tsx` @ `eb7f586b`). * Read points per key: `objectName` (throughout), `columns`/`fields` (:714-715), * `filter` (:739, lowered via `toFilterNode` to `$filter`), `defaultFilters` - * (:922 — the LEGACY fallback read only when `filter` is absent; it is read, - * so it stays declared — only the plural `filters` has zero read points), + * (:922 — the LEGACY fallback read only when `filter` is absent; RETIRED in + * v18, #11509, tombstoned below — the plural `filters` never had a read point), * `sort` (:741) / `defaultSort` (:943 — RETIRED #11805, tombstoned below; * objectui#5861 retires the read), `pagination`/`pageSize`/`showPagination` * (:567, :752, :2475-2480), `searchableFields`/`showSearch` (:959, :2484-2486), @@ -4529,44 +4592,31 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ }).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`'), /** - * [#19514] The legacy base-filter fallback — the SAME value in the SAME role - * as `filter` above, so it carries the same declaration. - * - * Its own description has said "read only when `filter` is absent" since the - * key entered this map (#7751), which is a statement that the two keys hold - * one kind of value: objectui's `ObjectGrid` reads this one through the same - * lowering sink it reads `filter` through, so every refusal that sink can - * give is reachable from a document that passed the protocol. While `filter` - * was narrowed to the rule array and this stayed `z.unknown()`, the block had - * a declared door and an undeclared one onto the same seam — a bare string, a - * number, a MongoDB-style record and an ObjectQL AST tuple array all parsed - * here, and the author's receipt said nothing about what the grid would do - * with them. At the objectui `.objectui-sha` pin `87af769e9a` - * (`ObjectGrid.tsx` → `toFilterNode`) that depends on the shape: the record - * form and the tuple array are lowered and APPLIED as declared; a bare string - * or a number is DROPPED, so the grid sends no filter and lists its rows - * unfiltered; and a list of malformed rules is REFUSED — on the wire with - * 400 `INVALID_FILTER`, or by the client before any request for the value - * shapes it judges itself. - * - * ⛔ **Narrowed, NOT retired.** Refusing the key outright is the other arm this - * could have taken and it is a REMOVAL of an accepted shape, which needs its - * own ruling. The deprecation stated in the description stands - * exactly where it stood — prefer `filter` — and is unchanged by this. + * REMOVED in v18 (#11509, ruling A-narrow, sub-question 1 — the shape + * `defaultSort`'s retirement took, below). The legacy base-filter fallback: + * the SAME value in the SAME role as `filter`, read only when `filter` + * lowered to nothing, so one intent had two spellings on one block. #19514 + * narrowed it to the rule array ("narrowed, NOT retired — refusing the key + * outright needs its own ruling"); #11509 is that ruling, and the narrowing's + * D3 entry (`object-grid-default-filters-rule-array`, never released in a + * major) is absorbed into this retirement's. * - * The `{ error }` map is `filter`'s, deliberately: an author who wrote the - * record form here needs the same conversion table, computed from their own - * keys, and a second hand-written sentence at this door is the drift - * `ruleArrayFilterError` exists to prevent. Its `surface` names which key was - * written, because the message's own subject is `filter`. + * The live mechanism is `filter`. The protocol-18 conversion + * `object-grid-default-filters-removed` carries the mechanical rewrite: when + * `filter` is empty (absent, `null`, `[]` or `{}`) the rules move into it; + * when `filter` has content the key is deleted, since the fallback was never + * read then. It runs before `page-component-filter-record-to-rule-array`, so + * a record-form value it moves is converted at `filter` like any other. */ - defaultFilters: z.array(ViewFilterRuleSchema, { - error: ruleArrayFilterError({ - surface: 'this `object-grid` (you wrote it on the `defaultFilters` fallback, which takes the same form)', - migration: 'object-grid-default-filters-rule-array', - }), - }).optional() - .describe('Legacy base-filter fallback, read only when `filter` is absent — the SAME ViewFilterRule array form `[{ field, operator, value }, ...]` as `filter`, lowered through the same sink. Prefer `filter`. The MongoDB-style record form, a bare string and an ObjectQL AST tuple array are refused — see migration `object-grid-default-filters-rule-array`'), + defaultFilters: retiredKey( + '`object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — ' + + 'it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to ' + + 'nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use ' + + '`filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, ' + + '`[{ field, operator, value }, ...]`) is unchanged. Where `filter` already has rules, delete this ' + + 'key: the grid never read it there. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + ), /** * Initial row order — the `SortItem` ARRAY form, `[{ field, order }, ...]`, * the one sort orthography every DECLARED `sort` door on this platform From 359e1ee94a964bfc3c8582d0339aebc9171c7948 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:26:36 +0000 Subject: [PATCH 02/16] wip(spec): regenerate the surfaces the element-binding retirement moves Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 82 +++---------------- packages/spec/api-surface/ui.json | 2 + packages/spec/authorable-surface/ui.json | 22 ++--- .../spec/dropped-refinements.baseline.json | 15 ---- packages/spec/export-origins/ui.json | 2 + 5 files changed, 27 insertions(+), 96 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index e56b72e0d3c..6c3eb738424 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -341,25 +341,15 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Source object | +| **object** | `never` | optional | [REMOVED] `element:number` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **field** | `string` | optional | Field to aggregate | | **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>` | ✅ | Aggregation function | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter rules narrowing the aggregate — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` input in this map shares. The MongoDB-style record form is refused — see migration `element-number-filter-rule-array` | +| **filter** | `never` | optional | [REMOVED] `element:number` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource.filter` already has rules, append these to it, since the two always AND-combined. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **format** | `Enum<'number' \| 'currency' \| 'percent'>` | optional | Number display format | | **prefix** | `string` | optional | Prefix text (e.g. "$") | | **suffix** | `string` | optional | Suffix text (e.g. "%") | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | -### Nested Shape: `ElementNumberProps.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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | - ### Nested Shape: `ElementNumberProps.aria` | Property | Type | Required | Description | @@ -377,40 +367,21 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object to pick records from | +| **object** | `never` | optional | [REMOVED] `element:record_picker` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **labelField** | `string` | optional | Field rendered as each row's text (default `name`) | | **valueField** | `string` | optional | Field whose value is written into the bound page variable (default `id`) | | **label** | `string \| Record` | optional | Control label rendered above the select | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter rules narrowing which records the picker offers — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography the array-declared `filter` doors of this map share. The MongoDB-style record form is refused — see migration `element-record-picker-filter-rule-array`. The binding-level `dataSource.filter` wins outright when both are set | -| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order — synonym of the component-level `dataSource.sort`, which takes precedence when both are set | -| **limit** | `integer` | optional | Max records offered — synonym of the component-level `dataSource.limit`, which takes precedence when both are set (renderer default 50) | +| **filter** | `never` | optional | [REMOVED] `element:record_picker` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **sort** | `never` | optional | [REMOVED] `element:record_picker` property `sort` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.sort`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **limit** | `never` | optional | [REMOVED] `element:record_picker` property `limit` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.limit`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **targetVariable** | `never` | optional | [REMOVED] `element:record_picker` property `targetVariable` was removed in @objectstack/spec 17 (ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the picked record id, declare it on the variable — `variables: [{ name: '', type: 'record_id', source: '' }]`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **placeholder** | `string \| Record` | optional | Placeholder text | | **emptyText** | `string \| Record` | optional | Text shown when the query returns no records (default "No records") | | **displayField** | `never` | optional | [REMOVED] `element:record_picker` property `displayField` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — it was a required declaration no renderer ever read, while the renderer honoured `labelField` for the same thing and defaulted to `name`. Rename the key to `labelField`; the value (a field name) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **searchFields** | `never` | optional | [REMOVED] `element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 (ADR-0049) — the picker renders a plain single-select with no search input, so no renderer ever read it and it narrowed nothing. Delete the key. To restrict which records the picker offers, use `filter` (or the component-level `dataSource.filter`), which the query path does apply. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **searchFields** | `never` | optional | [REMOVED] `element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 (ADR-0049) — the picker renders a plain single-select with no search input, so no renderer ever read it and it narrowed nothing. Delete the key. To restrict which records the picker offers, use the component-level `dataSource.filter`, which the query path does apply. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **multiple** | `never` | optional | [REMOVED] `element:record_picker` property `multiple` was removed in @objectstack/spec 17.0.0 (ADR-0049) — the picker is a single-select `Select` and the bound page variable holds one record id, so `multiple: true` selected nothing extra and reported success. Delete the key; multi-record selection is not implemented on this element. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | -### Nested Shape: `ElementRecordPickerProps.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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | - -### Nested Shape: `ElementRecordPickerProps.sort[number]` - -Sort field and direction pair - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **field** | `string` | ✅ | Field name to sort by | -| **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction | - ### Nested Shape: `ElementRecordPickerProps.aria` | Property | Type | Required | Description | @@ -428,12 +399,12 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object whose records the list repeats over — required: without it the list never queries | +| **object** | `never` | optional | [REMOVED] `element:repeater` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **titleField** | `string` | optional | Field shown first on each line, emphasized | | **fields** | `(string \| { field: string })[]` | optional | Fields shown after the title on each line, in order — a bare field name, or `{ field }` | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter rules narrowing the records — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` in this map shares | -| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order — `[{ field, order }]` | -| **limit** | `integer` | optional | Maximum records fetched and shown | +| **filter** | `never` | optional | [REMOVED] `element:repeater` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **sort** | `never` | optional | [REMOVED] `element:repeater` property `sort` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **limit** | `never` | optional | [REMOVED] `element:repeater` property `limit` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **emptyText** | `string` | optional | Copy shown when the query returns no records (renderer default: "No records"). A literal string — localize through the translation bundle entry for this component id | | **divided** | `boolean` | optional | Draw a separator between lines (renderer default: true) | @@ -443,25 +414,6 @@ Sort field and direction pair | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name | -### Nested Shape: `ElementRepeaterProps.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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | - -### Nested Shape: `ElementRepeaterProps.sort[number]` - -Sort field and direction pair - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **field** | `string` | ✅ | Field name to sort by | -| **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction | - --- @@ -845,7 +797,7 @@ Sort field and direction pair | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view's `columns` declares. One spelling per list: an array mixing strings and column objects is refused | | **fields** | `string[]` | optional | Field-name fallback the grid reads when `columns` is absent — bare field names (`['name', 'amount']`); write column decoration such as `label` or `width` on `columns` | | **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** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Legacy base-filter fallback, read only when `filter` is absent — the SAME ViewFilterRule array form `[{ field, operator, value }, ...]` as `filter`, lowered through the same sink. Prefer `filter`. The MongoDB-style record form, a bare string and an ObjectQL AST tuple array are refused — see migration `object-grid-default-filters-rule-array` | +| **defaultFilters** | `never` | optional | [REMOVED] `object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use `filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, `[{ field, operator, value }, ...]`) is unchanged. Where `filter` already has rules, delete this key: the grid never read it there. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Initial row order — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] } & Record` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging. `pageSize` and every `pageSizeOptions` entry is a positive integer — the accept set the view arm's `PaginationConfigSchema` already rules; the bag stays open, so other keys pass through unvalidated | @@ -915,16 +867,6 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | -### Nested Shape: `ObjectGridProps.defaultFilters[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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | - ### Nested Shape: `ObjectGridProps.sort[number]` Sort field and direction pair diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 0b28904473a..9de1a5bce4b 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -353,6 +353,7 @@ "RECORD_CONTEXT_BLOCK_TAGS (const)", "RECORD_CONTEXT_TYPE_PREFIX (const)", "RESERVED_COMPONENT_TYPE_NAMESPACES (const)", + "RETIRED_ELEMENT_FLAT_BINDING_KEYS (const)", "RETIRED_PAGE_COMPONENT_TYPES (const)", "ReactBlockDef (interface)", "ReactInteractionProp (interface)", @@ -400,6 +401,7 @@ "ResolvedActionParam (interface)", "ResponsiveStyles (type)", "ResponsiveStylesSchema (const)", + "RetiredFlatBindingElementType (type)", "RowColorConfig (type)", "RowColorConfigSchema (const)", "RowHeight (type)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index a3aaf5ca157..446bbbbaa12 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -471,32 +471,32 @@ "ui/ElementNumberProps:aggregate", "ui/ElementNumberProps:aria", "ui/ElementNumberProps:field", - "ui/ElementNumberProps:filter", + "ui/ElementNumberProps:filter [RETIRED]", "ui/ElementNumberProps:format", - "ui/ElementNumberProps:object", + "ui/ElementNumberProps:object [RETIRED]", "ui/ElementNumberProps:prefix", "ui/ElementNumberProps:suffix", "ui/ElementRecordPickerProps:aria", "ui/ElementRecordPickerProps:displayField [RETIRED]", "ui/ElementRecordPickerProps:emptyText", - "ui/ElementRecordPickerProps:filter", + "ui/ElementRecordPickerProps:filter [RETIRED]", "ui/ElementRecordPickerProps:label", "ui/ElementRecordPickerProps:labelField", - "ui/ElementRecordPickerProps:limit", + "ui/ElementRecordPickerProps:limit [RETIRED]", "ui/ElementRecordPickerProps:multiple [RETIRED]", - "ui/ElementRecordPickerProps:object", + "ui/ElementRecordPickerProps:object [RETIRED]", "ui/ElementRecordPickerProps:placeholder", "ui/ElementRecordPickerProps:searchFields [RETIRED]", - "ui/ElementRecordPickerProps:sort", + "ui/ElementRecordPickerProps:sort [RETIRED]", "ui/ElementRecordPickerProps:targetVariable [RETIRED]", "ui/ElementRecordPickerProps:valueField", "ui/ElementRepeaterProps:divided", "ui/ElementRepeaterProps:emptyText", "ui/ElementRepeaterProps:fields", - "ui/ElementRepeaterProps:filter", - "ui/ElementRepeaterProps:limit", - "ui/ElementRepeaterProps:object", - "ui/ElementRepeaterProps:sort", + "ui/ElementRepeaterProps:filter [RETIRED]", + "ui/ElementRepeaterProps:limit [RETIRED]", + "ui/ElementRepeaterProps:object [RETIRED]", + "ui/ElementRepeaterProps:sort [RETIRED]", "ui/ElementRepeaterProps:titleField", "ui/ElementTextInputProps:aria", "ui/ElementTextInputProps:defaultValue", @@ -863,7 +863,7 @@ "ui/ObjectGridProps:columns", "ui/ObjectGridProps:conditionalFormatting", "ui/ObjectGridProps:data", - "ui/ObjectGridProps:defaultFilters", + "ui/ObjectGridProps:defaultFilters [RETIRED]", "ui/ObjectGridProps:defaultSort [RETIRED]", "ui/ObjectGridProps:description", "ui/ObjectGridProps:editable", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index a2da918e93c..b7924a1675d 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -1277,21 +1277,6 @@ "filter.element" ] }, - "ui/ElementNumberProps": { - "sites": [ - "filter.element" - ] - }, - "ui/ElementRecordPickerProps": { - "sites": [ - "filter.element" - ] - }, - "ui/ElementRepeaterProps": { - "sites": [ - "filter.element" - ] - }, "ui/FormSection": { "sites": [ "in" diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 26e94e5a860..4006e862ac9 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -347,6 +347,7 @@ "RECORD_CONTEXT_BLOCK_TAGS": "src/ui/react-blocks.ts#RECORD_CONTEXT_BLOCK_TAGS (const)", "RECORD_CONTEXT_TYPE_PREFIX": "src/ui/react-blocks.ts#RECORD_CONTEXT_TYPE_PREFIX (const)", "RESERVED_COMPONENT_TYPE_NAMESPACES": "src/ui/component-type-vocabulary.ts#RESERVED_COMPONENT_TYPE_NAMESPACES (const)", + "RETIRED_ELEMENT_FLAT_BINDING_KEYS": "src/ui/component.zod.ts#RETIRED_ELEMENT_FLAT_BINDING_KEYS (const)", "RETIRED_PAGE_COMPONENT_TYPES": "src/ui/page.zod.ts#RETIRED_PAGE_COMPONENT_TYPES (const)", "ReactBlockDef": "src/ui/react-blocks.ts#ReactBlockDef (interface)", "ReactInteractionProp": "src/ui/react-blocks.ts#ReactInteractionProp (interface)", @@ -385,6 +386,7 @@ "ResolvedActionParam": "src/ui/action-params.zod.ts#ResolvedActionParam (interface)", "ResponsiveStyles": "src/ui/responsive.zod.ts#ResponsiveStyles (type)", "ResponsiveStylesSchema": "src/ui/responsive.zod.ts#ResponsiveStylesSchema (const)", + "RetiredFlatBindingElementType": "src/ui/component.zod.ts#RetiredFlatBindingElementType (type)", "RowColorConfig": "src/ui/view.zod.ts#RowColorConfig (type)", "RowColorConfigSchema": "src/ui/view.zod.ts#RowColorConfigSchema (const)", "RowHeight": "src/ui/view.zod.ts#RowHeight (type)", From 749cbc900174907d75263238f2e1fdd4f6bf6dd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:29:56 +0000 Subject: [PATCH 03/16] wip(lint)!: the component-props gate requires dataSource.object on the three data-source-bound elements Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../lint/src/validate-component-props.test.ts | 230 ++++++++++-------- packages/lint/src/validate-component-props.ts | 80 +++--- .../lint/src/validate-filter-tokens.test.ts | 4 +- .../src/validate-page-field-bindings.test.ts | 3 +- .../src/validate-print-page-blocks.test.ts | 2 +- packages/spec/scripts/check-yaml-examples.ts | 93 +++++-- packages/spec/src/conversions/registry.ts | 10 +- packages/spec/src/ui/component.zod.ts | 16 +- 8 files changed, 273 insertions(+), 165 deletions(-) diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 97a843a0586..3315e8caf44 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -12,10 +12,12 @@ // something. import { describe, expect, it } from 'vitest'; import { normalizeStackInput } from '@objectstack/spec'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; import { validateComponentProps, COMPONENT_PROPS_UNKNOWN_KEY, COMPONENT_PROPS_INVALID, + DATA_SOURCE_BOUND_ELEMENT_TYPES, } from './validate-component-props.js'; import { runAuthoringRules, AUTHORING_RULES } from './authoring-rules.js'; @@ -289,85 +291,118 @@ describe('validateComponentProps — value verdicts', () => { }); /** - * `ElementDataSourceSchema` is the component-node binding that "overrides - * page-level object context", and objectui's element renderers read it FIRST - * (`ds.object ?? props.object`). A component that binds through it has not - * omitted the flat shorthand — so reporting the props schema's required - * `object` here would be a WRONG verdict, not a strict one. + * #11509 (v18, ruling A-narrow) — the waiver turned into a refusal. This + * rule used to WAIVE the props schema's required flat `object` whenever the + * node's `dataSource.object` was present, for every type alike; the flat + * binding keys of the three data-source-bound elements are retired now, and + * the requirement sits on the binding the renderers actually read. One of + * the three with no `dataSource.object` is a `component-props-invalid` + * finding AT that path — the rule's existing finding, not a new rule. */ - it('does not report the required `object` prop when `dataSource` supplies it', () => { - const withDataSource = validateComponentProps( - stackWith([ - { - type: 'element:record_picker', - id: 'picker', - dataSource: { object: 'project', limit: 50 }, - properties: { labelField: 'name' }, - }, - ]), - ); - expect(withDataSource).toEqual([]); + describe('the data-source-bound elements owe `dataSource.object` (#11509)', () => { + const BOUND = ['element:record_picker', 'element:number', 'element:repeater'] as const; + /** The props each element needs to parse clean on its own, binding aside. */ + const PROPS: Record<(typeof BOUND)[number], Record> = { + 'element:record_picker': { labelField: 'name' }, + 'element:number': { aggregate: 'count' }, + 'element:repeater': { titleField: 'subject' }, + }; + + it.each(BOUND)('%s with its object on the binding: no finding', (type) => { + const findings = validateComponentProps( + stackWith([{ type, dataSource: { object: 'deal' }, properties: PROPS[type] }]), + ); + expect(findings).toEqual([]); + }); - // …and still reports it when nothing supplies it (or the suppression above - // would be indistinguishable from the rule never looking). - const without = validateComponentProps( - stackWith([{ type: 'element:record_picker', properties: { labelField: 'name' } }]), - ); - expect(invalid(without).map((f) => f.path)).toEqual([ - 'pages[0].regions[0].components[0].properties.object', - ]); - }); + it.each(BOUND)('%s with no binding: one finding, at `dataSource.object`', (type) => { + const findings = validateComponentProps(stackWith([{ type, properties: PROPS[type] }])); + expect(findings.map((f) => [f.rule, f.path, f.severity])).toEqual([ + [COMPONENT_PROPS_INVALID, 'pages[0].regions[0].components[0].dataSource.object', 'warning'], + ]); + expect(findings[0]!.message).toContain(`\`${type}\` reads its records from the node-level \`dataSource\` binding`); + expect(findings[0]!.hint).toContain('dataSource: {'); + }); - /** - * The waiver above covers a MISSING `object` only — no key, or `undefined`. - * A present value the row rejects is the author's own, and the binding - * supplies nothing in its place, so the row's verdict on it must reach the - * author exactly as it does with no binding at all. Each case is judged - * twice, beside the binding and without it, and the two answers must be the - * same finding: equality rather than wording, so the pin measures that the - * waiver lets the row's issue through unchanged. - */ - it.each([ - ['a number', 7], - ['null', null], - ])('reports a present-but-wrong `object` (%s) beside a `dataSource` binding, as it does without one', (_label, value) => { - const component = { type: 'element:number', properties: { object: value, aggregate: 'count' } }; - const withBinding = validateComponentProps( - stackWith([{ ...component, dataSource: { object: 'contact' } }]), - ); - const without = validateComponentProps(stackWith([component])); + it.each([ + ['a binding with no object', { view: 'hot_deals' }], + ['an empty object name', { object: '' }], + ['a non-string object', { object: 7 }], + ])('a binding that names no object (%s) is the same finding', (_label, dataSource) => { + const findings = validateComponentProps( + stackWith([{ type: 'element:number', dataSource, properties: { aggregate: 'count' } }]), + ); + expect(invalid(findings).map((f) => f.path)).toEqual([ + 'pages[0].regions[0].components[0].dataSource.object', + ]); + }); - // Control first: with nothing supplying `object`, the row reports it. - expect(without.map((f) => [f.rule, f.path])).toEqual([ - [COMPONENT_PROPS_INVALID, 'pages[0].regions[0].components[0].properties.object'], - ]); - // The binding does not silence it. - expect(withBinding.map((f) => [f.rule, f.path])).toEqual([ - [COMPONENT_PROPS_INVALID, 'pages[0].regions[0].components[0].properties.object'], - ]); - expect(withBinding).toEqual(without); - }); + it('a node with no `properties` bag at all is judged too — the binding is a key of the node', () => { + const findings = validateComponentProps(stackWith([{ type: 'element:repeater' }])); + expect(invalid(findings).map((f) => f.path)).toEqual([ + 'pages[0].regions[0].components[0].dataSource.object', + ]); + }); - it('still waives `object: undefined` beside a binding — an explicit undefined is a missing value', () => { - const findings = validateComponentProps( - stackWith([ - { - type: 'element:number', - dataSource: { object: 'contact' }, - properties: { object: undefined, aggregate: 'count' }, - }, - ]), - ); - expect(findings).toEqual([]); + it('control: an element outside the set owes no binding, and the old waiver is gone with the flat `object`', () => { + // `element:metadata_viewer` declares an `object` of its own (the metadata + // owner), unrelated to any binding — no binding is required of it. + const findings = validateComponentProps( + stackWith([ + { type: 'element:text', properties: { content: 'Hi' } }, + { type: 'element:metadata_viewer', properties: { type: 'flow', name: 'approve_deal' } }, + ]), + ); + expect(findings).toEqual([]); + }); - // …and the same bag with no binding is reported, so the silence above is - // the waiver and not the row accepting `undefined`. - const without = validateComponentProps( - stackWith([{ type: 'element:number', properties: { object: undefined, aggregate: 'count' } }]), - ); - expect(invalid(without).map((f) => f.path)).toEqual([ - 'pages[0].regions[0].components[0].properties.object', - ]); + /** + * THE REPEATER TRAP, closed from both sides. Before: a repeater bound only + * through `dataSource` passed this rule (the type-blind waiver) while its + * renderer read the flat keys alone and drew "No records"; the measured + * reading was 0 findings against the control's 1. After: that node is the + * clean shape (the renderer reads the binding, objectui#11880), and the + * node the old renderer DID read — a flat `object` — is refused twice: the + * tombstone at the key, with its prescription, and the missing binding. + */ + it('the repeater trap: bound only through `dataSource` is clean; aimed by a flat `object` is refused', () => { + const bound = validateComponentProps( + stackWith([{ type: 'element:repeater', dataSource: { object: 'deal_note', limit: 5 }, properties: { titleField: 'subject' } }]), + ); + expect(bound).toEqual([]); + + const flat = validateComponentProps( + stackWith([{ type: 'element:repeater', properties: { object: 'deal_note', titleField: 'subject' } }]), + ); + expect(invalid(flat).map((f) => f.path).sort()).toEqual([ + 'pages[0].regions[0].components[0].dataSource.object', + 'pages[0].regions[0].components[0].properties.object', + ]); + const tombstone = invalid(flat).find((f) => f.path.endsWith('.properties.object'))!; + expect(tombstone.message).toMatch(/`element:repeater` property `object` was removed in @objectstack\/spec 18.*`dataSource\.object`/s); + }); + + /** + * The gate keeps its own copy of the set, because the spec publishes none + * (an export would widen a retirement that only narrows). This pin derives + * the set from `ComponentPropsMap` — every row whose flat `object` is a + * tombstone pointing at `dataSource.object` — so a fourth element retired + * the same way, or one of the three un-retired, reds here. + */ + it('the set is the spec rows whose flat `object` is retired onto the binding', () => { + const derived = Object.entries(ComponentPropsMap) + .filter(([, schema]) => { + const parsed = (schema as { safeParse: (v: unknown) => { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }) + .safeParse({ object: 'probe' }); + return !parsed.success && parsed.error!.issues.some((i) => + i.path.length === 1 && i.path[0] === 'object' + && i.message.includes('was removed') && i.message.includes('`dataSource.object`')); + }) + .map(([type]) => type) + .sort(); + expect(derived).toEqual([...BOUND].sort()); + expect([...DATA_SOURCE_BOUND_ELEMENT_TYPES].sort()).toEqual(derived); + }); }); /** @@ -382,7 +417,8 @@ describe('validateComponentProps — value verdicts', () => { stackWith([ { type: 'element:record_picker', - properties: { object: 'project', displayField: 'name' }, + dataSource: { object: 'project' }, + properties: { displayField: 'name' }, }, ]), ); @@ -412,15 +448,15 @@ describe('validateComponentProps — value verdicts', () => { }); /** - * #6276 — the #5068 worklist entry #5775's key-by-key ruling left behind. - * The picker's renderer reads FOUR keys through one `ds. ?? props.` - * pattern; two of the flat spellings were declared and two were not, so this - * gate reported `sort`/`limit` as undeclared while the renderer honoured - * them. Both halves of the rule are asserted, because they fail differently: - * the key must stop being an unknown-key finding, and its VALUE must now be - * judged (before the declaration a wrong `limit` was stripped in silence). + * #6276 declared the picker's flat `sort` / `limit` beside `object` / + * `filter`, because the renderer read all four through one + * `ds. ?? props.` pattern. #11509 (v18) retired the four: the + * renderer reads the binding alone since objectui#11880. Each flat key is + * now a tombstone whose prescription reaches the author through this rule's + * value verdict — whatever value was written, a valid one included — and + * the missing binding is its own finding beside them. */ - it('reports nothing on the flat `sort` / `limit` shorthands the picker honours (#6276)', () => { + it('refuses the picker\'s four retired flat binding keys with their prescriptions (#11509)', () => { const findings = validateComponentProps( stackWith([ { @@ -429,28 +465,28 @@ describe('validateComponentProps — value verdicts', () => { properties: { object: 'showcase_project', labelField: 'name', + filter: [{ field: 'status', operator: 'equals', value: 'active' }], sort: [{ field: 'created_at', order: 'desc' }], limit: 20, }, }, ]), ); - expect(findings).toEqual([]); - }); - - it('now judges the VALUE of a flat `limit` instead of stripping it (#6276)', () => { - const findings = validateComponentProps( - stackWith([ - { - type: 'element:record_picker', - properties: { object: 'showcase_project', limit: 'twenty' }, - }, - ]), - ); - expect(invalid(findings).map((f) => f.path)).toEqual([ - 'pages[0].regions[0].components[0].properties.limit', + const base = 'pages[0].regions[0].components[0]'; + expect(invalid(findings).map((f) => f.path).sort()).toEqual([ + `${base}.dataSource.object`, + `${base}.properties.filter`, + `${base}.properties.limit`, + `${base}.properties.object`, + `${base}.properties.sort`, ]); - expect(invalid(findings)[0].message).toContain('expected number, received string'); + for (const key of ['object', 'filter', 'sort', 'limit']) { + const at = invalid(findings).find((f) => f.path === `${base}.properties.${key}`)!; + expect(at.message).toMatch( + new RegExp(`\`element:record_picker\` property \`${key}\` was removed in @objectstack/spec 18.*\`dataSource\\.${key}\``, 's'), + ); + } + expect(unknownKeys(findings)).toEqual([]); }); /** diff --git a/packages/lint/src/validate-component-props.ts b/packages/lint/src/validate-component-props.ts index dfcabca527a..5c1af337036 100644 --- a/packages/lint/src/validate-component-props.ts +++ b/packages/lint/src/validate-component-props.ts @@ -152,36 +152,37 @@ interface PropsSchema { const PROPS_SCHEMAS = ComponentPropsMap as unknown as Record; /** - * The one prop whose absence this rule does NOT report when the component - * carries a per-element `dataSource`. + * The elements whose query is the node-level `dataSource` binding ONLY — the + * three whose flat binding keys (`object` and `filter`, and on two of them + * `sort` / `limit`) retired in v18 (#11509, ruling A-narrow). The spec keeps + * its own list of them private (publishing it would widen a retirement that + * only narrows), so this copy is pinned instead: + * `validate-component-props.test.ts` derives the set from `ComponentPropsMap` + * — every row whose flat `object` is a tombstone pointing at + * `dataSource.object` — and holds this one equal to it. * - * `ElementDataSourceSchema` is declared on the component node as the binding - * that "overrides page-level object context", and objectui's element renderers - * read it FIRST (`const object = ds.object ?? props.object`) — the same - * precedence `page-walk.ts` encodes for every rule built on it. The props - * schemas declare `object` as required because it is the flat shorthand; a - * component that binds through the richer sibling has not omitted anything. - * Reporting it would be the rule judging one half of a two-key contract, which - * is a wrong verdict rather than a strict one — the showcase's - * `element:record_picker` (`dataSource: { object: 'showcase_project', limit: 50 }`) - * is the live specimen. + * Until that retirement this rule WAIVED the props schema's required flat + * `object` whenever `dataSource.object` was present — for every component + * type alike, on the reading that the element renderers resolve + * `dataSource.object` first. The waiver was type-blind, and on + * `element:repeater`, whose renderer read the flat keys alone, it passed a + * list bound only through `dataSource` that queried nothing and drew "No + * records". The retirement turns the waiver into a refusal on the binding the + * renderers actually read: one of these elements with no `dataSource.object` + * is a `component-props-invalid` finding at that path — the same rule id and + * tier as every other value verdict here, not a new gate. A flat `object` + * beside it is the tombstone's own finding (the parse below), with its + * prescription. */ -const DATASOURCE_SUPPLIED_PROP = 'object'; +export const DATA_SOURCE_BOUND_ELEMENT_TYPES: ReadonlySet = new Set([ + 'element:record_picker', + 'element:number', + 'element:repeater', +]); -/** - * Is this issue "the required `object` prop is missing", on a component whose - * `dataSource` supplies it? - */ -function suppliedByDataSource(issue: LintZodIssue, component: AnyRec): boolean { - if (issue.path.length !== 1 || issue.path[0] !== DATASOURCE_SUPPLIED_PROP) return false; - const dataSource = isRec(component.dataSource) ? component.dataSource : undefined; - if (strName(dataSource?.object) === undefined) return false; - // "Missing" is read off the component, never off the issue: the path alone - // also matches a PRESENT value the row rejects (`object: 7`, `object: null`), - // and the binding supplies nothing there — the author wrote that value and - // the row's own verdict on it stands. Only no key, or `undefined`, is waived. - const props = isRec(component.properties) ? component.properties : undefined; - return props?.[DATASOURCE_SUPPLIED_PROP] === undefined; +/** The object this component's node-level binding names, if it names one. */ +function boundObject(component: AnyRec): string | undefined { + return isRec(component.dataSource) ? strName(component.dataSource.object) : undefined; } /** @@ -257,9 +258,31 @@ export function validateComponentProps(stack: AnyRec): ComponentPropsFinding[] { : isRec(component.properties) ? component.properties : undefined; + const where = `page "${pageName}" · ${type}`; + + // ── The binding an element reads ───────────────────────────────── + // Judged before the props bag, and whatever the bag holds: the binding + // is a key of the NODE, so a malformed bag does not excuse a missing one. + if (DATA_SOURCE_BOUND_ELEMENT_TYPES.has(type) && boundObject(component) === undefined) { + findings.push({ + severity: 'warning', + rule: COMPONENT_PROPS_INVALID, + where, + path: `${path}.dataSource.object`, + message: + `dataSource.object: \`${type}\` reads its records from the node-level \`dataSource\` binding ` + + 'only, and this node names no object there, so it queries nothing and draws its empty state ' + + 'as if the object had no rows — nothing refuses it today, so the renderer receives the node ' + + 'as written', + hint: + `Name the object on the component node — \`dataSource: { object: '' }\`, a ` + + 'sibling of `type`, not a key inside `properties`, where a flat `object` is retired and read ' + + 'by nothing.', + }); + } + if (!props) continue; - const where = `page "${pageName}" · ${type}`; const base = `${path}.properties`; // ── Undeclared keys ────────────────────────────────────────────── @@ -292,7 +315,6 @@ export function validateComponentProps(stack: AnyRec): ComponentPropsFinding[] { const parsed = schema.safeParse(props); if (parsed.success) continue; for (const issue of parsed.error?.issues ?? []) { - if (suppliedByDataSource(issue, component)) continue; const at = issue.path.length ? `${base}.${issue.path.join('.')}` : base; // A strict UNION ARM reports the same fact one layer in — see // `unrecognizedKeysFromUnionArm`. Routed to the unknown-key rule id diff --git a/packages/lint/src/validate-filter-tokens.test.ts b/packages/lint/src/validate-filter-tokens.test.ts index 3a76bc230d8..75db4c11916 100644 --- a/packages/lint/src/validate-filter-tokens.test.ts +++ b/packages/lint/src/validate-filter-tokens.test.ts @@ -246,7 +246,7 @@ describe('validateFilterTokens — {record_id}', () => { { name: 'main', components: [ - { type: 'element:number', properties: { object: 'task', aggregate: 'count', filter } }, + { type: 'element:number', dataSource: { object: 'task', filter }, properties: { aggregate: 'count' } }, ], }, ], @@ -312,7 +312,7 @@ describe('validateFilterTokens — {record_id}', () => { it.each(['home', 'app', 'utility', 'list'])('refuses it on a %s page', (type) => { expectRefusal( validateFilterTokens({ pages: [recordPage(type, { assignee: '{record_id}' })] }), - 'pages[0].regions[0].components[0].properties.filter.assignee', + 'pages[0].regions[0].components[0].dataSource.filter.assignee', 'page "person_page"', ); }); diff --git a/packages/lint/src/validate-page-field-bindings.test.ts b/packages/lint/src/validate-page-field-bindings.test.ts index 6d157a4212c..e6f6f3d741c 100644 --- a/packages/lint/src/validate-page-field-bindings.test.ts +++ b/packages/lint/src/validate-page-field-bindings.test.ts @@ -82,7 +82,8 @@ describe('validatePageFieldBindings — highlights / KPI cards', () => { pages: [pageWith([ { type: 'element:number', - properties: { object: 'crm_account', field: 'amount', aggregate: 'sum' }, + dataSource: { object: 'crm_account' }, + properties: { field: 'amount', aggregate: 'sum' }, }, ])], }); diff --git a/packages/lint/src/validate-print-page-blocks.test.ts b/packages/lint/src/validate-print-page-blocks.test.ts index 6b1501654ac..08781eba2ea 100644 --- a/packages/lint/src/validate-print-page-blocks.test.ts +++ b/packages/lint/src/validate-print-page-blocks.test.ts @@ -117,7 +117,7 @@ describe('admits a print page built only from printable blocks', () => { { type: 'record:highlights', properties: { fields: ['name', 'invoice_date', 'due_date'] } }, { type: 'record:details', properties: { fields: ['customer', 'billing_address'] } }, { type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'description' }, { name: 'amount', type: 'currency' }], readonly: true } }, - { type: 'element:number', properties: { object: 'invoice_line', field: 'amount', aggregate: 'sum' } }, + { type: 'element:number', dataSource: { object: 'invoice_line' }, properties: { field: 'amount', aggregate: 'sum' } }, ], }, { name: 'footer', components: [{ type: 'element:divider' }, { type: 'element:text', properties: { content: 'Payment due within 30 days.' } }] }, diff --git a/packages/spec/scripts/check-yaml-examples.ts b/packages/spec/scripts/check-yaml-examples.ts index 8bf1f1f476d..05ddfae5b1c 100644 --- a/packages/spec/scripts/check-yaml-examples.ts +++ b/packages/spec/scripts/check-yaml-examples.ts @@ -590,23 +590,24 @@ function isPlainRecord(v: unknown): v is Record { const joinKey = (base: string, key: string) => (base ? `${base}.${key}` : key); /** - * The one prop whose absence is NOT reported when the component carries a - * per-element `dataSource` — ported deliberately from - * `validate-component-props.ts`, which carries the full reasoning: the props - * schemas declare `object` as the flat shorthand, objectui's element renderers - * read `dataSource.object` first (`const object = ds.object ?? props.object`), - * and a component that binds through the richer sibling has omitted nothing. - * `content/docs/ui/pages.mdx` and `deployment/validating-metadata.mdx` both - * teach that precedence, so this is a shape the tagged corpus can grow at any - * time — and reporting it would be a WRONG verdict, not a strict one. + * The elements whose query is the node-level `dataSource` binding ONLY — the + * twin of `validate-component-props.ts`'s `DATA_SOURCE_BOUND_ELEMENT_TYPES`, + * which carries the full reasoning, turned with it (#11509, ruling A-narrow): + * their flat binding keys retired in v18, so one of these nodes with no + * `dataSource.object` names no object and is reported here at that path, + * where this gate used to WAIVE the flat `object` for any type whose binding + * named one. The self-test holds this set equal to the rows of + * `ComponentPropsMap` whose flat `object` is a tombstone pointing at + * `dataSource.object`, so it cannot drift from the spec on its own. */ -const DATASOURCE_SUPPLIED_PROP = 'object'; - -function suppliedByDataSource( - issue: { path: ReadonlyArray }, - node: Record, -): boolean { - if (issue.path.length !== 1 || issue.path[0] !== DATASOURCE_SUPPLIED_PROP) return false; +const DATA_SOURCE_BOUND_ELEMENT_TYPES: ReadonlySet = new Set([ + 'element:record_picker', + 'element:number', + 'element:repeater', +]); + +/** Does this node's own `dataSource` binding name an object? */ +function namesBoundObject(node: Record): boolean { const dataSource = isPlainRecord(node.dataSource) ? node.dataSource : undefined; return typeof dataSource?.object === 'string' && dataSource.object.length > 0; } @@ -649,11 +650,16 @@ function collectComponentPropsFindings( stats.skipped += 1; } else { stats.dispatched += 1; + if (DATA_SOURCE_BOUND_ELEMENT_TYPES.has(type) && !namesBoundObject(value)) { + out.push( + ` · at ${joinKey(basePath, 'dataSource.object')}: \`${type}\` reads its records from the ` + + 'node-level `dataSource` binding only, and this node names no object there', + ); + } const parsed = schema.safeParse(props); if (!parsed.success) { const propsPath = joinKey(basePath, 'properties'); for (const issue of parsed.error.issues) { - if (suppliedByDataSource(issue, value)) continue; const at = issue.path.length === 0 ? propsPath : joinKey(propsPath, formatPath(issue.path)); out.push(` · at ${at}: ${issue.message}`); } @@ -1181,26 +1187,65 @@ function selfTest(): never { ); check('an unregistered SDUI block type is skipped, not refused', sdui.length === 0 && sduiStats.skipped === 1, sdui.join(' | ')); - // `dataSource.object` supplies the flat `object` prop — reporting it would - // be a wrong verdict, not a strict one (see DATASOURCE_SUPPLIED_PROP). + // The binding an element reads (#11509): the three data-source-bound + // elements owe `dataSource.object`, where this gate once WAIVED the flat + // `object` for any type whose binding named one. const boundStats = freshStats(); const bound = checkBlock( block( - 'type: element:record_picker\ndataSource:\n object: showcase_project\nproperties:\n limit: 50\n', + 'type: element:record_picker\ndataSource:\n object: showcase_project\n limit: 50\nproperties:\n labelField: name\n', 'PageComponentSchema', ), NAMESPACES, boundStats, ); - check('a component binding through `dataSource` is not refused for the flat `object` prop', + check('a data-source-bound element that names its object on the binding is not refused', bound.length === 0 && boundStats.dispatched === 1, bound.join(' | ')); const unbound = checkBlock( - block('type: element:record_picker\nproperties:\n limit: 50\n', 'PageComponentSchema'), + block('type: element:record_picker\nproperties:\n labelField: name\n', 'PageComponentSchema'), + NAMESPACES, + freshStats(), + ); + check('…while the same node with no binding is refused AT `dataSource.object`', + unbound.some((f) => f.includes('dataSource.object')), unbound.join(' | ')); + // The repeater trap, from the docs side: a repeater bound only through + // `dataSource` is the clean shape now, and a flat `object` is refused twice + // over — the tombstone at the key, and the missing binding. + const repeaterBound = checkBlock( + block('type: element:repeater\ndataSource:\n object: deal_note\nproperties:\n titleField: subject\n', 'PageComponentSchema'), + NAMESPACES, + freshStats(), + ); + check('a repeater bound only through `dataSource` is not refused', repeaterBound.length === 0, repeaterBound.join(' | ')); + const repeaterFlat = checkBlock( + block('type: element:repeater\nproperties:\n object: deal_note\n', 'PageComponentSchema'), + NAMESPACES, + freshStats(), + ); + check('a repeater aimed by a flat `object` is refused at the tombstone and at the missing binding', + repeaterFlat.some((f) => f.includes('properties.object') && f.includes('was removed')) + && repeaterFlat.some((f) => f.includes('dataSource.object')), + repeaterFlat.join(' | ')); + // Control: a type outside the set owes no binding. + const plain = checkBlock( + block('type: element:text\nproperties:\n content: Hi\n', 'PageComponentSchema'), NAMESPACES, freshStats(), ); - check('…while the same node with no binding at all still reports the missing prop', - unbound.some((f) => f.includes('properties.object')), unbound.join(' | ')); + check('an element outside the data-source-bound set owes no binding', plain.length === 0, plain.join(' | ')); + // The set is the spec's, not a recollection: every `ComponentPropsMap` row + // whose flat `object` is a tombstone pointing at `dataSource.object`. + const derived = Object.keys(COMPONENT_PROPS_SCHEMAS) + .filter((type) => { + const parsed = COMPONENT_PROPS_SCHEMAS[type]!.safeParse({ object: 'probe' }); + return !parsed.success && parsed.error.issues.some((i) => + i.path.length === 1 && i.path[0] === 'object' + && i.message.includes('was removed') && i.message.includes('`dataSource.object`')); + }) + .sort(); + check('the data-source-bound set equals the spec rows whose flat `object` is retired onto the binding', + JSON.stringify(derived) === JSON.stringify([...DATA_SOURCE_BOUND_ELEMENT_TYPES].sort()), + JSON.stringify(derived)); // Sequencing: the declared schema's verdict is never buried under a second one. const shortCircuit = checkBlock( diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index b20a36960dc..b819c5d0c60 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7302,10 +7302,12 @@ const elementFilterRemoved: MetadataConversion = { * The element layer's retired flat data-binding keys, per element type — the * keys {@link elementFlatDataBindingToDataSource} moves onto the node-level * `dataSource`. Declared here rather than imported from - * `ui/component.zod.ts` (its `RETIRED_ELEMENT_FLAT_BINDING_KEYS`), because this - * module is kept free of the page-component schemas it would drag into every - * bundle of the `./shared` entry (see {@link CONVERSIONS_BY_MAJOR}); - * `element-flat-data-binding-to-data-source.test.ts` holds the two equal. + * `ui/component.zod.ts`, whose list is module-private (exporting it would widen + * a retirement that only narrows) and which this module is kept free of, so + * the page-component schemas are not dragged into every bundle of the + * `./shared` entry (see {@link CONVERSIONS_BY_MAJOR}). + * `element-flat-data-binding-to-data-source.test.ts` holds this list equal to + * the tombstones `ComponentPropsMap` carries. */ const ELEMENT_FLAT_BINDING_KEYS_BY_TYPE: Readonly> = { 'element:record_picker': ['object', 'filter', 'sort', 'limit'], diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 8bf719d97d7..e9e8c0546f2 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2602,20 +2602,22 @@ export const ElementTextPropsSchema = lazySchema(() => strictObject({ /** * The three elements whose flat data-binding keys retired in v18 (#11509, - * ruling A-narrow), and the keys each one carried. The single source for the - * tombstones below, the `element-flat-data-binding-to-data-source` conversion - * and the component-props gate's "no `dataSource.object`" refusal - * (`@objectstack/lint`), so the three cannot disagree about which element owes - * a binding. + * ruling A-narrow), and the keys each one carried — the source of the + * tombstones below. Module-private on purpose: exporting it would widen the + * published surface of a retirement that only narrows. The two readers that + * need the set — the `element-flat-data-binding-to-data-source` conversion and + * the component-props gate's missing-binding refusal (`@objectstack/lint`) — + * keep their own copy, and each copy is pinned against these tombstones by + * probing `ComponentPropsMap`, so none of the three can drift alone. */ -export const RETIRED_ELEMENT_FLAT_BINDING_KEYS = { +const RETIRED_ELEMENT_FLAT_BINDING_KEYS = { 'element:record_picker': ['object', 'filter', 'sort', 'limit'], 'element:number': ['object', 'filter'], 'element:repeater': ['object', 'filter', 'sort', 'limit'], } as const satisfies Readonly>; /** An element whose query is the node-level `dataSource` binding only. */ -export type RetiredFlatBindingElementType = keyof typeof RETIRED_ELEMENT_FLAT_BINDING_KEYS; +type RetiredFlatBindingElementType = keyof typeof RETIRED_ELEMENT_FLAT_BINDING_KEYS; /** What the value of each retired key is — it moves unchanged. */ const ELEMENT_FLAT_BINDING_VALUE = { From fe970a9171f78e43b8509fc1426a8193e55df5c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:46:10 +0000 Subject: [PATCH 04/16] wip(spec): retirement pins, absorbed narrowing pins, and the tests the retirement moves Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../test/my-work-visibility.test.ts | 12 +- .../src/protocol.stored-migration.test.ts | 12 +- ...ponent-filter-record-to-rule-array.test.ts | 74 +- ...omponent-action-element-rows-20371.test.ts | 18 +- ...nt-object-grid-default-filters.pin.test.ts | 148 ---- packages/spec/src/ui/component.test.ts | 442 +++-------- .../element-flat-binding-retirement.test.ts | 742 ++++++++++++++++++ .../src/ui/filter-rule-array-guidance.test.ts | 22 +- 8 files changed, 930 insertions(+), 540 deletions(-) delete mode 100644 packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts create mode 100644 packages/spec/src/ui/element-flat-binding-retirement.test.ts diff --git a/examples/app-showcase/test/my-work-visibility.test.ts b/examples/app-showcase/test/my-work-visibility.test.ts index 030c9c74f20..e7487df861d 100644 --- a/examples/app-showcase/test/my-work-visibility.test.ts +++ b/examples/app-showcase/test/my-work-visibility.test.ts @@ -77,10 +77,12 @@ const workQueueGrid = () => allComponents().find((c) => c.type === 'object-grid' * - **`filters` is DEAD.** Zero read points in the renderer on any ref, so it * is accepted at authoring time and dropped before the wire. That is the * #7750 defect itself. - * - **`defaultFilters` is ALIVE.** `ObjectGrid.tsx` reads it and lowers it to - * `params.$filter` — the legacy path `object-grid` still honors. It is - * pinned absent because this page authors the CURRENT key, NOT because the - * legacy one is inert. + * - **`defaultFilters` was ALIVE, and is RETIRED.** `ObjectGrid.tsx` read it + * and lowered it to `params.$filter` when `filter` lowered to nothing — the + * legacy second spelling of `filter`, which v18 retires (objectstack#11509): + * the spec refuses it with the prescription, and its conversion moves the + * rules onto an empty `filter`. It is pinned absent because this page + * authors the CURRENT key, NOT because the legacy one was inert. * * That distinction is the whole point of the card, so getting it wrong here * would reproduce the defect one level up: a future author debugging a filter @@ -94,7 +96,7 @@ const NON_CANONICAL_FILTER_SPELLINGS: ReadonlyArray<{ key: string; why: string } }, { key: 'defaultFilters', - why: 'is the LEGACY key `object-grid` still reads and lowers to `$filter` — it works, but it is not the key this page declares', + why: 'is the LEGACY second spelling of `filter`, read only when `filter` lowered to nothing and retired in v18 — not the key this page declares', }, ]; diff --git a/packages/metadata-protocol/src/protocol.stored-migration.test.ts b/packages/metadata-protocol/src/protocol.stored-migration.test.ts index c1f0aed627d..7b0cfa842aa 100644 --- a/packages/metadata-protocol/src/protocol.stored-migration.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-migration.test.ts @@ -799,9 +799,12 @@ describe('migrateStoredMetadata — a site the chain leaves as stored is a TODO, grid({ stage: 'open' }), { type: 'object-kanban', dataSource: { object: 'deal', filter: COMBINATOR }, properties: { objectName: 'deal' } }, ]); - // The third door: `defaultFilters` on the grid, the same open bag. + // The third shape: the grid's `defaultFilters`, the same open bag. The key + // retired in v18 (#11509): with no `filter` beside it the fallback WAS the + // filter, so its retirement moves it onto `filter` — where the combinator + // is the record-form conversion's TODO, at the door it moved to. const defaultsMixed = pageRow('deal_grid', [ - { type: 'object-grid', properties: { objectName: 'deal', filter: { stage: 'open' }, defaultFilters: COMBINATOR } }, + { type: 'object-grid', properties: { objectName: 'deal', defaultFilters: COMBINATOR } }, ]); const { engine, tables } = makeStubEngine([mixedPage, bindingMixed, defaultsMixed]); const protocol = new ObjectStackProtocolImplementation(engine); @@ -816,9 +819,10 @@ describe('migrateStoredMetadata — a site the chain leaves as stored is a TODO, expect(binding.todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[1].dataSource.filter']); const defaults = report.rows.find((r) => r.name === 'deal_grid')!; expect(defaults.outcome).toBe('rewritten'); - expect(defaults.todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[0].properties.defaultFilters']); + expect(defaults.todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[0].properties.filter']); const storedDefaults = JSON.parse(metaRows(tables).find((r) => r.name === 'deal_grid')!.metadata); - expect(storedDefaults.regions[0].components[0].properties.defaultFilters).toEqual(COMBINATOR); + expect(storedDefaults.regions[0].components[0].properties.filter).toEqual(COMBINATOR); + expect(storedDefaults.regions[0].components[0].properties).not.toHaveProperty('defaultFilters'); // The rewritten row persisted its lossless half; the combinator is byte-identical. const stored = JSON.parse(metaRows(tables).find((r) => r.name === 'deal_desk')!.metadata); diff --git a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts index df24fe337de..07257fca72a 100644 --- a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts +++ b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts @@ -163,27 +163,60 @@ describe('§1 the ruled subset converts to the exact rule array', () => { expect(gridFilter({}).value).toEqual([]); }); - it('every door kind: the binding on any component, the block `filter`, the grid `defaultFilters`', () => { + it('every door kind: the binding on any component, and the block `filter`', () => { const { stack, notices } = convert( pageWith({ type: 'object-grid', dataSource: { object: 'deal', filter: { stage: 'open' } }, - properties: { objectName: 'deal', defaultFilters: { owner_id: 'u1' } }, + properties: { objectName: 'deal', filter: { owner_id: 'u1' } }, }), ); const component = componentOf(stack); expect((component.dataSource as Dict).filter).toEqual([ { field: 'stage', operator: 'equals', value: 'open' }, ]); - expect((component.properties as Dict).defaultFilters).toEqual([ + expect((component.properties as Dict).filter).toEqual([ { field: 'owner_id', operator: 'equals', value: 'u1' }, ]); expect(notices.map((n) => n.path)).toEqual([ 'pages[0].regions[0].components[0].dataSource.filter', - 'pages[0].regions[0].components[0].properties.defaultFilters', + 'pages[0].regions[0].components[0].properties.filter', ]); }); + /** + * The two doors that left this entry with #11509 (v18): `object-grid`'s + * `defaultFilters` and the elements' flat `filter`. Their retirements run + * BEFORE this entry and move a value onto the door it now lives at, so a + * record form there still reaches this entry — at the new door, converted + * exactly as any other filter there, each step with its own notice. + */ + it('a record form at a retired door reaches this entry at the door it moved to', () => { + const grid = convert(pageWith({ type: 'object-grid', properties: { objectName: 'deal', defaultFilters: { owner_id: 'u1' } } })); + expect(componentOf(grid.stack).properties).toEqual({ + objectName: 'deal', + filter: [{ field: 'owner_id', operator: 'equals', value: 'u1' }], + }); + expect(grid.notices.map((n) => [n.conversionId, n.path])).toEqual([ + ['object-grid-default-filters-removed', 'pages[0].regions[0].components[0].properties.filter'], + [ID, 'pages[0].regions[0].components[0].properties.filter'], + ]); + + const element = convert(pageWith({ type: 'element:number', properties: { object: 'deal', aggregate: 'count', filter: { stage: 'won' } } })); + expect(componentOf(element.stack)).toEqual({ + type: 'element:number', + properties: { aggregate: 'count' }, + dataSource: { object: 'deal', filter: [{ field: 'stage', operator: 'equals', value: 'won' }] }, + }); + expect(element.notices.map((n) => [n.conversionId, n.path])).toEqual([ + ['element-flat-data-binding-to-data-source', 'pages[0].regions[0].components[0].dataSource.object'], + ['element-flat-data-binding-to-data-source', 'pages[0].regions[0].components[0].dataSource.filter'], + [ID, 'pages[0].regions[0].components[0].dataSource.filter'], + ]); + expect(grid.todos).toEqual([]); + expect(element.todos).toEqual([]); + }); + it('reaches slots and nested containers, as every page-component conversion does', () => { const { stack } = convert({ pages: [ @@ -253,19 +286,6 @@ describe('§1 the ruled subset converts to the exact rule array', () => { expect(todos).toEqual([]); }); - it('`defaultFilters` on an inline-row grid converts too', () => { - const { value, notices, todos } = (() => { - const { stack, notices: n, todos: t } = convert(pageWith({ - type: 'object-grid', - properties: { data: { provider: 'value', items: [] }, defaultFilters: { stage: 'open' } }, - })); - return { value: (componentOf(stack).properties as Dict).defaultFilters, notices: n, todos: t }; - })(); - expect(value).toEqual([{ field: 'stage', operator: 'equals', value: 'open' }]); - expect(notices.map((n) => n.path)).toEqual(['pages[0].regions[0].components[0].properties.defaultFilters']); - expect(todos).toEqual([]); - }); - it('control: the same filter on an object-bound block of the same type converts', () => { for (const data of [undefined, { provider: 'object', object: 'deal' }]) { const { stack, notices, todos } = convert( @@ -373,7 +393,7 @@ describe('§2 what has no lossless rule spelling is left byte-identical', () => expect(todos).toEqual([]); }); - it('`defaultFilters` is converted on the grid only', () => { + it('`defaultFilters` on a block other than the grid is no door of this entry — nor of any', () => { const { stack, notices, todos } = convert( pageWith({ type: 'object-kanban', properties: { objectName: 'deal', defaultFilters: { a: 1 } } }), ); @@ -526,14 +546,22 @@ describe('§6 the reach is the family, read off the schema', () => { const TYPES = Object.keys(ComponentPropsMap) as Array; - it.each(['filter', 'defaultFilters'])('`properties.%s`: converted exactly where the door refuses the record', (key) => { - const doors = TYPES.filter((t) => refusesRecordWithPrescription(ComponentPropsMap[t], key)).sort(); - const reached = TYPES.filter((t) => converts(t, key)).sort(); + it('`properties.filter`: converted exactly where the door refuses the record', () => { + const doors = TYPES.filter((t) => refusesRecordWithPrescription(ComponentPropsMap[t], 'filter')).sort(); + const reached = TYPES.filter((t) => converts(t, 'filter')).sort(); // Lit control: the schema walk really found the family. expect(doors.length).toBeGreaterThan(0); expect(reached).toEqual(doors); }); + it('`properties.defaultFilters` is a door of no block since v18 — retired, so this entry reaches it nowhere', () => { + // #11509 retired `object-grid.defaultFilters` (the one block that had it): + // the row answers every value with its removal prescription, never the + // rule-array one, and no block keeps a record form there after the chain. + expect(TYPES.filter((t) => refusesRecordWithPrescription(ComponentPropsMap[t], 'defaultFilters'))).toEqual([]); + expect(TYPES.filter((t) => converts(t, 'defaultFilters'))).toEqual([]); + }); + it('the binding door is on every component, so the binding converts on any type', () => { expect(refusesRecordWithPrescription(ElementDataSourceSchema, 'filter')).toBe(true); const { stack } = convert(pageWith({ type: 'page:card', dataSource: { object: 'deal', filter: { a: 1 } } })); @@ -754,9 +782,9 @@ describe('§8 the TODO channel — every site left as stored is reported (ruling const entry = ALL_CONVERSIONS.find((c) => c.id === ID)!; const { notices, todos } = convert(entry.fixture.before); expect(todos.map((t) => [t.path, t.reason.slice(0, 40)])).toEqual([ - ['pages[0].regions[0].components[1].properties.filter', 'On the `object-kanban` block, this filte'], + ['pages[0].regions[0].components[2].properties.filter', 'On the `object-kanban` block, this filte'], ]); - expect(notices.map((n) => n.path)).toContain('pages[0].regions[0].components[2].properties.filter'); + expect(notices.map((n) => n.path)).toContain('pages[0].regions[0].components[3].properties.filter'); }); it('reporting writes nothing: every decline yields the same stack with or without a sink', () => { diff --git a/packages/spec/src/ui/component-action-element-rows-20371.test.ts b/packages/spec/src/ui/component-action-element-rows-20371.test.ts index 833a5752428..3b0810242b3 100644 --- a/packages/spec/src/ui/component-action-element-rows-20371.test.ts +++ b/packages/spec/src/ui/component-action-element-rows-20371.test.ts @@ -120,6 +120,9 @@ describe('key sets, asserted whole — measured from the renderers\' read points }); it('element:repeater', () => { + // `object` / `filter` / `sort` / `limit` are tombstones since v18 (#11509): + // a `retiredKey()` stays a key of the walked shape, and the list's query is + // the node-level `dataSource` binding. expect(keysOf(ElementRepeaterPropsSchema)).toEqual( ['object', 'titleField', 'fields', 'filter', 'sort', 'limit', 'emptyText', 'divided'].sort(), ); @@ -174,15 +177,16 @@ describe('one accepted authored example per type, taken from objectui', () => { }); it('element:repeater — objectui\'s own renderer specimen', () => { - const authored = { object: 'showcase_category', fields: ['name'], emptyText: 'Nothing here' }; + // The specimen's `object` moved onto the node-level binding in v18 + // (#11509); the props bag keeps the display keys. + const authored = { fields: ['name'], emptyText: 'Nothing here' }; expect(ElementRepeaterPropsSchema.parse(authored)).toEqual(authored); }); }); describe('strict from birth — an unknown key is refused on every row', () => { it.each(Object.entries(ROWS))('`%s` refuses an undeclared key, naming its surface', (type, schema) => { - const base = type === 'element:repeater' ? { object: 'task' } : {}; - const issue = unknownKeyIssue(schema.safeParse({ ...base, notARealProp: 1 })); + const issue = unknownKeyIssue(schema.safeParse({ notARealProp: 1 })); expect(issue.keys).toEqual(['notARealProp']); expect(issue.message).toContain(`\`${type}\``); }); @@ -322,10 +326,12 @@ describe('what the measurement decided, pinned', () => { } }); - it('repeater: the `object-*` family\'s `objectName` is refused and renamed to `object`', () => { - const issue = unknownKeyIssue(ElementRepeaterPropsSchema.safeParse({ object: 'task', objectName: 'task' })); + it('repeater: the `object-*` family\'s `objectName` is refused and pointed at the binding\'s `object`', () => { + // Until v18 this renamed to the flat `object`; that key retired onto the + // node-level binding (#11509), so the spelling points there instead. + const issue = unknownKeyIssue(ElementRepeaterPropsSchema.safeParse({ objectName: 'task' })); expect(issue.keys).toEqual(['objectName']); - expect(issue.message).toContain('`object`'); + expect(issue.message).toContain('`dataSource.object`'); }); }); diff --git a/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts b/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts deleted file mode 100644 index e4b8da529c7..00000000000 --- a/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#19514] `object-grid`'s `defaultFilters` carries the SAME declaration as its - * `filter` sibling. - * - * The key is described as "read only when `filter` is absent" — the same value - * in the same role — and objectui's `ObjectGrid` reads it through the same - * lowering sink. `filter` converged on the `ViewFilterRule` array with the rest - * of its family; this key was not named by that ruling and kept the - * pre-convergence `z.unknown()`, so the block had one declared door and one - * undeclared door onto one seam: a bare string, a number, a MongoDB-style - * record, an ObjectQL AST tuple array and a list of malformed rules all parsed - * here. At the objectui `.objectui-sha` pin `87af769e9a` the lowering - * treats them three ways, per shape: the record form and the tuple array are - * lowered and APPLIED as declared; a bare string or a number is DROPPED, so the - * grid sends no filter and lists its rows unfiltered; and a list of malformed - * rules is REFUSED, on the wire or by the client before any request. - * - * These pins hold the two keys EQUAL rather than transcribing a list of shapes - * — the equality is the rule, and a list would go stale the next time `filter` - * moves. Both directions are pinned at each key: the newly-refused shapes - * REFUSE and the shape that must keep working ACCEPTS, because an ACCEPT-only - * suite passes just as well against a door that has been narrowed to refuse - * everything. - * - * ⛔ Narrowed, NOT retired. Refusing the key outright is a REMOVAL of an - * accepted shape and needs its own ruling; the deprecation already stated in - * the description is unchanged. The pin below says so in the one way a test - * can: a well-formed `defaultFilters` still parses. - */ - -import { describe, expect, it } from 'vitest'; - -import { ComponentPropsMap } from './component.zod'; - -const GRID = ComponentPropsMap['object-grid']; - -/** A valid grid node with one key under test swapped in. */ -const node = (extra: Record) => ({ objectName: 'account', ...extra }); - -/** The rule array both keys take. */ -const RULES = [{ field: 'status', operator: 'equals', value: 'active' }] as const; - -/** The shapes the `z.unknown()` door used to receipt as valid. */ -const REFUSED_SHAPES: readonly (readonly [string, unknown])[] = [ - ['a bare string', 'status = active'], - ['a number', 42], - ['a boolean', true], - ['the MongoDB-style record form', { status: 'active' }], - ['an operator-object record form', { amount: { $gt: 100 } }], - ['an ObjectQL AST tuple array', [['owner_id', '=', '{current_user_id}']]], - ['an array of malformed rules', [{ nonsense: true }]], -]; - -describe('defaultFilters refuses what filter refuses', () => { - it.each(REFUSED_SHAPES.map(([label, value]) => [label, value] as const))( - 'refuses %s at the defaultFilters path', - (_label, value) => { - const result = GRID.safeParse(node({ defaultFilters: value })); - expect(result.success).toBe(false); - if (result.success) throw new Error('unreachable'); - const under = result.error.issues.filter((i) => String(i.path[0]) === 'defaultFilters'); - expect(under.length).toBeGreaterThan(0); - }, - ); - - it('the two keys agree shape for shape — the rule, not a transcribed list', () => { - // If `filter` is narrowed or widened again, this is what holds the fallback - // to it. A list of literals here would silently stop tracking. - for (const [label, value] of REFUSED_SHAPES) { - const onFilter = GRID.safeParse(node({ filter: value })).success; - const onFallback = GRID.safeParse(node({ defaultFilters: value })).success; - expect(onFallback, `${label}: defaultFilters`).toBe(onFilter); - } - expect(GRID.safeParse(node({ filter: RULES })).success).toBe(true); - expect(GRID.safeParse(node({ defaultFilters: RULES })).success).toBe(true); - }); - - it('the record form gets the conversion table, naming the key that was written', () => { - const result = GRID.safeParse(node({ defaultFilters: { status: 'active' } })); - expect(result.success).toBe(false); - if (result.success) throw new Error('unreachable'); - const issue = result.error.issues.find((i) => i.path.join('.') === 'defaultFilters')!; - expect(issue.message).toContain('`defaultFilters`'); - expect(issue.message).toContain('[{ field, operator, value }, ...]'); - // The rewrite is computed from the author's own keys, as at every sibling door. - expect(issue.message).toContain("[{ field: 'status', operator: 'equals', value: 'active' }]"); - expect(issue.message).toContain('migration `object-grid-default-filters-rule-array`'); - }); - - it.each([ - ['the rule array', RULES], - ['an empty rule array', []], - ['a multi-rule array', [ - { field: 'status', operator: 'equals', value: 'active' }, - { field: 'stage', operator: 'in', value: ['won', 'lost'] }, - ]], - ])('NEGATIVE CONTROL — still accepts %s', (_label, value) => { - expect(GRID.safeParse(node({ defaultFilters: value })).success).toBe(true); - }); - - it('NEGATIVE CONTROL — absence is still absence, and the key is still optional', () => { - const result = GRID.safeParse(node({})); - expect(result.success).toBe(true); - if (!result.success) throw new Error('unreachable'); - expect('defaultFilters' in (result.data as Record)).toBe(false); - }); - - it('NEGATIVE CONTROL — both keys together still parse, as the fallback contract allows', () => { - // The key is read only when `filter` is absent; authoring both has never - // been an error and this narrowing does not make it one. - expect(GRID.safeParse(node({ filter: RULES, defaultFilters: RULES })).success).toBe(true); - }); - - it('ENVELOPE CONTROL — the door refuses an unrelated thing, so it is reachable', () => { - // The card's own discriminating control. If this reads ACCEPT, the block is - // not being parsed at all and every REFUSE above is a phantom. - const result = GRID.safeParse({ objectName: 42 }); - expect(result.success).toBe(false); - if (result.success) throw new Error('unreachable'); - expect(result.error.issues.some((i) => i.path.join('.') === 'objectName')).toBe(true); - }); - - it('the element-level issues of an array author are still their own', () => { - // The fall-through `ruleArrayFilterError` protects: a blanket message at the - // key would overwrite the diagnosis an array author actually needs. - const result = GRID.safeParse(node({ defaultFilters: [{ field: 'status', operator: 'nope', value: 'a' }] })); - expect(result.success).toBe(false); - if (result.success) throw new Error('unreachable'); - const under = result.error.issues.filter((i) => i.path.join('.').startsWith('defaultFilters.')); - expect(under.length).toBeGreaterThan(0); - for (const issue of under) expect(issue.message).not.toContain('migration `'); - expect(result.error.issues.filter((i) => i.path.join('.') === 'defaultFilters')).toHaveLength(0); - }); - - it('the rule-level narrowings reach THIS key too — one schema, every carrier', () => { - // The scalar arm and the icontains comparand door are `ViewFilterRuleSchema`'s, - // so they arrive here by construction. Pinned because "the fallback is the - // same declaration" is the whole claim of this file. - expect(GRID.safeParse(node({ - defaultFilters: [{ field: 'tags', operator: 'equals', value: ['a'] }], - })).success).toBe(false); - expect(GRID.safeParse(node({ - defaultFilters: [{ field: 'name', operator: 'icontains', value: '' }], - })).success).toBe(false); - }); -}); diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 776b6d64bb5..5749466fe6a 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -1692,7 +1692,8 @@ describe('Content Elements', () => { it('should accept element:number component', () => { expect(() => PageComponentSchema.parse({ type: 'element:number', - properties: { object: 'order', aggregate: 'count' }, + dataSource: { object: 'order' }, + properties: { aggregate: 'count' }, })).not.toThrow(); }); @@ -1711,90 +1712,10 @@ describe('Content Elements', () => { }); }); -// --------------------------------------------------------------------------- -// element:number `filter` — the ViewFilterRule ARRAY orthography (ui#6206-B) -// --------------------------------------------------------------------------- -describe("element:number `filter` — one filter orthography platform-wide", () => { - const number = ComponentPropsMap['element:number']; - const relatedList = ComponentPropsMap['record:related_list']; - const RULES = [{ field: 'status', operator: 'equals', value: 'won' }]; - const RECORD_FORM = { status: 'won' }; - /** The issues a parse raised AT `key` (top-level), whatever else it raised. */ - const issuesAt = (r: { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; code: string }> } }, key: string) => - r.success ? [] : r.error!.issues.filter((i) => i.path[0] === key); - - it('accepts a ViewFilterRule[] filter — the acceptance criterion', () => { - // Before the 2026-08-25 ruling this exact value was REFUSED here (the entry - // said `FilterConditionSchema`, the MongoDB-style record) while every - // sibling `filter` input in the map accepted it. - const r = number.safeParse({ object: 'order', aggregate: 'count', 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', () => { - // `eq` is a legacy spelling `normalizeFilterOperator` lowers to `equals` — a - // plain `z.array(z.object(...))` would have echoed it back unchanged. - const legacy = number.safeParse({ - object: 'order', aggregate: 'count', - filter: [{ field: 'status', operator: 'eq', value: 'won' }], - }); - expect(legacy.success).toBe(true); - expect(legacy.data!.filter![0].operator).toBe('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 = number.safeParse({ - object: 'order', aggregate: 'count', - filter: [{ field: 'status', operator: 'in', value: 'won' }], - }); - 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 entry 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-number-filter-rule-array`. - const r = number.safeParse({ object: 'order', aggregate: 'count', filter: RECORD_FORM }); - expect(r.success).toBe(false); - const atFilter = issuesAt(r, 'filter'); - expect(atFilter).toHaveLength(1); - expect(atFilter[0].code).toBe('invalid_type'); - expect(atFilter[0]).toMatchObject({ expected: 'array' }); - // An operator-object record (`{ amount: { $gt: 100 } }`) is the same form - // and gets the same verdict — no arm accepts any spelling of the record. - const opRecord = number.safeParse({ object: 'order', aggregate: 'sum', field: 'amount', filter: { amount: { $gt: 100 } } }); - expect(opRecord.success).toBe(false); - expect(issuesAt(opRecord, 'filter').map((i) => i.code)).toEqual(['invalid_type']); - }); - - it('shares the array orthography with the sibling `filter` inputs — one value, two doors, the same verdicts', () => { - // The ruling is "one filter orthography platform-wide", so the pin is - // cross-entry: the same rule array raises no issue at `filter` on either - // door, and the same record form is refused at `filter` with the same - // issue code on both. Each door is asked only about ITS `filter` — the - // other keys the related list requires are not this pin's subject. - expect(issuesAt(number.safeParse({ object: 'order', aggregate: 'count', filter: RULES }), 'filter')).toEqual([]); - expect(issuesAt(relatedList.safeParse({ filter: RULES }), 'filter')).toEqual([]); - const numberRefusal = issuesAt(number.safeParse({ object: 'order', aggregate: 'count', filter: RECORD_FORM }), 'filter'); - const relatedRefusal = issuesAt(relatedList.safeParse({ filter: RECORD_FORM }), 'filter'); - expect(numberRefusal.map((i) => i.code)).toEqual(['invalid_type']); - expect(relatedRefusal.map((i) => i.code)).toEqual(numberRefusal.map((i) => i.code)); - }); - - it('positive control: a well-formed multi-rule array with a real `in` rule parses through the element', () => { - const r = number.safeParse({ - object: 'order', aggregate: 'sum', field: 'amount', - filter: [ - { field: 'status', operator: 'in', value: ['won', 'closed'] }, - { field: 'amount', operator: 'greater_than', value: 100 }, - ], - }); - expect(r.success).toBe(true); - expect(r.data!.filter).toHaveLength(2); - }); -}); +// `element:number`'s flat `filter` — the ViewFilterRule-array door ui#6206-B +// converged — retired in v18 with its flat `object` (#11509): the element's +// filter is `dataSource.filter`, the binding's own rule-array door. The +// retirement is pinned in `element-flat-binding-retirement.test.ts`. // --------------------------------------------------------------------------- // Element Props Schemas @@ -1911,28 +1832,23 @@ describe('ElementTextPropsSchema', () => { describe('ElementNumberPropsSchema', () => { it('should accept minimal number props', () => { - const props = ElementNumberPropsSchema.parse({ - object: 'order', - aggregate: 'count', - }); - expect(props.object).toBe('order'); + // The object is the node-level `dataSource.object` since v18 (#11509); + // the props bag carries the aggregate alone. + const props = ElementNumberPropsSchema.parse({ aggregate: 'count' }); expect(props.aggregate).toBe('count'); expect(props.field).toBeUndefined(); }); it('should accept full number props', () => { + // `object` / `filter` are the binding's since v18 (#11509). const props = ElementNumberPropsSchema.parse({ - object: 'order', field: 'amount', aggregate: 'sum', - // The ViewFilterRule array form (ui#6206-B) — this fixture authored the - // record form `{ status: 'paid' }` while the entry alone accepted it. - filter: [{ field: 'status', operator: 'equals', value: 'paid' }], format: 'currency', prefix: '$', suffix: ' USD', }); - expect(props.filter).toEqual([{ field: 'status', operator: 'equals', value: 'paid' }]); + expect(props.field).toBe('amount'); expect(props.format).toBe('currency'); expect(props.prefix).toBe('$'); expect(props.suffix).toBe(' USD'); @@ -1941,20 +1857,20 @@ describe('ElementNumberPropsSchema', () => { it('should accept all aggregate functions', () => { const aggregates = ['count', 'sum', 'avg', 'min', 'max'] as const; aggregates.forEach(aggregate => { - expect(() => ElementNumberPropsSchema.parse({ object: 'order', aggregate })).not.toThrow(); + expect(() => ElementNumberPropsSchema.parse({ aggregate })).not.toThrow(); }); }); it('should accept all format options', () => { const formats = ['number', 'currency', 'percent'] as const; formats.forEach(format => { - expect(() => ElementNumberPropsSchema.parse({ object: 'order', aggregate: 'count', format })).not.toThrow(); + expect(() => ElementNumberPropsSchema.parse({ aggregate: 'count', format })).not.toThrow(); }); }); it('should reject without required fields', () => { expect(() => ElementNumberPropsSchema.parse({})).toThrow(); - expect(() => ElementNumberPropsSchema.parse({ object: 'order' })).toThrow(); + expect(() => ElementNumberPropsSchema.parse({ field: 'amount' })).toThrow(); }); }); @@ -2015,11 +1931,8 @@ describe('ComponentPropsMap content elements', () => { }); it('should parse element:number props', () => { - const result = ComponentPropsMap['element:number'].parse({ - object: 'order', - aggregate: 'count', - }); - expect(result.object).toBe('order'); + const result = ComponentPropsMap['element:number'].parse({ aggregate: 'count' }); + expect(result.aggregate).toBe('count'); }); it('should parse element:image props', () => { @@ -2326,54 +2239,43 @@ describe('element:filter / element:form are refused by name at the node', () => // Interactive Elements — element:record_picker // --------------------------------------------------------------------------- describe('Interactive Elements — element:record_picker', () => { + // Since v18 (#11509) the picker's query — object, view, filter, sort, limit — + // is the node-level `dataSource` binding, and the props bag carries display + // config only. The four flat binding keys' retirement is pinned in + // `element-flat-binding-retirement.test.ts`. it('should accept element:record_picker component', () => { expect(() => PageComponentSchema.parse({ type: 'element:record_picker', - properties: { object: 'account', labelField: 'name' }, + dataSource: { object: 'account' }, + properties: { labelField: 'name' }, })).not.toThrow(); }); it('should parse record_picker props with defaults', () => { - const props = ElementRecordPickerPropsSchema.parse({ - object: 'account', - labelField: 'name', - }); - expect(props.object).toBe('account'); + const props = ElementRecordPickerPropsSchema.parse({ labelField: 'name' }); expect(props.labelField).toBe('name'); }); it('should accept full record_picker props', () => { const props = ElementRecordPickerPropsSchema.parse({ - object: 'account', labelField: 'name', valueField: 'id', label: 'Account', - // The ViewFilterRule array form (ui#6206-B, #14406) — this fixture - // authored the record form `{ status: 'active' }` while the entry alone - // accepted it. - filter: [{ field: 'status', operator: 'equals', value: 'active' }], placeholder: 'Search accounts...', emptyText: 'No accounts', }); expect(props.labelField).toBe('name'); expect(props.valueField).toBe('id'); expect(props.label).toBe('Account'); - expect(props.filter).toEqual([{ field: 'status', operator: 'equals', value: 'active' }]); expect(props.emptyText).toBe('No accounts'); }); - it('should reject record_picker without its one required field', () => { - expect(() => ElementRecordPickerPropsSchema.parse({})).toThrow(); - }); - - // #5775 — `object` is the ONLY required prop. `labelField` is optional - // because the renderer defaults it to `name` (`props.labelField ?? 'name'`), - // so omitting it is a working picker, not a broken one. This is the half of - // the ruling that lets the showcase's `page-variables` page stop reporting - // `component-props-invalid` (a required key it had no reason to write). - it('accepts a picker with `object` alone — labelField defaults in the renderer', () => { - const props = ElementRecordPickerPropsSchema.parse({ object: 'account' }); - expect(props.object).toBe('account'); + // #5775 made `object` the ONLY required prop (`labelField` defaults to + // `name` in the renderer, so omitting it is a working picker). #11509 moved + // that `object` onto the binding, so an empty bag is a complete one: the + // requirement is the component-props gate's, at `dataSource.object`. + it('accepts an empty props bag — the object is the binding\'s, labelField defaults in the renderer', () => { + const props = ElementRecordPickerPropsSchema.parse({}); expect(props.labelField).toBeUndefined(); }); @@ -2382,222 +2284,114 @@ describe('Interactive Elements — element:record_picker', () => { label: 'Project', labelField: 'name', placeholder: 'Choose a project…', - object: 'showcase_project', }); expect(props.labelField).toBe('name'); expect(props.label).toBe('Project'); + // …and the node it sits on, binding included. + expect(PageComponentSchema.safeParse({ + type: 'element:record_picker', + id: 'project_picker', + dataSource: { object: 'showcase_project', limit: 50 }, + properties: { label: 'Project', labelField: 'name', placeholder: 'Choose a project…' }, + }).success).toBe(true); }); // #5775 tombstones — the prescription IS the payload. `displayField` was a // REQUIRED declaration no renderer read; `searchFields` / `multiple` were // capability claims the single-select control never kept (ADR-0049). it('rejects the retired `displayField` with the rename prescription', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', displayField: 'title' })) + expect(() => ElementRecordPickerPropsSchema.parse({ displayField: 'title' })) .toThrow(/displayField.*removed.*use `labelField`|displayField.*removed.*`labelField`/s); }); it('rejects the retired `searchFields` with its prescription', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', searchFields: ['name'] })) + expect(() => ElementRecordPickerPropsSchema.parse({ searchFields: ['name'] })) .toThrow(/`searchFields`.*removed.*Delete the key/s); }); + it('the `searchFields` prescription names the binding\'s filter, never the retired flat one', () => { + const r = ElementRecordPickerPropsSchema.safeParse({ searchFields: ['name'] }); + const message = r.success ? '' : r.error.issues[0]!.message; + expect(message).toContain('use the component-level `dataSource.filter`'); + expect(message).not.toContain('use `filter`'); + }); + it('rejects the retired `multiple` with its prescription', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', multiple: true })) + expect(() => ElementRecordPickerPropsSchema.parse({ multiple: true })) .toThrow(/`multiple`.*removed.*Delete the key/s); }); it('does not materialize the retired keys on a clean parse', () => { - const props = ElementRecordPickerPropsSchema.parse({ object: 'a' }); - expect(props).not.toHaveProperty('displayField'); - expect(props).not.toHaveProperty('searchFields'); - expect(props).not.toHaveProperty('multiple'); - expect(props).not.toHaveProperty('targetVariable'); + const props = ElementRecordPickerPropsSchema.parse({}); + for (const key of ['displayField', 'searchFields', 'multiple', 'targetVariable', 'object', 'filter', 'sort', 'limit']) { + expect(props).not.toHaveProperty(key); + } }); // #9198 tombstone — `targetVariable` was a declarative hint with zero // readers; the live binding is the page variable whose `source` names this // component's `id` (ADR-0049 enforce-or-remove). it('rejects the retired `targetVariable` with its prescription', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', targetVariable: 'selected_id' })) + expect(() => ElementRecordPickerPropsSchema.parse({ targetVariable: 'selected_id' })) .toThrow(/`targetVariable`.*removed.*Delete the key/s); }); - // ── commit 78f0be872 — the flat `sort` / `limit` shorthands ────────────── - // The renderer resolves four keys through one pattern - // (`ds. ?? props.`); after #5775 two of the four flat spellings were - // declared and two were not. These pin the other two, in BOTH halves of what - // a declaration buys: the key is retained (not stripped into silence) and the - // VALUE is judged (a wrong shape is rejected by name rather than dropped). - it('retains the flat `sort` shorthand — declared, not stripped', () => { - const props = ElementRecordPickerPropsSchema.parse({ - object: 'showcase_project', - sort: [{ field: 'created_at', order: 'desc' }], - }); - expect(props.sort).toEqual([{ field: 'created_at', order: 'desc' }]); - }); - - it('retains the flat `limit` shorthand — declared, not stripped', () => { - const props = ElementRecordPickerPropsSchema.parse({ object: 'showcase_project', limit: 20 }); - expect(props.limit).toBe(20); - }); - - // The exact ADR-0078 trap the issue reported: an author who infers - // `properties.limit: 20` from the declared `object`/`filter` spelling used to - // get the renderer's default 50 with zero diagnostics, because the key was - // stripped before anything could read it. - it('rejects a non-integer / non-positive `limit` by name', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', limit: 0 })).toThrow(/limit/); - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', limit: -5 })).toThrow(/limit/); - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', limit: 2.5 })).toThrow(/limit/); - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', limit: 'ten' })).toThrow(/limit/); - }); - - it('rejects a malformed `sort` by name', () => { - // A bare field name — the shape an author reaches for when the key is - // undeclared and nothing has ever told them otherwise. - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', sort: 'created_at' })) - .toThrow(/sort/); - // Right container, wrong direction vocabulary. - expect(() => ElementRecordPickerPropsSchema.parse({ - object: 'a', - sort: [{ field: 'created_at', order: 'descending' }], - })).toThrow(/sort/); - // Right container, missing the required half of the pair. - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', sort: [{ field: 'created_at' }] })) + // ── the query's shapes, at the one door that carries them ──────────────── + // Commit 78f0be872 declared the flat `sort` / `limit` in the binding's own + // shapes so the two spellings could not drift into a third dialect; #11509 + // retired the flat spelling, and these shapes are now the binding's alone. + // The renderer's `?? 50` stays a renderer fallback, never a schema default. + it('the binding judges `sort` / `limit` by name, and does not default `limit`', () => { + for (const limit of [0, -5, 2.5, 'ten']) { + expect(() => ElementDataSourceSchema.parse({ object: 'a', limit })).toThrow(/limit/); + } + expect(() => ElementDataSourceSchema.parse({ object: 'a', sort: 'created_at' })).toThrow(/sort/); + expect(() => ElementDataSourceSchema.parse({ object: 'a', sort: [{ field: 'created_at', order: 'descending' }] })) .toThrow(/sort/); - }); - - // The shorthand IS the `dataSource` key, so one value must parse identically - // through both doors. This is what stops the flat spelling drifting into a - // third sort dialect (the ledger's `report.zod.ts` row records three already). - it('parses `sort` / `limit` identically to `dataSource` (one shape, two spellings)', () => { - const sort = [{ field: 'name', order: 'asc' as const }]; - const viaProps = ElementRecordPickerPropsSchema.parse({ object: 'a', sort, limit: 25 }); - const viaDataSource = ElementDataSourceSchema.parse({ object: 'a', sort, limit: 25 }); - expect(viaProps.sort).toEqual(viaDataSource.sort); - expect(viaProps.limit).toEqual(viaDataSource.limit); - // …and the same rejections on the same values. - expect(ElementRecordPickerPropsSchema.safeParse({ object: 'a', limit: 0 }).success) - .toBe(ElementDataSourceSchema.safeParse({ object: 'a', limit: 0 }).success); - expect(ElementRecordPickerPropsSchema.safeParse({ object: 'a', sort: 'name' }).success) - .toBe(ElementDataSourceSchema.safeParse({ object: 'a', sort: 'name' }).success); - }); - - // The renderer's `?? 50` is a RENDERER fallback, deliberately not a schema - // default: `.default(50)` would materialize a limit on every parsed picker - // and turn an unset key into an authored one (and would then have to be kept - // in sync with objectui by hand). - it('does not default `limit` — the 50 is the renderer fallback', () => { - const props = ElementRecordPickerPropsSchema.parse({ object: 'a' }); - expect(props.limit).toBeUndefined(); - expect(props.sort).toBeUndefined(); + expect(() => ElementDataSourceSchema.parse({ object: 'a', sort: [{ field: 'created_at' }] })).toThrow(/sort/); + const parsed = ElementDataSourceSchema.parse({ object: 'a' }); + expect(parsed.limit).toBeUndefined(); + expect(parsed.sort).toBeUndefined(); }); }); // --------------------------------------------------------------------------- -// element:record_picker `filter` — the ViewFilterRule ARRAY orthography (ui#6206-B, #14406) +// The `filter` doors of ComponentPropsMap — the census of the one orthography +// (ui#6206-B, #14406). `element:record_picker`'s and `element:number`'s flat +// `filter` were doors of it until #11509 retired them in v18 onto the binding's +// own rule-array door, `dataSource.filter`; a retired door is a tombstone that +// refuses EVERY value, so the census below asks the live doors only. // --------------------------------------------------------------------------- -describe("element:record_picker `filter` — one filter orthography platform-wide", () => { - const picker = ComponentPropsMap['element:record_picker']; - const number = ComponentPropsMap['element:number']; - const relatedList = ComponentPropsMap['record:related_list']; +describe('the live `filter` doors of ComponentPropsMap — one filter orthography platform-wide', () => { const RULES = [{ field: 'status', operator: 'equals', value: 'active' }]; - const RECORD_FORM = { status: 'active' }; - type ParseResult = { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; code: string }> } }; + type ParseResult = { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; code: string; message: string }> } }; + type Door = { shape?: Record; safeParse: (v: unknown) => ParseResult }; /** The issues a parse raised AT `key` (top-level), whatever else it raised. */ const issuesAt = (r: ParseResult, key: string) => r.success ? [] : r.error!.issues.filter((i) => i.path[0] === key); + const door = (type: string) => ComponentPropsMap[type as keyof typeof ComponentPropsMap] as unknown as Door; + /** A retired `filter` refuses the rule array with its removal prescription. */ + const isRetired = (type: string) => + issuesAt(door(type).safeParse({ filter: RULES }), 'filter').some((i) => i.message.includes('was removed')); - it('accepts a ViewFilterRule[] filter — the acceptance criterion', () => { - // Before #14406 this exact value was REFUSED here — the entry said - // `FilterConditionSchema`, the MongoDB-style record, the LAST one in the - // map — while every sibling `filter` input accepted it. Measured at the - // objectui pin before the declaration moved: the renderer hands the value - // to `query.$filter`, and `adapter.find()` lowers a rule array through - // `translateFilterArray`, so the array reaches the query. - const r = picker.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', () => { - // `eq` is a legacy spelling `normalizeFilterOperator` lowers to `equals` — a - // plain `z.array(z.object(...))` would have echoed it back unchanged. - const legacy = picker.safeParse({ - object: 'account', - filter: [{ field: 'status', operator: 'eq', value: 'active' }], - }); - expect(legacy.success).toBe(true); - expect(legacy.data!.filter![0].operator).toBe('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 = picker.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 entry 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-record-picker-filter-rule-array`. - const r = picker.safeParse({ object: 'account', filter: RECORD_FORM }); - expect(r.success).toBe(false); - const atFilter = issuesAt(r, 'filter'); - expect(atFilter).toHaveLength(1); - expect(atFilter[0].code).toBe('invalid_type'); - expect(atFilter[0]).toMatchObject({ expected: 'array' }); - // An operator-object record and a `$and` group are the same form and get - // the same verdict — no arm accepts any spelling of the record. - const opRecord = picker.safeParse({ object: 'account', filter: { amount: { $gt: 100 } } }); - expect(issuesAt(opRecord, 'filter').map((i) => i.code)).toEqual(['invalid_type']); - const group = picker.safeParse({ object: 'account', filter: { $and: [{ status: 'active' }] } }); - expect(issuesAt(group, 'filter').map((i) => i.code)).toEqual(['invalid_type']); - }); - - it('shares the array orthography with the sibling `filter` inputs — one value, three doors, the same verdicts', () => { - // The ruling is "one filter orthography platform-wide" and this entry was - // the last holdout, so the pin is cross-entry: the same rule array raises - // no issue at `filter` on any of the three declared doors, and the same - // record form is refused at `filter` with the same issue code on all - // three. Each door is asked only about ITS `filter`. - expect(issuesAt(picker.safeParse({ object: 'account', filter: RULES }), 'filter')).toEqual([]); - expect(issuesAt(number.safeParse({ object: 'account', aggregate: 'count', filter: RULES }), 'filter')).toEqual([]); - expect(issuesAt(relatedList.safeParse({ filter: RULES }), 'filter')).toEqual([]); - const pickerRefusal = issuesAt(picker.safeParse({ object: 'account', filter: RECORD_FORM }), 'filter').map((i) => i.code); - expect(pickerRefusal).toEqual(['invalid_type']); - expect(issuesAt(number.safeParse({ object: 'account', aggregate: 'count', filter: RECORD_FORM }), 'filter').map((i) => i.code)) - .toEqual(pickerRefusal); - expect(issuesAt(relatedList.safeParse({ filter: RECORD_FORM }), 'filter').map((i) => i.code)).toEqual(pickerRefusal); - }); - - it('no top-level `filter` door in ComponentPropsMap refuses the rule array any more — the census the card closes', () => { + it('no live top-level `filter` door refuses the rule array any more — the census the card closes', () => { // The card's claim is "the last record-form `filter` in `ComponentPropsMap`". - // 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. 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 }; + // Asserted over the WHOLE map by shape rather than over named entries, so a + // future entry declaring `FilterConditionSchema` at `filter` (which refuses + // an array outright, `invalid_type`) is caught here by name. 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); - // Guard the probe: the three doors pinned above must be found, or the - // shape read has gone wrong and the loop below is vacuous. - expect(doors).toEqual(expect.arrayContaining(['element:record_picker', 'element:number', 'record:related_list'])); - const holdouts = doors.filter((type) => { - const r = (ComponentPropsMap[type as keyof typeof ComponentPropsMap] as unknown as Door).safeParse({ filter: RULES }); - return issuesAt(r, 'filter').length > 0; - }); + // Guard the probe: the retired doors are still keys of their shapes (a + // tombstone is a key), and they are exactly the three element rows. + const retired = doors.filter(isRetired).sort(); + expect(retired).toEqual(['element:number', 'element:record_picker', 'element:repeater']); + const live = doors.filter((type) => !isRetired(type)); + expect(live).toEqual(expect.arrayContaining(['record:related_list', 'object-grid'])); + const holdouts = live.filter((type) => issuesAt(door(type).safeParse({ filter: RULES }), 'filter').length > 0); expect(holdouts).toEqual([]); }); }); @@ -2767,15 +2561,15 @@ describe('the four `object-*` `sort` doors — one sort orthography, the array', expect(unrecognized.flatMap((i) => i.keys ?? [])).toContain('bogusProp'); }); - it('`sort` agrees with `dataSource.sort` and with the picker shorthand — one shape, four doors', () => { + it('`sort` agrees with `dataSource.sort` — one shape, the four doors and the binding', () => { // The map's own copies are the same import (`SortItemSchema`), so this // asks the question the copies could not: do the doors AGREE, value for - // value, with the binding every data-bound element already carries. + // value, with the binding every data-bound element already carries. (The + // record picker's flat `sort` was a fifth door until #11509 retired it in + // v18 onto that binding.) const viaBinding = ElementDataSourceSchema.parse({ object: 'showcase_task', sort: ARRAY_FORM }); - for (const type of [...SORT_DOORS, 'element:record_picker']) { - const value = type === 'element:record_picker' - ? { object: 'showcase_task', sort: ARRAY_FORM } - : { objectName: 'showcase_task', sort: ARRAY_FORM }; + for (const type of SORT_DOORS) { + const value = { objectName: 'showcase_task', sort: ARRAY_FORM }; const r = door(type).safeParse(value); expect([type, r.success]).toEqual([type, true]); expect([type, r.data!.sort]).toEqual([type, viaBinding.sort]); @@ -2918,11 +2712,8 @@ describe('ComponentPropsMap interactive elements', () => { }); it('should parse element:record_picker props', () => { - const result = ComponentPropsMap['element:record_picker'].parse({ - object: 'account', - labelField: 'name', - }); - expect(result.object).toBe('account'); + const result = ComponentPropsMap['element:record_picker'].parse({ labelField: 'name' }); + expect(result.labelField).toBe('name'); }); it('should parse element:text_input props', () => { @@ -3499,41 +3290,16 @@ describe('object-* block props schemas — declared, so the props gate has a sch expect(r.error!.issues.some((i) => i.path[0] === 'data')).toBe(true); }); - it('`defaultFilters` stays HONOURED — it is a read legacy fallback, not an inert spelling', () => { - // ObjectGrid.tsx reads it and lowers it to `$filter` when `filter` is - // absent (the routed finding on #7751 verified the read point). Only the - // plural `filters` has zero read points. - // - // [#19514] The VALUE this pin carries moved, and the pin's subject did not. - // The key is still honoured and still parses; what changed is that it now - // carries `filter`'s own declaration — the same value in the same role, - // read through the same lowering sink — instead of `z.unknown()`. The AST - // tuple array this pin used to spell is one the pinned objectui grid - // APPLIES (`toFilterNode` passes it through and `parseFilterAST` accepts - // it), so its refusal here is a spelling change for the author, not the - // repair of a filter that failed. Its refusal is pinned below, and in full - // at `component-object-grid-default-filters.pin.test.ts`. - const rules = [{ field: 'status', operator: 'equals', value: 'open' }]; - const parsed = ComponentPropsMap['object-grid'].parse({ - objectName: 'showcase_task', - defaultFilters: rules, - }); - expect(parsed.defaultFilters).toEqual(rules); - }); - - it('`defaultFilters` refuses the AST tuple array the `z.unknown()` door used to receipt', () => { - const r = ComponentPropsMap['object-grid'].safeParse({ - objectName: 'showcase_task', - defaultFilters: [['status', '=', 'open']], - }); - expect(r.success).toBe(false); - expect(r.error!.issues.some((i) => String(i.path[0]) === 'defaultFilters')).toBe(true); - }); + // `defaultFilters` — the grid's legacy base-filter fallback, read only when + // `filter` lowered to nothing — retired in v18 (#11509, ruling A-narrow, + // sub-question 1) in the shape `defaultSort` took below; #19514's rule-array + // narrowing of it is absorbed. Pinned in + // `element-flat-binding-retirement.test.ts`. // #11805 — the grid's legacy single-sort fallback, retired by maintainer // ruling 2026-08-25 (decision-inbox batch 4; the producer half of - // objectui#5861 under the objectui#4869 「接受所有」 direction). Unlike - // `defaultFilters` above — a read fallback that STAYS — `defaultSort` was + // objectui#5861 under the objectui#4869 「接受所有」 direction). Like + // `defaultFilters` above, which followed it in v18, `defaultSort` was // the second spelling of `sort` (read only when `sort` was absent, and // wrapped `[schema.defaultSort]` by the renderer's own header-arrow path), // so the one-intent-two-spellings rule retires it at the producer. diff --git a/packages/spec/src/ui/element-flat-binding-retirement.test.ts b/packages/spec/src/ui/element-flat-binding-retirement.test.ts new file mode 100644 index 00000000000..cbf0259b636 --- /dev/null +++ b/packages/spec/src/ui/element-flat-binding-retirement.test.ts @@ -0,0 +1,742 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #11509 (v18, ruling A-narrow) — the element layer's flat data-binding keys + * and `object-grid.defaultFilters` RETIRED; an element binds data through the + * node-level `dataSource` only. + * + * Retired: `element:record_picker` `object` / `filter` / `sort` / `limit`, + * `element:number` `object` / `filter`, `element:repeater` `object` / `filter` + * / `sort` / `limit` — each the same query as a key of + * `ElementDataSourceSchema`, resolved per renderer by three contradictory rules + * (the picker let the binding win, `element:number` AND-combined the two + * filters, the repeater read the flat keys alone) — and `object-grid`'s + * `defaultFilters`, the legacy second spelling of `filter`. objectui moved the + * three renderers onto the binding first (objectui#11880), the order the + * ruling set. + * + * Bookkeeping shapes, pinned below: + * 1. `retiredKey()` tombstones carrying the prescription; the input type of + * each key is `never`. `PageComponentSchema.properties` is an open bag, so + * the props rows are reached by the component-props lint, never by the + * page parse. + * 2. D2 conversions `element-flat-data-binding-to-data-source` and + * `object-grid-default-filters-removed` (step 18), retired from the load + * path, ordered BEFORE `page-component-filter-record-to-rule-array`. + * 3. Eleven `RETIRED_KEYS_BY_MAJOR[18]` rows and one D3 entry per family; the + * three step-18 narrowings of these keys are absorbed. + * 4. A tree-scoped absence walk over the radius this package declares. + * + * The lint half — the missing-binding refusal that replaced the type-blind + * waiver, and the repeater trap — is pinned in + * `packages/lint/src/validate-component-props.test.ts`. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues carry + * `code` and `path` but no ADR-0112 `status` — no HTTP door parses these rows. + * So each refusal is pinned by the issue `code`, the `path` naming the key, and + * the prescription's first sentence. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { applyConversions, collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import type { ConversionNotice, ConversionTodoNotice } from '../conversions/types'; +import { applyMetaMigrations } from '../migrations/chain'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { normalizeStackInput } from '../shared/metadata-collection.zod'; +import { + ComponentPropsMap, + ElementNumberPropsSchema, + ElementRecordPickerPropsSchema, + ElementRepeaterPropsSchema, + ObjectGridPropsSchema, +} from './component.zod'; +import { PageComponentSchema } from './page.zod'; + +type Dict = Record; + +const ELEMENT_CONVERSION = 'element-flat-data-binding-to-data-source'; +const GRID_CONVERSION = 'object-grid-default-filters-removed'; +const RECORD_FORM_CONVERSION = 'page-component-filter-record-to-rule-array'; +const MIGRATE_SENTENCE = + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + +/** The retired keys, per element — what the ruling names, written out. */ +const RETIRED: Readonly> = { + 'element:record_picker': ['object', 'filter', 'sort', 'limit'], + 'element:number': ['object', 'filter'], + 'element:repeater': ['object', 'filter', 'sort', 'limit'], +}; +/** Each element's smallest clean props bag, the binding aside. */ +const MINIMAL: Readonly> = { + 'element:record_picker': {}, + 'element:number': { aggregate: 'count' }, + 'element:repeater': {}, +}; +/** A legal value of each retired key — what the binding takes at the same key. */ +const VALUE: Readonly> = { + object: 'deal', + filter: [{ field: 'stage', operator: 'equals', value: 'open' }], + sort: [{ field: 'amount', order: 'desc' }], + limit: 20, +}; +const CASES = Object.entries(RETIRED).flatMap(([type, keys]) => keys.map((key) => [type, key] as const)); + +type Issue = { code: string; path: PropertyKey[]; message: string }; +type Parse = { success: boolean; data?: unknown; error?: { issues: Issue[] } }; +const row = (type: string) => ComponentPropsMap[type as keyof typeof ComponentPropsMap] as unknown as { + safeParse: (v: unknown) => Parse; + shape: Dict; +}; + +/** A one-component page, the component under test at `regions[0].components[0]`. */ +const pageWith = (component: Dict): Dict => ({ pages: [{ name: 'probe', regions: [{ name: 'main', components: [component] }] }] }); +const componentOf = (stack: Dict): Dict => + ((((stack.pages as Dict[])[0]!.regions as Dict[])[0]!.components as Dict[])[0]!); + +/** The whole chain, retired entries included — as the data-at-rest seams replay it. */ +function convert(stack: Dict): { stack: Dict; notices: ConversionNotice[]; todos: ConversionTodoNotice[] } { + const notices: ConversionNotice[] = []; + const todos: ConversionTodoNotice[] = []; + const out = applyConversions(structuredClone(stack), { + includeRetired: true, + onNotice: (n) => notices.push(n), + onTodo: (t) => todos.push(t), + }); + return { stack: out, notices, todos }; +} +const brief = (n: ConversionNotice) => [n.conversionId, n.path, n.from, n.to]; + +describe('the element tombstones — refused at the key, with the prescription', () => { + it.each(CASES)('%s `%s`', (type, key) => { + const r = row(type).safeParse({ ...MINIMAL[type], [key]: VALUE[key] }); + expect(r.success).toBe(false); + const issues = r.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('invalid_type'); + expect(issues[0]!.path).toEqual([key]); + const message = issues[0]!.message; + // House convention 1 + 2: the qualified key opens it, then the release and ADR. + expect(message.startsWith(`\`${type}\` property \`${key}\` was removed in @objectstack/spec 18 (ADR-0087 D2) — `)).toBe(true); + // The live mechanism, named at its own key on the node. + expect(message).toContain(`Use \`dataSource.${key}\` on the component node, a sibling of \`type\``); + expect(message.endsWith(MIGRATE_SENTENCE)).toBe(true); + }); + + it('each element\'s prescription says what to do with a key the binding already sets — by that element\'s old rule', () => { + const message = (type: string, key: string) => + row(type).safeParse({ ...MINIMAL[type], [key]: VALUE[key] }).error!.issues[0]!.message; + expect(message('element:record_picker', 'limit')).toContain('delete this one, since the binding\'s value always won'); + expect(message('element:number', 'object')).toContain('delete this one, since the binding\'s value always won'); + expect(message('element:number', 'filter')).toContain('append these to it, since the two always AND-combined'); + expect(message('element:repeater', 'sort')).toContain('keep the value written here, which is the one the list honoured'); + }); + + it('refuses by the TOMBSTONE, not by the strict unknown-key arm — the two are different answers', () => { + const retired = row('element:number').safeParse({ aggregate: 'count', object: 'deal' }); + expect(retired.error!.issues.map((i) => i.code)).not.toContain('unrecognized_keys'); + // CONTROL: an undeclared sibling comes back as `unrecognized_keys`. + const undeclared = row('element:number').safeParse({ aggregate: 'count', zzzNotAKey: 'deal' }); + expect(undeclared.error!.issues.map((i) => i.code)).toContain('unrecognized_keys'); + }); + + it('the walked shapes keep each tombstone as a key — the authorable-surface `[RETIRED]` rows', () => { + for (const [type, keys] of Object.entries(RETIRED)) { + for (const key of keys) expect(Object.keys(row(type).shape), `${type}.${key}`).toContain(key); + } + }); + + it('fails tsc at the authoring site: the input type of each flat `object` is `never`', () => { + // @ts-expect-error — `object` is a retiredKey() tombstone on `element:number`. + const number: z.input = { aggregate: 'count', object: 'deal' }; + // @ts-expect-error — `object` is a retiredKey() tombstone on `element:record_picker`. + const picker: z.input = { object: 'deal' }; + // @ts-expect-error — `object` is a retiredKey() tombstone on `element:repeater`. + const repeater: z.input = { object: 'deal' }; + // The parse channel agrees with the type channel on the same literals. + for (const [schema, value] of [[ElementNumberPropsSchema, number], [ElementRecordPickerPropsSchema, picker], [ElementRepeaterPropsSchema, repeater]] as const) { + expect((schema as unknown as { safeParse: (v: unknown) => Parse }).safeParse(value).success).toBe(false); + } + }); + + it.each([ + ['objectName', 'object'], + ['filters', 'filter'], + ['where', 'filter'], + ['orderBy', 'sort'], + ['sortBy', 'sort'], + ['top', 'limit'], + ['pageSize', 'limit'], + ])('the repeater\'s `%s` spelling is pointed at the binding\'s `%s`, never at the tombstone', (spelling, key) => { + const r = row('element:repeater').safeParse({ [spelling]: VALUE[key] }); + const issue = r.error!.issues.find((i) => i.code === 'unrecognized_keys')!; + expect(issue.message).toContain(`Write it as \`dataSource.${key}\` on the component node`); + expect(issue.message).not.toContain(`Did you mean \`${key}\``); + }); +}); + +describe('the binding — the one door the three elements read', () => { + it.each(Object.keys(RETIRED))('%s: the node with its query on `dataSource` parses, and its props grow no retired key', (type) => { + const binding = type === 'element:number' + ? { object: 'deal', view: 'won_deals', filter: VALUE.filter } + : { object: 'deal', view: 'hot_deals', filter: VALUE.filter, sort: VALUE.sort, limit: 20 }; + const r = PageComponentSchema.safeParse({ type, dataSource: binding, properties: MINIMAL[type] }); + expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); + const props = row(type).safeParse(MINIMAL[type]); + expect(props.success).toBe(true); + for (const key of RETIRED[type]!) expect(props.data as Dict).not.toHaveProperty(key); + }); + + it('never hard-refuses an existing page: the page parse still accepts a node carrying a flat key', () => { + // The open `properties` bag is the props lint's to judge (advisory), not the page parse's. + const r = PageComponentSchema.safeParse({ type: 'element:repeater', properties: { object: 'deal' } }); + expect(r.success).toBe(true); + }); +}); + +describe('`object-grid.defaultFilters` — refused at the key, with the prescription', () => { + it.each([ + ['a rule array', [{ field: 'status', operator: 'equals', value: 'open' }]], + ['the record form', { status: 'open' }], + ['an empty array', []], + ])('refuses %s', (_label, value) => { + const r = (ObjectGridPropsSchema as unknown as { safeParse: (v: unknown) => Parse }) + .safeParse({ objectName: 'deal', defaultFilters: value }); + expect(r.success).toBe(false); + const at = r.error!.issues.filter((i) => i.path[0] === 'defaultFilters'); + expect(at).toHaveLength(1); + expect(at[0]!.code).toBe('invalid_type'); + expect(at[0]!.path).toEqual(['defaultFilters']); + expect(at[0]!.message.startsWith('`object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — ')).toBe(true); + expect(at[0]!.message).toContain('Use `filter`.'); + expect(at[0]!.message.endsWith(MIGRATE_SENTENCE)).toBe(true); + }); + + it('CONTROL: the live mechanism, `filter`, takes the same rule array', () => { + const rules = [{ field: 'status', operator: 'equals', value: 'open' }]; + const r = (ObjectGridPropsSchema as unknown as { safeParse: (v: unknown) => Parse }).safeParse({ objectName: 'deal', filter: rules }); + expect(r.success).toBe(true); + expect(r.data as Dict).not.toHaveProperty('defaultFilters'); + }); +}); + +describe('`element-flat-data-binding-to-data-source` — each element\'s old rule, made mechanical', () => { + it('no binding: every flat key moves onto a new one, unchanged', () => { + for (const [type, keys] of Object.entries(RETIRED)) { + const flat = Object.fromEntries(keys.map((k) => [k, VALUE[k]])); + const { stack, notices, todos } = convert(pageWith({ type, properties: { ...MINIMAL[type], ...flat } })); + const component = componentOf(stack); + expect(component.properties, type).toEqual(MINIMAL[type]); + expect(component.dataSource, type).toEqual(flat); + expect(notices.map(brief), type).toEqual(keys.map((k) => [ + ELEMENT_CONVERSION, `pages[0].regions[0].components[0].dataSource.${k}`, `properties.${k}`, `dataSource.${k}`, + ])); + expect(todos, type).toEqual([]); + // What the conversion writes is what the node door takes. + expect(PageComponentSchema.safeParse(component).success, type).toBe(true); + expect(row(type).safeParse(component.properties).success, type).toBe(true); + } + }); + + it('the record picker: a key the binding already set is DELETED (the binding always won), a missing one moves', () => { + const { stack, notices } = convert(pageWith({ + type: 'element:record_picker', + dataSource: { object: 'deal', limit: 10 }, + properties: { object: 'lead', limit: 50, sort: VALUE.sort, labelField: 'name' }, + })); + const component = componentOf(stack); + expect(component.dataSource).toEqual({ object: 'deal', limit: 10, sort: VALUE.sort }); + expect(component.properties).toEqual({ labelField: 'name' }); + expect(notices.map((n) => [n.path, n.to])).toEqual([ + ['pages[0].regions[0].components[0].properties.object', '(removed)'], + ['pages[0].regions[0].components[0].dataSource.sort', 'dataSource.sort'], + ['pages[0].regions[0].components[0].properties.limit', '(removed)'], + ]); + }); + + it('the record picker beside a saved `view`: a key the binding lacks is a TODO, left as stored', () => { + const before = pageWith({ + type: 'element:record_picker', + id: 'picker', + dataSource: { object: 'deal', view: 'hot_deals' }, + properties: { filter: VALUE.filter, limit: 20 }, + }); + const { stack, notices, todos } = convert(before); + expect(componentOf(stack)).toEqual(componentOf(before)); + expect(notices).toEqual([]); + expect(todos.map((t) => [t.conversionId, t.path])).toEqual([ + [ELEMENT_CONVERSION, 'pages[0].regions[0].components[0].properties.filter'], + [ELEMENT_CONVERSION, 'pages[0].regions[0].components[0].properties.limit'], + ]); + expect(todos[0]!.reason).toMatch(/^On the `element:record_picker` block `picker`, this flat `filter` sits beside `dataSource\.view: 'hot_deals'`/); + expect(todos[0]!.reason.endsWith('Left as stored, this key reaches no query.')).toBe(true); + // …while its `object` is decided whatever the view says: the binding names one. + const objectToo = convert(pageWith({ + type: 'element:record_picker', + dataSource: { object: 'deal', view: 'hot_deals' }, + properties: { object: 'deal' }, + })); + expect(objectToo.todos).toEqual([]); + expect(componentOf(objectToo.stack).properties).toEqual({}); + }); + + it('`element:number`: the two filters AND-combined, so the flat rules are APPENDED to the binding\'s', () => { + const own = [{ field: 'stage', operator: 'equals', value: 'won' }]; + const { stack, notices } = convert(pageWith({ + type: 'element:number', + dataSource: { object: 'deal', filter: own, view: 'this_quarter' }, + properties: { aggregate: 'count', object: 'lead', filter: VALUE.filter }, + })); + const component = componentOf(stack); + expect(component.dataSource).toEqual({ object: 'deal', view: 'this_quarter', filter: [...own, ...(VALUE.filter as Dict[])] }); + expect(component.properties).toEqual({ aggregate: 'count' }); + expect(notices.map((n) => n.to)).toEqual(['(removed)', 'dataSource.filter (rules appended; they AND)']); + }); + + it('`element:number`: a filter pair that is not two rule arrays is a TODO, left as stored', () => { + const before = pageWith({ + type: 'element:number', + dataSource: { object: 'deal', filter: [['stage', '=', 'won']] }, + properties: { aggregate: 'count', filter: VALUE.filter }, + }); + const { stack, todos } = convert(before); + expect((componentOf(stack).properties as Dict).filter).toEqual(VALUE.filter); + expect(todos.filter((t) => t.conversionId === ELEMENT_CONVERSION).map((t) => t.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.filter', + ]); + }); + + it('the repeater: it read the flat keys alone, so an EQUAL binding value is deleted, a DIFFERENT one or a `view` is a TODO', () => { + const { stack, notices, todos } = convert(pageWith({ + type: 'element:repeater', + dataSource: { object: 'deal', view: 'open_deals', limit: 5 }, + properties: { object: 'deal', limit: 10, filter: VALUE.filter, titleField: 'name' }, + })); + const component = componentOf(stack); + expect(component.dataSource).toEqual({ object: 'deal', view: 'open_deals', limit: 5 }); + expect(component.properties).toEqual({ limit: 10, filter: VALUE.filter, titleField: 'name' }); + expect(notices.map((n) => [n.path, n.to])).toEqual([['pages[0].regions[0].components[0].properties.object', '(removed)']]); + expect(todos.map((t) => t.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.filter', + 'pages[0].regions[0].components[0].properties.limit', + ]); + expect(todos[0]!.reason.startsWith('On the `element:repeater` block, the binding names the saved view `open_deals`')).toBe(true); + expect(todos[1]!.reason.startsWith('On the `element:repeater` block, `dataSource.limit` is set to a different value')).toBe(true); + }); + + it('is scoped by component TYPE: the same keys on another element are not this entry\'s', () => { + const before = pageWith({ type: 'element:metadata_viewer', properties: { type: 'flow', name: 'approve', object: 'deal' } }); + const { stack, notices } = collectConversionNotices(before, { includeRetired: true }); + expect(notices).toEqual([]); + expect(stack).toBe(before); + }); + + it('is idempotent by construction: a second replay converts nothing', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === ELEMENT_CONVERSION)!; + const once = collectConversionNotices(structuredClone(entry.fixture.before), { includeRetired: true }); + const twice = collectConversionNotices(once.stack, { includeRetired: true }); + expect(twice.notices).toEqual([]); + expect(twice.stack).toBe(once.stack); + }); + + it('the list it moves is the spec\'s own: exactly the keys `ComponentPropsMap` tombstones onto `dataSource.`', () => { + const tombstoned: string[] = []; + const moved: string[] = []; + for (const type of Object.keys(ComponentPropsMap)) { + for (const key of ['object', 'filter', 'sort', 'limit']) { + const r = row(type).safeParse({ [key]: VALUE[key] }); + const at = r.success ? [] : r.error!.issues.filter((i) => i.path.length === 1 && i.path[0] === key); + if (at.some((i) => i.message.includes('was removed') && i.message.includes(`\`dataSource.${key}\``))) { + tombstoned.push(`${type}:${key}`); + } + const { stack } = convert(pageWith({ type, properties: { [key]: VALUE[key] } })); + if ((componentOf(stack).dataSource as Dict | undefined)?.[key] !== undefined) moved.push(`${type}:${key}`); + } + } + expect(tombstoned.sort()).toEqual(CASES.map(([t, k]) => `${t}:${k}`).sort()); + expect(moved.sort()).toEqual(tombstoned.sort()); + }); +}); + +describe('`object-grid-default-filters-removed` — the shape `defaultSort`\'s retirement took', () => { + const RULES = [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]; + const grid = (properties: Dict) => pageWith({ type: 'object-grid', id: 'g', properties: { objectName: 'deal', ...properties } }); + + it.each([ + ['absent', {}], + ['null', { filter: null }], + ['an empty rule array', { filter: [] }], + ['an empty record', { filter: {} }], + ])('`filter` %s — the fallback WAS the filter, so it moves', (_label, filter) => { + const { stack, notices } = convert(grid({ ...filter, defaultFilters: RULES })); + expect(componentOf(stack).properties).toEqual({ objectName: 'deal', filter: RULES }); + expect(notices.map(brief)).toEqual([[GRID_CONVERSION, 'pages[0].regions[0].components[0].properties.filter', 'defaultFilters', 'filter']]); + }); + + it.each([ + ['a rule array', [{ field: 'status', operator: 'equals', value: 'open' }]], + ['the record form', { status: 'open' }], + ])('`filter` with content (%s) — the fallback was never read, so it is deleted', (_label, filter) => { + const { stack, notices } = convert(grid({ filter, defaultFilters: RULES })); + expect(componentOf(stack).properties).not.toHaveProperty('defaultFilters'); + expect(notices.filter((n) => n.conversionId === GRID_CONVERSION).map((n) => [n.path, n.to])).toEqual([ + ['pages[0].regions[0].components[0].properties.defaultFilters', '(removed)'], + ]); + }); + + it('an empty fallback carries nothing, so it is deleted whatever `filter` holds', () => { + const { stack } = convert(grid({ defaultFilters: [] })); + expect(componentOf(stack).properties).toEqual({ objectName: 'deal' }); + }); + + it('`filter` a value no lowering reads — a TODO, left as stored, never overwritten', () => { + const before = grid({ filter: 'status = open', defaultFilters: RULES }); + const { stack, notices, todos } = convert(before); + expect(componentOf(stack)).toEqual(componentOf(before)); + expect(notices).toEqual([]); + expect(todos.map((t) => [t.conversionId, t.path])).toEqual([[GRID_CONVERSION, 'pages[0].regions[0].components[0].properties.defaultFilters']]); + }); + + it('runs BEFORE the record-form conversion: a record-form fallback moves onto `filter` and is converted there', () => { + const order = CONVERSIONS_BY_MAJOR[18]!.map((c) => c.id); + expect(order.indexOf(GRID_CONVERSION)).toBeLessThan(order.indexOf(RECORD_FORM_CONVERSION)); + expect(order.indexOf(ELEMENT_CONVERSION)).toBeLessThan(order.indexOf(RECORD_FORM_CONVERSION)); + const { stack, notices } = convert(grid({ defaultFilters: { owner_id: '{current_user_id}' } })); + expect(componentOf(stack).properties).toEqual({ objectName: 'deal', filter: RULES }); + expect(notices.map((n) => n.conversionId)).toEqual([GRID_CONVERSION, RECORD_FORM_CONVERSION]); + }); + + it('is scoped by component TYPE: `defaultFilters` on another block is not this entry\'s', () => { + const before = pageWith({ type: 'object-kanban', properties: { objectName: 'deal', defaultFilters: RULES } }); + const { stack } = collectConversionNotices(before, { includeRetired: true }); + expect(stack).toBe(before); + }); +}); + +describe('the jurisdiction — retired from authoring, replayed at rest and by the chain', () => { + const page = { + name: 'deal_desk', + regions: [{ + name: 'main', + components: [ + { type: 'element:repeater', properties: { object: 'deal', limit: 5 } }, + { type: 'object-grid', properties: { objectName: 'deal', defaultFilters: [{ field: 'stage', operator: 'equals', value: 'open' }] } }, + ], + }], + }; + + it('⛔ the authoring funnel does not replay either — an author is refused at the parse instead', () => { + const notices: ConversionNotice[] = []; + const out = normalizeStackInput({ pages: [structuredClone(page)] }, { onConversionNotice: (n) => notices.push(n) }); + expect((out.pages as Dict[])[0]).toEqual(page); + expect(notices.filter((n) => n.conversionId === ELEMENT_CONVERSION || n.conversionId === GRID_CONVERSION)).toEqual([]); + }); + + it('the stored-row seam replays both', () => { + const stored = applyConversionsToStoredItem('page', structuredClone(page)) as typeof page; + const [repeater, gridNode] = stored.regions[0]!.components as Dict[]; + expect(repeater).toEqual({ type: 'element:repeater', properties: {}, dataSource: { object: 'deal', limit: 5 } }); + expect(gridNode!.properties).toEqual({ objectName: 'deal', filter: [{ field: 'stage', operator: 'equals', value: 'open' }] }); + }); + + it('`os migrate meta --from 17` replays both and lists the edits', () => { + const result = applyMetaMigrations({ pages: [structuredClone(page)] }, 17, 18); + const applied = result.applied.map((a) => a.conversionId); + expect(applied.filter((id) => id === ELEMENT_CONVERSION)).toHaveLength(2); + expect(applied.filter((id) => id === GRID_CONVERSION)).toHaveLength(1); + }); +}); + +describe('the ADR-0087 ledger rows', () => { + it('declares the eleven keys under major 18, and no other major', () => { + const keys = [ + ...CASES.map(([type, key]) => { + const def = { 'element:record_picker': 'ElementRecordPickerProps', 'element:number': 'ElementNumberProps', 'element:repeater': 'ElementRepeaterProps' }[type]!; + return `ui/${def}:${key}`; + }), + 'ui/ObjectGridProps:defaultFilters', + ]; + expect(keys).toHaveLength(11); + for (const key of keys) { + expect(RETIRED_KEYS_BY_MAJOR[18], key).toContain(key); + for (const [major, list] of Object.entries(RETIRED_KEYS_BY_MAJOR)) { + if (major !== '18') expect(list, `${key} @ ${major}`).not.toContain(key); + } + } + }); + + it('wires both D2 conversions into step 18 as retired, stamped entries', () => { + for (const id of [ELEMENT_CONVERSION, GRID_CONVERSION]) { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(id); + const conversion = ALL_CONVERSIONS.find((c) => c.id === id)!; + expect(conversion.toMajor).toBe(18); + expect(conversion.retiredFromLoadPath).toBe(true); + } + }); + + it('carries one D3 entry per family, naming its D2 conversion — and the absorbed narrowings are gone', () => { + const semantic = MIGRATIONS_BY_MAJOR[18]!.semantic; + for (const [id, conversion] of [['element-flat-data-binding-retired', ELEMENT_CONVERSION], ['object-grid-default-filters-retired', GRID_CONVERSION]] as const) { + const entries = semantic.filter((s) => s.id === id); + expect(entries, id).toHaveLength(1); + expect(entries[0]!.conversionIds).toEqual([conversion]); + expect(entries[0]!.reason).toContain(`\`${conversion}\``); + expect(entries[0]!.acceptanceCriteria.length).toBeGreaterThan(0); + } + const everyId = Object.values(MIGRATIONS_BY_MAJOR).flatMap((step) => step.semantic.map((s) => s.id)); + for (const absorbed of ['object-grid-default-filters-rule-array', 'element-number-filter-rule-array', 'element-record-picker-filter-rule-array']) { + expect(everyId, absorbed).not.toContain(absorbed); + } + }); +}); + +// ─── Tree-scoped absence, inside the radius the package already declares ─── +// +// `tsc` sweeps only TYPED authoring sites, and a page component's `properties` +// is an open bag, so it does not reach a node authored through +// `definePage` / `defineStack`, a YAML fence or a JSON export at all. This walk +// covers every text file under the repo roots `scripts/cross-package-test-inputs.mjs` +// declares for `@objectstack/spec#test` (mirrored in `turbo.json`) — the radius +// the sibling retirement pins walk — plus the example apps' own `src/` trees. +// +// The matchers judge the AUTHORING SHAPE, never a mention: +// - an element node — `type` naming one of the three elements — whose +// `properties` carries a retired key at its own level: in an object +// literal or JSON (shorthand included), in YAML by indentation, and in a +// JSX / HTML tag's attributes; +// - `defaultFilters` in key position with a literal value (`[`, `{`, or a +// YAML block), or as a tag attribute. +// The keys themselves are four of the most common words in metadata, so a +// bare-key matcher would be noise: the element node is the anchor. Inline code +// spans are prose and are stripped before judging; fenced examples are judged. +// The bound, stated: a node whose `type` is not a literal, or whose +// `properties` is built elsewhere and spread in, and the `docs/**`, +// `.claude/**`, `.github/**` and repo-root files, are outside what this walk +// sees. +describe('tree-scoped absence: nothing inside the declared radius still authors a retired key', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml', '.html']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const ELEMENT = 'element:(?:record_picker|number|repeater)'; + const KEYS = '(?:object|filter|sort|limit)'; + /** `type: 'element:…'` in an object literal or JSON. */ + const LITERAL_TYPE = new RegExp(`(^|[^\\w.$])["']?type["']?[ \\t]*:[ \\t]*["']${ELEMENT}["']`, 'gm'); + /** `type: element:…` as a YAML mapping key, its indentation captured (a list dash counts as indent). */ + const YAML_TYPE = new RegExp(`^([ \\t]*(?:-[ \\t]+)?)type:[ \\t]*["']?${ELEMENT}["']?[ \\t]*$`, 'gm'); + /** A JSX / HTML tag naming one of the three elements. */ + const TAG = new RegExp(`<[A-Za-z][\\w.:-]*\\b[^<>]*\\btype=["']${ELEMENT}["'][^<>]*>`, 'g'); + /** A retired key at a literal's own level: `key:` (quoted or bare), or a shorthand `key`. */ + const OWN_LEVEL_KEY = new RegExp(`(^|[^\\w.$])["']?${KEYS}["']?[ \\t]*:|(^|,)[ \\t\\n]*${KEYS}[ \\t\\n]*(?=,|$)`); + const DEFAULT_FILTERS = /(^|[^\w.$])["']?defaultFilters["']?[ \t]*:[ \t]*(\[|\{|$)|\sdefaultFilters=/m; + + /** Inline code spans are prose; newline-bounded, so a fenced example is still judged. */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + + /** The `{` that opens the object literal enclosing `at`, or -1. */ + const literalStart = (text: string, at: number): number => { + for (let i = at - 1, depth = 0; i >= 0; i -= 1) { + const c = text[i]; + if (c === '}' || c === ']') depth += 1; + else if (c === '{' || c === '[') { + if (depth === 0) return c === '{' ? i : -1; + depth -= 1; + } + } + return -1; + }; + /** The index of the bracket closing the one opened at `open`, or the text's end. */ + const groupEnd = (text: string, open: number): number => { + for (let i = open + 1, depth = 0; i < text.length; i += 1) { + const c = text[i]; + if (c === '{' || c === '[') depth += 1; + else if (c === '}' || c === ']') { + if (depth === 0) return i; + depth -= 1; + } + } + return text.length; + }; + /** + * A group's own level: its text with every nested `{…}` / `[…]` removed, and + * every quoted VALUE emptied (a quoted string not followed by `:`), so a + * string that merely contains `filter:` is not read as the key. + */ + const ownLevel = (text: string, open: number): string => { + const end = groupEnd(text, open); + let own = ''; + for (let i = open + 1, depth = 0; i < end; i += 1) { + const c = text[i]!; + if (c === '{' || c === '[') depth += 1; + else if (c === '}' || c === ']') depth -= 1; + else if (depth === 0) own += c; + } + return own.replace(/(["'])(?:\\.|(?!\1)[^\n])*\1(?![ \t]*:)/g, "''"); + }; + /** The `properties` group of the literal opened at `start`, judged at its own level. */ + const literalPropsAuthorRetired = (text: string, start: number): boolean => { + const end = groupEnd(text, start); + const PROPS = /["']?properties["']?[ \t]*:[ \t]*\{/g; + for (let depth = 0, i = start + 1; i < end; i += 1) { + const c = text[i]; + if (c === '{' || c === '[') { depth += 1; continue; } + if (c === '}' || c === ']') { depth -= 1; continue; } + if (depth !== 0) continue; + PROPS.lastIndex = i; + const m = PROPS.exec(text); + if (m && m.index === i && /[^\w.$]/.test(text[i - 1] ?? ' ')) { + return OWN_LEVEL_KEY.test(ownLevel(text, i + m[0].length - 1)); + } + } + return false; + }; + /** In YAML, the `properties` mapping beside the `type` line, judged by indentation. */ + const yamlPropsAuthorRetired = (lines: readonly string[], typeLine: number, keyIndent: number): boolean => { + for (let i = typeLine + 1; i < lines.length; i += 1) { + const line = lines[i]!; + if (line.trim() === '') continue; + const indent = line.length - line.trimStart().length; + if (indent < keyIndent) return false; + if (indent !== keyIndent) continue; + const props = /^properties:[ \t]*(.*)$/.exec(line.trimStart()); + if (!props) continue; + if (props[1]!.startsWith('{')) return OWN_LEVEL_KEY.test(ownLevel(props[1]!, 0)); + for (let j = i + 1, child = -1; j < lines.length; j += 1) { + const inner = lines[j]!; + if (inner.trim() === '') continue; + const innerIndent = inner.length - inner.trimStart().length; + if (innerIndent <= keyIndent) return false; + if (child < 0) child = innerIndent; + if (innerIndent === child && new RegExp(`^${KEYS}:`).test(inner.trimStart())) return true; + } + return false; + } + return false; + }; + + const judge = (raw: string): string | null => { + const text = stripInlineCode(raw); + for (const m of text.matchAll(LITERAL_TYPE)) { + const start = literalStart(text, m.index! + m[1]!.length); + if (start >= 0 && literalPropsAuthorRetired(text, start)) return m[0].trim(); + } + const lines = text.split('\n'); + for (const m of text.matchAll(YAML_TYPE)) { + const typeLine = text.slice(0, m.index!).split('\n').length - 1; + if (yamlPropsAuthorRetired(lines, typeLine, m[1]!.length)) return m[0].trim(); + } + for (const m of text.matchAll(TAG)) { + if (new RegExp(`\\s${KEYS}=`).test(m[0])) return m[0]; + } + const grid = DEFAULT_FILTERS.exec(text); + return grid ? grid[0].trim() : null; + }; + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell a retired key. + */ + const EXCLUDED = new Set([ + // This pin authors the keys to assert their refusal and their conversion. + THIS_FILE, + // The props-lint pin authors them to assert the finding an author meets. + 'packages/lint/src/validate-component-props.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversions' fixtures and tests author the pre-retirement shapes on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output, reached only because this is a FILESYSTEM walk. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matchers recognise an authoring in every syntax, and ignore a mention, a binding and a declaration (anti-vacuity)', () => { + // Offenders. + expect(judge("{ type: 'element:record_picker', properties: { object: 'deal', labelField: 'name' } }")).not.toBeNull(); + expect(judge("{\n type: 'element:number',\n id: 'kpi',\n properties: {\n aggregate: 'count',\n filter: [],\n },\n}")).not.toBeNull(); + expect(judge("{ properties: { limit, titleField: 'name' }, type: 'element:repeater' }")).not.toBeNull(); + expect(judge('{ "type": "element:repeater", "properties": { "sort": [] } }')).not.toBeNull(); + expect(judge('components:\n - type: element:number\n properties:\n aggregate: count\n object: deal\n')).not.toBeNull(); + expect(judge(' - type: element:repeater\n properties: { object: deal }\n')).not.toBeNull(); + expect(judge('')).not.toBeNull(); + expect(judge("{ type: 'object-grid', properties: { objectName: 'deal', defaultFilters: [] } }")).not.toBeNull(); + expect(judge('object-grid:\n defaultFilters:\n - field: stage\n')).not.toBeNull(); + // Neighbours that must NOT match. + expect(judge("{ type: 'element:record_picker', dataSource: { object: 'deal', limit: 50 }, properties: { labelField: 'name' } }")).toBeNull(); + expect(judge("{ type: 'element:number', properties: { aggregate: 'count', format: 'number' }, dataSource: { object: 'deal', filter: [] } }")).toBeNull(); + expect(judge("{ type: 'element:repeater', properties: { fields: [{ field: 'object' }], emptyText: 'filter: none' } }")).toBeNull(); + expect(judge("{ type: 'element:metadata_viewer', properties: { type: 'flow', name: 'x', object: 'deal' } }")).toBeNull(); + expect(judge("{ type: 'object-grid', properties: { objectName: 'deal', filter: [], sort: [], limit: 5 } }")).toBeNull(); + expect(judge('a flat `properties: { object: deal }` on an `element:number` is refused')).toBeNull(); + expect(judge(' - type: element:number\n dataSource:\n object: deal\n properties:\n aggregate: count\n')).toBeNull(); + expect(judge('defaultFilters: retiredKey(\n')).toBeNull(); + expect(judge("const { defaultFilters, ...rest } = properties;")).toBeNull(); + expect(judge('"ui/ObjectGridProps:defaultFilters",')).toBeNull(); + }); + + it('no retired key is authored inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const hit = judge(text); + if (hit) offenders.push(`${rel} authors \`${hit.replace(/\s+/g, ' ').slice(0, 120)}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree and the example apps' sources. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(offenders, 'an authored retired key means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/filter-rule-array-guidance.test.ts b/packages/spec/src/ui/filter-rule-array-guidance.test.ts index bd018cbc641..9688b2c53a2 100644 --- a/packages/spec/src/ui/filter-rule-array-guidance.test.ts +++ b/packages/spec/src/ui/filter-rule-array-guidance.test.ts @@ -1,8 +1,11 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The ten converged rule-array `filter` doors name the new spelling when - * they refuse the old one. + * The converged rule-array `filter` doors name the new spelling when they + * refuse the old one — eight of them since v18, when `element:number`'s and + * `element:record_picker`'s flat `filter` (two of the original ten) retired + * onto the binding's own door, `dataSource.filter` (#11509); a retired door + * answers every value with its removal prescription instead. * * Seven doors converged on `z.array(ViewFilterRuleSchema)` (the objectui#6206 * family) and each one refused the record form an author used to write with a @@ -39,7 +42,7 @@ import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; const RECORD_FORM = { status: 'active' } as const; /** - * The ten doors, each with enough sibling props to reach a clean reading — + * The doors, each with enough sibling props to reach a clean reading — * the other required keys are filled so the only issue under test is `filter`. * * Seven at the convergence; `object-map`, `object-gantt` and `object-tree` @@ -105,19 +108,6 @@ const DOORS: readonly { migration: 'element-data-source-and-object-block-filter-rule-array', parse: (filter) => ComponentPropsMap['object-tree'].safeParse({ objectName: 'task', filter }), }, - { - name: "ComponentPropsMap['element:number'].filter", - surface: 'this `element:number`', - migration: 'element-number-filter-rule-array', - parse: (filter) => - ComponentPropsMap['element:number'].safeParse({ object: 'task', aggregate: 'count', filter }), - }, - { - name: "ComponentPropsMap['element:record_picker'].filter", - surface: 'this `element:record_picker`', - migration: 'element-record-picker-filter-rule-array', - parse: (filter) => ComponentPropsMap['element:record_picker'].safeParse({ object: 'task', filter }), - }, ]; /** The one issue raised at the `filter` key itself. */ From feec7a01c7d2d7265323081a763bf7855211b162 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:05:33 +0000 Subject: [PATCH 05/16] wip: changeset for the element-binding retirement Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../11509-element-flat-binding-retired.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .changeset/11509-element-flat-binding-retired.md diff --git a/.changeset/11509-element-flat-binding-retired.md b/.changeset/11509-element-flat-binding-retired.md new file mode 100644 index 00000000000..cb03d665eb1 --- /dev/null +++ b/.changeset/11509-element-flat-binding-retired.md @@ -0,0 +1,42 @@ +--- +'@objectstack/spec': major +'@objectstack/lint': major +--- + +feat(spec)!: an element binds data through the node-level `dataSource` only — the flat `object` / `filter` / `sort` / `limit` of `element:record_picker`, `element:number` and `element:repeater`, and `object-grid.defaultFilters`, are retired (#11509) + +Clause-②: no (narrowing: the ten element-layer flat data-binding keys and `object-grid.defaultFilters` leave the accept set, and the component-props gate's `dataSource.object` waiver becomes a refusal) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). `@objectstack/lint` is `major` with it: its component-props rule refuses what it used to waive. + +**Why.** One page element carried two doors onto one query. Each of these flat keys was the same query as a key of the node-level `dataSource` binding (`ElementDataSourceSchema`), and the three renderers resolved them by three contradictory rules: the record picker let the binding win, `element:number` AND-combined the two filters, and the repeater read the flat keys alone and ignored the binding — while the component-props rule waived a missing flat `object` whenever `dataSource.object` was present, so a repeater bound only through `dataSource` passed `os validate` and drew "No records". The console moved all three elements onto the binding first (objectui#11880); from this release the binding is the one door. `object-grid.defaultFilters` was the legacy second spelling of `filter`, read only when `filter` lowered to nothing. + +**What changes.** + +- **`@objectstack/spec`.** `ElementRecordPickerPropsSchema` `object` / `filter` / `sort` / `limit`, `ElementNumberPropsSchema` `object` / `filter`, `ElementRepeaterPropsSchema` `object` / `filter` / `sort` / `limit` and `ObjectGridPropsSchema` `defaultFilters` are `retiredKey()` tombstones: each is refused at its key with the prescription, and its input type is `never`. The repeater's other spellings of its query keys (`objectName`, `where`, `orderBy`, `top`, …) now point at the binding. The `object-*` blocks keep their own keys, and the relationship-scoped blocks are unchanged. +- **`@objectstack/lint`.** `validate-component-props` (`component-props-invalid`, warning) reports an `element:record_picker`, `element:number` or `element:repeater` node with no `dataSource.object` at that path, instead of waiving the flat `object` for any type whose binding names one. A flat key is reported by its tombstone. +- **Conversions (stored rows, artifacts, `os migrate meta --from 17`).** `element-flat-data-binding-to-data-source` moves a flat key the binding lacks onto it, deletes one the binding already set where the binding won, and appends `element:number`'s flat filter to the binding's (they always AND-combined). It leaves for the author, as a TODO: a record-picker key beside a `dataSource.view` that sets no such key of its own, a repeater key the binding sets to a different value or beside a `view`, and an `element:number` filter pair that is not two rule arrays. `object-grid-default-filters-removed` moves `defaultFilters` onto an empty `filter` (absent, `null`, `[]` or `{}`) and deletes it beside a `filter` that has rules. Both run before `page-component-filter-record-to-rule-array`, which then converts a moved record-form filter at its new door. Both are retired from the load path: an author is refused at the parse. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `{ type: 'element:record_picker', properties: { object: 'deal', limit: 20, labelField: 'name' } }` | `{ type: 'element:record_picker', dataSource: { object: 'deal', limit: 20 }, properties: { labelField: 'name' } }` | +| `{ type: 'element:number', properties: { object: 'deal', aggregate: 'count', filter: [...] } }` | `{ type: 'element:number', dataSource: { object: 'deal', filter: [...] }, properties: { aggregate: 'count' } }` | +| `{ type: 'element:repeater', properties: { object: 'deal_note', sort: [...], titleField: 'subject' } }` | `{ type: 'element:repeater', dataSource: { object: 'deal_note', sort: [...] }, properties: { titleField: 'subject' } }` | +| `object-grid` `properties: { defaultFilters: [...] }` (no `filter`) | `properties: { filter: [...] }` | +| `object-grid` `properties: { filter: [...], defaultFilters: [...] }` | `properties: { filter: [...] }` — the fallback was never read beside a `filter` with rules | + +**The one-line fix: move each key from `properties` to the node's `dataSource` (a sibling of `type`), unchanged; rename `defaultFilters` to `filter` where `filter` is empty, and delete it where it is not.** + +**For a consumer that pins both repositories:** its objectui pin moves past objectui#11880 no later than its objectstack pin moves past this release — the converted shape is one only that objectui reads. + +**Who is affected, measured.** This repository authors none of the eleven keys: its one element-layer author (the showcase record picker) already binds through `dataSource`, and a tree-scoped absence pin (`packages/spec/src/ui/element-flat-binding-retirement.test.ts`) keeps it that way. Other repositories and deployed metadata were not measured here. + +### The retirement kit + +- `RETIRED_KEYS_BY_MAJOR[18]`: `ui/ElementRecordPickerProps:object|filter|sort|limit`, `ui/ElementNumberProps:object|filter`, `ui/ElementRepeaterProps:object|filter|sort|limit`, `ui/ObjectGridProps:defaultFilters`. D3 entries `element-flat-data-binding-retired` and `object-grid-default-filters-retired`, each with a step-18 rationale fragment. They absorb the three protocol-18 narrowings of the same keys to the rule array (`element-number-filter-rule-array`, `element-record-picker-filter-rule-array`, `object-grid-default-filters-rule-array`), whose keys are gone in the same major; `page-component-filter-record-to-rule-array` drops the retired doors from its reach. +- Generated: `authorable-surface/ui.json` (11 rows `[RETIRED]`) and the `ui/component` reference page. Hand-edited ledger: `dropped-refinements.baseline.json` loses the three rows whose only dropped refinement was a retired `filter`. +- Pins: the tombstones, the binding, both conversions and the tree-scoped absence walk in `element-flat-binding-retirement.test.ts`; the missing-binding refusal and the repeater trap in `validate-component-props.test.ts`; the docs gate's twin in `check-yaml-examples.ts --self-test`. From 418d40d9b0cf18ae698434280bbca98753b8d68c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:30:12 +0000 Subject: [PATCH 06/16] wip: tombstones name the published major; register the repo-scoped pin Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/lint/src/validate-component-props.test.ts | 4 ++-- packages/spec/src/ui/component.zod.ts | 4 ++-- packages/spec/src/ui/element-flat-binding-retirement.test.ts | 4 ++-- packages/spec/vitest.repo-tests.json | 1 + 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 3315e8caf44..5ecf6ddf37c 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -379,7 +379,7 @@ describe('validateComponentProps — value verdicts', () => { 'pages[0].regions[0].components[0].properties.object', ]); const tombstone = invalid(flat).find((f) => f.path.endsWith('.properties.object'))!; - expect(tombstone.message).toMatch(/`element:repeater` property `object` was removed in @objectstack\/spec 18.*`dataSource\.object`/s); + expect(tombstone.message).toMatch(/`element:repeater` property `object` was removed in @objectstack\/spec 17.*`dataSource\.object`/s); }); /** @@ -483,7 +483,7 @@ describe('validateComponentProps — value verdicts', () => { for (const key of ['object', 'filter', 'sort', 'limit']) { const at = invalid(findings).find((f) => f.path === `${base}.properties.${key}`)!; expect(at.message).toMatch( - new RegExp(`\`element:record_picker\` property \`${key}\` was removed in @objectstack/spec 18.*\`dataSource\\.${key}\``, 's'), + new RegExp(`\`element:record_picker\` property \`${key}\` was removed in @objectstack/spec 17.*\`dataSource\\.${key}\``, 's'), ); } expect(unknownKeys(findings)).toEqual([]); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index e9e8c0546f2..c4a1c07bbb0 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2648,7 +2648,7 @@ const elementFlatBindingRetired = ( : type === 'element:number' && key === 'filter' ? 'where `dataSource.filter` already has rules, append these to it, since the two always AND-combined' : 'where `dataSource` already sets it, delete this one, since the binding\'s value always won'; - return `\`${type}\` property \`${key}\` was removed in @objectstack/spec 18 (ADR-0087 D2) — ` + return `\`${type}\` property \`${key}\` was removed in @objectstack/spec 17 (ADR-0087 D2) — ` + `${why}, so a value written here reaches no query. Use \`dataSource.${key}\` on the component ` + 'node, a sibling of `type` rather than a key inside `properties`. Move the key; the value ' + `(${ELEMENT_FLAT_BINDING_VALUE[key]}) is unchanged, and ${both}. ` @@ -4611,7 +4611,7 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * a record-form value it moves is converted at `filter` like any other. */ defaultFilters: retiredKey( - '`object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — ' + '`object-grid` property `defaultFilters` was removed in @objectstack/spec 17 (ADR-0087 D2) — ' + 'it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to ' + 'nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use ' + '`filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, ' diff --git a/packages/spec/src/ui/element-flat-binding-retirement.test.ts b/packages/spec/src/ui/element-flat-binding-retirement.test.ts index cbf0259b636..2e43a2066f9 100644 --- a/packages/spec/src/ui/element-flat-binding-retirement.test.ts +++ b/packages/spec/src/ui/element-flat-binding-retirement.test.ts @@ -124,7 +124,7 @@ describe('the element tombstones — refused at the key, with the prescription', expect(issues[0]!.path).toEqual([key]); const message = issues[0]!.message; // House convention 1 + 2: the qualified key opens it, then the release and ADR. - expect(message.startsWith(`\`${type}\` property \`${key}\` was removed in @objectstack/spec 18 (ADR-0087 D2) — `)).toBe(true); + expect(message.startsWith(`\`${type}\` property \`${key}\` was removed in @objectstack/spec 17 (ADR-0087 D2) — `)).toBe(true); // The live mechanism, named at its own key on the node. expect(message).toContain(`Use \`dataSource.${key}\` on the component node, a sibling of \`type\``); expect(message.endsWith(MIGRATE_SENTENCE)).toBe(true); @@ -214,7 +214,7 @@ describe('`object-grid.defaultFilters` — refused at the key, with the prescrip expect(at).toHaveLength(1); expect(at[0]!.code).toBe('invalid_type'); expect(at[0]!.path).toEqual(['defaultFilters']); - expect(at[0]!.message.startsWith('`object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — ')).toBe(true); + expect(at[0]!.message.startsWith('`object-grid` property `defaultFilters` was removed in @objectstack/spec 17 (ADR-0087 D2) — ')).toBe(true); expect(at[0]!.message).toContain('Use `filter`.'); expect(at[0]!.message.endsWith(MIGRATE_SENTENCE)).toBe(true); }); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index a327509d614..aded290beee 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -46,6 +46,7 @@ "src/system/constants/platform-object-names.test.ts", "src/system/email-template-floor-locale-parity.pin.test.ts", "src/ui/action-requires-confirmation-docblock.pin.test.ts", + "src/ui/element-flat-binding-retirement.test.ts", "src/ui/element-text-variant-heading-retirement.test.ts", "src/ui/form-field-public-picker-retirement.test.ts", "src/ui/master-detail-detail-sort-field-retirement.test.ts", From cb72be3d709d506ac36de4b6ee5009fa810511a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:50:19 +0000 Subject: [PATCH 07/16] wip(spec): re-bind the element fixtures the absence walk found; repeater pins follow the binding Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- ...ponent-filter-record-to-rule-array.test.ts | 10 ++++- .../spec/src/system/i18n-resolver.test.ts | 2 +- ...omponent-action-element-rows-20371.test.ts | 31 +++++++++++----- packages/spec/src/ui/page.test.ts | 37 ++++++++++--------- 4 files changed, 52 insertions(+), 28 deletions(-) diff --git a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts index 07257fca72a..40b968c5536 100644 --- a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts +++ b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts @@ -558,7 +558,15 @@ describe('§6 the reach is the family, read off the schema', () => { // #11509 retired `object-grid.defaultFilters` (the one block that had it): // the row answers every value with its removal prescription, never the // rule-array one, and no block keeps a record form there after the chain. - expect(TYPES.filter((t) => refusesRecordWithPrescription(ComponentPropsMap[t], 'defaultFilters'))).toEqual([]); + // (Its tombstone quotes the rule form as the value to move, so the probe + // is the removal sentence, not the rule-array prescription's form text.) + const retired = TYPES.filter((t) => { + const parse = (ComponentPropsMap[t] as unknown as { safeParse: (v: unknown) => { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }).safeParse; + const r = parse.call(ComponentPropsMap[t], { defaultFilters: { status: 'active' } }); + return !r.success && r.error!.issues.some((i) => i.path[0] === 'defaultFilters' && i.message.includes('was removed')); + }); + expect(retired).toEqual(['object-grid']); + expect(TYPES.filter((t) => refusesRecordWithPrescription(ComponentPropsMap[t], 'defaultFilters') && !retired.includes(t))).toEqual([]); expect(TYPES.filter((t) => converts(t, 'defaultFilters'))).toEqual([]); }); diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 7844e25eb27..c812594ce6a 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -1605,7 +1605,7 @@ describe('translatePage', () => { { type: 'page:card', id: 'quick_create', properties: { title: 'Quick Create', icon: 'plus' } }, { type: 'element:kpi', id: 'kpi_revenue_won', properties: { label: 'Revenue (Won)', value: 42 } }, { type: 'page:card', id: 'ai_briefing', properties: { title: 'Ask the AI Assistant', description: 'Open the assistant panel from the right edge…' } }, - { type: 'element:record_picker', id: 'lead_picker', properties: { object: 'lead', placeholder: 'Search leads…', emptyText: 'No records' } }, + { type: 'element:record_picker', id: 'lead_picker', dataSource: { object: 'lead' }, properties: { placeholder: 'Search leads…', emptyText: 'No records' } }, // Was `element:form` until #9249 retired that element whole, then a // bespoke type carrying the `submitLabel` pin until commit d173125fb, whose // ruling retired the key from the copy face, so the node now pins diff --git a/packages/spec/src/ui/component-action-element-rows-20371.test.ts b/packages/spec/src/ui/component-action-element-rows-20371.test.ts index 3b0810242b3..d8d867a3f9a 100644 --- a/packages/spec/src/ui/component-action-element-rows-20371.test.ts +++ b/packages/spec/src/ui/component-action-element-rows-20371.test.ts @@ -30,7 +30,7 @@ import { hasReservedComponentNamespace, isKnownComponentType, } from './component-type-vocabulary'; -import { PageComponentSchema, PageComponentType } from './page.zod'; +import { ElementDataSourceSchema, PageComponentSchema, PageComponentType } from './page.zod'; type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[]; errors?: Issue[][] }; @@ -281,14 +281,20 @@ describe('what the measurement decided, pinned', () => { expect(ElementDefinitionListPropsSchema.safeParse({}).success).toBe(true); }); - it('repeater `object` is required — without it the list never queries', () => { - const issues = issuesOf(ElementRepeaterPropsSchema.safeParse({ fields: ['name'] })); + it('repeater: its object is the binding\'s since v18 — the props bag requires none, and refuses a flat one', () => { + // The measurement made the flat `object` REQUIRED (without it the list + // never queries). #11509 moved the list onto the node-level `dataSource`, + // so the requirement moved with it: the component-props lint reports a + // repeater with no `dataSource.object`, and the props bag refuses `object`. + expect(ElementRepeaterPropsSchema.safeParse({ fields: ['name'] }).success).toBe(true); + const issues = issuesOf(ElementRepeaterPropsSchema.safeParse({ object: 'task', fields: ['name'] })); expect(issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_type', 'object']]); + expect(issues[0]!.message).toContain('`dataSource.object`'); }); it('repeater `fields` takes a name or `{ field }`; the unrendered `label` is refused inside the union', () => { - expect(ElementRepeaterPropsSchema.safeParse({ object: 't', fields: ['a', { field: 'b' }] }).success).toBe(true); - const [union] = issuesOf(ElementRepeaterPropsSchema.safeParse({ object: 't', fields: [{ field: 'b', label: 'B' }] })); + expect(ElementRepeaterPropsSchema.safeParse({ fields: ['a', { field: 'b' }] }).success).toBe(true); + const [union] = issuesOf(ElementRepeaterPropsSchema.safeParse({ fields: [{ field: 'b', label: 'B' }] })); expect(union!.code).toBe('invalid_union'); expect(union!.path).toEqual(['fields', 0]); // Exactly one arm judged keys, and only keys — the shape the props gate @@ -298,18 +304,25 @@ describe('what the measurement decided, pinned', () => { expect(keyArms[0]!.map((i) => i.keys)).toEqual([['label']]); }); - it('repeater `filter` / `sort` are the family\'s one orthography — the record form is refused', () => { - const ok = ElementRepeaterPropsSchema.safeParse({ + it('repeater `filter` / `sort` / `limit` are the binding\'s since v18, in the family\'s one orthography', () => { + // Born on the rule array and the sort array; #11509 moved all three onto + // the node-level `dataSource`, which carries the same shapes. + const ok = ElementDataSourceSchema.safeParse({ object: 'task', filter: [{ field: 'status', operator: 'equals', value: 'open' }], sort: [{ field: 'due_date', order: 'asc' }], limit: 10, }); expect(ok.success).toBe(true); - const record = issuesOf(ElementRepeaterPropsSchema.safeParse({ object: 'task', filter: { status: 'open' } })); + const record = issuesOf(ElementDataSourceSchema.safeParse({ object: 'task', filter: { status: 'open' } })); expect(record.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_type', 'filter']]); - const limit = issuesOf(ElementRepeaterPropsSchema.safeParse({ object: 'task', limit: 0 })); + const limit = issuesOf(ElementDataSourceSchema.safeParse({ object: 'task', limit: 0 })); expect(limit.map((i) => [i.code, i.path.join('.')])).toEqual([['too_small', 'limit']]); + // …and the flat keys are refused at the props bag, with the prescription. + for (const key of ['filter', 'sort', 'limit']) { + const flat = issuesOf(ElementRepeaterPropsSchema.safeParse({ [key]: [] })); + expect(flat.map((i) => [i.code, i.path.join('.')]), key).toEqual([['invalid_type', key]]); + } }); it('`objectName` is declared where the renderer forwards it — on the action, never on a container', () => { diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 02a89fb8280..d625aa87bf2 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -591,7 +591,7 @@ describe('PageSchema with page types', () => { { name: 'main', components: [ - { type: 'element:number', properties: { object: 'order', aggregate: 'count' } }, + { type: 'element:number', dataSource: { object: 'order' }, properties: { aggregate: 'count' } }, ], }, ], @@ -774,27 +774,29 @@ describe('ElementDataSourceSchema `filter` — one filter orthography platform-w 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. 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. + // resolved through one `??` in the renderer; since v18 (#11509) its flat + // `filter` is retired and the binding is its one door. 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 a live props-map door — + // `object-grid`'s `filter` — through its 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 }, + properties: { labelField: 'name' }, dataSource: { object: 'account', filter: RULES }, }); expect(binding.success).toBe(true); const bindingRecord = PageComponentSchema.safeParse({ type: 'element:record_picker', - properties: { object: 'account', filter: RULES }, + properties: { labelField: 'name' }, dataSource: { object: 'account', filter: RECORD_FORM }, }); 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)) + const grid = ComponentPropsMap['object-grid']; + expect(issuesAt(grid.safeParse({ objectName: 'account', filter: RULES }), 'filter')).toEqual([]); + expect(issuesAt(grid.safeParse({ objectName: 'account', filter: RECORD_FORM }), 'filter').map((i) => i.code)) .toEqual(issuesAt(bindingRecord, 'dataSource.filter').map((i) => i.code)); }); }); @@ -806,7 +808,7 @@ describe('PageComponent dataSource integration', () => { it('should accept component with dataSource', () => { const component = PageComponentSchema.parse({ type: 'element:number', - properties: { object: 'order', aggregate: 'sum', field: 'total' }, + properties: { aggregate: 'sum', field: 'total' }, dataSource: { object: 'order', filter: [{ field: 'status', operator: 'equals', value: 'completed' }], @@ -867,9 +869,10 @@ describe('PageVariableSchema record_id type', () => { type: 'element:record_picker', // The binding is carried by the VARIABLE's `source` above, not by // any picker prop — `displayField` (#5775) and `targetVariable` - // (#9198) are both retired. + // (#9198) are both retired. Its object is the node-level binding + // (the flat `object` retired in v18, #11509). + dataSource: { object: 'account' }, properties: { - object: 'account', labelField: 'name', }, }, @@ -904,12 +907,12 @@ describe('Page end-to-end', () => { }, { type: 'element:number', - properties: { object: 'order', aggregate: 'count' }, + properties: { aggregate: 'count' }, dataSource: { object: 'order', filter: [{ field: 'status', operator: 'equals', value: 'pending' }] }, }, { type: 'element:number', - properties: { object: 'order', aggregate: 'sum', field: 'total', format: 'currency', prefix: '$' }, + properties: { aggregate: 'sum', field: 'total', format: 'currency', prefix: '$' }, dataSource: { object: 'order', filter: [{ field: 'status', operator: 'equals', value: 'completed' }] }, }, { From c70da2d652299c6e694edad5cef012404e72a7ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:27:44 +0000 Subject: [PATCH 08/16] wip(spec): ledger totals and the i18n fixture follow the retirement Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/spec/dropped-refinements.baseline.json | 4 ++-- packages/spec/src/system/i18n-resolver.test.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index b7924a1675d..7b3f8d546b6 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 220, - "droppedRefinementSites": 679, + "publishedSchemasWithDroppedRefinements": 217, + "droppedRefinementSites": 676, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index c812594ce6a..5bb2246b8d5 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -1658,7 +1658,8 @@ describe('translatePage', () => { const out = translatePage(homePage(), homeBundle, { locale: 'zh-CN' }); expect(byId(out, 'quick_create').properties.icon).toBe('plus'); expect(byId(out, 'kpi_revenue_won').properties.value).toBe(42); - expect(byId(out, 'lead_picker').properties.object).toBe('lead'); + // The picker's object is its node-level binding since v18 (#11509) — kept too. + expect(byId(out, 'lead_picker').dataSource.object).toBe('lead'); }); it('leaves a component with no entry — and one with no id — untouched', () => { From fbb45ba6bc719664554fbf2069f3ccf412f0ee1a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:37:41 +0000 Subject: [PATCH 09/16] =?UTF-8?q?wip(spec):=20api-surface=20and=20export-o?= =?UTF-8?q?rigins=20back=20to=20base=20=E2=80=94=20no=20export=20added?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/spec/api-surface/ui.json | 2 -- packages/spec/export-origins/ui.json | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 9de1a5bce4b..0b28904473a 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -353,7 +353,6 @@ "RECORD_CONTEXT_BLOCK_TAGS (const)", "RECORD_CONTEXT_TYPE_PREFIX (const)", "RESERVED_COMPONENT_TYPE_NAMESPACES (const)", - "RETIRED_ELEMENT_FLAT_BINDING_KEYS (const)", "RETIRED_PAGE_COMPONENT_TYPES (const)", "ReactBlockDef (interface)", "ReactInteractionProp (interface)", @@ -401,7 +400,6 @@ "ResolvedActionParam (interface)", "ResponsiveStyles (type)", "ResponsiveStylesSchema (const)", - "RetiredFlatBindingElementType (type)", "RowColorConfig (type)", "RowColorConfigSchema (const)", "RowHeight (type)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 4006e862ac9..26e94e5a860 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -347,7 +347,6 @@ "RECORD_CONTEXT_BLOCK_TAGS": "src/ui/react-blocks.ts#RECORD_CONTEXT_BLOCK_TAGS (const)", "RECORD_CONTEXT_TYPE_PREFIX": "src/ui/react-blocks.ts#RECORD_CONTEXT_TYPE_PREFIX (const)", "RESERVED_COMPONENT_TYPE_NAMESPACES": "src/ui/component-type-vocabulary.ts#RESERVED_COMPONENT_TYPE_NAMESPACES (const)", - "RETIRED_ELEMENT_FLAT_BINDING_KEYS": "src/ui/component.zod.ts#RETIRED_ELEMENT_FLAT_BINDING_KEYS (const)", "RETIRED_PAGE_COMPONENT_TYPES": "src/ui/page.zod.ts#RETIRED_PAGE_COMPONENT_TYPES (const)", "ReactBlockDef": "src/ui/react-blocks.ts#ReactBlockDef (interface)", "ReactInteractionProp": "src/ui/react-blocks.ts#ReactInteractionProp (interface)", @@ -386,7 +385,6 @@ "ResolvedActionParam": "src/ui/action-params.zod.ts#ResolvedActionParam (interface)", "ResponsiveStyles": "src/ui/responsive.zod.ts#ResponsiveStyles (type)", "ResponsiveStylesSchema": "src/ui/responsive.zod.ts#ResponsiveStylesSchema (const)", - "RetiredFlatBindingElementType": "src/ui/component.zod.ts#RetiredFlatBindingElementType (type)", "RowColorConfig": "src/ui/view.zod.ts#RowColorConfig (type)", "RowColorConfigSchema": "src/ui/view.zod.ts#RowColorConfigSchema (const)", "RowHeight": "src/ui/view.zod.ts#RowHeight (type)", From c0584368e2c459fd14b8e73ce57f6669a77a6674 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 04:01:13 +0000 Subject: [PATCH 10/16] chore(spec): regenerate the ui/component reference after the merge Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 6c3eb738424..30e912774a0 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -341,10 +341,10 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `never` | optional | [REMOVED] `element:number` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **object** | `never` | optional | [REMOVED] `element:number` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **field** | `string` | optional | Field to aggregate | | **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>` | ✅ | Aggregation function | -| **filter** | `never` | optional | [REMOVED] `element:number` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource.filter` already has rules, append these to it, since the two always AND-combined. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **filter** | `never` | optional | [REMOVED] `element:number` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource.filter` already has rules, append these to it, since the two always AND-combined. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **format** | `Enum<'number' \| 'currency' \| 'percent'>` | optional | Number display format | | **prefix** | `string` | optional | Prefix text (e.g. "$") | | **suffix** | `string` | optional | Suffix text (e.g. "%") | @@ -367,13 +367,13 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `never` | optional | [REMOVED] `element:record_picker` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **object** | `never` | optional | [REMOVED] `element:record_picker` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **labelField** | `string` | optional | Field rendered as each row's text (default `name`) | | **valueField** | `string` | optional | Field whose value is written into the bound page variable (default `id`) | | **label** | `string \| Record` | optional | Control label rendered above the select | -| **filter** | `never` | optional | [REMOVED] `element:record_picker` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **sort** | `never` | optional | [REMOVED] `element:record_picker` property `sort` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.sort`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **limit** | `never` | optional | [REMOVED] `element:record_picker` property `limit` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.limit`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **filter** | `never` | optional | [REMOVED] `element:record_picker` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **sort** | `never` | optional | [REMOVED] `element:record_picker` property `sort` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.sort`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **limit** | `never` | optional | [REMOVED] `element:record_picker` property `limit` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.limit`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **targetVariable** | `never` | optional | [REMOVED] `element:record_picker` property `targetVariable` was removed in @objectstack/spec 17 (ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the picked record id, declare it on the variable — `variables: [{ name: '', type: 'record_id', source: '' }]`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **placeholder** | `string \| Record` | optional | Placeholder text | | **emptyText** | `string \| Record` | optional | Text shown when the query returns no records (default "No records") | @@ -399,12 +399,12 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `never` | optional | [REMOVED] `element:repeater` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **object** | `never` | optional | [REMOVED] `element:repeater` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **titleField** | `string` | optional | Field shown first on each line, emphasized | | **fields** | `(string \| { field: string })[]` | optional | Fields shown after the title on each line, in order — a bare field name, or `{ field }` | -| **filter** | `never` | optional | [REMOVED] `element:repeater` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **sort** | `never` | optional | [REMOVED] `element:repeater` property `sort` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **limit** | `never` | optional | [REMOVED] `element:repeater` property `limit` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **filter** | `never` | optional | [REMOVED] `element:repeater` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **sort** | `never` | optional | [REMOVED] `element:repeater` property `sort` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **limit** | `never` | optional | [REMOVED] `element:repeater` property `limit` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **emptyText** | `string` | optional | Copy shown when the query returns no records (renderer default: "No records"). A literal string — localize through the translation bundle entry for this component id | | **divided** | `boolean` | optional | Draw a separator between lines (renderer default: true) | @@ -797,7 +797,7 @@ Sort field and direction pair | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view's `columns` declares. One spelling per list: an array mixing strings and column objects is refused | | **fields** | `string[]` | optional | Field-name fallback the grid reads when `columns` is absent — bare field names (`['name', 'amount']`); write column decoration such as `label` or `width` on `columns` | | **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** | `never` | optional | [REMOVED] `object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use `filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, `[{ field, operator, value }, ...]`) is unchanged. Where `filter` already has rules, delete this key: the grid never read it there. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **defaultFilters** | `never` | optional | [REMOVED] `object-grid` property `defaultFilters` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use `filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, `[{ field, operator, value }, ...]`) is unchanged. Where `filter` already has rules, delete this key: the grid never read it there. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Initial row order — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] } & Record` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging. `pageSize` and every `pageSizeOptions` entry is a positive integer — the accept set the view arm's `PaginationConfigSchema` already rules; the bag stays open, so other keys pass through unvalidated | From ebf82d0324eda4bf46d9d1f7bfe5001fc70f9a37 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:59:59 +0000 Subject: [PATCH 11/16] =?UTF-8?q?fix(spec):=20repeater=20prose=20holds=20a?= =?UTF-8?q?t=20the=20new=20pin=20=E2=80=94=20it=20reads=20the=20binding=20?= =?UTF-8?q?first=20and=20keeps=20its=20flat=20keys=20as=20a=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/spec/src/conversions/registry.ts | 33 +++++++++++-------- .../18.element-flat-data-binding-retired.ts | 4 +-- .../element-flat-binding-retirement.test.ts | 5 ++- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index b819c5d0c60..0736edfc1f6 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7329,7 +7329,10 @@ function isRuleObjectArray(value: unknown): value is Dict[] { * the saved view it names) first, the flat key second * (`composed?. ?? props.`); * - `element:number`'s `filter`: AND-combined with the binding's; - * - `element:repeater`: the flat keys ONLY — the binding was not read at all. + * - `element:repeater`: the flat keys ONLY — the binding was not read at all + * (objectui#11880 then put the binding first, keeping the flat keys as a + * fallback, so a value the two disagree on was applied differently by the + * two console versions). */ type FlatBindingDisposition = | { kind: 'move' } @@ -7352,17 +7355,18 @@ function flatBindingDisposition( return { kind: 'todo', reason: `\`dataSource.${key}\` is set to a different value than this flat \`${key}\`. The list read ` - + 'only its flat keys until it moved onto the binding, so the binding\'s value never applied; now it ' - + `is the only one read. Keep the value you mean in \`dataSource.${key}\` and delete this key.`, + + 'only its flat keys until the console moved it onto the binding, and reads the binding first since, ' + + 'so which of the two it applied depends on the console version. Keep the value you mean in ' + + `\`dataSource.${key}\` and delete this key.`, }; } if (view !== undefined && key !== 'object') { return { kind: 'todo', - reason: `the binding names the saved view \`${view}\`, which the list did not read until it moved onto ` - + `the binding. Moved there, this \`${key}\` would combine with the view's own (a filter ANDs, a ` - + 'sort or a limit overrides it), which the list never did. Decide whether the list should apply the ' - + `view, then write the \`${key}\` you mean on \`dataSource\` and delete this key.`, + reason: `the binding names the saved view \`${view}\`, which the list did not read until the console ` + + `moved it onto the binding. On the binding, this \`${key}\` combines with the view's own (a filter ` + + 'ANDs, a sort or a limit overrides it), which an older console never applied. Decide whether the list ' + + `should apply the view, then write the \`${key}\` you mean on \`dataSource\` and delete this key.`, }; } return { kind: 'move' }; @@ -7406,9 +7410,10 @@ function flatBindingDisposition( * `element:repeater` `object` / `filter` / `sort` / `limit`. Each was the * same query as a key of `ElementDataSourceSchema`, resolved per renderer by * three different rules, and objectui#11880 (objectui `5bc55c0c5a1e`) moved - * all three renderers onto the binding alone — the order the ruling set, so - * this rewrite never moves a working list's query into a position its - * renderer does not read. + * all three renderers onto the binding — the picker and `element:number` read + * it alone, the repeater reads it first and keeps its flat keys as a fallback + * — in the order the ruling set, so this rewrite never moves a working list's + * query into a position its renderer does not read. * * Mechanical where the OLD rule decides the answer * ({@link flatBindingDisposition}): @@ -7425,8 +7430,9 @@ function flatBindingDisposition( * key the binding lacks beside a `dataSource.view` (whether the view's own key * displaced it depends on the view, which no conversion reads); a repeater key * the binding sets to a different value, or beside a `view` (the repeater read - * neither before, so moving it would combine it with what the list never - * applied); and an `element:number` filter pair that is not two rule arrays. A + * neither before objectui#11880 and reads the binding first since, so what it + * applied depends on the console version); and an `element:number` filter pair + * that is not two rule arrays. A * key left as stored no longer reaches a query, and its tombstone refuses it * at the next parse with the same prescription. * @@ -7590,7 +7596,8 @@ const elementFlatDataBindingToDataSource: MetadataConversion = { }, ], }, - // The named-slot shape: a repeater, which read its flat keys alone. + // The named-slot shape: a repeater, which read its flat keys alone + // before the console put its binding first. { name: 'deal_detail', kind: 'slotted', diff --git a/packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts b/packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts index 134f7ead282..ddebd52501b 100644 --- a/packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts @@ -39,8 +39,8 @@ export const entry: SemanticMigration = { + 'left as stored and listed as TODOs, because only the author can decide them: a record-picker ' + 'key beside a `dataSource.view` the binding sets no such key of its own for (the flat value ' + 'applied only if the view supplied none, and no conversion reads the view); a repeater key the ' - + 'binding sets to a DIFFERENT value, or beside a `view` (the repeater never read either, so the ' - + 'list now applies something it did not before); and an `element:number` filter pair that is not ' + + 'binding sets to a DIFFERENT value, or beside a `view` (the repeater read neither until the console ' + + 'put its binding first, so what it applied depends on the console version); and an `element:number` filter pair that is not ' + 'two rule arrays. A repeater that carried a `dataSource` its list ignored now applies it — ' + 'compare it with what the list showed. A flat filter in the retired record form moves to ' + '`dataSource.filter` and is then converted there by `page-component-filter-record-to-rule-array` ' diff --git a/packages/spec/src/ui/element-flat-binding-retirement.test.ts b/packages/spec/src/ui/element-flat-binding-retirement.test.ts index 2e43a2066f9..8bc46e60f32 100644 --- a/packages/spec/src/ui/element-flat-binding-retirement.test.ts +++ b/packages/spec/src/ui/element-flat-binding-retirement.test.ts @@ -136,7 +136,10 @@ describe('the element tombstones — refused at the key, with the prescription', expect(message('element:record_picker', 'limit')).toContain('delete this one, since the binding\'s value always won'); expect(message('element:number', 'object')).toContain('delete this one, since the binding\'s value always won'); expect(message('element:number', 'filter')).toContain('append these to it, since the two always AND-combined'); - expect(message('element:repeater', 'sort')).toContain('keep the value written here, which is the one the list honoured'); + // The repeater put the binding first only at objectui#11880 and keeps its flat keys as a + // fallback, so two console versions applied a disagreeing pair differently: no rule to state. + expect(message('element:repeater', 'sort')).toContain('decide which of the two values the list should use'); + expect(message('element:repeater', 'sort')).not.toContain('reaches no query'); }); it('refuses by the TOMBSTONE, not by the strict unknown-key arm — the two are different answers', () => { From 5f93f6bfc07197e6c85ad7cef5cf21cc24cff300 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 11:00:37 +0000 Subject: [PATCH 12/16] chore(spec): regenerate the migration registry after the merge Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/spec/src/migrations/registry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a9691dd8d08..8c99c11053c 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -11578,8 +11578,8 @@ const step18: MigrationStep = { + 'left as stored and listed as TODOs, because only the author can decide them: a record-picker ' + 'key beside a `dataSource.view` the binding sets no such key of its own for (the flat value ' + 'applied only if the view supplied none, and no conversion reads the view); a repeater key the ' - + 'binding sets to a DIFFERENT value, or beside a `view` (the repeater never read either, so the ' - + 'list now applies something it did not before); and an `element:number` filter pair that is not ' + + 'binding sets to a DIFFERENT value, or beside a `view` (the repeater read neither until the console ' + + 'put its binding first, so what it applied depends on the console version); and an `element:number` filter pair that is not ' + 'two rule arrays. A repeater that carried a `dataSource` its list ignored now applies it — ' + 'compare it with what the list showed. A flat filter in the retired record form moves to ' + '`dataSource.filter` and is then converted there by `page-component-filter-record-to-rule-array` ' From 756fa0b335cc88db527ffee55543a81aaec39708 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 11:09:05 +0000 Subject: [PATCH 13/16] chore(spec): regenerate the ui/component reference for the repeater wording Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 30e912774a0..4fd631069bc 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -399,12 +399,12 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `never` | optional | [REMOVED] `element:repeater` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **object** | `never` | optional | [REMOVED] `element:repeater` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **titleField** | `string` | optional | Field shown first on each line, emphasized | | **fields** | `(string \| { field: string })[]` | optional | Fields shown after the title on each line, in order — a bare field name, or `{ field }` | -| **filter** | `never` | optional | [REMOVED] `element:repeater` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **sort** | `never` | optional | [REMOVED] `element:repeater` property `sort` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **limit** | `never` | optional | [REMOVED] `element:repeater` property `limit` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **filter** | `never` | optional | [REMOVED] `element:repeater` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **sort** | `never` | optional | [REMOVED] `element:repeater` property `sort` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **limit** | `never` | optional | [REMOVED] `element:repeater` property `limit` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **emptyText** | `string` | optional | Copy shown when the query returns no records (renderer default: "No records"). A literal string — localize through the translation bundle entry for this component id | | **divided** | `boolean` | optional | Draw a separator between lines (renderer default: true) | From c1d688804570706912833f73b081fd29af83e54d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 14:18:01 +0000 Subject: [PATCH 14/16] test(cli): the migrate-meta guidance pin stops naming the three step-18 entries this retirement absorbs The REWRITTEN floor in migrate-meta-engine-guidance.test.ts still named element-number-filter-rule-array, element-record-picker-filter-rule-array and object-grid-default-filters-rule-array. The element flat binding retirement absorbs all three into its own D3 entries, so none of them is in MIGRATIONS_BY_MAJOR any more and the floor case read "family lost element-number-filter-rule-array". The list's own rule admits only entries rewritten when their family was brought to the no-tracker-id line; an entry born without a tracker id needs no row, because the whole directory is held by the printed-block case. The two absorbing entries (element-flat-data-binding-retired, object-grid-default-filters-retired) were born that way, so they are not added. The assertion is unchanged. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/cli/test/migrate-meta-engine-guidance.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/cli/test/migrate-meta-engine-guidance.test.ts b/packages/cli/test/migrate-meta-engine-guidance.test.ts index ddc6aea620a..a34e992ac1e 100644 --- a/packages/cli/test/migrate-meta-engine-guidance.test.ts +++ b/packages/cli/test/migrate-meta-engine-guidance.test.ts @@ -155,8 +155,6 @@ const REWRITTEN = [ 'driver-sql-upsert-cross-row-identity-merge-refused', 'driver-turso-config-local-path-wasm-retired', 'element-data-source-and-object-block-filter-rule-array', - 'element-number-filter-rule-array', - 'element-record-picker-filter-rule-array', 'engine-dotted-filter-refused', 'engine-dotted-projection-refused', 'engine-find-formula-filter-refused', @@ -227,7 +225,6 @@ const REWRITTEN = [ 'notification-list-cursor-retired', 'object-block-sort-item-array', 'object-grid-data-view-data-converged', - 'object-grid-default-filters-rule-array', 'object-index-unknown-keys-refused', 'observability-cel-predicates-retired', 'package-api-contracts-unmounted-entries-retired', From 675b121bb6e5808a786028e84deca2653e306025 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:28:20 +0000 Subject: [PATCH 15/16] chore(spec): regenerate spec-changes.json and the upgrade guide on the merged tree Step 4 of the os-regen merge of origin/main 76bc1e03a, through the packages' own generators (`gen:spec-changes`, `gen:upgrade-guide`; `gen:migration-registry` rewrote registry.ts byte-identically). The committed spec-changes.json main brought still listed the three step-18 entries this branch absorbs (element-number-filter-rule-array, element-record-picker-filter-rule-array, object-grid-default-filters-rule-array) and lacked this branch's two D3 entries. `check:spec-changes` no longer compares the committed copy (it generates in memory), so it read green, while `check-adr-0087-registration` reads the committed copy at HEAD as its parser witness and refused with "ledger parser drift". Regenerating the committed copies from the merged registry clears it. The copies also pick up registry text main changed without regenerating them (the objectui pin readings at 20c6d351a, the storage-scope and flow-slot guidance). Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- docs/protocol-upgrade-guide.md | 47 ++++++----- packages/spec/spec-changes.json | 134 +++++++++++++++++--------------- 2 files changed, 95 insertions(+), 86 deletions(-) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 6afc2b5a959..e278ce04153 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -473,7 +473,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte ## Protocol 17 → 18 -Protocol 18 extends the publish-time refusal of unresolved placeholders, which protocol 17 applied to datasource connection config, to the memory driver's config-material persistence keys: `persistence.path` (file persistence and the `auto` override) and `persistence.key` (localStorage and the `auto` override) refuse `${…}` placeholder syntax at publish. Nothing resolves a placeholder there — the driver would create a literal `./${DATA_DIR}/…` path or write under the literal localStorage key — the same authored-under-a-false-belief shape, one surface over. The memory driver's `initialData` stays deliberately unjudged: it carries arbitrary record values, where a literal `${…}` may be legitimate data. It also retires `MetadataPluginConfig.additionalTypes` (ADR-0049 enforce-or-remove): the key was documented as THE plugin kind-declaration channel and read by nothing — the manager's type registry is seeded once from `DEFAULT_METADATA_TYPE_REGISTRY` and never merged with it, so authoring it configured nothing. A kind enters the live set as a side effect of registering an item of that kind. It also refuses malformed field `scale`/`precision` declarations: both are digit counts, so a non-integer or negative value (`scale: 2.5`, `precision: -1`) has no defined meaning — the write-time `scale` check, which refuses an over-scale value rather than rounding it, deliberately left it unenforced rather than invent floor/round semantics, which made the declaration silently inert. The schema now refuses both at parse (`z.number().int().min(0)`); the mechanical conversion deletes a malformed value from old sources and stored rows (behaviour-preserving), and the semantic entry tells the author to re-declare the count they meant. Finally, it removes the `objects["*"].allowExport` grant from the shipped admin permission sets — `admin_full_access`, `organization_admin` and the derived `organization_admin_no_bypass`. Measured on 17.0.0 GA, that wildcard made the export axis undeniable for an org admin: an application could declare an object exportable by nobody and the platform exported it anyway, with no supported opt-out, because a code-package set cannot be edited (`403 [not_overridable]`) and the admin held no app-authored set in which to write the per-object `false` that would have won. It is the earlier removal of `member_default`'s CRUD wildcard applied to the export axis, which had kept its wildcard by omission rather than by decision. From 18 an admin exports exactly what an app-authored set grants — a posture the same run measured to be already precise. Unlike everything else in this step it changes no schema, so nothing refuses at publish: the upgrade signal is behavioural and belongs here. Finally, it converges `record:chatter` / `record:discussion` `position` on the renderer's vocabulary (maintainer ruling 2026-08-15): the schema declared `sidebar`/`inline`/`drawer` — values no renderer branch ever compared, so the schema's own `sidebar` default silently rendered in flow while the value that actually docks the panel (`right`) was refused at publish. The row now speaks `bottom`/`right`/`left`; the mechanical conversion rewrites the old spellings (`sidebar` → `right`, `inline` → `bottom`, `drawer` → `right`), and the three schema defaults (`position`, `collapsible`, `defaultCollapsed`) are dropped per the `maxVisible` principle — renderer fallbacks stay the renderer's facts. It also retires `targetVariable` on `element:text_input` and `element:record_picker` (ADR-0049 enforce-or-remove): a declarative hint with zero readers in any repo — the live binding runs the other direction, resolved from the page variable whose `source` names the component's `id` (PageVariableSchema) — so an author who wrote only `targetVariable` got an input that wrote nothing, with a success receipt. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); the tombstone's prescription says how to declare the binding that works. Finally, it retires the whole `element:filter` element (ADR-0049 enforce-or-remove at ELEMENT grain — the wider finding that the `targetVariable` retirement recorded and left for its own card): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. List surfaces own their filtering: a view's `userFilters` quick-filter bar / the list toolbar's filter builder. It also retires the whole `element:form` element (ADR-0049 enforce-or-remove at ELEMENT grain — the `element:filter` shape one element over, recorded by that retirement's own verdict sweep): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion naming the live replacement, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. Use the object-bound `object-form` block instead — rendered, designer-publishable, its props declared for the component-props gate, and carrying the same intent (`objectName`, `fields`, `mode`, `submitText`). It also closes the two explicit column lists on relationship fields: `field.inlineColumns` entries are now the strict, name-keyed InlineGridColumnSchema (mirroring the objectui grid renderer's measured reads — objectui aligned the widget to `name` and retired the `field` spelling with no tolerant alias), and `field.relatedListColumns` entries are child field-name strings (the only form the related-list renderer hydrates fully). Both were z.array(z.any()) — a mis-keyed column published clean and rendered as blank cells with the right row count. The mechanical conversion respells inline `{ field }` entries as `{ name }` and folds related-list column objects to their identity string; unknown keys are named rejections at publish from this major. It also retires `measures..filters` on analytics cubes (ADR-0049 enforce-or-remove): a declared per-metric raw-SQL filter with zero consumers — both SQL strategies aggregate the metric's `sql` and never read `filters`, so a hand-authored `filters: [{ sql: "stage = 'closed_won'" }]` parsed, registered, and silently returned the UNFILTERED aggregate under the author's metric name (the same defect the dataset path had, on a hand-authored cube; the dataset half was repaired through its own structured channel when the analytics strategy began compiling each dataset measure's `filter`). The raw-SQL fragment also ran against the platform's structured-FilterCondition direction — it cannot be parameterized, re-targeted per driver dialect, or walked by the lint filter rules. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); filter at query time with `where`, or use an ADR-0021 dataset measure's structured `filter` (a metric's own `sql` is a column reference, see `cube-member-sql-expression-retired`). Finally, it retires the stack `themes` carrier and `ThemeSchema` whole (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, disposition B: 退役授权面): the pipeline was live from the authoring gate through artifact ingest and stopped there — zero non-test readers of stored `theme` items, `theme` never a registered metadata type, no first-party app mounting the spec-aware provider, nothing selecting an active theme — so an authored theme shipped through every green gate and changed nothing on screen. `app.branding` stays the one colour surface; objectui's ThemeEngine/ThemeContext and their unit tests are retained. Semantic rather than mechanical: an authored palette has no lossless target (N themes vs M apps is a judgment), so the entry prescribes the hand move instead of deleting authored content silently. It also retires the `record:highlights` highlight-field `icon` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, executing the 2026-08-20 census verdict): a declared key with zero read points in any direction — objectui's renderer normalized the authored object and carried `icon` into a highlight chip with no icon slot, `useRegisterHighlightFields` registers field NAMES only (structurally unable to carry it), and the Studio designer publishes the field list as plain strings — while six author-facing surfaces advertised the key (the shape that got the reference-rail `icon` refused, on the highlight chip). The mechanical conversion strips the key from the object entries of every `record:highlights` `fields[]` (pure lossless delete — the chip renders label and value only, so it never had an effect to lose); there is no replacement, and the live neighbour `readonly`, declared because the chip's read-only gate reads it, is untouched. It also retires the import mapping `lookup` transform's steering params (ADR-0049 enforce-or-remove — the sub-walk half of the 17.0.0 mapping cleanup that retired `extractQuery` / `errorPolicy` / `batchSize`): `fieldMapping[].params.object` / `.fromField` / `.toField` / `.autoCreate` declared a per-entry reference-resolution dialect the import path never implemented — `lookup` copies the cell through and resolution runs off the target field's own metadata — and `autoCreate` read as create-if-missing while an unresolved reference actually fails the row (`import_reference_not_found`), with or without the key. The eleven alias spellings convert to guidance so every spelling lands on the prescription; the mechanical conversion strips the four keys from stored sources (pure lossless deletes — none ever had an effect to lose). Finally, it retires the component-translation copy key `pages..components..submitLabel` and its `submit` alias (ADR-0049; maintainer ruling 2026-08-22): the face is measured, not mirrored — each copy key exists because some component in `ComponentPropsMap` declares it — and `submitLabel`'s only declarer was `element:form`, retired whole above, so the key had no declared component left to translate and the resolver overlay was its only reader. Retire won over re-anchor because the live form surface (`object-form`) speaks `submitText` (`I18nLabelSchema`), localizable at its own authoring site; re-anchoring would have widened the face for one word. The mechanical conversion strips the key from stored bundles and items (pure lossless delete — nothing read it once `element:form` was retired), at the acknowledged cost of dropping the bespoke-component route for that one word. Finally, it retires `page.components[].responsive` and the whole `ResponsiveConfig` layout vocabulary it carried (ADR-0049 D2; maintainer ruling 2026-08-22): the key was the destination the `dashboard.widgets[].responsive` tombstone prescribed as the live alternative, and a two-repo measurement (tsc-probe methodology with positive and negative controls) found the claim false — objectui's two implementations of the contract (`useResponsiveConfig`, `ResponsiveProtocol`) had zero callers and nothing read `.responsive` off a page component, so the prescribed migration moved an inert key to an inert key while the platform's own error message vouched for it. The same change repairs every shipped text that carried that redirect. `ResponsiveConfigSchema`, its two breakpoint maps and the `BreakpointName` enum had no other authorable carrier and leave with the key (RETIRED_DEFS_BY_MAJOR[18]); the live per-breakpoint channel on a page component is `responsiveStyles` (ADR-0065), which objectui really compiles. The mechanical conversion strips the key from stored pages (pure lossless delete — it never had an effect to lose). Finally, it retires nine of the eleven members of the plugin manifest's `contributes` block (ADR-0049 enforce-or-remove; triage graded 2026-08-21, cloud census leg discharged clean 2026-08-24): `events`, `menus`, `themes`, `translations`, `actions`, `drivers`, `fieldTypes`, `functions` and `commands`. A census of all three repos, with controls, measured that the whole monorepo contains exactly one non-test read of `manifest.contributes`, and it reads `kinds`; the other nine members parsed, entered the manifest, and changed nothing, while published docs and the schema's own JSDoc kept teaching them (`commands` documented Commander.js resolution the CLI dropped for oclif; `fieldTypes` advertised a registration seam that never existed). All nine are retiredKey tombstones mirroring `loading`; `kinds` survives (live reader), and `routes` was left to a ruling of its own, which retired it as well (the `plugin-manifest-contributes-routes-retired` entry). D3 semantic, no D2 conversion: a manifest is not a stack collection member, so a conversion would be a transform with no seam that ever runs. On the surviving `kinds` bucket it also retires the `globs` sub-field (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-24): the schema promised that declaring `globs` enables file-type discovery, but discovery globs `filePatterns` off the metadata type registry — which `contributes.kinds` does not extend, as `metadata-plugin.zod.ts` records outright — so an authored `globs` was accepted, stored, served back through `GET /metadata/kind`, and never consulted (zero value reads; the only non-test occurrences were the schema declaration and two type positions). The `kind` bucket itself and its `id` are untouched; file-type discovery stays single-channel on `filePatterns`. D3 semantic `plugin-manifest-kind-globs-retired`, same no-seam reasoning. Finally, it retires `object-grid`'s `defaultSort` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, decision-inbox batch 4 — the producer half of objectui's `table.defaultSort` retirement, which the maintainer's 2026-08-22 「接受所有」 ruling on objectui's sort sink ordered): the legacy second spelling of `sort`, a single `{ field, order }` pair the renderer read only when `sort` was absent (measured at the `.objectui-sha` pin `190fbd01d`, `plugin-grid/src/ObjectGrid.tsx:1244-1246` and `:2847`, which wraps it `[schema.defaultSort]` — the exact array shape `sort` carries). One intent, two spellings; objectui's mirror schema is parity-test-only and parses nothing at runtime, so only the spec strictObject can refuse the key. The mechanical conversion carries the pair over — renamed to `sort` and wrapped in the array shape — when `sort` is absent, and strips it as a pure lossless delete when `sort` is present (the renderer's own precedence made it unread then). Finally, it retires the object-permission lifecycle bits `allowRestore` and `allowPurge` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-26, decision-inbox batch 5, which chose retiring the two bits over gating operations that do not exist): the `restore` / `purge` ObjectQL operations the bits claimed to gate have never existed — no destructive lifecycle verb is in the engine's dispatch vocabulary, which a test pins — so granting the bits delivered nothing, and an author who declared `allowPurge: false` believed a lock on GDPR hard-deletion existed when the operation itself did not. Both keys are retiredKey tombstones; the evaluator's pre-mapping rows retired in the same batch (a dispatched `restore`/`purge` stays denied fail-closed via the DESTRUCTIVE_OPERATIONS backstop, so there is no ungated window), and the mechanical conversion strips the keys from every object grant in `permissions[].objects` (pure lossless delete — they never had an effect to lose). `allowTransfer` is ENFORCED — the server guards who may rewrite a record's owner — and stays. The keys return with the M2 lifecycle initiative (feature + RBAC in one batch), which stays open as their anchor. Finally, it narrows the per-option `default` key OUT of the form-view options vocabulary (ADR-0049 declared-but-unenforced; maintainer ruling 2026-08-28 on the console form renderer's analysis, disposition 甲): `SelectOptionSchema` serves two surfaces and only the OBJECT-field face reads `default` (enforced there by a maintainer ruling of 2026-08-10 — `applyFieldDefaults` falls back to the option marked `default: true`; that face, its alias rows and its precedence pin are untouched). On a form-view field's option list the key parsed clean and nothing read it — the insert-path fallback consults the object definition's options, never a form view's, and no form renderer seeds a value from it (measured against the console's form controls, none of which reads the key; the ruled census found ZERO authored occurrences across the tree, the example apps and the published *.form.ts corpus). The FormView vocabulary's own option shape (`FormSelectOptionSchema`, ui/view.zod.ts) now refuses the key with the prescription; the mechanical conversion strips it from stored sources (pure lossless delete — it never had an effect on this surface to lose). It also retires the paper metadata-customization protocol whole (ADR-0049 enforce-or-remove, maintainer ruling 2026-08-29): `kernel/metadata-customization.zod.ts` — the three-layer platform/user patch-overlay model with field-level change tracking and a 3-way-merge story — was exported, documented as the customization architecture, and implemented ONLY by an unreachable `packages/metadata` limb (no route served the paper `…/overlay`/`…/effective` endpoints; the four optional service members were called only by their own unit tests). ADR-0126 §6 wall 4 supersedes it on the record ("nothing may build against it"). The module's seven defs and the three section-5 API contracts leave via RETIRED_DEFS_BY_MAJOR; the authorable carriers `MetadataPluginConfig.customizationPolicies` / `.mergeStrategy` and `MetadataManagerConfig.persistence.overlayWritable` are retiredKey tombstones (no D2 conversion — plugin/manager configs are not stack collection members, the additionalTypes reasoning). The customization that actually ships: ADR-0005's org overlay and ADR-0126's packaged-metadata model. Finally, it canonicalizes the legacy objectql field-key dialect `reference_to` → `reference` on lookup/master_detail fields (the server half of the maintainer's 2026-08-31 ruling that the server normalizes the protocol and the renderer only executes it). `FieldSchema` has always refused `reference_to` by name, but stored `sys_metadata` rows written by seams that bypass the parse still carry it, held up today only by objectui's `reference ?? reference_to` fallback arms — which the ruling's objectui half deletes. The mechanical conversion renames the key (the house precedence for a shadowed alias: a canonical `reference` wins, a disagreeing pair is kept for the author), replays on every stored-row rehydration so the serve face only ever emits the canonical spelling, and `os migrate meta` rewrites old sources; the authoring-surface rejection with its rename prescription is unchanged. It also retires `connector.errorMapping` (ADR-0049 enforce-or-remove; triage ruling 2026-09-02): `ErrorMappingConfig` (4 keys) and its `ErrorMappingRule[]` (7 keys) were authorable through `ConnectorSchema` — and, via `DeclarativeConnectorEntrySchema`, through `stack.connectors[]` and the `/meta/connector` door — and read by nothing: no provider, dispatcher or materializer ever mapped an external error through the rules, so `unmappedBehavior` configured nothing and a rule's `userMessage` was never shown to anyone. That spelling is the live API-error channel's (`ApiError.userMessage`), so an author who wrote a rule here reasonably believed they were marking a refusal for an end user; the failure was silent in both directions. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), the three defs — `integration/ErrorMappingConfig`, `integration/ErrorMappingRule` and the orphaned `integration/ConnectorErrorCategory` enum — leave via RETIRED_DEFS_BY_MAJOR, and the mechanical conversion strips the block from `connectors[]` (pure lossless delete; it never had an effect to lose). It also retires the fourteen hour/minute/day-shaped deadline keys of the incident-response, training and change-management families (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02): six on the incident-response schemas, five on the training schemas and three nested in the change-management schemas, every one on the published surface and read by nothing — the schemas are mounted by no stack key and registered as no metadata type — so a compliance author who wrote `triageDeadlineHours: 4` held a deadline the platform never kept. All fourteen are retiredKey tombstones (the schemas are not strict; a bare deletion would be a silent strip) with no D2 conversion, for the additionalTypes reason: none of these schemas is a stack collection member, so the chain has no seam. It then retires those three compliance-shaped families WHOLE (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05, ruled A, not roadmapped): the nineteen defs of `system/incident-response.zod.ts`, `system/training.zod.ts` and `system/change-management.zod.ts` — roughly a hundred declared keys, exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the liveness ledgers, read by nothing repo-wide (examples, skills and objectui at the pinned sha included) — leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry per family; the fourteen deadline-key tombstones leave with their defs' source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. Boolean capability claims such as `notifyRegulators`, `requirePostIncidentReview`, `trackCompletion` and `approval.required` were the sharpest declared-≠-enforced shape left: an author writing `notifyRegulators: true` held a compliance promise the platform never kept. And it resolves the branch the deadline-key ruling held open — no roadmapped e-signature consumer — so `ESignatureConfig.expirationDays` / `reminderDays` (`data/document.zod.ts`, defaults 30 / 7 days, read by nothing) are retiredKey tombstones with no D2 conversion (`document` is no stack collection member), registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry. Finally, it moves the unit of every duration-shaped `z.number()` key whose unit lived only in its description into the key name (maintainer ruling 2026-09-02, no grandfathered baseline): `hook.timeout` and `job.timeout` become `timeoutMs` (mechanical rename, retired from the load path), and the five keys with no stack seam — `MetadataManagerConfig.cache.ttl` / `cache.databaseLoader.ttl` (seconds and milliseconds fourteen lines apart under one name), `DriverOptions.timeout`, and the tenant `connectionPool.idleTimeout` / `accessControl.sessionTimeout` whose unit the reference pages never published — are retiredKey tombstones with a semantic entry each, naming the suffixed key. The `data`, `ui`, `ai` and `integration` remainder closes the same sweep: `dashboard.refreshInterval` → `refreshIntervalSeconds`, the connector pair `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds` (both halves later absorbed by the removal of the block each key lived in — see the connector retirements below), and the two datasource config keys `memory config.persistence.autoSaveInterval` → `autoSaveIntervalMs` (BOTH union arms — the `auto` arm forwards the same value to the same file adapter, so splitting them would have left one value with two spellings) and `turso config.timeout` → `timeoutMs` all convert, because a dashboard, a connector and a datasource are stack collection members stored as rows; the two with no seam — `ConversationAnalytics.duration`, computed at runtime and never authored, and `NoSQLQueryOptions.timeout`, a per-call driver argument — are retiredKey tombstones with a semantic entry each. That remainder is what takes `check:duration-unit-keys` to zero offenders over `packages/spec/src/**`; the gate goes red again by design when its declared population widens beyond that subtree. It also retires the three outer keys of `MetadataManagerConfig.cache` — `enabled`, `ttlSeconds` (the duration rename's respelling of `ttl`, never shipped) and `maxSize` — that the rename above surfaced (ADR-0049 enforce-or-remove): declared, defaulted and published, read by nothing — `MetadataManager` hands only `cache.databaseLoader` to the loader — so `cache: { enabled: false }` switched nothing off. All three are retiredKey tombstones registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry and no D2 conversion (a manager config is no stack collection member); the rename is folded into the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key. It also retires the seven cron-typed positions nothing evaluated (ADR-0049; the 2026-09-06 ruling retired each family rather than marking it experimental): the two export-schedule crons, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule` and the two disaster-recovery crons were parsed into the cron envelope and read by nothing (the D7 ledger row `cron-declared-unwired`). All seven are DELETED OUTRIGHT — no retiredKey tombstone, no RETIRED_KEYS_BY_MAJOR[18] entry, no D2 conversion and no D3 semantic entry — so this step replays nothing for them and `migrate meta` lists no edit: the keys simply stop existing. That the chain is silent does NOT make the deletion silent to an author: the PARSE strips (no schema here is `.strict()`), but above it `lintUnknownAuthoringKeys` names the dropped key for the one position a stack manifest reaches — `os validate` and `os build` both print `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its value is dropped at load.`, and `os validate --strict` EXITS 1 on that warning. The other six positions are unreachable from a manifest, so for those the parse-level strip is the whole of it. That is the maintainer ruling of 2026-09-10 on the retirement PR, taken over the seat recommendation to keep the connector D2, on the reading that customers do not upgrade major by major in order. It also retires the `type: 'page'` LIST-VIEW mount and its `pageName` binding (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09 「撤」). The member was added so a view could render nothing of its own and delegate to an already-published page, but only the spec half landed: no renderer ever routed it — objectui's list-view switch shares its default arm with `grid` — so a page view drew an empty table where the page belonged, and the three parse refusals policing the binding policed a mount that never mounted anything. The enum VALUE carries its prescription on the `type` enum's own error map (an enum-value narrowing has no tombstone to hang one on, the `exportOptions` 'pdf' precedent); `pageName` is a retiredKey tombstone on both list-view doors. The D2 conversion STRIPS both keys rather than rewriting `type` to `'grid'`: `type` defaults to `grid` in the schema, so deleting it lands the row on exactly what it already rendered without this registry guessing a view type. The surviving page mount is the app navigation item (`PageNavItem.pageName`), untouched. It also retires `object-kanban`'s `quickAdd` (ADR-0049 enforce-or-remove; the spec half of the director-seat ruling of 2026-09-08 that the board grows no inline record-creation path and retires the key). The board FORWARDED the key into the shared renderer but the affordance is gated on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied FUNCTION JSON cannot carry and no producer puts on an `object-kanban` node — so the gate was permanently false. The drop was NOT silent, and that is what made it worse than silence: objectui's html tier reported the published key as `unknown-prop`, the same diagnostic a typo gets, so an author following the contract met a tool contradicting it with no way to tell which side was wrong. A retiredKey tombstone on `ObjectKanbanPropsSchema` with one D2 conversion that is a pure lossless DELETE (the key never had an effect to preserve) scoped by component `type`. Delete the key; `object-kanban` offers no quick-add control. It also retires the bare STRING `sort` clause on the list-view doors (ruled 2026-09-07: the legacy string clause is retired, one spelling, the array). This is the PRODUCER half of the seam whose consumer half shipped in objectui first: `convertSortToQueryParams` now refuses a runtime string, so `ListViewSchema.sort` was minting documents its own consumer rejects — a document that validated upstream failed downstream, and the author was told off by the wrong layer. Like the `type` value above it is a VALUE narrowing with no tombstone to hang a prescription on, so the surviving array member's own error map carries it, keyed on `issue.input` being a string. The D2 conversion REWRITES rather than strips, because the clause is losslessly mechanical: `'created_at desc'` is the tuple `{ field, order }`, a bare field name meant ascending and is written out as `order: 'asc'`, and the comma-separated multi-key form becomes one entry per key in the same order. A string that does not parse as that grammar — the `'-field'` dialect above all — is left alone and meets the door instead: that dialect belongs to `RecordRelatedListProps.sort`, never reaches `convertSortToQueryParams`, and retiring it was NOT ruled. It also removes `page.assignedProfiles` (ADR-0090 D2 / ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12 「同意」). The key was authorable on the published `PageSchema` and named for the Profile concept ADR-0090 D2 deleted, while the schema's own alias table CORRECTED an authored `profiles:` into it — two files from `security/permission.zod.ts` answering the same word with "no Profile concept". Measured across this repository and objectui it had zero readers, so a page that "assigned profiles" was open to every caller who could reach it. It is a retiredKey tombstone on `PageSchema` — the def is still parsed from the `page` root, so there is an author to teach — and the two alias entries became refusals naming the permission-set route. The D2 conversion STRIPS the key — there is no lossless target, because which permission set a given profile name corresponds to is a judgement no walker can make, which is what the paired D3 semantic entry is for. Finally, it removes `aria` from the chart config (ADR-0049 enforce-or-remove; maintainer decision of 2026-09-12 — judge the protocol wrong for this one key). It is the last member of the `aria` family retired for the same measured reason as `dashboard.aria` and `dashboard.widgets[].aria` before it: an ARIA block an author can declare and nothing lowers to the DOM. It survived those two sweeps by depth — it sits inside the widget’s `chartConfig` bag, which no drill had reached until the per-key pass recorded in `liveness/dashboard.json`. That pass found `aria` to be the one `ChartConfigSchema` key with no reader on EITHER face: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react block omits it from ``’s `dataProps`. Remove rather than enforce, because the same chart config already carries a WORKING accessible-name channel in `description` (lowered as `role="img"` + `aria-label`), and giving `aria` a reader would put two accessible-name sources on one element behind a precedence rule nobody has written — one node, one accessibility vocabulary. The tombstone rides `ChartConfigSchema` and therefore copies into `ReportChartSchema`, so the key is registered twice; the D2 conversion STRIPS it from all three authored sites (`dashboards[].widgets[].chartConfig`, `reports[].chart`, `reports[].blocks[].chart`) as a pure lossless delete — it never had an effect to lose. The two alias spellings that pointed at it, `accessibility` and `ariaProps`, became refusals carrying the same prescription rather than renames onto a tombstone. It also states, and enforces, who owns a dataset-bound chart's STRUCTURE (ADR-0021; maintainer ruling 2026-09-12): the dataset decides which series exist and which column each one reads, `chartConfig` carries appearance, and `dashboard.widgets[].chartConfig`'s `type`, `xAxis`, `yAxis` and `series` are refused by name on that carrier — the widget's own `type` is the chart family and `dimensions`/`values` are the selection. An authored `yAxis[].field` was a live membership channel: the renderer synthesised a series from it when the chart declared none, so one authored axis could silently re-point a dataset-bound series at another column and the chart still drew. The D2 conversion strips the four keys from dashboard widgets only — `ReportChartSchema` and the inline-data react `` tier keep their own axes — and the paired semantic entry carries what the stripped keys were saying, because an authored axis field may name a column the widget never selected and no walker can move that intent into the dataset. Finally, it splits the translation bundle type in two (maintainer ruling 2026-09-13: settings copy belongs to the platform): the platform bundle keeps all eleven groups and the per-app bundle (`stack.translations`, `defineTranslationBundle`) no longer declares `settings`, which is keyed by `SettingsManifest.namespace` and only platform code declares a manifest. Both bundles load into ONE served tree, so an app-authored `settings` branch did not sit inert — but nor did it override the platform: the app’s bundles arrive in `AppPlugin`’s `start()` (Phase 2) and the platform’s at `kernel:ready` (Phase 3), and `deepMerge` gives the later source the leaf, so what an application had was a GAP FILLER on a namespace it does not own — rendering only where the platform bundle carried no string for that key and locale. The registered `translation` ITEM follows the file door (maintainer ruling 2026-09-22: one app metadata type, two authoring doors, one accepted shape) and no longer declares `settings` either; there the group had been STRONGER, because the runtime-authored layer is read over the shipped bundles, so a stored item overrode the platform’s own copy. The D2 conversion strips the group from per-app bundle entries and from bare items alike — the runtime translation sync replays it over every stored row before merging — and the paired semantic entry says what the strip means at each door, because a notice reading "(removed)" says neither that an item’s overrides give way to the platform’s string nor that a gap falls back to the manifest's own English literal. Finally it retires object `tenancy.organizationField` (ADR-0049 enforce-or-remove). The key named the column a PLATFORM ROW is stamped from, as opposed to the column the object is WALLED by (`tenantField`); on an ordinary object those are the same column, and the entire protocol declared it exactly once — on `sys_api_key`, a better-auth-managed credential table this platform ships and no application authors. Its three readers were all platform-row writers, scope-pinned by name, so an application declaration was inert by construction while still forcing every future piece of organization logic to ask "what if somebody set this?". The divergence is NOT retired, only its authorability: it moves to `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, keyed by object name and read by the stamp face alone, so audit stamping, the approval-row writer and the automation-run recorder keep their behaviour with no authorable input. The conversion is a lossless delete, and a lossless delete still leaves the author a judgment, which the family's D3 entry `object-tenancy-organization-field-retired` carries — an application whose tenant column genuinely is not `organization_id` declares `tenancy.tenantField`, which both walls the object and stamps its platform rows. It also retires `connector.connectionTimeoutMs` (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-22, letter A — the narrower SECOND decision the key was owed after the ruling that made its nine ledger siblings live deliberately left this one dead). Bounded, defaulted, `.describe()`d and served back by `/meta/connector`, so an author had every signal it worked — and no site ever applied it as a deadline. This retirement is NOT the zero-mention shape: five sites outside `packages/spec` read the key (the materialization fingerprint and the provider-context build in the automation service, `ctx.connectionTimeoutMs` in the `rest` and `openapi` provider factories, and the `?? 30000` fallbacks that put it back on the reported def), but every one is a pass-through whose only termini are the def `GET /connectors` echoes and the fingerprint that decides whether to re-materialize. The one mapping from authored policy onto the platform's outbound `fetch` was handed `retryConfig` and `requestTimeoutMs` only, so the key was carried and never honoured — the same parsed-unmarked-unenforced state ADR-0049 forbids, wearing a longer route. Nor was the `实现` arm available: a WHATWG `fetch` exposes one `AbortSignal` over the whole operation and never the connect phase, so bounding time-to-response with it would kill a slow-but-connected upstream the author meant to allow with a large `requestTimeoutMs`. `requestTimeoutMs` is the replacement and the bound the platform can keep. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), registered under both def keys because `DeclarativeConnectorEntrySchema` carries it too, both carriers wrapping the same private `ConnectorBaseSchema`; the D2 conversion strips it from `connectors[]` as a pure lossless delete — it never had an effect to lose — because a stored connector row CAN carry it (the `PUT /meta/connector/:name` door persists the authored value and the stored-row rehydration seam is live for this type, both measured); and the withdrawn `ConnectorProviderContext` member, which is code and has no authored source to rewrite, leaves via the paired semantic entry instead. Finally it gives the one-filter-orthography convergence (ruled 2026-08-25: one filter spelling platform-wide, the rule array) its mechanical half at rest (ruled 2026-09-12): the D2 conversion `page-component-filter-record-to-rule-array` rewrites a record-form or single-level AST `filter` at the converged rule-array doors — `dataSource.filter`, the `object-*` / `element:number` / `element:record_picker` `filter` props and `object-grid.defaultFilters` — to the rule array wherever the mapping is lossless, and leaves a filter carrying `$and` / `$or` / `$not` (or any part with no lossless rule spelling) exactly as stored, because flattening a combinator changes which rows a page selects. It is retired from the load path, so authors are still refused at the door and taught the array; the stored-row seams and this chain replay it. It also retires the view item's `owner` and `hidden` (ADR-0049 enforce-or-remove). Both sat on the view-item identity layer, were accepted by the strict authoring door and by the wire member the `view` write door validates, and were stored verbatim — and nothing read either: both switcher read paths filter on `viewKind` + `object` and sort on `order`, so `hidden: true` hid nothing, and no per-user scope ever read `owner`, so a view marked as one user's was listed for everyone who can read the object. Per-user view scoping is a parked direction (ADR-0017, amended 2026-09-04), not a shipped mechanism. Both keys are `retiredKey()` tombstones on the SHARED shape, because that shape also feeds the `.strip()` wire member, where a bare deletion would be a silent strip. The D2 conversion `view-item-owner-hidden-removed` strips them from the view item RECORD spelling only, as a lossless delete, in both collections a record travels in — `views` (stack sources and stored rows) and the assembled-manifest `viewItems` channel (package export, environment artifacts), whose registration parse would otherwise refuse an artifact assembled before this release. It also retires a `joined` report's `chart` at both coordinates (ADR-0049 enforce-or-remove): the joined renderer draws each block as a table and returns before the one container `chart` read, and no renderer reads a block's `chart` at all, so a chart on a joined report parsed, passed the chart-bindings lint, and plotted nothing. The key leaves `JoinedReportBlockSchema`'s closed shape (its `guidance` table carries the prescription) and the joined arm of `ReportSchema`'s refinement refuses a container `chart`; `chart` stays live on every non-joined report. The D2 conversion `report-joined-chart-removed` strips both as a pure lossless delete — neither ever had an effect to lose — because a stored report row CAN carry them (the Studio report form offered a block `chart` input until this change); it is retired from the load path, so authors are refused at parse rather than rewritten. It retires the view item's `owner` / `hidden` pair on the flattened overlay door too (ADR-0049; the view item's disposition for the same key pair, followed here as triage directed): the lean personalization PUT with no `config` declared its own `owner` / `hidden`, accepted and stored them, and nothing read either. Both are `retiredKey()` tombstones on the two overlay members with the view item's own prescription texts, and the D2 conversion `view-overlay-owner-hidden-removed` strips them from the flattened spelling (no `config`, no container slot) in `views` and `viewItems`, so a stored overlay row is served without them. A row that held other view keys is then valid again and re-saves; a row that held nothing but its identity and the two keys is left identity-only, which the door refuses, so it is badged invalid, refused on a whole-row re-save and reported `failed` by `os migrate meta --stored --apply` until it is deleted or given the setting its author meant. Its D3 record is the semantic entry `view-overlay-owner-hidden-retired`. It also narrows form `layout` to `vertical` | `horizontal` on both surfaces that declared the four-arm enum — the `object-form` page component and the form view (ADR-0049 enforce-or-remove). No renderer ever gave `inline` or `grid` a behaviour of its own: every form presentation folded both to `vertical`, multi-column is `columns` (honoured under either layout), and `inline` is a toolbar / filter-row pattern rather than a record-form layout — redundant vocabulary under the maintainer's family criterion (a capability mainstream platforms have is served once, here by `columns`), retired with no alias window. Both enums refuse the two values with a per-value prescription naming `columns`; the D2 conversion `form-layout-inline-grid-to-vertical` rewrites them to `vertical` (behaviour-preserving, `columns` untouched) on `object-form` page components, on every form payload a view carries, and on the assembled-manifest `viewItems` channel. It also removes `currencyConfig.precision` (ADR-0049 enforce-or-remove): declared and validated against ISO 4217, read by no renderer or runtime — a currency amount's decimal places are its currency's ISO 4217 minor unit, derived from the currency itself. The D2 conversion `currency-config-precision-removed` strips it from every field's `currencyConfig` as a pure lossless delete, which matters most at rest: the schema used to bake `precision: 2` into parse output, so stored object rows and built artifacts carry it without anyone having written it. Retired from the load path; an authored key is refused with the prescription. It also retires the RLS policy's `tags` (ADR-0049 enforce-or-remove; graded RETIRE by the maintainer's criterion — no mainstream platform tags a row-level policy): the key promised categorization and reporting for governance and compliance, and nothing ever read it — the RLS compiler never consulted it and no preview rendered it. It is a `retiredKey()` tombstone on `RowLevelSecurityPolicySchema` (the `priority` posture one key over), and the D2 conversion `permission-rls-tags-removed` strips it from every policy in `permissions[].rowLevelSecurity` as a lossless delete, so a stored permission row that still carries it replays clean. It is retired from the load path, so authors are refused at parse rather than rewritten. Its D3 record is the semantic entry `permission-rls-tags-retired`. Finally, it removes `aria` from the action (ADR-0049 enforce-or-remove), the fourth member of the `aria` family after `dashboard.aria`, `dashboard.widgets[].aria` and the chart config's, and retired for the same measured reason: an ARIA block an author can declare and nothing lowers to the DOM. The liveness ledger had graded it `live` on an uncited "partial" note with no reader behind it; at the pinned renderer, none of the surfaces that render an action — button, icon, menu, group and bar, the row and bulk action menus, the record quick-actions toolbar — reads it. Remove rather than enforce, because every one of them already takes the accessible name from the action's required `label` (visible text, or `aria-label` on an icon-only action), and the node that places the actions carries the node-level `aria` block — a per-action block would be a second spelling of both. The D2 conversion `action-aria-removed` STRIPS the key from stack actions and object-nested actions as a pure lossless delete, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `action-aria-retired`. It also retires the connector resilience family (ADR-0049 enforce-or-remove, one batch): `connector.health` — the `healthCheck` probe (eight keys) and the `circuitBreaker` (six) — `connector.status` and the connector-nested `webhooks`, sixteen authorable keys with no reader outside the spec package. No loop ever polled a connector endpoint or tripped a breaker; nothing read an authored `status` (the runtime publishes a computed `state`, and participation is `enabled`); and a webhook nested in a connector was never registered as a `webhook` item, so it was never materialized or delivered — the top-level `webhooks:` collection is the delivered one. The three carrier keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `status`, defaulted `'inactive'`, joins `connectionTimeoutMs` in the retired-default residue stage, because every 17.x parse emitted it into every connector. Seven defs leave whole — `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` — and the D2 conversion `connector-resilience-keys-removed` strips the three keys from `connectors[]` and stored rows as a pure lossless delete (the nested webhooks are stripped, never moved: moving them would start deliveries that never happened). It ABSORBS the breaker half of the duration rename above: `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` is no longer converted, because the whole block it lived in is now removed. Finally it makes edge-branched `decision` nodes EXCLUSIVE (maintainer ruling 2026-09-23, 「跟主流对齐」): the first conditioned out-edge that holds, in declaration order, is the branch, and taking every true branch is the declared `mode: 'inclusive'`. The D2 conversion `flow-decision-mode-inclusive-explicit` writes that key onto every decision with two or more conditioned out-edges and no `conditions` list, so a flow written while every true branch ran keeps its behaviour; it is a default flip, so it is retired from the load path AND refused by the flow rehydration seam and the artifact-ingestion door, and replays only here — the paired semantic entry carries the judgment the diff then asks for. BREAKING for flows stored in `sys_metadata`, by maintainer ruling: such a decision with no `mode` takes the first-match meaning on upgrade and nothing rewrites it; `os migrate meta --stored` lists each one for review, and `mode: 'inclusive'` is the one-line fix where a node meant every branch. It also retires the list view's own `tabs` (ADR-0049 enforce-or-remove). The key parsed and was stored at every list-view door and drew nothing: a list view's own `tabs` has no reader, the one component that would draw it has no production mount, and the tab strip above an object's records is the saved-view switcher, which renders one tab per `listViews` entry and reads no `tabs` key (`userFilters.tabs`, a different key of the same element type, is read and rendered, and stays). The key is a `retiredKey()` tombstone on the list-view shape (its prescription says how to move each tab to a named `listViews` entry); `ViewTabSchema` itself stays, because the page-only `userFilters.tabs` preset bar reuses it and renders. The D2 conversion `view-list-tabs-removed` strips the key from every list payload in `stack.views[]` as a lossless delete, and is retired from the load path, so authors are refused at parse rather than rewritten. It retires the inner `name` on cube members — `measures..name` and `dimensions..name` (ADR-0049 enforce-or-remove) — by the mainstream criterion: Cube.dev and LookML key a member by its declared name, with no second inner name that can disagree. Both member bags are records, and every consumer already resolved a member by its record KEY, publishing and querying it as `.`; the REQUIRED inner copy was read by nothing, and one that disagreed with its key was silently ignored. The keys are retiredKey tombstones on `MetricSchema` and `DimensionSchema`, and because the key was required, every stored or built cube carries it: the D2 conversion `cube-member-inner-name-removed` strips it from every member of every cube, retired from the load path, and its notice prints a disagreeing value beside the key that stays. Its D3 record is the semantic entry `cube-member-inner-name-retired`, which asks the author of a disagreeing name which spelling they meant. It also retires the connector `triggers` array (ADR-0049 enforce-or-remove; ADR-0041 keeps connector-event triggers in its third tier, as their own trigger package): the `ConnectorTrigger` shape — `key`, `label`, `description`, `type` (`polling` / `webhook`) and `intervalSeconds` — was read by nothing. The automation engine registered a connector's actions only, its trigger registry holds FLOW trigger kinds that no connector trigger ever entered, no polling loop read an interval and no receiver was driven by a `webhook` trigger, so a declared trigger never started a flow. `triggers` is a retiredKey tombstone on `ConnectorBaseSchema`, registered under both carrier defs; the provider-bound refusal of the key, whose reason (the provider derives triggers) was untrue, is gone with it, since the tombstone refuses every value on every carrier. `ConnectorTrigger` leaves whole, and the D2 conversion `connector-triggers-removed` strips the array from `connectors[]` and stored rows as a pure lossless delete — never turning a trigger into a flow, which is the author's decision (an `api` flow for an external event, a `schedule` flow for a scheduled pull, each calling the connector's action). It ABSORBS the trigger half of the connector duration rename (its breaker half went with `health` above), so `connector-health-and-trigger-durations-unit-in-key`, with neither half left, is no longer in this step. It also retires a cube's `refreshKey` whole — the refresh cadence `every` and the data-change probe `sql` (ADR-0049 enforce-or-remove). Nothing read either key, and no analytics result is cached, so a declared cadence refreshed nothing and every query was computed when it was asked, as it still is. The key is a retiredKey tombstone on `CubeSchema`, and the D2 conversion `cube-refresh-key-removed` strips the whole block from every cube as a pure lossless delete, retired from the load path. Its D3 record is the semantic entry `cube-refresh-key-retired`. A refresh cadence is declared again when a result cache exists. It also narrows the `time` stored form to the zone-less wall clock the record validator already enforces (ADR-0053 D-C1), so a field default or an action param default or value with a `Z` or a UTC offset is refused when it is authored or submitted rather than on every insert that falls back to it. The D2 conversion `time-default-utc-suffix-dropped` drops a `Z` or a zero offset, which names the same wall clock, and leaves a non-zero offset as stored for its author to rewrite; its D3 record is the semantic entry `time-default-zone-refused`. It also retires the page header's `breadcrumb` switch (ADR-0049 enforce-or-remove): no renderer ever drew a trail for it — objectui drew an empty slot that nothing filled — and the navigation trail is drawn once, by the app shell's header. The key is a retiredKey tombstone on `PageHeaderProps`, beside the `icon` that row lost at 17, and the D2 conversion `page-header-breadcrumb-removed` strips it from every `page:header`, `true` and `false` alike, retired from the load path. Its D3 record is the semantic entry `page-header-breadcrumb-retired`. The `nav:breadcrumb` component type is not part of it: the Studio page palette still offers it. It also retires connector-attached sync from the connector (ADR-0049, the ENFORCE route by ruling): `connector.syncConfig` — `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode`, `filters` — and `connector.fieldMappings` — `source`, `target`, `defaultValue`, `dataType`, `required`, `syncMode` — fourteen keys no engine ever executed, whose `latest_wins` and `soft_delete` defaults read as configured policy and did nothing. The capability is mainstream, so the definition moves rather than lapses: every mainstream platform binds a sync to its TARGET, so a `mapping` gains `connectorSource`, the `rest` / `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, and a `job` sets the cadence (no schedule key returns to the connector). That binding is declared in this step and executed in a later one. Both connector keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `DataSyncConfig`, `SyncStrategy`, `ConnectorConflictResolution` and `ConnectorFieldMapping` leave whole; and the D2 conversion `connector-sync-keys-removed` strips both keys from `connectors[]` and stored rows as a pure lossless delete — never writing a `mapping`, which would start writes that never happened. It also narrows an analytics cube member's `sql` — `measures..sql` and `dimensions..sql` — to a column reference: a field of the cube's object, a relationship path ending in one, or `'*'` (maintainer ruling D, ADR-0021 "zero raw SQL / zero raw expressions" carried from the dataset layer to the cube members it compiles to; ADR-0049 enforce-or-remove). A SQL expression there names no single field, so no platform check could judge which fields it reads, and the two analytics strategies never agreed on it: the raw-SQL path ran it verbatim, the ObjectQL path refused it. It is now refused at parse with a prescription naming the ADR-0021 dataset form — a measure with its own structured `filter` for a conditional count or sum, and `derived: { op, of: [...] }` over named measures for a ratio, sum, difference or product. No D2 conversion: an expression has no mechanical rewrite into a dataset, so the semantic entry `cube-member-sql-expression-retired` carries the move, including the scale change a ratio makes (a `derived` ratio is a 0–1 fraction). It also closes the form view's inline grid columns: `subforms[].columns`, on `view.form` and on `formViews` entries, was `z.array(z.any())` while a relationship field's `inlineColumns` was already the strict `InlineGridColumnSchema`, so a mis-keyed column published clean and drew a blank grid column, and `scale` on a currency column, which the other carrier refuses under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), published green. The carrier now references that schema, so both carriers are judged by it, with its own prescriptions. The D2 conversion `form-view-subform-columns-canonicalized` respells a `{ field }` column as `{ name }`, the respelling `field-column-lists-canonicalized` makes on `inlineColumns`: it rewrites stored rows and assembled artifacts and lists the edit under `os migrate meta`, and it is retired from the load path, so an author writing `field` meets the refusal. A view saved with a failing column is refused with the column schema's prescription, and a stored row carrying one is diagnosed at rehydration; neither is stripped, because which column an unknown key or a mixed `field`/`name` entry meant is the author's call, and a conversion that dropped the key would accept at load what the parse now refuses. Its D3 record is the semantic entry `form-view-subform-columns-closed`. On both carriers, the reach of `inline-grid-column-currency-scale-refused` extends to a column that declares no `type`: such a column takes its type from the child field, which the column schema cannot see, when the console hydrates it, so `defineStack`'s cross-reference check re-parses a column whose `name` is a `currency` field of the child object as the type it renders as, and the refusal of its `scale` is the column schema's own. Reach: the child object must be declared in the same stack; a column naming no field of it, or a subform whose child object comes from another package, is not judged there. It has no D2 conversion, for the declared-type entry's reason: deleting the key is the migration, and a conversion that dropped it would accept it at load, the grace window ruling B refused. Its D3 record is the semantic entry `inline-grid-column-identity-only-currency-scale-refused`. It also gives the executor target of an action one spelling on the page blocks that run one. `ActionSchema` has always refused `endpoint` with the rename to `target`, while the `action:button` and `action:icon` component rows declared `endpoint` as a key of their own, and the console's `api` handler reads `target` only — so an `api` button authored with `endpoint` was accepted by the props gate and called nothing. The rows now refuse it with the same rename, read from the one alias table both share. The D2 conversion `action-block-endpoint-to-target` renames the key on an `api` action, where the rename is lossless, retired from the load path so authors are refused at the door while stored rows and `os migrate meta` replay it; an `endpoint` on a block with no `actionType` or another one is left as stored and reported as a TODO. Its D3 record is the semantic entry `action-block-endpoint-spelling-retired`. Finally, it retires the form field's `publicPicker` block (ADR-0087 D2, immediate — the maintainer's ruling E, which reverses the earlier ruling that had declared it): an anonymous public form no longer offers record search. The block opted a lookup, `master_detail` or `user` field on a public form into a picker served by an unauthenticated route; that route is deleted, and the public-form resolve route now leaves those three field types off the anonymous rendering unconditionally. The schema refuses the key with the prescription; the mechanical conversion `form-field-public-picker-removed` strips it from old sources and stored rows (lossless in effect — its only reader was the deleted route), and the semantic entry asks the author how a visitor should now choose: a `select` field with static `options`, or a form behind sign-in. It also closes the third carrier of the inline grid column: an `object-master-detail-form` page block's `details` was `z.array(z.unknown())`, so a key its renderer does not read and `scale` on a currency column, which the other two carriers refuse under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), went through `objectstack validate` green. Each detail entry is now a strict shape of the twelve keys the renderer reads, and its `columns` references `InlineGridColumnSchema`. Page-component `properties` is read by the component-props gate, which reports a failing entry or column as an advisory finding, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered; the authored census found nothing to respell. `defineStack`'s identity-only check reaches the block wherever a page carries it, with the reach `inline-grid-column-identity-only-currency-scale-refused` records for the other two carriers. Its D3 record is the semantic entry `ui-object-master-detail-form-details-closed`. It closes the fourth carrier the same way: `record:line_items` had no `ComponentPropsMap` row — it was the one entry on the string-arm registration ledger — so the component-props gate skipped its props, and the showcase project page's five `field`-keyed columns published green over a grid of empty cells. The row declares the fifteen keys the renderer reads, requires `relationshipField` and at least one column, and its `columns` references `InlineGridColumnSchema`; the showcase columns are respelled `name` in the same change. The panel draws its columns as authored, with no hydration from the child object's field, so `defineStack`'s identity-only check does not reach it. Its D3 record is the semantic entry `ui-record-line-items-props-closed`. It also holds an ADR-0021 dataset's `field` — `dimensions[].field` and `measures[].field` — to the accept set the cube members it compiles to already hold, from one shared declaration: a field of the dataset's object, a relationship path ending in one, and on a measure also `'*'` (ADR-0021 "zero raw SQL / zero raw expressions"; ADR-0049 enforce-or-remove). The slot was a bare string that parsed any expression, while the analytics dataset door already refused one on every query, so an expression could be saved and never answered. It is now refused at parse with a prescription naming the ADR-0021 form — a measure with its own structured `filter`, or `derived: { op, of: [...] }` over named measures — and so are an empty string (a count omits `field` instead) and `'*'` on a dimension, which names no axis. The one lossless repair is D2: `dataset-count-measure-empty-field-removed` drops a `count` measure's empty `field`, which still counts rows. An expression has no mechanical rewrite into a column, so the semantic entry `dataset-member-field-expression-refused` carries the rest. It also closes the export options of an `object-grid` page block. `exportOptions` was `z.unknown()`, so a bare format array — the list view's legacy spelling, which the list view lifts to `{ formats }` — was accepted on the grid, whose renderer reads `exportOptions.formats` and lifts nothing: the export menu offered its csv/json default and the author's list was dropped. The row now takes the list view's five-member export options object by identity, not the list view's union, and refuses a bare array with the object form named, a format outside the enum and an undeclared key. Page-component `properties` is read by the component-props gate, which reports these as advisory findings, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered: the bare array never worked here, and lifting it would change the menu a deployed grid shows. The authored census found nothing to respell. Its D3 record is the semantic entry `ui-object-grid-export-options-closed`. It also makes an agent's structured output JSON-only (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, enforces `structuredOutput` on every final answer and refused four of its members before an agent's first turn: the `regex`, `grammar` and `xml` formats — no key ever carried a pattern or grammar to check against, and an answer is checked only as JSON — and the `coerce_types` step, for which no coercion engine exists. All four are refused at parse with a prescription, and the D2 conversion `agent-structured-output-refused-members-removed` deletes a block whose `format` was retired, deletes a retired `fallbackFormat` and drops `coerce_types` from the pipeline, retired from the load path. It also retires the metric sub-caption at both ends (maintainer ruling 2026-10-01, which reverses the 2026-08-06 ruling that gave it a translation key of its own; ADR-0049). The widget translation key `dashboards..widgets..subCaption` overlaid a widget's `options.description`, a key the dashboard schema never declared and no authored widget wrote, so the overlay in `translateDashboard` was its only writer. The overlay is removed, `subCaption` is a `retiredKey()` tombstone on the widget translation node, and its former `subtitle` alias now carries the retirement instead of a rename onto a key that accepts nothing. A widget keeps one authored description, `widget.description`, which renders as the card-header subtitle and is translated by the widget's `description` key. The D2 conversion `translation-widget-sub-caption-removed` strips the key from bundle entries and stored translation items as a lossless delete of what is served, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `translation-widget-sub-caption-retired`. It also makes an agent's memory contract state exactly what the runtime honours (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, recalls the newest `maxEntries` long-term notes before the first round, writes one every `reflectionInterval` delivered interactions, and keeps them in its own database store; before an agent's first turn it refused the `vector` store (the old default) and `redis`, an enabled `longTerm` missing either number, and a `reflectionInterval` without one. So `longTerm.store` is retired as a whole key — the memory store is platform infrastructure, not agent metadata — and the D2 conversion `agent-memory-long-term-store-removed` deletes it, losslessly, retired from the load path; and with long-term memory enabled both numbers are required at authoring, with no default declared, so an upgrading author chooses them. It also retires an agent's conversation state machine, `agent.lifecycle` (ADR-0049 enforce-or-remove). It was parsed and never read: no runtime moved an agent through a declared state or refused an undeclared transition, and enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 rejected. What it reached for is served elsewhere — a conversation phase is a skill selected by its `triggerConditions`, a multi-step process is a Flow, a record's status transitions are the `state_machine` validation rule — so authoring refuses the key with that prescription, and the D2 conversion `agent-lifecycle-removed` deletes it, losslessly, retired from the load path. The XState `StateMachineSchema` family, kept by ADR-0020 only for this door, left the package with it. It also retires a cube measure's custom-SQL-expression types — `number`, `string` and `boolean` from `AggregationMetricType`, so from `measures..type` (ADR-0049 enforce-or-remove). They marked a measure whose `sql` was the whole computation, and with that `sql` now a column reference they had nothing left to compute: the raw-SQL path returned the column unaggregated and the ObjectQL path refused the measure. Each is refused at parse with a prescription naming the six aggregates. No D2 conversion: the column alone does not say which aggregate the author meant, so the semantic entry `cube-metric-expression-types-retired` carries the choice, and a stored cube that still carries one is refused rather than rewritten. It also retires `object-grid`'s `resizableColumns` (ADR-0049 enforce-or-remove; objectui's ruling that `resizable` is canonical, under the startup rule of immediate retirement): the legacy second spelling of `resizable`, read only as `schema.resizable ?? schema.resizableColumns` (measured at the `.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). One switch, two spellings, and zero writers in either repository, so there is no window. A retiredKey tombstone on `ObjectGridPropsSchema` with one D2 conversion that follows the renderer's precedence: the value moves to `resizable` when that is absent, and strips as a lossless delete when it is present (it was never read then). Its D3 record is the semantic entry `object-grid-resizable-columns-retired`. It also types seven members of an `object-grid` page block: `rowHeight`, `rowColor`, `navigation`, `conditionalFormatting`, `bulkActionDefs`, `aggregations` and `operations` were `z.unknown()` (an array of it for `bulkActionDefs`), although the grid reads each with one shape, so `rowHeight: 42` passed every door and rendered as `compact`. The five a list view also declares take the list view's own schemas by reference; `aggregations` takes the measured `[{ field, type }]` with the query AST's aggregation functions, and `operations` the four booleans a grid read point names (`create`, `update`, `delete`, `export`), refusing `read` and `import`, which nothing reads. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-row-members-typed`. It also types `navigation` on the `object-map`, `object-gantt` and `object-tree` page blocks (the first stage of the `ComponentPropsMap` `z.unknown()` close-out): each renderer hands it to the shared navigation hook, which reads `navigation.mode` and falls back to `page`, so `navigation: 42` and a bare mode string passed every door and opened the record page. The three rows now take the list view's `NavigationConfigSchema` by reference, the carrier the grid, kanban, calendar and timeline blocks already take. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-map-gantt-tree-navigation-typed`. It also retires the `ai:chat_window` page element (ADR-0049 enforce-or-remove), the `user:profile` shape one namespace over: no renderer for it ever shipped, and none is wanted — the console leaves it unregistered on purpose, because the floating chat overlay it mounts on every page is the supported AI chat entry point — so a page that placed one validated clean and drew "Unknown component type", and its four props configured nothing. The name leaves `PageComponentType` and is refused by name at the node, its `ComponentPropsMap` row stays as a whole-bag refusal carrying the same prescription, and the props def `AIChatWindowProps` is unpublished. No conversion is registered: the only edit is deleting the node, a layout decision that is the author's. Its D3 record is the semantic entry `ui-ai-chat-window-retired`; `ai:suggestion` is unchanged. It also narrows page `requires` to the kinds whose source is compiled at save (ADR-0080 §5; maintainer ruling 2026-10-03, letter A): the plugin-namespace list is derived from an html page's source when the page is saved, while on `react`, `full` and `slotted` pages nothing derived it, the Studio page editor dropped it on every save, and a load-time warning was its one reader. `PageSchema` now accepts the key only when `kind` is `html` or its deprecated alias `jsx`, and refuses it at `requires` on every other kind, a page that omits `kind` included, naming the key, the page's kind and the compiled kinds. The key stays live on html pages, so there is no tombstone. The D2 conversion `page-requires-non-compiled-kind-removed` deletes the key from those pages, retired from the load path, so stored rows and artifacts replay clean while authored sources are refused until edited; the delete is lossless. Its D3 record is the semantic entry `page-requires-non-compiled-kind-refused`. It also types eight list members of the `object-grid`, `object-kanban` and `object-calendar` page blocks (the second stage of the `ComponentPropsMap` `z.unknown()` close-out): the grid's `fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the kanban's `columns` and the calendar's `calendar` were `z.unknown()` (an array of it for the lists), although each renderer reads them with one shape, so a `{ name }` entry in `bulkActions` passed every door and was skipped. The members a list view declares take the list view's own by reference (`batchActions`, the spelling the grid reads first, takes `bulkActions`'s); the grid's `fields` and `selectable` and the kanban lane take the measured shape. The grid's `columns` stays open: its group headers draw an authored column's `options`, which the list view's column entry does not declare. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-kanban-calendar-list-members-typed`. It also refuses, at parse, a hook whose `body` targets a table of stored metadata, `sys_metadata` or `sys_metadata_history` (maintainer ruling 2026-10-03, letter A: an app-authored body may not touch those tables, whose only writer for a body is the metadata protocol). The runtime already refused such a hook where a body becomes a handler, so it never ran, while the metadata save door answered 200 for it. `HookSchema` now refuses the same set at `object`, or at the list member, with the runtime's prescription to change metadata through the metadata API, judged by the one predicate the runtime uses: a hook with a `body` in any form whose target names either table. A code `handler` and the wildcard `'*'` stay outside it, as they are at registration. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused hook carries no intent a rewrite could keep. Its D3 record is the semantic entry `hook-body-stored-metadata-target-refused`. It also types four members of the `object-form` page block (the third stage of the `ComponentPropsMap` `z.unknown()` close-out): `contentLayout`, `submitBehavior`, `navigateOnSuccess` and `mobile` were `z.unknown()`, although the form reads each with one shape, so a `submitBehavior` `kind` the form does not know passed every door and fell through to the thank-you panel. `submitBehavior` takes the form view's own block by reference; the other three take the measured shape. The form's `fields` and `sections` and the master-detail form's two stay open — the form draws a `{ name }` field entry and an inline runtime field inside a section, which the typed shapes would refuse — and `customFields` stays open until the spec declares the runtime form field its entries are. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-members-typed`. It also completes the `element:text` `variant` convergence (the second release of the ruled two-release split): the enum is the nine values `ui:text` publishes — `h1`-`h6`, `body`, `caption`, `overline` — and the pre-convergence spellings `heading` and `subheading`, which every release since the nine were added still accepted, are refused by name with a prescription naming the level to write. The D2 conversion `element-text-variant-heading-levels` rewrites `heading` to `h2` and `subheading` to `h3` on every `element:text` page component — the heading element each one always rendered, so the outline is unchanged and the heading takes that level's style. The `body` default for an absent `variant` is unchanged. It also types two members of the `object-metric` page block (the fourth stage of the `ComponentPropsMap` `z.unknown()` close-out): `aggregate` and `trend` were `z.unknown()`, although the tile reads each with one shape, so `aggregate: 'count'` and a trend with no `value` passed every door, and the tile asked the server for a measure it does not have, or painted a lone `%`. `aggregate` takes the query AST's aggregation functions and the chart aggregate's `groupBy` union by reference, with `groupBy` optional because a metric is one number; `trend` takes the badge's measured shape. `drillDown` and `compareTo` stay open: each by-reference candidate declares a key the tile never reads (the chart drill-down's `filter`, the dashboard comparison's `dimension`), and the chart drill-down refuses the `report` the tile draws, so each waits on a ruling. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-aggregate-trend-typed`. It also retires an `object-master-detail-form` detail entry's `sortField` (ADR-0049 enforce-or-remove; the spec half of objectui's own retirement of the override). The console stopped reading the authored override: the field its line grid stamps with each line's position on drag-reorder is derived from the child object — its first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort` — and the pinned console had crossed that change while the spec still declared the key, so an authored value published green and was dropped. A retiredKey tombstone on the strict detail entry with one D2 conversion that is a pure lossless DELETE scoped by component `type` and by position (`properties.details[]`); its D3 entry `object-master-detail-form-detail-sort-field-retired` carries the one judgment left, whether the child object declares the field the line order is kept in. It also types the `object-metric` page block's `compareTo` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out) to the tile's read, per the ruling between the reference and the read: `{ kind }`, with `kind` the dashboard widget comparison's own vocabulary by reference, and `dimension` refused by name, because this inline tile shifts the date macros in its own `filter` and never reads a dataset time dimension. A bare kind string, a kind outside the two and a `dimension` passed every door and compared the wrong window. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-compare-to-typed`. It also types the `object-metric` page block's `drillDown` to the tile's read (the same stage and ruling): its five list members — `enabled`, `title`, `target`, `columns`, `maxRows` — are the chart drill-down's own by reference, and `filter` and `mode` are refused by name, because a metric tile has no click event for a drill filter to resolve against and no row for `mode` to open; both passed every door and were ignored. The drill `report` stays open: the tile draws a dataset-bound report, but the spec declares no drill report yet, and declares that contract first. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-typed`. It also types the `object-grid` page block's `columns` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out), the list member the second stage held: the grid's group headers drew a column's `options`, which the list view's column entry does not declare, and objectui has since retired that read and takes the labels from the object field only. So the member takes the list view's own `columns` by reference — all field names or all column entries — and a column keyed `accessorKey` / `header` / `name`, a mixed list or an undeclared column key (`editable`, `options`, `reference`), which passed every door and drew no column or was ignored, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-columns-typed`. It also refuses, at parse, a flow `create_record`, `update_record` or `delete_record` node whose `objectName` is the string `sys_metadata` or `sys_metadata_history` (the maintainer ruling of 2026-10-03, letter A, applied to flows: app-authored work may not write those tables, whose only writer is the metadata protocol). The runtime already refused such a node before any write, at its first run, while every authoring door accepted the flow. `FlowSchema` now refuses the same set at `nodes.N.config.objectName`, through the one judge `registerFlow` and `objectstack validate` share, with the runtime's prescription to change metadata through the metadata API: one of those three write nodes whose `objectName` names either table by exact name. A `get_record` node and a dynamic target stay outside it: the run judges the name it hands the data engine. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused node carries no intent a rewrite could keep. Its D3 record is the semantic entry `flow-write-node-stored-metadata-target-refused`. It also types the top-level `fields` of the `object-form` and `object-master-detail-form` page blocks (the last stage of the `ComponentPropsMap` `z.unknown()` close-out), the two members the third stage held: the form drew a `{ name }` field entry its own page-builder guide taught, with a `label`, `type` and `required` it silently dropped, and objectui has since retired that entry from every authoring face, drawing only a stored one by its name. So both rows take field names, objectui's own declaration of the member, and refuse an object entry with what to write instead — a `{ name }` entry is its bare name, and a `{ field }` entry belongs in a section. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-fields-names-typed`. It also types the `object-gantt` page block's `markers` (the same stage): its entries were `z.unknown()` because the marker contract lived only in objectui, so a marker with no `date`, a numeric `date` or a misspelled member passed every door and the chart drew no line, or drew it unlabelled. The spec now declares objectui's own authoring declaration of a marker, `{ date, label?, color? }` with `date` a string, and the row takes it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-gantt-markers-typed`. It also types the `object-timeline` page block's `mapping` (the same stage): the binding record — four optional field names for an entry's title, date, description and marker colour — was `z.unknown()` because its contract lived only in objectui, so a bare field name or a misspelled member passed every door and the rail drew the default field. The spec now declares objectui's own declaration of it, and the row takes it. The stage's other members — the metric drill-down's `report`, the form's `customFields` and both forms' `sections`, the timeline's `items` and the action containers' members — stay open: each contract has more than one viable shape that no ruling decides yet. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-mapping-typed`. It also types the `object-kanban` page block's `conditionalFormatting`, the one member the `ComponentPropsMap` `z.unknown()` close-out held for a ruling: it was `z.unknown()` while objectui's kanban also authored a native rule dialect the list view refuses, so `42` or a rule with no `style` passed every door and the board painted no card for it. objectui has since made the list view's `{ condition, style }` rule the member's only authoring dialect, and the board evaluates it with the grid's evaluator, so the row takes the list view's own member by reference, as `object-grid` does. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-kanban-conditional-formatting-typed`. It also requires every block of a `joined` report to bind a `dataset` (ADR-0021 single-form, enforced under ADR-0049 enforce-or-remove): the schema comment and the reports guide both said each block is dataset-bound, but the joined arm of `ReportSchema`'s refinement required only a non-empty `blocks`, so a block with no `dataset` parsed, passed `objectstack validate` and every save door, and drew nothing: the joined renderer issues no query for it, and a report whose blocks all lack one falls through to the pre-9.0 presentation bridge, which issues none either. The arm now refuses each such block at `blocks[i].dataset`, naming the block, with the prescription to bind it to a dataset; `dataset` stays optional on the block shape, which is read only on a `joined` report. No key is removed, so there is no tombstone, and no D2 conversion exists: only the author knows which dataset a block was meant to show. Its D3 record is the semantic entry `ui-report-joined-block-dataset-required`. It also types the `object-form` page block's `customFields`, one of the two contracts the `ComponentPropsMap` `z.unknown()` close-out held as forks and the maintainer has since ruled: each member is the runtime form field the form draws, which the spec did not declare, so a member with no `name` or a misspelled member passed every door and the form drew the field without it. The spec now declares a closed runtime form field of the members the form draws, in camelCase, keyed by `name` — the `grid` widget's snake_case keys stay out until the widget reads a camelCase spelling — and the row takes a list of it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-custom-fields-typed`. It also types the `sections` of the `object-form` and `object-master-detail-form` page blocks, the other ruled fork: a section's `fields` draws an inline runtime form field beside a name and the form view's `{ field }` entry, which the stored form view's section refuses, so the sections stayed `z.unknown()` and a misspelled key passed every door. Both rows now take one page-block section shape of their own — the form view's section keys plus those three entry arms, the inline arm the runtime form field — in canonical spellings only: a page block's `properties` is never parsed on the way to the form, so a deprecated section `visibleOn` or a string `columns`, which a form view folds at parse, was dropped, and is refused with the canonical spelling. The stored form view is unchanged. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-sections-typed`. It then types the three members the stages above held open, as the maintainer ruled them on the decision card for those forks. The `object-metric` drill-down's `report` is `ReportSchema`, by reference (fork 1, letter B): it waited until a joined report refused a block that binds no dataset, and since then every report the member admits is one the drill drawer draws — a report with no `dataset`, a bare report name or a `{ name }` reference, which the drawer answered by listing the records, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-report-typed`. It types the `object-timeline` page block's `items` (fork 4, letter B): each entry is one of objectui's two ruled kinds, closed — a feed entry `{ time, title, description, variant, icon, content, className }` or a gantt row `{ label, items }` of bars `{ title, startDate, endDate, variant }`, each date a string or epoch milliseconds — and a row refinement pairs each entry with the kind the block's `variant` selects, so a feed entry with no `title`, or a gantt row on a feed timeline, is refused instead of drawn empty. A feed entry's `content` (child components) is held unjudged until a writer appears. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-items-typed`. And it types the members of the `action:group` and `action:menu` page blocks, the last of those forks (the same card, fork 5, letter A): each member was an open record the container draws and runs itself, so a misspelled key, a node-style `actionType` or an `endpoint` no `api` handler reads passed every door. A member now takes `action:button`'s keys with its executor spelled `type`, measured from the containers' reads — an `action:menu` item reads no `size` and declares none — with the rows' prescriptions; `outcomeMessages`, a member `className` and a member `properties.params` are refused, and `outcomeMessages` stays undeclared on all four action blocks as one decision. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-members-typed`. It then closes the one static-values spelling those members still accepted and the containers drop: an `action:group` or `action:menu` member's `params` takes the input list, an `ActionParam[]` array, only, unless the member's `type` is `api`, whose object `params` keeps its request-payload window. `params` carries one shape and no second value-bag key is declared, so an object `params` on any other member, which parsed and then reached no action, is refused at `actions.N.params` with the prescription to author an action with static parameter values as its own `action:button` node. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-member-params-array-only`. It also judges an `approval` flow node's `config` at parse against the contract the spec declares for it, `ApprovalNodeConfigSchema`, WHOLE. The approval executor fails the node on any issue of that contract, while `objectstack validate` and `objectstack compile` exited 0 on an undeclared `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the artifact. The approval node now joins a declared contract map beside the builtin executor contracts, read by the one judge `registerFlow` and `objectstack validate` share, with no plugin loaded: an undeclared key or a refused value is refused at `nodes.N.config.` in the contract's own words, its did-you-mean included, and a key left out as before. The builtin arm stays presence-only. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what the author meant. Its D3 record is the semantic entry `flow-approval-node-config-contract-refused`. Then the builtin arm stops being presence-only: a present value a builtin node's executor contract refuses is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Every builtin executor parses its config against that contract before it acts, so a `create_record` `outputVariable: 42` or a screen field `min: '1'` used to pass `objectstack validate` and `objectstack compile`, register, and fail every run that reached the node. The arm judges only what the build can know the run will parse: never a value carrying a `{token}`, whatever its slot's type (held back by ruling, not admitted: outside `http` such a token in a number or boolean slot still fails at its first run, so those slots take a literal); on `http`, which parses after interpolating, only token-free values and never the credential-held `signingSecret`; on a `loop`, only one with a `body`; on the region containers, never the region slots. Key membership is untouched. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know the value the author meant. Its D3 record is the semantic entry `flow-builtin-node-config-values-refused`. It also retires the flat-list form of a package manifest's `permissions` (ADR-0049 enforce-or-remove): `ManifestPermissionsSchema` was a union of a list of permission strings and the structured ADR-0025 block `{ services, hooks, network, fs }`, and nothing ever acted on the list — the loader registers the consented grant set, never the manifest's request — so the block is now the only form. A list is refused at parse with its prescription, and the D2 conversion `manifest-permissions-string-list-removed` strips it from the stack's manifest and every `packages[].manifest` as a lossless delete, retired from the load path; translating what each dropped string meant into the four lists is the author's judgement, not a rewrite. It also makes a declared index state its uniqueness scope (ADR-0120 D1, staged to this protocol by D7). On `indexes[].unique`, bare `true` was the one spelling whose scope was positional: it built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. The parse now refuses it with a prescription naming both words — `'global'` (installation-wide, the index bare `true` built) and `'organization'` (one holder per organization). Field-level `unique: true` is untouched. The D2 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'`, which is lossless and drift-free by construction, retired from the load path so authors are refused at the door while stored rows, built artifacts and `os migrate meta` replay it. Its D3 record is the semantic entry `declared-index-bare-unique-true-retired`: whether each respelled index was really meant installation-wide is the author's call. It also takes the injected organization column off seven deployment-level platform tables — `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` (ADR-0131 D7). A writer census found no writer that attributes a row of any of them to an organization, so the column only ever held NULL, and under a walled posture the tenant wall hid every row from every reader. Each now declares `systemFields: { tenant: false }` and the object-level capability gate `requiredPermissions: ['manage_platform_settings']`: with no column there is no wall, so reads are governed by object permission, and the gate keeps one organization's administrator off another organization's rows. Nothing in stack metadata is rewritten; an existing database keeps the column as an orphan the boot drift report names, and `os migrate apply --allow-destructive` drops it. The D3 records are the seven `sys-*-organization-column-retired` semantic entries. It also refuses, at parse, a flow edge that does not resolve in its own graph or that repeats an earlier one. An edge's `source` and `target` must name nodes of the graph that declares it — the flow's own nodes, or the region body's for an edge inside a region — because the engine resolves them there alone, and a dangling edge carried the run nowhere, silently; and an edge with the same `source`, `target`, `type`, `condition` and branch `label` as an earlier edge of that graph is refused, because the engine runs a target once per out-edge it selects and a copy ran it again. Both are judged in the region walk the node-id rule uses, so `objectstack validate`, `registerFlow` and the metadata save door agree. No key is removed, so there is no tombstone, and no D2 conversion exists: a dangling endpoint carries no intent a rewrite could recover, and dropping a copy changes how often its target runs. Its D3 record is the semantic entry `flow-edge-unresolved-or-repeated-refused`. And the builtin arm judges key membership where no other door does: a key a `script` or `subflow` node's executor contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Those two descriptors publish no `configSchema`, so `registerFlow`'s undeclared-key check skipped them, while their executors parse the strict contract and refuse the node on an undeclared key: a `script` `bogusKey` used to pass `objectstack validate`, `objectstack compile` and registration and fail every run that reached the node. Every other builtin keeps its undeclared keys at registration, against its descriptor; a retired `script` key keeps its tombstone. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what an undeclared key was meant to be. Its D3 record is the semantic entry `flow-script-subflow-config-undeclared-keys-refused`. It also moves the settings cascade's global rung out of the tenant-scoped `sys_setting` into the new tenant-less `sys_platform_setting` (ADR-0131 D7): one row per namespace and key for the deployment, no organization column, reads governed by the `manage_platform_settings` capability. The settings service writes a global-scope key there and reads the rung from there alone, and the `global` option of `sys_setting.scope` retires because no write reaches it. The cascade order and the `global` resolution source are unchanged. Nothing moves automatically: the v18 upgrade ceremony moves existing global rows, `sys_secret` handles included, and they open unchanged because the ADR-0128 AAD binds no holder object and no organization. The D3 record is the `sys-setting-global-rung-moved` semantic entry. It also retires the document family WHOLE (ADR-0049 enforce-or-remove; the ruling of record on PDF and print documents, letter B′, 2026-10-08: "A document is a page with a print declaration; no new template type"): the four defs of `data/document.zod.ts` — `data/DocumentTemplate` (a docx template with placeholders), `data/Document`, `data/ESignatureConfig` and the orphaned `data/DocumentVersion` — exported from `@objectstack/spec/data`, mounted by no stack key, registered as no metadata type and read by nothing in this repository, objectui or hotcrm, leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry, so that "template" means one thing: a printable document is a page that declares `print`. The `ESignatureConfig` deadline-key tombstones leave with their def's source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. It retires the single-brace `{…}` template dialect from the flow VALUE slots (the C half of the maintainer's ruling D on the flow expression dialects): the `assignment` node's values, in all three shapes, and the `fields` map of `create_record` and `update_record`, where a CEL value envelope is already the expression form. A string there is now the literal text it spells, and one carrying a `{…}` token is refused — by `FlowValueSlotSchema`, `registerFlow`, `objectstack validate` and the executor alike — with the CEL spelling of each token. No D2 conversion exists: every authored spelling was measured lossy (an absent key writes nothing under the template and fails under CEL; CEL divides two integers as integers), so which value an absent key should write is the author's judgment. The date macros and the `$User` paths keep their meaning until CEL can spell them. Its D3 record is the semantic entry `flow-value-slot-template-dialect-refused`. It also takes the injected organization column off the compliance ledger, `sys_audit_log` (ADR-0131 D7): some of its rows are about deployment-level actions no organization owns, so the organization a row is about stays in the attribution field `tenant_id`, which every writer already stamps, and never becomes the tenancy anchor. With no column there is no wall, so a platform administrator now reads the rows about no organization too; an organization reader is scoped to the rows about its active organization by the platform row policy `sys_audit_log_org`, stripped when no wall is enforced, and `organization_admin` names the ledger without the superuser bits so its wildcard bypass cannot skip that policy. Per-tenant retention partitions on `tenant_id`. Nothing moves automatically: an existing database keeps the column as an orphan the boot drift report names, for the v18 ceremony to drop once its values are confirmed in `tenant_id`. The D3 record is the `sys-audit-log-organization-column-retired` semantic entry. Then the builtin key arm covers every builtin whose contract registration could judge: a key the executor contract of a `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, closed with the rename-or-remove remedy. Registration's descriptor walk refused those keys already, after `objectstack validate` and `objectstack compile` had passed them, and it now stands aside for those types, so each has one judge; the declared key sets were measured equal first, so registration refuses what it refused before. `try_catch` waits for its contract's `retry` to close (below): it stripped an unknown key where its descriptor closes it. No key is removed, so there is no tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `flow-builtin-node-config-undeclared-keys-refused`. It executes ADR-0032 Decision 3 in the flow TEXT slots — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`: they render through the formula template engine, so their placeholders are `{{ }}` holes, a variable path with an optional formatter (the engine's hole grammar now admits a `$`-named variable, so `{{ $error.message }}` is a hole). A single-brace `{…}` token there is refused — by the node contract, `registerFlow` and `objectstack validate` alike — with the hole spelling of each path token, or, for arithmetic, a function, a date macro or a run-user path, the `assignment` that computes it into a variable. No D2 conversion exists: the 17.x interpolator and the engine render a `Date` differently (JSON-quoted against ISO text), and a whole-slot object differently in a screen or `end` text, so the rewrite is the author's to check. Every other flow string keeps the single-brace dialect. Its D3 record is the semantic entry `flow-text-slot-single-brace-refused`. It also makes the deployment's platform-global declaration total (ADR-0131 D7): an object a deployment declares platform-global in its `org-scoping` service's `platformGlobalObjects` gets no organization column on that deployment, because the injected-columns plan reads the declaration, so the organization wall and the driver agree by having nothing to scope. The engine reads it at its plugin start, before the first schema sync, once every plugin init has run, and re-plans the objects registered before it; the security layer's stand-down for such an object retires with it. An absent declaration changes nothing, and a malformed one is refused and declares nothing. Nothing moves automatically: a declaring deployment's existing table keeps the column as an orphan the boot drift report names. The D3 record is the `platform-global-object-organization-column-retired` semantic entry. It also retires the `sys_view_definition` platform object as inert (ADR-0131 D13): no framework code wrote or read its rows, and runtime-authored views are `view` items in `sys_metadata`. The object, its two registrations, its `kernel:ready` active-row index migration and that migration's exports leave, and its name leaves the platform-object registry. Nothing in stack metadata is rewritten; an existing database keeps the table, which no platform path drops. The D3 record is the `sys-view-definition-retired` semantic entry. Then the retry policy closes, and `try_catch` joins the builtin key arm: `RetryPolicySchema`, the one declaration behind `job.retryPolicy` and a `try_catch` node's `retry`, refuses a key it does not declare, naming it with a did-you-mean, where it used to strip it — and with opt-in defaults a stripped `maxRetries` meant no retry at all. No writer relied on the strip. With `retry` closed to the five keys the descriptor declares, a key a `try_catch` node's contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, and the descriptor walk keeps plugin node types only. A `retryDelayMs` the conversion leaves beside a different `backoffMs` meets its tombstone there, as it met the walk. No key is removed, so there is no new tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `try-catch-and-retry-policy-undeclared-keys-refused`. In those same text slots a `{{ }}` hole may root at a `$`-named variable only when the flow engine binds it (`$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`): a hole such as `{{ $User.Id }}`, which the 17.x contracts accepted as a plain string and which renders nothing under the template engine, is refused by the node contract, `registerFlow` and `objectstack validate` with the remedy its single-brace spelling gets — compute the value with an `assignment` node, then write the variable as a hole. The template engine binds no new variable, and no D2 conversion exists: what the hole was meant to read is not in the flow. Its D3 record is the semantic entry `flow-text-slot-unbound-dollar-root-refused`. The binding keys follow the same rule, so a flow cannot bind a `$` name it is then refused to read: a node's `outputVariable` (`get_record`, `create_record`, `map`, `script`, `subflow`) refuses a name that starts with `$`, and a `try_catch` `errorVariable` refuses every one but the engine's own `$error`, its default. The remedy is the same name without the `$`, read as `{{ name }}`. Each key states the rule as a `pattern`, so the published JSON Schema refuses what the parse refuses, and the node contract, `registerFlow`, `objectstack validate` and the run itself refuse such a name at the key. No D2 conversion exists: the bare name may already be bound in the flow, and the reads of the old name sit in every dialect a flow string speaks, so the rename is the author's. Its D3 record is the semantic entry `flow-binding-variable-dollar-name-refused`. +Protocol 18 extends the publish-time refusal of unresolved placeholders, which protocol 17 applied to datasource connection config, to the memory driver's config-material persistence keys: `persistence.path` (file persistence and the `auto` override) and `persistence.key` (localStorage and the `auto` override) refuse `${…}` placeholder syntax at publish. Nothing resolves a placeholder there — the driver would create a literal `./${DATA_DIR}/…` path or write under the literal localStorage key — the same authored-under-a-false-belief shape, one surface over. The memory driver's `initialData` stays deliberately unjudged: it carries arbitrary record values, where a literal `${…}` may be legitimate data. It also retires `MetadataPluginConfig.additionalTypes` (ADR-0049 enforce-or-remove): the key was documented as THE plugin kind-declaration channel and read by nothing — the manager's type registry is seeded once from `DEFAULT_METADATA_TYPE_REGISTRY` and never merged with it, so authoring it configured nothing. A kind enters the live set as a side effect of registering an item of that kind. It also refuses malformed field `scale`/`precision` declarations: both are digit counts, so a non-integer or negative value (`scale: 2.5`, `precision: -1`) has no defined meaning — the write-time `scale` check, which refuses an over-scale value rather than rounding it, deliberately left it unenforced rather than invent floor/round semantics, which made the declaration silently inert. The schema now refuses both at parse (`z.number().int().min(0)`); the mechanical conversion deletes a malformed value from old sources and stored rows (behaviour-preserving), and the semantic entry tells the author to re-declare the count they meant. Finally, it removes the `objects["*"].allowExport` grant from the shipped admin permission sets — `admin_full_access`, `organization_admin` and the derived `organization_admin_no_bypass`. Measured on 17.0.0 GA, that wildcard made the export axis undeniable for an org admin: an application could declare an object exportable by nobody and the platform exported it anyway, with no supported opt-out, because a code-package set cannot be edited (`403 [not_overridable]`) and the admin held no app-authored set in which to write the per-object `false` that would have won. It is the earlier removal of `member_default`'s CRUD wildcard applied to the export axis, which had kept its wildcard by omission rather than by decision. From 18 an admin exports exactly what an app-authored set grants — a posture the same run measured to be already precise. Unlike everything else in this step it changes no schema, so nothing refuses at publish: the upgrade signal is behavioural and belongs here. Finally, it converges `record:chatter` / `record:discussion` `position` on the renderer's vocabulary (maintainer ruling 2026-08-15): the schema declared `sidebar`/`inline`/`drawer` — values no renderer branch ever compared, so the schema's own `sidebar` default silently rendered in flow while the value that actually docks the panel (`right`) was refused at publish. The row now speaks `bottom`/`right`/`left`; the mechanical conversion rewrites the old spellings (`sidebar` → `right`, `inline` → `bottom`, `drawer` → `right`), and the three schema defaults (`position`, `collapsible`, `defaultCollapsed`) are dropped per the `maxVisible` principle — renderer fallbacks stay the renderer's facts. It also retires `targetVariable` on `element:text_input` and `element:record_picker` (ADR-0049 enforce-or-remove): a declarative hint with zero readers in any repo — the live binding runs the other direction, resolved from the page variable whose `source` names the component's `id` (PageVariableSchema) — so an author who wrote only `targetVariable` got an input that wrote nothing, with a success receipt. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); the tombstone's prescription says how to declare the binding that works. Finally, it retires the whole `element:filter` element (ADR-0049 enforce-or-remove at ELEMENT grain — the wider finding that the `targetVariable` retirement recorded and left for its own card): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. List surfaces own their filtering: a view's `userFilters` quick-filter bar / the list toolbar's filter builder. It also retires the whole `element:form` element (ADR-0049 enforce-or-remove at ELEMENT grain — the `element:filter` shape one element over, recorded by that retirement's own verdict sweep): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion naming the live replacement, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. Use the object-bound `object-form` block instead — rendered, designer-publishable, its props declared for the component-props gate, and carrying the same intent (`objectName`, `fields`, `mode`, `submitText`). It also closes the two explicit column lists on relationship fields: `field.inlineColumns` entries are now the strict, name-keyed InlineGridColumnSchema (mirroring the objectui grid renderer's measured reads — objectui aligned the widget to `name` and retired the `field` spelling with no tolerant alias), and `field.relatedListColumns` entries are child field-name strings (the only form the related-list renderer hydrates fully). Both were z.array(z.any()) — a mis-keyed column published clean and rendered as blank cells with the right row count. The mechanical conversion respells inline `{ field }` entries as `{ name }` and folds related-list column objects to their identity string; unknown keys are named rejections at publish from this major. It also retires `measures..filters` on analytics cubes (ADR-0049 enforce-or-remove): a declared per-metric raw-SQL filter with zero consumers — both SQL strategies aggregate the metric's `sql` and never read `filters`, so a hand-authored `filters: [{ sql: "stage = 'closed_won'" }]` parsed, registered, and silently returned the UNFILTERED aggregate under the author's metric name (the same defect the dataset path had, on a hand-authored cube; the dataset half was repaired through its own structured channel when the analytics strategy began compiling each dataset measure's `filter`). The raw-SQL fragment also ran against the platform's structured-FilterCondition direction — it cannot be parameterized, re-targeted per driver dialect, or walked by the lint filter rules. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); filter at query time with `where`, or use an ADR-0021 dataset measure's structured `filter` (a metric's own `sql` is a column reference, see `cube-member-sql-expression-retired`). Finally, it retires the stack `themes` carrier and `ThemeSchema` whole (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, disposition B: 退役授权面): the pipeline was live from the authoring gate through artifact ingest and stopped there — zero non-test readers of stored `theme` items, `theme` never a registered metadata type, no first-party app mounting the spec-aware provider, nothing selecting an active theme — so an authored theme shipped through every green gate and changed nothing on screen. `app.branding` stays the one colour surface; objectui's ThemeEngine/ThemeContext and their unit tests are retained. Semantic rather than mechanical: an authored palette has no lossless target (N themes vs M apps is a judgment), so the entry prescribes the hand move instead of deleting authored content silently. It also retires the `record:highlights` highlight-field `icon` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, executing the 2026-08-20 census verdict): a declared key with zero read points in any direction — objectui's renderer normalized the authored object and carried `icon` into a highlight chip with no icon slot, `useRegisterHighlightFields` registers field NAMES only (structurally unable to carry it), and the Studio designer publishes the field list as plain strings — while six author-facing surfaces advertised the key (the shape that got the reference-rail `icon` refused, on the highlight chip). The mechanical conversion strips the key from the object entries of every `record:highlights` `fields[]` (pure lossless delete — the chip renders label and value only, so it never had an effect to lose); there is no replacement, and the live neighbour `readonly`, declared because the chip's read-only gate reads it, is untouched. It also retires the import mapping `lookup` transform's steering params (ADR-0049 enforce-or-remove — the sub-walk half of the 17.0.0 mapping cleanup that retired `extractQuery` / `errorPolicy` / `batchSize`): `fieldMapping[].params.object` / `.fromField` / `.toField` / `.autoCreate` declared a per-entry reference-resolution dialect the import path never implemented — `lookup` copies the cell through and resolution runs off the target field's own metadata — and `autoCreate` read as create-if-missing while an unresolved reference actually fails the row (`import_reference_not_found`), with or without the key. The eleven alias spellings convert to guidance so every spelling lands on the prescription; the mechanical conversion strips the four keys from stored sources (pure lossless deletes — none ever had an effect to lose). Finally, it retires the component-translation copy key `pages..components..submitLabel` and its `submit` alias (ADR-0049; maintainer ruling 2026-08-22): the face is measured, not mirrored — each copy key exists because some component in `ComponentPropsMap` declares it — and `submitLabel`'s only declarer was `element:form`, retired whole above, so the key had no declared component left to translate and the resolver overlay was its only reader. Retire won over re-anchor because the live form surface (`object-form`) speaks `submitText` (`I18nLabelSchema`), localizable at its own authoring site; re-anchoring would have widened the face for one word. The mechanical conversion strips the key from stored bundles and items (pure lossless delete — nothing read it once `element:form` was retired), at the acknowledged cost of dropping the bespoke-component route for that one word. Finally, it retires `page.components[].responsive` and the whole `ResponsiveConfig` layout vocabulary it carried (ADR-0049 D2; maintainer ruling 2026-08-22): the key was the destination the `dashboard.widgets[].responsive` tombstone prescribed as the live alternative, and a two-repo measurement (tsc-probe methodology with positive and negative controls) found the claim false — objectui's two implementations of the contract (`useResponsiveConfig`, `ResponsiveProtocol`) had zero callers and nothing read `.responsive` off a page component, so the prescribed migration moved an inert key to an inert key while the platform's own error message vouched for it. The same change repairs every shipped text that carried that redirect. `ResponsiveConfigSchema`, its two breakpoint maps and the `BreakpointName` enum had no other authorable carrier and leave with the key (RETIRED_DEFS_BY_MAJOR[18]); the live per-breakpoint channel on a page component is `responsiveStyles` (ADR-0065), which objectui really compiles. The mechanical conversion strips the key from stored pages (pure lossless delete — it never had an effect to lose). Finally, it retires nine of the eleven members of the plugin manifest's `contributes` block (ADR-0049 enforce-or-remove; triage graded 2026-08-21, cloud census leg discharged clean 2026-08-24): `events`, `menus`, `themes`, `translations`, `actions`, `drivers`, `fieldTypes`, `functions` and `commands`. A census of all three repos, with controls, measured that the whole monorepo contains exactly one non-test read of `manifest.contributes`, and it reads `kinds`; the other nine members parsed, entered the manifest, and changed nothing, while published docs and the schema's own JSDoc kept teaching them (`commands` documented Commander.js resolution the CLI dropped for oclif; `fieldTypes` advertised a registration seam that never existed). All nine are retiredKey tombstones mirroring `loading`; `kinds` survives (live reader), and `routes` was left to a ruling of its own, which retired it as well (the `plugin-manifest-contributes-routes-retired` entry). D3 semantic, no D2 conversion: a manifest is not a stack collection member, so a conversion would be a transform with no seam that ever runs. On the surviving `kinds` bucket it also retires the `globs` sub-field (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-24): the schema promised that declaring `globs` enables file-type discovery, but discovery globs `filePatterns` off the metadata type registry — which `contributes.kinds` does not extend, as `metadata-plugin.zod.ts` records outright — so an authored `globs` was accepted, stored, served back through `GET /metadata/kind`, and never consulted (zero value reads; the only non-test occurrences were the schema declaration and two type positions). The `kind` bucket itself and its `id` are untouched; file-type discovery stays single-channel on `filePatterns`. D3 semantic `plugin-manifest-kind-globs-retired`, same no-seam reasoning. Finally, it retires `object-grid`'s `defaultSort` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, decision-inbox batch 4 — the producer half of objectui's `table.defaultSort` retirement, which the maintainer's 2026-08-22 「接受所有」 ruling on objectui's sort sink ordered): the legacy second spelling of `sort`, a single `{ field, order }` pair the renderer read only when `sort` was absent (measured at the `.objectui-sha` pin `190fbd01d`, `plugin-grid/src/ObjectGrid.tsx:1244-1246` and `:2847`, which wraps it `[schema.defaultSort]` — the exact array shape `sort` carries). One intent, two spellings; objectui's mirror schema is parity-test-only and parses nothing at runtime, so only the spec strictObject can refuse the key. The mechanical conversion carries the pair over — renamed to `sort` and wrapped in the array shape — when `sort` is absent, and strips it as a pure lossless delete when `sort` is present (the renderer's own precedence made it unread then). Finally, it retires the object-permission lifecycle bits `allowRestore` and `allowPurge` (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-26, decision-inbox batch 5, which chose retiring the two bits over gating operations that do not exist): the `restore` / `purge` ObjectQL operations the bits claimed to gate have never existed — no destructive lifecycle verb is in the engine's dispatch vocabulary, which a test pins — so granting the bits delivered nothing, and an author who declared `allowPurge: false` believed a lock on GDPR hard-deletion existed when the operation itself did not. Both keys are retiredKey tombstones; the evaluator's pre-mapping rows retired in the same batch (a dispatched `restore`/`purge` stays denied fail-closed via the DESTRUCTIVE_OPERATIONS backstop, so there is no ungated window), and the mechanical conversion strips the keys from every object grant in `permissions[].objects` (pure lossless delete — they never had an effect to lose). `allowTransfer` is ENFORCED — the server guards who may rewrite a record's owner — and stays. The keys return with the M2 lifecycle initiative (feature + RBAC in one batch), which stays open as their anchor. Finally, it narrows the per-option `default` key OUT of the form-view options vocabulary (ADR-0049 declared-but-unenforced; maintainer ruling 2026-08-28 on the console form renderer's analysis, disposition 甲): `SelectOptionSchema` serves two surfaces and only the OBJECT-field face reads `default` (enforced there by a maintainer ruling of 2026-08-10 — `applyFieldDefaults` falls back to the option marked `default: true`; that face, its alias rows and its precedence pin are untouched). On a form-view field's option list the key parsed clean and nothing read it — the insert-path fallback consults the object definition's options, never a form view's, and no form renderer seeds a value from it (measured against the console's form controls, none of which reads the key; the ruled census found ZERO authored occurrences across the tree, the example apps and the published *.form.ts corpus). The FormView vocabulary's own option shape (`FormSelectOptionSchema`, ui/view.zod.ts) now refuses the key with the prescription; the mechanical conversion strips it from stored sources (pure lossless delete — it never had an effect on this surface to lose). It also retires the paper metadata-customization protocol whole (ADR-0049 enforce-or-remove, maintainer ruling 2026-08-29): `kernel/metadata-customization.zod.ts` — the three-layer platform/user patch-overlay model with field-level change tracking and a 3-way-merge story — was exported, documented as the customization architecture, and implemented ONLY by an unreachable `packages/metadata` limb (no route served the paper `…/overlay`/`…/effective` endpoints; the four optional service members were called only by their own unit tests). ADR-0126 §6 wall 4 supersedes it on the record ("nothing may build against it"). The module's seven defs and the three section-5 API contracts leave via RETIRED_DEFS_BY_MAJOR; the authorable carriers `MetadataPluginConfig.customizationPolicies` / `.mergeStrategy` and `MetadataManagerConfig.persistence.overlayWritable` are retiredKey tombstones (no D2 conversion — plugin/manager configs are not stack collection members, the additionalTypes reasoning). The customization that actually ships: ADR-0005's org overlay and ADR-0126's packaged-metadata model. Finally, it canonicalizes the legacy objectql field-key dialect `reference_to` → `reference` on lookup/master_detail fields (the server half of the maintainer's 2026-08-31 ruling that the server normalizes the protocol and the renderer only executes it). `FieldSchema` has always refused `reference_to` by name, but stored `sys_metadata` rows written by seams that bypass the parse still carry it, held up today only by objectui's `reference ?? reference_to` fallback arms — which the ruling's objectui half deletes. The mechanical conversion renames the key (the house precedence for a shadowed alias: a canonical `reference` wins, a disagreeing pair is kept for the author), replays on every stored-row rehydration so the serve face only ever emits the canonical spelling, and `os migrate meta` rewrites old sources; the authoring-surface rejection with its rename prescription is unchanged. It also retires `connector.errorMapping` (ADR-0049 enforce-or-remove; triage ruling 2026-09-02): `ErrorMappingConfig` (4 keys) and its `ErrorMappingRule[]` (7 keys) were authorable through `ConnectorSchema` — and, via `DeclarativeConnectorEntrySchema`, through `stack.connectors[]` and the `/meta/connector` door — and read by nothing: no provider, dispatcher or materializer ever mapped an external error through the rules, so `unmappedBehavior` configured nothing and a rule's `userMessage` was never shown to anyone. That spelling is the live API-error channel's (`ApiError.userMessage`), so an author who wrote a rule here reasonably believed they were marking a refusal for an end user; the failure was silent in both directions. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), the three defs — `integration/ErrorMappingConfig`, `integration/ErrorMappingRule` and the orphaned `integration/ConnectorErrorCategory` enum — leave via RETIRED_DEFS_BY_MAJOR, and the mechanical conversion strips the block from `connectors[]` (pure lossless delete; it never had an effect to lose). It also retires the fourteen hour/minute/day-shaped deadline keys of the incident-response, training and change-management families (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02): six on the incident-response schemas, five on the training schemas and three nested in the change-management schemas, every one on the published surface and read by nothing — the schemas are mounted by no stack key and registered as no metadata type — so a compliance author who wrote `triageDeadlineHours: 4` held a deadline the platform never kept. All fourteen are retiredKey tombstones (the schemas are not strict; a bare deletion would be a silent strip) with no D2 conversion, for the additionalTypes reason: none of these schemas is a stack collection member, so the chain has no seam. It then retires those three compliance-shaped families WHOLE (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05, ruled A, not roadmapped): the nineteen defs of `system/incident-response.zod.ts`, `system/training.zod.ts` and `system/change-management.zod.ts` — roughly a hundred declared keys, exported from `@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, absent from the liveness ledgers, read by nothing repo-wide (examples, skills and objectui at the pinned sha included) — leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry per family; the fourteen deadline-key tombstones leave with their defs' source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. Boolean capability claims such as `notifyRegulators`, `requirePostIncidentReview`, `trackCompletion` and `approval.required` were the sharpest declared-≠-enforced shape left: an author writing `notifyRegulators: true` held a compliance promise the platform never kept. And it resolves the branch the deadline-key ruling held open — no roadmapped e-signature consumer — so `ESignatureConfig.expirationDays` / `reminderDays` (`data/document.zod.ts`, defaults 30 / 7 days, read by nothing) are retiredKey tombstones with no D2 conversion (`document` is no stack collection member), registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry. Finally, it moves the unit of every duration-shaped `z.number()` key whose unit lived only in its description into the key name (maintainer ruling 2026-09-02, no grandfathered baseline): `hook.timeout` and `job.timeout` become `timeoutMs` (mechanical rename, retired from the load path), and the five keys with no stack seam — `MetadataManagerConfig.cache.ttl` / `cache.databaseLoader.ttl` (seconds and milliseconds fourteen lines apart under one name), `DriverOptions.timeout`, and the tenant `connectionPool.idleTimeout` / `accessControl.sessionTimeout` whose unit the reference pages never published — are retiredKey tombstones with a semantic entry each, naming the suffixed key. The `data`, `ui`, `ai` and `integration` remainder closes the same sweep: `dashboard.refreshInterval` → `refreshIntervalSeconds`, the connector pair `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds` (both halves later absorbed by the removal of the block each key lived in — see the connector retirements below), and the two datasource config keys `memory config.persistence.autoSaveInterval` → `autoSaveIntervalMs` (BOTH union arms — the `auto` arm forwards the same value to the same file adapter, so splitting them would have left one value with two spellings) and `turso config.timeout` → `timeoutMs` all convert, because a dashboard, a connector and a datasource are stack collection members stored as rows; the two with no seam — `ConversationAnalytics.duration`, computed at runtime and never authored, and `NoSQLQueryOptions.timeout`, a per-call driver argument — are retiredKey tombstones with a semantic entry each. That remainder is what takes `check:duration-unit-keys` to zero offenders over `packages/spec/src/**`; the gate goes red again by design when its declared population widens beyond that subtree. It also retires the three outer keys of `MetadataManagerConfig.cache` — `enabled`, `ttlSeconds` (the duration rename's respelling of `ttl`, never shipped) and `maxSize` — that the rename above surfaced (ADR-0049 enforce-or-remove): declared, defaulted and published, read by nothing — `MetadataManager` hands only `cache.databaseLoader` to the loader — so `cache: { enabled: false }` switched nothing off. All three are retiredKey tombstones registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry and no D2 conversion (a manager config is no stack collection member); the rename is folded into the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key. It also retires the seven cron-typed positions nothing evaluated (ADR-0049; the 2026-09-06 ruling retired each family rather than marking it experimental): the two export-schedule crons, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule` and the two disaster-recovery crons were parsed into the cron envelope and read by nothing (the D7 ledger row `cron-declared-unwired`). All seven are DELETED OUTRIGHT — no retiredKey tombstone, no RETIRED_KEYS_BY_MAJOR[18] entry, no D2 conversion and no D3 semantic entry — so this step replays nothing for them and `migrate meta` lists no edit: the keys simply stop existing. That the chain is silent does NOT make the deletion silent to an author: the PARSE strips (no schema here is `.strict()`), but above it `lintUnknownAuthoringKeys` names the dropped key for the one position a stack manifest reaches — `os validate` and `os build` both print `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its value is dropped at load.`, and `os validate --strict` EXITS 1 on that warning. The other six positions are unreachable from a manifest, so for those the parse-level strip is the whole of it. That is the maintainer ruling of 2026-09-10 on the retirement PR, taken over the seat recommendation to keep the connector D2, on the reading that customers do not upgrade major by major in order. It also retires the `type: 'page'` LIST-VIEW mount and its `pageName` binding (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09 「撤」). The member was added so a view could render nothing of its own and delegate to an already-published page, but only the spec half landed: no renderer ever routed it — objectui's list-view switch shares its default arm with `grid` — so a page view drew an empty table where the page belonged, and the three parse refusals policing the binding policed a mount that never mounted anything. The enum VALUE carries its prescription on the `type` enum's own error map (an enum-value narrowing has no tombstone to hang one on, the `exportOptions` 'pdf' precedent); `pageName` is a retiredKey tombstone on both list-view doors. The D2 conversion STRIPS both keys rather than rewriting `type` to `'grid'`: `type` defaults to `grid` in the schema, so deleting it lands the row on exactly what it already rendered without this registry guessing a view type. The surviving page mount is the app navigation item (`PageNavItem.pageName`), untouched. It also retires `object-kanban`'s `quickAdd` (ADR-0049 enforce-or-remove; the spec half of the director-seat ruling of 2026-09-08 that the board grows no inline record-creation path and retires the key). The board FORWARDED the key into the shared renderer but the affordance is gated on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied FUNCTION JSON cannot carry and no producer puts on an `object-kanban` node — so the gate was permanently false. The drop was NOT silent, and that is what made it worse than silence: objectui's html tier reported the published key as `unknown-prop`, the same diagnostic a typo gets, so an author following the contract met a tool contradicting it with no way to tell which side was wrong. A retiredKey tombstone on `ObjectKanbanPropsSchema` with one D2 conversion that is a pure lossless DELETE (the key never had an effect to preserve) scoped by component `type`. Delete the key; `object-kanban` offers no quick-add control. It also retires the bare STRING `sort` clause on the list-view doors (ruled 2026-09-07: the legacy string clause is retired, one spelling, the array). This is the PRODUCER half of the seam whose consumer half shipped in objectui first: `convertSortToQueryParams` now refuses a runtime string, so `ListViewSchema.sort` was minting documents its own consumer rejects — a document that validated upstream failed downstream, and the author was told off by the wrong layer. Like the `type` value above it is a VALUE narrowing with no tombstone to hang a prescription on, so the surviving array member's own error map carries it, keyed on `issue.input` being a string. The D2 conversion REWRITES rather than strips, because the clause is losslessly mechanical: `'created_at desc'` is the tuple `{ field, order }`, a bare field name meant ascending and is written out as `order: 'asc'`, and the comma-separated multi-key form becomes one entry per key in the same order. A string that does not parse as that grammar — the `'-field'` dialect above all — is left alone and meets the door instead: that dialect belongs to `RecordRelatedListProps.sort`, never reaches `convertSortToQueryParams`, and retiring it was NOT ruled. It also removes `page.assignedProfiles` (ADR-0090 D2 / ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12 「同意」). The key was authorable on the published `PageSchema` and named for the Profile concept ADR-0090 D2 deleted, while the schema's own alias table CORRECTED an authored `profiles:` into it — two files from `security/permission.zod.ts` answering the same word with "no Profile concept". Measured across this repository and objectui it had zero readers, so a page that "assigned profiles" was open to every caller who could reach it. It is a retiredKey tombstone on `PageSchema` — the def is still parsed from the `page` root, so there is an author to teach — and the two alias entries became refusals naming the permission-set route. The D2 conversion STRIPS the key — there is no lossless target, because which permission set a given profile name corresponds to is a judgement no walker can make, which is what the paired D3 semantic entry is for. Finally, it removes `aria` from the chart config (ADR-0049 enforce-or-remove; maintainer decision of 2026-09-12 — judge the protocol wrong for this one key). It is the last member of the `aria` family retired for the same measured reason as `dashboard.aria` and `dashboard.widgets[].aria` before it: an ARIA block an author can declare and nothing lowers to the DOM. It survived those two sweeps by depth — it sits inside the widget’s `chartConfig` bag, which no drill had reached until the per-key pass recorded in `liveness/dashboard.json`. That pass found `aria` to be the one `ChartConfigSchema` key with no reader on EITHER face: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react block omits it from ``’s `dataProps`. Remove rather than enforce, because the same chart config already carries a WORKING accessible-name channel in `description` (lowered as `role="img"` + `aria-label`), and giving `aria` a reader would put two accessible-name sources on one element behind a precedence rule nobody has written — one node, one accessibility vocabulary. The tombstone rides `ChartConfigSchema` and therefore copies into `ReportChartSchema`, so the key is registered twice; the D2 conversion STRIPS it from all three authored sites (`dashboards[].widgets[].chartConfig`, `reports[].chart`, `reports[].blocks[].chart`) as a pure lossless delete — it never had an effect to lose. The two alias spellings that pointed at it, `accessibility` and `ariaProps`, became refusals carrying the same prescription rather than renames onto a tombstone. It also states, and enforces, who owns a dataset-bound chart's STRUCTURE (ADR-0021; maintainer ruling 2026-09-12): the dataset decides which series exist and which column each one reads, `chartConfig` carries appearance, and `dashboard.widgets[].chartConfig`'s `type`, `xAxis`, `yAxis` and `series` are refused by name on that carrier — the widget's own `type` is the chart family and `dimensions`/`values` are the selection. An authored `yAxis[].field` was a live membership channel: the renderer synthesised a series from it when the chart declared none, so one authored axis could silently re-point a dataset-bound series at another column and the chart still drew. The D2 conversion strips the four keys from dashboard widgets only — `ReportChartSchema` and the inline-data react `` tier keep their own axes — and the paired semantic entry carries what the stripped keys were saying, because an authored axis field may name a column the widget never selected and no walker can move that intent into the dataset. Finally, it splits the translation bundle type in two (maintainer ruling 2026-09-13: settings copy belongs to the platform): the platform bundle keeps all eleven groups and the per-app bundle (`stack.translations`, `defineTranslationBundle`) no longer declares `settings`, which is keyed by `SettingsManifest.namespace` and only platform code declares a manifest. Both bundles load into ONE served tree, so an app-authored `settings` branch did not sit inert — but nor did it override the platform: the app’s bundles arrive in `AppPlugin`’s `start()` (Phase 2) and the platform’s at `kernel:ready` (Phase 3), and `deepMerge` gives the later source the leaf, so what an application had was a GAP FILLER on a namespace it does not own — rendering only where the platform bundle carried no string for that key and locale. The registered `translation` ITEM follows the file door (maintainer ruling 2026-09-22: one app metadata type, two authoring doors, one accepted shape) and no longer declares `settings` either; there the group had been STRONGER, because the runtime-authored layer is read over the shipped bundles, so a stored item overrode the platform’s own copy. The D2 conversion strips the group from per-app bundle entries and from bare items alike — the runtime translation sync replays it over every stored row before merging — and the paired semantic entry says what the strip means at each door, because a notice reading "(removed)" says neither that an item’s overrides give way to the platform’s string nor that a gap falls back to the manifest's own English literal. Finally it retires object `tenancy.organizationField` (ADR-0049 enforce-or-remove). The key named the column a PLATFORM ROW is stamped from, as opposed to the column the object is WALLED by (`tenantField`); on an ordinary object those are the same column, and the entire protocol declared it exactly once — on `sys_api_key`, a better-auth-managed credential table this platform ships and no application authors. Its three readers were all platform-row writers, scope-pinned by name, so an application declaration was inert by construction while still forcing every future piece of organization logic to ask "what if somebody set this?". The divergence is NOT retired, only its authorability: it moves to `PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `@objectstack/metadata-core`, keyed by object name and read by the stamp face alone, so audit stamping, the approval-row writer and the automation-run recorder keep their behaviour with no authorable input. The conversion is a lossless delete, and a lossless delete still leaves the author a judgment, which the family's D3 entry `object-tenancy-organization-field-retired` carries — an application whose tenant column genuinely is not `organization_id` declares `tenancy.tenantField`, which both walls the object and stamps its platform rows. It also retires `connector.connectionTimeoutMs` (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-22, letter A — the narrower SECOND decision the key was owed after the ruling that made its nine ledger siblings live deliberately left this one dead). Bounded, defaulted, `.describe()`d and served back by `/meta/connector`, so an author had every signal it worked — and no site ever applied it as a deadline. This retirement is NOT the zero-mention shape: five sites outside `packages/spec` read the key (the materialization fingerprint and the provider-context build in the automation service, `ctx.connectionTimeoutMs` in the `rest` and `openapi` provider factories, and the `?? 30000` fallbacks that put it back on the reported def), but every one is a pass-through whose only termini are the def `GET /connectors` echoes and the fingerprint that decides whether to re-materialize. The one mapping from authored policy onto the platform's outbound `fetch` was handed `retryConfig` and `requestTimeoutMs` only, so the key was carried and never honoured — the same parsed-unmarked-unenforced state ADR-0049 forbids, wearing a longer route. Nor was the `实现` arm available: a WHATWG `fetch` exposes one `AbortSignal` over the whole operation and never the connect phase, so bounding time-to-response with it would kill a slow-but-connected upstream the author meant to allow with a large `requestTimeoutMs`. `requestTimeoutMs` is the replacement and the bound the platform can keep. The carrier key is a retiredKey tombstone on the non-strict `ConnectorSchema` (a bare deletion would be a silent strip), registered under both def keys because `DeclarativeConnectorEntrySchema` carries it too, both carriers wrapping the same private `ConnectorBaseSchema`; the D2 conversion strips it from `connectors[]` as a pure lossless delete — it never had an effect to lose — because a stored connector row CAN carry it (the `PUT /meta/connector/:name` door persists the authored value and the stored-row rehydration seam is live for this type, both measured); and the withdrawn `ConnectorProviderContext` member, which is code and has no authored source to rewrite, leaves via the paired semantic entry instead. Finally it gives the one-filter-orthography convergence (ruled 2026-08-25: one filter spelling platform-wide, the rule array) its mechanical half at rest (ruled 2026-09-12): the D2 conversion `page-component-filter-record-to-rule-array` rewrites a record-form or single-level AST `filter` at the converged rule-array doors — `dataSource.filter` and the `object-*` `filter` props — to the rule array wherever the mapping is lossless, and leaves a filter carrying `$and` / `$or` / `$not` (or any part with no lossless rule spelling) exactly as stored, because flattening a combinator changes which rows a page selects. It is retired from the load path, so authors are still refused at the door and taught the array; the stored-row seams and this chain replay it. It also retires the view item's `owner` and `hidden` (ADR-0049 enforce-or-remove). Both sat on the view-item identity layer, were accepted by the strict authoring door and by the wire member the `view` write door validates, and were stored verbatim — and nothing read either: both switcher read paths filter on `viewKind` + `object` and sort on `order`, so `hidden: true` hid nothing, and no per-user scope ever read `owner`, so a view marked as one user's was listed for everyone who can read the object. Per-user view scoping is a parked direction (ADR-0017, amended 2026-09-04), not a shipped mechanism. Both keys are `retiredKey()` tombstones on the SHARED shape, because that shape also feeds the `.strip()` wire member, where a bare deletion would be a silent strip. The D2 conversion `view-item-owner-hidden-removed` strips them from the view item RECORD spelling only, as a lossless delete, in both collections a record travels in — `views` (stack sources and stored rows) and the assembled-manifest `viewItems` channel (package export, environment artifacts), whose registration parse would otherwise refuse an artifact assembled before this release. It also retires a `joined` report's `chart` at both coordinates (ADR-0049 enforce-or-remove): the joined renderer draws each block as a table and returns before the one container `chart` read, and no renderer reads a block's `chart` at all, so a chart on a joined report parsed, passed the chart-bindings lint, and plotted nothing. The key leaves `JoinedReportBlockSchema`'s closed shape (its `guidance` table carries the prescription) and the joined arm of `ReportSchema`'s refinement refuses a container `chart`; `chart` stays live on every non-joined report. The D2 conversion `report-joined-chart-removed` strips both as a pure lossless delete — neither ever had an effect to lose — because a stored report row CAN carry them (the Studio report form offered a block `chart` input until this change); it is retired from the load path, so authors are refused at parse rather than rewritten. It retires the view item's `owner` / `hidden` pair on the flattened overlay door too (ADR-0049; the view item's disposition for the same key pair, followed here as triage directed): the lean personalization PUT with no `config` declared its own `owner` / `hidden`, accepted and stored them, and nothing read either. Both are `retiredKey()` tombstones on the two overlay members with the view item's own prescription texts, and the D2 conversion `view-overlay-owner-hidden-removed` strips them from the flattened spelling (no `config`, no container slot) in `views` and `viewItems`, so a stored overlay row is served without them. A row that held other view keys is then valid again and re-saves; a row that held nothing but its identity and the two keys is left identity-only, which the door refuses, so it is badged invalid, refused on a whole-row re-save and reported `failed` by `os migrate meta --stored --apply` until it is deleted or given the setting its author meant. Its D3 record is the semantic entry `view-overlay-owner-hidden-retired`. It also narrows form `layout` to `vertical` | `horizontal` on both surfaces that declared the four-arm enum — the `object-form` page component and the form view (ADR-0049 enforce-or-remove). No renderer ever gave `inline` or `grid` a behaviour of its own: every form presentation folded both to `vertical`, multi-column is `columns` (honoured under either layout), and `inline` is a toolbar / filter-row pattern rather than a record-form layout — redundant vocabulary under the maintainer's family criterion (a capability mainstream platforms have is served once, here by `columns`), retired with no alias window. Both enums refuse the two values with a per-value prescription naming `columns`; the D2 conversion `form-layout-inline-grid-to-vertical` rewrites them to `vertical` (behaviour-preserving, `columns` untouched) on `object-form` page components, on every form payload a view carries, and on the assembled-manifest `viewItems` channel. It also removes `currencyConfig.precision` (ADR-0049 enforce-or-remove): declared and validated against ISO 4217, read by no renderer or runtime — a currency amount's decimal places are its currency's ISO 4217 minor unit, derived from the currency itself. The D2 conversion `currency-config-precision-removed` strips it from every field's `currencyConfig` as a pure lossless delete, which matters most at rest: the schema used to bake `precision: 2` into parse output, so stored object rows and built artifacts carry it without anyone having written it. Retired from the load path; an authored key is refused with the prescription. It also retires the RLS policy's `tags` (ADR-0049 enforce-or-remove; graded RETIRE by the maintainer's criterion — no mainstream platform tags a row-level policy): the key promised categorization and reporting for governance and compliance, and nothing ever read it — the RLS compiler never consulted it and no preview rendered it. It is a `retiredKey()` tombstone on `RowLevelSecurityPolicySchema` (the `priority` posture one key over), and the D2 conversion `permission-rls-tags-removed` strips it from every policy in `permissions[].rowLevelSecurity` as a lossless delete, so a stored permission row that still carries it replays clean. It is retired from the load path, so authors are refused at parse rather than rewritten. Its D3 record is the semantic entry `permission-rls-tags-retired`. Finally, it removes `aria` from the action (ADR-0049 enforce-or-remove), the fourth member of the `aria` family after `dashboard.aria`, `dashboard.widgets[].aria` and the chart config's, and retired for the same measured reason: an ARIA block an author can declare and nothing lowers to the DOM. The liveness ledger had graded it `live` on an uncited "partial" note with no reader behind it; at the pinned renderer, none of the surfaces that render an action — button, icon, menu, group and bar, the row and bulk action menus, the record quick-actions toolbar — reads it. Remove rather than enforce, because every one of them already takes the accessible name from the action's required `label` (visible text, or `aria-label` on an icon-only action), and the node that places the actions carries the node-level `aria` block — a per-action block would be a second spelling of both. The D2 conversion `action-aria-removed` STRIPS the key from stack actions and object-nested actions as a pure lossless delete, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `action-aria-retired`. It also retires the connector resilience family (ADR-0049 enforce-or-remove, one batch): `connector.health` — the `healthCheck` probe (eight keys) and the `circuitBreaker` (six) — `connector.status` and the connector-nested `webhooks`, sixteen authorable keys with no reader outside the spec package. No loop ever polled a connector endpoint or tripped a breaker; nothing read an authored `status` (the runtime publishes a computed `state`, and participation is `enabled`); and a webhook nested in a connector was never registered as a `webhook` item, so it was never materialized or delivered — the top-level `webhooks:` collection is the delivered one. The three carrier keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `status`, defaulted `'inactive'`, joins `connectionTimeoutMs` in the retired-default residue stage, because every 17.x parse emitted it into every connector. Seven defs leave whole — `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` — and the D2 conversion `connector-resilience-keys-removed` strips the three keys from `connectors[]` and stored rows as a pure lossless delete (the nested webhooks are stripped, never moved: moving them would start deliveries that never happened). It ABSORBS the breaker half of the duration rename above: `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` is no longer converted, because the whole block it lived in is now removed. Finally it makes edge-branched `decision` nodes EXCLUSIVE (maintainer ruling 2026-09-23, 「跟主流对齐」): the first conditioned out-edge that holds, in declaration order, is the branch, and taking every true branch is the declared `mode: 'inclusive'`. The D2 conversion `flow-decision-mode-inclusive-explicit` writes that key onto every decision with two or more conditioned out-edges and no `conditions` list, so a flow written while every true branch ran keeps its behaviour; it is a default flip, so it is retired from the load path AND refused by the flow rehydration seam and the artifact-ingestion door, and replays only here — the paired semantic entry carries the judgment the diff then asks for. BREAKING for flows stored in `sys_metadata`, by maintainer ruling: such a decision with no `mode` takes the first-match meaning on upgrade and nothing rewrites it; `os migrate meta --stored` lists each one for review, and `mode: 'inclusive'` is the one-line fix where a node meant every branch. It also retires the list view's own `tabs` (ADR-0049 enforce-or-remove). The key parsed and was stored at every list-view door and drew nothing: a list view's own `tabs` has no reader, the one component that would draw it has no production mount, and the tab strip above an object's records is the saved-view switcher, which renders one tab per `listViews` entry and reads no `tabs` key (`userFilters.tabs`, a different key of the same element type, is read and rendered, and stays). The key is a `retiredKey()` tombstone on the list-view shape (its prescription says how to move each tab to a named `listViews` entry); `ViewTabSchema` itself stays, because the page-only `userFilters.tabs` preset bar reuses it and renders. The D2 conversion `view-list-tabs-removed` strips the key from every list payload in `stack.views[]` as a lossless delete, and is retired from the load path, so authors are refused at parse rather than rewritten. It retires the inner `name` on cube members — `measures..name` and `dimensions..name` (ADR-0049 enforce-or-remove) — by the mainstream criterion: Cube.dev and LookML key a member by its declared name, with no second inner name that can disagree. Both member bags are records, and every consumer already resolved a member by its record KEY, publishing and querying it as `.`; the REQUIRED inner copy was read by nothing, and one that disagreed with its key was silently ignored. The keys are retiredKey tombstones on `MetricSchema` and `DimensionSchema`, and because the key was required, every stored or built cube carries it: the D2 conversion `cube-member-inner-name-removed` strips it from every member of every cube, retired from the load path, and its notice prints a disagreeing value beside the key that stays. Its D3 record is the semantic entry `cube-member-inner-name-retired`, which asks the author of a disagreeing name which spelling they meant. It also retires the connector `triggers` array (ADR-0049 enforce-or-remove; ADR-0041 keeps connector-event triggers in its third tier, as their own trigger package): the `ConnectorTrigger` shape — `key`, `label`, `description`, `type` (`polling` / `webhook`) and `intervalSeconds` — was read by nothing. The automation engine registered a connector's actions only, its trigger registry holds FLOW trigger kinds that no connector trigger ever entered, no polling loop read an interval and no receiver was driven by a `webhook` trigger, so a declared trigger never started a flow. `triggers` is a retiredKey tombstone on `ConnectorBaseSchema`, registered under both carrier defs; the provider-bound refusal of the key, whose reason (the provider derives triggers) was untrue, is gone with it, since the tombstone refuses every value on every carrier. `ConnectorTrigger` leaves whole, and the D2 conversion `connector-triggers-removed` strips the array from `connectors[]` and stored rows as a pure lossless delete — never turning a trigger into a flow, which is the author's decision (an `api` flow for an external event, a `schedule` flow for a scheduled pull, each calling the connector's action). It ABSORBS the trigger half of the connector duration rename (its breaker half went with `health` above), so `connector-health-and-trigger-durations-unit-in-key`, with neither half left, is no longer in this step. It also retires a cube's `refreshKey` whole — the refresh cadence `every` and the data-change probe `sql` (ADR-0049 enforce-or-remove). Nothing read either key, and no analytics result is cached, so a declared cadence refreshed nothing and every query was computed when it was asked, as it still is. The key is a retiredKey tombstone on `CubeSchema`, and the D2 conversion `cube-refresh-key-removed` strips the whole block from every cube as a pure lossless delete, retired from the load path. Its D3 record is the semantic entry `cube-refresh-key-retired`. A refresh cadence is declared again when a result cache exists. It also narrows the `time` stored form to the zone-less wall clock the record validator already enforces (ADR-0053 D-C1), so a field default or an action param default or value with a `Z` or a UTC offset is refused when it is authored or submitted rather than on every insert that falls back to it. The D2 conversion `time-default-utc-suffix-dropped` drops a `Z` or a zero offset, which names the same wall clock, and leaves a non-zero offset as stored for its author to rewrite; its D3 record is the semantic entry `time-default-zone-refused`. It also retires the page header's `breadcrumb` switch (ADR-0049 enforce-or-remove): no renderer ever drew a trail for it — objectui drew an empty slot that nothing filled — and the navigation trail is drawn once, by the app shell's header. The key is a retiredKey tombstone on `PageHeaderProps`, beside the `icon` that row lost at 17, and the D2 conversion `page-header-breadcrumb-removed` strips it from every `page:header`, `true` and `false` alike, retired from the load path. Its D3 record is the semantic entry `page-header-breadcrumb-retired`. The `nav:breadcrumb` component type is not part of it: the Studio page palette still offers it. It also retires connector-attached sync from the connector (ADR-0049, the ENFORCE route by ruling): `connector.syncConfig` — `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode`, `filters` — and `connector.fieldMappings` — `source`, `target`, `defaultValue`, `dataType`, `required`, `syncMode` — fourteen keys no engine ever executed, whose `latest_wins` and `soft_delete` defaults read as configured policy and did nothing. The capability is mainstream, so the definition moves rather than lapses: every mainstream platform binds a sync to its TARGET, so a `mapping` gains `connectorSource`, the `rest` / `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, and a `job` sets the cadence (no schedule key returns to the connector). That binding is declared in this step and executed in a later one. Both connector keys are retiredKey tombstones on `ConnectorBaseSchema`, registered under both carrier defs; `DataSyncConfig`, `SyncStrategy`, `ConnectorConflictResolution` and `ConnectorFieldMapping` leave whole; and the D2 conversion `connector-sync-keys-removed` strips both keys from `connectors[]` and stored rows as a pure lossless delete — never writing a `mapping`, which would start writes that never happened. It also narrows an analytics cube member's `sql` — `measures..sql` and `dimensions..sql` — to a column reference: a field of the cube's object, a relationship path ending in one, or `'*'` (maintainer ruling D, ADR-0021 "zero raw SQL / zero raw expressions" carried from the dataset layer to the cube members it compiles to; ADR-0049 enforce-or-remove). A SQL expression there names no single field, so no platform check could judge which fields it reads, and the two analytics strategies never agreed on it: the raw-SQL path ran it verbatim, the ObjectQL path refused it. It is now refused at parse with a prescription naming the ADR-0021 dataset form — a measure with its own structured `filter` for a conditional count or sum, and `derived: { op, of: [...] }` over named measures for a ratio, sum, difference or product. No D2 conversion: an expression has no mechanical rewrite into a dataset, so the semantic entry `cube-member-sql-expression-retired` carries the move, including the scale change a ratio makes (a `derived` ratio is a 0–1 fraction). It also closes the form view's inline grid columns: `subforms[].columns`, on `view.form` and on `formViews` entries, was `z.array(z.any())` while a relationship field's `inlineColumns` was already the strict `InlineGridColumnSchema`, so a mis-keyed column published clean and drew a blank grid column, and `scale` on a currency column, which the other carrier refuses under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), published green. The carrier now references that schema, so both carriers are judged by it, with its own prescriptions. The D2 conversion `form-view-subform-columns-canonicalized` respells a `{ field }` column as `{ name }`, the respelling `field-column-lists-canonicalized` makes on `inlineColumns`: it rewrites stored rows and assembled artifacts and lists the edit under `os migrate meta`, and it is retired from the load path, so an author writing `field` meets the refusal. A view saved with a failing column is refused with the column schema's prescription, and a stored row carrying one is diagnosed at rehydration; neither is stripped, because which column an unknown key or a mixed `field`/`name` entry meant is the author's call, and a conversion that dropped the key would accept at load what the parse now refuses. Its D3 record is the semantic entry `form-view-subform-columns-closed`. On both carriers, the reach of `inline-grid-column-currency-scale-refused` extends to a column that declares no `type`: such a column takes its type from the child field, which the column schema cannot see, when the console hydrates it, so `defineStack`'s cross-reference check re-parses a column whose `name` is a `currency` field of the child object as the type it renders as, and the refusal of its `scale` is the column schema's own. Reach: the child object must be declared in the same stack; a column naming no field of it, or a subform whose child object comes from another package, is not judged there. It has no D2 conversion, for the declared-type entry's reason: deleting the key is the migration, and a conversion that dropped it would accept it at load, the grace window ruling B refused. Its D3 record is the semantic entry `inline-grid-column-identity-only-currency-scale-refused`. It also gives the executor target of an action one spelling on the page blocks that run one. `ActionSchema` has always refused `endpoint` with the rename to `target`, while the `action:button` and `action:icon` component rows declared `endpoint` as a key of their own, and the console's `api` handler reads `target` only — so an `api` button authored with `endpoint` was accepted by the props gate and called nothing. The rows now refuse it with the same rename, read from the one alias table both share. The D2 conversion `action-block-endpoint-to-target` renames the key on an `api` action, where the rename is lossless, retired from the load path so authors are refused at the door while stored rows and `os migrate meta` replay it; an `endpoint` on a block with no `actionType` or another one is left as stored and reported as a TODO. Its D3 record is the semantic entry `action-block-endpoint-spelling-retired`. Finally, it retires the form field's `publicPicker` block (ADR-0087 D2, immediate — the maintainer's ruling E, which reverses the earlier ruling that had declared it): an anonymous public form no longer offers record search. The block opted a lookup, `master_detail` or `user` field on a public form into a picker served by an unauthenticated route; that route is deleted, and the public-form resolve route now leaves those three field types off the anonymous rendering unconditionally. The schema refuses the key with the prescription; the mechanical conversion `form-field-public-picker-removed` strips it from old sources and stored rows (lossless in effect — its only reader was the deleted route), and the semantic entry asks the author how a visitor should now choose: a `select` field with static `options`, or a form behind sign-in. It also closes the third carrier of the inline grid column: an `object-master-detail-form` page block's `details` was `z.array(z.unknown())`, so a key its renderer does not read and `scale` on a currency column, which the other two carriers refuse under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), went through `objectstack validate` green. Each detail entry is now a strict shape of the twelve keys the renderer reads, and its `columns` references `InlineGridColumnSchema`. Page-component `properties` is read by the component-props gate, which reports a failing entry or column as an advisory finding, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered; the authored census found nothing to respell. `defineStack`'s identity-only check reaches the block wherever a page carries it, with the reach `inline-grid-column-identity-only-currency-scale-refused` records for the other two carriers. Its D3 record is the semantic entry `ui-object-master-detail-form-details-closed`. It closes the fourth carrier the same way: `record:line_items` had no `ComponentPropsMap` row — it was the one entry on the string-arm registration ledger — so the component-props gate skipped its props, and the showcase project page's five `field`-keyed columns published green over a grid of empty cells. The row declares the fifteen keys the renderer reads, requires `relationshipField` and at least one column, and its `columns` references `InlineGridColumnSchema`; the showcase columns are respelled `name` in the same change. The panel draws its columns as authored, with no hydration from the child object's field, so `defineStack`'s identity-only check does not reach it. Its D3 record is the semantic entry `ui-record-line-items-props-closed`. It also holds an ADR-0021 dataset's `field` — `dimensions[].field` and `measures[].field` — to the accept set the cube members it compiles to already hold, from one shared declaration: a field of the dataset's object, a relationship path ending in one, and on a measure also `'*'` (ADR-0021 "zero raw SQL / zero raw expressions"; ADR-0049 enforce-or-remove). The slot was a bare string that parsed any expression, while the analytics dataset door already refused one on every query, so an expression could be saved and never answered. It is now refused at parse with a prescription naming the ADR-0021 form — a measure with its own structured `filter`, or `derived: { op, of: [...] }` over named measures — and so are an empty string (a count omits `field` instead) and `'*'` on a dimension, which names no axis. The one lossless repair is D2: `dataset-count-measure-empty-field-removed` drops a `count` measure's empty `field`, which still counts rows. An expression has no mechanical rewrite into a column, so the semantic entry `dataset-member-field-expression-refused` carries the rest. It also closes the export options of an `object-grid` page block. `exportOptions` was `z.unknown()`, so a bare format array — the list view's legacy spelling, which the list view lifts to `{ formats }` — was accepted on the grid, whose renderer reads `exportOptions.formats` and lifts nothing: the export menu offered its csv/json default and the author's list was dropped. The row now takes the list view's five-member export options object by identity, not the list view's union, and refuses a bare array with the object form named, a format outside the enum and an undeclared key. Page-component `properties` is read by the component-props gate, which reports these as advisory findings, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered: the bare array never worked here, and lifting it would change the menu a deployed grid shows. The authored census found nothing to respell. Its D3 record is the semantic entry `ui-object-grid-export-options-closed`. It also makes an agent's structured output JSON-only (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, enforces `structuredOutput` on every final answer and refused four of its members before an agent's first turn: the `regex`, `grammar` and `xml` formats — no key ever carried a pattern or grammar to check against, and an answer is checked only as JSON — and the `coerce_types` step, for which no coercion engine exists. All four are refused at parse with a prescription, and the D2 conversion `agent-structured-output-refused-members-removed` deletes a block whose `format` was retired, deletes a retired `fallbackFormat` and drops `coerce_types` from the pipeline, retired from the load path. It also retires the metric sub-caption at both ends (maintainer ruling 2026-10-01, which reverses the 2026-08-06 ruling that gave it a translation key of its own; ADR-0049). The widget translation key `dashboards..widgets..subCaption` overlaid a widget's `options.description`, a key the dashboard schema never declared and no authored widget wrote, so the overlay in `translateDashboard` was its only writer. The overlay is removed, `subCaption` is a `retiredKey()` tombstone on the widget translation node, and its former `subtitle` alias now carries the retirement instead of a rename onto a key that accepts nothing. A widget keeps one authored description, `widget.description`, which renders as the card-header subtitle and is translated by the widget's `description` key. The D2 conversion `translation-widget-sub-caption-removed` strips the key from bundle entries and stored translation items as a lossless delete of what is served, retired from the load path so authors are refused at parse; its D3 record is the semantic entry `translation-widget-sub-caption-retired`. It also makes an agent's memory contract state exactly what the runtime honours (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, recalls the newest `maxEntries` long-term notes before the first round, writes one every `reflectionInterval` delivered interactions, and keeps them in its own database store; before an agent's first turn it refused the `vector` store (the old default) and `redis`, an enabled `longTerm` missing either number, and a `reflectionInterval` without one. So `longTerm.store` is retired as a whole key — the memory store is platform infrastructure, not agent metadata — and the D2 conversion `agent-memory-long-term-store-removed` deletes it, losslessly, retired from the load path; and with long-term memory enabled both numbers are required at authoring, with no default declared, so an upgrading author chooses them. It also retires an agent's conversation state machine, `agent.lifecycle` (ADR-0049 enforce-or-remove). It was parsed and never read: no runtime moved an agent through a declared state or refused an undeclared transition, and enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 rejected. What it reached for is served elsewhere — a conversation phase is a skill selected by its `triggerConditions`, a multi-step process is a Flow, a record's status transitions are the `state_machine` validation rule — so authoring refuses the key with that prescription, and the D2 conversion `agent-lifecycle-removed` deletes it, losslessly, retired from the load path. The XState `StateMachineSchema` family, kept by ADR-0020 only for this door, left the package with it. It also retires a cube measure's custom-SQL-expression types — `number`, `string` and `boolean` from `AggregationMetricType`, so from `measures..type` (ADR-0049 enforce-or-remove). They marked a measure whose `sql` was the whole computation, and with that `sql` now a column reference they had nothing left to compute: the raw-SQL path returned the column unaggregated and the ObjectQL path refused the measure. Each is refused at parse with a prescription naming the six aggregates. No D2 conversion: the column alone does not say which aggregate the author meant, so the semantic entry `cube-metric-expression-types-retired` carries the choice, and a stored cube that still carries one is refused rather than rewritten. It also retires `object-grid`'s `resizableColumns` (ADR-0049 enforce-or-remove; objectui's ruling that `resizable` is canonical, under the startup rule of immediate retirement): the legacy second spelling of `resizable`, read only as `schema.resizable ?? schema.resizableColumns` (measured at the `.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). One switch, two spellings, and zero writers in either repository, so there is no window. A retiredKey tombstone on `ObjectGridPropsSchema` with one D2 conversion that follows the renderer's precedence: the value moves to `resizable` when that is absent, and strips as a lossless delete when it is present (it was never read then). Its D3 record is the semantic entry `object-grid-resizable-columns-retired`. It also types seven members of an `object-grid` page block: `rowHeight`, `rowColor`, `navigation`, `conditionalFormatting`, `bulkActionDefs`, `aggregations` and `operations` were `z.unknown()` (an array of it for `bulkActionDefs`), although the grid reads each with one shape, so `rowHeight: 42` passed every door and rendered as `compact`. The five a list view also declares take the list view's own schemas by reference; `aggregations` takes the measured `[{ field, type }]` with the query AST's aggregation functions, and `operations` the four booleans a grid read point names (`create`, `update`, `delete`, `export`), refusing `read` and `import`, which nothing reads. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-row-members-typed`. It also types `navigation` on the `object-map`, `object-gantt` and `object-tree` page blocks (the first stage of the `ComponentPropsMap` `z.unknown()` close-out): each renderer hands it to the shared navigation hook, which reads `navigation.mode` and falls back to `page`, so `navigation: 42` and a bare mode string passed every door and opened the record page. The three rows now take the list view's `NavigationConfigSchema` by reference, the carrier the grid, kanban, calendar and timeline blocks already take. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-map-gantt-tree-navigation-typed`. It also retires the `ai:chat_window` page element (ADR-0049 enforce-or-remove), the `user:profile` shape one namespace over: no renderer for it ever shipped, and none is wanted — the console leaves it unregistered on purpose, because the floating chat overlay it mounts on every page is the supported AI chat entry point — so a page that placed one validated clean and drew "Unknown component type", and its four props configured nothing. The name leaves `PageComponentType` and is refused by name at the node, its `ComponentPropsMap` row stays as a whole-bag refusal carrying the same prescription, and the props def `AIChatWindowProps` is unpublished. No conversion is registered: the only edit is deleting the node, a layout decision that is the author's. Its D3 record is the semantic entry `ui-ai-chat-window-retired`; `ai:suggestion` is unchanged. It also narrows page `requires` to the kinds whose source is compiled at save (ADR-0080 §5; maintainer ruling 2026-10-03, letter A): the plugin-namespace list is derived from an html page's source when the page is saved, while on `react`, `full` and `slotted` pages nothing derived it, the Studio page editor dropped it on every save, and a load-time warning was its one reader. `PageSchema` now accepts the key only when `kind` is `html` or its deprecated alias `jsx`, and refuses it at `requires` on every other kind, a page that omits `kind` included, naming the key, the page's kind and the compiled kinds. The key stays live on html pages, so there is no tombstone. The D2 conversion `page-requires-non-compiled-kind-removed` deletes the key from those pages, retired from the load path, so stored rows and artifacts replay clean while authored sources are refused until edited; the delete is lossless. Its D3 record is the semantic entry `page-requires-non-compiled-kind-refused`. It also types eight list members of the `object-grid`, `object-kanban` and `object-calendar` page blocks (the second stage of the `ComponentPropsMap` `z.unknown()` close-out): the grid's `fields`, `selection`, `selectable`, `rowActions`, `bulkActions` and `batchActions`, the kanban's `columns` and the calendar's `calendar` were `z.unknown()` (an array of it for the lists), although each renderer reads them with one shape, so a `{ name }` entry in `bulkActions` passed every door and was skipped. The members a list view declares take the list view's own by reference (`batchActions`, the spelling the grid reads first, takes `bulkActions`'s); the grid's `fields` and `selectable` and the kanban lane take the measured shape. The grid's `columns` stays open: its group headers draw an authored column's `options`, which the list view's column entry does not declare. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-kanban-calendar-list-members-typed`. It also refuses, at parse, a hook whose `body` targets a table of stored metadata, `sys_metadata` or `sys_metadata_history` (maintainer ruling 2026-10-03, letter A: an app-authored body may not touch those tables, whose only writer for a body is the metadata protocol). The runtime already refused such a hook where a body becomes a handler, so it never ran, while the metadata save door answered 200 for it. `HookSchema` now refuses the same set at `object`, or at the list member, with the runtime's prescription to change metadata through the metadata API, judged by the one predicate the runtime uses: a hook with a `body` in any form whose target names either table. A code `handler` and the wildcard `'*'` stay outside it, as they are at registration. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused hook carries no intent a rewrite could keep. Its D3 record is the semantic entry `hook-body-stored-metadata-target-refused`. It also types four members of the `object-form` page block (the third stage of the `ComponentPropsMap` `z.unknown()` close-out): `contentLayout`, `submitBehavior`, `navigateOnSuccess` and `mobile` were `z.unknown()`, although the form reads each with one shape, so a `submitBehavior` `kind` the form does not know passed every door and fell through to the thank-you panel. `submitBehavior` takes the form view's own block by reference; the other three take the measured shape. The form's `fields` and `sections` and the master-detail form's two stay open — the form draws a `{ name }` field entry and an inline runtime field inside a section, which the typed shapes would refuse — and `customFields` stays open until the spec declares the runtime form field its entries are. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-members-typed`. It also completes the `element:text` `variant` convergence (the second release of the ruled two-release split): the enum is the nine values `ui:text` publishes — `h1`-`h6`, `body`, `caption`, `overline` — and the pre-convergence spellings `heading` and `subheading`, which every release since the nine were added still accepted, are refused by name with a prescription naming the level to write. The D2 conversion `element-text-variant-heading-levels` rewrites `heading` to `h2` and `subheading` to `h3` on every `element:text` page component — the heading element each one always rendered, so the outline is unchanged and the heading takes that level's style. The `body` default for an absent `variant` is unchanged. It also types two members of the `object-metric` page block (the fourth stage of the `ComponentPropsMap` `z.unknown()` close-out): `aggregate` and `trend` were `z.unknown()`, although the tile reads each with one shape, so `aggregate: 'count'` and a trend with no `value` passed every door, and the tile asked the server for a measure it does not have, or painted a lone `%`. `aggregate` takes the query AST's aggregation functions and the chart aggregate's `groupBy` union by reference, with `groupBy` optional because a metric is one number; `trend` takes the badge's measured shape. `drillDown` and `compareTo` stay open: each by-reference candidate declares a key the tile never reads (the chart drill-down's `filter`, the dashboard comparison's `dimension`), and the chart drill-down refuses the `report` the tile draws, so each waits on a ruling. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-aggregate-trend-typed`. It also retires an `object-master-detail-form` detail entry's `sortField` (ADR-0049 enforce-or-remove; the spec half of objectui's own retirement of the override). The console stopped reading the authored override: the field its line grid stamps with each line's position on drag-reorder is derived from the child object — its first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort` — and the pinned console had crossed that change while the spec still declared the key, so an authored value published green and was dropped. A retiredKey tombstone on the strict detail entry with one D2 conversion that is a pure lossless DELETE scoped by component `type` and by position (`properties.details[]`); its D3 entry `object-master-detail-form-detail-sort-field-retired` carries the one judgment left, whether the child object declares the field the line order is kept in. It also types the `object-metric` page block's `compareTo` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out) to the tile's read, per the ruling between the reference and the read: `{ kind }`, with `kind` the dashboard widget comparison's own vocabulary by reference, and `dimension` refused by name, because this inline tile shifts the date macros in its own `filter` and never reads a dataset time dimension. A bare kind string, a kind outside the two and a `dimension` passed every door and compared the wrong window. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-compare-to-typed`. It also types the `object-metric` page block's `drillDown` to the tile's read (the same stage and ruling): its five list members — `enabled`, `title`, `target`, `columns`, `maxRows` — are the chart drill-down's own by reference, and `filter` and `mode` are refused by name, because a metric tile has no click event for a drill filter to resolve against and no row for `mode` to open; both passed every door and were ignored. The drill `report` stays open: the tile draws a dataset-bound report, but the spec declares no drill report yet, and declares that contract first. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-typed`. It also types the `object-grid` page block's `columns` (the fifth stage of the `ComponentPropsMap` `z.unknown()` close-out), the list member the second stage held: the grid's group headers drew a column's `options`, which the list view's column entry does not declare, and objectui has since retired that read and takes the labels from the object field only. So the member takes the list view's own `columns` by reference — all field names or all column entries — and a column keyed `accessorKey` / `header` / `name`, a mixed list or an undeclared column key (`editable`, `options`, `reference`), which passed every door and drew no column or was ignored, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-grid-columns-typed`. It also refuses, at parse, a flow `create_record`, `update_record` or `delete_record` node whose `objectName` is the string `sys_metadata` or `sys_metadata_history` (the maintainer ruling of 2026-10-03, letter A, applied to flows: app-authored work may not write those tables, whose only writer is the metadata protocol). The runtime already refused such a node before any write, at its first run, while every authoring door accepted the flow. `FlowSchema` now refuses the same set at `nodes.N.config.objectName`, through the one judge `registerFlow` and `objectstack validate` share, with the runtime's prescription to change metadata through the metadata API: one of those three write nodes whose `objectName` names either table by exact name. A `get_record` node and a dynamic target stay outside it: the run judges the name it hands the data engine. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused node carries no intent a rewrite could keep. Its D3 record is the semantic entry `flow-write-node-stored-metadata-target-refused`. It also types the top-level `fields` of the `object-form` and `object-master-detail-form` page blocks (the last stage of the `ComponentPropsMap` `z.unknown()` close-out), the two members the third stage held: the form drew a `{ name }` field entry its own page-builder guide taught, with a `label`, `type` and `required` it silently dropped, and objectui has since retired that entry from every authoring face, drawing only a stored one by its name. So both rows take field names, objectui's own declaration of the member, and refuse an object entry with what to write instead — a `{ name }` entry is its bare name, and a `{ field }` entry belongs in a section. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-fields-names-typed`. It also types the `object-gantt` page block's `markers` (the same stage): its entries were `z.unknown()` because the marker contract lived only in objectui, so a marker with no `date`, a numeric `date` or a misspelled member passed every door and the chart drew no line, or drew it unlabelled. The spec now declares objectui's own authoring declaration of a marker, `{ date, label?, color? }` with `date` a string, and the row takes it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-gantt-markers-typed`. It also types the `object-timeline` page block's `mapping` (the same stage): the binding record — four optional field names for an entry's title, date, description and marker colour — was `z.unknown()` because its contract lived only in objectui, so a bare field name or a misspelled member passed every door and the rail drew the default field. The spec now declares objectui's own declaration of it, and the row takes it. The stage's other members — the metric drill-down's `report`, the form's `customFields` and both forms' `sections`, the timeline's `items` and the action containers' members — stay open: each contract has more than one viable shape that no ruling decides yet. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-mapping-typed`. It also types the `object-kanban` page block's `conditionalFormatting`, the one member the `ComponentPropsMap` `z.unknown()` close-out held for a ruling: it was `z.unknown()` while objectui's kanban also authored a native rule dialect the list view refuses, so `42` or a rule with no `style` passed every door and the board painted no card for it. objectui has since made the list view's `{ condition, style }` rule the member's only authoring dialect, and the board evaluates it with the grid's evaluator, so the row takes the list view's own member by reference, as `object-grid` does. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-kanban-conditional-formatting-typed`. It also requires every block of a `joined` report to bind a `dataset` (ADR-0021 single-form, enforced under ADR-0049 enforce-or-remove): the schema comment and the reports guide both said each block is dataset-bound, but the joined arm of `ReportSchema`'s refinement required only a non-empty `blocks`, so a block with no `dataset` parsed, passed `objectstack validate` and every save door, and drew nothing: the joined renderer issues no query for it, and a report whose blocks all lack one falls through to the pre-9.0 presentation bridge, which issues none either. The arm now refuses each such block at `blocks[i].dataset`, naming the block, with the prescription to bind it to a dataset; `dataset` stays optional on the block shape, which is read only on a `joined` report. No key is removed, so there is no tombstone, and no D2 conversion exists: only the author knows which dataset a block was meant to show. Its D3 record is the semantic entry `ui-report-joined-block-dataset-required`. It also types the `object-form` page block's `customFields`, one of the two contracts the `ComponentPropsMap` `z.unknown()` close-out held as forks and the maintainer has since ruled: each member is the runtime form field the form draws, which the spec did not declare, so a member with no `name` or a misspelled member passed every door and the form drew the field without it. The spec now declares a closed runtime form field of the members the form draws, in camelCase, keyed by `name` — the `grid` widget's snake_case keys stay out until the widget reads a camelCase spelling — and the row takes a list of it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-custom-fields-typed`. It also types the `sections` of the `object-form` and `object-master-detail-form` page blocks, the other ruled fork: a section's `fields` draws an inline runtime form field beside a name and the form view's `{ field }` entry, which the stored form view's section refuses, so the sections stayed `z.unknown()` and a misspelled key passed every door. Both rows now take one page-block section shape of their own — the form view's section keys plus those three entry arms, the inline arm the runtime form field — in canonical spellings only: a page block's `properties` is never parsed on the way to the form, so a deprecated section `visibleOn` or a string `columns`, which a form view folds at parse, was dropped, and is refused with the canonical spelling. The stored form view is unchanged. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-form-sections-typed`. It then types the three members the stages above held open, as the maintainer ruled them on the decision card for those forks. The `object-metric` drill-down's `report` is `ReportSchema`, by reference (fork 1, letter B): it waited until a joined report refused a block that binds no dataset, and since then every report the member admits is one the drill drawer draws — a report with no `dataset`, a bare report name or a `{ name }` reference, which the drawer answered by listing the records, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-metric-drill-down-report-typed`. It types the `object-timeline` page block's `items` (fork 4, letter B): each entry is one of objectui's two ruled kinds, closed — a feed entry `{ time, title, description, variant, icon, content, className }` or a gantt row `{ label, items }` of bars `{ title, startDate, endDate, variant }`, each date a string or epoch milliseconds — and a row refinement pairs each entry with the kind the block's `variant` selects, so a feed entry with no `title`, or a gantt row on a feed timeline, is refused instead of drawn empty. A feed entry's `content` (child components) is held unjudged until a writer appears. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-items-typed`. And it types the members of the `action:group` and `action:menu` page blocks, the last of those forks (the same card, fork 5, letter A): each member was an open record the container draws and runs itself, so a misspelled key, a node-style `actionType` or an `endpoint` no `api` handler reads passed every door. A member now takes `action:button`'s keys with its executor spelled `type`, measured from the containers' reads — an `action:menu` item reads no `size` and declares none — with the rows' prescriptions; `outcomeMessages`, a member `className` and a member `properties.params` are refused, and `outcomeMessages` stays undeclared on all four action blocks as one decision. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-members-typed`. It then closes the one static-values spelling those members still accepted and the containers drop: an `action:group` or `action:menu` member's `params` takes the input list, an `ActionParam[]` array, only, unless the member's `type` is `api`, whose object `params` keeps its request-payload window. `params` carries one shape and no second value-bag key is declared, so an object `params` on any other member, which parsed and then reached no action, is refused at `actions.N.params` with the prescription to author an action with static parameter values as its own `action:button` node. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry `ui-action-group-menu-member-params-array-only`. It also judges an `approval` flow node's `config` at parse against the contract the spec declares for it, `ApprovalNodeConfigSchema`, WHOLE. The approval executor fails the node on any issue of that contract, while `objectstack validate` and `objectstack compile` exited 0 on an undeclared `escalation.bogusKey` or a `timeoutHours: 0.5` and compile copied it into the artifact. The approval node now joins a declared contract map beside the builtin executor contracts, read by the one judge `registerFlow` and `objectstack validate` share, with no plugin loaded: an undeclared key or a refused value is refused at `nodes.N.config.` in the contract's own words, its did-you-mean included, and a key left out as before. The builtin arm stays presence-only. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what the author meant. Its D3 record is the semantic entry `flow-approval-node-config-contract-refused`. Then the builtin arm stops being presence-only: a present value a builtin node's executor contract refuses is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Every builtin executor parses its config against that contract before it acts, so a `create_record` `outputVariable: 42` or a screen field `min: '1'` used to pass `objectstack validate` and `objectstack compile`, register, and fail every run that reached the node. The arm judges only what the build can know the run will parse: never a value carrying a `{token}`, whatever its slot's type (held back by ruling, not admitted: outside `http` such a token in a number or boolean slot still fails at its first run, so those slots take a literal); on `http`, which parses after interpolating, only token-free values and never the credential-held `signingSecret`; on a `loop`, only one with a `body`; on the region containers, never the region slots. Key membership is untouched. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know the value the author meant. Its D3 record is the semantic entry `flow-builtin-node-config-values-refused`. It also retires the flat-list form of a package manifest's `permissions` (ADR-0049 enforce-or-remove): `ManifestPermissionsSchema` was a union of a list of permission strings and the structured ADR-0025 block `{ services, hooks, network, fs }`, and nothing ever acted on the list — the loader registers the consented grant set, never the manifest's request — so the block is now the only form. A list is refused at parse with its prescription, and the D2 conversion `manifest-permissions-string-list-removed` strips it from the stack's manifest and every `packages[].manifest` as a lossless delete, retired from the load path; translating what each dropped string meant into the four lists is the author's judgement, not a rewrite. It also makes a declared index state its uniqueness scope (ADR-0120 D1, staged to this protocol by D7). On `indexes[].unique`, bare `true` was the one spelling whose scope was positional: it built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. The parse now refuses it with a prescription naming both words — `'global'` (installation-wide, the index bare `true` built) and `'organization'` (one holder per organization). Field-level `unique: true` is untouched. The D2 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'`, which is lossless and drift-free by construction, retired from the load path so authors are refused at the door while stored rows, built artifacts and `os migrate meta` replay it. Its D3 record is the semantic entry `declared-index-bare-unique-true-retired`: whether each respelled index was really meant installation-wide is the author's call. It also takes the injected organization column off seven deployment-level platform tables — `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` (ADR-0131 D7). A writer census found no writer that attributes a row of any of them to an organization, so the column only ever held NULL, and under a walled posture the tenant wall hid every row from every reader. Each now declares `systemFields: { tenant: false }` and the object-level capability gate `requiredPermissions: ['manage_platform_settings']`: with no column there is no wall, so reads are governed by object permission, and the gate keeps one organization's administrator off another organization's rows. Nothing in stack metadata is rewritten; an existing database keeps the column as an orphan the boot drift report names, and `os migrate apply --allow-destructive` drops it. The D3 records are the seven `sys-*-organization-column-retired` semantic entries. It also refuses, at parse, a flow edge that does not resolve in its own graph or that repeats an earlier one. An edge's `source` and `target` must name nodes of the graph that declares it — the flow's own nodes, or the region body's for an edge inside a region — because the engine resolves them there alone, and a dangling edge carried the run nowhere, silently; and an edge with the same `source`, `target`, `type`, `condition` and branch `label` as an earlier edge of that graph is refused, because the engine runs a target once per out-edge it selects and a copy ran it again. Both are judged in the region walk the node-id rule uses, so `objectstack validate`, `registerFlow` and the metadata save door agree. No key is removed, so there is no tombstone, and no D2 conversion exists: a dangling endpoint carries no intent a rewrite could recover, and dropping a copy changes how often its target runs. Its D3 record is the semantic entry `flow-edge-unresolved-or-repeated-refused`. And the builtin arm judges key membership where no other door does: a key a `script` or `subflow` node's executor contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code. Those two descriptors publish no `configSchema`, so `registerFlow`'s undeclared-key check skipped them, while their executors parse the strict contract and refuse the node on an undeclared key: a `script` `bogusKey` used to pass `objectstack validate`, `objectstack compile` and registration and fail every run that reached the node. Every other builtin keeps its undeclared keys at registration, against its descriptor; a retired `script` key keeps its tombstone. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what an undeclared key was meant to be. Its D3 record is the semantic entry `flow-script-subflow-config-undeclared-keys-refused`. It also moves the settings cascade's global rung out of the tenant-scoped `sys_setting` into the new tenant-less `sys_platform_setting` (ADR-0131 D7): one row per namespace and key for the deployment, no organization column, reads governed by the `manage_platform_settings` capability. The settings service writes a global-scope key there and reads the rung from there alone, and the `global` option of `sys_setting.scope` retires because no write reaches it. The cascade order and the `global` resolution source are unchanged. Nothing moves automatically: the v18 upgrade ceremony moves existing global rows, `sys_secret` handles included, and they open unchanged because the ADR-0128 AAD binds no holder object and no organization. The D3 record is the `sys-setting-global-rung-moved` semantic entry. It also retires the document family WHOLE (ADR-0049 enforce-or-remove; the ruling of record on PDF and print documents, letter B′, 2026-10-08: "A document is a page with a print declaration; no new template type"): the four defs of `data/document.zod.ts` — `data/DocumentTemplate` (a docx template with placeholders), `data/Document`, `data/ESignatureConfig` and the orphaned `data/DocumentVersion` — exported from `@objectstack/spec/data`, mounted by no stack key, registered as no metadata type and read by nothing in this repository, objectui or hotcrm, leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry, so that "template" means one thing: a printable document is a page that declares `print`. The `ESignatureConfig` deadline-key tombstones leave with their def's source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. It retires the single-brace `{…}` template dialect from the flow VALUE slots (the C half of the maintainer's ruling D on the flow expression dialects): the `assignment` node's values, in all three shapes, and the `fields` map of `create_record` and `update_record`, where a CEL value envelope is already the expression form. A string there is now the literal text it spells, and one carrying a `{…}` token is refused — by `FlowValueSlotSchema`, `registerFlow`, `objectstack validate` and the executor alike — with the CEL spelling of each token. No D2 conversion exists: every authored spelling was measured lossy (an absent key writes nothing under the template and fails under CEL; CEL divides two integers as integers), so which value an absent key should write is the author's judgment. The `$User` paths are refused too: the flow CEL scope binds `current_user`, the run's user or `null` in a run with none, so `{$User.Id}` is `current_user.id`, guarded where a flow can run without a user, and the other `$User` paths, which never resolved, name a read of the user record. The date macros keep their meaning until CEL can spell them. Its D3 record is the semantic entry `flow-value-slot-template-dialect-refused`. It also takes the injected organization column off the compliance ledger, `sys_audit_log` (ADR-0131 D7): some of its rows are about deployment-level actions no organization owns, so the organization a row is about stays in the attribution field `tenant_id`, which every writer already stamps, and never becomes the tenancy anchor. With no column there is no wall, so a platform administrator now reads the rows about no organization too; an organization reader is scoped to the rows about its active organization by the platform row policy `sys_audit_log_org`, stripped when no wall is enforced, and `organization_admin` names the ledger without the superuser bits so its wildcard bypass cannot skip that policy. Per-tenant retention partitions on `tenant_id`. Nothing moves automatically: an existing database keeps the column as an orphan the boot drift report names, for the v18 ceremony to drop once its values are confirmed in `tenant_id`. The D3 record is the `sys-audit-log-organization-column-retired` semantic entry. Then the builtin key arm covers every builtin whose contract registration could judge: a key the executor contract of a `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, closed with the rename-or-remove remedy. Registration's descriptor walk refused those keys already, after `objectstack validate` and `objectstack compile` had passed them, and it now stands aside for those types, so each has one judge; the declared key sets were measured equal first, so registration refuses what it refused before. `try_catch` waits for its contract's `retry` to close (below): it stripped an unknown key where its descriptor closes it. No key is removed, so there is no tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `flow-builtin-node-config-undeclared-keys-refused`. It executes ADR-0032 Decision 3 in the flow TEXT slots — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`: they render through the formula template engine, so their placeholders are `{{ }}` holes, a variable path with an optional formatter (the engine's hole grammar now admits a `$`-named variable, so `{{ $error.message }}` is a hole). A single-brace `{…}` token there is refused — by the node contract, `registerFlow` and `objectstack validate` alike — with the hole spelling of each path token, or, for arithmetic, a function, a date macro or a run-user path, the `assignment` that computes it into a variable. No D2 conversion exists: the 17.x interpolator and the engine render a `Date` differently (JSON-quoted against ISO text), and a whole-slot object differently in a screen or `end` text, so the rewrite is the author's to check. Every other flow string keeps the single-brace dialect. Its D3 record is the semantic entry `flow-text-slot-single-brace-refused`. It also makes the deployment's platform-global declaration total (ADR-0131 D7): an object a deployment declares platform-global in its `org-scoping` service's `platformGlobalObjects` gets no organization column on that deployment, because the injected-columns plan reads the declaration, so the organization wall and the driver agree by having nothing to scope. The engine reads it at its plugin start, before the first schema sync, once every plugin init has run, and re-plans the objects registered before it; the security layer's stand-down for such an object retires with it. An absent declaration changes nothing, and a malformed one is refused and declares nothing. Nothing moves automatically: a declaring deployment's existing table keeps the column as an orphan the boot drift report names. The D3 record is the `platform-global-object-organization-column-retired` semantic entry. It also retires the element layer's second data door (#11509, ruling A-narrow): the flat `object` / `filter` / `sort` / `limit` keys of `element:record_picker` and `element:repeater` and the flat `object` / `filter` of `element:number`, each the same query as a key of the node-level `dataSource` binding, resolved per element by three contradictory rules. The console moved all three elements onto the binding first, so from this major an element binds data through `dataSource` only; the keys are retiredKey tombstones, and the component-props gate requires `dataSource.object` on the three instead of waiving the flat key for them — which also closes the repeater that passed validation bound only through a binding it did not read. The D2 conversion `element-flat-data-binding-to-data-source` follows each element's old rule (move where the binding lacks the key, delete where the binding won, append `element:number`'s filter, which AND-combined) and runs before the record-form filter conversion, which then converts a moved record form at `dataSource.filter`; what the old rule leaves undecided is a TODO, judged by the D3 entry. It also retires `object-grid`'s `defaultFilters` (#11509, ruling A-narrow, in the shape of the `defaultSort` retirement): the legacy second spelling of `filter`, read only when `filter` lowered to nothing, which an earlier narrowing in this major had shaped as the rule array and this retirement absorbs. The mechanical conversion moves the rules onto an empty `filter` and deletes the key beside a `filter` with content (the renderer never read it there); it runs before the record-form filter conversion, which then converts a moved record form at `filter`. It also retires the `sys_view_definition` platform object as inert (ADR-0131 D13): no framework code wrote or read its rows, and runtime-authored views are `view` items in `sys_metadata`. The object, its two registrations, its `kernel:ready` active-row index migration and that migration's exports leave, and its name leaves the platform-object registry. Nothing in stack metadata is rewritten; an existing database keeps the table, which no platform path drops. The D3 record is the `sys-view-definition-retired` semantic entry. Then the retry policy closes, and `try_catch` joins the builtin key arm: `RetryPolicySchema`, the one declaration behind `job.retryPolicy` and a `try_catch` node's `retry`, refuses a key it does not declare, naming it with a did-you-mean, where it used to strip it — and with opt-in defaults a stripped `maxRetries` meant no retry at all. No writer relied on the strip. With `retry` closed to the five keys the descriptor declares, a key a `try_catch` node's contract does not declare is refused at parse, at `nodes.N.config.`, with the same `node-config-refused-by-contract` code, and the descriptor walk keeps plugin node types only. A `retryDelayMs` the conversion leaves beside a different `backoffMs` meets its tombstone there, as it met the walk. No key is removed, so there is no new tombstone, and no D2 conversion exists. Its D3 record is the semantic entry `try-catch-and-retry-policy-undeclared-keys-refused`. In those same text slots a `{{ }}` hole may root at a `$`-named variable only when the flow engine binds it (`$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`): a hole such as `{{ $User.Id }}`, which the 17.x contracts accepted as a plain string and which renders nothing under the template engine, is refused by the node contract, `registerFlow` and `objectstack validate` with the remedy its single-brace spelling gets — compute the value with an `assignment` node, then write the variable as a hole. The template engine binds no new variable, and no D2 conversion exists: what the hole was meant to read is not in the flow. Its D3 record is the semantic entry `flow-text-slot-unbound-dollar-root-refused`. The binding keys follow the same rule, so a flow cannot bind a `$` name it is then refused to read: a node's `outputVariable` (`get_record`, `create_record`, `map`, `script`, `subflow`) refuses a name that starts with `$`, and a `try_catch` `errorVariable` refuses every one but the engine's own `$error`, its default. The remedy is the same name without the `$`, read as `{{ name }}`. Each key states the rule as a `pattern`, so the published JSON Schema refuses what the parse refuses, and the node contract, `registerFlow`, `objectstack validate` and the run itself refuse such a name at the key. No D2 conversion exists: the bare name may already be bound in the flow, and the reads of the old name sit in every dialect a flow string speaks, so the rename is the author's. Its D3 record is the semantic entry `flow-binding-variable-dollar-name-refused`. ### Mechanical (applied for you) @@ -514,7 +514,9 @@ Protocol 18 extends the publish-time refusal of unresolved placeholders, which p | `dashboard-widget-chart-config-structure-removed` | `dashboard.widgets[].chartConfig.type / dashboard.widgets[].chartConfig.xAxis / dashboard.widgets[].chartConfig.yAxis / dashboard.widgets[].chartConfig.series` | dataset-bound dashboard widget chart-config keys 'type'/'xAxis'/'yAxis'/'series' removed (ADR-0021 — the dataset decides which series exist and which column each one reads; the widget's own 'type' is the chart family, and 'dimensions'/'values' are the selection, so an authored axis could only agree with the dataset or silently re-point a series at another column) | retired — `migrate meta` only | | `translation-per-app-settings-removed` | `stack.translations[]..settings / translation.settings` | translation group 'settings' removed from both application-authored faces, the per-app bundle entry and the registered translation item: settings copy belongs to the platform, and the two authoring doors of one application translation type accept one shape. It is keyed by SettingsManifest.namespace and only platform code declares a manifest. A per-app bundle entry could only fill gaps the platform's own bundle left in the one merged served tree, and was overwritten wherever both defined the key; a stored item OVERRODE the platform copy, because the runtime-authored layer is read over the shipped bundles. Overrides now give way to the platform copy, gaps fall back to the manifest literal, and the group stays on the PLATFORM bundle, PlatformTranslationData | retired — `migrate meta` only | | `object-tenancy-organization-field-removed` | `object.tenancy.organizationField` | object `tenancy.organizationField` removed (ADR-0049 — the stamp-only column declaration was authorable by every application and declared exactly once in the whole protocol, on the platform's own credential table; the divergence moves to a platform-internal table in @objectstack/metadata-core and stops being a knob) | retired — `migrate meta` only | -| `page-component-filter-record-to-rule-array` | `page.component.dataSource.filter / page.component.properties.filter (the object-* blocks, element:number, element:record_picker) / page.component.properties.defaultFilters (object-grid) — the record and single-level AST filter forms` | a record-form or single-level AST filter at a converged rule-array door becomes the `[{ field, operator, value }]` rule array wherever the mapping is lossless (flat keys → `equals` rules, `{ $op: v }` → the mapped operator, AST comparisons → one rule each); a filter carrying `$and` / `$or` / `$not` or any part with no lossless rule spelling is left exactly as stored — reported as a TODO, which `os migrate meta --stored` lists — and is not the form its door declares (one filter orthography platform-wide, the rule array; the migration converts only what maps losslessly and names the rest, because flattening a combinator would silently change what a page selects) | retired — `migrate meta` only | +| `element-flat-data-binding-to-data-source` | `page.component.element:record_picker.object / page.component.element:record_picker.filter / page.component.element:record_picker.sort / page.component.element:record_picker.limit / page.component.element:number.object / page.component.element:number.filter / page.component.element:repeater.object / page.component.element:repeater.filter / page.component.element:repeater.sort / page.component.element:repeater.limit` | the element layer's flat data-binding keys removed — 'object' / 'filter' / 'sort' / 'limit' on element:record_picker and element:repeater, 'object' / 'filter' on element:number — each the same query as a key of the node-level 'dataSource' binding, the one door the element reads: a key the binding lacks moves there unchanged, one the binding already set is deleted where the binding always won (and element:number's filter is appended to the binding's, since the two always AND-combined); a key whose effect depended on a saved 'dataSource.view', or that disagrees with a binding the repeater never read, is left as stored and reported as a TODO | retired — `migrate meta` only | +| `object-grid-default-filters-removed` | `page.component.object-grid.defaultFilters` | object-grid component prop 'defaultFilters' removed (the legacy second spelling of 'filter', read only when 'filter' lowered to nothing; its rules move onto an empty 'filter', and the key is deleted beside a 'filter' that has content, which the grid always read instead) | retired — `migrate meta` only | +| `page-component-filter-record-to-rule-array` | `page.component.dataSource.filter / page.component.properties.filter (the object-* blocks) — the record and single-level AST filter forms` | a record-form or single-level AST filter at a converged rule-array door becomes the `[{ field, operator, value }]` rule array wherever the mapping is lossless (flat keys → `equals` rules, `{ $op: v }` → the mapped operator, AST comparisons → one rule each); a filter carrying `$and` / `$or` / `$not` or any part with no lossless rule spelling is left exactly as stored — reported as a TODO, which `os migrate meta --stored` lists — and is not the form its door declares (one filter orthography platform-wide, the rule array; the migration converts only what maps losslessly and names the rest, because flattening a combinator would silently change what a page selects) | retired — `migrate meta` only | | `view-item-owner-hidden-removed` | `view.owner / view.hidden — on the view item record ({ name, object, viewKind, config })` | view item keys 'owner'/'hidden' removed (ADR-0049 — declared on the view item record and stored verbatim, read by nothing: no view switcher ever filtered on `hidden`, and no per-user scope ever read `owner`, so a view marked as one user's was listed for everyone) | retired — `migrate meta` only | | `report-joined-chart-removed` | `report.blocks[].chart / report.chart on a joined report` | a joined report's 'chart' removed from its blocks and refused on the container (ADR-0049 enforce-or-remove: the joined renderer draws each block as a table and never read either, so the chart parsed and nothing was plotted; a non-joined report keeps its live 'chart') | retired — `migrate meta` only | | `view-overlay-owner-hidden-removed` | `view.owner / view.hidden — on a flattened view overlay ({ name, object, viewKind, …, no config })` | flattened view overlay keys 'owner'/'hidden' removed (ADR-0049 — the view item's pair on the overlay door, retired the same way: declared, accepted by the write door and stored verbatim, read by nothing, so a `hidden: true` overlay hid no view and an `owner` scoped none) | retired — `migrate meta` only | @@ -837,15 +839,12 @@ This is a CODE-path API, not stored metadata, so — like `driver-sql-unresolvab - **`element-filter-and-form-node-refused`** — `page.component.element:filter / page.component.element:form — the bare component node itself, left standing by the `element-filter-removed` and `element-form-removed` conversions after they strip its properties` → Delete the component node. `element:filter` → a list surface owns its own filtering: use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. `element:form` → the object-bound `object-form` block, which is rendered, designer-publishable and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Nothing is placed where the node was unless the page needs it — which region keeps its layout is the judgment this step delegates - Why not automatic: Both elements were retired whole at element grain (ADR-0049 enforce-or-remove): no renderer for either ever shipped in objectui, framework or cloud, so every authorable key was a capability claim nothing kept. The conversions are mechanical where they can be — they strip all twelve keys losslessly — and stop at the node, because removing an authored page node changes the LAYOUT of a page the author composed, and a conversion cannot know whether the region should close up, hold a replacement, or keep its slot. That residue is no longer inert: both names are members of `RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses them by name, and a stack that replays the chain and stops there is schema-INVALID. Mechanical where it can be, delegated where it cannot — this entry is the delegation, in writing - Done when: No `element:filter` and no `element:form` component remains in any page — regions, named slots and nested containers alike (the conversions walk all three, so every place they stripped properties is a place a bare node can be sitting). `os validate` is clean: the refusal is reported at the node's `type` path with `params.retiredComponentType` naming the element, so a remaining node is named individually rather than as one page-level failure. Replaying the same 17 → 18 chain over the edited source then reports the migrated stack schema-valid — `schemaValid: true` in `--json`, and the run closes with the schema-valid line rather than the manual-changes warning +- **`element-flat-data-binding-retired`** — `page.component properties of element:record_picker (object, filter, sort, limit), element:number (object, filter) and element:repeater (object, filter, sort, limit) — the flat data-binding keys beside the node-level dataSource` → `dataSource` on the component node — `{ object, view?, filter?, sort?, limit? }`, a sibling of `type` rather than a key inside `properties` — the one binding each of the three elements reads. Each key moves unchanged: `properties: { object: 'deal', limit: 20 }` becomes `dataSource: { object: 'deal', limit: 20 }`, and a filter keeps its rule-array form `[{ field, operator, value }, ...]`. `element:number` reads `object` and `filter` only. With a `view`, the view supplies the baseline, an explicit binding key overrides it, and the binding filter AND-combines with the view's. + - Why not automatic: One node carried two doors onto one query, resolved by three different rules: the record picker let the binding win (its flat key was read only when the binding, or the saved view the binding named, supplied none), `element:number` resolved `object` binding-first and AND-combined the two filters, and the repeater read its flat keys alone and ignored the binding — while the component-props gate waived a missing flat `object` whenever `dataSource.object` was present, so a repeater bound only through `dataSource` passed validation and drew an empty list. The console moved all three elements onto the binding first, and in v18 the flat keys are refused. The D2 conversion `element-flat-data-binding-to-data-source` follows each element's old rule: a key the binding lacks moves there, a key the binding already set is deleted where the binding won, and `element:number`'s filter is appended to the binding's. Three cases are left as stored and listed as TODOs, because only the author can decide them: a record-picker key beside a `dataSource.view` the binding sets no such key of its own for (the flat value applied only if the view supplied none, and no conversion reads the view); a repeater key the binding sets to a DIFFERENT value, or beside a `view` (the repeater read neither until the console put its binding first, so what it applied depends on the console version); and an `element:number` filter pair that is not two rule arrays. A repeater that carried a `dataSource` its list ignored now applies it — compare it with what the list showed. A flat filter in the retired record form moves to `dataSource.filter` and is then converted there by `page-component-filter-record-to-rule-array` wherever the mapping is lossless; that entry lists the rest. Code that builds these props — a host, a generator, a designer — must write the binding, which no conversion reaches. And the gate now requires `dataSource.object` on all three elements: a node with none names no object and is reported. + - Done when: No `element:record_picker`, `element:number` or `element:repeater` node carries `object`, `filter`, `sort` or `limit` inside `properties`; the parse refuses each. Every such node has `dataSource.object`, and `os validate` reports no missing-binding finding for it. In the running page each picker offers, each number aggregates and each repeater lists the records the author intends — checked first on every node the migration listed as a TODO, and on every repeater that already carried a `dataSource`. - **`element-input-target-variable-retired`** — `page.component.element:text_input.targetVariable / page.component.element:record_picker.targetVariable — the declarative binding hint on the two input elements` → Declare the binding on the page variable instead: a `variables[]` entry whose `source` is the input component `id`. That reverse lookup is the one binding the renderer has ever honoured; the variable name is the author's choice, and `targetVariable` named it from the wrong end. - Why not automatic: The D2 conversion `element-input-target-variable-removed` deletes `targetVariable` from every text-input and record-picker component, and the delete is lossless: no renderer, hook or runtime ever read the key, so an input authored with it and without a matching `variables[].source` wrote nothing, with a success receipt and no diagnostic. What the delete cannot do is restore the intent. An author who wrote `targetVariable: 'contact_email'` meant that input to feed that variable, and after the strip the page is exactly as unbound as it always was — now without even the hint that says so. Whether the variable exists, whether its `source` already names this component, and whether anything downstream (a flow input, a filter, a visibility predicate) reads it are facts about the author's page that no conversion can see, so the binding is delegated rather than invented. - Done when: For every `element:text_input` and `element:record_picker` component that carried `targetVariable`: either the page declares a variable whose `source` equals the component `id`, or the author has decided the input needs no binding. With the binding declared, typing into the input (or picking a record) and then reading the variable — from whatever consumes it on the page — returns the value entered. No component authors `targetVariable`; the parse refuses it by name. -- **`element-number-filter-rule-array`** — ``element:number` component props — `filter` (the FORM: the MongoDB-style `FilterConditionSchema` record vs the `ViewFilterRule` array)` → `z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` every other `filter` input in `ComponentPropsMap` already declares (`record:related_list` and its Add-affordance picker). A record-form filter `{ status: 'won' }` becomes `[{ field: 'status', operator: 'equals', value: 'won' }]`; an operator object `{ amount: { $gt: 100 } }` becomes `[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse - - Why not automatic: One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: align the element to the `ViewFilterRule` array rather than keep it the record-shaped exception). `ComponentPropsMap['element:number'].filter` was the one `filter` input in the map declared as the MongoDB-style record (`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so the filter a list view stores and renders was refused by the KPI element beside it, and the objectui parity gate had to carry a reasoned exemption to look away. The convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): the console adapter was changed so `ObjectStackAdapter.aggregate()` runs the same `translateFilterArray` its `find()` path runs, and the objectui pin carrying it was re-measured before this entry moved — but that measurement named the wrong hop, and the runtime route's refusal of the array corrects it here. `translateFilterArray` yields AST tuples, which are still a `FilterArray` — input-only sugar — so the real path is: authored array → `translateFilterArray` → lowered by `parseFilterAST` (`@objectstack/spec/data`, the single sink the `FilterArray` docblock names, since the maintainer's 2026-08-04 ruling C declared the array input-only sugar with one lowering seam) in the adapter, BEFORE the wire → a `FilterCondition` on the body. The hop that decides it is the runtime route `POST /analytics/query`, which parses `where` with `AnalyticsQueryRequestSchema` — a `FilterCondition` and nothing else — so an un-lowered array is refused there before any service code runs. `lowerAnalyticsWhere` (`service-analytics`), where that earlier measurement stopped, is the IN-PROCESS door (added when an array `where` was found silently dropped on the analytics path) for callers reaching `analyticsService.query` directly, not the wire's; it too still refuses a RAW rule-object array by design. The adapter-side lowering lands in the console's own repository. The ruled migration check ran with the change: the sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, packages/apps/, spec fixtures) found ONE `element:number` author writing a record-form `filter` — a spec test fixture, rewritten to the array form in the same change — and zero outside the spec package; this entry carries the prescription for authors outside the repo. - - Done when: `ComponentPropsMap['element:number'].safeParse({ object, aggregate, filter: [{ field: 'status', operator: 'equals', value: 'won' }] })` succeeds and the parsed `filter` is the same rule array; a record-form `filter: { status: 'won' }` is refused at the `filter` path (`invalid_type`, expected array). At runtime the element renders its aggregate on an analytics-capable deployment with the array filter applied — the same filter a list view renders. Downstream (objectui, after a released spec version reaches the pin): the `element:number.filter:array` entry in `OFF_SPEC_ARM_EXEMPTIONS` (`registry-inputs-spec-parity.test.ts`) becomes deletable, which is what closes the console-side half of this convergence. -- **`element-record-picker-filter-rule-array`** — ``element:record_picker` component props — `filter` (the FORM: the MongoDB-style `FilterConditionSchema` record vs the `ViewFilterRule` array)` → `z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` the map's array-declared `filter` doors already carry (`record:related_list`, its nested Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as `z.unknown()`, a gap measured on its own). A record-form filter `{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; an operator object `{ amount: { $gt: 100 } }` becomes `[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse. The binding-level `dataSource.filter` on the same node is a different key (`ElementDataSourceSchema`) and is not moved by this entry - - Why not automatic: One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: every `filter` door takes the `ViewFilterRule` array rather than keeping record-shaped exceptions). `ComponentPropsMap['element:record_picker'].filter` was the LAST `filter` input in the map still declared as the MongoDB-style record (`FilterConditionSchema`) after `element:number` converged: the three array-declared doors (`record:related_list`, its nested Add-affordance picker, `element:number`) carried the `ViewFilterRule` array and the four `object-*` doors declare `z.unknown()`, so the filter a list view stores and renders was refused by the picker beside them, and a lone holdout is the state where the next author copies the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 Option-A ordering ruling: measure the consumer's read path before the contract moves): at the objectui pin `00d3f09c` the renderer hands `filter` to `query.$filter` and calls `adapter.find()` (`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples (`data-objectstack/src/index.ts`), the same door every list view's stored rule array already takes, and the engine lowers the tuples before the driver (`engine-filter-array-lowering.test.ts`); nothing on that path parses `properties` against the installed spec. The pin and objectui `main` (`f7cf7e8`) are byte-identical on every read-path file. The ruled migration check ran with the change: the sweep of first-party corpora (examples/, skills/, content/docs/, docs/, packages/**, .changeset/) found ONE `element:record_picker` author writing a record-form `filter` — a spec test fixture, rewritten to the array form in the same change — and zero outside the spec package; this entry carries the prescription for authors outside the repo. - - Done when: `ComponentPropsMap['element:record_picker'].safeParse({ object, filter: [{ field: 'status', operator: 'equals', value: 'active' }] })` succeeds and the parsed `filter` is the same rule array; a record-form `filter: { status: 'active' }` is refused at the `filter` path (`invalid_type`, expected array). At runtime the picker offers exactly the rows the array selects — the same filter a list view renders. Downstream (objectui, after a released spec version reaches the pin): the registry's `inputs.filter` entry for `element:record_picker` (`type: 'object'`, `record-picker.tsx`) flips to the array arm and the `record-picker-inputs-spec-parity.test.ts` pins that assert the record form follow — a console-side change filed in the objectui repository, blocked on that release. - **`element-text-variant-heading-subheading-retired`** — `page components of type element:text — properties.variant authored as heading or subheading (ElementTextPropsSchema.variant)` → one of the nine values `ui:text` publishes: `h1`-`h6`, `body`, `caption` or `overline`. 'heading' → 'h2' and 'subheading' → 'h3' (the heading element each one always rendered), or the level the page outline means - Why not automatic: The ruling converged `element:text` on the vocabulary `ui:text` already publishes, because a heading is a document level, not a text style: `heading` and `subheading` named a style and left the renderer to pick a level. It landed in two releases so authors outside this repository could move first — 17.5.0 added the nine and refused nothing, and 17.6.0 was a full release in which both vocabularies parsed. The D2 conversion `element-text-variant-heading-levels` makes the ruled edit: `heading` → `h2`, `subheading` → `h3`. That keeps the heading element (the renderer drew `heading` as an h2 element and `subheading` as an h3 element), so the document outline a screen reader walks is unchanged, but not the size: `heading` drew in the `h3` style and `subheading` in a medium-weight small heading style, and `h2` / `h3` draw their own, larger styles. Whether the page wanted that level is the author's call — a heading placed for its size rather than its place in the outline may want a deeper level. Nothing is dropped at rest: a stored page replays the rewrite at rehydration; a page component's `properties` is not parsed on the save path, and the component-props gate reports an old spelling as an advisory `component-props-invalid` finding, carrying the prescription, on `os validate`, `os build` and `os lint`. ADR-0087 - Done when: No `element:text` page component carries `variant` `heading` or `subheading`; `os validate` reports no `component-props-invalid` finding under `properties.variant` for these blocks. For each rewritten block, open the page and check the heading: it renders the same heading element as before, in its level's style. Where the old, smaller look mattered more than the level, pick the level whose style you want and confirm the outline still reads in order. A block that omits `variant` still renders as `body`. @@ -973,18 +972,18 @@ AUTHOR-REACHABLE SURFACES: a saved report's `query.filter` (`sys_saved_report`) - **`flow-script-subflow-config-undeclared-keys-refused`** — `a script or subflow flow node whose config carries a key its executor contract does not declare — a typo (funtion), a key copied from another node type (a subflow timeoutMs written inside config, an approvers list on a script), or a key nothing reads (bogusKey). script declares function, inputs and outputVariable; subflow declares flowName, input and outputVariable. Never a retired script key (actionType, template, recipients, variables, script), which keeps its own path, and never a key on any other builtin node type, whose undeclared keys registration already judges against the node type descriptor. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata` → the key the contract declares, or no key: rename a typo to the declared key it meant (`function`, `inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow`), move a value the function or the child flow should receive into `inputs` (script) or `input` (subflow), move a `subflow` timeout to the node itself (`{ id, type: 'subflow', timeoutMs: 30000, config: { … } }`), and delete a key nothing reads. The refusal carries the contract's own sentence, with its did-you-mean for a near miss - Why not automatic: The `script` and `subflow` executors (`service-automation` `builtin/screen-nodes.ts`, `builtin/subflow-node.ts`) parse the node's `config` against a strict contract (`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared key. No door before the run judged one: `registerFlow`'s undeclared-key check derives the declared set from the node type descriptor's `configSchema`, and these two descriptors publish none (the schemaless class, `SCHEMALESS_NODE_CONFIG_SCHEMAS`), while the build doors' executor-contract arm judged required keys and present values but held key membership back on the premise that registration judges it. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (which copied the key into the artifact), registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share (`flowNodeConfigRefusals`) now refuses such a key on these two types as `node-config-refused-by-contract`, anchored at the key, one refusal per key, in the contract's own words — the code the value half and the approval contract already use. Every other builtin keeps its undeclared keys where they were judged: at registration, against its descriptor, with that check's own prescriptions. `decision` is schemaless too, but its executor parses no contract, so an undeclared key there fails no run and stays unjudged. A retired `script` key keeps its tombstone path. ⚠️ A spelling the ADR-0087 D2 conversion `flow-node-script-config-aliases` or `flow-node-subflow-flow-alias` still rewrites at load (`functionName`, `input` on a `script`; `flow` on a `subflow`) is converted before the judge at every door that converts first; met by a direct `FlowSchema.parse` or `defineFlow()` it is refused like any other undeclared key, as the missing canonical key already was. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows beside it register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031. - Done when: Run `objectstack validate` over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: `FlowSchema.parse` anchors a `custom` issue at `nodes.N.config.` (`nodes.N.config.bogusKey`, or the region path `nodes.N.config.body.nodes.M.config…`), `objectstack validate` prints the same path, and `validateStackExpressions` phrases it as `node 'summarize' (script) config.bogusKey`. For each hit rename, move or delete the key per the replacement. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it — that warn line is the locator for a row that exists only in `sys_metadata`. A `script` or `subflow` node whose keys its contract declares parses and registers byte-identically to before. -- **`flow-text-slot-single-brace-refused`** — `flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token` → a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths as the value-slot spelling that still reads them — and written as {{ variable }} +- **`flow-text-slot-single-brace-refused`** — `flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token` → a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros as the value-slot spelling that still reads them, the run user's id as the CEL value envelope current_user.id — and written as {{ variable }} - Why not automatic: ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it. - Done when: Run objectstack validate: it reports each refused text slot as expression-invalid at the node and the slot's key, with the double-brace spelling of every path token. Rewrite each slot as that spelling; for a token no hole can spell, add the assignment the refusal names and write its variable as a hole. Re-run the flow paths that send those notifications or show those screens and compare the text with the text the 17.x renderer produced — in particular any slot that renders a date value or a whole object. -- **`flow-text-slot-unbound-dollar-root-refused`** — `flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}` → a variable the run has, written as a hole. The run user is computed first, with an assignment node whose value slot still reads the run-user path (assignments: { by: '{$User.Id}' }), then written as {{ by }}. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }} +- **`flow-text-slot-unbound-dollar-root-refused`** — `flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}` → a variable the run has, written as a hole. The run user's id is computed first, with an assignment node whose CEL value envelope reads current_user, the run's user (assignments: { by: { dialect: 'cel', source: 'current_user.id' } }), then written as {{ by }}; every other run-user path never resolved in any shipped run, and an email or a name is read from the user record by current_user.id. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }} - Why not automatic: The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the inner token and left a literal brace on each side; the 18 text slots render holes through the template engine, where such a hole renders nothing and the run reports success. It is now refused by the node contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author meant the hole to read is not in the flow, and the template engine binds no new variable to answer it. - Done when: Run objectstack validate: it reports each refused text slot as expression-invalid at the node and the slot's key, naming the hole and its remedy. For a run-user hole, add the assignment the remedy names and write its variable as the hole; for a variable the flow binds under a dollar name, drop the dollar sign at the binding and in the hole. Re-run the flow paths that send those notifications or show those screens and confirm the text carries the value, with no stray brace and no missing fragment. - **`flow-trigger-record-credential-masked`** — `the record and previous roots a record-change flow receives — a password or secret field, and an internal field, of the triggering record, on every object` → read a credential through a privileged binder — the flow credential channel for an http node's signing secret, or a privileged server-side read such as the engine's resolveSecretField — never off `record` or `previous`; on those roots a set credential-class field now reads as the mask `SECRET_MASK`, an unset one as null, and an `internal: true` field is absent - Why not automatic: ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged. - Done when: No flow reads a password, secret or internal field off its trigger record or previous values expecting the stored value; a flow that needs a credential obtains it through a privileged binder; a start or edge condition that compared such a field against a literal is rewritten to test whether it is set (not null). -- **`flow-value-slot-template-dialect-refused`** — `flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token` → a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal - - Why not automatic: The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it. - - Done when: Run objectstack validate: it reports each refused value as expression-invalid at the node and the value's path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or route around the node. Re-run the flow paths that write those fields and compare the stored values with the ones the template wrote. +- **`flow-value-slot-template-dialect-refused`** — `flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token, the run-user paths beginning $User. included` → a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). The run user's id, $User.Id, is current_user.id — current_user is the run's user, or null when the run has none — and in a flow that can run without a user it is current_user != null ? current_user.id : null, which writes null where the template wrote nothing, so on update_record it clears a stored value the template left alone. Every other run-user path ($User.Email, $User.Name, …) never resolved in any shipped run: current_user carries only what the run holds (id, positions, organizationId, isPlatformAdmin), and an email or a name is read from the user record by current_user.id (a get_record node on sys_user). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal + - Why not automatic: The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. The run user's id was the run's userId under the template, and nothing in a run with no user (a schedule, a record change made by a system write); the flow CEL scope binds current_user to the run's user and to null in such a run, never a pseudo-user, so current_user.id fails there and its guarded form writes null. The other run-user paths read a user object no run carries, so they wrote nothing in every run. One spelling is kept with its old meaning, because CEL cannot write it yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it. + - Done when: Run objectstack validate: it reports each refused value as expression-invalid at the node and the value's path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or route around the node. For the run user, find which flows can run without one (a schedule, a record change a system write can make): there, guard current_user.id, or skip the node with a start condition or a decision on current_user != null where an update_record must leave the stored value alone. Re-run the flow paths that write those fields and compare the stored values with the ones the template wrote. - **`flow-write-node-stored-metadata-target-refused`** — `a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body` → Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its provenance is recorded. Delete the node, or point its `objectName` at the object the flow really means to write. Elevation (`runAs`, a system context) does not change this. - Why not automatic: `FlowSchema` accepted a `create_record`, `update_record` or `delete_record` node whose `objectName` names `sys_metadata` or `sys_metadata_history`, the tables that hold stored metadata. The maintainer ruled (2026-10-03) that app-authored work may not write those tables: the metadata protocol is their only writer, where a change is validated and its provenance recorded, and a flow is app-authored automation. The runtime enforces that at the node, refusing the write before it resolves a filter, computes a field or calls the data engine, under every run identity; but every authoring door still accepted such a flow, and the author learned otherwise only at its first run. The parse now refuses it too, through the one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate` share (`flowNodeConfigRefusals`), with the runtime's prescription: `objectstack validate`, `defineStack`, compile, an artifact's parse, `registerFlow` and the metadata save door each name the node at `nodes.N.config.objectName`. The refused set is exactly the runtime's: one of those three write nodes, whose `objectName` is a string naming a stored-metadata table by exact name. A `get_record` node is outside it (a read is not a write), and so is a dynamic target, a `{token}` template or an expression envelope: the parse cannot read it as a name, and the run judges the name it hands the data engine. No authored flow writing either table was measured in this repository, its examples, its skills or its docs. There is no mechanical rewrite: retargeting the node or deleting it each changes what the author wrote, and the runtime already never ran it. Where such a node already sits, the whole flow is refused: registered from `sys_metadata` at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. - Done when: `objectstack validate` reports no issue at a flow node's `config.objectName`: no `create_record`, `update_record` or `delete_record` node names `sys_metadata` or `sys_metadata_history`. Every change those nodes made to metadata is made through the metadata API instead. Saving each formerly affected flow through the metadata API succeeds instead of answering a 422 that names `config.objectName`, and boot logs no `failed to register flow` warn for it. @@ -1048,7 +1047,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. What makes these two one entry rather than two is the neighbour they share and the one they do not. Both hang off EventBusConfig, so an author configuring a bus met the same bare word twice and had to learn the unit twice; and on EventSourcingConfig the bare retention sits two keys below snapshotRetention, which is a COUNT of snapshots to keep, not a span of time. `retention: 365` and `snapshotRetention: 10` read as the same kind of number and are not. Suffixing the duration separates the families at the authoring site; snapshotRetention keeps its name, because a count has no unit to carry. Both are retiredKey() tombstones — neither shape is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: an EventBusConfig is the event bus construction argument a host builds in code (stack.zod.ts declares no eventBus key and no metadata kind is bound to one), so it is never a stack collection member and never a stored sys_metadata row, and the conversion chain has no seam that would see one. That is what ruling B prescribes for a key that is not authorable metadata, and the disposition the epoch-instant renames on this same kernel took (epoch-instant-keys-renamed). ADR-0087. - Done when: Every EventPersistenceSchema.parse(…) / EventSourcingConfigSchema.parse(…) site and every literal handed to an event bus spells retentionDays; authoring either old spelling fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged in both cases: a bus configured with `retentionDays: 90` keeps events for ninety days exactly as `retention: 90` did, and a config that omits the key still gets the 365 default on EventSourcingConfig. The positive-integer bound rides along with the renamed key, so a zero or negative window is still refused — the pin covering that in kernel/events.test.ts was moved onto the new spelling rather than dropped. - **`kernel-health-check-and-hot-reload-durations-unit-in-key`** — `the three plugin-lifecycle durations whose unit lived in a source JSDoc only: PluginHealthCheck.interval, PluginHealthCheck.timeout and HotReloadConfig.debounceDelay (kernel/plugin-lifecycle-advanced.zod.ts)` → intervalMs, timeoutMs and debounceDelayMs — rename each key; all three values (milliseconds) and their 30000 / 5000 / 1000 defaults are unchanged - - Why not automatic: Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. Each key named milliseconds in its JSDoc — "Health check interval in milliseconds", "Timeout for health check in milliseconds", "Debounce delay before reloading (milliseconds)" — and the JSDoc above a key is NOT what `content/docs/references/**` renders; `.describe()` is. Measured on this tree by the gate's own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] — no unit in the name and none in the published prose either. `interval` is the sharpest of the three: its describe carried one unit-shaped token, the parenthetical "(default: 30s)", which names SECONDS for a value the schema bounds and defaults in MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, published to the one reader who cannot see the source. The suffix is the family's own spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the Ttl-versus-TTL question the sibling round had to settle, there is no competing family spelling to choose between. All three old spellings are retiredKey() tombstones: neither PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and here the stripped value lands on a setInterval period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — no metadata-type binding, stack collection or manifest embed carries either, and both are library parameters a host passes to PluginHealthMonitor / HotReloadManager in TypeScript (kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, and as a host-driven library when the declarative lifecycle config container was retired) — so a conversion would be a transform with no seam that ever runs. That is the same disposition plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded for keys on these two defs. The registration-time refusals in PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for the audience that does not parse. Measured on 884e8347d: the only in-repo readers are packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in this same change; and the pinned objectui checkout — the pin this repo builds against, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — names neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and the string debounceDelay each occur 0 times across its 8281 tracked files (0 across the 8234 at f0268ad78, the 7754 at a58626c88, the 7650 at 0abd4f9f8, the 7632 at 9dfaca654, the 7579 at 2e818d0b5, the 10267 at ab1879721, the 10071 at 89cad75d5, the 9912 at 31971ff1e, the 9800 at e420df310, the 9546 at db11afd49, the 9283 at dd3f7e1be, the 8512 at f8a9d0fb0 and the 8303 at 62597c588 too), against lit controls objectstack 12966 and @objectstack/spec 4997 on the same corpus at 87af769e9, which re-count to 13125 and 5043 respectively at 62597c588, to 13347 and 5123 at f8a9d0fb0, to 13745 and 5466 at dd3f7e1be, to 14704 and 5545 at db11afd49, to 15352 and 6024 at e420df310, to 15691 and 6206 at 31971ff1e, to 16044 and 6461 at 89cad75d5, to 16377 and 6665 at ab1879721, to 17227 and 7134 at 2e818d0b5, to 17313 and 7186 at 9dfaca654, to 17390 and 7209 at 0abd4f9f8, to 17468 and 7246 at a58626c88, to 17956 and 7522 at f0268ad78 and to 17980 and 7523 at this pin (git grep -o -F, the method that reproduces every earlier count). + - Why not automatic: Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. Each key named milliseconds in its JSDoc — "Health check interval in milliseconds", "Timeout for health check in milliseconds", "Debounce delay before reloading (milliseconds)" — and the JSDoc above a key is NOT what `content/docs/references/**` renders; `.describe()` is. Measured on this tree by the gate's own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] — no unit in the name and none in the published prose either. `interval` is the sharpest of the three: its describe carried one unit-shaped token, the parenthetical "(default: 30s)", which names SECONDS for a value the schema bounds and defaults in MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, published to the one reader who cannot see the source. The suffix is the family's own spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the Ttl-versus-TTL question the sibling round had to settle, there is no competing family spelling to choose between. All three old spellings are retiredKey() tombstones: neither PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and here the stripped value lands on a setInterval period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — no metadata-type binding, stack collection or manifest embed carries either, and both are library parameters a host passes to PluginHealthMonitor / HotReloadManager in TypeScript (kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, and as a host-driven library when the declarative lifecycle config container was retired) — so a conversion would be a transform with no seam that ever runs. That is the same disposition plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded for keys on these two defs. The registration-time refusals in PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for the audience that does not parse. Measured on 884e8347d: the only in-repo readers are packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in this same change; and the pinned objectui checkout — the pin this repo builds against, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — names neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and the string debounceDelay each occur 0 times across its 8351 tracked files (0 across the 8281 at 47b1f0bb7, the 8234 at f0268ad78, the 7754 at a58626c88, the 7650 at 0abd4f9f8, the 7632 at 9dfaca654, the 7579 at 2e818d0b5, the 10267 at ab1879721, the 10071 at 89cad75d5, the 9912 at 31971ff1e, the 9800 at e420df310, the 9546 at db11afd49, the 9283 at dd3f7e1be, the 8512 at f8a9d0fb0 and the 8303 at 62597c588 too), against lit controls objectstack 12966 and @objectstack/spec 4997 on the same corpus at 87af769e9, which re-count to 13125 and 5043 respectively at 62597c588, to 13347 and 5123 at f8a9d0fb0, to 13745 and 5466 at dd3f7e1be, to 14704 and 5545 at db11afd49, to 15352 and 6024 at e420df310, to 15691 and 6206 at 31971ff1e, to 16044 and 6461 at 89cad75d5, to 16377 and 6665 at ab1879721, to 17227 and 7134 at 2e818d0b5, to 17313 and 7186 at 9dfaca654, to 17390 and 7209 at 0abd4f9f8, to 17468 and 7246 at a58626c88, to 17956 and 7522 at f0268ad78, to 17980 and 7523 at 47b1f0bb7 and to 18047 and 7545 at this pin (git grep -o -F, the method that reproduces every earlier count). - Done when: Every producer and reader of a PluginHealthCheck spells intervalMs and timeoutMs, and every one of a HotReloadConfig spells debounceDelayMs — concretely packages/core/src/health-monitor.ts, whose loop now reads setInterval(..., config.intervalMs) and whose race reads config.timeoutMs, and packages/core/src/hot-reload.ts, whose debounce now reads config.debounceDelayMs. Authoring any old spelling fails to compile (input type `never`) and fails to parse with the rename prescription naming the suffixed key; handing one to registerPlugin on either class is refused with an ADR-0112 VALIDATION_ERROR / 400 before the plugin is stored. Behaviour is unchanged: the same milliseconds, the same 30000 / 5000 / 1000 defaults and the same min bounds (1000 / 100 / 0), and the published describes now name milliseconds. The sibling shutdownTimeout on HotReloadConfig is deliberately NOT renamed with them: its JSDoc reads "Graceful shutdown timeout" and names no unit anywhere, so it is the unit-nowhere shape (no unit in the name or in the published describe, first measured on two tenant timeouts) that the duration-unit gate leaves outside its verdict, not part of this row set. - **`kernel-package-lifecycle-durations-unit-in-key`** — `the three package and version lifecycle durations whose name carried no unit: UpgradePlan.estimatedDuration (kernel/package-upgrade.zod.ts), PackageDependencyResolutionResult.resolvedIn (kernel/plugin-security.zod.ts) and MultiVersionSupport.rollout.duration (kernel/plugin-versioning.zod.ts)` → estimatedDurationSeconds, resolvedInMs and durationMs — rename each key; every value is unchanged - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These three are one entry because they are one story told to one audience — a package being planned, resolved and rolled out — and because the group is precisely where the unit SPLITS: estimatedDuration is SECONDS while resolvedIn and rollout.duration are MILLISECONDS, three adjacent measurements of the same install, two units, none of them named. A reader who learned the unit from one of these three learned it wrongly for the other two. The rollout case adds a second confusion of its own: duration sat directly beside the unit-less percentage, so one block carried a proportion and a span as indistinguishable bare numbers; percentage keeps its name, because a proportion has no time unit to carry. All three are retiredKey() tombstones; no shape here is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: an UpgradePlan is GENERATED by IPackageService.planUpgrade() before an upgrade runs, a PackageDependencyResolutionResult is emitted by a resolution run, and MultiVersionSupport is a version-routing argument a host constructs — none is a stack collection member or a stored sys_metadata row, so the conversion chain has no seam that would see one. That is what ruling B prescribes for a key that is not authorable metadata. ADR-0087. @@ -1060,7 +1059,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These four are one entry because they are one document — everything here hangs off a PluginSecurityManifest — and because together they are this rule's clearest case in the whole spec: FOUR durations on one manifest carried FOUR DIFFERENT units (milliseconds, seconds, days, hours) and not one of them said so in its name. The sharpest pair is responseTime. On this manifest it means HOURS (how fast a publisher promises to answer a vulnerability report); on PluginHealthReport.metrics, renamed by the same card, the identical bare name meant MILLISECONDS. So `responseTime: 24` was a day on one kernel shape and a fortieth of a second on another, with nothing at the authoring site to tell them apart. The policy was already inconsistent with itself, too: its rate-limit window two blocks above tokenExpiration was ALREADY spelled windowMs, so one security policy carried both conventions. All four are retiredKey() tombstones inside live blocks whose siblings must keep parsing; no shape here is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: a PluginSecurityManifest is a package artifact a publisher ships and a SandboxConfig is the isolation argument a host constructs, so neither is a stack collection member or a stored sys_metadata row and the conversion chain has no seam that would see one. That is what ruling B prescribes for a key that is not authorable metadata. One key deliberately left alone: RuntimeConfig.resourceLimits.timeout on this same file names its unit only in the JSDoc above it ("Execution timeout in milliseconds"), a channel the gate does not read: it reads `.describe()` and `.meta({ description })`, and that key's describe ("Maximum execution time") names none. So the gate lists it among the duration-shaped keys without judging it — neither an offender nor an exemption — and it is outside this rename; that JSDoc-channel gap was filed as a finding of its own and is closed for this key by kernel-runtime-config-timeout-unit-in-key. ADR-0087. - Done when: Every SandboxConfigSchema.parse(…), KernelSecurityPolicySchema.parse(…) and PluginSecurityManifestSchema.parse(…) site, and every literal handed to a plugin sandbox or security manifest, spells the suffixed keys; authoring any old spelling fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged in every case: a sandbox given `timeoutMs: 30000` kills a spawned process after thirty seconds exactly as `timeout: 30000` did, a policy with `tokenExpirationSeconds: 3600` still expires tokens hourly, `retentionDays: 90` still keeps ninety days of audit log, and `responseTimeHours: 24` still promises a twenty-four-hour disclosure response. Every integer bound rides along with its renamed key. Verify the sharp pair explicitly: a manifest and a health report in the same codebase must now read responseTimeHours and responseTimeMs respectively, and neither accepts the bare name. - **`kernel-runtime-config-timeout-unit-in-key`** — `RuntimeConfig resourceLimits.timeout (kernel/plugin-security-advanced.zod.ts)` → resourceLimits.timeoutMs — rename the key; the value (milliseconds) is unchanged - - Why not automatic: This entry COMPLETES what the kernel-directory duration renames deliberately left alone, and the two are meant to be read as a sequence. That round renamed the four plugin-security durations on this same file (`kernel-plugin-security-durations-unit-in-key`) and recorded, accurately, that one key was out of its scope: RuntimeConfig.resourceLimits.timeout named its unit only in the JSDoc above it ("Execution timeout in milliseconds"), a channel check:duration-unit-keys does not read — it reads `.describe()` and `.meta({ description })` — and that key's describe ("Maximum execution time") named none, so the gate listed it among the duration-shaped keys without judging it, neither an offender nor an exemption. That JSDoc-channel gap was filed as a finding of its own, and that round's statement about its own scope stays true. The finding is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. So the reader who most needs the unit — the reader of the published reference page, who never sees the source JSDoc — got a bare integer on content/docs/references/kernel/plugin-security-advanced.mdx and could not tell 60000 milliseconds from 60000 seconds. The key is renamed and the describe is corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation (unit in prose, none in the name). Spelled Ms, the same token SandboxConfig.process.timeoutMs on this very file already carries: counted on this tree, the suffixed family spells it that way in every member (29 key-position `timeoutMs` declarations across packages/spec/src/**/*.zod.ts, 40 distinct *Ms keys) and there is no timeoutMillis, timeout_ms or timeoutMS variant anywhere in packages/spec/src. Tombstoned with retiredKey() because the nested resourceLimits object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: a RuntimeConfig is the engine block of the SandboxConfig a host or a plugin security manifest constructs — stack.zod.ts declares no sandbox, security-policy or runtime-config collection and it is not a stored sys_metadata row — so the conversion chain has no seam that runs on it; the same reading the kernel-directory round recorded for the four keys it renamed. Measured on 146c291943: no in-repo runtime reads the key — packages/core/src/security/sandbox-runtime.ts, the one consumer of this shape, reads resourceLimits.maxCpu (3 occurrences of resourceLimits) and spells timeout 0 times; outside the zod file and its test the only live occurrences are the generated rows in content/docs/references/kernel/plugin-security-advanced.mdx, which this rename regenerates. The pinned objectui checkout — this is the pin we build against, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells resourceLimits.timeout 0 times across 8281 tracked files, against lit controls timeout 1674, RuntimeConfig 337 and resourceLimits 2 on the same corpus (0 across 8234, and 1658 / 337 / 2, at f0268ad78; 0 across 7754, and 1431 / 299 / 2, at a58626c88; 0 across 7650, and 1360 / 293 / 2, at 0abd4f9f8; 0 across 7632, and 1360 / 293 / 2, at 9dfaca654; 0 across 7579, and 1351 / 276 / 2, at 2e818d0b5; 0 across 10267, and 1348 / 273 / 2, at ab1879721; 0 across 10071, and 1331 / 273 / 2, at 89cad75d5; 0 across 9912, and 1303 / 273 / 2, at 31971ff1e; 0 across 9800, and 1293 / 273 / 2, at e420df310; 0 across 9546, and 1197 / 273 / 2, at db11afd49; 0 across 9283, and 1172 / 263 / 2, at dd3f7e1be; 0 across 8512, and 1096 / 245 / 2, at f8a9d0fb0; 0 across 8303, and 1086 / 240 / 2, at 62597c588); both resourceLimits hits are prose in packages/app-shell recording that objectui's own AppShellRuntimeConfig shares not one key with the spec's RuntimeConfig, so nothing there authors this key and no pin bump is owed. ADR-0087. + - Why not automatic: This entry COMPLETES what the kernel-directory duration renames deliberately left alone, and the two are meant to be read as a sequence. That round renamed the four plugin-security durations on this same file (`kernel-plugin-security-durations-unit-in-key`) and recorded, accurately, that one key was out of its scope: RuntimeConfig.resourceLimits.timeout named its unit only in the JSDoc above it ("Execution timeout in milliseconds"), a channel check:duration-unit-keys does not read — it reads `.describe()` and `.meta({ description })` — and that key's describe ("Maximum execution time") named none, so the gate listed it among the duration-shaped keys without judging it, neither an offender nor an exemption. That JSDoc-channel gap was filed as a finding of its own, and that round's statement about its own scope stays true. The finding is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. So the reader who most needs the unit — the reader of the published reference page, who never sees the source JSDoc — got a bare integer on content/docs/references/kernel/plugin-security-advanced.mdx and could not tell 60000 milliseconds from 60000 seconds. The key is renamed and the describe is corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation (unit in prose, none in the name). Spelled Ms, the same token SandboxConfig.process.timeoutMs on this very file already carries: counted on this tree, the suffixed family spells it that way in every member (29 key-position `timeoutMs` declarations across packages/spec/src/**/*.zod.ts, 40 distinct *Ms keys) and there is no timeoutMillis, timeout_ms or timeoutMS variant anywhere in packages/spec/src. Tombstoned with retiredKey() because the nested resourceLimits object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: a RuntimeConfig is the engine block of the SandboxConfig a host or a plugin security manifest constructs — stack.zod.ts declares no sandbox, security-policy or runtime-config collection and it is not a stored sys_metadata row — so the conversion chain has no seam that runs on it; the same reading the kernel-directory round recorded for the four keys it renamed. Measured on 146c291943: no in-repo runtime reads the key — packages/core/src/security/sandbox-runtime.ts, the one consumer of this shape, reads resourceLimits.maxCpu (3 occurrences of resourceLimits) and spells timeout 0 times; outside the zod file and its test the only live occurrences are the generated rows in content/docs/references/kernel/plugin-security-advanced.mdx, which this rename regenerates. The pinned objectui checkout — this is the pin we build against, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6`, re-read from this tree — spells resourceLimits.timeout 0 times across 8351 tracked files, against lit controls timeout 1694, RuntimeConfig 337 and resourceLimits 2 on the same corpus (0 across 8281, and 1674 / 337 / 2, at 47b1f0bb7; 0 across 8234, and 1658 / 337 / 2, at f0268ad78; 0 across 7754, and 1431 / 299 / 2, at a58626c88; 0 across 7650, and 1360 / 293 / 2, at 0abd4f9f8; 0 across 7632, and 1360 / 293 / 2, at 9dfaca654; 0 across 7579, and 1351 / 276 / 2, at 2e818d0b5; 0 across 10267, and 1348 / 273 / 2, at ab1879721; 0 across 10071, and 1331 / 273 / 2, at 89cad75d5; 0 across 9912, and 1303 / 273 / 2, at 31971ff1e; 0 across 9800, and 1293 / 273 / 2, at e420df310; 0 across 9546, and 1197 / 273 / 2, at db11afd49; 0 across 9283, and 1172 / 263 / 2, at dd3f7e1be; 0 across 8512, and 1096 / 245 / 2, at f8a9d0fb0; 0 across 8303, and 1086 / 240 / 2, at 62597c588); both resourceLimits hits are prose in packages/app-shell recording that objectui's own AppShellRuntimeConfig shares not one key with the spec's RuntimeConfig, so nothing there authors this key and no pin bump is owed. ADR-0087. - Done when: Every RuntimeConfigSchema.parse(…) site, and every literal handed to a plugin sandbox as its runtime block, spells resourceLimits.timeoutMs; authoring resourceLimits.timeout fails to compile (input type `never`) and fails to parse with the rename prescription naming timeoutMs and the shape it belongs to. Behaviour is unchanged: a runtime given timeoutMs: 60000 aborts execution after sixty seconds exactly as timeout: 60000 did, and the min(0) integer bound rides along with the renamed key. The published describe reads "Maximum execution time in milliseconds". Verify the two same-named keys on this one file apart: RuntimeConfig.resourceLimits.timeout and SandboxConfig.process.timeout both retire to a key spelled timeoutMs, and each refusal names its own shape so an upgrading author edits the right block. - **`kernel-startup-orchestrator-durations-unit-in-key`** — `the three startup-orchestration durations whose name carried no unit: StartupOptions.timeout, PluginStartupResult.duration and StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts)` → timeoutMs, durationMs and totalDurationMs — rename each key; every value is unchanged, and so is the 30000 default on StartupOptions - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These three are one entry because they are one boundary: a host passes StartupOptions in, and the orchestrator hands PluginStartupResult and StartupOrchestrationResult back from the same call. The file already contained its own counter-example — IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs) named its parameter timeoutMs while the options object beside it said timeout, so one contract carried both conventions and the suffixed one was already the honest half. totalDuration is the sum of the per-plugin durations, so the two had to move together or the aggregate would have been spelled unlike its parts. All three are retiredKey() tombstones; none of these shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: StartupOptions is a boot-time call argument and the two result shapes are emitted measurements, so none is ever a stack collection member or a stored sys_metadata row and the conversion chain has no seam that would see one — the same disposition HealthStatus.timestamp took on this very file (epoch-instant-keys-renamed), and what ruling B prescribes for a runtime-emitted key. ADR-0087. @@ -1078,7 +1077,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - Why not automatic: The D2 conversion `view-list-tabs-removed` deletes `tabs` from every list payload in `stack.views[]`, in all three persisted spellings, and the delete is lossless in pixels: no renderer ever mounted a tab bar for the key, so a view that declared tabs has always drawn without them, and it still does. The judgment the conversion cannot make is the author's intent: each tab was a named preset the author wanted end users to switch to, and the platform delivers that as a named list view, not as a sub-key of one. Which tabs deserve an entry, what each should filter and show, and whether the switcher already lists an equivalent, are the author's decisions. The tab keys with no list-view counterpart — `icon`, `order`, `pinned`, `isDefault`, `visible` — never had an effect either. One boundary is the author's by construction: tabs declared under `objects[].listViews` are reached by no conversion, so such an object is refused at its own door until edited by hand. - Done when: No list view in `stack.views[]` or in any object `listViews` map declares `tabs`; the parse refuses the key by name at every list-view door. For each view that did: every tab the author still wants is a `listViews` entry with its own `label`, `filter` and `columns`, and it appears as a tab in the switcher above the object's records and shows the rows its filter selects; a tab nobody wants is simply gone. No page-level `userFilters` preset bar changes — that `tabs` is a different key, and it stays. - **`logging-durations-unit-in-key`** — `HttpDestinationConfig `batch.flushInterval` / `retry.initialDelay` / `timeout` and LoggingConfig `buffer.flushInterval` (system/logging.zod.ts)` → `batch.flushIntervalMs` (default 5000) / `retry.initialDelayMs` (default 1000) / `timeoutMs` (default 30000) on HttpDestinationConfig, and `buffer.flushIntervalMs` (default 1000) on LoggingConfig — rename the keys; every value (milliseconds) is unchanged - - Why not automatic: Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. All four keys named milliseconds in a source JSDoc — "Flush interval in milliseconds", "Initial retry delay in milliseconds", "Timeout in milliseconds" — and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is, and none of the four carried one at all. Measured by the `check:duration-unit-keys` census on this tree before the change, all four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 and nothing on the page decided milliseconds from seconds. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so each key is renamed and given the describe it never had in the same stroke. ⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two different defaults — 5000 on the HTTP destination's batch and 1000 on the logging buffer — so they are two keys, each with its own tombstone and its own registered row; the prescriptions name their def so a reader who lands on one is not sent to the other. The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` declarations in `packages/spec/src` against 75 `*Seconds:`, and the only competing unit spellings are 3 `*MS:` and 9 `*Millis:` — every one of them a name fixed outside this repo (MongoDB's `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and `connectionTimeoutMillis` on `PoolConfigSchema`), so unlike the `Ttl`-versus-`TTL` question a sibling round settled there is no in-repo alternative to choose between. All three target spellings were already attested as key-position `*.zod.ts` declarations before this change: `flushIntervalMs` 1 (`kernel/events/integrations.zod.ts`, same 1000 default), `initialDelayMs` 5, `timeoutMs` 30. Tombstoned with `retiredKey()` rather than deleted because none of the four enclosing objects — `HttpDestinationConfig` itself and its nested `batch` and `retry`, and `LoggingConfig`'s nested `buffer` — is `.strict()`, so a bare deletion would have stripped the value in silence. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no rehydration seam that runs on an authored logging document — the same reading `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key. Measured on 4dab2bc5c: no in-repo runtime reads any of the four — outside `packages/spec/src/system/logging.zod.ts` and its test the only occurrences are the generated rows in `content/docs/references/system/logging.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8281 tracked files, against lit controls `useState` 2630 and `timeout` 1674 on the same corpus (all four 0 across 8234, against 2622 and 1658, at f0268ad78, 0 across 7754, against 2491 and 1431, at a58626c88, 0 across 7650, against 2478 and 1360, at 0abd4f9f8, 0 across 7632, against 2477 and 1360, at 9dfaca654, 0 across 7579, against 2477 and 1351, at 2e818d0b5, 0 across 10267, against 2476 and 1348, at ab1879721, 0 across 10071, against 2470 and 1331, at 89cad75d5, 0 across 9912, against 2469 and 1303, at 31971ff1e, 0 across 9800, against 2464 and 1293, at e420df310, 0 across 9546, against 2449 and 1197, at db11afd49, 0 across 9283, against 2435 and 1172, at dd3f7e1be, 0 across 8512, against 2391 and 1096, at f8a9d0fb0, and 0 across 8303, against 2389 and 1086, at 62597c588). + - Why not automatic: Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. All four keys named milliseconds in a source JSDoc — "Flush interval in milliseconds", "Initial retry delay in milliseconds", "Timeout in milliseconds" — and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is, and none of the four carried one at all. Measured by the `check:duration-unit-keys` census on this tree before the change, all four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 and nothing on the page decided milliseconds from seconds. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so each key is renamed and given the describe it never had in the same stroke. ⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two different defaults — 5000 on the HTTP destination's batch and 1000 on the logging buffer — so they are two keys, each with its own tombstone and its own registered row; the prescriptions name their def so a reader who lands on one is not sent to the other. The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` declarations in `packages/spec/src` against 75 `*Seconds:`, and the only competing unit spellings are 3 `*MS:` and 9 `*Millis:` — every one of them a name fixed outside this repo (MongoDB's `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and `connectionTimeoutMillis` on `PoolConfigSchema`), so unlike the `Ttl`-versus-`TTL` question a sibling round settled there is no in-repo alternative to choose between. All three target spellings were already attested as key-position `*.zod.ts` declarations before this change: `flushIntervalMs` 1 (`kernel/events/integrations.zod.ts`, same 1000 default), `initialDelayMs` 5, `timeoutMs` 30. Tombstoned with `retiredKey()` rather than deleted because none of the four enclosing objects — `HttpDestinationConfig` itself and its nested `batch` and `retry`, and `LoggingConfig`'s nested `buffer` — is `.strict()`, so a bare deletion would have stripped the value in silence. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no rehydration seam that runs on an authored logging document — the same reading `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key. Measured on 4dab2bc5c: no in-repo runtime reads any of the four — outside `packages/spec/src/system/logging.zod.ts` and its test the only occurrences are the generated rows in `content/docs/references/system/logging.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8351 tracked files, against lit controls `useState` 2630 and `timeout` 1694 on the same corpus (all four 0 across 8281, against 2630 and 1674, at 47b1f0bb7, 0 across 8234, against 2622 and 1658, at f0268ad78, 0 across 7754, against 2491 and 1431, at a58626c88, 0 across 7650, against 2478 and 1360, at 0abd4f9f8, 0 across 7632, against 2477 and 1360, at 9dfaca654, 0 across 7579, against 2477 and 1351, at 2e818d0b5, 0 across 10267, against 2476 and 1348, at ab1879721, 0 across 10071, against 2470 and 1331, at 89cad75d5, 0 across 9912, against 2469 and 1303, at 31971ff1e, 0 across 9800, against 2464 and 1293, at e420df310, 0 across 9546, against 2449 and 1197, at db11afd49, 0 across 9283, against 2435 and 1172, at dd3f7e1be, 0 across 8512, against 2391 and 1096, at f8a9d0fb0, and 0 across 8303, against 2389 and 1086, at 62597c588). - Done when: Every HTTP log destination spells `batch.flushIntervalMs`, `retry.initialDelayMs` and `timeoutMs`, and every logging buffer spells `buffer.flushIntervalMs`; authoring any of the four retired spellings fails to compile and fails to parse with a rename prescription naming the suffixed key and its def; the parsed defaults are 5000 / 1000 / 30000 / 1000 as before; and each published describe names milliseconds. - **`manage-org-presentation-retired`** — `the manage_org_presentation platform capability (its PLATFORM_CAPABILITIES entry in @objectstack/spec security, the ORG_PRESENTATION_AUTHORING_CAPABILITY constant exported by @objectstack/metadata-core) and the arm of metaWriteCapabilityVerdict that admitted its holders to org-scoped writes of the five org-overridable types through the /meta item doors` → grant `manage_metadata` to whoever must author views, dashboards, reports, translations or email templates through Studio or `PUT /api/v1/meta//`; such a write now lands environment-wide (`organization_id` NULL) and is served to every organization of the deployment. There is no organization-bounded authoring capability: delete `manage_org_presentation` from every permission set's `systemPermissions`, and delete any import of `ORG_PRESENTATION_AUTHORING_CAPABILITY`. `metaWriteCapabilityVerdict` takes `{ isSystem, systemPermissions, operation }`: drop the `canonicalType` and `activeOrganizationId` members from the call - Why not automatic: ADR-0131 D6 retires the per-organization overlay axis, and the /meta doors stop carrying an organization into a metadata write (the companion entry meta-doors-organization-scope-retired). The capability admitted an organization admin to exactly the writes those doors threaded into the admin's own organization; with no organization threaded, keeping it would have admitted its holders to environment-wide authoring, which is the reach of manage_metadata and a wider one than the capability ever granted. It was granted by no shipped permission set, so a deployment that never granted it by hand observes nothing. @@ -1137,9 +1136,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - **`object-grid-data-view-data-converged`** — ``object-grid` component props — `data` (the KIND: bare array `z.array(z.unknown())` vs the `ViewDataSchema` provider object)` → `ViewDataSchema` — the provider-discriminated object (`provider: 'object' | 'api' | 'value' | 'schema'`). Static inline rows move from `data: [...]` to `data: { provider: 'value', items: [...] }` — the same rows, wrapped in the one arm that means "hardcoded data array". The other three arms are unchanged `ViewDataSchema` semantics; `staticData` (the deprecated bare-array shortcut the renderer still reads) keeps its shape but is not the prescription - Why not automatic: Two entries of one contract disagreed on the KIND (contract-vs-contract, found by objectui's declared-arm parity gate): `ComponentPropsMap['object-grid'].data` said bare array ('Static inline rows — bypasses the object query') while `ViewDataSchema` — the authority objectui aligned the grid's registry declaration to, pinned by `gridDataInputContract.test.ts`, and what `ObjectGridSchema.data` resolves to — is an object discriminated on `provider`. Measured on @objectstack/spec@17.2.0: `{ provider: 'value', items: [] }` — the pinned-legal form — was REFUSED by the props-map entry (`expected array, received object`) while the bare array parsed. Whichever authority a value satisfied, the other refused it, and the objectui parity gate had to carry the reasoned exemption `object-grid.data:object` to look away. The maintainer's ruling of 2026-08-25 (option A) converged the props-map entry onto `ViewDataSchema`; the bare-array form is the deprecated `staticData` shortcut that objectui's deprecated-alias carve-out already refuses to publish as authoring surface. The ruled migration check ran with the change: the sweep of generated artifacts, templates and first-party corpora (examples/, skills/, create-objectstack, spec fixtures) found ZERO bare-array `data` authors, so no rewrite ships — this entry carries the prescription for authors outside the repo. - Done when: `ComponentPropsMap['object-grid'].safeParse({ data: { provider: 'value', items: [] } })` succeeds (and the other `ViewDataSchema` arms parse through the same entry); a bare-array `data: [...]` is refused at the `data` path. An author carrying `data: [...]` writes `data: { provider: 'value', items: [...] }` — same rows, one wrapping object. Downstream (objectui, after a released spec version reaches the pin): the `object-grid.data:object` exemption entry in `registry-inputs-spec-parity.test.ts` becomes deletable, which is what closes the objectui finding that the two authorities disagreed. -- **`object-grid-default-filters-rule-array`** — `the object-grid page block's defaultFilters property — the legacy base-filter fallback in ComponentPropsMap, which was z.unknown and therefore accepted a bare string, a number, a MongoDB-style record, an ObjectQL AST tuple array and a list of malformed rules alike` → the same ViewFilterRule array form its sibling filter takes — [{ field, operator, value }, ...]. A record-form fallback { status: "active" } becomes [{ field: "status", operator: "equals", value: "active" }] and several record keys become several rules, which AND; an operator object { amount: { $gt: 100 } } lifts the operator into the rule, becoming [{ field: "amount", operator: "greater_than", value: 100 }]; an AST tuple array [["owner_id", "=", "{current_user_id}"]] becomes [{ field: "owner_id", operator: "equals", value: "{current_user_id}" }], value placeholders and date macros unchanged. Legacy operator shorthands are accepted and normalized on parse. Better still, write the rules on filter and delete this key: it is read only when filter is absent, and its own description has prescribed filter all along - - Why not automatic: The protocol half of the maintainer's ruling C-prime of 2026-09-20 on objectui's render-time filter converter — the protocol is the only refusal set, so a document it accepts never throws at render time — verbatim, untranslated: 「the differences are the protocol's to close」. This is the SAME value in the SAME role as filter — the key's own description says it is read only when filter is absent — and the consumer reads it through the SAME lowering sink, so every refusal that sink can give was reachable from a document the protocol had just accepted. filter converged on the rule array with the rest of its family; this key was not named by that ruling and kept the pre-convergence read-point shape, which left the block with one declared door and one undeclared door onto one seam. The parse receipt said nothing about what the grid would then do with the value, and in the objectui version this release pins that depended on the shape: ObjectGrid lowers defaultFilters through toFilterNode whenever filter lowers to nothing, so a record form and an AST tuple array were lowered and applied as declared; a bare string or a number was dropped without a word, so the grid sent no filter and listed its rows unfiltered; and a list of malformed rules was refused — on the wire with 400 INVALID_FILTER, or by the client before any request for the value shapes it judges itself. ⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key outright — the other arm the finding offered — removes an accepted shape and needs its own ruling; the deprecation already stated in the description is unchanged and still says to prefer filter. Metadata AT REST: the record form and the AST tuple array at this key are rewritten to the rule array by the same D2 conversion as its sibling filter, page-component-filter-record-to-rule-array, wherever the mapping is lossless — by os migrate meta --stored, and on every stored-row read until it runs. What it cannot map losslessly is left exactly as stored and keeps rendering as it does today — a combinator, a null value, an operator the rule vocabulary does not spell, or the bare string or number this key also took — and its door refuses such a value only as the component-props gate's advisory finding (os validate, os build, os lint), since a re-save through the metadata API is not refused there: a record form with the message the filter door gives, a worked rewrite computed from the author's own keys and a pointer to this entry's conversion table, and a bare string or number or an AST tuple array with the schema's plain type refusal. ADR-0049 / ADR-0087. - - Done when: Every object-grid node in your pages either omits defaultFilters or carries a ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is that array raises no issue at the key; a record form is refused AT defaultFilters with the conversion table and a worked rewrite built from the keys that were written, and an AST tuple array is refused one level in, at the first element. What to re-check depends on the shape that was there, as the objectui version this release pins treats it. A record form or an AST tuple array was lowered and applied, so for those the rewrite is a spelling change. A bare string or a number was dropped by that lowering, so the grid has been listing its rows unfiltered — decide which rows it is supposed to show before writing the rule that selects them. A list of malformed rules was refused when the grid loaded. Where both keys are authored, that grid reads defaultFilters only when filter lowers to nothing: beside a non-empty filter, deleting defaultFilters is the whole migration; beside filter: [] the grid reads defaultFilters, so move those rules onto filter rather than deleting them. +- **`object-grid-default-filters-retired`** — `page.component.object-grid.defaultFilters — the legacy second spelling of the grid base filter` → `filter: [{ field, operator, value }, ...]` — the one base-filter key every read path honours; the same rules, unchanged. + - Why not automatic: The grid read `defaultFilters` only when `filter` lowered to nothing, so one intent had two spellings on one block. The D2 conversion `object-grid-default-filters-removed` follows that precedence: where `filter` was empty (absent, null, `[]` or `{}`) the fallback WAS the grid's filter, so its rules move onto `filter`; where `filter` had rules the fallback was never read, so it is deleted. Both preserve what the grid showed, and the second is where the judgment sits: a grid that authored both keys has always listed the rows `filter` selects, while its author may believe `defaultFilters` applied. The conversion keeps the rows users have been seeing and discards the rules that were written; only the author can say which were meant. A `filter` that is neither empty nor rules (a bare string, a number) is left as stored and listed as a TODO: the grid fell back to `defaultFilters` there too, and moving it would overwrite what was written at `filter`. A fallback in the retired record form moves to `filter` and is then converted there by `page-component-filter-record-to-rule-array` wherever the mapping is lossless; that entry lists the rest. Code that builds object-grid props (a host, a generator) must also stop emitting the key, which no conversion reaches. + - Done when: No `object-grid` component carries `defaultFilters`; the parse refuses it. Each grid's `filter` holds the rules the author intends, and the grid lists exactly the rows they select. For every grid that had authored both keys, the author has compared the discarded `defaultFilters` rules with the kept `filter` and confirmed the kept one. - **`object-grid-default-sort-retired`** — `page.component.object-grid.defaultSort — the legacy single-pair second spelling of the grid sort` → `sort: [{ field, order }]` — the array every read path honours; a single pair is a one-entry array. - Why not automatic: The D2 conversion `object-grid-default-sort-removed` follows the renderer's own precedence: where `sort` was absent the `defaultSort` pair WAS the grid's sort, so it moves to `sort` as a one-entry array; where `sort` was present the pair was never read, so it is deleted. Both are behaviour-preserving, and the second is where the judgment sits. A grid that authored both keys with DIFFERENT orders has always loaded in the `sort` order while its author may believe `defaultSort` governed the initial load — the key's name says it should have. The conversion keeps the order users have been seeing and discards the one the author wrote; only the author can say which one they meant. Code that builds object-grid props (a host, a generator) must also stop emitting the key, which no conversion reaches. - Done when: No `object-grid` component carries `defaultSort`; the parse refuses it. Each grid's `sort` array lists the fields and directions the author intends, and the grid loads with its rows in that order and shows that column as sorted. For every grid that had authored both keys, the author has compared the discarded `defaultSort` pair with the kept `sort` and confirmed the kept one. @@ -1315,8 +1314,8 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06, option 3: keep a startup-result contract re-declared as the shape the kernel ships, and retire the rest. The module declared an orchestration design that never landed, and the spec and the kernel had already drifted into disagreement about the one shape that did: PluginStartupResultSchema described a plugin object, a required durationMs and a health member, while @objectstack/core shipped pluginName, an optional durationMs and timedOut. The ruling keeps a startup-result contract that describes what the kernel actually produces, and retires the rest. Re-measured on this card: zero implementers and zero consumers of the four retired surfaces in this repository and in the pinned objectui checkout, with lit same-corpus controls (defineStack, ManifestSchema); every remaining reference was a generated artifact or a released CHANGELOG.md. healthCheck and HealthStatus are the sharpest of the four: they name a per-plugin health probe the runtime has never had, the shape of the plugin sandboxing / integrity / approval config that was never wired to anything, which an AI author (ADR-0033) reads as proof the capability exists. With no authored document carrying any of the three defs there is no seam for a D2 conversion and no author to tombstone for: route 3, the shape of the dynamic plugin-loading family's removal and the advanced plugin-lifecycle config's retirement — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. The two keys of the SURVIVING result schema that leave (plugin, health) are tombstoned instead, and registered in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is imported by @objectstack/core. A third key arrives on the spec surface only to leave it: core deprecated startTime alias, which held the same elapsed milliseconds as durationMs under a name that promises an instant. The re-declaration had to either mirror it or tombstone it, and mirroring is refused by check:duration-unit-keys (ruling B on duration-shaped number keys: the unit lives in the key name) since it is an elapsed number whose key name carries no unit and matches neither of that rule two schema-declared exemptions. So the L1 window closes here and the kernel stops populating it in the same change. - Done when: No code imports any of the 8 retired names from @objectstack/spec, @objectstack/spec/kernel or @objectstack/spec/contracts — every one is TS2305 after upgrade, pinned by resolved symbol identity in kernel/startup-orchestrator-retirement.test.ts. No metadata document needs editing: none of the three defs was reachable from a metadata-type binding, a stack collection or a manifest embed, so no authored document could ever carry one. PluginStartupResult SURVIVES on both entries with the shape the kernel ships — pluginName, success, optional durationMs, the serializable error projection, timedOut — and @objectstack/core now imports that type instead of declaring a twin, so the drift cannot recur. Writing plugin, health or startTime on a PluginStartupResult is a tsc error and a parse error carrying the rename or the deletion; a reader of the removed startTime alias reads durationMs, which has always carried the same value. Runtime behaviour is unchanged except for that one alias: nothing ever read the retired ORCHESTRATION surfaces, the kernel boot loop is untouched, and the only observable difference is that a startup result no longer carries startTime beside durationMs. - **`storage-scope-public-retired`** — `the storage scope public — the scope of an upload request, presigned or chunked, and ObjectStorageConfig.scope (StorageScope)` → another scope, or none for the default (`user` on an upload, `global` on a storage configuration), and `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in (ADR-0104) - - Why not automatic: A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. Files already stored with scope public are not touched and download exactly as before. ADR-0049 - - Done when: No upload call names scope public and no ObjectStorageConfig declares it; each upload that did now names another scope or none and is answered 200. Each file that must render before sign-in has acl 'public_read' on its stored file record, and fetching it with no session serves it; fetching any other uploaded file with no session is answered 401. + - Why not automatic: A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. The stored file record retires the value too: the scope select of sys_file no longer lists public, so a deployment that stored files with it runs the one-time operator sweep that @objectstack/service-storage exports (`planSysFilePublicScopeBackfill` for the dry run, then `applySysFilePublicScopeBackfill`), which rewrites each of those records to scope user. No access changes: no reader tells user apart from public, the storage key and the file bytes are not touched, and those files download exactly as before. Until the sweep has run, a record write that names such a file while another field already owns it is refused, because the copy it makes carries scope public. ADR-0049 + - Done when: No upload call names scope public and no ObjectStorageConfig declares it; each upload that did now names another scope or none and is answered 200. Each file that must render before sign-in has acl 'public_read' on its stored file record, and fetching it with no session serves it; fetching any other uploaded file with no session is answered 401. On each deployment, a dry run of the sweep scans zero sys_file records with scope public. - **`strategy-context-aggregation-method-narrowed`** — `StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string` → AggregationFunction (count | sum | avg | min | max | count_distinct, data/query.zod.ts) - the same closed vocabulary IDataEngine.aggregate already declares for the identical slot (AggregationNodeSchema.function; the analytics bridge renames method to function and forwards). A caller filling method from a string-typed value narrows the value to the enum - typing it AggregationFunction, or parsing with the spec's own AggregationFunction zod enum where the value enters from data. Values outside the six were never served: the bridge has parsed-and-refused them at runtime since it stopped declaring its own engine type and began parsing the method with the spec enum, and that refusal stays as defence in depth - Why not automatic: Maintainer ruling 2026-08-28 (option A, census-first): one slot, one declaration. Two spec-declared surfaces described the same value and disagreed about its type: IDataEngine.aggregate's aggregations[].function is the closed six-value AggregationFunction enum while StrategyContext.executeAggregate declared the same slot aggregations[].method: string, so nothing on the analytics side of that seam was compile-checked against the engine's vocabulary - an author, very often an AI (ADR-0033), writing an analytics strategy got no compile-time help and could carry any method name all the way to the bridge's runtime refusal. One slot now has one declaration. Bookkeeping: this is a TYPE narrowing on a runtime TS interface member - no authorable metadata key, no wire shape and no walked-shape def changed, so nothing lands in RETIRED_KEYS_BY_MAJOR / RETIRED_DEFS_BY_MAJOR and the surface ratchets are expected byte-identical. It is a SEMANTIC entry rather than a D2 conversion because there is no authored document or sys_metadata row for the chain to rewrite: the only consumers are TypeScript call sites, and the compile error is the channel that reaches them. In-repo census at the ruling (hard precondition, measured before the narrowing landed): every implementor and every call site filling method is legal under the enum - ObjectQLStrategy.resolveMeasureAggregation emits only the six once it refuses a custom-SQL measure up front, the two literal producers write count, and every test fixture is implementor-side and stays assignable by contravariance. - Done when: External implementors of StrategyContext stay source-compatible: a handler accepting method: string accepts a superset and remains assignable to the narrowed member. External callers filling method with a string-typed or out-of-vocabulary value fail tsc at the executeAggregate call site on upgrade; the fix is narrowing the value's type to AggregationFunction (parsing with the spec enum where it enters from data), never widening a local mirror of the contract. Runtime behaviour is unchanged: the bridge's parse-and-refuse accepts and rejects exactly the same sets before and after, and no stored metadata or document needs editing. @@ -1366,7 +1365,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because its file has exactly one offender left — and because the key directly beside it is the counter-example that shows where the line falls. FailoverConfig.dns.ttl is also a bare-named duration in seconds, and it is NOT renamed: it carries an externalVocabulary marker because it mirrors the DNS resource-record TTL field (RFC 1035 section 4.1.3), spelled ttl by every provider API the value is forwarded to (Route 53, Cloudflare). healthCheckInterval mirrors nothing outside this repo, so the exemption does not reach it. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no disasterRecovery collection and a failover config is host configuration, never a stored sys_metadata row. ADR-0087. - Done when: Every FailoverConfig author spells healthCheckIntervalSeconds; authoring healthCheckInterval fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged: healthCheckIntervalSeconds: 30 probes every thirty seconds exactly as before, and an omitted key still defaults to 30. The migration is proved correct when dns.ttl is still spelled ttl — a sweep that renamed it too has over-applied the rule and stripped a declared exemption. - **`system-metrics-jsdoc-durations-unit-in-key`** — `the five remaining metrics durations whose unit lived in a source JSDoc only: MetricDefinition.summary.maxAge, ServiceLevelObjective.errorBudget.burnRateWindows[].window, MetricExportConfig.interval, MetricsConfig.collectionInterval and MetricsConfig.retention.period (system/metrics.zod.ts)` → summary.maxAgeSeconds, errorBudget.burnRateWindows[].durationSeconds, intervalSeconds, collectionIntervalSeconds and retention.durationSeconds — rename each key; every value is unchanged - - Why not automatic: This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, and the two are meant to be read as a sequence — this one does not amend that record, which stays a true account of what the system-directory duration round did. That round renamed the three metrics window and period lengths whose describe named no unit, and recorded that the error-budget burn-rate window was "outside this rename, not outside the gate population", naming the JSDoc-channel gap as where it would be settled. That gap is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. ⚠️ One consequence for readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its name and that a sweep renaming it has over-applied the rule. That sentence was true of that round and is superseded here, by the ruling it itself pointed at; the other key it names, the exporter batch size, is a COUNT of records and still does not move. All five keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a channel check:duration-unit-keys does not read — it reads .describe() and .meta({ description }) — and four of the five carried no describe at all while the fifth read "Window size". So the reader who most needs the unit, the reader of the published reference page, got a bare integer: 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Each key is renamed and its describe corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation. Three of the five spellings are not the mechanical suffix, and each departure has a reason this file already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, because the enclosing array is already called burnRateWindows so the key would stutter — the objection the system-directory round recorded against window.windowSeconds — and because on this tree windowSeconds is not an authorable key at all, its only key-position occurrence being an alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; retention.period becomes durationSeconds, not periodSeconds, because period is calendar vocabulary elsewhere in this spec (ServiceLevelObjective.period.type selects rolling or calendar, PluginRegistryEntry.pricing.billingPeriod is monthly or yearly) so periodSeconds would keep the ambiguous half of the name; and collectionInterval keeps its qualifier as collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds this same card creates one def over. The two mechanical spellings are attested: maxAgeSeconds is the token AccessControlConfig.maxAgeSeconds already carries after this same rule renamed it on system/object-storage.zod.ts, and it keeps the age stem the sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued cadences already carry. Counted in key position across packages/spec/src at fc28c1d38, the base of this change, the seconds suffixes run Seconds 40, Sec 1 (maxExecutionTimeSec) and S 0 — the two bare S keys on that corpus, maxCommitTimeMS and enableRLS, are a millisecond spelling and a boolean — so Seconds is the family; this change takes Seconds to 45 at 9b62f54671. All five are retiredKey() tombstones; none of the five enclosing shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of a metric definition, an SLO, an export config or a metrics config is a registered metadata kind stored as a sys_metadata row — the same reading the system-directory round recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo code consumer reads any of the five — outside packages/spec the only occurrences of every distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the generated content/docs/references/system/metrics.mdx, which this rename regenerates, against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 (1195 again at 9b62f54671); and the objectui checkout this repo builds against — this is the pin, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8281 tracked files at that sha, against lit controls window 4449, timeout 1674, period 249, interval 213 and metrics 455 on that same corpus and sha (0 across 8234, against 4430 / 1658 / 249 / 213 / 404, at f0268ad78, 0 across 7754, against 4255 / 1431 / 247 / 200 / 401, at a58626c88, 0 across 7650, against 4194 / 1360 / 238 / 195 / 401, at 0abd4f9f8, 0 across 7632, against 4193 / 1360 / 238 / 195 / 401, at 9dfaca654, 0 across 7579, against 4175 / 1351 / 238 / 195 / 374, at 2e818d0b5, 0 across 10267, against 4044 / 1348 / 231 / 196 / 354, at ab1879721, 0 across 10071, against 4002 / 1331 / 231 / 196 / 355, at 89cad75d5, 0 across 9912, against 3916 / 1303 / 234 / 196 / 354, at 31971ff1e, 0 across 9800, against 3873 / 1293 / 233 / 196 / 352, at e420df310, 0 across 9546, against 3772 / 1197 / 228 / 196 / 341, at db11afd49, 0 across 9283, against 3681 / 1172 / 183 / 176 / 340, at dd3f7e1be, 0 across 8512, against 3581 / 1096 / 171 / 179 / 326, at f8a9d0fb0, and 0 across 8303, against 3526 / 1086 / 170 / 179 / 324, at 62597c588), so no pin bump is owed. ADR-0087. + - Why not automatic: This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, and the two are meant to be read as a sequence — this one does not amend that record, which stays a true account of what the system-directory duration round did. That round renamed the three metrics window and period lengths whose describe named no unit, and recorded that the error-budget burn-rate window was "outside this rename, not outside the gate population", naming the JSDoc-channel gap as where it would be settled. That gap is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. ⚠️ One consequence for readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its name and that a sweep renaming it has over-applied the rule. That sentence was true of that round and is superseded here, by the ruling it itself pointed at; the other key it names, the exporter batch size, is a COUNT of records and still does not move. All five keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a channel check:duration-unit-keys does not read — it reads .describe() and .meta({ description }) — and four of the five carried no describe at all while the fifth read "Window size". So the reader who most needs the unit, the reader of the published reference page, got a bare integer: 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Each key is renamed and its describe corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation. Three of the five spellings are not the mechanical suffix, and each departure has a reason this file already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, because the enclosing array is already called burnRateWindows so the key would stutter — the objection the system-directory round recorded against window.windowSeconds — and because on this tree windowSeconds is not an authorable key at all, its only key-position occurrence being an alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; retention.period becomes durationSeconds, not periodSeconds, because period is calendar vocabulary elsewhere in this spec (ServiceLevelObjective.period.type selects rolling or calendar, PluginRegistryEntry.pricing.billingPeriod is monthly or yearly) so periodSeconds would keep the ambiguous half of the name; and collectionInterval keeps its qualifier as collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds this same card creates one def over. The two mechanical spellings are attested: maxAgeSeconds is the token AccessControlConfig.maxAgeSeconds already carries after this same rule renamed it on system/object-storage.zod.ts, and it keeps the age stem the sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued cadences already carry. Counted in key position across packages/spec/src at fc28c1d38, the base of this change, the seconds suffixes run Seconds 40, Sec 1 (maxExecutionTimeSec) and S 0 — the two bare S keys on that corpus, maxCommitTimeMS and enableRLS, are a millisecond spelling and a boolean — so Seconds is the family; this change takes Seconds to 45 at 9b62f54671. All five are retiredKey() tombstones; none of the five enclosing shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of a metric definition, an SLO, an export config or a metrics config is a registered metadata kind stored as a sys_metadata row — the same reading the system-directory round recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo code consumer reads any of the five — outside packages/spec the only occurrences of every distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the generated content/docs/references/system/metrics.mdx, which this rename regenerates, against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 (1195 again at 9b62f54671); and the objectui checkout this repo builds against — this is the pin, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8351 tracked files at that sha, against lit controls window 4470, timeout 1694, period 249, interval 213 and metrics 456 on that same corpus and sha (0 across 8281, against 4449 / 1674 / 249 / 213 / 455, at 47b1f0bb7, 0 across 8234, against 4430 / 1658 / 249 / 213 / 404, at f0268ad78, 0 across 7754, against 4255 / 1431 / 247 / 200 / 401, at a58626c88, 0 across 7650, against 4194 / 1360 / 238 / 195 / 401, at 0abd4f9f8, 0 across 7632, against 4193 / 1360 / 238 / 195 / 401, at 9dfaca654, 0 across 7579, against 4175 / 1351 / 238 / 195 / 374, at 2e818d0b5, 0 across 10267, against 4044 / 1348 / 231 / 196 / 354, at ab1879721, 0 across 10071, against 4002 / 1331 / 231 / 196 / 355, at 89cad75d5, 0 across 9912, against 3916 / 1303 / 234 / 196 / 354, at 31971ff1e, 0 across 9800, against 3873 / 1293 / 233 / 196 / 352, at e420df310, 0 across 9546, against 3772 / 1197 / 228 / 196 / 341, at db11afd49, 0 across 9283, against 3681 / 1172 / 183 / 176 / 340, at dd3f7e1be, 0 across 8512, against 3581 / 1096 / 171 / 179 / 326, at f8a9d0fb0, and 0 across 8303, against 3526 / 1086 / 170 / 179 / 324, at 62597c588), so no pin bump is owed. ADR-0087. - Done when: Every metric definition spells summary.maxAgeSeconds, every error-budget burn rate window spells durationSeconds, every metric export config spells intervalSeconds, and every metrics config spells collectionIntervalSeconds and retention.durationSeconds. Authoring any of the five old spellings fails to compile (input type `never`) and fails to parse with the rename prescription naming the suffixed key — not an unrecognized_keys issue. Behaviour is unchanged: collectionIntervalSeconds: 15 collects every fifteen seconds exactly as collectionInterval: 15 did, and every default (600, 60, 15, 604800) and positive-integer bound rides along with its renamed key. Each new describe names the unit, so the reference page carries it. Verify the same-named decoys on this one file apart: MetricAggregationConfig.window and ServiceLevelIndicator.window are objects that already hold a durationSeconds of their own, and ServiceLevelObjective.period is an object holding a durationSeconds and a calendar — none of the three moves, and a sweep that renamed any of them has over-applied this rule. - **`system-metrics-window-durations-unit-in-key`** — `the three metrics window/period lengths whose name carried no unit: MetricAggregationConfig.window.size, ServiceLevelIndicator.window.size and ServiceLevelObjective.period.duration (system/metrics.zod.ts)` → window.durationSeconds, window.durationSeconds and period.durationSeconds — rename each key; every value is unchanged - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. The three are one entry because they are one measurement expressed three times on one file: how long a window or period is. The new name is deliberately NOT the mechanical sizeSeconds the gate prints. size means a byte or row count everywhere else in this spec — CacheTier.maxSize is megabytes, RegistryConfig.cache.maxSize is bytes, and this very file spells a batch row count size — so sizeSeconds would have kept the misleading half of the name and bolted a unit onto it, leaving a reader to decide whether a window is measured in bytes-per-second or in time. windowSeconds was rejected for a plainer reason: the parent key is already window, so it would read window.windowSeconds. durationSeconds names what the number IS, and the file itself supplied the precedent — ServiceLevelObjective.period already called its length a duration, so after the rename all three read alike instead of one borrowing byte vocabulary. All three are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of an aggregation config, an SLI or an SLO is a registered metadata kind stored as a sys_metadata row. ADR-0087. @@ -1378,7 +1377,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. The three are one entry because they are one file and, for the first two, one object: RegistryUpstream declared a SECONDS interval and a MILLISECONDS timeout twenty-five lines apart, both bare. That pair carries the clearest demonstration in this card of why a bound is no substitute for a name — timeout is min(1000), which reads as one second under the right unit and as sixteen minutes under the wrong one, and both readings satisfy the validator. The cache TTL is the same defect one schema over, beside a maxSize measured in bytes. All three are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no registry collection, and a registry config is host configuration read at startup rather than a stored sys_metadata row, so the conversion chain has no seam that would see it. ADR-0087. - Done when: Every upstream declaration spells syncIntervalSeconds and timeoutMs, and every registry cache block spells ttlSeconds. Authoring any old spelling fails to compile (input type `never`) and fails to parse with the rename prescription. Behaviour is unchanged: syncIntervalSeconds: 300 syncs every five minutes exactly as syncInterval: 300 did, an omitted timeoutMs still defaults to 30000, an omitted ttlSeconds still defaults to 3600, and the min-60 / min-1000 / min-0 bounds ride along with the renamed keys so a too-small interval or timeout is still refused. The pair on RegistryUpstream is the one to check by hand rather than by search-and-replace: after the migration a reader can tell at the authoring site that 300 and 30000 are not the same kind of number. - **`system-tracing-otel-exporter-durations-unit-in-key`** — `the four tracing-configuration durations whose unit lived in a source JSDoc only: OpenTelemetryCompatibility.exporter.timeout, OpenTelemetryCompatibility.exporter.batch.exportTimeout, OpenTelemetryCompatibility.exporter.batch.scheduledDelay and TracingConfig.performance.exportInterval (system/tracing.zod.ts)` → timeoutMs, exportTimeoutMs, scheduledDelayMs and exportIntervalMs — rename each key; all four values (milliseconds) and their 10000 / 30000 / 5000 / 5000 defaults are unchanged - - Why not automatic: Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. It follows system-tracing-span-duration-unit-in-key on this same file and does not amend it: that entry retired Span.duration under ruling B, whose population was the describe channel, and these four keys were never in it — they are the JSDoc-only channel that finding opened, which is why one file carries two rounds. Each key named milliseconds in its JSDoc — "Timeout in milliseconds", "Export timeout in milliseconds", "Scheduled delay in milliseconds", "Background export interval in milliseconds" — and the JSDoc above a key is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: all four carried NO .describe() at all, so the published reference row for each was a bare integer with no unit anywhere on the page — a strictly worse channel than the unit-in-prose shape the duration-unit rule already refuses, since here the reference reader had no prose to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, 5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any name; the Delay-plus-Ms pairing is likewise already attested on that same ref (maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout and its own landed precedent is Span.duration to durationMs, the opposite of the sibling metrics card whose rows were seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) beside the exporter's own request deadline — so renaming either to something more distinctive would depart from the vocabulary the shape mirrors, and the nesting already disambiguates every read point (exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema nor any object nested inside them is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and the stripped value lands on an export deadline and a background export period. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts declares no tracing collection, no metadata-type binding or manifest embed carries either, and a tracing configuration is never a stored sys_metadata row — so a conversion would be a transform with no seam that ever runs. That is the same disposition system-tracing-span-duration-unit-in-key recorded for the other key on this file. Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times across the whole tree at that ref excluding packages/spec and content/docs/references, against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a dark control of 0; inside packages/spec the only occurrences are tracing.zod.ts, its test, and the generated rows in content/docs/references/system/tracing.mdx, which this rename regenerates. And the pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8281 files tracked at that sha (the 517 Span and 57 SpanSchema hits are objectui's own HTML text-span component, TextSpanSchema, an unrelated name, plus colSpan and prose), against two lit controls on that same corpus and sha: 17980 hits for the bare token objectstack, and 7523 for the package specifier @objectstack/spec (at f0268ad78: 0 across 8234, Span 517, 17956 and 7522; at a58626c88: 0 across 7754, Span 509, 17468 and 7246; at 0abd4f9f8: 0 across 7650, Span 508, 17390 and 7209; at 9dfaca654: 0 across 7632, Span 508, 17313 and 7186; at 2e818d0b5: 0 across 7579, Span 505, 17227 and 7134; at ab1879721: 0 across 10267, Span 491, 16377 and 6665; at 89cad75d5: 0 across 10071, Span 489, 16044 and 6461; at 31971ff1e: 0 across 9912, Span 486, 15691 and 6206; at e420df310: 0 across 9800, Span 486, 15352 and 6024; at db11afd49: 0 across 9546, Span 486, 14704 and 5545; at dd3f7e1be: 0 across 9283, Span 485, 13745 and 5466; at f8a9d0fb0: 0 across 8512, Span 488, 13347 and 5123; at 62597c588: 0 across 8303, Span 486, 13125 and 5043). + - Why not automatic: Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. It follows system-tracing-span-duration-unit-in-key on this same file and does not amend it: that entry retired Span.duration under ruling B, whose population was the describe channel, and these four keys were never in it — they are the JSDoc-only channel that finding opened, which is why one file carries two rounds. Each key named milliseconds in its JSDoc — "Timeout in milliseconds", "Export timeout in milliseconds", "Scheduled delay in milliseconds", "Background export interval in milliseconds" — and the JSDoc above a key is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: all four carried NO .describe() at all, so the published reference row for each was a bare integer with no unit anywhere on the page — a strictly worse channel than the unit-in-prose shape the duration-unit rule already refuses, since here the reference reader had no prose to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, 5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any name; the Delay-plus-Ms pairing is likewise already attested on that same ref (maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout and its own landed precedent is Span.duration to durationMs, the opposite of the sibling metrics card whose rows were seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) beside the exporter's own request deadline — so renaming either to something more distinctive would depart from the vocabulary the shape mirrors, and the nesting already disambiguates every read point (exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema nor any object nested inside them is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and the stripped value lands on an export deadline and a background export period. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts declares no tracing collection, no metadata-type binding or manifest embed carries either, and a tracing configuration is never a stored sys_metadata row — so a conversion would be a transform with no seam that ever runs. That is the same disposition system-tracing-span-duration-unit-in-key recorded for the other key on this file. Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times across the whole tree at that ref excluding packages/spec and content/docs/references, against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a dark control of 0; inside packages/spec the only occurrences are tracing.zod.ts, its test, and the generated rows in content/docs/references/system/tracing.mdx, which this rename regenerates. And the pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8351 files tracked at that sha (the 517 Span and 57 SpanSchema hits are objectui's own HTML text-span component, TextSpanSchema, an unrelated name, plus colSpan and prose), against two lit controls on that same corpus and sha: 18047 hits for the bare token objectstack, and 7545 for the package specifier @objectstack/spec (at 47b1f0bb7: 0 across 8281, Span 517, 17980 and 7523; at f0268ad78: 0 across 8234, Span 517, 17956 and 7522; at a58626c88: 0 across 7754, Span 509, 17468 and 7246; at 0abd4f9f8: 0 across 7650, Span 508, 17390 and 7209; at 9dfaca654: 0 across 7632, Span 508, 17313 and 7186; at 2e818d0b5: 0 across 7579, Span 505, 17227 and 7134; at ab1879721: 0 across 10267, Span 491, 16377 and 6665; at 89cad75d5: 0 across 10071, Span 489, 16044 and 6461; at 31971ff1e: 0 across 9912, Span 486, 15691 and 6206; at e420df310: 0 across 9800, Span 486, 15352 and 6024; at db11afd49: 0 across 9546, Span 486, 14704 and 5545; at dd3f7e1be: 0 across 9283, Span 485, 13745 and 5466; at f8a9d0fb0: 0 across 8512, Span 488, 13347 and 5123; at 62597c588: 0 across 8303, Span 486, 13125 and 5043). - Done when: Every author and reader of an OpenTelemetryCompatibility spells exporter.timeoutMs, exporter.batch.exportTimeoutMs and exporter.batch.scheduledDelayMs, and every one of a TracingConfig spells performance.exportIntervalMs. Authoring any old spelling fails to compile (input type `never`) and fails to parse with the rename prescription naming the suffixed key — not with a generic unrecognized_keys issue, which these non-strict shapes could never have raised anyway. Behaviour is unchanged: the same milliseconds, the same 10000 / 30000 / 5000 / 5000 defaults and the same int().positive() bounds, and all four published describes now name milliseconds where before there was no describe at all. The authorable-surface and authorable-defaults ledgers move nothing: every one of the four is NESTED, and those artifacts record top-level keys per def only. - **`system-tracing-span-duration-unit-in-key`** — `Span.duration, the emitted trace-span length whose name carried no unit (system/tracing.zod.ts)` → durationMs — rename the key; the value is unchanged - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender on its file and the only one in this card that is a pure runtime-emitted measurement: a span is written by an exporter and read by a backend, never authored by hand. That is also why it is a rename and not an externalVocabulary mirror, which is the exemption a tracing shape would most plausibly claim: OpenTelemetry, whose model this schema follows, carries span length as a start/end nanosecond PAIR and declares no key named duration at all, so there is no external spelling for the marker to point at. The shape already spells its two instants startTime and endTime, so the bare duration was the one measurement on the span that did not say what it was. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and an exporter emitting the old spelling would lose the value without an error. Why a semantic entry and not a D2 conversion: an emitted span is never a stack collection member and never a stored sys_metadata row — the same disposition every runtime-emitted measurement in this stack has taken. ADR-0087. @@ -1387,7 +1386,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender left on its file, and the file itself is what makes it a drift rather than a convention: TaskResult.durationMs, declared ninety lines earlier in the SAME source, already spelled the identical measurement with its unit. One file, one unit, two spellings, and the correct one was already there — so this rename removes an internal inconsistency rather than imposing an external one. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and a queue would fall back to no rate limit at all without an error. Why a semantic entry and not a D2 conversion: stack.zod.ts declares jobs, not queues, so a QueueConfig is worker host configuration rather than a stack collection member or a stored sys_metadata row, and the conversion chain has no seam that would see it. ADR-0087. - Done when: Every queue declaration spells rateLimit.durationMs. Authoring rateLimit.duration fails to compile (input type `never`) and fails to parse with the rename prescription rather than silently dropping the window and leaving the queue unthrottled. Behaviour is unchanged: { max: 100, durationMs: 60000 } is a hundred tasks a minute exactly as { max: 100, duration: 60000 } was, and the positive-integer bound rides along with the renamed key. The sibling max is a COUNT and keeps its name — it has no unit to carry. - **`tenant-schema-cache-ttl-unit-in-key`** — `SchemaLevelIsolationStrategy `performance.schemaCacheTTL` (system/tenant.zod.ts)` → `performance.schemaCacheTtlSeconds` (default 3600) — rename the key; the value (seconds) is unchanged - - Why not automatic: Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. The key carried its unit (seconds) in a source JSDoc only — "Schema cache TTL in seconds" — while `.describe()`, the text `content/docs/references/**` publishes, said "Schema cache TTL" and named no unit at all. So the reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 3600 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so the key is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: counted on this tree, the suffixed family already spells it that way in every member (`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position `TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested `performance` object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is not a stored metadata row (it describes cloud tenancy configuration), so the chain has no seam that runs on it — the same reading `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` and its test the only occurrences are the four generated rows in `content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells it 0 times across 8281 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the same corpus (0 across 8234, against 184 and 1338, at f0268ad78; 0 across 7754, against 184 and 1319, at a58626c88; 0 across 7650, against 182 and 1318, at 0abd4f9f8; 0 across 7632, against 182 and 1318, at 9dfaca654; 0 across 7579, against 182 and 1317, at 2e818d0b5; 0 across 10267, against 180 and 1238, at ab1879721; 0 across 10071, against 181 and 1237, at 89cad75d5; 0 across 9912, against 181 and 1237, at 31971ff1e; 0 across 9800, against 181 and 1235, at e420df310; 0 across 9546, against 181 and 1200, at db11afd49; 0 across 9283, against 181 and 1185, at dd3f7e1be; 0 across 8512, against 156 and 1034, at f8a9d0fb0; 0 across 8303, against 156 and 987, at 62597c588). + - Why not automatic: Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. The key carried its unit (seconds) in a source JSDoc only — "Schema cache TTL in seconds" — while `.describe()`, the text `content/docs/references/**` publishes, said "Schema cache TTL" and named no unit at all. So the reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 3600 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so the key is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: counted on this tree, the suffixed family already spells it that way in every member (`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position `TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested `performance` object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is not a stored metadata row (it describes cloud tenancy configuration), so the chain has no seam that runs on it — the same reading `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` and its test the only occurrences are the four generated rows in `content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — spells it 0 times across 8351 tracked files, against lit controls `TTL` 184 and `tenant` 1340 on the same corpus (0 across 8281, against 184 and 1338, at 47b1f0bb7; 0 across 8234, against 184 and 1338, at f0268ad78; 0 across 7754, against 184 and 1319, at a58626c88; 0 across 7650, against 182 and 1318, at 0abd4f9f8; 0 across 7632, against 182 and 1318, at 9dfaca654; 0 across 7579, against 182 and 1317, at 2e818d0b5; 0 across 10267, against 180 and 1238, at ab1879721; 0 across 10071, against 181 and 1237, at 89cad75d5; 0 across 9912, against 181 and 1237, at 31971ff1e; 0 across 9800, against 181 and 1235, at e420df310; 0 across 9546, against 181 and 1200, at db11afd49; 0 across 9283, against 181 and 1185, at dd3f7e1be; 0 across 8512, against 156 and 1034, at f8a9d0fb0; 0 across 8303, against 156 and 987, at 62597c588). - Done when: Every schema-level tenant isolation source spells `performance.schemaCacheTtlSeconds`; authoring `performance.schemaCacheTTL` fails to compile and fails to parse with the rename prescription naming the suffixed key; the parsed default is 3600 as before, and the published describe reads "Schema cache TTL in seconds". - **`tenant-timeouts-unit-in-key`** — `DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy `accessControl.sessionTimeout` (system/tenant.zod.ts)` → `connectionPool.idleTimeoutSeconds` (default 300) and `accessControl.sessionTimeoutSeconds` (default 3600) — rename each key; the values (seconds) are unchanged - Why not automatic: Maintainer ruling 2026-09-02, B: a duration number key carries its unit in its name, enforced by a gate with no grandfathered baseline — folding in the finding that these two descriptions named no unit. Both keys carried their unit (seconds) in a source JSDoc only; `.describe()` — the text `content/docs/references/**` publishes — said "Idle pool timeout" and "Session timeout" with no unit at all. So the one reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 300 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. That finding proposed adding the unit to the two descriptions; under the ruled gate that exact fix is a violation (unit in prose, none in the name), so the keys are renamed instead — one breaking change per key, and the tree never passes through a state the gate refuses. Both are retiredKey tombstones (the nested objects are not strict). Why a semantic entry and not a D2 conversion: neither schema is a stack collection member or a stored row (they describe cloud tenancy configuration), so the chain has no seam that runs on them (the `kernel/Manifest:loading` precedent). Measured on ca46f8f12: no in-repo runtime reads either key. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 0b5b326ce29..c3a3274ecef 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -561,7 +561,19 @@ "toMajor": 18 }, { - "surface": "page.component.dataSource.filter / page.component.properties.filter (the object-* blocks, element:number, element:record_picker) / page.component.properties.defaultFilters (object-grid) — the record and single-level AST filter forms", + "surface": "page.component.element:record_picker.object / page.component.element:record_picker.filter / page.component.element:record_picker.sort / page.component.element:record_picker.limit / page.component.element:number.object / page.component.element:number.filter / page.component.element:repeater.object / page.component.element:repeater.filter / page.component.element:repeater.sort / page.component.element:repeater.limit", + "to": "the element layer's flat data-binding keys removed — 'object' / 'filter' / 'sort' / 'limit' on element:record_picker and element:repeater, 'object' / 'filter' on element:number — each the same query as a key of the node-level 'dataSource' binding, the one door the element reads: a key the binding lacks moves there unchanged, one the binding already set is deleted where the binding always won (and element:number's filter is appended to the binding's, since the two always AND-combined); a key whose effect depended on a saved 'dataSource.view', or that disagrees with a binding the repeater never read, is left as stored and reported as a TODO", + "conversionId": "element-flat-data-binding-to-data-source", + "toMajor": 18 + }, + { + "surface": "page.component.object-grid.defaultFilters", + "to": "object-grid component prop 'defaultFilters' removed (the legacy second spelling of 'filter', read only when 'filter' lowered to nothing; its rules move onto an empty 'filter', and the key is deleted beside a 'filter' that has content, which the grid always read instead)", + "conversionId": "object-grid-default-filters-removed", + "toMajor": 18 + }, + { + "surface": "page.component.dataSource.filter / page.component.properties.filter (the object-* blocks) — the record and single-level AST filter forms", "to": "a record-form or single-level AST filter at a converged rule-array door becomes the `[{ field, operator, value }]` rule array wherever the mapping is lossless (flat keys → `equals` rules, `{ $op: v }` → the mapped operator, AST comparisons → one rule each); a filter carrying `$and` / `$or` / `$not` or any part with no lossless rule spelling is left exactly as stored — reported as a TODO, which `os migrate meta --stored` lists — and is not the form its door declares (one filter orthography platform-wide, the rule array; the migration converts only what maps losslessly and names the rest, because flattening a combinator would silently change what a page selects)", "conversionId": "page-component-filter-record-to-rule-array", "toMajor": 18 @@ -1912,6 +1924,13 @@ "toMajor": 18, "rationale": "Both elements were retired whole at element grain (ADR-0049 enforce-or-remove): no renderer for either ever shipped in objectui, framework or cloud, so every authorable key was a capability claim nothing kept. The conversions are mechanical where they can be — they strip all twelve keys losslessly — and stop at the node, because removing an authored page node changes the LAYOUT of a page the author composed, and a conversion cannot know whether the region should close up, hold a replacement, or keep its slot. That residue is no longer inert: both names are members of `RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses them by name, and a stack that replays the chain and stops there is schema-INVALID. Mechanical where it can be, delegated where it cannot — this entry is the delegation, in writing" }, + { + "surface": "page.component properties of element:record_picker (object, filter, sort, limit), element:number (object, filter) and element:repeater (object, filter, sort, limit) — the flat data-binding keys beside the node-level dataSource", + "replacement": "`dataSource` on the component node — `{ object, view?, filter?, sort?, limit? }`, a sibling of `type` rather than a key inside `properties` — the one binding each of the three elements reads. Each key moves unchanged: `properties: { object: 'deal', limit: 20 }` becomes `dataSource: { object: 'deal', limit: 20 }`, and a filter keeps its rule-array form `[{ field, operator, value }, ...]`. `element:number` reads `object` and `filter` only. With a `view`, the view supplies the baseline, an explicit binding key overrides it, and the binding filter AND-combines with the view's.", + "migrationId": "element-flat-data-binding-retired", + "toMajor": 18, + "rationale": "One node carried two doors onto one query, resolved by three different rules: the record picker let the binding win (its flat key was read only when the binding, or the saved view the binding named, supplied none), `element:number` resolved `object` binding-first and AND-combined the two filters, and the repeater read its flat keys alone and ignored the binding — while the component-props gate waived a missing flat `object` whenever `dataSource.object` was present, so a repeater bound only through `dataSource` passed validation and drew an empty list. The console moved all three elements onto the binding first, and in v18 the flat keys are refused. The D2 conversion `element-flat-data-binding-to-data-source` follows each element's old rule: a key the binding lacks moves there, a key the binding already set is deleted where the binding won, and `element:number`'s filter is appended to the binding's. Three cases are left as stored and listed as TODOs, because only the author can decide them: a record-picker key beside a `dataSource.view` the binding sets no such key of its own for (the flat value applied only if the view supplied none, and no conversion reads the view); a repeater key the binding sets to a DIFFERENT value, or beside a `view` (the repeater read neither until the console put its binding first, so what it applied depends on the console version); and an `element:number` filter pair that is not two rule arrays. A repeater that carried a `dataSource` its list ignored now applies it — compare it with what the list showed. A flat filter in the retired record form moves to `dataSource.filter` and is then converted there by `page-component-filter-record-to-rule-array` wherever the mapping is lossless; that entry lists the rest. Code that builds these props — a host, a generator, a designer — must write the binding, which no conversion reaches. And the gate now requires `dataSource.object` on all three elements: a node with none names no object and is reported." + }, { "surface": "page.component.element:text_input.targetVariable / page.component.element:record_picker.targetVariable — the declarative binding hint on the two input elements", "replacement": "Declare the binding on the page variable instead: a `variables[]` entry whose `source` is the input component `id`. That reverse lookup is the one binding the renderer has ever honoured; the variable name is the author's choice, and `targetVariable` named it from the wrong end.", @@ -1919,20 +1938,6 @@ "toMajor": 18, "rationale": "The D2 conversion `element-input-target-variable-removed` deletes `targetVariable` from every text-input and record-picker component, and the delete is lossless: no renderer, hook or runtime ever read the key, so an input authored with it and without a matching `variables[].source` wrote nothing, with a success receipt and no diagnostic. What the delete cannot do is restore the intent. An author who wrote `targetVariable: 'contact_email'` meant that input to feed that variable, and after the strip the page is exactly as unbound as it always was — now without even the hint that says so. Whether the variable exists, whether its `source` already names this component, and whether anything downstream (a flow input, a filter, a visibility predicate) reads it are facts about the author's page that no conversion can see, so the binding is delegated rather than invented." }, - { - "surface": "`element:number` component props — `filter` (the FORM: the MongoDB-style `FilterConditionSchema` record vs the `ViewFilterRule` array)", - "replacement": "`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` every other `filter` input in `ComponentPropsMap` already declares (`record:related_list` and its Add-affordance picker). A record-form filter `{ status: 'won' }` becomes `[{ field: 'status', operator: 'equals', value: 'won' }]`; an operator object `{ amount: { $gt: 100 } }` becomes `[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse", - "migrationId": "element-number-filter-rule-array", - "toMajor": 18, - "rationale": "One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: align the element to the `ViewFilterRule` array rather than keep it the record-shaped exception). `ComponentPropsMap['element:number'].filter` was the one `filter` input in the map declared as the MongoDB-style record (`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so the filter a list view stores and renders was refused by the KPI element beside it, and the objectui parity gate had to carry a reasoned exemption to look away. The convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): the console adapter was changed so `ObjectStackAdapter.aggregate()` runs the same `translateFilterArray` its `find()` path runs, and the objectui pin carrying it was re-measured before this entry moved — but that measurement named the wrong hop, and the runtime route's refusal of the array corrects it here. `translateFilterArray` yields AST tuples, which are still a `FilterArray` — input-only sugar — so the real path is: authored array → `translateFilterArray` → lowered by `parseFilterAST` (`@objectstack/spec/data`, the single sink the `FilterArray` docblock names, since the maintainer's 2026-08-04 ruling C declared the array input-only sugar with one lowering seam) in the adapter, BEFORE the wire → a `FilterCondition` on the body. The hop that decides it is the runtime route `POST /analytics/query`, which parses `where` with `AnalyticsQueryRequestSchema` — a `FilterCondition` and nothing else — so an un-lowered array is refused there before any service code runs. `lowerAnalyticsWhere` (`service-analytics`), where that earlier measurement stopped, is the IN-PROCESS door (added when an array `where` was found silently dropped on the analytics path) for callers reaching `analyticsService.query` directly, not the wire's; it too still refuses a RAW rule-object array by design. The adapter-side lowering lands in the console's own repository. The ruled migration check ran with the change: the sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, packages/apps/, spec fixtures) found ONE `element:number` author writing a record-form `filter` — a spec test fixture, rewritten to the array form in the same change — and zero outside the spec package; this entry carries the prescription for authors outside the repo." - }, - { - "surface": "`element:record_picker` component props — `filter` (the FORM: the MongoDB-style `FilterConditionSchema` record vs the `ViewFilterRule` array)", - "replacement": "`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` the map's array-declared `filter` doors already carry (`record:related_list`, its nested Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as `z.unknown()`, a gap measured on its own). A record-form filter `{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; an operator object `{ amount: { $gt: 100 } }` becomes `[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse. The binding-level `dataSource.filter` on the same node is a different key (`ElementDataSourceSchema`) and is not moved by this entry", - "migrationId": "element-record-picker-filter-rule-array", - "toMajor": 18, - "rationale": "One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: every `filter` door takes the `ViewFilterRule` array rather than keeping record-shaped exceptions). `ComponentPropsMap['element:record_picker'].filter` was the LAST `filter` input in the map still declared as the MongoDB-style record (`FilterConditionSchema`) after `element:number` converged: the three array-declared doors (`record:related_list`, its nested Add-affordance picker, `element:number`) carried the `ViewFilterRule` array and the four `object-*` doors declare `z.unknown()`, so the filter a list view stores and renders was refused by the picker beside them, and a lone holdout is the state where the next author copies the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 Option-A ordering ruling: measure the consumer's read path before the contract moves): at the objectui pin `00d3f09c` the renderer hands `filter` to `query.$filter` and calls `adapter.find()` (`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples (`data-objectstack/src/index.ts`), the same door every list view's stored rule array already takes, and the engine lowers the tuples before the driver (`engine-filter-array-lowering.test.ts`); nothing on that path parses `properties` against the installed spec. The pin and objectui `main` (`f7cf7e8`) are byte-identical on every read-path file. The ruled migration check ran with the change: the sweep of first-party corpora (examples/, skills/, content/docs/, docs/, packages/**, .changeset/) found ONE `element:record_picker` author writing a record-form `filter` — a spec test fixture, rewritten to the array form in the same change — and zero outside the spec package; this entry carries the prescription for authors outside the repo." - }, { "surface": "page components of type element:text — properties.variant authored as heading or subheading (ElementTextPropsSchema.variant)", "replacement": "one of the nine values `ui:text` publishes: `h1`-`h6`, `body`, `caption` or `overline`. 'heading' → 'h2' and 'subheading' → 'h3' (the heading element each one always rendered), or the level the page outline means", @@ -2208,14 +2213,14 @@ }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token", - "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths as the value-slot spelling that still reads them — and written as {{ variable }}", + "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros as the value-slot spelling that still reads them, the run user's id as the CEL value envelope current_user.id — and written as {{ variable }}", "migrationId": "flow-text-slot-single-brace-refused", "toMajor": 18, "rationale": "ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}", - "replacement": "a variable the run has, written as a hole. The run user is computed first, with an assignment node whose value slot still reads the run-user path (assignments: { by: '{$User.Id}' }), then written as {{ by }}. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", + "replacement": "a variable the run has, written as a hole. The run user's id is computed first, with an assignment node whose CEL value envelope reads current_user, the run's user (assignments: { by: { dialect: 'cel', source: 'current_user.id' } }), then written as {{ by }}; every other run-user path never resolved in any shipped run, and an email or a name is read from the user record by current_user.id. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", "migrationId": "flow-text-slot-unbound-dollar-root-refused", "toMajor": 18, "rationale": "The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the inner token and left a literal brace on each side; the 18 text slots render holes through the template engine, where such a hole renders nothing and the run reports success. It is now refused by the node contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author meant the hole to read is not in the flow, and the template engine binds no new variable to answer it." @@ -2228,11 +2233,11 @@ "rationale": "ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged." }, { - "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token", - "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token, the run-user paths beginning $User. included", + "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). The run user's id, $User.Id, is current_user.id — current_user is the run's user, or null when the run has none — and in a flow that can run without a user it is current_user != null ? current_user.id : null, which writes null where the template wrote nothing, so on update_record it clears a stored value the template left alone. Every other run-user path ($User.Email, $User.Name, …) never resolved in any shipped run: current_user carries only what the run holds (id, positions, organizationId, isPlatformAdmin), and an email or a name is read from the user record by current_user.id (a get_record node on sys_user). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", "migrationId": "flow-value-slot-template-dialect-refused", "toMajor": 18, - "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." + "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. The run user's id was the run's userId under the template, and nothing in a run with no user (a schedule, a record change made by a system write); the flow CEL scope binds current_user to the run's user and to null in such a run, never a pseudo-user, so current_user.id fails there and its guarded form writes null. The other run-user paths read a user object no run carries, so they wrote nothing in every run. One spelling is kept with its old meaning, because CEL cannot write it yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body", @@ -2365,7 +2370,7 @@ "replacement": "intervalMs, timeoutMs and debounceDelayMs — rename each key; all three values (milliseconds) and their 30000 / 5000 / 1000 defaults are unchanged", "migrationId": "kernel-health-check-and-hot-reload-durations-unit-in-key", "toMajor": 18, - "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. Each key named milliseconds in its JSDoc — \"Health check interval in milliseconds\", \"Timeout for health check in milliseconds\", \"Debounce delay before reloading (milliseconds)\" — and the JSDoc above a key is NOT what `content/docs/references/**` renders; `.describe()` is. Measured on this tree by the gate's own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] — no unit in the name and none in the published prose either. `interval` is the sharpest of the three: its describe carried one unit-shaped token, the parenthetical \"(default: 30s)\", which names SECONDS for a value the schema bounds and defaults in MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, published to the one reader who cannot see the source. The suffix is the family's own spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the Ttl-versus-TTL question the sibling round had to settle, there is no competing family spelling to choose between. All three old spellings are retiredKey() tombstones: neither PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and here the stripped value lands on a setInterval period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — no metadata-type binding, stack collection or manifest embed carries either, and both are library parameters a host passes to PluginHealthMonitor / HotReloadManager in TypeScript (kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, and as a host-driven library when the declarative lifecycle config container was retired) — so a conversion would be a transform with no seam that ever runs. That is the same disposition plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded for keys on these two defs. The registration-time refusals in PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for the audience that does not parse. Measured on 884e8347d: the only in-repo readers are packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in this same change; and the pinned objectui checkout — the pin this repo builds against, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — names neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and the string debounceDelay each occur 0 times across its 8281 tracked files (0 across the 8234 at f0268ad78, the 7754 at a58626c88, the 7650 at 0abd4f9f8, the 7632 at 9dfaca654, the 7579 at 2e818d0b5, the 10267 at ab1879721, the 10071 at 89cad75d5, the 9912 at 31971ff1e, the 9800 at e420df310, the 9546 at db11afd49, the 9283 at dd3f7e1be, the 8512 at f8a9d0fb0 and the 8303 at 62597c588 too), against lit controls objectstack 12966 and @objectstack/spec 4997 on the same corpus at 87af769e9, which re-count to 13125 and 5043 respectively at 62597c588, to 13347 and 5123 at f8a9d0fb0, to 13745 and 5466 at dd3f7e1be, to 14704 and 5545 at db11afd49, to 15352 and 6024 at e420df310, to 15691 and 6206 at 31971ff1e, to 16044 and 6461 at 89cad75d5, to 16377 and 6665 at ab1879721, to 17227 and 7134 at 2e818d0b5, to 17313 and 7186 at 9dfaca654, to 17390 and 7209 at 0abd4f9f8, to 17468 and 7246 at a58626c88, to 17956 and 7522 at f0268ad78 and to 17980 and 7523 at this pin (git grep -o -F, the method that reproduces every earlier count)." + "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. Each key named milliseconds in its JSDoc — \"Health check interval in milliseconds\", \"Timeout for health check in milliseconds\", \"Debounce delay before reloading (milliseconds)\" — and the JSDoc above a key is NOT what `content/docs/references/**` renders; `.describe()` is. Measured on this tree by the gate's own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] — no unit in the name and none in the published prose either. `interval` is the sharpest of the three: its describe carried one unit-shaped token, the parenthetical \"(default: 30s)\", which names SECONDS for a value the schema bounds and defaults in MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, published to the one reader who cannot see the source. The suffix is the family's own spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the Ttl-versus-TTL question the sibling round had to settle, there is no competing family spelling to choose between. All three old spellings are retiredKey() tombstones: neither PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and here the stripped value lands on a setInterval period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — no metadata-type binding, stack collection or manifest embed carries either, and both are library parameters a host passes to PluginHealthMonitor / HotReloadManager in TypeScript (kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, and as a host-driven library when the declarative lifecycle config container was retired) — so a conversion would be a transform with no seam that ever runs. That is the same disposition plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded for keys on these two defs. The registration-time refusals in PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for the audience that does not parse. Measured on 884e8347d: the only in-repo readers are packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in this same change; and the pinned objectui checkout — the pin this repo builds against, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — names neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and the string debounceDelay each occur 0 times across its 8351 tracked files (0 across the 8281 at 47b1f0bb7, the 8234 at f0268ad78, the 7754 at a58626c88, the 7650 at 0abd4f9f8, the 7632 at 9dfaca654, the 7579 at 2e818d0b5, the 10267 at ab1879721, the 10071 at 89cad75d5, the 9912 at 31971ff1e, the 9800 at e420df310, the 9546 at db11afd49, the 9283 at dd3f7e1be, the 8512 at f8a9d0fb0 and the 8303 at 62597c588 too), against lit controls objectstack 12966 and @objectstack/spec 4997 on the same corpus at 87af769e9, which re-count to 13125 and 5043 respectively at 62597c588, to 13347 and 5123 at f8a9d0fb0, to 13745 and 5466 at dd3f7e1be, to 14704 and 5545 at db11afd49, to 15352 and 6024 at e420df310, to 15691 and 6206 at 31971ff1e, to 16044 and 6461 at 89cad75d5, to 16377 and 6665 at ab1879721, to 17227 and 7134 at 2e818d0b5, to 17313 and 7186 at 9dfaca654, to 17390 and 7209 at 0abd4f9f8, to 17468 and 7246 at a58626c88, to 17956 and 7522 at f0268ad78, to 17980 and 7523 at 47b1f0bb7 and to 18047 and 7545 at this pin (git grep -o -F, the method that reproduces every earlier count)." }, { "surface": "the three package and version lifecycle durations whose name carried no unit: UpgradePlan.estimatedDuration (kernel/package-upgrade.zod.ts), PackageDependencyResolutionResult.resolvedIn (kernel/plugin-security.zod.ts) and MultiVersionSupport.rollout.duration (kernel/plugin-versioning.zod.ts)", @@ -2393,7 +2398,7 @@ "replacement": "resourceLimits.timeoutMs — rename the key; the value (milliseconds) is unchanged", "migrationId": "kernel-runtime-config-timeout-unit-in-key", "toMajor": 18, - "rationale": "This entry COMPLETES what the kernel-directory duration renames deliberately left alone, and the two are meant to be read as a sequence. That round renamed the four plugin-security durations on this same file (`kernel-plugin-security-durations-unit-in-key`) and recorded, accurately, that one key was out of its scope: RuntimeConfig.resourceLimits.timeout named its unit only in the JSDoc above it (\"Execution timeout in milliseconds\"), a channel check:duration-unit-keys does not read — it reads `.describe()` and `.meta({ description })` — and that key's describe (\"Maximum execution time\") named none, so the gate listed it among the duration-shaped keys without judging it, neither an offender nor an exemption. That JSDoc-channel gap was filed as a finding of its own, and that round's statement about its own scope stays true. The finding is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. So the reader who most needs the unit — the reader of the published reference page, who never sees the source JSDoc — got a bare integer on content/docs/references/kernel/plugin-security-advanced.mdx and could not tell 60000 milliseconds from 60000 seconds. The key is renamed and the describe is corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation (unit in prose, none in the name). Spelled Ms, the same token SandboxConfig.process.timeoutMs on this very file already carries: counted on this tree, the suffixed family spells it that way in every member (29 key-position `timeoutMs` declarations across packages/spec/src/**/*.zod.ts, 40 distinct *Ms keys) and there is no timeoutMillis, timeout_ms or timeoutMS variant anywhere in packages/spec/src. Tombstoned with retiredKey() because the nested resourceLimits object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: a RuntimeConfig is the engine block of the SandboxConfig a host or a plugin security manifest constructs — stack.zod.ts declares no sandbox, security-policy or runtime-config collection and it is not a stored sys_metadata row — so the conversion chain has no seam that runs on it; the same reading the kernel-directory round recorded for the four keys it renamed. Measured on 146c291943: no in-repo runtime reads the key — packages/core/src/security/sandbox-runtime.ts, the one consumer of this shape, reads resourceLimits.maxCpu (3 occurrences of resourceLimits) and spells timeout 0 times; outside the zod file and its test the only live occurrences are the generated rows in content/docs/references/kernel/plugin-security-advanced.mdx, which this rename regenerates. The pinned objectui checkout — this is the pin we build against, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells resourceLimits.timeout 0 times across 8281 tracked files, against lit controls timeout 1674, RuntimeConfig 337 and resourceLimits 2 on the same corpus (0 across 8234, and 1658 / 337 / 2, at f0268ad78; 0 across 7754, and 1431 / 299 / 2, at a58626c88; 0 across 7650, and 1360 / 293 / 2, at 0abd4f9f8; 0 across 7632, and 1360 / 293 / 2, at 9dfaca654; 0 across 7579, and 1351 / 276 / 2, at 2e818d0b5; 0 across 10267, and 1348 / 273 / 2, at ab1879721; 0 across 10071, and 1331 / 273 / 2, at 89cad75d5; 0 across 9912, and 1303 / 273 / 2, at 31971ff1e; 0 across 9800, and 1293 / 273 / 2, at e420df310; 0 across 9546, and 1197 / 273 / 2, at db11afd49; 0 across 9283, and 1172 / 263 / 2, at dd3f7e1be; 0 across 8512, and 1096 / 245 / 2, at f8a9d0fb0; 0 across 8303, and 1086 / 240 / 2, at 62597c588); both resourceLimits hits are prose in packages/app-shell recording that objectui's own AppShellRuntimeConfig shares not one key with the spec's RuntimeConfig, so nothing there authors this key and no pin bump is owed. ADR-0087." + "rationale": "This entry COMPLETES what the kernel-directory duration renames deliberately left alone, and the two are meant to be read as a sequence. That round renamed the four plugin-security durations on this same file (`kernel-plugin-security-durations-unit-in-key`) and recorded, accurately, that one key was out of its scope: RuntimeConfig.resourceLimits.timeout named its unit only in the JSDoc above it (\"Execution timeout in milliseconds\"), a channel check:duration-unit-keys does not read — it reads `.describe()` and `.meta({ description })` — and that key's describe (\"Maximum execution time\") named none, so the gate listed it among the duration-shaped keys without judging it, neither an offender nor an exemption. That JSDoc-channel gap was filed as a finding of its own, and that round's statement about its own scope stays true. The finding is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. So the reader who most needs the unit — the reader of the published reference page, who never sees the source JSDoc — got a bare integer on content/docs/references/kernel/plugin-security-advanced.mdx and could not tell 60000 milliseconds from 60000 seconds. The key is renamed and the describe is corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation (unit in prose, none in the name). Spelled Ms, the same token SandboxConfig.process.timeoutMs on this very file already carries: counted on this tree, the suffixed family spells it that way in every member (29 key-position `timeoutMs` declarations across packages/spec/src/**/*.zod.ts, 40 distinct *Ms keys) and there is no timeoutMillis, timeout_ms or timeoutMS variant anywhere in packages/spec/src. Tombstoned with retiredKey() because the nested resourceLimits object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: a RuntimeConfig is the engine block of the SandboxConfig a host or a plugin security manifest constructs — stack.zod.ts declares no sandbox, security-policy or runtime-config collection and it is not a stored sys_metadata row — so the conversion chain has no seam that runs on it; the same reading the kernel-directory round recorded for the four keys it renamed. Measured on 146c291943: no in-repo runtime reads the key — packages/core/src/security/sandbox-runtime.ts, the one consumer of this shape, reads resourceLimits.maxCpu (3 occurrences of resourceLimits) and spells timeout 0 times; outside the zod file and its test the only live occurrences are the generated rows in content/docs/references/kernel/plugin-security-advanced.mdx, which this rename regenerates. The pinned objectui checkout — this is the pin we build against, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6`, re-read from this tree — spells resourceLimits.timeout 0 times across 8351 tracked files, against lit controls timeout 1694, RuntimeConfig 337 and resourceLimits 2 on the same corpus (0 across 8281, and 1674 / 337 / 2, at 47b1f0bb7; 0 across 8234, and 1658 / 337 / 2, at f0268ad78; 0 across 7754, and 1431 / 299 / 2, at a58626c88; 0 across 7650, and 1360 / 293 / 2, at 0abd4f9f8; 0 across 7632, and 1360 / 293 / 2, at 9dfaca654; 0 across 7579, and 1351 / 276 / 2, at 2e818d0b5; 0 across 10267, and 1348 / 273 / 2, at ab1879721; 0 across 10071, and 1331 / 273 / 2, at 89cad75d5; 0 across 9912, and 1303 / 273 / 2, at 31971ff1e; 0 across 9800, and 1293 / 273 / 2, at e420df310; 0 across 9546, and 1197 / 273 / 2, at db11afd49; 0 across 9283, and 1172 / 263 / 2, at dd3f7e1be; 0 across 8512, and 1096 / 245 / 2, at f8a9d0fb0; 0 across 8303, and 1086 / 240 / 2, at 62597c588); both resourceLimits hits are prose in packages/app-shell recording that objectui's own AppShellRuntimeConfig shares not one key with the spec's RuntimeConfig, so nothing there authors this key and no pin bump is owed. ADR-0087." }, { "surface": "the three startup-orchestration durations whose name carried no unit: StartupOptions.timeout, PluginStartupResult.duration and StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts)", @@ -2435,7 +2440,7 @@ "replacement": "`batch.flushIntervalMs` (default 5000) / `retry.initialDelayMs` (default 1000) / `timeoutMs` (default 30000) on HttpDestinationConfig, and `buffer.flushIntervalMs` (default 1000) on LoggingConfig — rename the keys; every value (milliseconds) is unchanged", "migrationId": "logging-durations-unit-in-key", "toMajor": 18, - "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. All four keys named milliseconds in a source JSDoc — \"Flush interval in milliseconds\", \"Initial retry delay in milliseconds\", \"Timeout in milliseconds\" — and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is, and none of the four carried one at all. Measured by the `check:duration-unit-keys` census on this tree before the change, all four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 and nothing on the page decided milliseconds from seconds. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so each key is renamed and given the describe it never had in the same stroke. ⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two different defaults — 5000 on the HTTP destination's batch and 1000 on the logging buffer — so they are two keys, each with its own tombstone and its own registered row; the prescriptions name their def so a reader who lands on one is not sent to the other. The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` declarations in `packages/spec/src` against 75 `*Seconds:`, and the only competing unit spellings are 3 `*MS:` and 9 `*Millis:` — every one of them a name fixed outside this repo (MongoDB's `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and `connectionTimeoutMillis` on `PoolConfigSchema`), so unlike the `Ttl`-versus-`TTL` question a sibling round settled there is no in-repo alternative to choose between. All three target spellings were already attested as key-position `*.zod.ts` declarations before this change: `flushIntervalMs` 1 (`kernel/events/integrations.zod.ts`, same 1000 default), `initialDelayMs` 5, `timeoutMs` 30. Tombstoned with `retiredKey()` rather than deleted because none of the four enclosing objects — `HttpDestinationConfig` itself and its nested `batch` and `retry`, and `LoggingConfig`'s nested `buffer` — is `.strict()`, so a bare deletion would have stripped the value in silence. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no rehydration seam that runs on an authored logging document — the same reading `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key. Measured on 4dab2bc5c: no in-repo runtime reads any of the four — outside `packages/spec/src/system/logging.zod.ts` and its test the only occurrences are the generated rows in `content/docs/references/system/logging.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8281 tracked files, against lit controls `useState` 2630 and `timeout` 1674 on the same corpus (all four 0 across 8234, against 2622 and 1658, at f0268ad78, 0 across 7754, against 2491 and 1431, at a58626c88, 0 across 7650, against 2478 and 1360, at 0abd4f9f8, 0 across 7632, against 2477 and 1360, at 9dfaca654, 0 across 7579, against 2477 and 1351, at 2e818d0b5, 0 across 10267, against 2476 and 1348, at ab1879721, 0 across 10071, against 2470 and 1331, at 89cad75d5, 0 across 9912, against 2469 and 1303, at 31971ff1e, 0 across 9800, against 2464 and 1293, at e420df310, 0 across 9546, against 2449 and 1197, at db11afd49, 0 across 9283, against 2435 and 1172, at dd3f7e1be, 0 across 8512, against 2391 and 1096, at f8a9d0fb0, and 0 across 8303, against 2389 and 1086, at 62597c588)." + "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. All four keys named milliseconds in a source JSDoc — \"Flush interval in milliseconds\", \"Initial retry delay in milliseconds\", \"Timeout in milliseconds\" — and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is, and none of the four carried one at all. Measured by the `check:duration-unit-keys` census on this tree before the change, all four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 and nothing on the page decided milliseconds from seconds. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so each key is renamed and given the describe it never had in the same stroke. ⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two different defaults — 5000 on the HTTP destination's batch and 1000 on the logging buffer — so they are two keys, each with its own tombstone and its own registered row; the prescriptions name their def so a reader who lands on one is not sent to the other. The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` declarations in `packages/spec/src` against 75 `*Seconds:`, and the only competing unit spellings are 3 `*MS:` and 9 `*Millis:` — every one of them a name fixed outside this repo (MongoDB's `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and `connectionTimeoutMillis` on `PoolConfigSchema`), so unlike the `Ttl`-versus-`TTL` question a sibling round settled there is no in-repo alternative to choose between. All three target spellings were already attested as key-position `*.zod.ts` declarations before this change: `flushIntervalMs` 1 (`kernel/events/integrations.zod.ts`, same 1000 default), `initialDelayMs` 5, `timeoutMs` 30. Tombstoned with `retiredKey()` rather than deleted because none of the four enclosing objects — `HttpDestinationConfig` itself and its nested `batch` and `retry`, and `LoggingConfig`'s nested `buffer` — is `.strict()`, so a bare deletion would have stripped the value in silence. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no rehydration seam that runs on an authored logging document — the same reading `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key. Measured on 4dab2bc5c: no in-repo runtime reads any of the four — outside `packages/spec/src/system/logging.zod.ts` and its test the only occurrences are the generated rows in `content/docs/references/system/logging.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8351 tracked files, against lit controls `useState` 2630 and `timeout` 1694 on the same corpus (all four 0 across 8281, against 2630 and 1674, at 47b1f0bb7, 0 across 8234, against 2622 and 1658, at f0268ad78, 0 across 7754, against 2491 and 1431, at a58626c88, 0 across 7650, against 2478 and 1360, at 0abd4f9f8, 0 across 7632, against 2477 and 1360, at 9dfaca654, 0 across 7579, against 2477 and 1351, at 2e818d0b5, 0 across 10267, against 2476 and 1348, at ab1879721, 0 across 10071, against 2470 and 1331, at 89cad75d5, 0 across 9912, against 2469 and 1303, at 31971ff1e, 0 across 9800, against 2464 and 1293, at e420df310, 0 across 9546, against 2449 and 1197, at db11afd49, 0 across 9283, against 2435 and 1172, at dd3f7e1be, 0 across 8512, against 2391 and 1096, at f8a9d0fb0, and 0 across 8303, against 2389 and 1086, at 62597c588)." }, { "surface": "the manage_org_presentation platform capability (its PLATFORM_CAPABILITIES entry in @objectstack/spec security, the ORG_PRESENTATION_AUTHORING_CAPABILITY constant exported by @objectstack/metadata-core) and the arm of metaWriteCapabilityVerdict that admitted its holders to org-scoped writes of the five org-overridable types through the /meta item doors", @@ -2571,11 +2576,11 @@ "rationale": "Two entries of one contract disagreed on the KIND (contract-vs-contract, found by objectui's declared-arm parity gate): `ComponentPropsMap['object-grid'].data` said bare array ('Static inline rows — bypasses the object query') while `ViewDataSchema` — the authority objectui aligned the grid's registry declaration to, pinned by `gridDataInputContract.test.ts`, and what `ObjectGridSchema.data` resolves to — is an object discriminated on `provider`. Measured on @objectstack/spec@17.2.0: `{ provider: 'value', items: [] }` — the pinned-legal form — was REFUSED by the props-map entry (`expected array, received object`) while the bare array parsed. Whichever authority a value satisfied, the other refused it, and the objectui parity gate had to carry the reasoned exemption `object-grid.data:object` to look away. The maintainer's ruling of 2026-08-25 (option A) converged the props-map entry onto `ViewDataSchema`; the bare-array form is the deprecated `staticData` shortcut that objectui's deprecated-alias carve-out already refuses to publish as authoring surface. The ruled migration check ran with the change: the sweep of generated artifacts, templates and first-party corpora (examples/, skills/, create-objectstack, spec fixtures) found ZERO bare-array `data` authors, so no rewrite ships — this entry carries the prescription for authors outside the repo." }, { - "surface": "the object-grid page block's defaultFilters property — the legacy base-filter fallback in ComponentPropsMap, which was z.unknown and therefore accepted a bare string, a number, a MongoDB-style record, an ObjectQL AST tuple array and a list of malformed rules alike", - "replacement": "the same ViewFilterRule array form its sibling filter takes — [{ field, operator, value }, ...]. A record-form fallback { status: \"active\" } becomes [{ field: \"status\", operator: \"equals\", value: \"active\" }] and several record keys become several rules, which AND; an operator object { amount: { $gt: 100 } } lifts the operator into the rule, becoming [{ field: \"amount\", operator: \"greater_than\", value: 100 }]; an AST tuple array [[\"owner_id\", \"=\", \"{current_user_id}\"]] becomes [{ field: \"owner_id\", operator: \"equals\", value: \"{current_user_id}\" }], value placeholders and date macros unchanged. Legacy operator shorthands are accepted and normalized on parse. Better still, write the rules on filter and delete this key: it is read only when filter is absent, and its own description has prescribed filter all along", - "migrationId": "object-grid-default-filters-rule-array", + "surface": "page.component.object-grid.defaultFilters — the legacy second spelling of the grid base filter", + "replacement": "`filter: [{ field, operator, value }, ...]` — the one base-filter key every read path honours; the same rules, unchanged.", + "migrationId": "object-grid-default-filters-retired", "toMajor": 18, - "rationale": "The protocol half of the maintainer's ruling C-prime of 2026-09-20 on objectui's render-time filter converter — the protocol is the only refusal set, so a document it accepts never throws at render time — verbatim, untranslated: 「the differences are the protocol's to close」. This is the SAME value in the SAME role as filter — the key's own description says it is read only when filter is absent — and the consumer reads it through the SAME lowering sink, so every refusal that sink can give was reachable from a document the protocol had just accepted. filter converged on the rule array with the rest of its family; this key was not named by that ruling and kept the pre-convergence read-point shape, which left the block with one declared door and one undeclared door onto one seam. The parse receipt said nothing about what the grid would then do with the value, and in the objectui version this release pins that depended on the shape: ObjectGrid lowers defaultFilters through toFilterNode whenever filter lowers to nothing, so a record form and an AST tuple array were lowered and applied as declared; a bare string or a number was dropped without a word, so the grid sent no filter and listed its rows unfiltered; and a list of malformed rules was refused — on the wire with 400 INVALID_FILTER, or by the client before any request for the value shapes it judges itself. ⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key outright — the other arm the finding offered — removes an accepted shape and needs its own ruling; the deprecation already stated in the description is unchanged and still says to prefer filter. Metadata AT REST: the record form and the AST tuple array at this key are rewritten to the rule array by the same D2 conversion as its sibling filter, page-component-filter-record-to-rule-array, wherever the mapping is lossless — by os migrate meta --stored, and on every stored-row read until it runs. What it cannot map losslessly is left exactly as stored and keeps rendering as it does today — a combinator, a null value, an operator the rule vocabulary does not spell, or the bare string or number this key also took — and its door refuses such a value only as the component-props gate's advisory finding (os validate, os build, os lint), since a re-save through the metadata API is not refused there: a record form with the message the filter door gives, a worked rewrite computed from the author's own keys and a pointer to this entry's conversion table, and a bare string or number or an AST tuple array with the schema's plain type refusal. ADR-0049 / ADR-0087." + "rationale": "The grid read `defaultFilters` only when `filter` lowered to nothing, so one intent had two spellings on one block. The D2 conversion `object-grid-default-filters-removed` follows that precedence: where `filter` was empty (absent, null, `[]` or `{}`) the fallback WAS the grid's filter, so its rules move onto `filter`; where `filter` had rules the fallback was never read, so it is deleted. Both preserve what the grid showed, and the second is where the judgment sits: a grid that authored both keys has always listed the rows `filter` selects, while its author may believe `defaultFilters` applied. The conversion keeps the rows users have been seeing and discards the rules that were written; only the author can say which were meant. A `filter` that is neither empty nor rules (a bare string, a number) is left as stored and listed as a TODO: the grid fell back to `defaultFilters` there too, and moving it would overwrite what was written at `filter`. A fallback in the retired record form moves to `filter` and is then converted there by `page-component-filter-record-to-rule-array` wherever the mapping is lossless; that entry lists the rest. Code that builds object-grid props (a host, a generator) must also stop emitting the key, which no conversion reaches." }, { "surface": "page.component.object-grid.defaultSort — the legacy single-pair second spelling of the grid sort", @@ -2988,7 +2993,7 @@ "replacement": "another scope, or none for the default (`user` on an upload, `global` on a storage configuration), and `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in (ADR-0104)", "migrationId": "storage-scope-public-retired", "toMajor": 18, - "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. Files already stored with scope public are not touched and download exactly as before. ADR-0049" + "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. The stored file record retires the value too: the scope select of sys_file no longer lists public, so a deployment that stored files with it runs the one-time operator sweep that @objectstack/service-storage exports (`planSysFilePublicScopeBackfill` for the dry run, then `applySysFilePublicScopeBackfill`), which rewrites each of those records to scope user. No access changes: no reader tells user apart from public, the storage key and the file bytes are not touched, and those files download exactly as before. Until the sweep has run, a record write that names such a file while another field already owns it is refused, because the copy it makes carries scope public. ADR-0049" }, { "surface": "StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string", @@ -3107,7 +3112,7 @@ "replacement": "summary.maxAgeSeconds, errorBudget.burnRateWindows[].durationSeconds, intervalSeconds, collectionIntervalSeconds and retention.durationSeconds — rename each key; every value is unchanged", "migrationId": "system-metrics-jsdoc-durations-unit-in-key", "toMajor": 18, - "rationale": "This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, and the two are meant to be read as a sequence — this one does not amend that record, which stays a true account of what the system-directory duration round did. That round renamed the three metrics window and period lengths whose describe named no unit, and recorded that the error-budget burn-rate window was \"outside this rename, not outside the gate population\", naming the JSDoc-channel gap as where it would be settled. That gap is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. ⚠️ One consequence for readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its name and that a sweep renaming it has over-applied the rule. That sentence was true of that round and is superseded here, by the ruling it itself pointed at; the other key it names, the exporter batch size, is a COUNT of records and still does not move. All five keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a channel check:duration-unit-keys does not read — it reads .describe() and .meta({ description }) — and four of the five carried no describe at all while the fifth read \"Window size\". So the reader who most needs the unit, the reader of the published reference page, got a bare integer: 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Each key is renamed and its describe corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation. Three of the five spellings are not the mechanical suffix, and each departure has a reason this file already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, because the enclosing array is already called burnRateWindows so the key would stutter — the objection the system-directory round recorded against window.windowSeconds — and because on this tree windowSeconds is not an authorable key at all, its only key-position occurrence being an alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; retention.period becomes durationSeconds, not periodSeconds, because period is calendar vocabulary elsewhere in this spec (ServiceLevelObjective.period.type selects rolling or calendar, PluginRegistryEntry.pricing.billingPeriod is monthly or yearly) so periodSeconds would keep the ambiguous half of the name; and collectionInterval keeps its qualifier as collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds this same card creates one def over. The two mechanical spellings are attested: maxAgeSeconds is the token AccessControlConfig.maxAgeSeconds already carries after this same rule renamed it on system/object-storage.zod.ts, and it keeps the age stem the sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued cadences already carry. Counted in key position across packages/spec/src at fc28c1d38, the base of this change, the seconds suffixes run Seconds 40, Sec 1 (maxExecutionTimeSec) and S 0 — the two bare S keys on that corpus, maxCommitTimeMS and enableRLS, are a millisecond spelling and a boolean — so Seconds is the family; this change takes Seconds to 45 at 9b62f54671. All five are retiredKey() tombstones; none of the five enclosing shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of a metric definition, an SLO, an export config or a metrics config is a registered metadata kind stored as a sys_metadata row — the same reading the system-directory round recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo code consumer reads any of the five — outside packages/spec the only occurrences of every distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the generated content/docs/references/system/metrics.mdx, which this rename regenerates, against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 (1195 again at 9b62f54671); and the objectui checkout this repo builds against — this is the pin, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8281 tracked files at that sha, against lit controls window 4449, timeout 1674, period 249, interval 213 and metrics 455 on that same corpus and sha (0 across 8234, against 4430 / 1658 / 249 / 213 / 404, at f0268ad78, 0 across 7754, against 4255 / 1431 / 247 / 200 / 401, at a58626c88, 0 across 7650, against 4194 / 1360 / 238 / 195 / 401, at 0abd4f9f8, 0 across 7632, against 4193 / 1360 / 238 / 195 / 401, at 9dfaca654, 0 across 7579, against 4175 / 1351 / 238 / 195 / 374, at 2e818d0b5, 0 across 10267, against 4044 / 1348 / 231 / 196 / 354, at ab1879721, 0 across 10071, against 4002 / 1331 / 231 / 196 / 355, at 89cad75d5, 0 across 9912, against 3916 / 1303 / 234 / 196 / 354, at 31971ff1e, 0 across 9800, against 3873 / 1293 / 233 / 196 / 352, at e420df310, 0 across 9546, against 3772 / 1197 / 228 / 196 / 341, at db11afd49, 0 across 9283, against 3681 / 1172 / 183 / 176 / 340, at dd3f7e1be, 0 across 8512, against 3581 / 1096 / 171 / 179 / 326, at f8a9d0fb0, and 0 across 8303, against 3526 / 1086 / 170 / 179 / 324, at 62597c588), so no pin bump is owed. ADR-0087." + "rationale": "This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, and the two are meant to be read as a sequence — this one does not amend that record, which stays a true account of what the system-directory duration round did. That round renamed the three metrics window and period lengths whose describe named no unit, and recorded that the error-budget burn-rate window was \"outside this rename, not outside the gate population\", naming the JSDoc-channel gap as where it would be settled. That gap is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. ⚠️ One consequence for readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its name and that a sweep renaming it has over-applied the rule. That sentence was true of that round and is superseded here, by the ruling it itself pointed at; the other key it names, the exporter batch size, is a COUNT of records and still does not move. All five keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a channel check:duration-unit-keys does not read — it reads .describe() and .meta({ description }) — and four of the five carried no describe at all while the fifth read \"Window size\". So the reader who most needs the unit, the reader of the published reference page, got a bare integer: 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Each key is renamed and its describe corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation. Three of the five spellings are not the mechanical suffix, and each departure has a reason this file already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, because the enclosing array is already called burnRateWindows so the key would stutter — the objection the system-directory round recorded against window.windowSeconds — and because on this tree windowSeconds is not an authorable key at all, its only key-position occurrence being an alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; retention.period becomes durationSeconds, not periodSeconds, because period is calendar vocabulary elsewhere in this spec (ServiceLevelObjective.period.type selects rolling or calendar, PluginRegistryEntry.pricing.billingPeriod is monthly or yearly) so periodSeconds would keep the ambiguous half of the name; and collectionInterval keeps its qualifier as collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds this same card creates one def over. The two mechanical spellings are attested: maxAgeSeconds is the token AccessControlConfig.maxAgeSeconds already carries after this same rule renamed it on system/object-storage.zod.ts, and it keeps the age stem the sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued cadences already carry. Counted in key position across packages/spec/src at fc28c1d38, the base of this change, the seconds suffixes run Seconds 40, Sec 1 (maxExecutionTimeSec) and S 0 — the two bare S keys on that corpus, maxCommitTimeMS and enableRLS, are a millisecond spelling and a boolean — so Seconds is the family; this change takes Seconds to 45 at 9b62f54671. All five are retiredKey() tombstones; none of the five enclosing shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of a metric definition, an SLO, an export config or a metrics config is a registered metadata kind stored as a sys_metadata row — the same reading the system-directory round recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo code consumer reads any of the five — outside packages/spec the only occurrences of every distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the generated content/docs/references/system/metrics.mdx, which this rename regenerates, against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 (1195 again at 9b62f54671); and the objectui checkout this repo builds against — this is the pin, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8351 tracked files at that sha, against lit controls window 4470, timeout 1694, period 249, interval 213 and metrics 456 on that same corpus and sha (0 across 8281, against 4449 / 1674 / 249 / 213 / 455, at 47b1f0bb7, 0 across 8234, against 4430 / 1658 / 249 / 213 / 404, at f0268ad78, 0 across 7754, against 4255 / 1431 / 247 / 200 / 401, at a58626c88, 0 across 7650, against 4194 / 1360 / 238 / 195 / 401, at 0abd4f9f8, 0 across 7632, against 4193 / 1360 / 238 / 195 / 401, at 9dfaca654, 0 across 7579, against 4175 / 1351 / 238 / 195 / 374, at 2e818d0b5, 0 across 10267, against 4044 / 1348 / 231 / 196 / 354, at ab1879721, 0 across 10071, against 4002 / 1331 / 231 / 196 / 355, at 89cad75d5, 0 across 9912, against 3916 / 1303 / 234 / 196 / 354, at 31971ff1e, 0 across 9800, against 3873 / 1293 / 233 / 196 / 352, at e420df310, 0 across 9546, against 3772 / 1197 / 228 / 196 / 341, at db11afd49, 0 across 9283, against 3681 / 1172 / 183 / 176 / 340, at dd3f7e1be, 0 across 8512, against 3581 / 1096 / 171 / 179 / 326, at f8a9d0fb0, and 0 across 8303, against 3526 / 1086 / 170 / 179 / 324, at 62597c588), so no pin bump is owed. ADR-0087." }, { "surface": "the three metrics window/period lengths whose name carried no unit: MetricAggregationConfig.window.size, ServiceLevelIndicator.window.size and ServiceLevelObjective.period.duration (system/metrics.zod.ts)", @@ -3135,7 +3140,7 @@ "replacement": "timeoutMs, exportTimeoutMs, scheduledDelayMs and exportIntervalMs — rename each key; all four values (milliseconds) and their 10000 / 30000 / 5000 / 5000 defaults are unchanged", "migrationId": "system-tracing-otel-exporter-durations-unit-in-key", "toMajor": 18, - "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. It follows system-tracing-span-duration-unit-in-key on this same file and does not amend it: that entry retired Span.duration under ruling B, whose population was the describe channel, and these four keys were never in it — they are the JSDoc-only channel that finding opened, which is why one file carries two rounds. Each key named milliseconds in its JSDoc — \"Timeout in milliseconds\", \"Export timeout in milliseconds\", \"Scheduled delay in milliseconds\", \"Background export interval in milliseconds\" — and the JSDoc above a key is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: all four carried NO .describe() at all, so the published reference row for each was a bare integer with no unit anywhere on the page — a strictly worse channel than the unit-in-prose shape the duration-unit rule already refuses, since here the reference reader had no prose to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, 5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any name; the Delay-plus-Ms pairing is likewise already attested on that same ref (maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout and its own landed precedent is Span.duration to durationMs, the opposite of the sibling metrics card whose rows were seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) beside the exporter's own request deadline — so renaming either to something more distinctive would depart from the vocabulary the shape mirrors, and the nesting already disambiguates every read point (exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema nor any object nested inside them is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and the stripped value lands on an export deadline and a background export period. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts declares no tracing collection, no metadata-type binding or manifest embed carries either, and a tracing configuration is never a stored sys_metadata row — so a conversion would be a transform with no seam that ever runs. That is the same disposition system-tracing-span-duration-unit-in-key recorded for the other key on this file. Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times across the whole tree at that ref excluding packages/spec and content/docs/references, against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a dark control of 0; inside packages/spec the only occurrences are tracing.zod.ts, its test, and the generated rows in content/docs/references/system/tracing.mdx, which this rename regenerates. And the pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8281 files tracked at that sha (the 517 Span and 57 SpanSchema hits are objectui's own HTML text-span component, TextSpanSchema, an unrelated name, plus colSpan and prose), against two lit controls on that same corpus and sha: 17980 hits for the bare token objectstack, and 7523 for the package specifier @objectstack/spec (at f0268ad78: 0 across 8234, Span 517, 17956 and 7522; at a58626c88: 0 across 7754, Span 509, 17468 and 7246; at 0abd4f9f8: 0 across 7650, Span 508, 17390 and 7209; at 9dfaca654: 0 across 7632, Span 508, 17313 and 7186; at 2e818d0b5: 0 across 7579, Span 505, 17227 and 7134; at ab1879721: 0 across 10267, Span 491, 16377 and 6665; at 89cad75d5: 0 across 10071, Span 489, 16044 and 6461; at 31971ff1e: 0 across 9912, Span 486, 15691 and 6206; at e420df310: 0 across 9800, Span 486, 15352 and 6024; at db11afd49: 0 across 9546, Span 486, 14704 and 5545; at dd3f7e1be: 0 across 9283, Span 485, 13745 and 5466; at f8a9d0fb0: 0 across 8512, Span 488, 13347 and 5123; at 62597c588: 0 across 8303, Span 486, 13125 and 5043)." + "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. It follows system-tracing-span-duration-unit-in-key on this same file and does not amend it: that entry retired Span.duration under ruling B, whose population was the describe channel, and these four keys were never in it — they are the JSDoc-only channel that finding opened, which is why one file carries two rounds. Each key named milliseconds in its JSDoc — \"Timeout in milliseconds\", \"Export timeout in milliseconds\", \"Scheduled delay in milliseconds\", \"Background export interval in milliseconds\" — and the JSDoc above a key is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: all four carried NO .describe() at all, so the published reference row for each was a bare integer with no unit anywhere on the page — a strictly worse channel than the unit-in-prose shape the duration-unit rule already refuses, since here the reference reader had no prose to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, 5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any name; the Delay-plus-Ms pairing is likewise already attested on that same ref (maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout and its own landed precedent is Span.duration to durationMs, the opposite of the sibling metrics card whose rows were seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) beside the exporter's own request deadline — so renaming either to something more distinctive would depart from the vocabulary the shape mirrors, and the nesting already disambiguates every read point (exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema nor any object nested inside them is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and the stripped value lands on an export deadline and a background export period. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts declares no tracing collection, no metadata-type binding or manifest embed carries either, and a tracing configuration is never a stored sys_metadata row — so a conversion would be a transform with no seam that ever runs. That is the same disposition system-tracing-span-duration-unit-in-key recorded for the other key on this file. Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times across the whole tree at that ref excluding packages/spec and content/docs/references, against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a dark control of 0; inside packages/spec the only occurrences are tracing.zod.ts, its test, and the generated rows in content/docs/references/system/tracing.mdx, which this rename regenerates. And the pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8351 files tracked at that sha (the 517 Span and 57 SpanSchema hits are objectui's own HTML text-span component, TextSpanSchema, an unrelated name, plus colSpan and prose), against two lit controls on that same corpus and sha: 18047 hits for the bare token objectstack, and 7545 for the package specifier @objectstack/spec (at 47b1f0bb7: 0 across 8281, Span 517, 17980 and 7523; at f0268ad78: 0 across 8234, Span 517, 17956 and 7522; at a58626c88: 0 across 7754, Span 509, 17468 and 7246; at 0abd4f9f8: 0 across 7650, Span 508, 17390 and 7209; at 9dfaca654: 0 across 7632, Span 508, 17313 and 7186; at 2e818d0b5: 0 across 7579, Span 505, 17227 and 7134; at ab1879721: 0 across 10267, Span 491, 16377 and 6665; at 89cad75d5: 0 across 10071, Span 489, 16044 and 6461; at 31971ff1e: 0 across 9912, Span 486, 15691 and 6206; at e420df310: 0 across 9800, Span 486, 15352 and 6024; at db11afd49: 0 across 9546, Span 486, 14704 and 5545; at dd3f7e1be: 0 across 9283, Span 485, 13745 and 5466; at f8a9d0fb0: 0 across 8512, Span 488, 13347 and 5123; at 62597c588: 0 across 8303, Span 486, 13125 and 5043)." }, { "surface": "Span.duration, the emitted trace-span length whose name carried no unit (system/tracing.zod.ts)", @@ -3156,7 +3161,7 @@ "replacement": "`performance.schemaCacheTtlSeconds` (default 3600) — rename the key; the value (seconds) is unchanged", "migrationId": "tenant-schema-cache-ttl-unit-in-key", "toMajor": 18, - "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. The key carried its unit (seconds) in a source JSDoc only — \"Schema cache TTL in seconds\" — while `.describe()`, the text `content/docs/references/**` publishes, said \"Schema cache TTL\" and named no unit at all. So the reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 3600 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so the key is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: counted on this tree, the suffixed family already spells it that way in every member (`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position `TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested `performance` object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is not a stored metadata row (it describes cloud tenancy configuration), so the chain has no seam that runs on it — the same reading `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` and its test the only occurrences are the four generated rows in `content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells it 0 times across 8281 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the same corpus (0 across 8234, against 184 and 1338, at f0268ad78; 0 across 7754, against 184 and 1319, at a58626c88; 0 across 7650, against 182 and 1318, at 0abd4f9f8; 0 across 7632, against 182 and 1318, at 9dfaca654; 0 across 7579, against 182 and 1317, at 2e818d0b5; 0 across 10267, against 180 and 1238, at ab1879721; 0 across 10071, against 181 and 1237, at 89cad75d5; 0 across 9912, against 181 and 1237, at 31971ff1e; 0 across 9800, against 181 and 1235, at e420df310; 0 across 9546, against 181 and 1200, at db11afd49; 0 across 9283, against 181 and 1185, at dd3f7e1be; 0 across 8512, against 156 and 1034, at f8a9d0fb0; 0 across 8303, against 156 and 987, at 62597c588)." + "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. The key carried its unit (seconds) in a source JSDoc only — \"Schema cache TTL in seconds\" — while `.describe()`, the text `content/docs/references/**` publishes, said \"Schema cache TTL\" and named no unit at all. So the reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 3600 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so the key is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: counted on this tree, the suffixed family already spells it that way in every member (`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position `TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested `performance` object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is not a stored metadata row (it describes cloud tenancy configuration), so the chain has no seam that runs on it — the same reading `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` and its test the only occurrences are the four generated rows in `content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — spells it 0 times across 8351 tracked files, against lit controls `TTL` 184 and `tenant` 1340 on the same corpus (0 across 8281, against 184 and 1338, at 47b1f0bb7; 0 across 8234, against 184 and 1338, at f0268ad78; 0 across 7754, against 184 and 1319, at a58626c88; 0 across 7650, against 182 and 1318, at 0abd4f9f8; 0 across 7632, against 182 and 1318, at 9dfaca654; 0 across 7579, against 182 and 1317, at 2e818d0b5; 0 across 10267, against 180 and 1238, at ab1879721; 0 across 10071, against 181 and 1237, at 89cad75d5; 0 across 9912, against 181 and 1237, at 31971ff1e; 0 across 9800, against 181 and 1235, at e420df310; 0 across 9546, against 181 and 1200, at db11afd49; 0 across 9283, against 181 and 1185, at dd3f7e1be; 0 across 8512, against 156 and 1034, at f8a9d0fb0; 0 across 8303, against 156 and 987, at 62597c588)." }, { "surface": "DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy `accessControl.sessionTimeout` (system/tenant.zod.ts)", @@ -4738,7 +4743,19 @@ "toMajor": 18 }, { - "surface": "page.component.dataSource.filter / page.component.properties.filter (the object-* blocks, element:number, element:record_picker) / page.component.properties.defaultFilters (object-grid) — the record and single-level AST filter forms", + "surface": "page.component.element:record_picker.object / page.component.element:record_picker.filter / page.component.element:record_picker.sort / page.component.element:record_picker.limit / page.component.element:number.object / page.component.element:number.filter / page.component.element:repeater.object / page.component.element:repeater.filter / page.component.element:repeater.sort / page.component.element:repeater.limit", + "to": "the element layer's flat data-binding keys removed — 'object' / 'filter' / 'sort' / 'limit' on element:record_picker and element:repeater, 'object' / 'filter' on element:number — each the same query as a key of the node-level 'dataSource' binding, the one door the element reads: a key the binding lacks moves there unchanged, one the binding already set is deleted where the binding always won (and element:number's filter is appended to the binding's, since the two always AND-combined); a key whose effect depended on a saved 'dataSource.view', or that disagrees with a binding the repeater never read, is left as stored and reported as a TODO", + "conversionId": "element-flat-data-binding-to-data-source", + "toMajor": 18 + }, + { + "surface": "page.component.object-grid.defaultFilters", + "to": "object-grid component prop 'defaultFilters' removed (the legacy second spelling of 'filter', read only when 'filter' lowered to nothing; its rules move onto an empty 'filter', and the key is deleted beside a 'filter' that has content, which the grid always read instead)", + "conversionId": "object-grid-default-filters-removed", + "toMajor": 18 + }, + { + "surface": "page.component.dataSource.filter / page.component.properties.filter (the object-* blocks) — the record and single-level AST filter forms", "to": "a record-form or single-level AST filter at a converged rule-array door becomes the `[{ field, operator, value }]` rule array wherever the mapping is lossless (flat keys → `equals` rules, `{ $op: v }` → the mapped operator, AST comparisons → one rule each); a filter carrying `$and` / `$or` / `$not` or any part with no lossless rule spelling is left exactly as stored — reported as a TODO, which `os migrate meta --stored` lists — and is not the form its door declares (one filter orthography platform-wide, the rule array; the migration converts only what maps losslessly and names the rest, because flattening a combinator would silently change what a page selects)", "conversionId": "page-component-filter-record-to-rule-array", "toMajor": 18 @@ -5550,6 +5567,13 @@ "toMajor": 18, "rationale": "Both elements were retired whole at element grain (ADR-0049 enforce-or-remove): no renderer for either ever shipped in objectui, framework or cloud, so every authorable key was a capability claim nothing kept. The conversions are mechanical where they can be — they strip all twelve keys losslessly — and stop at the node, because removing an authored page node changes the LAYOUT of a page the author composed, and a conversion cannot know whether the region should close up, hold a replacement, or keep its slot. That residue is no longer inert: both names are members of `RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses them by name, and a stack that replays the chain and stops there is schema-INVALID. Mechanical where it can be, delegated where it cannot — this entry is the delegation, in writing" }, + { + "surface": "page.component properties of element:record_picker (object, filter, sort, limit), element:number (object, filter) and element:repeater (object, filter, sort, limit) — the flat data-binding keys beside the node-level dataSource", + "replacement": "`dataSource` on the component node — `{ object, view?, filter?, sort?, limit? }`, a sibling of `type` rather than a key inside `properties` — the one binding each of the three elements reads. Each key moves unchanged: `properties: { object: 'deal', limit: 20 }` becomes `dataSource: { object: 'deal', limit: 20 }`, and a filter keeps its rule-array form `[{ field, operator, value }, ...]`. `element:number` reads `object` and `filter` only. With a `view`, the view supplies the baseline, an explicit binding key overrides it, and the binding filter AND-combines with the view's.", + "migrationId": "element-flat-data-binding-retired", + "toMajor": 18, + "rationale": "One node carried two doors onto one query, resolved by three different rules: the record picker let the binding win (its flat key was read only when the binding, or the saved view the binding named, supplied none), `element:number` resolved `object` binding-first and AND-combined the two filters, and the repeater read its flat keys alone and ignored the binding — while the component-props gate waived a missing flat `object` whenever `dataSource.object` was present, so a repeater bound only through `dataSource` passed validation and drew an empty list. The console moved all three elements onto the binding first, and in v18 the flat keys are refused. The D2 conversion `element-flat-data-binding-to-data-source` follows each element's old rule: a key the binding lacks moves there, a key the binding already set is deleted where the binding won, and `element:number`'s filter is appended to the binding's. Three cases are left as stored and listed as TODOs, because only the author can decide them: a record-picker key beside a `dataSource.view` the binding sets no such key of its own for (the flat value applied only if the view supplied none, and no conversion reads the view); a repeater key the binding sets to a DIFFERENT value, or beside a `view` (the repeater read neither until the console put its binding first, so what it applied depends on the console version); and an `element:number` filter pair that is not two rule arrays. A repeater that carried a `dataSource` its list ignored now applies it — compare it with what the list showed. A flat filter in the retired record form moves to `dataSource.filter` and is then converted there by `page-component-filter-record-to-rule-array` wherever the mapping is lossless; that entry lists the rest. Code that builds these props — a host, a generator, a designer — must write the binding, which no conversion reaches. And the gate now requires `dataSource.object` on all three elements: a node with none names no object and is reported." + }, { "surface": "page.component.element:text_input.targetVariable / page.component.element:record_picker.targetVariable — the declarative binding hint on the two input elements", "replacement": "Declare the binding on the page variable instead: a `variables[]` entry whose `source` is the input component `id`. That reverse lookup is the one binding the renderer has ever honoured; the variable name is the author's choice, and `targetVariable` named it from the wrong end.", @@ -5557,20 +5581,6 @@ "toMajor": 18, "rationale": "The D2 conversion `element-input-target-variable-removed` deletes `targetVariable` from every text-input and record-picker component, and the delete is lossless: no renderer, hook or runtime ever read the key, so an input authored with it and without a matching `variables[].source` wrote nothing, with a success receipt and no diagnostic. What the delete cannot do is restore the intent. An author who wrote `targetVariable: 'contact_email'` meant that input to feed that variable, and after the strip the page is exactly as unbound as it always was — now without even the hint that says so. Whether the variable exists, whether its `source` already names this component, and whether anything downstream (a flow input, a filter, a visibility predicate) reads it are facts about the author's page that no conversion can see, so the binding is delegated rather than invented." }, - { - "surface": "`element:number` component props — `filter` (the FORM: the MongoDB-style `FilterConditionSchema` record vs the `ViewFilterRule` array)", - "replacement": "`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` every other `filter` input in `ComponentPropsMap` already declares (`record:related_list` and its Add-affordance picker). A record-form filter `{ status: 'won' }` becomes `[{ field: 'status', operator: 'equals', value: 'won' }]`; an operator object `{ amount: { $gt: 100 } }` becomes `[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse", - "migrationId": "element-number-filter-rule-array", - "toMajor": 18, - "rationale": "One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: align the element to the `ViewFilterRule` array rather than keep it the record-shaped exception). `ComponentPropsMap['element:number'].filter` was the one `filter` input in the map declared as the MongoDB-style record (`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so the filter a list view stores and renders was refused by the KPI element beside it, and the objectui parity gate had to carry a reasoned exemption to look away. The convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): the console adapter was changed so `ObjectStackAdapter.aggregate()` runs the same `translateFilterArray` its `find()` path runs, and the objectui pin carrying it was re-measured before this entry moved — but that measurement named the wrong hop, and the runtime route's refusal of the array corrects it here. `translateFilterArray` yields AST tuples, which are still a `FilterArray` — input-only sugar — so the real path is: authored array → `translateFilterArray` → lowered by `parseFilterAST` (`@objectstack/spec/data`, the single sink the `FilterArray` docblock names, since the maintainer's 2026-08-04 ruling C declared the array input-only sugar with one lowering seam) in the adapter, BEFORE the wire → a `FilterCondition` on the body. The hop that decides it is the runtime route `POST /analytics/query`, which parses `where` with `AnalyticsQueryRequestSchema` — a `FilterCondition` and nothing else — so an un-lowered array is refused there before any service code runs. `lowerAnalyticsWhere` (`service-analytics`), where that earlier measurement stopped, is the IN-PROCESS door (added when an array `where` was found silently dropped on the analytics path) for callers reaching `analyticsService.query` directly, not the wire's; it too still refuses a RAW rule-object array by design. The adapter-side lowering lands in the console's own repository. The ruled migration check ran with the change: the sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, packages/apps/, spec fixtures) found ONE `element:number` author writing a record-form `filter` — a spec test fixture, rewritten to the array form in the same change — and zero outside the spec package; this entry carries the prescription for authors outside the repo." - }, - { - "surface": "`element:record_picker` component props — `filter` (the FORM: the MongoDB-style `FilterConditionSchema` record vs the `ViewFilterRule` array)", - "replacement": "`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` the map's array-declared `filter` doors already carry (`record:related_list`, its nested Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as `z.unknown()`, a gap measured on its own). A record-form filter `{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; an operator object `{ amount: { $gt: 100 } }` becomes `[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse. The binding-level `dataSource.filter` on the same node is a different key (`ElementDataSourceSchema`) and is not moved by this entry", - "migrationId": "element-record-picker-filter-rule-array", - "toMajor": 18, - "rationale": "One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: every `filter` door takes the `ViewFilterRule` array rather than keeping record-shaped exceptions). `ComponentPropsMap['element:record_picker'].filter` was the LAST `filter` input in the map still declared as the MongoDB-style record (`FilterConditionSchema`) after `element:number` converged: the three array-declared doors (`record:related_list`, its nested Add-affordance picker, `element:number`) carried the `ViewFilterRule` array and the four `object-*` doors declare `z.unknown()`, so the filter a list view stores and renders was refused by the picker beside them, and a lone holdout is the state where the next author copies the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 Option-A ordering ruling: measure the consumer's read path before the contract moves): at the objectui pin `00d3f09c` the renderer hands `filter` to `query.$filter` and calls `adapter.find()` (`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples (`data-objectstack/src/index.ts`), the same door every list view's stored rule array already takes, and the engine lowers the tuples before the driver (`engine-filter-array-lowering.test.ts`); nothing on that path parses `properties` against the installed spec. The pin and objectui `main` (`f7cf7e8`) are byte-identical on every read-path file. The ruled migration check ran with the change: the sweep of first-party corpora (examples/, skills/, content/docs/, docs/, packages/**, .changeset/) found ONE `element:record_picker` author writing a record-form `filter` — a spec test fixture, rewritten to the array form in the same change — and zero outside the spec package; this entry carries the prescription for authors outside the repo." - }, { "surface": "page components of type element:text — properties.variant authored as heading or subheading (ElementTextPropsSchema.variant)", "replacement": "one of the nine values `ui:text` publishes: `h1`-`h6`, `body`, `caption` or `overline`. 'heading' → 'h2' and 'subheading' → 'h3' (the heading element each one always rendered), or the level the page outline means", @@ -5846,14 +5856,14 @@ }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token", - "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros and the run-user paths as the value-slot spelling that still reads them — and written as {{ variable }}", + "replacement": "a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros as the value-slot spelling that still reads them, the run user's id as the CEL value envelope current_user.id — and written as {{ variable }}", "migrationId": "flow-text-slot-single-brace-refused", "toMajor": 18, "rationale": "ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }}", - "replacement": "a variable the run has, written as a hole. The run user is computed first, with an assignment node whose value slot still reads the run-user path (assignments: { by: '{$User.Id}' }), then written as {{ by }}. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", + "replacement": "a variable the run has, written as a hole. The run user's id is computed first, with an assignment node whose CEL value envelope reads current_user, the run's user (assignments: { by: { dialect: 'cel', source: 'current_user.id' } }), then written as {{ by }}; every other run-user path never resolved in any shipped run, and an email or a name is read from the user record by current_user.id. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}", "migrationId": "flow-text-slot-unbound-dollar-root-refused", "toMajor": 18, "rationale": "The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the inner token and left a literal brace on each side; the 18 text slots render holes through the template engine, where such a hole renders nothing and the run reports success. It is now refused by the node contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author meant the hole to read is not in the flow, and the template engine binds no new variable to answer it." @@ -5866,11 +5876,11 @@ "rationale": "ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged." }, { - "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token", - "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "surface": "flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token, the run-user paths beginning $User. included", + "replacement": "a CEL value envelope, { dialect: \"cel\", source: \"…\" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars[\"$error\"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). The run user's id, $User.Id, is current_user.id — current_user is the run's user, or null when the run has none — and in a flow that can run without a user it is current_user != null ? current_user.id : null, which writes null where the template wrote nothing, so on update_record it clears a stored value the template left alone. Every other run-user path ($User.Email, $User.Name, …) never resolved in any shipped run: current_user carries only what the run holds (id, positions, organizationId, isPlatformAdmin), and an email or a name is read from the user record by current_user.id (a get_record node on sys_user). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", "migrationId": "flow-value-slot-template-dialect-refused", "toMajor": 18, - "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. Two spellings are kept with their old meaning, because CEL cannot write them yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one) and the run-user paths beginning $User. (the flow CEL scope binds no user). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." + "rationale": "The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. The run user's id was the run's userId under the template, and nothing in a run with no user (a schedule, a record change made by a system write); the flow CEL scope binds current_user to the run's user and to null in such a run, never a pseudo-user, so current_user.id fails there and its guarded form writes null. The other run-user paths read a user object no run carries, so they wrote nothing in every run. One spelling is kept with its old meaning, because CEL cannot write it yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it." }, { "surface": "a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body", @@ -6003,7 +6013,7 @@ "replacement": "intervalMs, timeoutMs and debounceDelayMs — rename each key; all three values (milliseconds) and their 30000 / 5000 / 1000 defaults are unchanged", "migrationId": "kernel-health-check-and-hot-reload-durations-unit-in-key", "toMajor": 18, - "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. Each key named milliseconds in its JSDoc — \"Health check interval in milliseconds\", \"Timeout for health check in milliseconds\", \"Debounce delay before reloading (milliseconds)\" — and the JSDoc above a key is NOT what `content/docs/references/**` renders; `.describe()` is. Measured on this tree by the gate's own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] — no unit in the name and none in the published prose either. `interval` is the sharpest of the three: its describe carried one unit-shaped token, the parenthetical \"(default: 30s)\", which names SECONDS for a value the schema bounds and defaults in MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, published to the one reader who cannot see the source. The suffix is the family's own spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the Ttl-versus-TTL question the sibling round had to settle, there is no competing family spelling to choose between. All three old spellings are retiredKey() tombstones: neither PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and here the stripped value lands on a setInterval period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — no metadata-type binding, stack collection or manifest embed carries either, and both are library parameters a host passes to PluginHealthMonitor / HotReloadManager in TypeScript (kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, and as a host-driven library when the declarative lifecycle config container was retired) — so a conversion would be a transform with no seam that ever runs. That is the same disposition plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded for keys on these two defs. The registration-time refusals in PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for the audience that does not parse. Measured on 884e8347d: the only in-repo readers are packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in this same change; and the pinned objectui checkout — the pin this repo builds against, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — names neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and the string debounceDelay each occur 0 times across its 8281 tracked files (0 across the 8234 at f0268ad78, the 7754 at a58626c88, the 7650 at 0abd4f9f8, the 7632 at 9dfaca654, the 7579 at 2e818d0b5, the 10267 at ab1879721, the 10071 at 89cad75d5, the 9912 at 31971ff1e, the 9800 at e420df310, the 9546 at db11afd49, the 9283 at dd3f7e1be, the 8512 at f8a9d0fb0 and the 8303 at 62597c588 too), against lit controls objectstack 12966 and @objectstack/spec 4997 on the same corpus at 87af769e9, which re-count to 13125 and 5043 respectively at 62597c588, to 13347 and 5123 at f8a9d0fb0, to 13745 and 5466 at dd3f7e1be, to 14704 and 5545 at db11afd49, to 15352 and 6024 at e420df310, to 15691 and 6206 at 31971ff1e, to 16044 and 6461 at 89cad75d5, to 16377 and 6665 at ab1879721, to 17227 and 7134 at 2e818d0b5, to 17313 and 7186 at 9dfaca654, to 17390 and 7209 at 0abd4f9f8, to 17468 and 7246 at a58626c88, to 17956 and 7522 at f0268ad78 and to 17980 and 7523 at this pin (git grep -o -F, the method that reproduces every earlier count)." + "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. Each key named milliseconds in its JSDoc — \"Health check interval in milliseconds\", \"Timeout for health check in milliseconds\", \"Debounce delay before reloading (milliseconds)\" — and the JSDoc above a key is NOT what `content/docs/references/**` renders; `.describe()` is. Measured on this tree by the gate's own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] — no unit in the name and none in the published prose either. `interval` is the sharpest of the three: its describe carried one unit-shaped token, the parenthetical \"(default: 30s)\", which names SECONDS for a value the schema bounds and defaults in MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, published to the one reader who cannot see the source. The suffix is the family's own spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the Ttl-versus-TTL question the sibling round had to settle, there is no competing family spelling to choose between. All three old spellings are retiredKey() tombstones: neither PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and here the stripped value lands on a setInterval period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — no metadata-type binding, stack collection or manifest embed carries either, and both are library parameters a host passes to PluginHealthMonitor / HotReloadManager in TypeScript (kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, and as a host-driven library when the declarative lifecycle config container was retired) — so a conversion would be a transform with no seam that ever runs. That is the same disposition plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded for keys on these two defs. The registration-time refusals in PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for the audience that does not parse. Measured on 884e8347d: the only in-repo readers are packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in this same change; and the pinned objectui checkout — the pin this repo builds against, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — names neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and the string debounceDelay each occur 0 times across its 8351 tracked files (0 across the 8281 at 47b1f0bb7, the 8234 at f0268ad78, the 7754 at a58626c88, the 7650 at 0abd4f9f8, the 7632 at 9dfaca654, the 7579 at 2e818d0b5, the 10267 at ab1879721, the 10071 at 89cad75d5, the 9912 at 31971ff1e, the 9800 at e420df310, the 9546 at db11afd49, the 9283 at dd3f7e1be, the 8512 at f8a9d0fb0 and the 8303 at 62597c588 too), against lit controls objectstack 12966 and @objectstack/spec 4997 on the same corpus at 87af769e9, which re-count to 13125 and 5043 respectively at 62597c588, to 13347 and 5123 at f8a9d0fb0, to 13745 and 5466 at dd3f7e1be, to 14704 and 5545 at db11afd49, to 15352 and 6024 at e420df310, to 15691 and 6206 at 31971ff1e, to 16044 and 6461 at 89cad75d5, to 16377 and 6665 at ab1879721, to 17227 and 7134 at 2e818d0b5, to 17313 and 7186 at 9dfaca654, to 17390 and 7209 at 0abd4f9f8, to 17468 and 7246 at a58626c88, to 17956 and 7522 at f0268ad78, to 17980 and 7523 at 47b1f0bb7 and to 18047 and 7545 at this pin (git grep -o -F, the method that reproduces every earlier count)." }, { "surface": "the three package and version lifecycle durations whose name carried no unit: UpgradePlan.estimatedDuration (kernel/package-upgrade.zod.ts), PackageDependencyResolutionResult.resolvedIn (kernel/plugin-security.zod.ts) and MultiVersionSupport.rollout.duration (kernel/plugin-versioning.zod.ts)", @@ -6031,7 +6041,7 @@ "replacement": "resourceLimits.timeoutMs — rename the key; the value (milliseconds) is unchanged", "migrationId": "kernel-runtime-config-timeout-unit-in-key", "toMajor": 18, - "rationale": "This entry COMPLETES what the kernel-directory duration renames deliberately left alone, and the two are meant to be read as a sequence. That round renamed the four plugin-security durations on this same file (`kernel-plugin-security-durations-unit-in-key`) and recorded, accurately, that one key was out of its scope: RuntimeConfig.resourceLimits.timeout named its unit only in the JSDoc above it (\"Execution timeout in milliseconds\"), a channel check:duration-unit-keys does not read — it reads `.describe()` and `.meta({ description })` — and that key's describe (\"Maximum execution time\") named none, so the gate listed it among the duration-shaped keys without judging it, neither an offender nor an exemption. That JSDoc-channel gap was filed as a finding of its own, and that round's statement about its own scope stays true. The finding is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. So the reader who most needs the unit — the reader of the published reference page, who never sees the source JSDoc — got a bare integer on content/docs/references/kernel/plugin-security-advanced.mdx and could not tell 60000 milliseconds from 60000 seconds. The key is renamed and the describe is corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation (unit in prose, none in the name). Spelled Ms, the same token SandboxConfig.process.timeoutMs on this very file already carries: counted on this tree, the suffixed family spells it that way in every member (29 key-position `timeoutMs` declarations across packages/spec/src/**/*.zod.ts, 40 distinct *Ms keys) and there is no timeoutMillis, timeout_ms or timeoutMS variant anywhere in packages/spec/src. Tombstoned with retiredKey() because the nested resourceLimits object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: a RuntimeConfig is the engine block of the SandboxConfig a host or a plugin security manifest constructs — stack.zod.ts declares no sandbox, security-policy or runtime-config collection and it is not a stored sys_metadata row — so the conversion chain has no seam that runs on it; the same reading the kernel-directory round recorded for the four keys it renamed. Measured on 146c291943: no in-repo runtime reads the key — packages/core/src/security/sandbox-runtime.ts, the one consumer of this shape, reads resourceLimits.maxCpu (3 occurrences of resourceLimits) and spells timeout 0 times; outside the zod file and its test the only live occurrences are the generated rows in content/docs/references/kernel/plugin-security-advanced.mdx, which this rename regenerates. The pinned objectui checkout — this is the pin we build against, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells resourceLimits.timeout 0 times across 8281 tracked files, against lit controls timeout 1674, RuntimeConfig 337 and resourceLimits 2 on the same corpus (0 across 8234, and 1658 / 337 / 2, at f0268ad78; 0 across 7754, and 1431 / 299 / 2, at a58626c88; 0 across 7650, and 1360 / 293 / 2, at 0abd4f9f8; 0 across 7632, and 1360 / 293 / 2, at 9dfaca654; 0 across 7579, and 1351 / 276 / 2, at 2e818d0b5; 0 across 10267, and 1348 / 273 / 2, at ab1879721; 0 across 10071, and 1331 / 273 / 2, at 89cad75d5; 0 across 9912, and 1303 / 273 / 2, at 31971ff1e; 0 across 9800, and 1293 / 273 / 2, at e420df310; 0 across 9546, and 1197 / 273 / 2, at db11afd49; 0 across 9283, and 1172 / 263 / 2, at dd3f7e1be; 0 across 8512, and 1096 / 245 / 2, at f8a9d0fb0; 0 across 8303, and 1086 / 240 / 2, at 62597c588); both resourceLimits hits are prose in packages/app-shell recording that objectui's own AppShellRuntimeConfig shares not one key with the spec's RuntimeConfig, so nothing there authors this key and no pin bump is owed. ADR-0087." + "rationale": "This entry COMPLETES what the kernel-directory duration renames deliberately left alone, and the two are meant to be read as a sequence. That round renamed the four plugin-security durations on this same file (`kernel-plugin-security-durations-unit-in-key`) and recorded, accurately, that one key was out of its scope: RuntimeConfig.resourceLimits.timeout named its unit only in the JSDoc above it (\"Execution timeout in milliseconds\"), a channel check:duration-unit-keys does not read — it reads `.describe()` and `.meta({ description })` — and that key's describe (\"Maximum execution time\") named none, so the gate listed it among the duration-shaped keys without judging it, neither an offender nor an exemption. That JSDoc-channel gap was filed as a finding of its own, and that round's statement about its own scope stays true. The finding is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. So the reader who most needs the unit — the reader of the published reference page, who never sees the source JSDoc — got a bare integer on content/docs/references/kernel/plugin-security-advanced.mdx and could not tell 60000 milliseconds from 60000 seconds. The key is renamed and the describe is corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation (unit in prose, none in the name). Spelled Ms, the same token SandboxConfig.process.timeoutMs on this very file already carries: counted on this tree, the suffixed family spells it that way in every member (29 key-position `timeoutMs` declarations across packages/spec/src/**/*.zod.ts, 40 distinct *Ms keys) and there is no timeoutMillis, timeout_ms or timeoutMS variant anywhere in packages/spec/src. Tombstoned with retiredKey() because the nested resourceLimits object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: a RuntimeConfig is the engine block of the SandboxConfig a host or a plugin security manifest constructs — stack.zod.ts declares no sandbox, security-policy or runtime-config collection and it is not a stored sys_metadata row — so the conversion chain has no seam that runs on it; the same reading the kernel-directory round recorded for the four keys it renamed. Measured on 146c291943: no in-repo runtime reads the key — packages/core/src/security/sandbox-runtime.ts, the one consumer of this shape, reads resourceLimits.maxCpu (3 occurrences of resourceLimits) and spells timeout 0 times; outside the zod file and its test the only live occurrences are the generated rows in content/docs/references/kernel/plugin-security-advanced.mdx, which this rename regenerates. The pinned objectui checkout — this is the pin we build against, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6`, re-read from this tree — spells resourceLimits.timeout 0 times across 8351 tracked files, against lit controls timeout 1694, RuntimeConfig 337 and resourceLimits 2 on the same corpus (0 across 8281, and 1674 / 337 / 2, at 47b1f0bb7; 0 across 8234, and 1658 / 337 / 2, at f0268ad78; 0 across 7754, and 1431 / 299 / 2, at a58626c88; 0 across 7650, and 1360 / 293 / 2, at 0abd4f9f8; 0 across 7632, and 1360 / 293 / 2, at 9dfaca654; 0 across 7579, and 1351 / 276 / 2, at 2e818d0b5; 0 across 10267, and 1348 / 273 / 2, at ab1879721; 0 across 10071, and 1331 / 273 / 2, at 89cad75d5; 0 across 9912, and 1303 / 273 / 2, at 31971ff1e; 0 across 9800, and 1293 / 273 / 2, at e420df310; 0 across 9546, and 1197 / 273 / 2, at db11afd49; 0 across 9283, and 1172 / 263 / 2, at dd3f7e1be; 0 across 8512, and 1096 / 245 / 2, at f8a9d0fb0; 0 across 8303, and 1086 / 240 / 2, at 62597c588); both resourceLimits hits are prose in packages/app-shell recording that objectui's own AppShellRuntimeConfig shares not one key with the spec's RuntimeConfig, so nothing there authors this key and no pin bump is owed. ADR-0087." }, { "surface": "the three startup-orchestration durations whose name carried no unit: StartupOptions.timeout, PluginStartupResult.duration and StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts)", @@ -6073,7 +6083,7 @@ "replacement": "`batch.flushIntervalMs` (default 5000) / `retry.initialDelayMs` (default 1000) / `timeoutMs` (default 30000) on HttpDestinationConfig, and `buffer.flushIntervalMs` (default 1000) on LoggingConfig — rename the keys; every value (milliseconds) is unchanged", "migrationId": "logging-durations-unit-in-key", "toMajor": 18, - "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. All four keys named milliseconds in a source JSDoc — \"Flush interval in milliseconds\", \"Initial retry delay in milliseconds\", \"Timeout in milliseconds\" — and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is, and none of the four carried one at all. Measured by the `check:duration-unit-keys` census on this tree before the change, all four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 and nothing on the page decided milliseconds from seconds. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so each key is renamed and given the describe it never had in the same stroke. ⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two different defaults — 5000 on the HTTP destination's batch and 1000 on the logging buffer — so they are two keys, each with its own tombstone and its own registered row; the prescriptions name their def so a reader who lands on one is not sent to the other. The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` declarations in `packages/spec/src` against 75 `*Seconds:`, and the only competing unit spellings are 3 `*MS:` and 9 `*Millis:` — every one of them a name fixed outside this repo (MongoDB's `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and `connectionTimeoutMillis` on `PoolConfigSchema`), so unlike the `Ttl`-versus-`TTL` question a sibling round settled there is no in-repo alternative to choose between. All three target spellings were already attested as key-position `*.zod.ts` declarations before this change: `flushIntervalMs` 1 (`kernel/events/integrations.zod.ts`, same 1000 default), `initialDelayMs` 5, `timeoutMs` 30. Tombstoned with `retiredKey()` rather than deleted because none of the four enclosing objects — `HttpDestinationConfig` itself and its nested `batch` and `retry`, and `LoggingConfig`'s nested `buffer` — is `.strict()`, so a bare deletion would have stripped the value in silence. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no rehydration seam that runs on an authored logging document — the same reading `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key. Measured on 4dab2bc5c: no in-repo runtime reads any of the four — outside `packages/spec/src/system/logging.zod.ts` and its test the only occurrences are the generated rows in `content/docs/references/system/logging.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8281 tracked files, against lit controls `useState` 2630 and `timeout` 1674 on the same corpus (all four 0 across 8234, against 2622 and 1658, at f0268ad78, 0 across 7754, against 2491 and 1431, at a58626c88, 0 across 7650, against 2478 and 1360, at 0abd4f9f8, 0 across 7632, against 2477 and 1360, at 9dfaca654, 0 across 7579, against 2477 and 1351, at 2e818d0b5, 0 across 10267, against 2476 and 1348, at ab1879721, 0 across 10071, against 2470 and 1331, at 89cad75d5, 0 across 9912, against 2469 and 1303, at 31971ff1e, 0 across 9800, against 2464 and 1293, at e420df310, 0 across 9546, against 2449 and 1197, at db11afd49, 0 across 9283, against 2435 and 1172, at dd3f7e1be, 0 across 8512, against 2391 and 1096, at f8a9d0fb0, and 0 across 8303, against 2389 and 1086, at 62597c588)." + "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. All four keys named milliseconds in a source JSDoc — \"Flush interval in milliseconds\", \"Initial retry delay in milliseconds\", \"Timeout in milliseconds\" — and the JSDoc above a key is not what `content/docs/references/**` renders; `.describe()` is, and none of the four carried one at all. Measured by the `check:duration-unit-keys` census on this tree before the change, all four read `[name: -] [prose: -]`: no unit in the key and no published prose to supply it, so `content/docs/references/system/logging.mdx` printed a bare 5000 / 1000 / 30000 / 1000 and nothing on the page decided milliseconds from seconds. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so each key is renamed and given the describe it never had in the same stroke. ⚠️ `flushInterval` was declared TWICE on this file, in two different defs and with two different defaults — 5000 on the HTTP destination's batch and 1000 on the logging buffer — so they are two keys, each with its own tombstone and its own registered row; the prescriptions name their def so a reader who lands on one is not sent to the other. The `Ms` suffix is the family's own spelling, counted in key position on this tree: 272 `*Ms:` declarations in `packages/spec/src` against 75 `*Seconds:`, and the only competing unit spellings are 3 `*MS:` and 9 `*Millis:` — every one of them a name fixed outside this repo (MongoDB's `maxCommitTimeMS` and `connectTimeoutMS`, node-postgres's `idleTimeoutMillis` and `connectionTimeoutMillis` on `PoolConfigSchema`), so unlike the `Ttl`-versus-`TTL` question a sibling round settled there is no in-repo alternative to choose between. All three target spellings were already attested as key-position `*.zod.ts` declarations before this change: `flushIntervalMs` 1 (`kernel/events/integrations.zod.ts`, same 1000 default), `initialDelayMs` 5, `timeoutMs` 30. Tombstoned with `retiredKey()` rather than deleted because none of the four enclosing objects — `HttpDestinationConfig` itself and its nested `batch` and `retry`, and `LoggingConfig`'s nested `buffer` — is `.strict()`, so a bare deletion would have stripped the value in silence. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no logging collection and neither `LoggingConfigSchema` nor `HttpDestinationConfigSchema` is referenced anywhere in `packages/spec/src` outside `system/logging.zod.ts`, so the chain has no rehydration seam that runs on an authored logging document — the same reading `tenant-schema-cache-ttl-unit-in-key` recorded for its sibling key. Measured on 4dab2bc5c: no in-repo runtime reads any of the four — outside `packages/spec/src/system/logging.zod.ts` and its test the only occurrences are the generated rows in `content/docs/references/system/logging.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8351 tracked files, against lit controls `useState` 2630 and `timeout` 1694 on the same corpus (all four 0 across 8281, against 2630 and 1674, at 47b1f0bb7, 0 across 8234, against 2622 and 1658, at f0268ad78, 0 across 7754, against 2491 and 1431, at a58626c88, 0 across 7650, against 2478 and 1360, at 0abd4f9f8, 0 across 7632, against 2477 and 1360, at 9dfaca654, 0 across 7579, against 2477 and 1351, at 2e818d0b5, 0 across 10267, against 2476 and 1348, at ab1879721, 0 across 10071, against 2470 and 1331, at 89cad75d5, 0 across 9912, against 2469 and 1303, at 31971ff1e, 0 across 9800, against 2464 and 1293, at e420df310, 0 across 9546, against 2449 and 1197, at db11afd49, 0 across 9283, against 2435 and 1172, at dd3f7e1be, 0 across 8512, against 2391 and 1096, at f8a9d0fb0, and 0 across 8303, against 2389 and 1086, at 62597c588)." }, { "surface": "the manage_org_presentation platform capability (its PLATFORM_CAPABILITIES entry in @objectstack/spec security, the ORG_PRESENTATION_AUTHORING_CAPABILITY constant exported by @objectstack/metadata-core) and the arm of metaWriteCapabilityVerdict that admitted its holders to org-scoped writes of the five org-overridable types through the /meta item doors", @@ -6209,11 +6219,11 @@ "rationale": "Two entries of one contract disagreed on the KIND (contract-vs-contract, found by objectui's declared-arm parity gate): `ComponentPropsMap['object-grid'].data` said bare array ('Static inline rows — bypasses the object query') while `ViewDataSchema` — the authority objectui aligned the grid's registry declaration to, pinned by `gridDataInputContract.test.ts`, and what `ObjectGridSchema.data` resolves to — is an object discriminated on `provider`. Measured on @objectstack/spec@17.2.0: `{ provider: 'value', items: [] }` — the pinned-legal form — was REFUSED by the props-map entry (`expected array, received object`) while the bare array parsed. Whichever authority a value satisfied, the other refused it, and the objectui parity gate had to carry the reasoned exemption `object-grid.data:object` to look away. The maintainer's ruling of 2026-08-25 (option A) converged the props-map entry onto `ViewDataSchema`; the bare-array form is the deprecated `staticData` shortcut that objectui's deprecated-alias carve-out already refuses to publish as authoring surface. The ruled migration check ran with the change: the sweep of generated artifacts, templates and first-party corpora (examples/, skills/, create-objectstack, spec fixtures) found ZERO bare-array `data` authors, so no rewrite ships — this entry carries the prescription for authors outside the repo." }, { - "surface": "the object-grid page block's defaultFilters property — the legacy base-filter fallback in ComponentPropsMap, which was z.unknown and therefore accepted a bare string, a number, a MongoDB-style record, an ObjectQL AST tuple array and a list of malformed rules alike", - "replacement": "the same ViewFilterRule array form its sibling filter takes — [{ field, operator, value }, ...]. A record-form fallback { status: \"active\" } becomes [{ field: \"status\", operator: \"equals\", value: \"active\" }] and several record keys become several rules, which AND; an operator object { amount: { $gt: 100 } } lifts the operator into the rule, becoming [{ field: \"amount\", operator: \"greater_than\", value: 100 }]; an AST tuple array [[\"owner_id\", \"=\", \"{current_user_id}\"]] becomes [{ field: \"owner_id\", operator: \"equals\", value: \"{current_user_id}\" }], value placeholders and date macros unchanged. Legacy operator shorthands are accepted and normalized on parse. Better still, write the rules on filter and delete this key: it is read only when filter is absent, and its own description has prescribed filter all along", - "migrationId": "object-grid-default-filters-rule-array", + "surface": "page.component.object-grid.defaultFilters — the legacy second spelling of the grid base filter", + "replacement": "`filter: [{ field, operator, value }, ...]` — the one base-filter key every read path honours; the same rules, unchanged.", + "migrationId": "object-grid-default-filters-retired", "toMajor": 18, - "rationale": "The protocol half of the maintainer's ruling C-prime of 2026-09-20 on objectui's render-time filter converter — the protocol is the only refusal set, so a document it accepts never throws at render time — verbatim, untranslated: 「the differences are the protocol's to close」. This is the SAME value in the SAME role as filter — the key's own description says it is read only when filter is absent — and the consumer reads it through the SAME lowering sink, so every refusal that sink can give was reachable from a document the protocol had just accepted. filter converged on the rule array with the rest of its family; this key was not named by that ruling and kept the pre-convergence read-point shape, which left the block with one declared door and one undeclared door onto one seam. The parse receipt said nothing about what the grid would then do with the value, and in the objectui version this release pins that depended on the shape: ObjectGrid lowers defaultFilters through toFilterNode whenever filter lowers to nothing, so a record form and an AST tuple array were lowered and applied as declared; a bare string or a number was dropped without a word, so the grid sent no filter and listed its rows unfiltered; and a list of malformed rules was refused — on the wire with 400 INVALID_FILTER, or by the client before any request for the value shapes it judges itself. ⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key outright — the other arm the finding offered — removes an accepted shape and needs its own ruling; the deprecation already stated in the description is unchanged and still says to prefer filter. Metadata AT REST: the record form and the AST tuple array at this key are rewritten to the rule array by the same D2 conversion as its sibling filter, page-component-filter-record-to-rule-array, wherever the mapping is lossless — by os migrate meta --stored, and on every stored-row read until it runs. What it cannot map losslessly is left exactly as stored and keeps rendering as it does today — a combinator, a null value, an operator the rule vocabulary does not spell, or the bare string or number this key also took — and its door refuses such a value only as the component-props gate's advisory finding (os validate, os build, os lint), since a re-save through the metadata API is not refused there: a record form with the message the filter door gives, a worked rewrite computed from the author's own keys and a pointer to this entry's conversion table, and a bare string or number or an AST tuple array with the schema's plain type refusal. ADR-0049 / ADR-0087." + "rationale": "The grid read `defaultFilters` only when `filter` lowered to nothing, so one intent had two spellings on one block. The D2 conversion `object-grid-default-filters-removed` follows that precedence: where `filter` was empty (absent, null, `[]` or `{}`) the fallback WAS the grid's filter, so its rules move onto `filter`; where `filter` had rules the fallback was never read, so it is deleted. Both preserve what the grid showed, and the second is where the judgment sits: a grid that authored both keys has always listed the rows `filter` selects, while its author may believe `defaultFilters` applied. The conversion keeps the rows users have been seeing and discards the rules that were written; only the author can say which were meant. A `filter` that is neither empty nor rules (a bare string, a number) is left as stored and listed as a TODO: the grid fell back to `defaultFilters` there too, and moving it would overwrite what was written at `filter`. A fallback in the retired record form moves to `filter` and is then converted there by `page-component-filter-record-to-rule-array` wherever the mapping is lossless; that entry lists the rest. Code that builds object-grid props (a host, a generator) must also stop emitting the key, which no conversion reaches." }, { "surface": "page.component.object-grid.defaultSort — the legacy single-pair second spelling of the grid sort", @@ -6626,7 +6636,7 @@ "replacement": "another scope, or none for the default (`user` on an upload, `global` on a storage configuration), and `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in (ADR-0104)", "migrationId": "storage-scope-public-retired", "toMajor": 18, - "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. Files already stored with scope public are not touched and download exactly as before. ADR-0049" + "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. The stored file record retires the value too: the scope select of sys_file no longer lists public, so a deployment that stored files with it runs the one-time operator sweep that @objectstack/service-storage exports (`planSysFilePublicScopeBackfill` for the dry run, then `applySysFilePublicScopeBackfill`), which rewrites each of those records to scope user. No access changes: no reader tells user apart from public, the storage key and the file bytes are not touched, and those files download exactly as before. Until the sweep has run, a record write that names such a file while another field already owns it is refused, because the copy it makes carries scope public. ADR-0049" }, { "surface": "StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string", @@ -6745,7 +6755,7 @@ "replacement": "summary.maxAgeSeconds, errorBudget.burnRateWindows[].durationSeconds, intervalSeconds, collectionIntervalSeconds and retention.durationSeconds — rename each key; every value is unchanged", "migrationId": "system-metrics-jsdoc-durations-unit-in-key", "toMajor": 18, - "rationale": "This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, and the two are meant to be read as a sequence — this one does not amend that record, which stays a true account of what the system-directory duration round did. That round renamed the three metrics window and period lengths whose describe named no unit, and recorded that the error-budget burn-rate window was \"outside this rename, not outside the gate population\", naming the JSDoc-channel gap as where it would be settled. That gap is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. ⚠️ One consequence for readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its name and that a sweep renaming it has over-applied the rule. That sentence was true of that round and is superseded here, by the ruling it itself pointed at; the other key it names, the exporter batch size, is a COUNT of records and still does not move. All five keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a channel check:duration-unit-keys does not read — it reads .describe() and .meta({ description }) — and four of the five carried no describe at all while the fifth read \"Window size\". So the reader who most needs the unit, the reader of the published reference page, got a bare integer: 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Each key is renamed and its describe corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation. Three of the five spellings are not the mechanical suffix, and each departure has a reason this file already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, because the enclosing array is already called burnRateWindows so the key would stutter — the objection the system-directory round recorded against window.windowSeconds — and because on this tree windowSeconds is not an authorable key at all, its only key-position occurrence being an alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; retention.period becomes durationSeconds, not periodSeconds, because period is calendar vocabulary elsewhere in this spec (ServiceLevelObjective.period.type selects rolling or calendar, PluginRegistryEntry.pricing.billingPeriod is monthly or yearly) so periodSeconds would keep the ambiguous half of the name; and collectionInterval keeps its qualifier as collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds this same card creates one def over. The two mechanical spellings are attested: maxAgeSeconds is the token AccessControlConfig.maxAgeSeconds already carries after this same rule renamed it on system/object-storage.zod.ts, and it keeps the age stem the sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued cadences already carry. Counted in key position across packages/spec/src at fc28c1d38, the base of this change, the seconds suffixes run Seconds 40, Sec 1 (maxExecutionTimeSec) and S 0 — the two bare S keys on that corpus, maxCommitTimeMS and enableRLS, are a millisecond spelling and a boolean — so Seconds is the family; this change takes Seconds to 45 at 9b62f54671. All five are retiredKey() tombstones; none of the five enclosing shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of a metric definition, an SLO, an export config or a metrics config is a registered metadata kind stored as a sys_metadata row — the same reading the system-directory round recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo code consumer reads any of the five — outside packages/spec the only occurrences of every distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the generated content/docs/references/system/metrics.mdx, which this rename regenerates, against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 (1195 again at 9b62f54671); and the objectui checkout this repo builds against — this is the pin, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8281 tracked files at that sha, against lit controls window 4449, timeout 1674, period 249, interval 213 and metrics 455 on that same corpus and sha (0 across 8234, against 4430 / 1658 / 249 / 213 / 404, at f0268ad78, 0 across 7754, against 4255 / 1431 / 247 / 200 / 401, at a58626c88, 0 across 7650, against 4194 / 1360 / 238 / 195 / 401, at 0abd4f9f8, 0 across 7632, against 4193 / 1360 / 238 / 195 / 401, at 9dfaca654, 0 across 7579, against 4175 / 1351 / 238 / 195 / 374, at 2e818d0b5, 0 across 10267, against 4044 / 1348 / 231 / 196 / 354, at ab1879721, 0 across 10071, against 4002 / 1331 / 231 / 196 / 355, at 89cad75d5, 0 across 9912, against 3916 / 1303 / 234 / 196 / 354, at 31971ff1e, 0 across 9800, against 3873 / 1293 / 233 / 196 / 352, at e420df310, 0 across 9546, against 3772 / 1197 / 228 / 196 / 341, at db11afd49, 0 across 9283, against 3681 / 1172 / 183 / 176 / 340, at dd3f7e1be, 0 across 8512, against 3581 / 1096 / 171 / 179 / 326, at f8a9d0fb0, and 0 across 8303, against 3526 / 1086 / 170 / 179 / 324, at 62597c588), so no pin bump is owed. ADR-0087." + "rationale": "This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, and the two are meant to be read as a sequence — this one does not amend that record, which stays a true account of what the system-directory duration round did. That round renamed the three metrics window and period lengths whose describe named no unit, and recorded that the error-budget burn-rate window was \"outside this rename, not outside the gate population\", naming the JSDoc-channel gap as where it would be settled. That gap is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. ⚠️ One consequence for readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its name and that a sweep renaming it has over-applied the rule. That sentence was true of that round and is superseded here, by the ruling it itself pointed at; the other key it names, the exporter batch size, is a COUNT of records and still does not move. All five keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a channel check:duration-unit-keys does not read — it reads .describe() and .meta({ description }) — and four of the five carried no describe at all while the fifth read \"Window size\". So the reader who most needs the unit, the reader of the published reference page, got a bare integer: 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Each key is renamed and its describe corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation. Three of the five spellings are not the mechanical suffix, and each departure has a reason this file already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, because the enclosing array is already called burnRateWindows so the key would stutter — the objection the system-directory round recorded against window.windowSeconds — and because on this tree windowSeconds is not an authorable key at all, its only key-position occurrence being an alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; retention.period becomes durationSeconds, not periodSeconds, because period is calendar vocabulary elsewhere in this spec (ServiceLevelObjective.period.type selects rolling or calendar, PluginRegistryEntry.pricing.billingPeriod is monthly or yearly) so periodSeconds would keep the ambiguous half of the name; and collectionInterval keeps its qualifier as collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds this same card creates one def over. The two mechanical spellings are attested: maxAgeSeconds is the token AccessControlConfig.maxAgeSeconds already carries after this same rule renamed it on system/object-storage.zod.ts, and it keeps the age stem the sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued cadences already carry. Counted in key position across packages/spec/src at fc28c1d38, the base of this change, the seconds suffixes run Seconds 40, Sec 1 (maxExecutionTimeSec) and S 0 — the two bare S keys on that corpus, maxCommitTimeMS and enableRLS, are a millisecond spelling and a boolean — so Seconds is the family; this change takes Seconds to 45 at 9b62f54671. All five are retiredKey() tombstones; none of the five enclosing shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of a metric definition, an SLO, an export config or a metrics config is a registered metadata kind stored as a sys_metadata row — the same reading the system-directory round recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo code consumer reads any of the five — outside packages/spec the only occurrences of every distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the generated content/docs/references/system/metrics.mdx, which this rename regenerates, against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 (1195 again at 9b62f54671); and the objectui checkout this repo builds against — this is the pin, `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8351 tracked files at that sha, against lit controls window 4470, timeout 1694, period 249, interval 213 and metrics 456 on that same corpus and sha (0 across 8281, against 4449 / 1674 / 249 / 213 / 455, at 47b1f0bb7, 0 across 8234, against 4430 / 1658 / 249 / 213 / 404, at f0268ad78, 0 across 7754, against 4255 / 1431 / 247 / 200 / 401, at a58626c88, 0 across 7650, against 4194 / 1360 / 238 / 195 / 401, at 0abd4f9f8, 0 across 7632, against 4193 / 1360 / 238 / 195 / 401, at 9dfaca654, 0 across 7579, against 4175 / 1351 / 238 / 195 / 374, at 2e818d0b5, 0 across 10267, against 4044 / 1348 / 231 / 196 / 354, at ab1879721, 0 across 10071, against 4002 / 1331 / 231 / 196 / 355, at 89cad75d5, 0 across 9912, against 3916 / 1303 / 234 / 196 / 354, at 31971ff1e, 0 across 9800, against 3873 / 1293 / 233 / 196 / 352, at e420df310, 0 across 9546, against 3772 / 1197 / 228 / 196 / 341, at db11afd49, 0 across 9283, against 3681 / 1172 / 183 / 176 / 340, at dd3f7e1be, 0 across 8512, against 3581 / 1096 / 171 / 179 / 326, at f8a9d0fb0, and 0 across 8303, against 3526 / 1086 / 170 / 179 / 324, at 62597c588), so no pin bump is owed. ADR-0087." }, { "surface": "the three metrics window/period lengths whose name carried no unit: MetricAggregationConfig.window.size, ServiceLevelIndicator.window.size and ServiceLevelObjective.period.duration (system/metrics.zod.ts)", @@ -6773,7 +6783,7 @@ "replacement": "timeoutMs, exportTimeoutMs, scheduledDelayMs and exportIntervalMs — rename each key; all four values (milliseconds) and their 10000 / 30000 / 5000 / 5000 defaults are unchanged", "migrationId": "system-tracing-otel-exporter-durations-unit-in-key", "toMajor": 18, - "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. It follows system-tracing-span-duration-unit-in-key on this same file and does not amend it: that entry retired Span.duration under ruling B, whose population was the describe channel, and these four keys were never in it — they are the JSDoc-only channel that finding opened, which is why one file carries two rounds. Each key named milliseconds in its JSDoc — \"Timeout in milliseconds\", \"Export timeout in milliseconds\", \"Scheduled delay in milliseconds\", \"Background export interval in milliseconds\" — and the JSDoc above a key is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: all four carried NO .describe() at all, so the published reference row for each was a bare integer with no unit anywhere on the page — a strictly worse channel than the unit-in-prose shape the duration-unit rule already refuses, since here the reference reader had no prose to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, 5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any name; the Delay-plus-Ms pairing is likewise already attested on that same ref (maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout and its own landed precedent is Span.duration to durationMs, the opposite of the sibling metrics card whose rows were seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) beside the exporter's own request deadline — so renaming either to something more distinctive would depart from the vocabulary the shape mirrors, and the nesting already disambiguates every read point (exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema nor any object nested inside them is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and the stripped value lands on an export deadline and a background export period. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts declares no tracing collection, no metadata-type binding or manifest embed carries either, and a tracing configuration is never a stored sys_metadata row — so a conversion would be a transform with no seam that ever runs. That is the same disposition system-tracing-span-duration-unit-in-key recorded for the other key on this file. Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times across the whole tree at that ref excluding packages/spec and content/docs/references, against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a dark control of 0; inside packages/spec the only occurrences are tracing.zod.ts, its test, and the generated rows in content/docs/references/system/tracing.mdx, which this rename regenerates. And the pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8281 files tracked at that sha (the 517 Span and 57 SpanSchema hits are objectui's own HTML text-span component, TextSpanSchema, an unrelated name, plus colSpan and prose), against two lit controls on that same corpus and sha: 17980 hits for the bare token objectstack, and 7523 for the package specifier @objectstack/spec (at f0268ad78: 0 across 8234, Span 517, 17956 and 7522; at a58626c88: 0 across 7754, Span 509, 17468 and 7246; at 0abd4f9f8: 0 across 7650, Span 508, 17390 and 7209; at 9dfaca654: 0 across 7632, Span 508, 17313 and 7186; at 2e818d0b5: 0 across 7579, Span 505, 17227 and 7134; at ab1879721: 0 across 10267, Span 491, 16377 and 6665; at 89cad75d5: 0 across 10071, Span 489, 16044 and 6461; at 31971ff1e: 0 across 9912, Span 486, 15691 and 6206; at e420df310: 0 across 9800, Span 486, 15352 and 6024; at db11afd49: 0 across 9546, Span 486, 14704 and 5545; at dd3f7e1be: 0 across 9283, Span 485, 13745 and 5466; at f8a9d0fb0: 0 across 8512, Span 488, 13347 and 5123; at 62597c588: 0 across 8303, Span 486, 13125 and 5043)." + "rationale": "Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. It follows system-tracing-span-duration-unit-in-key on this same file and does not amend it: that entry retired Span.duration under ruling B, whose population was the describe channel, and these four keys were never in it — they are the JSDoc-only channel that finding opened, which is why one file carries two rounds. Each key named milliseconds in its JSDoc — \"Timeout in milliseconds\", \"Export timeout in milliseconds\", \"Scheduled delay in milliseconds\", \"Background export interval in milliseconds\" — and the JSDoc above a key is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: all four carried NO .describe() at all, so the published reference row for each was a bare integer with no unit anywhere on the page — a strictly worse channel than the unit-in-prose shape the duration-unit rule already refuses, since here the reference reader had no prose to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, 5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any name; the Delay-plus-Ms pairing is likewise already attested on that same ref (maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout and its own landed precedent is Span.duration to durationMs, the opposite of the sibling metrics card whose rows were seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) beside the exporter's own request deadline — so renaming either to something more distinctive would depart from the vocabulary the shape mirrors, and the nesting already disambiguates every read point (exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema nor any object nested inside them is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and the stripped value lands on an export deadline and a background export period. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts declares no tracing collection, no metadata-type binding or manifest embed carries either, and a tracing configuration is never a stored sys_metadata row — so a conversion would be a transform with no seam that ever runs. That is the same disposition system-tracing-span-duration-unit-in-key recorded for the other key on this file. Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times across the whole tree at that ref excluding packages/spec and content/docs/references, against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a dark control of 0; inside packages/spec the only occurrences are tracing.zod.ts, its test, and the generated rows in content/docs/references/system/tracing.mdx, which this rename regenerates. And the pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8351 files tracked at that sha (the 517 Span and 57 SpanSchema hits are objectui's own HTML text-span component, TextSpanSchema, an unrelated name, plus colSpan and prose), against two lit controls on that same corpus and sha: 18047 hits for the bare token objectstack, and 7545 for the package specifier @objectstack/spec (at 47b1f0bb7: 0 across 8281, Span 517, 17980 and 7523; at f0268ad78: 0 across 8234, Span 517, 17956 and 7522; at a58626c88: 0 across 7754, Span 509, 17468 and 7246; at 0abd4f9f8: 0 across 7650, Span 508, 17390 and 7209; at 9dfaca654: 0 across 7632, Span 508, 17313 and 7186; at 2e818d0b5: 0 across 7579, Span 505, 17227 and 7134; at ab1879721: 0 across 10267, Span 491, 16377 and 6665; at 89cad75d5: 0 across 10071, Span 489, 16044 and 6461; at 31971ff1e: 0 across 9912, Span 486, 15691 and 6206; at e420df310: 0 across 9800, Span 486, 15352 and 6024; at db11afd49: 0 across 9546, Span 486, 14704 and 5545; at dd3f7e1be: 0 across 9283, Span 485, 13745 and 5466; at f8a9d0fb0: 0 across 8512, Span 488, 13347 and 5123; at 62597c588: 0 across 8303, Span 486, 13125 and 5043)." }, { "surface": "Span.duration, the emitted trace-span length whose name carried no unit (system/tracing.zod.ts)", @@ -6794,7 +6804,7 @@ "replacement": "`performance.schemaCacheTtlSeconds` (default 3600) — rename the key; the value (seconds) is unchanged", "migrationId": "tenant-schema-cache-ttl-unit-in-key", "toMajor": 18, - "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. The key carried its unit (seconds) in a source JSDoc only — \"Schema cache TTL in seconds\" — while `.describe()`, the text `content/docs/references/**` publishes, said \"Schema cache TTL\" and named no unit at all. So the reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 3600 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so the key is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: counted on this tree, the suffixed family already spells it that way in every member (`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position `TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested `performance` object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is not a stored metadata row (it describes cloud tenancy configuration), so the chain has no seam that runs on it — the same reading `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` and its test the only occurrences are the four generated rows in `content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells it 0 times across 8281 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the same corpus (0 across 8234, against 184 and 1338, at f0268ad78; 0 across 7754, against 184 and 1319, at a58626c88; 0 across 7650, against 182 and 1318, at 0abd4f9f8; 0 across 7632, against 182 and 1318, at 9dfaca654; 0 across 7579, against 182 and 1317, at 2e818d0b5; 0 across 10267, against 180 and 1238, at ab1879721; 0 across 10071, against 181 and 1237, at 89cad75d5; 0 across 9912, against 181 and 1237, at 31971ff1e; 0 across 9800, against 181 and 1235, at e420df310; 0 across 9546, against 181 and 1200, at db11afd49; 0 across 9283, against 181 and 1185, at dd3f7e1be; 0 across 8512, against 156 and 1034, at f8a9d0fb0; 0 across 8303, against 156 and 987, at 62597c588)." + "rationale": "Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. The key carried its unit (seconds) in a source JSDoc only — \"Schema cache TTL in seconds\" — while `.describe()`, the text `content/docs/references/**` publishes, said \"Schema cache TTL\" and named no unit at all. So the reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 3600 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so the key is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: counted on this tree, the suffixed family already spells it that way in every member (`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position `TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested `performance` object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is not a stored metadata row (it describes cloud tenancy configuration), so the chain has no seam that runs on it — the same reading `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` and its test the only occurrences are the four generated rows in `content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned objectui checkout — `.objectui-sha` = `20c6d351ad74d2b14a93becdc134d51b91b2d2e6` — spells it 0 times across 8351 tracked files, against lit controls `TTL` 184 and `tenant` 1340 on the same corpus (0 across 8281, against 184 and 1338, at 47b1f0bb7; 0 across 8234, against 184 and 1338, at f0268ad78; 0 across 7754, against 184 and 1319, at a58626c88; 0 across 7650, against 182 and 1318, at 0abd4f9f8; 0 across 7632, against 182 and 1318, at 9dfaca654; 0 across 7579, against 182 and 1317, at 2e818d0b5; 0 across 10267, against 180 and 1238, at ab1879721; 0 across 10071, against 181 and 1237, at 89cad75d5; 0 across 9912, against 181 and 1237, at 31971ff1e; 0 across 9800, against 181 and 1235, at e420df310; 0 across 9546, against 181 and 1200, at db11afd49; 0 across 9283, against 181 and 1185, at dd3f7e1be; 0 across 8512, against 156 and 1034, at f8a9d0fb0; 0 across 8303, against 156 and 987, at 62597c588)." }, { "surface": "DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy `accessControl.sessionTimeout` (system/tenant.zod.ts)", From 5faf12295faaf4d53d2d9d797e90470685084e67 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:29:55 +0000 Subject: [PATCH 16/16] docs(spec/ui): the object-tree titleField rationale is true at the pin again At objectui 20c6d351a nothing reads a tree `titleField`: the renderer's `getTreeConfig` rung went on objectui#8841, and the `case 'tree'` arm's flatten rung went on objectui#6152 round 14 (objectui `3fd862510`), so `ListView.tsx:3985` now reads `treeCfg.labelField || 'name'` and emits no `titleField`. The OBJECT_TREE_FLAT_CONFIG_GUIDANCE docblock and the last sentence of its prescription said the key was "only ever read as that key's last fallback", a read that no longer exists. The prose now says why the key stays in the set (for its prescription, not for a read), and the prescription still names `tree.labelField`. The flat key set and every schema shape are unchanged; keeping or dropping `titleField` from the set is a schema decision outside this change. The in-test note beside the key-set pin gains the same dated reading. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/spec/src/ui/component.test.ts | 6 ++++- packages/spec/src/ui/component.zod.ts | 34 +++++++++++++++----------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 2af89eeb78e..974f8de9f29 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -4314,7 +4314,11 @@ describe('object-map / object-gantt / object-tree — key sets derived from the // still resolves `treeCfg.titleField` into `labelField` before emitting // (`ListView.tsx:3270` at this pin), so an authored `titleField` is still // only ever the block's `labelField`. What died is the renderer's own - // fallback, not the flatten's. + // fallback, not the flatten's. At `20c6d351a` the flatten's rung is gone + // too (objectui#6152 round 14, objectui `3fd862510`: `ListView.tsx:3985` + // reads `treeCfg.labelField || 'name'`), so no half reads `titleField`; + // it stays in the set for the prescription it carries, which names + // `tree.labelField` (see `OBJECT_TREE_FLAT_CONFIG_GUIDANCE`). expect(setFor('object-tree', 'OBJECT_TREE_FLAT_CONFIG_KEYS')) .toEqual([...Object.keys(TreeConfigSchema.shape), 'titleField'].sort()); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 9964d0ddd96..32bc5f71ecd 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -8611,19 +8611,25 @@ export type ObjectGanttPropsParsed = z.infer; * uses above. The registration agrees: `plugin-tree/src/index.tsx` declares * `{ name: 'tree', type: 'object' }` and no flat input. * - * `titleField` is in the set although no `tree` block key is spelled that way: - * `ListView`'s flatten resolves `treeCfg.titleField` into `labelField` before - * emitting (`ListView.tsx:3927`, `labelField: treeCfg.labelField || - * treeCfg.titleField || 'name'`), so the author's intent is always the block's - * `labelField`, and the prescription below says so. - * - * ⚠️ The renderer's own half of that sentence is GONE, and it is a DELETED - * read rather than a moved one: `getTreeConfig`'s `?? schema.titleField` rung - * — the `:117` this record used to cite — was removed on objectui#8841 - * because it read the FLATTENED NODE and never the block, and the docblock at + * `titleField` is in the set although no `tree` block key is spelled that way, + * and at `20c6d351a` NOTHING reads it: both halves that once did are DELETED + * reads, not moved ones. The flatten half went on objectui#6152 round 14 + * (objectui `3fd862510`): at `47b1f0bb7` the `case 'tree'` arm resolved + * `labelField: treeCfg.labelField || treeCfg.titleField || 'name'` (`:3927`); + * at `20c6d351a` it reads `labelField: treeCfg.labelField || 'name'` + * (`ListView.tsx:3985`) and emits no `titleField`. The renderer half went + * earlier: `getTreeConfig`'s `?? schema.titleField` rung — the `:117` this + * record used to cite — was removed on objectui#8841 because it read the + * FLATTENED NODE and never the block, and the docblock at * `ObjectTree.tsx:240-261` records the three measurements that retired it. - * The flatten half above is what keeps the key in this set; ⛔ do not restore - * the renderer half from this record's history. + * This paragraph is the correction the 2026-10-10 re-read above reported; + * ⛔ restore neither rung from this record's history. + * + * So `titleField` is in this set for its prescription, not for a read: an + * author who writes a flat `titleField` on an `object-tree` is naming the + * tree's label field, and the one key that does that is `tree.labelField`, + * which the prescription below says. Keeping or dropping `titleField` from + * the set is a schema decision this record does not make. */ const OBJECT_TREE_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ ...COMPONENT_LEVEL_GUIDANCE, @@ -8634,8 +8640,8 @@ const OBJECT_TREE_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ prescription: 'Write this as a key of the `tree` config object instead — `tree: { parentField, labelField, fields, ' + 'defaultExpandedDepth }`. The flat top-level spelling is the internal form `ObjectView`/`ListView` ' - + 'produce when they flatten `options.tree`, not a second authoring spelling. A `titleField` is the ' - + "block's `labelField`: it is only ever read as that key's last fallback.", + + 'produce when they flatten `options.tree`, not a second authoring spelling. A `titleField` names the ' + + "tree's label field, which the `tree` block spells `labelField`: write `tree: { labelField }`.", }, ];