From 9e547c26430ae8b24e15c1726463c953a5ee8a48 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 01:21:26 +0000 Subject: [PATCH 1/6] wip(spec): retire the page action row's own patch; the row's update runs its named action Claude-Session: https://claude.ai/code/session_01WnW7HvfnfHbLg1fpkHX3Mu Co-authored-by: Claude --- .../spec/src/ui/action-declarative-update.ts | 68 ++++++++--- packages/spec/src/ui/action.zod.ts | 2 +- packages/spec/src/ui/component.zod.ts | 112 ++++++++++++------ 3 files changed, 126 insertions(+), 56 deletions(-) diff --git a/packages/spec/src/ui/action-declarative-update.ts b/packages/spec/src/ui/action-declarative-update.ts index 29522057ef7..92ed6457ac8 100644 --- a/packages/spec/src/ui/action-declarative-update.ts +++ b/packages/spec/src/ui/action-declarative-update.ts @@ -23,7 +23,9 @@ import { onSuccessBesideUpdateOperationRefusal } from './action-on-success-execu * read. The rules, in the order the issues are raised: * * 1. `patch` without `operation: 'update'` is refused (it would be silently - * dropped — ADR-0078). + * dropped — ADR-0078). An action's rule only: a page action row has no + * `patch` of its own ({@link DeclarativeUpdateSurface.writesNamedAction}), + * so its tombstone refuses the key whatever `operation` says. * 2. Beside `operation: 'update'`, an executor other than `'script'` — the * route the write is posted to — is refused at the executor key * ({@link updateOperationExecutorRefusal}). An explicit `'script'` names @@ -33,7 +35,10 @@ import { onSuccessBesideUpdateOperationRefusal } from './action-on-success-execu * asymmetry). * 3. Every key that names or shapes a dispatch the update does not make is * refused at its own path ({@link DECLARATIVE_UPDATE_REFUSED_KEYS}). - * 4. `operation: 'update'` with nothing to write is refused at `patch`. + * 4. `operation: 'update'` with nothing to write is refused. On an action that + * is no `patch` and no non-empty `params`, refused at `patch`. On a page + * action row it is no `name`, refused at `name`: the row runs the declared + * action its `name` names, and the route writes THAT action's `patch`. * 5. A `list_toolbar` location is refused: no current record there. * * Deliberately NOT judged here: `objectName`. `ActionSchema` parses an action @@ -46,9 +51,10 @@ import { onSuccessBesideUpdateOperationRefusal } from './action-on-success-execu * What differs between an action and a row is only what a * {@link DeclarativeUpdateSurface} says: the key that spells the executor, * whether that key has a default, the subject the `onSuccess` refusal opens - * with, and whether `params` has an object form the write reads. Every rule and - * every text is the same function of it. On `ActionSchema`'s surface each text - * is byte-for-byte the action's own. + * with, and whether the write is the surface's own `patch` or the `patch` of + * the declared action the surface names. Every rule and every text is the same + * function of it. On `ActionSchema`'s surface each text is byte-for-byte the + * action's own. * * Why a module of its own: `action-on-success-executor.ts`'s reason. The two * schema files must apply the SAME judge; `component.zod.ts` already imports @@ -72,15 +78,24 @@ export interface DeclarativeUpdateSurface { /** Opens the refusal of `onSuccess` beside the operation ({@link onSuccessBesideUpdateOperationRefusal}). */ readonly onSuccessSubject: string; /** - * `true` where a non-empty OBJECT `params` holds static field values the - * write merges in, besides the array the dialog collects: an `action:button` - * / `action:icon` node, whose `params` IS `properties.params`, the static - * values the renderer forwards (`readStaticParamValues`) and the runner's - * update merges OVER `patch` (`executeUpdateOperation`). An action's - * `params` is the input list only, and so is a container member's, whose - * object `params` never reaches a non-`api` executor. + * `true` on a page action row (`action:button`, `action:icon`, an + * `action:group` / `action:menu` member): its `operation: 'update'` runs the + * declared action its `name` names. The console's runner posts the row's + * collected values to the platform action route under that name, and the + * route resolves the declaration by name (ADR-0110 D2) and writes that + * declaration's `patch`, merged under the values. So the row carries no + * `patch` of its own (its tombstone refuses one), and what the row has to + * write is that action, named: a row with no `name`, or an empty one, has + * nothing to write, and the runner's server dispatch refuses it before any + * request. A row's `params` are no write of its own either. An array is the + * input list the dialog collects. A node's static object `params` ride the + * request as params, which the route writes only where the named action + * declares them, and otherwise refuses with a 400 (ADR-0104 D2). + * + * `false` on `ActionSchema`: the action IS the declaration, so its write is + * its own `patch` and its collected `params`. */ - readonly staticParams: boolean; + readonly writesNamedAction: boolean; } /** `` `type: 'api'` `` on an action or a member, `` `actionType: 'api'` `` on a page node. */ @@ -162,12 +177,14 @@ const DECLARATIVE_UPDATE_REFUSED_KEYS: ReadonlyArray< + 'has no such flow. Drop it.'], ]; -/** Whether `params` gives the write field values on `surface` (rule 4). */ -function paramsWrite(surface: DeclarativeUpdateSurface, params: unknown): boolean { - if (Array.isArray(params)) return params.length > 0; - return surface.staticParams && params !== null && typeof params === 'object' && Object.keys(params).length > 0; +/** Whether an action's `params` gives its write field values (rule 4): a non-empty input list. */ +function paramsWrite(params: unknown): boolean { + return Array.isArray(params) && params.length > 0; } +/** Whether a row names the declared action its write is (rule 4 on a row): a non-empty `name`. */ +const namesAction = (name: unknown): boolean => typeof name === 'string' && name !== ''; + /** * The declarative-update judge for `surface`: the five rules above, as one * refinement. `ActionSchema` and each page action row pass it to @@ -176,7 +193,8 @@ function paramsWrite(surface: DeclarativeUpdateSurface, params: unknown): boolea export function refuseDeclarativeUpdateContradictions(surface: DeclarativeUpdateSurface) { return (data: Record, ctx: z.core.$RefinementCtx): void => { if (data.operation !== 'update') { - if (data.patch !== undefined) { + // A row's `patch` is its tombstone's to refuse, whatever `operation` says. + if (!surface.writesNamedAction && data.patch !== undefined) { ctx.addIssue({ code: 'custom', path: ['patch'], @@ -206,7 +224,19 @@ export function refuseDeclarativeUpdateContradictions(surface: DeclarativeUpdate } } - if (data.patch === undefined && !paramsWrite(surface, data.params)) { + if (surface.writesNamedAction) { + if (!namesAction(data.name)) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: + "A page action row's `operation: 'update'` runs the declared action its `name` names — the platform " + + 'action route resolves that action by name and writes its `patch` — so a row with no `name` has nothing ' + + 'to write, and the runner refuses it before any request. Set `name` to the declared ' + + "`operation: 'update'` action whose `patch` this row should write.", + }); + } + } else if (data.patch === undefined && !paramsWrite(data.params)) { ctx.addIssue({ code: 'custom', path: ['patch'], diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 2364ed68f73..1eecae05f2c 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1833,7 +1833,7 @@ const ACTION_DECLARATIVE_UPDATE: DeclarativeUpdateSurface = { executorKey: 'type', executorDefaultsToScript: true, onSuccessSubject: "An `operation: 'update'` action's `onSuccess`", - staticParams: false, + writesNamedAction: false, }; /** diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 6b7722a5965..754fd8346ef 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3750,19 +3750,40 @@ const actionOnSuccessBlock = () => ActionSchema.in.shape.onSuccess.unwrap(); const actionOperation = () => ActionSchema.in.shape.operation.unwrap(); /** - * A page action row's `patch`: `ActionSchema`'s own record of static field - * values, by reference, read off the object half of the action's pipe as - * {@link actionOperation} reads `operation`. ⛔ No second shape. - * - * Every row forwards `patch` to the runner beside `operation`, and the runner's - * update merges it only when it is a plain object (`executeUpdateOperation`: - * anything else becomes `{}`), as the platform - * route's write bag does (`declarativeUpdateWrite`). Until this, the rows - * declared `z.unknown()`, so a string, array or number `patch` parsed clean and - * was dropped before the write; and the declarative-update judge reads a - * present `patch` as something to write, which holds only for a record. - */ -const actionPatch = () => ActionSchema.in.shape.patch.unwrap(); + * [#24394] A page action row's `patch` — REMOVED, a `retiredKey()` tombstone + * on `action:button`, `action:icon` and each `action:group` / `action:menu` + * member (ADR-0049 enforce-or-remove; ADR-0087 D2). + * + * Two implementations of one write disagreed, and the governed side wins. A + * row's `operation: 'update'` posts through the runner's server dispatch to the + * platform action route under the row's `name`. The route resolves THAT + * declaration by name (ADR-0110 D2, "resolve, then address: declaration + * first") and writes the declaration's own `patch`, merged under the request's + * params (`declarativeUpdateWrite`). It judges those params against the + * declaration (ADR-0104 D2). The row's `patch` reached no request. objectui's + * `executeUpdateOperation` posts the collected values alone ("⛔ never the + * request: the route applies `patch` from its declaration"), and + * `createServerActionHandler` posts `{ recordId, params }`; the row's `patch` + * fed the Undo snapshot only (objectui `0707b4ae37` and `main` `4b7ce47`). So + * the rows declared a write the platform never performs. Until this, a row + * took `ActionSchema`'s own `patch` record by reference. + * + * What a row writes now is the declared action it names, and the judge refuses + * a row with `operation: 'update'` and no `name` ({@link rowDeclarativeUpdate}). + * `ActionSchema.patch` itself is untouched. Census before the change: no page + * action row authors `patch` in this repository's examples or apps, and + * objectui (pin and `main`) authors none. Its page designer has no editor for + * an action row's keys, and its registry's `patch` input is an advertisement, + * not an authored instance. Stored and built pages are stripped by the D2 + * conversion `page-action-row-patch-removed`. + */ +const rowPatchRetired = (owner: string) => retiredKey( + `${owner} \`patch\` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's ` + + "`operation: 'update'` runs the declared action its `name` names, and the platform action route resolves " + + "that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. " + + "Move the values into the `patch` of the action the row's `name` names, then delete the key. " + + '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 console runner's built-in executors: `ActionType`'s six, the members its @@ -3835,15 +3856,19 @@ function refuseRowOnSuccessBesideExecutor(executorKey: 'actionType' | 'type', ow /** * `ActionSchema`'s declarative-update rules on a page action row: the one judge * both read (`action-declarative-update.ts`), on the row's surface. ⛔ No - * second copy of a rule or a text. Every row forwards `operation`, `patch`, + * second copy of a rule or a text. Every row forwards `operation`, `name`, * `params`, `target`, `method`, `bodyExtra`, `bodyShape`, `locations` and * `onSuccess` to the console's action runner (and `action:button` its * `recordIdField`), and the runner reads `operation` before any executor: the - * write is posted through its `script` dispatch, so beside it the row's - * executor and the keys that bind one are never read. So each rule lands on the - * row at the same path, with the action's text: - * - * - `patch` without `operation: 'update'` — refused at `patch`; + * write is posted through its `script` dispatch to the platform action route + * under the row's `name`, so beside it the row's executor and the keys that + * bind one are never read. So each rule lands on the row at the same path, + * with the action's text, except the two the row's surface re-trues + * (`writesNamedAction`: the route writes the named declaration's `patch`): + * + * - a row has no `patch` of its own ({@link rowPatchRetired}): its tombstone + * refuses one whatever `operation` says, so the action's "`patch` without + * `operation: 'update'`" rule does not run here; * - beside it, an executor key other than `'script'` — refused at that key * (`actionType` on a node, `type` on a member). The row's key has no default, * so the text says it names that route or nothing; an empty key names @@ -3852,11 +3877,18 @@ function refuseRowOnSuccessBesideExecutor(executorKey: 'actionType' | 'type', ow * (`action:button` only) `recordIdField` — each refused at its own path. The * table's other keys are not row keys, so the strict row refuses them as * unknown before this runs; - * - beside it, nothing to write — refused at `patch`. A node's non-empty - * OBJECT `params` counts: on `action:button` / `action:icon` it IS - * `properties.params`, whose static values the runner's update merges over - * `patch`. A member's does not (refused off `api` by - * {@link actionContainerMemberParamsFitType}, and never forwarded); + * - beside it, nothing to write — on a row that is no `name` (or an empty + * one), refused at `name`. The row's write is the `patch` of the declared + * action its `name` names. Without one the runner's server dispatch refuses + * the row before any request, and the route has no declaration to resolve. + * A row's `params` are no write of its own. An array is the input list. A + * node's static object `params` (`properties.params` on `action:button` / + * `action:icon`) ride the request as params, which the route writes only + * where the named action declares them and otherwise refuses with a 400 + * (ADR-0104 D2). A member's object `params` is refused off `api` by + * {@link actionContainerMemberParamsFitType}. Whether the named action + * exists, is an `operation: 'update'` action and declares those params is a + * cross-reference this row cannot see; * - beside it, a `list_toolbar` location — refused at `locations.N`. * * Applied first on each row, as the runner reads `operation` first: the row's @@ -3870,7 +3902,7 @@ function rowDeclarativeUpdate(executorKey: 'actionType' | 'type', owner: string) executorKey, executorDefaultsToScript: false, onSuccessSubject: `${owner} \`onSuccess\` (\`operation: 'update'\`)`, - staticParams: executorKey === 'actionType', + writesNamedAction: true, }); } @@ -3918,6 +3950,9 @@ const ACTION_NODE_GUIDANCE = { * `successMessage`, `errorMessage`, `refreshAfter`, `undoable`, * `recordIdField`, `locations`, `toast`, `resultDialog`, `onSuccess`, * `objectName` — and, since objectui#11344, `outcomeMessages`. + * `patch` is forwarded and RETIRED here ({@link rowPatchRetired}): the + * runner reads it for its Undo snapshot only, and the route writes the + * `patch` of the declared action the row's `name` names. * * ⚠️ NOT a key of this row: objectui#11344 forwards `outcomeMessages`, the * success copy per handler outcome that `ActionSchema` declares, because an @@ -3956,7 +3991,7 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ guidance: ACTION_NODE_GUIDANCE, }, { name: z.string().optional() - .describe('Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone. With no `actionType`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names'), + .describe('Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone. With no `actionType`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names. Beside `operation: \'update\'` it is required: it names the declared action the platform action route runs, whose `patch` is the write'), label: z.string().optional() .describe('Button text. A literal string, placed as-is — localize through the translation bundle entry for this component id, not an inline locale map'), icon: z.string().optional() @@ -3972,7 +4007,7 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ disabled: actionCondition().optional() .describe('Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the button is shown but cannot be pressed while it is TRUE. Omit for never-disabled'), params: z.unknown().optional() - .describe('Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs; an object is forwarded as the static parameter values'), + .describe('Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs; an object is forwarded as the static parameter values. Beside `operation: \'update\'` both ride the request as params: the route writes them only where the action the row\'s `name` names declares them, and refuses any other'), description: z.string().optional() .describe('Action description, forwarded to the runner — the parameter dialog shows it under its title'), target: z.string().optional() @@ -3982,8 +4017,8 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), bodyExtra: z.unknown().optional().describe('Static request-body fields for an `api` action, forwarded to the runner'), bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), - operation: actionOperation().optional().describe('Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`\'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape`, `recordIdField` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location'), - patch: actionPatch().optional().describe('Static field values the declarative `operation: \'update\'` writes, forwarded to the runner: `ActionSchema`\'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: \'update\'` (it would be silently dropped)'), + operation: actionOperation().optional().describe('Declarative single-record write: `ActionSchema`\'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row\'s values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row\'s `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape`, `recordIdField` and `onSuccess` are refused, and `list_toolbar` is not a location'), + patch: rowPatchRetired("An `action:button`'s"), confirmText: z.string().optional().describe('Confirmation question asked before the action runs'), successMessage: z.string().optional().describe('Toast shown when the action succeeds'), errorMessage: z.string().optional().describe('Toast shown when the action fails, in place of the raw error'), @@ -4036,6 +4071,8 @@ export type ActionButtonPropsParsed = z.infer; * `bodyExtra`, `bodyShape`, `operation`, `patch`, `confirmText`, * `successMessage`, `errorMessage`, `refreshAfter`, `locations`, `toast`, * `resultDialog`, `onSuccess`, `objectName`. + * - `patch` is forwarded and RETIRED, for `action:button`'s reason + * ({@link rowPatchRetired}). * - `outcomeMessages` is forwarded too since objectui#11344 and NOT declared, * for `action:button`'s reason: it is an `ActionSchema` key that reaches this * renderer on the `action:bar` member path, and objectui leaves its input @@ -4056,7 +4093,7 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ }, }, { name: z.string().optional() - .describe('Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button`. With no `actionType`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names'), + .describe('Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button`. With no `actionType`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names. Beside `operation: \'update\'` it is required: it names the declared action the platform action route runs, whose `patch` is the write'), label: z.string().optional() .describe('Accessible label and tooltip text (and its first letter stands in when no icon resolves). A literal string — localize through the translation bundle entry for this component id'), icon: z.string().optional() @@ -4072,7 +4109,7 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ description: z.string().optional() .describe('Action description — the tooltip text when there is no `label`, and forwarded to the runner for the parameter dialog'), params: z.unknown().optional() - .describe('Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user; an object is forwarded as the static parameter values'), + .describe('Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user; an object is forwarded as the static parameter values. Beside `operation: \'update\'` both ride the request as params: the route writes them only where the action the row\'s `name` names declares them, and refuses any other'), target: z.string().optional() .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: \'url\'`, or no or an empty `actionType` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused'), openIn: z.enum(['self', 'new-tab']).optional() @@ -4080,8 +4117,8 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), bodyExtra: z.unknown().optional().describe('Static request-body fields for an `api` action, forwarded to the runner'), bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), - operation: actionOperation().optional().describe('Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`\'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location'), - patch: actionPatch().optional().describe('Static field values the declarative `operation: \'update\'` writes, forwarded to the runner: `ActionSchema`\'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: \'update\'` (it would be silently dropped)'), + operation: actionOperation().optional().describe('Declarative single-record write: `ActionSchema`\'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row\'s values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row\'s `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location'), + patch: rowPatchRetired("An `action:icon`'s"), confirmText: z.string().optional().describe('Confirmation question asked before the action runs'), successMessage: z.string().optional().describe('Toast shown when the action succeeds'), errorMessage: z.string().optional().describe('Toast shown when the action fails, in place of the raw error'), @@ -4160,6 +4197,9 @@ export type ActionIconPropsParsed = z.infer; * * - `outcomeMessages` (forwarded since objectui#11344): undeclared on all four * action blocks, as one decision, until an inline writer appears; + * - `patch` (forwarded): RETIRED on every page action row + * ({@link rowPatchRetired}), since the route writes the named action's own + * `patch`; * - a member `className` and the member's own `properties.params` bag of * static values (`readMemberStaticParamValues`); * - `endpoint` (forwarded): read by no console `api` handler, refused onto @@ -4201,7 +4241,7 @@ export type ActionIconPropsParsed = z.infer; function actionContainerMemberShape() { return { name: z.string().optional() - .describe('Action name, forwarded to the action runner, and the item\'s text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names'), + .describe('Action name, forwarded to the action runner, and the item\'s text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names. Beside `operation: \'update\'` it is required: it names the declared action the platform action route runs, whose `patch` is the write'), label: z.string().optional() .describe('The item\'s text. A literal string, placed as-is — localize through the translation bundle entry for this component id'), icon: z.string().optional() @@ -4227,8 +4267,8 @@ function actionContainerMemberShape() { method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), bodyExtra: z.unknown().optional().describe('Static request-body fields for an `api` action, forwarded to the runner'), bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), - operation: actionOperation().optional().describe('Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`\'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location'), - patch: actionPatch().optional().describe('Static field values the declarative `operation: \'update\'` writes, forwarded to the runner: `ActionSchema`\'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: \'update\'` (it would be silently dropped)'), + operation: actionOperation().optional().describe('Declarative single-record write: `ActionSchema`\'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row\'s values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row\'s `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location'), + patch: rowPatchRetired('An `action:group` or `action:menu` member\'s'), confirmText: z.string().optional().describe('Confirmation question asked before the action runs'), successMessage: z.string().optional().describe('Toast shown when the action succeeds'), errorMessage: z.string().optional().describe('Toast shown when the action fails, in place of the raw error'), From 895a9ae36fd7ba1e9d387fcbaf90a4b7b99f54ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 01:28:48 +0000 Subject: [PATCH 2/6] wip(spec): register the row patch retirement (D2 conversion, retired keys, D3 entry, step-18 rationale) Claude-Session: https://claude.ai/code/session_01WnW7HvfnfHbLg1fpkHX3Mu Co-authored-by: Claude --- packages/spec/liveness/page.json | 2 +- packages/spec/src/conversions/registry.ts | 185 ++++++++++++++++++ .../18.ui__ActionButtonProps__patch.ts | 15 ++ .../18.ui__ActionIconProps__patch.ts | 9 + .../18.ui-action-group-menu-members-typed.ts | 2 +- ...age-action-row-declarative-update-rules.ts | 20 +- ...8.ui-page-action-row-operation-declared.ts | 4 +- .../18.ui-page-action-row-patch-retired.ts | 45 +++++ .../spec/src/migrations/registry.ts.template | 15 ++ 9 files changed, 286 insertions(+), 11 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ActionButtonProps__patch.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ActionIconProps__patch.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-patch-retired.ts diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index fa4ae5a8f7b..26d4f08ec8c 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -120,7 +120,7 @@ "verifiedAt": "2026-10-15", "evidenceScope": "cross-repo", "evidence": "objectui @0656b0e3c0 packages/components/src/renderers/layout/page.tsx#RegionLayout resolves the named slots (header/sidebar/main/aside/footer, by #findRegion, case-insensitive) and appends the remainder below main (#getRemainingRegions); #HeaderSidebarMainTemplate places header/sidebar/main, #ThreeColumnTemplate the five, #DashboardTemplate header/footer and grids the rest — re-read 2026-10-15. The spec declares the same slots per template (PAGE_TEMPLATE_SLOTS) and refuses a region its template does not place (checkPageRegionPlacement, #23610)", - "note": "region → component tree rendering (header/main/sidebar/footer). RE-CITED 2026-08-10 (#7133/#7142): same split-citation pruning as `template` — at objectui @11c1e71e the containers.tsx half names `regions` only in a comment (:798, 'page:section — thin wrapper used inside regions'). Verdict unchanged: LIVE. Component-level liveness stays unclassified (undrilled container, #4956); one component key carries a verdict OUTSIDE this file: `components[].responsive` is a retiredKey tombstone (#11027, ADR-0049 D2 — dead on a two-repo measurement; see the file _note). COMPONENT-MEMBER READ POINTS (moved out of packages/spec/src, #23434; ruling 6165388570 letter C, Q1 A as the seat read it): an EVIDENCE LIST only — no verdict is asserted, so component-level liveness stays unclassified as above. Each entry names the ComponentPropsMap row and member it reads, then its read points in the spelling `objectui @SHA FILE#SYMBOL` (the SHA is the objectui tree the point was last read in; a pin hop moves nothing here, and a renamed or deleted symbol is the event that re-measures it). Inside one entry, a letter defined at its head abbreviates a repeated file: with `P = objectui @SHA FILE`, `P#a P#b` reads `objectui @SHA FILE#a`, `objectui @SHA FILE#b`. Entries, in source order: [1] page:header (H = objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx#PageHeaderRenderer): title (`schema?.title ?? schema?.properties?.title`), the refused `icon` (resolved only per header action) and `breadcrumb` (an empty slot), `actions` (resolvedHeaderActions then headerActionPlan), recordChrome (`=== false` on either carrier, branching the header), the refused `showStar` (the record chip draws its star only where the host wires `onToggleFavorite`, and no reference host does; retired #23650) and showCopyId (the record chip), maxVisible and mobileMaxVisible (`?? 3` / `?? 1`), all in H; re-read 2026-10-15, first cited sha-less; [2] page:tabs (T = objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx#PageTabsRenderer): tabStyle (`schema?.properties?.type || schema?.tabStyle || 'line'`), alwaysShowStrip (`schema?.alwaysShowStrip === true || schema?.properties?.alwaysShowStrip === true`), items[].value (`it.value`, falling back to `tab-${idx}`; the refused `key` is the designer's `page:tabs.items.itemFields` input in objectui @b9814b334 packages/app-shell/src/views/metadata-admin/previews/block-config.ts#BLOCK_CONFIG), items[].label (`translateLabel(pickLocalized(it.label, language), language, ttLabel)`: the inline locale map first, then the bundle, the order packages/spec/src/ui/i18n-label-resolver.ts mirrors, whose module docblock cited it by line, sha-less, until #23434; objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx#PageAccordionRenderer composes an accordion item's label the same way), all in T; the retired props key `type` (the reason for the rename): left unhoisted by objectui @b9814b334 packages/react/src/SchemaRenderer.tsx#HOIST_PROTECTED_KEYS, and skipped as a base prop by objectui @b9814b334 packages/sdui-parser/src/validate.ts#BASE_PROPS; re-read 2026-10-15, first cited sha-less; [3] page:tabs items[].icon (C = objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx): C#triggerClass (`LazyIcon` inside the TabsTrigger) in C#PageTabsRenderer; published by its registration's `items` input; vocabulary objectui @b9814b334 packages/components/src/lib/lazy-icon.tsx#getLazyIcon; [4] page:tabs items[].visibleWhen (the tab-item evaluator: `data` is the row, bare fields spread flat, ambient roots after it): objectui @1fb1ced36243 packages/components/src/renderers/layout/containers.tsx#isItemVisible (its ExpressionEvaluator) in PageTabsRenderer; [5] record:details / record:highlights / record:related_list / record:quick_actions .requiredPermissions (RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION; R = objectui @b9814b334 packages/plugin-detail/src/renderers/, P = objectui @b9814b334 packages/permissions/src/): the gate `perms.hasCapabilities(required)` and the notice in R record-details.tsx#RecordDetailsBody, R record-highlights.tsx#highlightsAllowed (RecordHighlightsBody), R record-related-list.tsx#RecordRelatedListBody, R record-quick-actions.tsx#RecordQuickActionsBody; earlier withholding: no record R record-details.tsx#RecordDetailsBody, no object and the related read gate R record-related-list.tsx#RecordRelatedListBody; a gated highlights registers no fields R record-highlights.tsx#RecordHighlightsBody (useRegisterHighlightFields); ALL OF THEM and fail-open on unreported P MePermissionsProvider.tsx#hasCapabilities; fail-open P PermissionProvider.tsx#ALL_CAPABILITIES and P usePermissions.ts#NO_PROVIDER_PERMISSIONS; the binding wrapper (objectui#12294) objectui @b9814b334 packages/plugin-detail/src/renderers/recordBinding.tsx#recordBindingBlock; [6] record:details sections[].headerColor (closed enum, #12126): the six class literals of objectui @b9814b334 packages/plugin-detail/src/headerColor.ts#headerColorClass (objectui#6294, merged 2026-08-25); re-read 2026-10-15, first cited sha-less; [7] record:details sections[] hideEmpty, collapsible, showBorder and the #11661 trio (renderer defaults measured; the trio at objectui @190fbd01): objectui @190fbd01 packages/plugin-detail/src/renderers/record-details.tsx#RecordDetailsRenderer and objectui @190fbd01 packages/plugin-detail/src/DetailSection.tsx#DetailSection (`hideEmpty ?? true`; the undeclared `s.title ?? s.label` limb); [8] record:related_list.parentDataSource (the name): the sibling record blocks' binding read objectui @f0532d8a packages/plugin-detail/src/renderers/recordBinding.tsx#recordBindingBlock (`isElementDataSourceConfig(schema.dataSource)`), measured on objectui main; [9] record:details sections[].count (read since objectui c08c9332a, objectui#12192): objectui @b9814b334 packages/plugin-detail/src/renderers/record-details.tsx#countField (inside filteredSections; fills every `{{ count }}` hole); [10] record:related_list.parentDataSource (PLANNED): the objectui measurement is the file _note's (objectui @f0532d8a, the record context read in RecordRelatedListBody); no new evidence here; [11] record:activity guidance `items` / `loading` (host feed slot; R = objectui @89cad75d5570 packages/plugin-detail/src/renderers/): R record-activity.tsx#hostItems and R record-activity.tsx#loading in RecordActivityRenderer; record:chatter / record:discussion `feed` reads neither, R record-chatter.tsx#RecordChatterRenderer; [12] record:chatter.position (vocabulary bottom / right / left, #8762): objectui @665661ab0932 packages/plugin-detail/src/RecordChatterPanel.tsx#position (`right`/`left` dock vs `bottom` in-flow); the designer's CHATTER_INPUTS and the merge fallback in objectui @665661ab0932 packages/plugin-detail/src/renderers/record-chatter.tsx#RecordChatterRenderer; [13] record:reference_rail (RecordReferenceRailProps): objectui @b9814b334 packages/plugin-detail/src/renderers/record-reference-rail.tsx#RecordReferenceRailRenderer ( an entry `icon` is unread, `title` a raw React child); the synthesizer emitting `icon` objectui @b9814b334 packages/plugin-detail/src/synth/buildDefaultPageSchema.ts#buildDefaultPageSchema; re-read 2026-10-15, first cited sha-less; [14] record:alert, record:quick_actions, record:history: objectui @b9814b334 packages/plugin-detail/src/renderers/record-alert.tsx#RecordAlertRenderer, objectui @b9814b334 packages/plugin-detail/src/renderers/record-quick-actions.tsx#RecordQuickActionsRenderer, objectui @b9814b334 packages/plugin-detail/src/renderers/record-history.tsx#RecordHistoryRenderer and objectui @b9814b334 packages/plugin-detail/src/HistoryTimeline.tsx#HistoryTimeline (each renderer's ACTUAL reads, against its registration's declared inputs); re-read 2026-10-15, first cited sha-less; [15] record:line_items (RecordLineItemsProps; L = objectui @31971ff1e28f packages/plugin-form/src/LineItemsPanel.tsx): registered as `line_items` (namespace `record`) in objectui @31971ff1e28f packages/plugin-form/src/index.tsx with its mapping objectui @31971ff1e28f packages/plugin-form/src/index.tsx#RECORD_LINE_ITEMS_DATA_SOURCE; the fifteen `schema.KEY` reads in L#LineItemsPanel (childObject L#heldReloadRef; parentObject L#parentObject; parentId, recordId L#parentId; amountField, totalField L#parentPatch; filter L#scopeFilter; limit L#message; columns L#gridField (applyColumnPermissions)); the gate objectui @31971ff1e28f packages/react/src/element-data-source/ElementDataSourceGate.tsx#useElementDataSourceSchema; read set unchanged at objectui main d59f11c0d3dc; [16] record:line_items guidance (addLabel, sortField, onRowExpand not handed to the grid): objectui @9dfaca654311 packages/plugin-form/src/LineItemsPanel.tsx#gridField; [17] record:approvals (RecordApprovalsProps, EMPTY key set; A = objectui @47b1f0bb71 packages/app-shell/src/views/): registered as `approvals` (namespace `record`) in A record-approvals-renderer.tsx#RecordApprovalsRenderer; approvals A record-approvals-renderer.tsx#hostApprovals, threaded by A RecordDetailView.tsx#RecordDetailView and emitted by objectui @47b1f0bb71 packages/plugin-detail/src/synth/buildDefaultPageSchema.ts#buildDefaultPageSchema; currentUserId falls back in A RecordApprovalsPanel.tsx#isSubmitter; Studio page create seeds without options in A metadata-admin/anchors.ts#synth; [18] page:accordion items[].value (refused): objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx#PageAccordionRenderer overwrites it with `panel-${idx}`; the designer input `page:accordion.items.itemFields` in objectui @b9814b334 packages/app-shell/src/views/metadata-admin/previews/block-config.ts#BLOCK_CONFIG (objectui#7973); re-read 2026-10-15, first cited sha-less; [19] page:accordion items[].icon (C = objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx): C#commonChildren (`LazyIcon` in the AccordionTrigger) inside C#PageAccordionRenderer; published by its `accordion` registration's `items` input; vocabulary objectui @b9814b334 packages/components/src/lib/lazy-icon.tsx#getLazyIcon; [20] page:accordion.variant: objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx#PageAccordionRenderer (`schema?.variant ?? schema?.properties?.variant ?? 'flush'`); re-read 2026-10-15, first cited sha-less; [21] element:text.variant retired `heading` / `subheading`: objectui @89cad75d55 packages/components/src/renderers/basic/elements.tsx#VARIANT_TAG; [22] element:button.icon (B = objectui @b9814b334 packages/components/src/renderers/form/button.tsx, R = objectui @b9814b334 packages/components/src/renderers/action/resolve-icon.ts): B#Icon (resolveIcon) and its render on either side of the label in B#ButtonRenderer; R#resolveIcon R#describeIconLookup R#toPascalCase R#iconNameMap R#recordIconName (the vocabulary: the generated objectui @b9814b334 packages/components/src/lib/lucide-record-icon-names.ts#LUCIDE_RECORD_ICON_NAME_TABLE); contrast objectui @b9814b334 packages/components/src/lib/lazy-icon.tsx#getLazyIcon; no `icon` in the registration's inputs inside B#ButtonRenderer (objectui#9910 added the `children` slot input); history: the read point moved files on the hop onto objectui 9602dc820, and the registration ranges were first wrong-since-written at objectui a472b0716; [23] action:* rows, the effective executor and the runner's reads (R = objectui @b9814b334 packages/core/src/actions/ActionRunner.ts, first read at objectui @1fb1ced36243 with every anchor at the same symbols; A = objectui @b9814b334 packages/components/src/renderers/action/): executor resolution R#execute (`action.type || action.actionType || action.name`); forwarded executor A action-button.tsx#forwarded, A action-icon.tsx#forwarded (`type: schema.actionType`), A action-group.tsx#paramsPayload, A action-menu.tsx#paramsPayload (`type: action.type`); the name leg documented in A action-button.tsx#forwarded; built-in executors and the `navigation` alias R#ActionRunner (`builtinExecutors`), url navigation executeUrl / executeNavigation / navigateTo in R#ActionRunner; no console host handler under `url` / `navigation`: objectui @b9814b334 packages/app-shell/src/console/ConsoleShell.tsx, packages/app-shell/src/hooks/useConsoleActionRuntime.tsx, packages/app-shell/src/console/AppContent.tsx, packages/app-shell/src/hooks/useObjectActions.ts; onSuccess R#handlePostExecution R#readOnSuccessNavigation; operation read before any executor R#execute (isUpdateOperationAction), forwarded A action-button.tsx#forwarded; patch merged only when a plain object (executeUpdateOperation in R#ActionRunner); unchanged at objectui main 866763bd and 72594f29; [24] section 4b, the action:* quartet and element:* lists (shared reads; A = objectui @b9814b334 packages/components/src/renderers/action/): publication objectui @b9814b334 packages/core/src/registry/public-blocks.ts#PUBLIC_BLOCKS; `autoTrigger` \"NOT persisted metadata\" in the header of A auto-trigger.ts#useAutoTriggerOnce; the Button primitive vocabulary objectui @b9814b334 packages/components/src/ui/button.tsx#buttonVariants; `objectName` forwarded by A action-button.tsx#forwarded and A action-icon.tsx#forwarded, and per member by A action-group.tsx#paramsPayload and A action-menu.tsx#paramsPayload; history: objectui#11168 slices 1-2, objectui#11212 (fail-closed `visible`), objectui#11182 (host `disabled` verdict), objectui#11344 (`outcomeMessages`), objectui#11638 (member `properties.params` read retired), objectui#11839 (disabled reason), objectui#11880 (repeater binding), objectui#12085 (repeater publishes only the binding); [25] action:button (ActionButtonPropsSchema; B = objectui @b9814b334 packages/components/src/renderers/action/action-button.tsx; the retired gate's quoted first line `type: schema.actionType,` holds at B#forwarded): name B#isVisible (diagnostic label) B#forwarded; label B#button (React child) B#isVisible B#forwarded; icon B#Icon (resolveIcon); actionType B#forwarded (`type`); variant, size B#variant and its `size` twin; visible B#isVisible B#ActionButtonRenderer (hasDeclaredVisibilityGate); disabled B#isDisabled B#button; params B#staticValues (withUndoBaseline) and objectui @b9814b334 packages/components/src/renderers/action/static-params.ts#readStaticParamValues; the forwarded set B#forwarded (outcomeMessages forwarded since objectui#11344, left unpublished in the registration's inputs inside B#ActionButtonRenderer); undo baseline B#withUndoBaseline; [26] action:icon (ActionIconPropsSchema; I = objectui @b9814b334 packages/components/src/renderers/action/action-icon.tsx; the retired gate's quoted first line `type: schema.actionType,` holds at I#forwarded): size not read I#size (fixed `'icon'`); the forward I#forwarded (no undoable / recordIdField; outcomeMessages since objectui#11344); label I#isVisible I#forwarded I#button (aria-label, initial letter, DisabledReasonTrigger heading) I#ActionIconRenderer (tooltip); visible I#isVisible I#ActionIconRenderer; disabled I#isDisabledPred I#button; variant I#variant; params I#staticValues (readStaticParamValues); [27] action:group / action:menu members (actionContainerMember; G = objectui @2e818d0b51ec packages/components/src/renderers/action/action-group.tsx, M = objectui @2e818d0b51ec packages/components/src/renderers/action/action-menu.tsx, P = objectui @2e818d0b51ec packages/components/src/renderers/action/static-params.ts): drawn G#InlineActionButton G#DropdownActionItem M#ActionMenuItem; visible G#useMemberVisible M#useMenuActionVisible; size (group only) G#btnSize; tags G#showSeparator; locations G#actions (actionRendersAt); forwarded G#staticValues M#staticValues (the forward block); object params forwarded on `api` and dropped, with a development-build warning only, for every other `type` P#readActionEntryParamValues (first cited at objectui @0abd4f9f8, same symbol); refused: properties.params P#readMemberStaticParamValues, className G#handleClick, autoTrigger M#ActionAutoTrigger (useAutoTriggerOnce), onClick G#handleExecute M#handleExecute; the array forward (the retired gate's quoted first line `const paramsPayload: ActionDef = Array.isArray(action.params)`) objectui @b9814b334 packages/components/src/renderers/action/action-group.tsx#paramsPayload and objectui @b9814b334 packages/components/src/renderers/action/action-menu.tsx#paramsPayload, other values objectui @b9814b334 packages/components/src/renderers/action/static-params.ts#readActionEntryParamValues; the fork measured at objectui @ab1879721595, files byte-identical at objectui main 2abec3a96; [28] action:group (ActionGroupPropsSchema; G = objectui @b9814b334 packages/components/src/renderers/action/action-group.tsx): actions G#declaredActions G#actions (actionRendersAt) G#useMemberVisible G#paramsPayload; display G#display; label, icon, variant, size G#TriggerIcon (the trigger; size raw to inline members) G#btnSize; visible G#isVisible; the host `disabled` verdict (objectui#11182) G#ActionGroupRenderer; group `name` unread (the comment above G#ActionGroupRenderer, objectui#11168); the registration's inputs inside G#ActionGroupRenderer; [29] action:menu (ActionMenuPropsSchema; M = objectui @b9814b334 packages/components/src/renderers/action/action-menu.tsx): actions M#ActionMenuRenderer M#ActionMenuItem M#ActionAutoTrigger M#paramsPayload; label, icon, variant, size, visible and the host `disabled` verdict (objectui#11182) M#ActionMenuRenderer; the registration's inputs inside M#ActionMenuRenderer (objectui#11168 slice 1); [30] element:definition-list (ElementDefinitionListPropsSchema; L = objectui @b9814b334 packages/components/src/renderers/basic/data-list.tsx; the retired gate's quoted first line `const props = readProps<{` holds at L#props): items, columns (`=== 2`), inline, term, description L#DefinitionListRenderer (an item's `description` shown through L#toText: a string or number as text, an object as JSON; first cited at objectui @89cad75d55); the registration's number enum and optional `items` (objectui#11168 slice 2) at its `definition-list` registration in L; the designer's number control objectui @b9814b334 packages/app-shell/src/views/metadata-admin/previews/block-config.ts#BLOCK_CONFIG; [31] element:repeater (ElementRepeaterPropsSchema; L = objectui @b9814b334 packages/components/src/renderers/basic/data-list.tsx; the quoted first line `const props = readProps<{` holds at L#props in L#RepeaterRenderer): object, filter, sort, limit, emptyText, divided, titleField, fields L#RepeaterRenderer; the binding L#dataBinding (useElementDataSource, objectui#11880, aaba8655a); resolved filter tokens L#filterScope; delivery objectui @b9814b334 packages/data-objectstack/src/index.ts#ObjectStackAdapter (translateFilterArray arm) and objectui @b9814b334 packages/data-objectstack/src/index.ts#serializeOrderBy; the registration wrapped by elementDataSourceBlock publishes no `object` input since objectui#12085 (ce991bd70); the registration's `object` required flag read at objectui @20c6d351a; [32] object-grid.aggregations[] (GridAggregationSchema): objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx#groupHeaders (useGroupedData) and objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx#serverGroupSource (useServerGroupHeaders); the grid's own function vocabulary objectui @89cad75d55 packages/plugin-grid/src/useGroupedData.ts#AggregationType (unchanged at objectui @b9814b334); [33] object-grid (ObjectGridPropsSchema, per key; E = objectui @eb7f586b packages/plugin-grid/src/ObjectGrid.tsx): columns, fields E#schemaFields; filter E#schemaFilterSource (toFilterNode); defaultFilters E#cols (legacy fallback); sort E#schemaSort; defaultSort E#cols; pagination, pageSize, showPagination E#lastFindParamsRef E#schemaPageSize E#paginationEnabled; searchableFields, showSearch E#trimmedSearch E#searchEnabled; rowHeight E#ObjectGrid (resolveRowHeightMode); grouping, aggregations E#grouping E#groupValueFormatter; rowColor E#getRowClassName; conditionalFormatting E#predicateFields E#rules; selection, selectable E#ObjectGrid; rowActions E#rowActionsList; batchActions, bulkActions E#declaredBulkActions; bulkActionDefs E#legacyBulkNames; navigation E#navigation; editable, singleClickEdit, resizable, resizableColumns, reorderableColumns E#dataTableSchema; frozenColumns E#effectiveFrozenColumns; showColumnTypeIcons E#fieldMeta; exportOptions, operations E#declared; label, title E#fileNameFor E#dataTableSchema; data, staticData E#getDataConfig; [#20694] description objectui @db11afd4967c packages/plugin-grid/src/ObjectGrid.tsx#resolvedDescription (drawn as `gridDescription`); emptyState objectui @db11afd4967c packages/plugin-grid/src/ObjectGrid.tsx#authoredEmptyState (DataEmptyState in renderedGridContent); keyboardNavigation 0 read points at objectui @db11afd4967c, 15 hit lines at objectui @ab1879721595 and at objectui @b9814b334 against 3 then 4 for the control `schema.editable` (the fourth objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx#carriesConcurrencyToken); [34] object-grid.description, object-grid.emptyState (B = objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx): description objectui @db11afd4967c packages/plugin-grid/src/ObjectGrid.tsx#resolvedDescription; emptyState title/message B#authoredEmptyTitle (resolveInlineI18nLabel, objectui#11227 carried by 6158e4c93; raw at objectui @89cad75d5570) drawn in B#renderedGridContent, link (objectui#12270, b434f5f47) B#renderedGridContent (EmptyStateLink); [35] object-grid.columns (ListViewSchema.shape.columns; A = objectui @ab1879721595 packages/plugin-grid/src/ObjectGrid.tsx): A#normalizeColumns (first-entry dispatch) A#cols A#fieldName (resolvesToDataColumn); the retired `colOverride?.options` read: A#groupValueFormatter A#rawOptions (objectui b0bf413, objectui#11544); the footer summary's off-contract column keys objectui @ab1879721595 packages/plugin-grid/src/useColumnSummary.ts#columnHints; [36] object-grid.fields (O = objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx): O#schemaFields O#fieldsToShow O#generateColumns; the host hand-off projection O#getSelectFields; [37] object-grid.filter: objectui @53ded82b packages/plugin-grid/src/ObjectGrid.tsx#ObjectGrid (toFilterNode before `$filter`); [38] object-grid.sort (S = objectui @53ded82b packages/plugin-grid/src/ObjectGrid.tsx): S#schemaSort S#cols (the fetch path's string arm and array arm) S#declaredSort (parseSchemaSort, `TableSortItem[]`); the registration's array input objectui @53ded82b packages/plugin-grid/src/index.tsx (`{ name: 'sort', type: 'array' }`); [39] object-grid.defaultSort (REMOVED, tombstone; D = objectui @190fbd01d packages/plugin-grid/src/ObjectGrid.tsx): D#cols (`$orderby` fallback) and D#declaredSort (`[schema.defaultSort]`); [40] object-grid.pagination (D = objectui @d18322415 packages/plugin-grid/src/ObjectGrid.tsx): pageSize D#lastFindParamsRef D#schemaPageSize D#pageSize; pageSizeOptions D#dataTableSchema; presence D#paginationEnabled; the registry input objectui @d18322415 packages/plugin-grid/src/index.tsx (`pagination`, type object); [41] object-grid members read at objectui @89cad75d55 (O = objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx): rowHeight O#resolveRowHeightMode; aggregations O#groupHeaders (useGroupedData) O#serverGroupSource (useServerGroupHeaders); conditionalFormatting O#getRowStyle (resolveConditionalFormatting); rowColor O#getRowClassName (useRowColor); selection and selectable O#ObjectGrid (the selection-mode branch) O#dataTableSchema (`selectable`), read by objectui @89cad75d55 packages/components/src/renderers/complex/data-table.tsx#resolveSelectionMode; rowActions O#rowActionsDeclared; bulkActions, batchActions O#declaredBulkActions (`batchActions ?? bulkActions`); bulkActionDefs O#legacyBulkNames (resolveBulkActions); [42] object-grid.navigation: objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx#navigation (useNavigationOverlay); [43] object-grid.keyboardNavigation (built by objectui 154075ab1, objectui#11068; declared ahead of its reader, which read nothing at objectui @db11afd4967c): objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx#dataTableSchema (`schema.keyboardNavigation ?? inlineEditable`) with objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx#inlineEditable; objectui @b9814b334 packages/components/src/renderers/complex/data-table.tsx#DataTableRenderer (default false, `role=\"grid\"`, the roving tabIndex), objectui @b9814b334 packages/components/src/renderers/complex/data-table.tsx#navigationTarget and objectui @b9814b334 packages/components/src/renderers/complex/data-table.tsx#handleCellKeyDown; [44] object-grid.resizableColumns (REMOVED, tombstone): objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx#dataTableSchema (`schema.resizable ?? schema.resizableColumns ?? true`); [45] object-grid.operations (GridOperationsSchema; O = objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx): create O#dataTableSchema (showAddRow); update, delete O#wantDeleteAction; export O#handleExport O#dataTableSchema (exportable) O#exportEnabled; a declared block replaces the default O#explicitOperations; [46] object-grid.data: the registry input objectui @b9814b334 packages/plugin-grid/src/index.tsx#GRID_QUERY_INPUTS (`{ name: 'data', type: 'object' }`, held by gridDataInputContract.test.ts); re-read 2026-10-15, first cited sha-less; [47] object-metric.aggregate (ObjectMetricAggregateSchema; M = objectui @89cad75d55 packages/plugin-dashboard/src/ObjectMetricWidget.tsx): field, function M#computeOne; function also M#fieldUnitApplies (answersInFieldUnit) M#inferredFormat (min/max width); groupBy M#computeOne (objectAggregateSpecQuery; floored at `'_all'`) typed on M#ObjectMetricWidgetProps (`ChartAggregate['groupBy']`); objectui's unit-rule pin ObjectMetricWidget.countNotCurrency-10356.test.tsx mounts one tile per AggregationFunction member; [48] object-metric.trend (ObjectMetricTrendSchema; W = objectui @89cad75d55 packages/plugin-dashboard/src/): drawn in W MetricWidget.tsx#MetricWidget (`{value}%`, direction arrow), caption W MetricWidget.tsx#localizedTrendLabel (pickLocalized); declared on W MetricWidget.tsx#MetricWidgetProps, mirrored on W ObjectMetricWidget.tsx#ObjectMetricWidgetProps; replaced by a derived trend in W ObjectMetricWidget.tsx#effectiveTrend; [49] object-metric.drillDown (ObjectMetricDrillDownSchema; A = objectui @ab1879721595): enabled A packages/plugin-dashboard/src/ObjectMetricWidget.tsx#drillEnabled (isDrillEnabled); title A packages/plugin-dashboard/src/ObjectMetricWidget.tsx#drawerTitle (resolveDrillTitle against an empty event); target, columns, maxRows A packages/plugin-dashboard/src/ObjectMetricWidget.tsx#drillDrawer (DrillDownDrawer); `filter` / `mode` refused as objectui's own A packages/types/src/data-display.ts#ObjectMetricDrillDownConfig refuses them; measured writers, objectui's drill-down member pins A packages/plugin-dashboard/src/__tests__/objectMetricDrillDownMembers-8071.test.tsx and A packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.drillRoutedToSharedDrawer-8970.test.tsx; [50] object-metric.drillDown.report (R = objectui @2e818d0b51ec packages/plugin-dashboard/src/): handed verbatim in R ObjectMetricWidget.tsx#drillDrawer; drawn when R DrillDownDrawer.tsx#isDatasetBoundReport holds (R DrillDownDrawer.tsx#isReportDrill), joining the metric filter into `runtimeFilter` (R DrillDownDrawer.tsx#body); typed `SpecReportInput` in objectui @2e818d0b51ec packages/types/src/data-display.ts; the fork measured at objectui @ab1879721595, both files byte-identical at objectui main 2abec3a96; [51] object-metric.compareTo (ObjectMetricCompareToSchema; A = objectui @ab1879721595): A packages/plugin-dashboard/src/ObjectMetricWidget.tsx#comparisonFilter (second aggregate over shiftFilterByCompareTo) and A packages/plugin-dashboard/src/ObjectMetricWidget.tsx#derivedTrend (compareToTrendLabelKey); both dispatch on `kind === 'previousYear'` in A packages/core/src/utils/compare-to.ts#shiftFilterByCompareTo and A packages/core/src/utils/compare-to.ts#compareToTrendLabelKey; `dimension` \"deliberately never read on this path\" in that module's header; [52] object-metric (ObjectMetricPropsSchema): objectui @eb7f586b packages/plugin-dashboard/src/ObjectMetricWidget.tsx#ObjectMetricWidgetProps (the complete destructured read set); [53] object-metric.icon (B = objectui @b9814b334 packages/plugin-dashboard/src/): published by B index.tsx#ObjectMetricBlock (`{ name: 'icon', type: 'string' }`); destructured and forwarded by B ObjectMetricWidget.tsx#ObjectMetricWidget; resolved and drawn by B MetricWidget.tsx#MetricWidget (getLazyIcon, guarded on a string); vocabulary objectui @b9814b334 packages/components/src/lib/lazy-icon.tsx#getLazyIcon; not B MetricCard.tsx (off this key's path); [54] object-metric.filter: the analytics wire lowering objectui @53ded82b packages/data-objectstack/src/index.ts#lowerAnalyticsFilterForWire (objectui#7754); before it the adapter at objectui @a472b07 posted an array verbatim (a 400, #15828); [55] object-kanban.columns[] lane (ObjectKanbanLaneSchema; K = objectui @89cad75d55 packages/plugin-kanban/src/): id K index.tsx#bucketCardsIntoColumns; title K ObjectKanban.tsx#localizeColumn; cards K index.tsx#bucketCardsIntoColumns (kept ahead of the bucketed records); limit K KanbanImpl.tsx#isLimitExceeded; className K KanbanImpl.tsx#KanbanColumnView; collapsed K KanbanImpl.tsx#isColumnCollapsed; [56] object-kanban (ObjectKanbanPropsSchema; E = objectui @eb7f586b packages/plugin-kanban/src/ObjectKanban.tsx): columns E#effectiveColumns; filter E#fetchData (`$filter`); data E#boundData; cardTitle, titleField E#explicitTitleField; cardFields E#explicitCardFields; swimlaneField, grouping E#effectiveSwimlaneField; coverImageField, conditionalFormatting via objectui @eb7f586b packages/plugin-kanban/src/index.tsx#KanbanRenderer (the spread of the authored bag); limit objectui @b9814b334 packages/plugin-kanban/src/ObjectKanban.tsx#query (`$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_FETCH_BATCH_SIZE)`), first re-read at objectui @87af769e9 where `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` had become resolveRowLimit; [57] object-kanban.columns (the first-entry dispatch): objectui @89cad75d55 packages/plugin-kanban/src/ObjectKanban.tsx#effectiveColumns (string arm only without `groupBy`); [58] object-kanban.filter: objectui @53ded82b packages/plugin-kanban/src/ObjectKanban.tsx#ObjectKanban (`$filter: schema.filter`, verbatim, lowered by ObjectStackAdapter.convertQueryParams through translateFilterArray); [59] object-kanban.limit (B = objectui @b9814b334): query B packages/plugin-kanban/src/ObjectKanban.tsx#query (`$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_FETCH_BATCH_SIZE)`) with B packages/plugin-kanban/src/ObjectKanban.tsx#DEFAULT_KANBAN_FETCH_BATCH_SIZE and the resolved filter B packages/plugin-kanban/src/ObjectKanban.tsx#filterScope; mapping B packages/plugin-kanban/src/index.tsx#OBJECT_KANBAN_DATA_SOURCE (`limit: 'limit'`); type B packages/types/src/objectql.ts#ObjectKanbanSchema (`limit?: number`); docs B content/docs/plugins/plugin-kanban.mdx (`limit: 250` snippet and Properties row); precedence B packages/react/src/element-data-source/ElementDataSourceGate.tsx#useElementDataSourceSchema (binding cap over this key; a view's pagination.pageSize only when unset) and B packages/react/src/element-data-source/ElementDataSourceGate.tsx#isUsableRowLimit; the saved-view fallback B packages/core/src/data-scope/element-data-source.ts#savedViewRawLimit; first re-read at objectui @87af769e9 2026-09-20; [60] object-master-detail-form (ObjectMasterDetailFormPropsSchema): objectui @eb7f586b packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailForm; auto-derivation objectui @eb7f586b packages/plugin-form/src/deriveMasterDetail.ts; the `formType` pair objectui's MasterDetailFormSchema declares; [61] object-kanban.quickAdd (RETIRED, tombstone; K = objectui @b9814b334 packages/plugin-kanban/src/): K index.tsx#KanbanRenderer passes `quickAdd` / `onQuickAdd` (the host-mounted React component); gated on both in K KanbanImpl.tsx#KanbanColumnView; K KanbanBoardCore.tsx#KanbanBoardCoreProps reads neither off `schema` (objectui#11234); K ObjectKanban.tsx#ObjectKanban names `quickAdd` and `onQuickAdd` 2 times each (both in comments) against 11 for `onCardClick` (method: occurrences in that file, re-counted 2026-10-15); no `kanban-ui` registration at that sha (objectui#8257; the name survives only in tests and the changelog); through objectui @db11afd49 the board's spread forwarded the key; [62] object-kanban.conditionalFormatting (A = objectui @ab1879721595): A packages/plugin-kanban/src/KanbanBoardCore.tsx#processedColumns hands it to the board; A packages/plugin-kanban/src/KanbanImpl.tsx#getCardStyles evaluates it through A packages/core/src/evaluator/listConditional.ts#resolveConditionalFormatting, the evaluator of A packages/plugin-grid/src/ObjectGrid.tsx#getRowStyle; objectui's declaration A packages/types/src/zod/objectql.zod.ts#KanbanConditionalFormattingRuleSchema and A packages/types/src/objectql.ts#KanbanConditionalFormattingRule (objectui#11522, landed by objectui#11532 c73cdb5695); the stored-dialect arms in A packages/plugin-kanban/src/KanbanImpl.tsx#getCardStyles; blank-predicate fallback A packages/core/src/evaluator/listConditional.ts#evalRowPredicate; the four reader files byte-identical at objectui main 2e818d0b51; the writer census also read objectui at main; [63] object-kanban.navigation (K = objectui @b9814b334 packages/plugin-kanban/src/ObjectKanban.tsx): K#navConfig (`schema.navigation ?? { mode: 'drawer' }`) K#navigation (useNavigationOverlay) K#boardView (card click) K#renderRecordOverlay (NavigationOverlay); first re-derived at objectui @87af769e9, where the `(schema as any)` cast was still spelled; [64] object-calendar flat field keys (OBJECT_CALENDAR_FLAT_FIELD_GUIDANCE): objectui @b9814b334 packages/plugin-calendar/src/ObjectCalendar.tsx#getCalendarConfig (the backward-compat flat fallback); re-read 2026-10-15, first cited sha-less; [65] object-calendar (ObjectCalendarPropsSchema; C = objectui @eb7f586b packages/plugin-calendar/src/ObjectCalendar.tsx): calendar C#getCalendarConfig; defaultView C#schemaDefaultView; locale, loading via the registry shell objectui @eb7f586b packages/plugin-calendar/src/index.tsx; [66] object-calendar.calendar (CalendarConfigSchema by reference; C = objectui @89cad75d55 packages/plugin-calendar/src/ObjectCalendar.tsx): C#getCalendarConfig returns the block; its five bindings read in C#queryFilter, C#handleEventDropDefault and C#submitQuickCreate; [67] object-calendar.filter: objectui @53ded82b packages/plugin-calendar/src/ObjectCalendar.tsx#fetchData (`$filter: schema.filter`, verbatim); [68] object-tree.navigation: objectui @89cad75d55 packages/plugin-tree/src/ObjectTree.tsx#navigation (useNavigationOverlay); [69] object-calendar.sort: objectui @53ded82b packages/plugin-calendar/src/ObjectCalendar.tsx#fetchData (`$orderby: convertSortToQueryParams(schema.sort)`), whose sink objectui @53ded82b packages/core/src/utils/sort-query.ts#convertSortToQueryParams still lowered the string clause there; no `sort` input on objectui @53ded82b packages/plugin-calendar/src/index.tsx; [70] object-timeline flat config keys (OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE): the backward-compat flat rung (with the `dateField` alias) in objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#startDateField and its siblings; emitted by objectui @b9814b334 packages/plugin-list/src/ListView.tsx#viewComponentSchema (`case 'timeline'`); re-read 2026-10-15, first cited sha-less; [71] object-calendar.navigation (C = objectui @b9814b334 packages/plugin-calendar/src/ObjectCalendar.tsx): C#navConfig (`schema.navigation ?? { mode: 'drawer' }`) C#navigation (useNavigationOverlay) C#calendarView (event click) C#renderRecordOverlay (NavigationOverlay); [72] object-timeline.mapping (ObjectTimelineMappingSchema; A = objectui @ab1879721595 packages/plugin-timeline/src/ObjectTimeline.tsx; unchanged at objectui main 94985a92ba): title A#titleField; date A#startDateField; description A#descField; variant A#variantField; declared on A#ObjectTimelineProps (`mapping`) and A#TimelineMappingSchema; [73] object-timeline.items[] arms (R = objectui @2e818d0b51ec packages/plugin-timeline/src/renderer.tsx; unchanged at objectui main 2abec3a96): gantt bar startDate / endDate R#dimensions R#calculateDateRange, variant and title in R#TimelineRenderer (the bar and its tooltip); feed entry time R#dateLabel and title, description, variant, icon, content (renderChildren), className in R#TimelineRenderer; gantt row label R#TimelineRenderer (TimelineGanttLabel), items R#classifyGanttRows; vocabulary objectui @2e818d0b51ec packages/types/src/data-display.ts#TimelineItemVariant; element shape and refinement objectui @2e818d0b51ec packages/types/src/zod/data-display.zod.ts#TimelineItemSchema and objectui @2e818d0b51ec packages/types/src/zod/data-display.zod.ts#timelineItemsFitVariant; [74] object-form.layout retired values `inline` / `grid` (OBJECT_FORM_LAYOUT_RETIRED; P = objectui @f8a9d0fb0 packages/plugin-form/src/): folded to 'vertical' in P ObjectForm.tsx#SimpleObjectForm; only 'vertical' / 'horizontal' passed through by P ObjectForm.tsx#drawerLayout, ObjectForm.tsx#modalLayout, DrawerForm.tsx#formLayout, ModalForm.tsx#formLayout; 'vertical' hard-coded in P TabbedForm.tsx#TabbedForm, SplitForm.tsx#panes, WizardForm.tsx#WizardForm; [75] object-form.mobile (ObjectFormMobileSchema; F = objectui @89cad75d55 packages/plugin-form/src/ObjectForm.tsx): F#mobileOpts (the flat arm); stickyActions F#formSchema; stepper, stepperMinFields, stepperFieldsPerStep F#stepperMode; fullscreenLongText F#fieldsWithMobile; the `data-mobile-form` marker in F#SimpleObjectForm; [76] object-form.fields and object-master-detail-form.fields (formFieldNameList): objectui @ab1879721595 packages/plugin-form/src/MasterDetailForm.tsx#parentSchema (hands `fields` over verbatim), read by objectui @ab1879721595 packages/plugin-form/src/ObjectForm.tsx#fieldsToShow and objectui @ab1879721595 packages/plugin-form/src/flatFields.ts#buildFlatFields (a stored `{ name }` entry drawn by name; any other object entry warned by warnUnresolvedTopLevelField); unchanged at objectui main 94985a92ba; the retired inline field entry an author was taught by objectui @ab1879721595 skills/objectui/guides/page-builder.md (its object-form example; first cited sha-less); [77] object-form.customFields[] `grid` widget keys (minRows, maxRows, allowAdd, allowDelete, allowReorder, totalField, addLabel, sortField; W = objectui @9dfaca654311 packages/fields/src/widgets/GridField.tsx): declared on objectui @9dfaca654311 packages/types/src/field-types.ts#GridFieldMetadata, carried by objectui @9dfaca654311 packages/types/src/form.ts#FormField, mirrored in objectui @9dfaca654311 packages/types/src/zod/form.zod.ts#FormFieldSchema; read W#cfg W#allowAdd W#removeRow (minRows) W#totalField W#showTotal W#emit (sortField) W#GridFieldBody (maxRows, allowReorder, addLabel); objectui#11610 (merge 2abec3a96c) camelCased them; the parent-field `totalField` hand-off objectui @9dfaca654311 packages/plugin-form/src/LineItemsPanel.tsx#gridField and objectui @9dfaca654311 packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailLines; the writer shape (one `grid` field carrying all eight camelCase keys) objectui @9dfaca654311 examples/schema-catalog/src/schemas/fields-grid/line-items-grid.json; [78] object-form.customFields[] retired snake_case `grid` keys (OBJECT_FORM_GRID_WIDGET_SNAKE_KEYS): objectui @9dfaca654311 packages/types/src/field-types.ts#GRID_FIELD_RETIRED_KEYS; refused by objectui @9dfaca654311 packages/fields/src/widgets/GridField.tsx#GridField, tombstoned on objectui @9dfaca654311 packages/types/src/field-types.ts#GridFieldMetadata and objectui @9dfaca654311 packages/types/src/form.ts#FormField, named in objectui @9dfaca654311 packages/types/src/zod/form.zod.ts#FormFieldSchema (retiredGridFieldKey); [79] object-form.customFields[].validation: objectui @2e818d0b51ec packages/components/src/renderers/form/form.tsx#rules (inside renderFormField: minLength/maxLength/min/max spread as `{ value, message }`, `required` read only as a message string); objectui's declaration objectui @2e818d0b51ec packages/types/src/form.ts#FieldValidationRules; [80] object-timeline.navigation: objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#navigation (useNavigationOverlay) and objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#effectiveSchema (entry click, overlay); re-read 2026-10-15, first cited sha-less, when the read still spelled `(schema as any).navigation`; [81] object-form.customFields[].options[] (buildObjectFormRuntimeOption; F = objectui @2e818d0b51ec packages/components/src/renderers/form/form.tsx): label, value F#BuiltinSelectControl and objectui @2e818d0b51ec packages/core/src/evaluator/optionRules.ts#optionDisplayLabel (the four option widgets); visibleWhen F#cascade (resolveCascadingOptions; each widget's useCascadingOptions); description objectui @2e818d0b51ec packages/fields/src/widgets/LookupField.tsx#filteredOptions; `value` a runtime value: F#BuiltinSelectControl (matchOptionValue), objectui @2e818d0b51ec packages/types/src/zod/form.zod.ts#SelectOptionSchema (deliberate divergence comment), objectui @2e818d0b51ec packages/plugin-designer/src/ObjectManager.tsx#formSchema (writes `{ label, value }` pairs); unchanged at objectui main fd060f076; [82] object-form.customFields[] and the inline section entry (buildObjectFormRuntimeField; F = objectui @2e818d0b51ec packages/components/src/renderers/form/form.tsx, W = objectui @2e818d0b51ec packages/fields/src/widgets/): merge objectui @2e818d0b51ec packages/plugin-form/src/customFieldsMerge.ts#mergeCustomFields from objectui @2e818d0b51ec packages/plugin-form/src/ObjectForm.tsx#generateField; carrier F#builtinBranchHasNoControl (`field.field || field`); field row F#renderFormField (name, label, description, type, required, disabled, readonly, hidden, colSpan via F#colSpanClass), widget F#resolvedType, validation F#rules, the three `*When` rules F#ruleState, placeholder and inputType F#builtinBranchHasNoControl, options and dependsOn F#cascade, multiple F#resolvedType; layout objectui @2e818d0b51ec packages/plugin-form/src/autoLayout.ts#resolveColSpan (span, colSpan) and objectui @2e818d0b51ec packages/plugin-form/src/fieldGroups.ts#deriveFieldGroupSections (group); widgets W TextAreaField.tsx#rows, FileField.tsx#multiple (accept, multiple), VectorField.tsx#dimensions, LookupField.tsx#referenceTo, NumberField.tsx#NumberField (min, max), F#renderFieldComponent (minLength, maxLength), FormulaField.tsx#returnType, SummaryField.tsx#summaryType, GridField.tsx#allColumns (columns); read and refused: visibleOn F#ruleState, condition F#legacyConditionCel, id F#fieldKey; a measured writer, the object-manager create / edit dialog (a `formType: 'modal'` block): objectui @b9814b334 packages/plugin-designer/src/ObjectManager.tsx#formSchema (first cited sha-less); [83] object-form / object-master-detail-form sections[].fields[] `{ field }` entry (buildObjectFormSectionFieldEntry): objectui @2e818d0b51ec packages/plugin-form/src/sectionFields.ts#resolveSectionEntry (names an object field by `field`, every other key overrides its generated definition; label/placeholder/helpText copied as they stand); [84] object-form / object-master-detail-form sections[] (buildObjectFormSection; F = objectui @2e818d0b51ec packages/plugin-form/src/ObjectForm.tsx): handed over verbatim by objectui @2e818d0b51ec packages/plugin-form/src/MasterDetailForm.tsx#parentSchema; name, label F#sectionCtx (heading) F#inertPredicateSections (per-formType maps); collapsible, collapsed F#collapse (resolveSectionCollapse); visibleWhen F#sectionCtx (divider-row predicate); columns F#secCols F#clampCol (a NUMBER only); pane objectui @2e818d0b51ec packages/plugin-form/src/SplitForm.tsx#paneOf; the inline runtime field drawn as it stands in objectui @2e818d0b51ec packages/plugin-form/src/sectionFields.ts#resolveSectionEntry; objectui's own declaration objectui @2e818d0b51ec packages/types/src/objectql.ts#ObjectFormSection; [85] object-form (ObjectFormPropsSchema: the UNION of `schema.*` reads of the simple form and the sub-forms it forwards the bag into): objectui @eb7f586b packages/plugin-form/src/ObjectForm.tsx#ObjectForm, with TabbedForm, WizardForm, SplitForm, DrawerForm, ModalForm under objectui @eb7f586b packages/plugin-form/src/; `layout` `inline` / `grid` folded to `vertical` there while the registry inputs and the designer palette offered them; [86] object-form.fields (the `{ name }` entry retired from the authoring faces by objectui#11550): a census of objectui @ab1879721595 found it written only by fixtures probing the stored read and the refusal; [87] object-form.customFields: objectui @2e818d0b51ec packages/plugin-form/src/customFieldsMerge.ts#mergeCustomFields (merged over the generated fields); at objectui @2e818d0b51ec the open runtime field spelled the eight `grid` widget keys in snake_case, camelCased by objectui#11610 (carried at objectui @9dfaca654311); [88] object-form.contentLayout: objectui @89cad75d55 packages/plugin-form/src/ModalForm.tsx#renderContent (`schema.contentLayout === 'tabbed' && groups.length > 1`), declared on objectui @89cad75d55 packages/plugin-form/src/ModalForm.tsx#ModalFormSchema; [89] object-form.submitBehavior: objectui @89cad75d55 packages/plugin-form/src/ObjectForm.tsx#handleSubmit and objectui @89cad75d55 packages/plugin-form/src/WizardForm.tsx#handleStepSubmit (switch on `kind`), redirect `url` judged by objectui @89cad75d55 packages/plugin-form/src/submitRedirect.ts#resolveSubmitRedirect (FormViewSchema.safeParse); [90] object-form.navigateOnSuccess: objectui @89cad75d55 packages/plugin-form/src/successBehavior.ts#resolveSuccessNavigate, called from objectui @89cad75d55 packages/plugin-form/src/ObjectForm.tsx#handleSubmit and objectui @89cad75d55 packages/plugin-form/src/WizardForm.tsx#handleStepSubmit; a refused value reported on the success toast in objectui @89cad75d55 packages/plugin-form/src/ObjectForm.tsx#handleSubmit; [91] object-master-detail-form.details[] (the eleven entry keys): objectui @31971ff1e28f packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailDetailConfig and every `d.KEY` read in objectui @31971ff1e28f packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailLines; re-read at objectui @89cad75d5570 for #21589; [92] object-master-detail-form.details[].sortField (REMOVED, tombstone): objectui @89cad75d5570 packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailDetailConfig (no `sortField` member), derived by objectui @89cad75d5570 packages/plugin-form/src/deriveMasterDetail.ts#deriveDetail (SORT_FIELD_NAMES) and handed to the grid as `sort_field` in objectui @89cad75d5570 packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailLines; at objectui @9dfaca654311 the same hand-off spells `sortField` (objectui#11610) from objectui @9dfaca654311 packages/plugin-form/src/deriveMasterDetail.ts#deriveDetail; an entry the renderer does not resolve loads no child schema (objectui @9dfaca654311 packages/plugin-form/src/MasterDetailForm.tsx#entries); [93] object-master-detail-form.sections, .fields: objectui @2e818d0b51ec packages/plugin-form/src/MasterDetailForm.tsx#parentSchema (handed to the parent object-form verbatim); [94] object-map flat config keys (OBJECT_MAP_FLAT_CONFIG_GUIDANCE): objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#getMapConfig (branch 2) and objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#warnOnShadowedFlatMapKeys and objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#FLAT_MAP_CONFIG_KEYS; emitted by objectui @b9814b334 packages/plugin-view/src/ObjectView.tsx#ObjectView and objectui @b9814b334 packages/plugin-list/src/ListView.tsx#viewComponentSchema (`case 'map'`); re-read 2026-10-15, first cited sha-less at the then pin; [95] object-map (ObjectMapPropsSchema, per key; P = objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx, C = objectui @b9814b334 packages/core/src/utils/record-source.ts, I = objectui @b9814b334 packages/plugin-map/src/index.tsx): data P#getDataConfig C#resolveRecordSourceConfig (rung 1; the deleted array head and its carriers are recorded in the docblock above P#FLAT_MAP_CONFIG_KEYS); staticData C#resolveRecordSourceConfig (rung 2); objectName C#resolveRecordSourceConfig (rung 3) P#navigation P#warnOnLegacyFilterMapConfig P#warnOnTopLevelStyleUrl P#warnOnShadowedFlatMapKeys (cache keys) P#recordSourceObjectName (useSettledSchema); filter P#queryFilter P#fetchData (`$filter` on both fetches); sort P#fetchData (convertSortToQueryParams); map P#getMapConfig (branch 1; validates against ObjectMapConfigSchema) I#ObjectMapRenderer (its registration's `map` input); mapStyle P#getMapConfig (`schema.mapStyle || schema.map?.style`) I#ObjectMapRenderer; navigation P#navigation I#ObjectMapRenderer; enableClustering P#clusteredData I#ObjectMapRenderer; measured and not declared: style P#warnOnTopLevelStyleUrl (read only to say it is not a map style); [96] object-map.filter: objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#queryFilter (useResolvedFilter) and objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#fetchData (`$filter` on the inline and the object fetch); the registration's `filter` input objectui @b9814b334 packages/plugin-map/src/index.tsx#ObjectMapRenderer; [97] object-map.sort: objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#fetchData (convertSortToQueryParams to `$orderby`, both fetches); the registration's array-form `sort` input (objectui#8220) objectui @b9814b334 packages/plugin-map/src/index.tsx#ObjectMapRenderer; [98] object-map.navigation: objectui @89cad75d55 packages/plugin-map/src/ObjectMap.tsx#navigation (hands `schema.navigation` to useNavigationOverlay) and objectui @89cad75d55 packages/react/src/hooks/useNavigationOverlay.ts#useNavigationOverlay (`navigation?.mode ?? 'page'`); [99] object-gantt flat config keys (OBJECT_GANTT_FLAT_CONFIG_GUIDANCE): objectui @b9814b334 packages/plugin-gantt/src/ObjectGantt.tsx#FLAT_GANTT_CONFIG_KEYS and objectui @b9814b334 packages/plugin-gantt/src/ObjectGantt.tsx#getGanttConfig (branch 2 reads them; a present `gantt` block is taken whole and the flat keys beside it named as ignored); re-read 2026-10-15, first cited sha-less at the then pin; [100] object-gantt.markers (objectGanttMarker): G = objectui @ab1879721595 packages/plugin-gantt/src/GanttView.tsx: objectui @ab1879721595 packages/plugin-gantt/src/ObjectGantt.tsx#ganttView (hands `schema.markers` to GanttView verbatim); date G#markers (time-zone re-base) G#resolvedMarkers (axis mapping, drops unparsable or out-of-range dates); color G#resolvedMarkers (`m.color || 'hsl(var(--primary))'`); drawn in G#GanttView and in G#buildExportSvg; the declaration taken: objectui @ab1879721595 packages/types/src/objectql.ts#ObjectGanttSchema and objectui @ab1879721595 packages/types/src/zod/objectql.zod.ts#ObjectGanttSchema (`markers`); unchanged at objectui main 94985a92ba; [101] object-gantt (ObjectGanttPropsSchema, per key; P = objectui @b9814b334 packages/plugin-gantt/src/ObjectGantt.tsx, C = objectui @b9814b334 packages/core/src/utils/record-source.ts): data P#rawDataConfig C#resolveRecordSourceConfig (rung 1); staticData C#resolveRecordSourceConfig (rung 2); objectName (rung 3) P#persistLayoutKey P#navigation; filter P#queryFilter P#reload (`$filter`); sort P#reload (convertSortToQueryParams to `$orderby`); gantt P#getGanttConfig (branch 1, validated against the spec's GanttConfigSchema) and objectui @b9814b334 packages/plugin-gantt/src/index.tsx#ObjectGanttRenderer (its registration's `gantt` object input); navigation P#navConfig P#navigation; label P#ganttView (resolveInlineI18nLabel, export file-name chain); skipWeekends, holidays P#workingCalendar; persistLayout, viewName P#persistLayoutKey; markers, criticalPath, showBaselines, readOnly, mobileReadOnly P#ganttView, readOnly also P#renderRecordOverlay; measured and not declared: title (absent from the P#ganttView export-name chain), row cap (P#reload spreads nonGridRowCeilingQuery, marked not authorable), search and searchableFields P#searchTerm P#reload (host-generated by objectui @b9814b334 packages/plugin-list/src/ListView.tsx#viewComponentSchema, its `case 'gantt'` arm); [102] object-gantt.navigation: objectui @89cad75d55 packages/plugin-gantt/src/ObjectGantt.tsx#navConfig (`schema.navigation ?? { mode: 'drawer' }`) and objectui @89cad75d55 packages/plugin-gantt/src/ObjectGantt.tsx#navigation (useNavigationOverlay); [103] object-tree flat config keys (OBJECT_TREE_FLAT_CONFIG_GUIDANCE: parentField, labelField, titleField, fields, defaultExpandedDepth): objectui @b9814b334 packages/plugin-tree/src/ObjectTree.tsx#getTreeConfig (reads the flat spellings ahead of the `tree` block; the `schema.titleField` rung's retirement is recorded in its docblock above `fieldKey`, objectui#8841), objectui @b9814b334 packages/plugin-list/src/ListView.tsx#viewComponentSchema (its `case 'tree'` arm emits parentField, `labelField: treeCfg.labelField || 'name'`, fields, defaultExpandedDepth and no titleField since objectui 3fd862510), objectui @b9814b334 packages/plugin-tree/src/index.tsx#treeInputs (`{ name: 'tree', type: 'object' }`, no flat input); [104] object-tree (ObjectTreePropsSchema, per key; P = objectui @b9814b334 packages/plugin-tree/src/ObjectTree.tsx, C = objectui @b9814b334 packages/core/src/utils/record-source.ts): data P#dataConfig (resolveRecordSourceConfig with the `view-data` arm) and C#resolveRecordSourceConfig (rung 1, authoredDataIsOnTheDeclaredArm); staticData C#resolveRecordSourceConfig (rung 2); objectName (rung 3) P#headerObjectName P#navigation P#renderRecordOverlay (each behind resolveRecordSourceObjectName) P#parentField (detectParentField); filter P#queryFilter (useResolvedFilter) P#fetchData (`$filter` on the object fetch and on the inline ValueDataSource query); tree P#getTreeConfig and objectui @b9814b334 packages/plugin-tree/src/index.tsx#treeInputs; navigation P#navigation (useNavigationOverlay); host-prop `data` (not the authored key) P#passed; measured and not declared: sort (no `$orderby` in P#fetchData), row cap (P#fetchData spreads nonGridRowCeilingQuery); [105] object-tree.objectName (optional because no ElementDataSourceGate wiring): objectui @b9814b334 packages/plugin-tree/src/index.tsx#ObjectTreeRenderer has 0 occurrences of `ElementDataSourceGate` (re-counted 2026-10-15), against controls objectui @b9814b334 packages/plugin-gantt/src/index.tsx#ObjectGanttRenderer, packages/plugin-grid/src/index.tsx, packages/plugin-calendar/src/index.tsx (3 each) and packages/plugin-map/src/index.tsx#ObjectMapRenderer (4); method: occurrences of the identifier in each `src/index.tsx`; [106] object-timeline (ObjectTimelinePropsSchema, per key; P = objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx, R = objectui @b9814b334 packages/plugin-timeline/src/renderer.tsx, I = objectui @b9814b334 packages/plugin-timeline/src/index.tsx): objectName P#ObjectTimeline (fetch gate) P#useSettledSchema P#fetchesForItself P#invalidationNonce P#fetchData P#handleRefresh P#effectiveItems P#navigation; timeline P#timelineConfig; filter P#queryFilter P#filterKey P#fetchData; sort P#sortKey P#fetchData (convertSortToQueryParams); limit P#fetchData (resolveRowLimit) P#DEFAULT_TIMELINE_LIMIT P#describeRefusedRowLimit; items P#hasInlineItems P#fetchesForItself P#effectiveItems P#hasAuthoredItems; data P#hasInlineData P#fetchesForItself P#rawData; descriptionField P#descField; mapping P#titleField P#startDateField P#descField P#variantField; variant P#hasAuthoredItems (the `timeline-unsupported-variant` refusal, objectui#6655) and I#OBJECT_TIMELINE_INPUTS; navigation P#navigation P#effectiveSchema; dateFormat, rowLabel, minDate, maxDate R#TimelineRenderer (rowLabel and minDate/maxDate in its gantt branch, R#findUnusableGanttDate) off P#effectiveSchema; measured and not declared: bind P#boundData (useDataScope), className P#effectiveSchema; [107] object-timeline.data (read off React props, not `schema`; the prop channel is SchemaRenderer's `properties.*` hoist, objectui @b9814b334 packages/react/src/SchemaRenderer.tsx#SchemaRenderer, with `object-timeline` absent from objectui @b9814b334 packages/core/src/utils/record-source.ts#RECORD_SOURCE_DATA_ARM_BY_TYPE): objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#hasInlineData (loading seed), objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#fetchesForItself (the query skip the fetch effect reads), objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#rawData (row source `(props as any).data || boundData || fetchedData`), objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#effectiveItems (the `timeline` field bindings a fetched row takes); [108] object-timeline.items: objectui @2e818d0b51ec packages/types/src/data-display.ts#TimelineFeedItem and objectui @2e818d0b51ec packages/types/src/data-display.ts#TimelineGanttItem (the declared authored entry, ruled on objectui#6356), handed to the rail verbatim at objectui @2e818d0b51ec packages/plugin-timeline/src/ObjectTimeline.tsx#effectiveItems (`if (schema.items) return schema.items`); [109] record:reference_rail, record:alert, record:quick_actions, record:history, record:line_items (rows): key sets measured from the renderers' read points; the per-key read points are recorded under each props schema below; [110] record:approval_decision (row, EMPTY key set): objectui @d03b022 packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx#ApprovalDecisionRenderer (landed in objectui a368ccb, module-internal at d03b022; discards `schema`, draws from the record context); [111] record:attachments (row, EMPTY key set): objectui @47b1f0bb71 packages/app-shell/src/views/record-attachments-renderer.tsx#RecordAttachmentsRenderer (registered as `attachments` in the `record` namespace; discards `schema`); [112] cloud-connection:panel, marketplace:installed-list (rows, EMPTY key sets): objectui @b9814b334 packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx#CloudConnectionPanel and objectui @b9814b334 packages/app-shell/src/console/marketplace/InstalledListWidget.tsx#InstalledList (each registration is `() => `; re-read 2026-10-15, first measured sha-less at the then pin); [113] mcp:connect-agent (row, EMPTY key set): objectui @b9814b334 packages/app-shell/src/console/connect/ConnectAgentWidget.tsx#ConnectAgent (registration `() => `; re-read 2026-10-15, first measured sha-less at the then pin); [114] element:definition-list, element:repeater (rows: the three-part string-arm evidence): objectui @b9814b334 packages/components/src/renderers/basic/data-list.tsx#DefinitionListRenderer and objectui @b9814b334 packages/components/src/renderers/basic/data-list.tsx#RepeaterRenderer (both registered in the `element` namespace), objectui @b9814b334 packages/core/src/registry/public-blocks.ts#PUBLIC_BLOCKS (publication), objectui @b9814b334 packages/app-shell/src/views/metadata-admin/previews/block-types.ts#BLOCK_TYPE_META (palette), objectui @b9814b334 packages/app-shell/src/views/metadata-admin/previews/block-config.ts#BLOCK_CONFIG (each block's inspector); [115] action:button, action:group, action:menu, action:icon (rows): objectui @b9814b334 packages/core/src/registry/public-blocks.ts#PUBLIC_BLOCKS (the four `action:*` members), registered under objectui packages/components/src/renderers/action/ in the `action` namespace; [116] object-map, object-gantt, object-tree (row key sets; per-key read points under each props schema): objectui @b9814b334 packages/plugin-map/src/index.tsx#ObjectMapRenderer, objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#ObjectMap, objectui @b9814b334 packages/plugin-gantt/src/index.tsx#ObjectGanttRenderer, objectui @b9814b334 packages/plugin-gantt/src/ObjectGantt.tsx#ObjectGantt, objectui @b9814b334 packages/plugin-tree/src/index.tsx#ObjectTreeRenderer, objectui @b9814b334 packages/plugin-tree/src/ObjectTree.tsx#ObjectTree; [117] object-timeline (row key set; per-key read points under ObjectTimelinePropsSchema): objectui @b9814b334 packages/plugin-timeline/src/index.tsx#ObjectTimelineRenderer (the `object-timeline` and `timeline` registrations), objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#ObjectTimeline, objectui @b9814b334 packages/plugin-timeline/src/renderer.tsx#TimelineRenderer; [118] EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT rows statistic, card, button (the top-level text keys each renderer reads back; R = objectui @82a9417 packages/components/src/renderers/, re-verified identical at objectui 6c68b13 on 2026-08-18): statistic `label`, `value`, `description` R data-display/statistic.tsx#StatisticRenderer; card `title`, `description` R layout/card.tsx#CardRenderer; button `label` R form/button.tsx#ButtonRenderer; the deliberate `text` omission, `schema.content || schema.value`, at its `text` registration in R basic/text.tsx; [119] PageComponent.dataSource, the PLANNED API binding (ElementApiDataSourceSchema; its record is this file's `_note`; R = objectui @7578323a544e packages/core/src/): `read.url`'s refusal of a `./` path, measured against the console's `apiBase` promotion of a root-relative `/api/…` path only, R actions/ActionRunner.ts#navigateTo; `read.method` GET alone because every read (find, findOne, aggregate) sends an explicit `method: 'GET'`, R adapters/ApiDataSource.ts#ApiDataSource; no renderer reads the binding yet, the hook admitting only a string `object`, R data-scope/element-data-source.ts#isElementDataSourceConfig; [120] PageComponent.visibleWhen (the component-level evaluator's roots): the ambient scope (`current_user`, `user`, `ctx.user`, `os.user`, `features`, nothing else) objectui @1fb1ced36243 packages/app-shell/src/providers/ExpressionProvider.tsx#buildExpressionScope; composed with `record` (only when the page has a row) and `page` by objectui @1fb1ced36243 packages/react/src/SchemaRenderer.tsx#configEvaluationScope; no `data` root (objectui#9308) in objectui @1fb1ced36243 packages/react/src/SchemaRenderer.tsx#evaluatedSchema; the files identical at objectui main f228d710; the `page:tabs` item-level evaluator is the `page:tabs items[].visibleWhen` entry;" + "note": "region → component tree rendering (header/main/sidebar/footer). RE-CITED 2026-08-10 (#7133/#7142): same split-citation pruning as `template` — at objectui @11c1e71e the containers.tsx half names `regions` only in a comment (:798, 'page:section — thin wrapper used inside regions'). Verdict unchanged: LIVE. Component-level liveness stays unclassified (undrilled container, #4956); one component key carries a verdict OUTSIDE this file: `components[].responsive` is a retiredKey tombstone (#11027, ADR-0049 D2 — dead on a two-repo measurement; see the file _note). COMPONENT-MEMBER READ POINTS (moved out of packages/spec/src, #23434; ruling 6165388570 letter C, Q1 A as the seat read it): an EVIDENCE LIST only — no verdict is asserted, so component-level liveness stays unclassified as above. Each entry names the ComponentPropsMap row and member it reads, then its read points in the spelling `objectui @SHA FILE#SYMBOL` (the SHA is the objectui tree the point was last read in; a pin hop moves nothing here, and a renamed or deleted symbol is the event that re-measures it). Inside one entry, a letter defined at its head abbreviates a repeated file: with `P = objectui @SHA FILE`, `P#a P#b` reads `objectui @SHA FILE#a`, `objectui @SHA FILE#b`. Entries, in source order: [1] page:header (H = objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx#PageHeaderRenderer): title (`schema?.title ?? schema?.properties?.title`), the refused `icon` (resolved only per header action) and `breadcrumb` (an empty slot), `actions` (resolvedHeaderActions then headerActionPlan), recordChrome (`=== false` on either carrier, branching the header), the refused `showStar` (the record chip draws its star only where the host wires `onToggleFavorite`, and no reference host does; retired #23650) and showCopyId (the record chip), maxVisible and mobileMaxVisible (`?? 3` / `?? 1`), all in H; re-read 2026-10-15, first cited sha-less; [2] page:tabs (T = objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx#PageTabsRenderer): tabStyle (`schema?.properties?.type || schema?.tabStyle || 'line'`), alwaysShowStrip (`schema?.alwaysShowStrip === true || schema?.properties?.alwaysShowStrip === true`), items[].value (`it.value`, falling back to `tab-${idx}`; the refused `key` is the designer's `page:tabs.items.itemFields` input in objectui @b9814b334 packages/app-shell/src/views/metadata-admin/previews/block-config.ts#BLOCK_CONFIG), items[].label (`translateLabel(pickLocalized(it.label, language), language, ttLabel)`: the inline locale map first, then the bundle, the order packages/spec/src/ui/i18n-label-resolver.ts mirrors, whose module docblock cited it by line, sha-less, until #23434; objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx#PageAccordionRenderer composes an accordion item's label the same way), all in T; the retired props key `type` (the reason for the rename): left unhoisted by objectui @b9814b334 packages/react/src/SchemaRenderer.tsx#HOIST_PROTECTED_KEYS, and skipped as a base prop by objectui @b9814b334 packages/sdui-parser/src/validate.ts#BASE_PROPS; re-read 2026-10-15, first cited sha-less; [3] page:tabs items[].icon (C = objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx): C#triggerClass (`LazyIcon` inside the TabsTrigger) in C#PageTabsRenderer; published by its registration's `items` input; vocabulary objectui @b9814b334 packages/components/src/lib/lazy-icon.tsx#getLazyIcon; [4] page:tabs items[].visibleWhen (the tab-item evaluator: `data` is the row, bare fields spread flat, ambient roots after it): objectui @1fb1ced36243 packages/components/src/renderers/layout/containers.tsx#isItemVisible (its ExpressionEvaluator) in PageTabsRenderer; [5] record:details / record:highlights / record:related_list / record:quick_actions .requiredPermissions (RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION; R = objectui @b9814b334 packages/plugin-detail/src/renderers/, P = objectui @b9814b334 packages/permissions/src/): the gate `perms.hasCapabilities(required)` and the notice in R record-details.tsx#RecordDetailsBody, R record-highlights.tsx#highlightsAllowed (RecordHighlightsBody), R record-related-list.tsx#RecordRelatedListBody, R record-quick-actions.tsx#RecordQuickActionsBody; earlier withholding: no record R record-details.tsx#RecordDetailsBody, no object and the related read gate R record-related-list.tsx#RecordRelatedListBody; a gated highlights registers no fields R record-highlights.tsx#RecordHighlightsBody (useRegisterHighlightFields); ALL OF THEM and fail-open on unreported P MePermissionsProvider.tsx#hasCapabilities; fail-open P PermissionProvider.tsx#ALL_CAPABILITIES and P usePermissions.ts#NO_PROVIDER_PERMISSIONS; the binding wrapper (objectui#12294) objectui @b9814b334 packages/plugin-detail/src/renderers/recordBinding.tsx#recordBindingBlock; [6] record:details sections[].headerColor (closed enum, #12126): the six class literals of objectui @b9814b334 packages/plugin-detail/src/headerColor.ts#headerColorClass (objectui#6294, merged 2026-08-25); re-read 2026-10-15, first cited sha-less; [7] record:details sections[] hideEmpty, collapsible, showBorder and the #11661 trio (renderer defaults measured; the trio at objectui @190fbd01): objectui @190fbd01 packages/plugin-detail/src/renderers/record-details.tsx#RecordDetailsRenderer and objectui @190fbd01 packages/plugin-detail/src/DetailSection.tsx#DetailSection (`hideEmpty ?? true`; the undeclared `s.title ?? s.label` limb); [8] record:related_list.parentDataSource (the name): the sibling record blocks' binding read objectui @f0532d8a packages/plugin-detail/src/renderers/recordBinding.tsx#recordBindingBlock (`isElementDataSourceConfig(schema.dataSource)`), measured on objectui main; [9] record:details sections[].count (read since objectui c08c9332a, objectui#12192): objectui @b9814b334 packages/plugin-detail/src/renderers/record-details.tsx#countField (inside filteredSections; fills every `{{ count }}` hole); [10] record:related_list.parentDataSource (PLANNED): the objectui measurement is the file _note's (objectui @f0532d8a, the record context read in RecordRelatedListBody); no new evidence here; [11] record:activity guidance `items` / `loading` (host feed slot; R = objectui @89cad75d5570 packages/plugin-detail/src/renderers/): R record-activity.tsx#hostItems and R record-activity.tsx#loading in RecordActivityRenderer; record:chatter / record:discussion `feed` reads neither, R record-chatter.tsx#RecordChatterRenderer; [12] record:chatter.position (vocabulary bottom / right / left, #8762): objectui @665661ab0932 packages/plugin-detail/src/RecordChatterPanel.tsx#position (`right`/`left` dock vs `bottom` in-flow); the designer's CHATTER_INPUTS and the merge fallback in objectui @665661ab0932 packages/plugin-detail/src/renderers/record-chatter.tsx#RecordChatterRenderer; [13] record:reference_rail (RecordReferenceRailProps): objectui @b9814b334 packages/plugin-detail/src/renderers/record-reference-rail.tsx#RecordReferenceRailRenderer ( an entry `icon` is unread, `title` a raw React child); the synthesizer emitting `icon` objectui @b9814b334 packages/plugin-detail/src/synth/buildDefaultPageSchema.ts#buildDefaultPageSchema; re-read 2026-10-15, first cited sha-less; [14] record:alert, record:quick_actions, record:history: objectui @b9814b334 packages/plugin-detail/src/renderers/record-alert.tsx#RecordAlertRenderer, objectui @b9814b334 packages/plugin-detail/src/renderers/record-quick-actions.tsx#RecordQuickActionsRenderer, objectui @b9814b334 packages/plugin-detail/src/renderers/record-history.tsx#RecordHistoryRenderer and objectui @b9814b334 packages/plugin-detail/src/HistoryTimeline.tsx#HistoryTimeline (each renderer's ACTUAL reads, against its registration's declared inputs); re-read 2026-10-15, first cited sha-less; [15] record:line_items (RecordLineItemsProps; L = objectui @31971ff1e28f packages/plugin-form/src/LineItemsPanel.tsx): registered as `line_items` (namespace `record`) in objectui @31971ff1e28f packages/plugin-form/src/index.tsx with its mapping objectui @31971ff1e28f packages/plugin-form/src/index.tsx#RECORD_LINE_ITEMS_DATA_SOURCE; the fifteen `schema.KEY` reads in L#LineItemsPanel (childObject L#heldReloadRef; parentObject L#parentObject; parentId, recordId L#parentId; amountField, totalField L#parentPatch; filter L#scopeFilter; limit L#message; columns L#gridField (applyColumnPermissions)); the gate objectui @31971ff1e28f packages/react/src/element-data-source/ElementDataSourceGate.tsx#useElementDataSourceSchema; read set unchanged at objectui main d59f11c0d3dc; [16] record:line_items guidance (addLabel, sortField, onRowExpand not handed to the grid): objectui @9dfaca654311 packages/plugin-form/src/LineItemsPanel.tsx#gridField; [17] record:approvals (RecordApprovalsProps, EMPTY key set; A = objectui @47b1f0bb71 packages/app-shell/src/views/): registered as `approvals` (namespace `record`) in A record-approvals-renderer.tsx#RecordApprovalsRenderer; approvals A record-approvals-renderer.tsx#hostApprovals, threaded by A RecordDetailView.tsx#RecordDetailView and emitted by objectui @47b1f0bb71 packages/plugin-detail/src/synth/buildDefaultPageSchema.ts#buildDefaultPageSchema; currentUserId falls back in A RecordApprovalsPanel.tsx#isSubmitter; Studio page create seeds without options in A metadata-admin/anchors.ts#synth; [18] page:accordion items[].value (refused): objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx#PageAccordionRenderer overwrites it with `panel-${idx}`; the designer input `page:accordion.items.itemFields` in objectui @b9814b334 packages/app-shell/src/views/metadata-admin/previews/block-config.ts#BLOCK_CONFIG (objectui#7973); re-read 2026-10-15, first cited sha-less; [19] page:accordion items[].icon (C = objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx): C#commonChildren (`LazyIcon` in the AccordionTrigger) inside C#PageAccordionRenderer; published by its `accordion` registration's `items` input; vocabulary objectui @b9814b334 packages/components/src/lib/lazy-icon.tsx#getLazyIcon; [20] page:accordion.variant: objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx#PageAccordionRenderer (`schema?.variant ?? schema?.properties?.variant ?? 'flush'`); re-read 2026-10-15, first cited sha-less; [21] element:text.variant retired `heading` / `subheading`: objectui @89cad75d55 packages/components/src/renderers/basic/elements.tsx#VARIANT_TAG; [22] element:button.icon (B = objectui @b9814b334 packages/components/src/renderers/form/button.tsx, R = objectui @b9814b334 packages/components/src/renderers/action/resolve-icon.ts): B#Icon (resolveIcon) and its render on either side of the label in B#ButtonRenderer; R#resolveIcon R#describeIconLookup R#toPascalCase R#iconNameMap R#recordIconName (the vocabulary: the generated objectui @b9814b334 packages/components/src/lib/lucide-record-icon-names.ts#LUCIDE_RECORD_ICON_NAME_TABLE); contrast objectui @b9814b334 packages/components/src/lib/lazy-icon.tsx#getLazyIcon; no `icon` in the registration's inputs inside B#ButtonRenderer (objectui#9910 added the `children` slot input); history: the read point moved files on the hop onto objectui 9602dc820, and the registration ranges were first wrong-since-written at objectui a472b0716; [23] action:* rows, the effective executor and the runner's reads (R = objectui @b9814b334 packages/core/src/actions/ActionRunner.ts, first read at objectui @1fb1ced36243 with every anchor at the same symbols; A = objectui @b9814b334 packages/components/src/renderers/action/): executor resolution R#execute (`action.type || action.actionType || action.name`); forwarded executor A action-button.tsx#forwarded, A action-icon.tsx#forwarded (`type: schema.actionType`), A action-group.tsx#paramsPayload, A action-menu.tsx#paramsPayload (`type: action.type`); the name leg documented in A action-button.tsx#forwarded; built-in executors and the `navigation` alias R#ActionRunner (`builtinExecutors`), url navigation executeUrl / executeNavigation / navigateTo in R#ActionRunner; no console host handler under `url` / `navigation`: objectui @b9814b334 packages/app-shell/src/console/ConsoleShell.tsx, packages/app-shell/src/hooks/useConsoleActionRuntime.tsx, packages/app-shell/src/console/AppContent.tsx, packages/app-shell/src/hooks/useObjectActions.ts; onSuccess R#handlePostExecution R#readOnSuccessNavigation; operation read before any executor R#execute (isUpdateOperationAction), forwarded A action-button.tsx#forwarded; the row `patch` is never posted and is read for the Undo snapshot only (executeUpdateOperation in R#ActionRunner; the route writes the named declaration's `patch`), so it is a retiredKey() tombstone since #24394, re-read at objectui 0707b4ae37 and main 4b7ce47; unchanged at objectui main 866763bd and 72594f29; [24] section 4b, the action:* quartet and element:* lists (shared reads; A = objectui @b9814b334 packages/components/src/renderers/action/): publication objectui @b9814b334 packages/core/src/registry/public-blocks.ts#PUBLIC_BLOCKS; `autoTrigger` \"NOT persisted metadata\" in the header of A auto-trigger.ts#useAutoTriggerOnce; the Button primitive vocabulary objectui @b9814b334 packages/components/src/ui/button.tsx#buttonVariants; `objectName` forwarded by A action-button.tsx#forwarded and A action-icon.tsx#forwarded, and per member by A action-group.tsx#paramsPayload and A action-menu.tsx#paramsPayload; history: objectui#11168 slices 1-2, objectui#11212 (fail-closed `visible`), objectui#11182 (host `disabled` verdict), objectui#11344 (`outcomeMessages`), objectui#11638 (member `properties.params` read retired), objectui#11839 (disabled reason), objectui#11880 (repeater binding), objectui#12085 (repeater publishes only the binding); [25] action:button (ActionButtonPropsSchema; B = objectui @b9814b334 packages/components/src/renderers/action/action-button.tsx; the retired gate's quoted first line `type: schema.actionType,` holds at B#forwarded): name B#isVisible (diagnostic label) B#forwarded; label B#button (React child) B#isVisible B#forwarded; icon B#Icon (resolveIcon); actionType B#forwarded (`type`); variant, size B#variant and its `size` twin; visible B#isVisible B#ActionButtonRenderer (hasDeclaredVisibilityGate); disabled B#isDisabled B#button; params B#staticValues (withUndoBaseline) and objectui @b9814b334 packages/components/src/renderers/action/static-params.ts#readStaticParamValues; the forwarded set B#forwarded (outcomeMessages forwarded since objectui#11344, left unpublished in the registration's inputs inside B#ActionButtonRenderer); undo baseline B#withUndoBaseline; [26] action:icon (ActionIconPropsSchema; I = objectui @b9814b334 packages/components/src/renderers/action/action-icon.tsx; the retired gate's quoted first line `type: schema.actionType,` holds at I#forwarded): size not read I#size (fixed `'icon'`); the forward I#forwarded (no undoable / recordIdField; outcomeMessages since objectui#11344); label I#isVisible I#forwarded I#button (aria-label, initial letter, DisabledReasonTrigger heading) I#ActionIconRenderer (tooltip); visible I#isVisible I#ActionIconRenderer; disabled I#isDisabledPred I#button; variant I#variant; params I#staticValues (readStaticParamValues); [27] action:group / action:menu members (actionContainerMember; G = objectui @2e818d0b51ec packages/components/src/renderers/action/action-group.tsx, M = objectui @2e818d0b51ec packages/components/src/renderers/action/action-menu.tsx, P = objectui @2e818d0b51ec packages/components/src/renderers/action/static-params.ts): drawn G#InlineActionButton G#DropdownActionItem M#ActionMenuItem; visible G#useMemberVisible M#useMenuActionVisible; size (group only) G#btnSize; tags G#showSeparator; locations G#actions (actionRendersAt); forwarded G#staticValues M#staticValues (the forward block); object params forwarded on `api` and dropped, with a development-build warning only, for every other `type` P#readActionEntryParamValues (first cited at objectui @0abd4f9f8, same symbol); refused: properties.params P#readMemberStaticParamValues, className G#handleClick, autoTrigger M#ActionAutoTrigger (useAutoTriggerOnce), onClick G#handleExecute M#handleExecute; the array forward (the retired gate's quoted first line `const paramsPayload: ActionDef = Array.isArray(action.params)`) objectui @b9814b334 packages/components/src/renderers/action/action-group.tsx#paramsPayload and objectui @b9814b334 packages/components/src/renderers/action/action-menu.tsx#paramsPayload, other values objectui @b9814b334 packages/components/src/renderers/action/static-params.ts#readActionEntryParamValues; the fork measured at objectui @ab1879721595, files byte-identical at objectui main 2abec3a96; [28] action:group (ActionGroupPropsSchema; G = objectui @b9814b334 packages/components/src/renderers/action/action-group.tsx): actions G#declaredActions G#actions (actionRendersAt) G#useMemberVisible G#paramsPayload; display G#display; label, icon, variant, size G#TriggerIcon (the trigger; size raw to inline members) G#btnSize; visible G#isVisible; the host `disabled` verdict (objectui#11182) G#ActionGroupRenderer; group `name` unread (the comment above G#ActionGroupRenderer, objectui#11168); the registration's inputs inside G#ActionGroupRenderer; [29] action:menu (ActionMenuPropsSchema; M = objectui @b9814b334 packages/components/src/renderers/action/action-menu.tsx): actions M#ActionMenuRenderer M#ActionMenuItem M#ActionAutoTrigger M#paramsPayload; label, icon, variant, size, visible and the host `disabled` verdict (objectui#11182) M#ActionMenuRenderer; the registration's inputs inside M#ActionMenuRenderer (objectui#11168 slice 1); [30] element:definition-list (ElementDefinitionListPropsSchema; L = objectui @b9814b334 packages/components/src/renderers/basic/data-list.tsx; the retired gate's quoted first line `const props = readProps<{` holds at L#props): items, columns (`=== 2`), inline, term, description L#DefinitionListRenderer (an item's `description` shown through L#toText: a string or number as text, an object as JSON; first cited at objectui @89cad75d55); the registration's number enum and optional `items` (objectui#11168 slice 2) at its `definition-list` registration in L; the designer's number control objectui @b9814b334 packages/app-shell/src/views/metadata-admin/previews/block-config.ts#BLOCK_CONFIG; [31] element:repeater (ElementRepeaterPropsSchema; L = objectui @b9814b334 packages/components/src/renderers/basic/data-list.tsx; the quoted first line `const props = readProps<{` holds at L#props in L#RepeaterRenderer): object, filter, sort, limit, emptyText, divided, titleField, fields L#RepeaterRenderer; the binding L#dataBinding (useElementDataSource, objectui#11880, aaba8655a); resolved filter tokens L#filterScope; delivery objectui @b9814b334 packages/data-objectstack/src/index.ts#ObjectStackAdapter (translateFilterArray arm) and objectui @b9814b334 packages/data-objectstack/src/index.ts#serializeOrderBy; the registration wrapped by elementDataSourceBlock publishes no `object` input since objectui#12085 (ce991bd70); the registration's `object` required flag read at objectui @20c6d351a; [32] object-grid.aggregations[] (GridAggregationSchema): objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx#groupHeaders (useGroupedData) and objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx#serverGroupSource (useServerGroupHeaders); the grid's own function vocabulary objectui @89cad75d55 packages/plugin-grid/src/useGroupedData.ts#AggregationType (unchanged at objectui @b9814b334); [33] object-grid (ObjectGridPropsSchema, per key; E = objectui @eb7f586b packages/plugin-grid/src/ObjectGrid.tsx): columns, fields E#schemaFields; filter E#schemaFilterSource (toFilterNode); defaultFilters E#cols (legacy fallback); sort E#schemaSort; defaultSort E#cols; pagination, pageSize, showPagination E#lastFindParamsRef E#schemaPageSize E#paginationEnabled; searchableFields, showSearch E#trimmedSearch E#searchEnabled; rowHeight E#ObjectGrid (resolveRowHeightMode); grouping, aggregations E#grouping E#groupValueFormatter; rowColor E#getRowClassName; conditionalFormatting E#predicateFields E#rules; selection, selectable E#ObjectGrid; rowActions E#rowActionsList; batchActions, bulkActions E#declaredBulkActions; bulkActionDefs E#legacyBulkNames; navigation E#navigation; editable, singleClickEdit, resizable, resizableColumns, reorderableColumns E#dataTableSchema; frozenColumns E#effectiveFrozenColumns; showColumnTypeIcons E#fieldMeta; exportOptions, operations E#declared; label, title E#fileNameFor E#dataTableSchema; data, staticData E#getDataConfig; [#20694] description objectui @db11afd4967c packages/plugin-grid/src/ObjectGrid.tsx#resolvedDescription (drawn as `gridDescription`); emptyState objectui @db11afd4967c packages/plugin-grid/src/ObjectGrid.tsx#authoredEmptyState (DataEmptyState in renderedGridContent); keyboardNavigation 0 read points at objectui @db11afd4967c, 15 hit lines at objectui @ab1879721595 and at objectui @b9814b334 against 3 then 4 for the control `schema.editable` (the fourth objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx#carriesConcurrencyToken); [34] object-grid.description, object-grid.emptyState (B = objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx): description objectui @db11afd4967c packages/plugin-grid/src/ObjectGrid.tsx#resolvedDescription; emptyState title/message B#authoredEmptyTitle (resolveInlineI18nLabel, objectui#11227 carried by 6158e4c93; raw at objectui @89cad75d5570) drawn in B#renderedGridContent, link (objectui#12270, b434f5f47) B#renderedGridContent (EmptyStateLink); [35] object-grid.columns (ListViewSchema.shape.columns; A = objectui @ab1879721595 packages/plugin-grid/src/ObjectGrid.tsx): A#normalizeColumns (first-entry dispatch) A#cols A#fieldName (resolvesToDataColumn); the retired `colOverride?.options` read: A#groupValueFormatter A#rawOptions (objectui b0bf413, objectui#11544); the footer summary's off-contract column keys objectui @ab1879721595 packages/plugin-grid/src/useColumnSummary.ts#columnHints; [36] object-grid.fields (O = objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx): O#schemaFields O#fieldsToShow O#generateColumns; the host hand-off projection O#getSelectFields; [37] object-grid.filter: objectui @53ded82b packages/plugin-grid/src/ObjectGrid.tsx#ObjectGrid (toFilterNode before `$filter`); [38] object-grid.sort (S = objectui @53ded82b packages/plugin-grid/src/ObjectGrid.tsx): S#schemaSort S#cols (the fetch path's string arm and array arm) S#declaredSort (parseSchemaSort, `TableSortItem[]`); the registration's array input objectui @53ded82b packages/plugin-grid/src/index.tsx (`{ name: 'sort', type: 'array' }`); [39] object-grid.defaultSort (REMOVED, tombstone; D = objectui @190fbd01d packages/plugin-grid/src/ObjectGrid.tsx): D#cols (`$orderby` fallback) and D#declaredSort (`[schema.defaultSort]`); [40] object-grid.pagination (D = objectui @d18322415 packages/plugin-grid/src/ObjectGrid.tsx): pageSize D#lastFindParamsRef D#schemaPageSize D#pageSize; pageSizeOptions D#dataTableSchema; presence D#paginationEnabled; the registry input objectui @d18322415 packages/plugin-grid/src/index.tsx (`pagination`, type object); [41] object-grid members read at objectui @89cad75d55 (O = objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx): rowHeight O#resolveRowHeightMode; aggregations O#groupHeaders (useGroupedData) O#serverGroupSource (useServerGroupHeaders); conditionalFormatting O#getRowStyle (resolveConditionalFormatting); rowColor O#getRowClassName (useRowColor); selection and selectable O#ObjectGrid (the selection-mode branch) O#dataTableSchema (`selectable`), read by objectui @89cad75d55 packages/components/src/renderers/complex/data-table.tsx#resolveSelectionMode; rowActions O#rowActionsDeclared; bulkActions, batchActions O#declaredBulkActions (`batchActions ?? bulkActions`); bulkActionDefs O#legacyBulkNames (resolveBulkActions); [42] object-grid.navigation: objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx#navigation (useNavigationOverlay); [43] object-grid.keyboardNavigation (built by objectui 154075ab1, objectui#11068; declared ahead of its reader, which read nothing at objectui @db11afd4967c): objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx#dataTableSchema (`schema.keyboardNavigation ?? inlineEditable`) with objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx#inlineEditable; objectui @b9814b334 packages/components/src/renderers/complex/data-table.tsx#DataTableRenderer (default false, `role=\"grid\"`, the roving tabIndex), objectui @b9814b334 packages/components/src/renderers/complex/data-table.tsx#navigationTarget and objectui @b9814b334 packages/components/src/renderers/complex/data-table.tsx#handleCellKeyDown; [44] object-grid.resizableColumns (REMOVED, tombstone): objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx#dataTableSchema (`schema.resizable ?? schema.resizableColumns ?? true`); [45] object-grid.operations (GridOperationsSchema; O = objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx): create O#dataTableSchema (showAddRow); update, delete O#wantDeleteAction; export O#handleExport O#dataTableSchema (exportable) O#exportEnabled; a declared block replaces the default O#explicitOperations; [46] object-grid.data: the registry input objectui @b9814b334 packages/plugin-grid/src/index.tsx#GRID_QUERY_INPUTS (`{ name: 'data', type: 'object' }`, held by gridDataInputContract.test.ts); re-read 2026-10-15, first cited sha-less; [47] object-metric.aggregate (ObjectMetricAggregateSchema; M = objectui @89cad75d55 packages/plugin-dashboard/src/ObjectMetricWidget.tsx): field, function M#computeOne; function also M#fieldUnitApplies (answersInFieldUnit) M#inferredFormat (min/max width); groupBy M#computeOne (objectAggregateSpecQuery; floored at `'_all'`) typed on M#ObjectMetricWidgetProps (`ChartAggregate['groupBy']`); objectui's unit-rule pin ObjectMetricWidget.countNotCurrency-10356.test.tsx mounts one tile per AggregationFunction member; [48] object-metric.trend (ObjectMetricTrendSchema; W = objectui @89cad75d55 packages/plugin-dashboard/src/): drawn in W MetricWidget.tsx#MetricWidget (`{value}%`, direction arrow), caption W MetricWidget.tsx#localizedTrendLabel (pickLocalized); declared on W MetricWidget.tsx#MetricWidgetProps, mirrored on W ObjectMetricWidget.tsx#ObjectMetricWidgetProps; replaced by a derived trend in W ObjectMetricWidget.tsx#effectiveTrend; [49] object-metric.drillDown (ObjectMetricDrillDownSchema; A = objectui @ab1879721595): enabled A packages/plugin-dashboard/src/ObjectMetricWidget.tsx#drillEnabled (isDrillEnabled); title A packages/plugin-dashboard/src/ObjectMetricWidget.tsx#drawerTitle (resolveDrillTitle against an empty event); target, columns, maxRows A packages/plugin-dashboard/src/ObjectMetricWidget.tsx#drillDrawer (DrillDownDrawer); `filter` / `mode` refused as objectui's own A packages/types/src/data-display.ts#ObjectMetricDrillDownConfig refuses them; measured writers, objectui's drill-down member pins A packages/plugin-dashboard/src/__tests__/objectMetricDrillDownMembers-8071.test.tsx and A packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.drillRoutedToSharedDrawer-8970.test.tsx; [50] object-metric.drillDown.report (R = objectui @2e818d0b51ec packages/plugin-dashboard/src/): handed verbatim in R ObjectMetricWidget.tsx#drillDrawer; drawn when R DrillDownDrawer.tsx#isDatasetBoundReport holds (R DrillDownDrawer.tsx#isReportDrill), joining the metric filter into `runtimeFilter` (R DrillDownDrawer.tsx#body); typed `SpecReportInput` in objectui @2e818d0b51ec packages/types/src/data-display.ts; the fork measured at objectui @ab1879721595, both files byte-identical at objectui main 2abec3a96; [51] object-metric.compareTo (ObjectMetricCompareToSchema; A = objectui @ab1879721595): A packages/plugin-dashboard/src/ObjectMetricWidget.tsx#comparisonFilter (second aggregate over shiftFilterByCompareTo) and A packages/plugin-dashboard/src/ObjectMetricWidget.tsx#derivedTrend (compareToTrendLabelKey); both dispatch on `kind === 'previousYear'` in A packages/core/src/utils/compare-to.ts#shiftFilterByCompareTo and A packages/core/src/utils/compare-to.ts#compareToTrendLabelKey; `dimension` \"deliberately never read on this path\" in that module's header; [52] object-metric (ObjectMetricPropsSchema): objectui @eb7f586b packages/plugin-dashboard/src/ObjectMetricWidget.tsx#ObjectMetricWidgetProps (the complete destructured read set); [53] object-metric.icon (B = objectui @b9814b334 packages/plugin-dashboard/src/): published by B index.tsx#ObjectMetricBlock (`{ name: 'icon', type: 'string' }`); destructured and forwarded by B ObjectMetricWidget.tsx#ObjectMetricWidget; resolved and drawn by B MetricWidget.tsx#MetricWidget (getLazyIcon, guarded on a string); vocabulary objectui @b9814b334 packages/components/src/lib/lazy-icon.tsx#getLazyIcon; not B MetricCard.tsx (off this key's path); [54] object-metric.filter: the analytics wire lowering objectui @53ded82b packages/data-objectstack/src/index.ts#lowerAnalyticsFilterForWire (objectui#7754); before it the adapter at objectui @a472b07 posted an array verbatim (a 400, #15828); [55] object-kanban.columns[] lane (ObjectKanbanLaneSchema; K = objectui @89cad75d55 packages/plugin-kanban/src/): id K index.tsx#bucketCardsIntoColumns; title K ObjectKanban.tsx#localizeColumn; cards K index.tsx#bucketCardsIntoColumns (kept ahead of the bucketed records); limit K KanbanImpl.tsx#isLimitExceeded; className K KanbanImpl.tsx#KanbanColumnView; collapsed K KanbanImpl.tsx#isColumnCollapsed; [56] object-kanban (ObjectKanbanPropsSchema; E = objectui @eb7f586b packages/plugin-kanban/src/ObjectKanban.tsx): columns E#effectiveColumns; filter E#fetchData (`$filter`); data E#boundData; cardTitle, titleField E#explicitTitleField; cardFields E#explicitCardFields; swimlaneField, grouping E#effectiveSwimlaneField; coverImageField, conditionalFormatting via objectui @eb7f586b packages/plugin-kanban/src/index.tsx#KanbanRenderer (the spread of the authored bag); limit objectui @b9814b334 packages/plugin-kanban/src/ObjectKanban.tsx#query (`$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_FETCH_BATCH_SIZE)`), first re-read at objectui @87af769e9 where `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` had become resolveRowLimit; [57] object-kanban.columns (the first-entry dispatch): objectui @89cad75d55 packages/plugin-kanban/src/ObjectKanban.tsx#effectiveColumns (string arm only without `groupBy`); [58] object-kanban.filter: objectui @53ded82b packages/plugin-kanban/src/ObjectKanban.tsx#ObjectKanban (`$filter: schema.filter`, verbatim, lowered by ObjectStackAdapter.convertQueryParams through translateFilterArray); [59] object-kanban.limit (B = objectui @b9814b334): query B packages/plugin-kanban/src/ObjectKanban.tsx#query (`$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_FETCH_BATCH_SIZE)`) with B packages/plugin-kanban/src/ObjectKanban.tsx#DEFAULT_KANBAN_FETCH_BATCH_SIZE and the resolved filter B packages/plugin-kanban/src/ObjectKanban.tsx#filterScope; mapping B packages/plugin-kanban/src/index.tsx#OBJECT_KANBAN_DATA_SOURCE (`limit: 'limit'`); type B packages/types/src/objectql.ts#ObjectKanbanSchema (`limit?: number`); docs B content/docs/plugins/plugin-kanban.mdx (`limit: 250` snippet and Properties row); precedence B packages/react/src/element-data-source/ElementDataSourceGate.tsx#useElementDataSourceSchema (binding cap over this key; a view's pagination.pageSize only when unset) and B packages/react/src/element-data-source/ElementDataSourceGate.tsx#isUsableRowLimit; the saved-view fallback B packages/core/src/data-scope/element-data-source.ts#savedViewRawLimit; first re-read at objectui @87af769e9 2026-09-20; [60] object-master-detail-form (ObjectMasterDetailFormPropsSchema): objectui @eb7f586b packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailForm; auto-derivation objectui @eb7f586b packages/plugin-form/src/deriveMasterDetail.ts; the `formType` pair objectui's MasterDetailFormSchema declares; [61] object-kanban.quickAdd (RETIRED, tombstone; K = objectui @b9814b334 packages/plugin-kanban/src/): K index.tsx#KanbanRenderer passes `quickAdd` / `onQuickAdd` (the host-mounted React component); gated on both in K KanbanImpl.tsx#KanbanColumnView; K KanbanBoardCore.tsx#KanbanBoardCoreProps reads neither off `schema` (objectui#11234); K ObjectKanban.tsx#ObjectKanban names `quickAdd` and `onQuickAdd` 2 times each (both in comments) against 11 for `onCardClick` (method: occurrences in that file, re-counted 2026-10-15); no `kanban-ui` registration at that sha (objectui#8257; the name survives only in tests and the changelog); through objectui @db11afd49 the board's spread forwarded the key; [62] object-kanban.conditionalFormatting (A = objectui @ab1879721595): A packages/plugin-kanban/src/KanbanBoardCore.tsx#processedColumns hands it to the board; A packages/plugin-kanban/src/KanbanImpl.tsx#getCardStyles evaluates it through A packages/core/src/evaluator/listConditional.ts#resolveConditionalFormatting, the evaluator of A packages/plugin-grid/src/ObjectGrid.tsx#getRowStyle; objectui's declaration A packages/types/src/zod/objectql.zod.ts#KanbanConditionalFormattingRuleSchema and A packages/types/src/objectql.ts#KanbanConditionalFormattingRule (objectui#11522, landed by objectui#11532 c73cdb5695); the stored-dialect arms in A packages/plugin-kanban/src/KanbanImpl.tsx#getCardStyles; blank-predicate fallback A packages/core/src/evaluator/listConditional.ts#evalRowPredicate; the four reader files byte-identical at objectui main 2e818d0b51; the writer census also read objectui at main; [63] object-kanban.navigation (K = objectui @b9814b334 packages/plugin-kanban/src/ObjectKanban.tsx): K#navConfig (`schema.navigation ?? { mode: 'drawer' }`) K#navigation (useNavigationOverlay) K#boardView (card click) K#renderRecordOverlay (NavigationOverlay); first re-derived at objectui @87af769e9, where the `(schema as any)` cast was still spelled; [64] object-calendar flat field keys (OBJECT_CALENDAR_FLAT_FIELD_GUIDANCE): objectui @b9814b334 packages/plugin-calendar/src/ObjectCalendar.tsx#getCalendarConfig (the backward-compat flat fallback); re-read 2026-10-15, first cited sha-less; [65] object-calendar (ObjectCalendarPropsSchema; C = objectui @eb7f586b packages/plugin-calendar/src/ObjectCalendar.tsx): calendar C#getCalendarConfig; defaultView C#schemaDefaultView; locale, loading via the registry shell objectui @eb7f586b packages/plugin-calendar/src/index.tsx; [66] object-calendar.calendar (CalendarConfigSchema by reference; C = objectui @89cad75d55 packages/plugin-calendar/src/ObjectCalendar.tsx): C#getCalendarConfig returns the block; its five bindings read in C#queryFilter, C#handleEventDropDefault and C#submitQuickCreate; [67] object-calendar.filter: objectui @53ded82b packages/plugin-calendar/src/ObjectCalendar.tsx#fetchData (`$filter: schema.filter`, verbatim); [68] object-tree.navigation: objectui @89cad75d55 packages/plugin-tree/src/ObjectTree.tsx#navigation (useNavigationOverlay); [69] object-calendar.sort: objectui @53ded82b packages/plugin-calendar/src/ObjectCalendar.tsx#fetchData (`$orderby: convertSortToQueryParams(schema.sort)`), whose sink objectui @53ded82b packages/core/src/utils/sort-query.ts#convertSortToQueryParams still lowered the string clause there; no `sort` input on objectui @53ded82b packages/plugin-calendar/src/index.tsx; [70] object-timeline flat config keys (OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE): the backward-compat flat rung (with the `dateField` alias) in objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#startDateField and its siblings; emitted by objectui @b9814b334 packages/plugin-list/src/ListView.tsx#viewComponentSchema (`case 'timeline'`); re-read 2026-10-15, first cited sha-less; [71] object-calendar.navigation (C = objectui @b9814b334 packages/plugin-calendar/src/ObjectCalendar.tsx): C#navConfig (`schema.navigation ?? { mode: 'drawer' }`) C#navigation (useNavigationOverlay) C#calendarView (event click) C#renderRecordOverlay (NavigationOverlay); [72] object-timeline.mapping (ObjectTimelineMappingSchema; A = objectui @ab1879721595 packages/plugin-timeline/src/ObjectTimeline.tsx; unchanged at objectui main 94985a92ba): title A#titleField; date A#startDateField; description A#descField; variant A#variantField; declared on A#ObjectTimelineProps (`mapping`) and A#TimelineMappingSchema; [73] object-timeline.items[] arms (R = objectui @2e818d0b51ec packages/plugin-timeline/src/renderer.tsx; unchanged at objectui main 2abec3a96): gantt bar startDate / endDate R#dimensions R#calculateDateRange, variant and title in R#TimelineRenderer (the bar and its tooltip); feed entry time R#dateLabel and title, description, variant, icon, content (renderChildren), className in R#TimelineRenderer; gantt row label R#TimelineRenderer (TimelineGanttLabel), items R#classifyGanttRows; vocabulary objectui @2e818d0b51ec packages/types/src/data-display.ts#TimelineItemVariant; element shape and refinement objectui @2e818d0b51ec packages/types/src/zod/data-display.zod.ts#TimelineItemSchema and objectui @2e818d0b51ec packages/types/src/zod/data-display.zod.ts#timelineItemsFitVariant; [74] object-form.layout retired values `inline` / `grid` (OBJECT_FORM_LAYOUT_RETIRED; P = objectui @f8a9d0fb0 packages/plugin-form/src/): folded to 'vertical' in P ObjectForm.tsx#SimpleObjectForm; only 'vertical' / 'horizontal' passed through by P ObjectForm.tsx#drawerLayout, ObjectForm.tsx#modalLayout, DrawerForm.tsx#formLayout, ModalForm.tsx#formLayout; 'vertical' hard-coded in P TabbedForm.tsx#TabbedForm, SplitForm.tsx#panes, WizardForm.tsx#WizardForm; [75] object-form.mobile (ObjectFormMobileSchema; F = objectui @89cad75d55 packages/plugin-form/src/ObjectForm.tsx): F#mobileOpts (the flat arm); stickyActions F#formSchema; stepper, stepperMinFields, stepperFieldsPerStep F#stepperMode; fullscreenLongText F#fieldsWithMobile; the `data-mobile-form` marker in F#SimpleObjectForm; [76] object-form.fields and object-master-detail-form.fields (formFieldNameList): objectui @ab1879721595 packages/plugin-form/src/MasterDetailForm.tsx#parentSchema (hands `fields` over verbatim), read by objectui @ab1879721595 packages/plugin-form/src/ObjectForm.tsx#fieldsToShow and objectui @ab1879721595 packages/plugin-form/src/flatFields.ts#buildFlatFields (a stored `{ name }` entry drawn by name; any other object entry warned by warnUnresolvedTopLevelField); unchanged at objectui main 94985a92ba; the retired inline field entry an author was taught by objectui @ab1879721595 skills/objectui/guides/page-builder.md (its object-form example; first cited sha-less); [77] object-form.customFields[] `grid` widget keys (minRows, maxRows, allowAdd, allowDelete, allowReorder, totalField, addLabel, sortField; W = objectui @9dfaca654311 packages/fields/src/widgets/GridField.tsx): declared on objectui @9dfaca654311 packages/types/src/field-types.ts#GridFieldMetadata, carried by objectui @9dfaca654311 packages/types/src/form.ts#FormField, mirrored in objectui @9dfaca654311 packages/types/src/zod/form.zod.ts#FormFieldSchema; read W#cfg W#allowAdd W#removeRow (minRows) W#totalField W#showTotal W#emit (sortField) W#GridFieldBody (maxRows, allowReorder, addLabel); objectui#11610 (merge 2abec3a96c) camelCased them; the parent-field `totalField` hand-off objectui @9dfaca654311 packages/plugin-form/src/LineItemsPanel.tsx#gridField and objectui @9dfaca654311 packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailLines; the writer shape (one `grid` field carrying all eight camelCase keys) objectui @9dfaca654311 examples/schema-catalog/src/schemas/fields-grid/line-items-grid.json; [78] object-form.customFields[] retired snake_case `grid` keys (OBJECT_FORM_GRID_WIDGET_SNAKE_KEYS): objectui @9dfaca654311 packages/types/src/field-types.ts#GRID_FIELD_RETIRED_KEYS; refused by objectui @9dfaca654311 packages/fields/src/widgets/GridField.tsx#GridField, tombstoned on objectui @9dfaca654311 packages/types/src/field-types.ts#GridFieldMetadata and objectui @9dfaca654311 packages/types/src/form.ts#FormField, named in objectui @9dfaca654311 packages/types/src/zod/form.zod.ts#FormFieldSchema (retiredGridFieldKey); [79] object-form.customFields[].validation: objectui @2e818d0b51ec packages/components/src/renderers/form/form.tsx#rules (inside renderFormField: minLength/maxLength/min/max spread as `{ value, message }`, `required` read only as a message string); objectui's declaration objectui @2e818d0b51ec packages/types/src/form.ts#FieldValidationRules; [80] object-timeline.navigation: objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#navigation (useNavigationOverlay) and objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#effectiveSchema (entry click, overlay); re-read 2026-10-15, first cited sha-less, when the read still spelled `(schema as any).navigation`; [81] object-form.customFields[].options[] (buildObjectFormRuntimeOption; F = objectui @2e818d0b51ec packages/components/src/renderers/form/form.tsx): label, value F#BuiltinSelectControl and objectui @2e818d0b51ec packages/core/src/evaluator/optionRules.ts#optionDisplayLabel (the four option widgets); visibleWhen F#cascade (resolveCascadingOptions; each widget's useCascadingOptions); description objectui @2e818d0b51ec packages/fields/src/widgets/LookupField.tsx#filteredOptions; `value` a runtime value: F#BuiltinSelectControl (matchOptionValue), objectui @2e818d0b51ec packages/types/src/zod/form.zod.ts#SelectOptionSchema (deliberate divergence comment), objectui @2e818d0b51ec packages/plugin-designer/src/ObjectManager.tsx#formSchema (writes `{ label, value }` pairs); unchanged at objectui main fd060f076; [82] object-form.customFields[] and the inline section entry (buildObjectFormRuntimeField; F = objectui @2e818d0b51ec packages/components/src/renderers/form/form.tsx, W = objectui @2e818d0b51ec packages/fields/src/widgets/): merge objectui @2e818d0b51ec packages/plugin-form/src/customFieldsMerge.ts#mergeCustomFields from objectui @2e818d0b51ec packages/plugin-form/src/ObjectForm.tsx#generateField; carrier F#builtinBranchHasNoControl (`field.field || field`); field row F#renderFormField (name, label, description, type, required, disabled, readonly, hidden, colSpan via F#colSpanClass), widget F#resolvedType, validation F#rules, the three `*When` rules F#ruleState, placeholder and inputType F#builtinBranchHasNoControl, options and dependsOn F#cascade, multiple F#resolvedType; layout objectui @2e818d0b51ec packages/plugin-form/src/autoLayout.ts#resolveColSpan (span, colSpan) and objectui @2e818d0b51ec packages/plugin-form/src/fieldGroups.ts#deriveFieldGroupSections (group); widgets W TextAreaField.tsx#rows, FileField.tsx#multiple (accept, multiple), VectorField.tsx#dimensions, LookupField.tsx#referenceTo, NumberField.tsx#NumberField (min, max), F#renderFieldComponent (minLength, maxLength), FormulaField.tsx#returnType, SummaryField.tsx#summaryType, GridField.tsx#allColumns (columns); read and refused: visibleOn F#ruleState, condition F#legacyConditionCel, id F#fieldKey; a measured writer, the object-manager create / edit dialog (a `formType: 'modal'` block): objectui @b9814b334 packages/plugin-designer/src/ObjectManager.tsx#formSchema (first cited sha-less); [83] object-form / object-master-detail-form sections[].fields[] `{ field }` entry (buildObjectFormSectionFieldEntry): objectui @2e818d0b51ec packages/plugin-form/src/sectionFields.ts#resolveSectionEntry (names an object field by `field`, every other key overrides its generated definition; label/placeholder/helpText copied as they stand); [84] object-form / object-master-detail-form sections[] (buildObjectFormSection; F = objectui @2e818d0b51ec packages/plugin-form/src/ObjectForm.tsx): handed over verbatim by objectui @2e818d0b51ec packages/plugin-form/src/MasterDetailForm.tsx#parentSchema; name, label F#sectionCtx (heading) F#inertPredicateSections (per-formType maps); collapsible, collapsed F#collapse (resolveSectionCollapse); visibleWhen F#sectionCtx (divider-row predicate); columns F#secCols F#clampCol (a NUMBER only); pane objectui @2e818d0b51ec packages/plugin-form/src/SplitForm.tsx#paneOf; the inline runtime field drawn as it stands in objectui @2e818d0b51ec packages/plugin-form/src/sectionFields.ts#resolveSectionEntry; objectui's own declaration objectui @2e818d0b51ec packages/types/src/objectql.ts#ObjectFormSection; [85] object-form (ObjectFormPropsSchema: the UNION of `schema.*` reads of the simple form and the sub-forms it forwards the bag into): objectui @eb7f586b packages/plugin-form/src/ObjectForm.tsx#ObjectForm, with TabbedForm, WizardForm, SplitForm, DrawerForm, ModalForm under objectui @eb7f586b packages/plugin-form/src/; `layout` `inline` / `grid` folded to `vertical` there while the registry inputs and the designer palette offered them; [86] object-form.fields (the `{ name }` entry retired from the authoring faces by objectui#11550): a census of objectui @ab1879721595 found it written only by fixtures probing the stored read and the refusal; [87] object-form.customFields: objectui @2e818d0b51ec packages/plugin-form/src/customFieldsMerge.ts#mergeCustomFields (merged over the generated fields); at objectui @2e818d0b51ec the open runtime field spelled the eight `grid` widget keys in snake_case, camelCased by objectui#11610 (carried at objectui @9dfaca654311); [88] object-form.contentLayout: objectui @89cad75d55 packages/plugin-form/src/ModalForm.tsx#renderContent (`schema.contentLayout === 'tabbed' && groups.length > 1`), declared on objectui @89cad75d55 packages/plugin-form/src/ModalForm.tsx#ModalFormSchema; [89] object-form.submitBehavior: objectui @89cad75d55 packages/plugin-form/src/ObjectForm.tsx#handleSubmit and objectui @89cad75d55 packages/plugin-form/src/WizardForm.tsx#handleStepSubmit (switch on `kind`), redirect `url` judged by objectui @89cad75d55 packages/plugin-form/src/submitRedirect.ts#resolveSubmitRedirect (FormViewSchema.safeParse); [90] object-form.navigateOnSuccess: objectui @89cad75d55 packages/plugin-form/src/successBehavior.ts#resolveSuccessNavigate, called from objectui @89cad75d55 packages/plugin-form/src/ObjectForm.tsx#handleSubmit and objectui @89cad75d55 packages/plugin-form/src/WizardForm.tsx#handleStepSubmit; a refused value reported on the success toast in objectui @89cad75d55 packages/plugin-form/src/ObjectForm.tsx#handleSubmit; [91] object-master-detail-form.details[] (the eleven entry keys): objectui @31971ff1e28f packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailDetailConfig and every `d.KEY` read in objectui @31971ff1e28f packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailLines; re-read at objectui @89cad75d5570 for #21589; [92] object-master-detail-form.details[].sortField (REMOVED, tombstone): objectui @89cad75d5570 packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailDetailConfig (no `sortField` member), derived by objectui @89cad75d5570 packages/plugin-form/src/deriveMasterDetail.ts#deriveDetail (SORT_FIELD_NAMES) and handed to the grid as `sort_field` in objectui @89cad75d5570 packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailLines; at objectui @9dfaca654311 the same hand-off spells `sortField` (objectui#11610) from objectui @9dfaca654311 packages/plugin-form/src/deriveMasterDetail.ts#deriveDetail; an entry the renderer does not resolve loads no child schema (objectui @9dfaca654311 packages/plugin-form/src/MasterDetailForm.tsx#entries); [93] object-master-detail-form.sections, .fields: objectui @2e818d0b51ec packages/plugin-form/src/MasterDetailForm.tsx#parentSchema (handed to the parent object-form verbatim); [94] object-map flat config keys (OBJECT_MAP_FLAT_CONFIG_GUIDANCE): objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#getMapConfig (branch 2) and objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#warnOnShadowedFlatMapKeys and objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#FLAT_MAP_CONFIG_KEYS; emitted by objectui @b9814b334 packages/plugin-view/src/ObjectView.tsx#ObjectView and objectui @b9814b334 packages/plugin-list/src/ListView.tsx#viewComponentSchema (`case 'map'`); re-read 2026-10-15, first cited sha-less at the then pin; [95] object-map (ObjectMapPropsSchema, per key; P = objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx, C = objectui @b9814b334 packages/core/src/utils/record-source.ts, I = objectui @b9814b334 packages/plugin-map/src/index.tsx): data P#getDataConfig C#resolveRecordSourceConfig (rung 1; the deleted array head and its carriers are recorded in the docblock above P#FLAT_MAP_CONFIG_KEYS); staticData C#resolveRecordSourceConfig (rung 2); objectName C#resolveRecordSourceConfig (rung 3) P#navigation P#warnOnLegacyFilterMapConfig P#warnOnTopLevelStyleUrl P#warnOnShadowedFlatMapKeys (cache keys) P#recordSourceObjectName (useSettledSchema); filter P#queryFilter P#fetchData (`$filter` on both fetches); sort P#fetchData (convertSortToQueryParams); map P#getMapConfig (branch 1; validates against ObjectMapConfigSchema) I#ObjectMapRenderer (its registration's `map` input); mapStyle P#getMapConfig (`schema.mapStyle || schema.map?.style`) I#ObjectMapRenderer; navigation P#navigation I#ObjectMapRenderer; enableClustering P#clusteredData I#ObjectMapRenderer; measured and not declared: style P#warnOnTopLevelStyleUrl (read only to say it is not a map style); [96] object-map.filter: objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#queryFilter (useResolvedFilter) and objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#fetchData (`$filter` on the inline and the object fetch); the registration's `filter` input objectui @b9814b334 packages/plugin-map/src/index.tsx#ObjectMapRenderer; [97] object-map.sort: objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#fetchData (convertSortToQueryParams to `$orderby`, both fetches); the registration's array-form `sort` input (objectui#8220) objectui @b9814b334 packages/plugin-map/src/index.tsx#ObjectMapRenderer; [98] object-map.navigation: objectui @89cad75d55 packages/plugin-map/src/ObjectMap.tsx#navigation (hands `schema.navigation` to useNavigationOverlay) and objectui @89cad75d55 packages/react/src/hooks/useNavigationOverlay.ts#useNavigationOverlay (`navigation?.mode ?? 'page'`); [99] object-gantt flat config keys (OBJECT_GANTT_FLAT_CONFIG_GUIDANCE): objectui @b9814b334 packages/plugin-gantt/src/ObjectGantt.tsx#FLAT_GANTT_CONFIG_KEYS and objectui @b9814b334 packages/plugin-gantt/src/ObjectGantt.tsx#getGanttConfig (branch 2 reads them; a present `gantt` block is taken whole and the flat keys beside it named as ignored); re-read 2026-10-15, first cited sha-less at the then pin; [100] object-gantt.markers (objectGanttMarker): G = objectui @ab1879721595 packages/plugin-gantt/src/GanttView.tsx: objectui @ab1879721595 packages/plugin-gantt/src/ObjectGantt.tsx#ganttView (hands `schema.markers` to GanttView verbatim); date G#markers (time-zone re-base) G#resolvedMarkers (axis mapping, drops unparsable or out-of-range dates); color G#resolvedMarkers (`m.color || 'hsl(var(--primary))'`); drawn in G#GanttView and in G#buildExportSvg; the declaration taken: objectui @ab1879721595 packages/types/src/objectql.ts#ObjectGanttSchema and objectui @ab1879721595 packages/types/src/zod/objectql.zod.ts#ObjectGanttSchema (`markers`); unchanged at objectui main 94985a92ba; [101] object-gantt (ObjectGanttPropsSchema, per key; P = objectui @b9814b334 packages/plugin-gantt/src/ObjectGantt.tsx, C = objectui @b9814b334 packages/core/src/utils/record-source.ts): data P#rawDataConfig C#resolveRecordSourceConfig (rung 1); staticData C#resolveRecordSourceConfig (rung 2); objectName (rung 3) P#persistLayoutKey P#navigation; filter P#queryFilter P#reload (`$filter`); sort P#reload (convertSortToQueryParams to `$orderby`); gantt P#getGanttConfig (branch 1, validated against the spec's GanttConfigSchema) and objectui @b9814b334 packages/plugin-gantt/src/index.tsx#ObjectGanttRenderer (its registration's `gantt` object input); navigation P#navConfig P#navigation; label P#ganttView (resolveInlineI18nLabel, export file-name chain); skipWeekends, holidays P#workingCalendar; persistLayout, viewName P#persistLayoutKey; markers, criticalPath, showBaselines, readOnly, mobileReadOnly P#ganttView, readOnly also P#renderRecordOverlay; measured and not declared: title (absent from the P#ganttView export-name chain), row cap (P#reload spreads nonGridRowCeilingQuery, marked not authorable), search and searchableFields P#searchTerm P#reload (host-generated by objectui @b9814b334 packages/plugin-list/src/ListView.tsx#viewComponentSchema, its `case 'gantt'` arm); [102] object-gantt.navigation: objectui @89cad75d55 packages/plugin-gantt/src/ObjectGantt.tsx#navConfig (`schema.navigation ?? { mode: 'drawer' }`) and objectui @89cad75d55 packages/plugin-gantt/src/ObjectGantt.tsx#navigation (useNavigationOverlay); [103] object-tree flat config keys (OBJECT_TREE_FLAT_CONFIG_GUIDANCE: parentField, labelField, titleField, fields, defaultExpandedDepth): objectui @b9814b334 packages/plugin-tree/src/ObjectTree.tsx#getTreeConfig (reads the flat spellings ahead of the `tree` block; the `schema.titleField` rung's retirement is recorded in its docblock above `fieldKey`, objectui#8841), objectui @b9814b334 packages/plugin-list/src/ListView.tsx#viewComponentSchema (its `case 'tree'` arm emits parentField, `labelField: treeCfg.labelField || 'name'`, fields, defaultExpandedDepth and no titleField since objectui 3fd862510), objectui @b9814b334 packages/plugin-tree/src/index.tsx#treeInputs (`{ name: 'tree', type: 'object' }`, no flat input); [104] object-tree (ObjectTreePropsSchema, per key; P = objectui @b9814b334 packages/plugin-tree/src/ObjectTree.tsx, C = objectui @b9814b334 packages/core/src/utils/record-source.ts): data P#dataConfig (resolveRecordSourceConfig with the `view-data` arm) and C#resolveRecordSourceConfig (rung 1, authoredDataIsOnTheDeclaredArm); staticData C#resolveRecordSourceConfig (rung 2); objectName (rung 3) P#headerObjectName P#navigation P#renderRecordOverlay (each behind resolveRecordSourceObjectName) P#parentField (detectParentField); filter P#queryFilter (useResolvedFilter) P#fetchData (`$filter` on the object fetch and on the inline ValueDataSource query); tree P#getTreeConfig and objectui @b9814b334 packages/plugin-tree/src/index.tsx#treeInputs; navigation P#navigation (useNavigationOverlay); host-prop `data` (not the authored key) P#passed; measured and not declared: sort (no `$orderby` in P#fetchData), row cap (P#fetchData spreads nonGridRowCeilingQuery); [105] object-tree.objectName (optional because no ElementDataSourceGate wiring): objectui @b9814b334 packages/plugin-tree/src/index.tsx#ObjectTreeRenderer has 0 occurrences of `ElementDataSourceGate` (re-counted 2026-10-15), against controls objectui @b9814b334 packages/plugin-gantt/src/index.tsx#ObjectGanttRenderer, packages/plugin-grid/src/index.tsx, packages/plugin-calendar/src/index.tsx (3 each) and packages/plugin-map/src/index.tsx#ObjectMapRenderer (4); method: occurrences of the identifier in each `src/index.tsx`; [106] object-timeline (ObjectTimelinePropsSchema, per key; P = objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx, R = objectui @b9814b334 packages/plugin-timeline/src/renderer.tsx, I = objectui @b9814b334 packages/plugin-timeline/src/index.tsx): objectName P#ObjectTimeline (fetch gate) P#useSettledSchema P#fetchesForItself P#invalidationNonce P#fetchData P#handleRefresh P#effectiveItems P#navigation; timeline P#timelineConfig; filter P#queryFilter P#filterKey P#fetchData; sort P#sortKey P#fetchData (convertSortToQueryParams); limit P#fetchData (resolveRowLimit) P#DEFAULT_TIMELINE_LIMIT P#describeRefusedRowLimit; items P#hasInlineItems P#fetchesForItself P#effectiveItems P#hasAuthoredItems; data P#hasInlineData P#fetchesForItself P#rawData; descriptionField P#descField; mapping P#titleField P#startDateField P#descField P#variantField; variant P#hasAuthoredItems (the `timeline-unsupported-variant` refusal, objectui#6655) and I#OBJECT_TIMELINE_INPUTS; navigation P#navigation P#effectiveSchema; dateFormat, rowLabel, minDate, maxDate R#TimelineRenderer (rowLabel and minDate/maxDate in its gantt branch, R#findUnusableGanttDate) off P#effectiveSchema; measured and not declared: bind P#boundData (useDataScope), className P#effectiveSchema; [107] object-timeline.data (read off React props, not `schema`; the prop channel is SchemaRenderer's `properties.*` hoist, objectui @b9814b334 packages/react/src/SchemaRenderer.tsx#SchemaRenderer, with `object-timeline` absent from objectui @b9814b334 packages/core/src/utils/record-source.ts#RECORD_SOURCE_DATA_ARM_BY_TYPE): objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#hasInlineData (loading seed), objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#fetchesForItself (the query skip the fetch effect reads), objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#rawData (row source `(props as any).data || boundData || fetchedData`), objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#effectiveItems (the `timeline` field bindings a fetched row takes); [108] object-timeline.items: objectui @2e818d0b51ec packages/types/src/data-display.ts#TimelineFeedItem and objectui @2e818d0b51ec packages/types/src/data-display.ts#TimelineGanttItem (the declared authored entry, ruled on objectui#6356), handed to the rail verbatim at objectui @2e818d0b51ec packages/plugin-timeline/src/ObjectTimeline.tsx#effectiveItems (`if (schema.items) return schema.items`); [109] record:reference_rail, record:alert, record:quick_actions, record:history, record:line_items (rows): key sets measured from the renderers' read points; the per-key read points are recorded under each props schema below; [110] record:approval_decision (row, EMPTY key set): objectui @d03b022 packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx#ApprovalDecisionRenderer (landed in objectui a368ccb, module-internal at d03b022; discards `schema`, draws from the record context); [111] record:attachments (row, EMPTY key set): objectui @47b1f0bb71 packages/app-shell/src/views/record-attachments-renderer.tsx#RecordAttachmentsRenderer (registered as `attachments` in the `record` namespace; discards `schema`); [112] cloud-connection:panel, marketplace:installed-list (rows, EMPTY key sets): objectui @b9814b334 packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx#CloudConnectionPanel and objectui @b9814b334 packages/app-shell/src/console/marketplace/InstalledListWidget.tsx#InstalledList (each registration is `() => `; re-read 2026-10-15, first measured sha-less at the then pin); [113] mcp:connect-agent (row, EMPTY key set): objectui @b9814b334 packages/app-shell/src/console/connect/ConnectAgentWidget.tsx#ConnectAgent (registration `() => `; re-read 2026-10-15, first measured sha-less at the then pin); [114] element:definition-list, element:repeater (rows: the three-part string-arm evidence): objectui @b9814b334 packages/components/src/renderers/basic/data-list.tsx#DefinitionListRenderer and objectui @b9814b334 packages/components/src/renderers/basic/data-list.tsx#RepeaterRenderer (both registered in the `element` namespace), objectui @b9814b334 packages/core/src/registry/public-blocks.ts#PUBLIC_BLOCKS (publication), objectui @b9814b334 packages/app-shell/src/views/metadata-admin/previews/block-types.ts#BLOCK_TYPE_META (palette), objectui @b9814b334 packages/app-shell/src/views/metadata-admin/previews/block-config.ts#BLOCK_CONFIG (each block's inspector); [115] action:button, action:group, action:menu, action:icon (rows): objectui @b9814b334 packages/core/src/registry/public-blocks.ts#PUBLIC_BLOCKS (the four `action:*` members), registered under objectui packages/components/src/renderers/action/ in the `action` namespace; [116] object-map, object-gantt, object-tree (row key sets; per-key read points under each props schema): objectui @b9814b334 packages/plugin-map/src/index.tsx#ObjectMapRenderer, objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#ObjectMap, objectui @b9814b334 packages/plugin-gantt/src/index.tsx#ObjectGanttRenderer, objectui @b9814b334 packages/plugin-gantt/src/ObjectGantt.tsx#ObjectGantt, objectui @b9814b334 packages/plugin-tree/src/index.tsx#ObjectTreeRenderer, objectui @b9814b334 packages/plugin-tree/src/ObjectTree.tsx#ObjectTree; [117] object-timeline (row key set; per-key read points under ObjectTimelinePropsSchema): objectui @b9814b334 packages/plugin-timeline/src/index.tsx#ObjectTimelineRenderer (the `object-timeline` and `timeline` registrations), objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#ObjectTimeline, objectui @b9814b334 packages/plugin-timeline/src/renderer.tsx#TimelineRenderer; [118] EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT rows statistic, card, button (the top-level text keys each renderer reads back; R = objectui @82a9417 packages/components/src/renderers/, re-verified identical at objectui 6c68b13 on 2026-08-18): statistic `label`, `value`, `description` R data-display/statistic.tsx#StatisticRenderer; card `title`, `description` R layout/card.tsx#CardRenderer; button `label` R form/button.tsx#ButtonRenderer; the deliberate `text` omission, `schema.content || schema.value`, at its `text` registration in R basic/text.tsx; [119] PageComponent.dataSource, the PLANNED API binding (ElementApiDataSourceSchema; its record is this file's `_note`; R = objectui @7578323a544e packages/core/src/): `read.url`'s refusal of a `./` path, measured against the console's `apiBase` promotion of a root-relative `/api/…` path only, R actions/ActionRunner.ts#navigateTo; `read.method` GET alone because every read (find, findOne, aggregate) sends an explicit `method: 'GET'`, R adapters/ApiDataSource.ts#ApiDataSource; no renderer reads the binding yet, the hook admitting only a string `object`, R data-scope/element-data-source.ts#isElementDataSourceConfig; [120] PageComponent.visibleWhen (the component-level evaluator's roots): the ambient scope (`current_user`, `user`, `ctx.user`, `os.user`, `features`, nothing else) objectui @1fb1ced36243 packages/app-shell/src/providers/ExpressionProvider.tsx#buildExpressionScope; composed with `record` (only when the page has a row) and `page` by objectui @1fb1ced36243 packages/react/src/SchemaRenderer.tsx#configEvaluationScope; no `data` root (objectui#9308) in objectui @1fb1ced36243 packages/react/src/SchemaRenderer.tsx#evaluatedSchema; the files identical at objectui main f228d710; the `page:tabs` item-level evaluator is the `page:tabs items[].visibleWhen` entry;" }, "assignedProfiles": { "status": "dead", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 4f56b2223e6..098fbfc8370 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -14489,6 +14489,190 @@ const pageActionRowNavigationExecutorToUrl: MetadataConversion = { }, }; +/** + * A page action row's own `patch` leaves the contract (protocol 18, #24394 — + * ADR-0049 enforce-or-remove; the `domain:spec` seat's route on the card). + * + * Two implementations of one write disagreed, and the governed side wins. A + * row's `operation: 'update'` posts through the console runner's server + * dispatch to the platform action route under the row's `name`. The route + * resolves that declaration by name (ADR-0110 D2) and writes the + * DECLARATION's `patch`, merged under the request's params. The row's own + * `patch` reached no request. The runner posts the collected values alone and + * reads the row's `patch` for its Undo snapshot only. The tombstones on + * `ActionButtonProps`, `ActionIconProps` and each `action:group` / + * `action:menu` member refuse it for a live author: the component-props gate + * (`@objectstack/lint`) reports it at `os validate`, `os build`, `os lint` and + * the metadata save door. The load path keeps a page's open `properties` bag + * as written, so this entry carries stored rows and built artifacts. + * + * **A strip, not a move.** The values were never written, so deleting them + * changes no write the route performs. Moving them into the named action's + * `patch` is a judgement no walker can make: the action may live in another + * package, carry a `patch` of its own, or not exist. That half is the D3 entry + * `ui-page-action-row-patch-retired`. What the strip does change is the + * console's Undo snapshot, which captured the row's `patch` keys. + * + * Three surfaces: `properties.patch` on an `action:button` / `action:icon` + * node, and `patch` on each `action:group` / `action:menu` member of + * `properties.actions`. Scoped by component `type`, never by key name: `patch` + * is an ordinary name for an open-namespace component's own prop. A member + * that is not an object, or a list that is not an array, is left as stored. + * The reach is every position `mapPageComponents` visits (regions, slots, + * nested containers), which is the props gate's own reach. Idempotent by + * construction: `stripKeys` skips an absent key. + * + * Who writes the key: nobody measured. No page action row authors `patch` in + * this repository's examples or apps, and objectui (pin and `main`) authors + * none. + */ +const pageActionRowPatchRemoved: MetadataConversion = { + id: 'page-action-row-patch-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + surface: 'page.component.action:button.patch / page.component.action:icon.patch / ' + + 'page.component.action:group.actions[].patch / page.component.action:menu.actions[].patch', + summary: + "page action row 'patch' removed (a row's operation: 'update' runs the declared action its name names, and " + + "the platform action route writes that action's own patch, never the row's)", + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + const type = component.type; + if (typeof type !== 'string') return component; + const properties = component.properties; + if (!isDict(properties)) return component; + if (ACTION_RUNNER_BLOCK_TYPES.has(type)) { + const stripped = stripKeys(properties, ['patch'], emit, `${path}.properties`); + return stripped === properties ? component : { ...component, properties: stripped }; + } + if (!ACTION_CONTAINER_BLOCK_TYPES.has(type)) return component; + const actions = properties.actions; + if (!Array.isArray(actions)) return component; + let changed = false; + const nextActions = actions.map((member, i) => { + if (!isDict(member)) return member; + const stripped = stripKeys(member, ['patch'], emit, `${path}.properties.actions[${i}]`); + if (stripped !== member) changed = true; + return stripped; + }); + return changed ? { ...component, properties: { ...properties, actions: nextActions } } : component; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'task_record', + regions: [ + { + name: 'header', + components: [ + // The shape the rows invited: the write the route never made. + { + type: 'action:button', + properties: { label: 'Close', name: 'close_task', operation: 'update', patch: { status: 'done' } }, + }, + // A `patch` with no `operation`, which the rows already refused. + { type: 'action:icon', properties: { icon: 'check', name: 'close_task', patch: { status: 'done' } } }, + // A row WITHOUT the key rides through untouched. + { type: 'action:button', properties: { label: 'Reopen', name: 'reopen_task', operation: 'update' } }, + // ⚠️ The same key name on an open-namespace component — its own prop. + { type: 'acme:quick_edit', properties: { patch: { status: 'done' } } }, + { + type: 'action:group', + properties: { + actions: [ + { label: 'Close', name: 'close_task', operation: 'update', patch: { status: 'done' } }, + { label: 'Reopen', name: 'reopen_task', operation: 'update' }, + ], + }, + }, + // The nested position: a menu inside a card's `children`. + { + type: 'page:card', + properties: { + title: 'Triage', + children: [ + { + type: 'action:menu', + properties: { actions: [{ label: 'Close', name: 'close_task', operation: 'update', patch: { status: 'done' } }] }, + }, + ], + }, + }, + ], + }, + ], + }, + // The named-slot shape. + { + name: 'task_record_detail', + kind: 'slotted', + regions: [], + slots: { + header: { + type: 'action:icon', + properties: { icon: 'check', name: 'close_task', operation: 'update', patch: { status: 'done' } }, + }, + }, + }, + ], + }, + after: { + pages: [ + { + name: 'task_record', + regions: [ + { + name: 'header', + components: [ + { type: 'action:button', properties: { label: 'Close', name: 'close_task', operation: 'update' } }, + { type: 'action:icon', properties: { icon: 'check', name: 'close_task' } }, + { type: 'action:button', properties: { label: 'Reopen', name: 'reopen_task', operation: 'update' } }, + { type: 'acme:quick_edit', properties: { patch: { status: 'done' } } }, + { + type: 'action:group', + properties: { + actions: [ + { label: 'Close', name: 'close_task', operation: 'update' }, + { label: 'Reopen', name: 'reopen_task', operation: 'update' }, + ], + }, + }, + { + type: 'page:card', + properties: { + title: 'Triage', + children: [ + { + type: 'action:menu', + properties: { actions: [{ label: 'Close', name: 'close_task', operation: 'update' }] }, + }, + ], + }, + }, + ], + }, + ], + }, + { + name: 'task_record_detail', + kind: 'slotted', + regions: [], + slots: { + header: { type: 'action:icon', properties: { icon: 'check', name: 'close_task', operation: 'update' } }, + }, + }, + ], + }, + // The button, the bare icon, the group member, the nested menu member and + // the slotted header icon. The row without the key and the open-namespace + // component emit none. + expectedNotices: 5, + }, +}; + /** * `page.assignedProfiles` removed (protocol 18, ADR-0090 D2 / ADR-0049). * @@ -17398,6 +17582,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: objectMasterDetailFormDetailSortFieldRemoved, order: 60 }, { conversion: objectTenancyOrganizationFieldRemoved, order: 35 }, { conversion: pageActionRowNavigationExecutorToUrl, order: 63 }, + { conversion: pageActionRowPatchRemoved, order: 70 }, { conversion: pageAssignedProfilesRemoved, order: 31 }, { conversion: pageComponentFilterRecordToRuleArray, order: 36 }, { conversion: pageComponentResponsiveRemoved, order: 13 }, diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ActionButtonProps__patch.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ActionButtonProps__patch.ts new file mode 100644 index 00000000000..25f90bd5072 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ActionButtonProps__patch.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #24394 — ADR-0049 enforce-or-remove through the ADR-0087 D2 route. A page +// action row's own `patch` (here `action:button`'s) declared a write the +// platform never performs: the row's `operation: 'update'` posts to the +// platform action route under the row's `name`, and the route resolves that +// declaration by name (ADR-0110 D2) and writes the declaration's own `patch`. +// The row's `patch` reached no request; the console read it for its Undo +// snapshot only. Tombstoned with `retiredKey()` on the row and on +// `action:icon` and each `action:group` / `action:menu` member (the members' +// keys have no surface row of their own, so two keys are registered); +// `ActionSchema.patch` is untouched. Stored and built pages are stripped by the +// D2 conversion `page-action-row-patch-removed`, whose D3 record is +// `ui-page-action-row-patch-retired`. +export const entry = 'ui/ActionButtonProps:patch'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ActionIconProps__patch.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ActionIconProps__patch.ts new file mode 100644 index 00000000000..472521fb2d0 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ActionIconProps__patch.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #24394 — `action:icon`'s own `patch`, retired with `action:button`'s for the +// same reason: the row's `operation: 'update'` runs the declared action its +// `name` names, and the platform action route writes THAT declaration's +// `patch` (ADR-0110 D2), never the row's. Same tombstone, conversion +// (`page-action-row-patch-removed`) and D3 record +// (`ui-page-action-row-patch-retired`) as `ui/ActionButtonProps:patch`. +export const entry = 'ui/ActionIconProps:patch'; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-action-group-menu-members-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-action-group-menu-members-typed.ts index 03ce9983499..d36b6e4535d 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-action-group-menu-members-typed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-action-group-menu-members-typed.ts @@ -20,7 +20,7 @@ export const entry: SemanticMigration = { + 'used to pass unjudged)', replacement: 'an inline action with `action:button`\'s keys, its executor spelled `type`: `{ name?, label?, ' + 'icon?, type?, variant?, visible?, disabled?, tags?, params?, description?, target?, openIn?, method?, ' - + 'bodyExtra?, bodyShape?, operation?, patch?, confirmText?, successMessage?, errorMessage?, refreshAfter?, ' + + 'bodyExtra?, bodyShape?, operation?, confirmText?, successMessage?, errorMessage?, refreshAfter?, ' + 'locations?, toast?, resultDialog?, onSuccess?, objectName? }`, plus `size?` on an `action:group` member. ' + 'Write `actionType` as `type`, `endpoint` (and `url` / `path` / `href`) as `target`, `enabled` as `disabled` ' + 'with the condition inverted, and `outcomeMessages` as one `successMessage`; drop a member `className`, ' diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-declarative-update-rules.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-declarative-update-rules.ts index b2fe7ba7a83..1da53a94187 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-declarative-update-rules.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-declarative-update-rules.ts @@ -8,12 +8,14 @@ export const entry: SemanticMigration = { + '`action:menu` member of `properties.actions`) that carries `patch` without `operation: \'update\'`, or ' + 'that carries `operation: \'update\'` beside an executor other than `\'script\'` (`actionType` on a node, ' + '`type` on a member), beside `target`, `method`, `bodyExtra`, `bodyShape` or (on `action:button`) ' - + '`recordIdField`, with nothing to write (no `patch`, and no non-empty `params`), or with `list_toolbar` in ' - + '`locations`. Also a row `patch` that is not a record of field values', - replacement: 'Write the row as `ActionSchema` accepts the same action: `operation: \'update\'` with a `patch` ' - + 'record (or `params` that supply the values), no executor key or `\'script\'`, none of the dispatch keys, ' - + 'and record locations only. A row that meant a call of its own keeps its executor and its `target` and ' - + 'drops `operation` and `patch` instead. ⛔ NOT mechanically convertible: whether a row meant the ' + + '`recordIdField`, with nothing to write (no `name`: the row runs the declared action its `name` names), or ' + + 'with `list_toolbar` in `locations`. A row\'s own `patch` is retired outright ' + + '(`ui-page-action-row-patch-retired`)', + replacement: 'Write the row as `ActionSchema` accepts the same action, except that its write is the declared ' + + 'action its `name` names: `operation: \'update\'` with that `name` (the action\'s own `patch` is the write, ' + + 'and the row carries none), no executor key or `\'script\'`, none of the dispatch keys, and record ' + + 'locations only. A row that meant a call of its own keeps its executor and its `target` and drops ' + + '`operation` instead. ⛔ NOT mechanically convertible: whether a row meant the ' + 'declarative write or the executor is the author\'s call.', reason: 'Every page action row forwards `operation`, `patch`, `params`, `target`, its executor key and `locations` ' @@ -22,7 +24,9 @@ export const entry: SemanticMigration = { + 'that bind one are never read, a `patch` without it is never written, and a `patch` that is not a record ' + 'is dropped before the write. `ActionSchema` refused each of these shapes on an action; the rows accepted ' + 'them. The rows now apply the same judge, read from one place, with the action\'s own texts (the executor ' - + 'spelled as the row spells it), and take `ActionSchema`\'s own `patch` record by reference. A page\'s ' + + 'spelled as the row spells it). Two rules read the row\'s own surface: the route writes the `patch` of the ' + + 'declared action the row\'s `name` names, so the row\'s own `patch` is retired ' + + '(`ui-page-action-row-patch-retired`) and its nothing-to-write rule asks for that `name`. A page\'s ' + '`properties` is not parsed by the page schema; the component-props gate parses it against these same rows, ' + 'at `os validate`, `os build`, `os lint` and the metadata save door, and reports each refusal at the row\'s ' + 'own key; the load path runs no such gate, so a stored page still loads and its next save meets the ' @@ -33,7 +37,7 @@ export const entry: SemanticMigration = { acceptanceCriteria: 'No page action row carries `patch` without `operation: \'update\'`; every row with `operation: \'update\'` ' + 'names no executor or `\'script\'`, carries none of `target`, `method`, `bodyExtra`, `bodyShape` and ' - + '`recordIdField`, has a `patch` record or non-empty `params`, and lists no `list_toolbar` location; ' + + '`recordIdField`, names its declared action (`name`), and lists no `list_toolbar` location; ' + '`os validate` reports no `component-props-invalid` finding at `properties.KEY` or ' + '`properties.actions.N.KEY` for those keys, and every such row parses as before.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-operation-declared.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-operation-declared.ts index a35aaa8df29..3bd77199519 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-operation-declared.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-operation-declared.ts @@ -8,7 +8,9 @@ export const entry: SemanticMigration = { + 'and `operation` on each `action:group` / `action:menu` member of `properties.actions`): any value but ' + '`\'update\'`. Also the `onSuccess` of a page action row that declares `operation: \'update\'`, whatever ' + 'executor the row names (`actionType` on a node, `type` on a member, or none)', - replacement: '`operation: \'update\'` with a `patch` for the declarative single-record write, or no `operation`. ' + replacement: '`operation: \'update\'` with a `name` naming the declared action whose `patch` is the declarative ' + + 'single-record write (the row carries no `patch` of its own: `ui-page-action-row-patch-retired`), or no ' + + '`operation`. ' + 'A row delete is the object\'s own delete affordance; a bulk def\'s `\'custom\'` has no row form, because the ' + 'row is already the action. Beside `operation: \'update\'`, no `onSuccess`. ⛔ NOT mechanically convertible: ' + 'whether a row meant the write or the hop is the author\'s call.', diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-patch-retired.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-patch-retired.ts new file mode 100644 index 00000000000..3f86101e850 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-patch-retired.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #24394 (ADR-0049 enforce-or-remove) — the D3 entry of the +// `page-action-row-patch-removed` family (one D3 entry per retirement family, +// even when D2 is lossless for the write). Registered keys: +// `ui/ActionButtonProps:patch`, `ui/ActionIconProps:patch`; the +// `action:group` / `action:menu` member carries the same tombstone and has no +// surface row of its own. The strip changes no write the platform performs; +// what it can change is the console's Undo snapshot, and what it cannot do is +// move the values onto the named action. +export const entry: SemanticMigration = { + id: 'ui-page-action-row-patch-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: 'page.component.action:button.patch / page.component.action:icon.patch / ' + + 'page.component.action:group.actions[].patch / page.component.action:menu.actions[].patch — ' + + 'the static field values a page action row declared for its operation: update', + replacement: + 'The `patch` of the declared action the row\'s `name` names. A row\'s `operation: \'update\'` runs that action, ' + + 'so write the values there (`defineAction({ name, operation: \'update\', patch })`), give the row that ' + + '`name`, and delete the row\'s `patch`. A row\'s static `params` are written only where that action ' + + 'declares them.', + reason: + 'A page action row\'s `operation: \'update\'` posts through the console runner\'s server dispatch to the ' + + 'platform action route under the row\'s `name`, and the route resolves that declaration by name and ' + + 'writes the declaration\'s own `patch`, merged under the request\'s params, which it judges against the ' + + 'params the declaration declares. The row\'s `patch` was never sent: the runner posted the collected ' + + 'values alone and read the row\'s `patch` for its Undo snapshot only. So a row whose `patch` differed ' + + 'from its action\'s wrote the action\'s values and offered an Undo of the row\'s. The D2 conversion ' + + '`page-action-row-patch-removed` deletes the key and changes nothing the route writes. What it cannot ' + + 'decide is where the values belong: when the named action\'s own `patch` lacks a value the row carried, ' + + 'that value was never written, and adding it to the action is a change to what the action writes, ' + + 'which is the author\'s call. A row with `operation: \'update\'` and no `name` is now refused at `name`, ' + + 'because it has no declared action to run: the console\'s server dispatch refused it before any ' + + 'request. Measured producers: none in this repository\'s examples or apps; none among objectui\'s page ' + + 'rows at its pin or main.', + acceptanceCriteria: + 'No page action row carries `patch`, and every row with `operation: \'update\'` names a declared ' + + '`operation: \'update\'` action whose `patch` holds the values the row is meant to write; `os validate` ' + + 'reports no `component-props-invalid` finding at `properties.patch`, `properties.actions.N.patch`, ' + + '`properties.name` or `properties.actions.N.name`, and clicking the row in the console writes the ' + + 'action\'s `patch` as before.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 53a93fc2127..491b535a527 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -3192,6 +3192,21 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '`os migrate meta` replay it. Its D3 record is the semantic entry ' + '`ui-page-action-row-navigation-executor-refused`.', }, + { + id: 'ui-page-action-row-patch-retired', + order: 103, + text: + 'It also retires the page action rows\' own `patch` (`action:button`, `action:icon`, and each ' + + '`action:group` / `action:menu` member; ADR-0049 enforce-or-remove). A row\'s `operation: \'update\'` ' + + 'runs the declared action its `name` names: the platform action route resolves that action by name ' + + '(ADR-0110 D2) and writes the action\'s own `patch`, so the row\'s `patch` was never sent and never ' + + 'written. The rows now carry a tombstone at `patch`, and beside `operation: \'update\'` they need a `name`. ' + + 'A row\'s static `params` are written only where that action declares them. The D2 conversion ' + + '`page-action-row-patch-removed` strips the key, retired from the load path so authors are refused at ' + + 'the door while stored rows and `os migrate meta` replay it. Its D3 record is the semantic entry ' + + '`ui-page-action-row-patch-retired`, which names the judgement the strip cannot make: moving the ' + + 'values onto the named action\'s `patch`.', + }, { id: 'ui-record-json-retired', order: 100, From a5e985ed6799ba37b7a030beeeeb8eddb24329cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 01:34:39 +0000 Subject: [PATCH 3/6] wip(spec): regenerate authorable surface and reference docs for the row patch tombstone Claude-Session: https://claude.ai/code/session_01WnW7HvfnfHbLg1fpkHX3Mu Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 28 ++++++++++++------------ packages/spec/authorable-surface/ui.json | 4 ++-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index e47a34301a9..0dccae03fa7 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -28,7 +28,7 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | optional | Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone. With no `actionType`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names | +| **name** | `string` | optional | Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone. With no `actionType`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names. Beside `operation: 'update'` it is required: it names the declared action the platform action route runs, whose `patch` is the write | | **label** | `string` | optional | Button text. A literal string, placed as-is — localize through the translation bundle entry for this component id, not an inline locale map | | **icon** | `string` | optional | Lucide icon name drawn left of the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | | **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type`: on a page component `type` is the component itself | @@ -36,15 +36,15 @@ const result = ActionButtonPropsSchema.parse(data); | **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon' \| 'md'>` | optional | Button size — the Button primitive's vocabulary, plus `md`, which renders as `default` (renderer default: `default`) | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the button is not rendered when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the button is shown but cannot be pressed while it is TRUE. Omit for never-disabled | -| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs; an object is forwarded as the static parameter values | +| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs; an object is forwarded as the static parameter values. Beside `operation: 'update'` both ride the request as params: the route writes them only where the action the row's `name` names declares them, and refuses any other | | **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title | | **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: 'url'`, or no or an empty `actionType` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | | **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner | | **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner | | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | -| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape`, `recordIdField` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location | -| **patch** | `Record` | optional | Static field values the declarative `operation: 'update'` writes, forwarded to the runner: `ActionSchema`'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: 'update'` (it would be silently dropped) | +| **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape`, `recordIdField` and `onSuccess` are refused, and `list_toolbar` is not a location | +| **patch** | `never` | optional | [REMOVED] An `action:button`'s `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. 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. | | **confirmText** | `string` | optional | Confirmation question asked before the action runs | | **successMessage** | `string` | optional | Toast shown when the action succeeds | | **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | @@ -86,7 +86,7 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names | +| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names. Beside `operation: 'update'` it is required: it names the declared action the platform action route runs, whose `patch` is the write | | **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | | **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | @@ -101,8 +101,8 @@ const result = ActionButtonPropsSchema.parse(data); | **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner | | **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner | | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | -| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location | -| **patch** | `Record` | optional | Static field values the declarative `operation: 'update'` writes, forwarded to the runner: `ActionSchema`'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: 'update'` (it would be silently dropped) | +| **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location | +| **patch** | `never` | optional | [REMOVED] An `action:group` or `action:menu` member's `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. 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. | | **confirmText** | `string` | optional | Confirmation question asked before the action runs | | **successMessage** | `string` | optional | Toast shown when the action succeeds | | **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | @@ -123,7 +123,7 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | optional | Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button`. With no `actionType`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names | +| **name** | `string` | optional | Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button`. With no `actionType`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names. Beside `operation: 'update'` it is required: it names the declared action the platform action route runs, whose `patch` is the write | | **label** | `string` | optional | Accessible label and tooltip text (and its first letter stands in when no icon resolves). A literal string — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name, resolved through the shared action-icon resolver (renderer registration default `play`) | | **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type` | @@ -131,14 +131,14 @@ const result = ActionButtonPropsSchema.parse(data); | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the icon is not rendered when it is FALSE. Omit for always-visible | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the icon is shown but cannot be pressed while it is TRUE. Omit for never-disabled | | **description** | `string` | optional | Action description — the tooltip text when there is no `label`, and forwarded to the runner for the parameter dialog | -| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user; an object is forwarded as the static parameter values | +| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user; an object is forwarded as the static parameter values. Beside `operation: 'update'` both ride the request as params: the route writes them only where the action the row's `name` names declares them, and refuses any other | | **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: 'url'`, or no or an empty `actionType` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | | **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner | | **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner | | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | -| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location | -| **patch** | `Record` | optional | Static field values the declarative `operation: 'update'` writes, forwarded to the runner: `ActionSchema`'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: 'update'` (it would be silently dropped) | +| **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location | +| **patch** | `never` | optional | [REMOVED] An `action:icon`'s `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. 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. | | **confirmText** | `string` | optional | Confirmation question asked before the action runs | | **successMessage** | `string` | optional | Toast shown when the action succeeds | | **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | @@ -176,7 +176,7 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names | +| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type`, or an empty one, the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target, and `onSuccess` is judged beside the executor the name names. Beside `operation: 'update'` it is required: it names the declared action the platform action route runs, whose `patch` is the write | | **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | | **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | @@ -191,8 +191,8 @@ const result = ActionButtonPropsSchema.parse(data); | **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner | | **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner | | **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | -| **operation** | `Enum<'update'>` | optional | Declarative single-record write, forwarded to the runner together with `patch`: `ActionSchema`'s own `operation`, whose one member is `update`. The runner reads it before any executor, performs the write through the platform action route and runs no executor of its own. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, a `patch` or `params` gives the values to write, and `list_toolbar` is not a location | -| **patch** | `Record` | optional | Static field values the declarative `operation: 'update'` writes, forwarded to the runner: `ActionSchema`'s own record of field values, merged UNDER the values `params` supplies. Refused on a row without `operation: 'update'` (it would be silently dropped) | +| **operation** | `Enum<'update'>` | optional | Declarative single-record write: `ActionSchema`'s own `operation`, whose one member is `update`. The row runs the declared action its `name` names. The runner reads `operation` before any executor and posts the row's values to the platform action route under that `name`; the route resolves that action by name and writes ITS `patch`, so the row has no `patch` of its own and needs a `name`. What the row's `params` supply rides the request as params, written only where that action declares them and otherwise refused by the route. Judged as `ActionSchema` judges it: beside it the executor key is absent or `script`, `target`, `method`, `bodyExtra`, `bodyShape` and `onSuccess` are refused, and `list_toolbar` is not a location | +| **patch** | `never` | optional | [REMOVED] An `action:group` or `action:menu` member's `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. 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. | | **confirmText** | `string` | optional | Confirmation question asked before the action runs | | **successMessage** | `string` | optional | Toast shown when the action succeeds | | **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index c2bf6b874dd..b74034a632d 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -75,7 +75,7 @@ "ui/ActionButtonProps:openIn", "ui/ActionButtonProps:operation", "ui/ActionButtonProps:params", - "ui/ActionButtonProps:patch", + "ui/ActionButtonProps:patch [RETIRED]", "ui/ActionButtonProps:recordIdField", "ui/ActionButtonProps:refreshAfter", "ui/ActionButtonProps:resultDialog", @@ -111,7 +111,7 @@ "ui/ActionIconProps:openIn", "ui/ActionIconProps:operation", "ui/ActionIconProps:params", - "ui/ActionIconProps:patch", + "ui/ActionIconProps:patch [RETIRED]", "ui/ActionIconProps:refreshAfter", "ui/ActionIconProps:resultDialog", "ui/ActionIconProps:successMessage", From 8a64d441fbcbebc9489a7506ea626e522041f6d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 01:42:59 +0000 Subject: [PATCH 4/6] wip(spec,lint): pins for the retired row patch and the row's named-action rule Claude-Session: https://claude.ai/code/session_01WnW7HvfnfHbLg1fpkHX3Mu Co-authored-by: Claude --- ...ate-component-props-row-on-success.test.ts | 5 +- .../lint/src/validate-component-props.test.ts | 38 ++ .../retired-key-removal-version.pin.test.ts | 8 + ...nent-action-row-declarative-update.test.ts | 127 ++--- ...ent-action-row-on-success-executor.test.ts | 18 +- ...ponent-action-row-patch-retirement.test.ts | 521 ++++++++++++++++++ ...omponent-props-unknown-members.pin.test.ts | 11 +- 7 files changed, 652 insertions(+), 76 deletions(-) create mode 100644 packages/spec/src/ui/component-action-row-patch-retirement.test.ts diff --git a/packages/lint/src/validate-component-props-row-on-success.test.ts b/packages/lint/src/validate-component-props-row-on-success.test.ts index 16b18871eda..8b245ae9e32 100644 --- a/packages/lint/src/validate-component-props-row-on-success.test.ts +++ b/packages/lint/src/validate-component-props-row-on-success.test.ts @@ -117,7 +117,8 @@ describe("the props gate refuses a page action row's `onSuccess` beside an execu // `component-action-row-declarative-update.test.ts`). describe("the props gate judges a page action row's `operation` as `ActionSchema` does", () => { - const UPDATE = { operation: 'update', patch: { status: 'done' } }; + // The row runs the declared action its `name` names; a row has no `patch` of its own. + const UPDATE = { operation: 'update', name: 'close_task' }; it.each(ROWS)("REFUSAL: %s with `script`, `operation: 'update'` and `onSuccess` is one finding at `onSuccess`", (_row, node, at, key, subject) => { const findings = validateComponentProps(stackWith(node({ [key]: 'script', ...UPDATE, onSuccess: { navigate: '/tasks' } }))); @@ -132,7 +133,7 @@ describe("the props gate judges a page action row's `operation` as `ActionSchema }); it.each(ROWS)("REFUSAL: %s with `operation: 'delete'` is one finding at `operation`", (_row, node, at) => { - const findings = validateComponentProps(stackWith(node({ operation: 'delete', patch: { status: 'done' } }))); + const findings = validateComponentProps(stackWith(node({ operation: 'delete', name: 'close_task' }))); expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.operation`]]); expect(findings[0].message).toContain("`operation: 'delete'` is a bulk-def operation with no row-level form"); }); diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 50bb196be67..6d1c78aeb5a 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -123,6 +123,44 @@ describe('validateComponentProps — undeclared keys', () => { } }); + // #24394 — a page action row's own `patch` is a retiredKey tombstone on every + // row: the row's `operation: 'update'` runs the declared action its `name` + // names, and the platform action route writes THAT action's `patch`. + it('refuses a page action row `patch` as an error carrying the prescription, on each node and member', () => { + const PATCH = { status: 'done' }; + const cases: ReadonlyArray = [ + [{ type: 'action:button', properties: { label: 'Close', name: 'close_task', operation: 'update', patch: PATCH } }, + 'properties.patch', "An `action:button`'s `patch` was removed in @objectstack/spec 18"], + [{ type: 'action:icon', properties: { icon: 'check', name: 'close_task', operation: 'update', patch: PATCH } }, + 'properties.patch', "An `action:icon`'s `patch` was removed in @objectstack/spec 18"], + [{ type: 'action:group', properties: { actions: [{ label: 'Close', name: 'close_task', operation: 'update', patch: PATCH }] } }, + 'properties.actions.0.patch', "An `action:group` or `action:menu` member's `patch` was removed in @objectstack/spec 18"], + [{ type: 'action:menu', properties: { actions: [{ label: 'Close', name: 'close_task', operation: 'update', patch: PATCH }] } }, + 'properties.actions.0.patch', "An `action:group` or `action:menu` member's `patch` was removed in @objectstack/spec 18"], + ]; + for (const [component, at, opening] of cases) { + const findings = validateComponentProps(stackWith([component])); + expect(findings, at).toHaveLength(1); + const [f] = findings; + expect(f.rule).toBe('component-props-invalid'); + expect(f.severity).toBe('error'); + expect(f.path).toBe(`pages[0].regions[0].components[0].${at}`); + expect(f.message).toContain(opening); + expect(f.message).toContain("Move the values into the `patch` of the action the row's `name` names"); + } + }); + + it("refuses a page action row's `operation: 'update'` with no `name` at `name`, and accepts it with one", () => { + const nameless = validateComponentProps(stackWith([{ type: 'action:button', properties: { label: 'Close', operation: 'update' } }])); + expect(nameless.map((f) => [f.rule, f.path])).toEqual([ + ['component-props-invalid', 'pages[0].regions[0].components[0].properties.name'], + ]); + const named = validateComponentProps( + stackWith([{ type: 'action:button', properties: { label: 'Close', name: 'close_task', operation: 'update' } }]), + ); + expect(named).toEqual([]); + }); + // #21589 — an `object-master-detail-form` detail entry's `sortField` is a // retiredKey tombstone, one array level down. The same door and, since // #23026, the same refusal: an error that names the entry's key. diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 32155373e7c..20c1cfaf110 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -261,6 +261,14 @@ const JOIN: Readonly> = { refusal("action:group member type 'navigation'", () => ActionGroupPropsSchema.safeParse({ actions: [{ label: 'Clone', type: 'navigation', target: '/apps/crm/tasks' }] })), refusal("action:menu member type 'navigation'", () => ActionMenuPropsSchema.safeParse({ actions: [{ label: 'Clone', type: 'navigation', target: '/apps/crm/tasks' }] })), ], + // #24394: retired after 17.7.0, so the v18 train's major. The two nodes carry + // the tombstone at `patch`; the containers' shared member carries the same one. + 'page-action-row-patch-removed': [ + tombstone(ActionButtonPropsSchema, 'action:button', 'patch'), + tombstone(ActionIconPropsSchema, 'action:icon', 'patch'), + refusal("action:group member's patch", () => ActionGroupPropsSchema.safeParse({ actions: [{ label: 'Close', patch: { status: 'done' } }] }), 'actions'), + refusal("action:menu member's patch", () => ActionMenuPropsSchema.safeParse({ actions: [{ label: 'Close', patch: { status: 'done' } }] }), 'actions'), + ], 'cbp-master-detail-required-defaulted': [ refusal( 'a controlled_by_parent master_detail without `required`', diff --git a/packages/spec/src/ui/component-action-row-declarative-update.test.ts b/packages/spec/src/ui/component-action-row-declarative-update.test.ts index 7017029d70e..d84a81bd53f 100644 --- a/packages/spec/src/ui/component-action-row-declarative-update.test.ts +++ b/packages/spec/src/ui/component-action-row-declarative-update.test.ts @@ -4,20 +4,29 @@ * The page action rows — `action:button`, `action:icon` and an `action:group` / * `action:menu` member — judged by `ActionSchema`'s declarative-update rules, * from the one judge both read (`action-declarative-update.ts`). Every row - * forwards `operation`, `patch`, `params`, `target`, the executor key and + * forwards `operation`, `name`, `params`, `target`, the executor key and * `locations` to the console's action runner, which reads `operation` before - * any executor; so a row the action would refuse is refused here too, at the + * any executor and posts the write to the platform action route under the + * row's `name`; so a row the action would refuse is refused here too, at the * same key, with the action's text (the executor spelled as the row spells it). * - * - §1 THE FIVE MEMBERS the family's closing card names, on every row: each is + * Two of the action's five rules read the row's own surface (#24394): the + * route writes the `patch` of the declared action the row's `name` names. So a + * row has no `patch` of its own (its tombstone, pinned in + * `component-action-row-patch-retirement.test.ts`, replaces the action's + * "`patch` without `operation`" rule), and a row with nothing to write is a + * row with no `name`, refused at `name`. + * + * - §1 THE THREE MEMBERS a row shares with the action, on every row: each is * one issue at its key, and the same row made valid parses. * - §2 ONE TEXT: a member's refusal is `ActionSchema`'s, derived from the * action's own parse rather than restated here. * - §3 THE REST OF THE SET: the refused-key table's other row keys, the keys a - * row does not declare (refused as unknown before the judge), the executor - * rule's edges, `params` as a source of values, and `patch` as the action's - * own record. - * - §4 OPERATION FIRST: beside the operation, the rows' url-target and + * row does not declare (refused as unknown before the judge), and the + * executor rule's edges. + * - §4 THE ROW'S OWN SURFACE: nothing to write is no `name`; `params` are no + * write of the row's own; the action's half of the rule does not move. + * - §5 OPERATION FIRST: beside the operation, the rows' url-target and * executor-alias judgments stand down, so a row is never told to fix a key * it must drop. * @@ -27,8 +36,6 @@ */ import { describe, it, expect } from 'vitest'; -import type { z } from 'zod'; - import { ActionSchema } from './action.zod'; import { ActionButtonPropsSchema, ActionIconPropsSchema, ComponentPropsMap } from './component.zod'; @@ -105,11 +112,12 @@ const asRowSpells = (row: Row, actionText: string): string => ? actionText : actionText.replaceAll("a `type: '", "an `actionType: '").replaceAll("`type: '", "`actionType: '"); -const UPDATE = { operation: 'update', patch: { status: 'done' } } as const; +/** A row's declarative update: it runs the declared action its `name` names. */ +const UPDATE = { operation: 'update', name: 'close_task' } as const; const STATUS_PARAM = [{ name: 'status', type: 'text', label: 'Status' }]; // =========================================================================== -// §1 + §2. The five members, on every row +// §1 + §2. The three members a row shares with the action, on every row // =========================================================================== interface Member { @@ -123,12 +131,6 @@ interface Member { } const MEMBERS: readonly Member[] = [ - { - title: '1. `patch` without `operation`', - keys: () => ({ patch: { status: 'done' } }), - at: () => ['patch'], - control: () => ({ ...UPDATE }), - }, { title: "2. `operation: 'update'` + `target`", keys: () => ({ ...UPDATE, target: 'close_task_handler' }), @@ -141,12 +143,6 @@ const MEMBERS: readonly Member[] = [ at: (k) => [k], control: (k) => ({ ...UPDATE, [k]: 'script' }), }, - { - title: "4. `operation: 'update'` with no `patch` and no `params`", - keys: () => ({ operation: 'update' }), - at: () => ['patch'], - control: () => ({ operation: 'update', params: STATUS_PARAM }), - }, { title: "5. `operation: 'update'` + a `list_toolbar` location", keys: () => ({ ...UPDATE, locations: ['record_header', 'list_toolbar'] }), @@ -157,7 +153,7 @@ const MEMBERS: readonly Member[] = [ const ROW_MEMBERS = ROWS.flatMap((row) => MEMBERS.map((member) => [row.name, member.title, row, member] as const)); -describe('the five declarative-update shapes the rows accepted while `ActionSchema` refused them', () => { +describe('the declarative-update shapes the rows accepted while `ActionSchema` refused them', () => { it.each(ROW_MEMBERS)('REFUSAL: %s, member %s, is ONE issue at its key', (_name, _title, row, member) => { const issues = issuesOf(row.parse(member.keys(row.executorKey))); expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, ...member.at(row.executorKey)]]]); @@ -261,58 +257,65 @@ describe("the executor rule's edges on a row", () => { }); }); -describe('`params` as the source of the values to write', () => { - it.each(ROWS)('$name: a non-empty input list counts; an empty one does not', (row) => { - expect(row.parse({ operation: 'update', params: STATUS_PARAM }).success).toBe(true); - const issues = issuesOf(row.parse({ operation: 'update', params: [] })); - expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'patch']]]); +// =========================================================================== +// §4. The row's own surface +// =========================================================================== + +describe("nothing to write, on a row's surface: no `name`", () => { + it.each(ROWS)("REFUSAL: $name with `operation: 'update'` and no `name`, or an empty one, is ONE issue at `name`", (row) => { + for (const keys of [{ operation: 'update' }, { operation: 'update', name: '' }]) { + const issues = issuesOf(row.parse(keys)); + expect(issues.map((i) => [i.code, i.path]), JSON.stringify(keys)).toEqual([['custom', [...row.at, 'name']]]); + expect(issues[0].message).toContain('`name`'); + } + }); + + it.each(ROWS)("CONTROL: $name with `operation: 'update'` and a `name`, and no `patch` and no `params`, parses", (row) => { + // The write is the named action's own `patch`, which the route resolves. + const r = row.parse({ ...UPDATE }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }); + + it("the action's half does not move: an action with no `patch` and no `params` is still refused at `patch`", () => { + const issues = actionIssues({ operation: 'update' }); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['patch']]]); + expect(issues[0].message.startsWith("An `operation: 'update'` action has nothing to write")).toBe(true); + expect(actionIssues({ operation: 'update', params: STATUS_PARAM })).toEqual([]); + expect(actionIssues({ operation: 'update', params: [] }).map((i) => i.path)).toEqual([['patch']]); + }); +}); + +describe("a row's `params` are no write of its own", () => { + it.each(ROWS)('$name: an input list does not stand in for the `name` — without one it is still ONE issue at `name`', (row) => { + const issues = issuesOf(row.parse({ operation: 'update', params: STATUS_PARAM })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'name']]]); + const named = row.parse({ ...UPDATE, params: STATUS_PARAM }); + expect(named.success, JSON.stringify(issuesOf(named))).toBe(true); }); it.each(ROWS.filter((row) => row.node))( - '$name: a non-empty static-values object counts (it IS `properties.params`, merged by the write); an empty one does not', + '$name: a static-values object rides the request as params — it parses beside a `name`, and is no `name` of its own', (row) => { - const r = row.parse({ operation: 'update', params: { status: 'done' } }); - expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); - const issues = issuesOf(row.parse({ operation: 'update', params: {} })); - expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'patch']]]); + // The route writes them only where the named action declares them, and + // refuses any other (ADR-0104 D2): a cross-reference this row cannot see. + const named = row.parse({ ...UPDATE, params: { status: 'done' } }); + expect(named.success, JSON.stringify(issuesOf(named))).toBe(true); + const issues = issuesOf(row.parse({ operation: 'update', params: { status: 'done' } })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'name']]]); }, ); it.each(ROWS.filter((row) => !row.node))( - '$name: an object `params` does not count — the container never forwards it to a non-`api` executor', + '$name: an object `params` is refused at `params` — the container never forwards it to a non-`api` executor', (row) => { - const issues = issuesOf(row.parse({ operation: 'update', params: { status: 'done' } })); - expect(issues.map((i) => [i.code, i.path]).sort()).toEqual([ - ['custom', [...row.at, 'params']], - ['custom', [...row.at, 'patch']], - ].sort()); + const issues = issuesOf(row.parse({ ...UPDATE, params: { status: 'done' } })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'params']]]); }, ); }); -describe("a row's `patch` is `ActionSchema`'s own record of field values", () => { - type Shape = { shape: Record> }; - const memberOf = (props: unknown) => - ((props as Shape).shape.actions.unwrap() as unknown as z.ZodArray).element as unknown as Shape; - - it('NO SECOND SHAPE: every row holds the very schema instance `ActionSchema.patch` declares', () => { - const patch = ActionSchema.in.shape.patch.unwrap(); - expect((ActionButtonPropsSchema as unknown as Shape).shape.patch.unwrap()).toBe(patch); - expect((ActionIconPropsSchema as unknown as Shape).shape.patch.unwrap()).toBe(patch); - expect(memberOf(GROUP).shape.patch.unwrap()).toBe(patch); - expect(memberOf(MENU).shape.patch.unwrap()).toBe(patch); - }); - - it.each(ROWS)('$name: a `patch` that is not a record is refused at `patch` — the runner drops it before the write', (row) => { - for (const patch of ['done', ['status', 'done'], 1]) { - const issues = issuesOf(row.parse({ operation: 'update', patch })); - expect(issues.map((i) => [i.code, i.path]), JSON.stringify(patch)).toEqual([['invalid_type', [...row.at, 'patch']]]); - } - }); -}); - // =========================================================================== -// §4. Operation first: the rows' executor-dependent judgments stand down +// §5. Operation first: the rows' executor-dependent judgments stand down // =========================================================================== describe('beside the operation, the url-target and executor-alias judgments stand down', () => { diff --git a/packages/spec/src/ui/component-action-row-on-success-executor.test.ts b/packages/spec/src/ui/component-action-row-on-success-executor.test.ts index 13f511e471a..dda8d6e27ea 100644 --- a/packages/spec/src/ui/component-action-row-on-success-executor.test.ts +++ b/packages/spec/src/ui/component-action-row-on-success-executor.test.ts @@ -342,14 +342,20 @@ describe("a page action row's `onSuccess` stays open beside `api`, `script` and // 4. operation — ActionSchema's own enum, and its `onSuccess` rule // =========================================================================== -/** A declarative update row: `operation: 'update'` with a static `patch`. */ -const UPDATE = { operation: 'update', patch: { status: 'done' } } as const; +/** + * A declarative update row: `operation: 'update'` naming the declared action + * whose `patch` the platform action route writes. A row has no `patch` of its + * own (`component-action-row-patch-retirement.test.ts`). + */ +const UPDATE = { operation: 'update', name: 'close_task' } as const; +/** The same write on `ActionSchema`, which IS the declaration and carries its own `patch`. */ +const ACTION_UPDATE = { operation: 'update', patch: { status: 'done' } } as const; /** `ActionSchema`'s refusal of `onSuccess` beside `operation: 'update'`, and the subject it opens with. */ const ACTION_UPDATE_SUBJECT = "An `operation: 'update'` action's `onSuccess`"; const actionUpdateOnSuccessMessage = (): string => { const issues = issuesOf(ActionSchema.safeParse({ - name: 'close_task', label: 'Close', ...UPDATE, onSuccess: HOP, + name: 'close_task', label: 'Close', ...ACTION_UPDATE, onSuccess: HOP, })); expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['onSuccess']]]); expect(issues[0].message.startsWith(ACTION_UPDATE_SUBJECT)).toBe(true); @@ -369,7 +375,7 @@ describe("a page action row's `operation` is `ActionSchema`'s own enum", () => { }); it.each(ROWS)("REFUSAL: $name with `operation: 'delete'` is refused at `operation` with the action's own text", (row) => { - const issues = issuesOf(row.parse({ operation: 'delete', patch: { status: 'done' } })); + const issues = issuesOf(row.parse({ operation: 'delete', name: 'close_task' })); expect(issues.map((i) => i.path)).toEqual([[...row.at, 'operation']]); const action = issuesOf(ActionSchema.safeParse({ name: 'x', label: 'X', operation: 'delete', patch: { status: 'done' } })) .find((i) => i.path.join('.') === 'operation'); @@ -378,11 +384,11 @@ describe("a page action row's `operation` is `ActionSchema`'s own enum", () => { }); it.each(ROWS)("REFUSAL: $name with `operation: 'create'` (no member) is refused at `operation`", (row) => { - const issues = issuesOf(row.parse({ operation: 'create', patch: { status: 'done' } })); + const issues = issuesOf(row.parse({ operation: 'create', name: 'close_task' })); expect(issues.map((i) => [i.code, i.path])).toEqual([['invalid_value', [...row.at, 'operation']]]); }); - it.each(ROWS)("ACCEPTED CONTROL: $name with `operation: 'update'` and a `patch` parses unchanged", (row) => { + it.each(ROWS)("ACCEPTED CONTROL: $name with `operation: 'update'` and a `name` parses unchanged", (row) => { const r = row.parse({ ...UPDATE }); expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); expect(row.parsed(r.data).operation).toBe('update'); diff --git a/packages/spec/src/ui/component-action-row-patch-retirement.test.ts b/packages/spec/src/ui/component-action-row-patch-retirement.test.ts new file mode 100644 index 00000000000..d1fcab9b5d1 --- /dev/null +++ b/packages/spec/src/ui/component-action-row-patch-retirement.test.ts @@ -0,0 +1,521 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A page action row's own `patch` RETIRED (#24394) — ADR-0049 + * enforce-or-remove through the ADR-0087 D2 route, on the `domain:spec` + * seat's route for the card: of two implementations of one write, the governed + * side wins and the other is deleted. + * + * Measured before removal: a row's `operation: 'update'` posts through the + * console runner's server dispatch to the platform action route under the + * row's `name`. The route resolves that declaration by name (ADR-0110 D2) and + * writes the DECLARATION's `patch`. The runner posts the collected values + * alone and reads the row's `patch` for its Undo snapshot only (objectui + * `ActionRunner.executeUpdateOperation`, at the pin `0707b4ae37` and at `main` + * `4b7ce47`), so the row's `patch` was never written. No page action row + * authors it in this repo's examples or apps, nor among objectui's rows. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone at `patch` on `ActionButtonProps`, + * `ActionIconProps` and the `action:group` / `action:menu` member, each a + * strict row — the refusal carries the prescription, and the key's input + * type is `never`. `ActionSchema.patch` is untouched. + * 2. D2 conversion `page-action-row-patch-removed` (step 18), a strip from + * every node and member, retired from the load path. + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries `ui/ActionButtonProps:patch` and + * `ui/ActionIconProps:patch` (the member's keys have no surface row of + * their own), and the family's D3 entry is + * `ui-page-action-row-patch-retired`. + * + * 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 this row + * (the page write door parses `properties` as an open bag). So each refusal is + * pinned by the issue `code`, the `path` naming the key, and the prescription. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; + +import { collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import type { ConversionNotice } from '../conversions/types'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { ActionSchema } from './action.zod'; +import { ActionButtonPropsSchema, ActionIconPropsSchema, ComponentPropsMap } from './component.zod'; +import { PageSchema } from './page.zod'; + +const CONVERSION_ID = 'page-action-row-patch-removed'; +const D3_ID = 'ui-page-action-row-patch-retired'; +const REGISTERED_KEYS = ['ui/ActionButtonProps:patch', 'ui/ActionIconProps:patch']; + +/** The prescription after its owner, with the house migrate sentence. */ +const PRESCRIPTION = + /`patch` was removed in @objectstack\/spec 18 \(ADR-0110 D2\) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`.*Move the values into the `patch` of the action the row's `name` names, then delete the key\..*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\./s; + +type Issue = { code: string; path: PropertyKey[]; message: string }; +type Parsed = { success: boolean; data?: unknown; error?: { issues: unknown[] } }; +const issuesOf = (r: Parsed): Issue[] => (r.success ? [] : (r.error?.issues ?? [])) as Issue[]; +type Parser = { safeParse: (value: unknown) => Parsed }; + +interface Row { + readonly name: string; + readonly parse: (action: Record) => Parsed; + readonly at: readonly PropertyKey[]; + /** How the tombstone's message opens on this row (house convention 1). */ + readonly owner: string; +} + +const ROWS: readonly Row[] = [ + { + name: 'action:button', + parse: (action) => ActionButtonPropsSchema.safeParse({ label: 'Close', ...action }), + at: [], + owner: "An `action:button`'s", + }, + { + name: 'action:icon', + parse: (action) => ActionIconPropsSchema.safeParse({ icon: 'check', label: 'Close', ...action }), + at: [], + owner: "An `action:icon`'s", + }, + { + name: 'action:group member', + parse: (action) => (ComponentPropsMap['action:group'] as unknown as Parser).safeParse({ actions: [{ label: 'Close', ...action }] }), + at: ['actions', 0], + owner: "An `action:group` or `action:menu` member's", + }, + { + name: 'action:menu member', + parse: (action) => (ComponentPropsMap['action:menu'] as unknown as Parser).safeParse({ actions: [{ label: 'Close', ...action }] }), + at: ['actions', 0], + owner: "An `action:group` or `action:menu` member's", + }, +]; + +/** A stored `page` row whose action button carries the retired key. */ +const storedPage = () => ({ + name: 'task_record', + label: 'Task', + type: 'record', + object: 'task', + regions: [ + { + name: 'header', + components: [ + { + type: 'action:button', + properties: { label: 'Close', name: 'close_task', operation: 'update', patch: { status: 'done' } }, + }, + ], + }, + ], +}); + +type Notice = Pick; +const brief = (n: ConversionNotice): Notice => ({ conversionId: n.conversionId, path: n.path, from: n.from, to: n.to }); + +describe("a page action row's `patch` retirement — the tombstone", () => { + // A record (the shape the rows invited), the non-records the rows already + // refused, with and without the operation, with and without a `name`. + const VALUES: readonly unknown[] = [{ status: 'done' }, 'done', ['status', 'done'], 1]; + const BESIDE: readonly Record[] = [ + {}, + { operation: 'update' }, + { operation: 'update', name: 'close_task' }, + ]; + + it.each(ROWS)('$name: every `patch` is ONE issue at the key, with the prescription', (row) => { + for (const patch of VALUES) { + for (const beside of BESIDE) { + const issues = issuesOf(row.parse({ ...beside, patch })); + const label = JSON.stringify({ ...beside, patch }); + expect(issues.map((i) => [i.code, i.path]), label).toEqual([['invalid_type', [...row.at, 'patch']]]); + expect(issues[0].message, label).toMatch(PRESCRIPTION); + expect(issues[0].message.startsWith(`${row.owner} \`patch\` was removed`), label).toBe(true); + } + } + }); + + it.each(ROWS)('$name: refused by the TOMBSTONE, not by the strict unknown-key arm — the two are different answers', (row) => { + expect(issuesOf(row.parse({ patch: { status: 'done' } })).map((i) => i.code)).not.toContain('unrecognized_keys'); + // CONTROL: an undeclared sibling on the same row comes back as + // `unrecognized_keys`. Without this pair, a row that had simply DROPPED + // the key would pass the pin above on the strict arm's generic message. + expect(issuesOf(row.parse({ bogusProp: true })).map((i) => [i.code, i.path])).toEqual([['unrecognized_keys', [...row.at]]]); + }); + + it.each(ROWS)("$name: CONTROL — `operation: 'update'` with a `name` and no `patch` parses, and grows no `patch`", (row) => { + const r = row.parse({ operation: 'update', name: 'close_task' }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + expect(JSON.stringify(r.data)).not.toContain('"patch"'); + }); + + it('the walked shape keeps `patch` as a key — the authorable-surface rows stay reachable', () => { + for (const schema of [ActionButtonPropsSchema, ActionIconPropsSchema]) { + const shape = (schema as unknown as { shape?: Record }).shape; + expect(shape, 'the row must expose its shape').toBeDefined(); + expect(Object.keys(shape!)).toContain('patch'); + expect(Object.keys(shape!), 'CONTROL: its live neighbour').toContain('operation'); + } + }); + + it('the rows answer the props lint with the same schemas', () => { + // `validateComponentProps` (packages/lint) reads `ComponentPropsMap[type]`. + expect(ComponentPropsMap['action:button']).toBe(ActionButtonPropsSchema); + expect(ComponentPropsMap['action:icon']).toBe(ActionIconPropsSchema); + }); + + it('fails tsc at the authoring site: the input type of a row `patch` is `never`', () => { + const button: z.input = { + label: 'Close', + name: 'close_task', + operation: 'update', + // @ts-expect-error — `patch` is a retiredKey() tombstone on the row: its input type is `never`. + patch: { status: 'done' }, + }; + // The parse channel agrees with the type channel on the same literal. + expect(issuesOf(ActionButtonPropsSchema.safeParse(button)).map((i) => i.path)).toEqual([['patch']]); + }); + + it("`ActionSchema.patch` is untouched: the action's own record, and its own `patch`-without-`operation` refusal", () => { + const declared = ActionSchema.in.shape.patch.unwrap() as unknown as { _zod: { def: { type: string } } }; + expect(declared._zod.def.type).toBe('record'); + const row = (ActionButtonPropsSchema as unknown as { shape: Record> }).shape.patch.unwrap(); + expect(row).not.toBe(declared); + expect(ActionSchema.safeParse({ name: 'close_task', label: 'Close', operation: 'update', patch: { status: 'done' } }).success) + .toBe(true); + const refused = issuesOf(ActionSchema.safeParse({ name: 'close_task', label: 'Close', type: 'api', target: '/x', patch: { a: 1 } })); + expect(refused.map((i) => [i.code, i.path])).toEqual([['custom', ['patch']]]); + expect(refused[0].message.startsWith("`patch` only applies to `operation: 'update'`")).toBe(true); + }); + + it('never hard-refuses an existing page: the page parse still accepts a row carrying the key', () => { + // `PageComponentSchema.properties` is an open bag — the page door does not + // dispatch on the component type. The tombstone speaks through the props + // lint, and the D2 conversion strips the key from stored rows. + const r = PageSchema.safeParse(storedPage()); + expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); + }); +}); + +describe("a page action row's `patch` retirement — the D2 conversion", () => { + it('a STORED page whose button carries `patch` loads with the key stripped and the notice recorded', () => { + const notices: ConversionNotice[] = []; + const rehydrated = applyConversionsToStoredItem('page', storedPage(), { + onNotice: (n) => notices.push(n), + }) as ReturnType; + expect(notices.map(brief)).toEqual([ + { + conversionId: CONVERSION_ID, + path: 'pages[0].regions[0].components[0].properties.patch', + from: 'patch', + to: '(removed)', + }, + ]); + const properties = rehydrated.regions[0]!.components[0]!.properties; + expect(properties).toEqual({ label: 'Close', name: 'close_task', operation: 'update' }); + // The rehydrated row is exactly what the props row accepts now. + expect(ActionButtonPropsSchema.safeParse(properties).success).toBe(true); + }); + + it('a BUILT artifact replays the same strip — every node and member position', () => { + const artifact = { + pages: [ + { + name: 'task_record', + regions: [ + { + name: 'header', + components: [ + { type: 'action:icon', properties: { icon: 'check', name: 'close_task', patch: { status: 'done' } } }, + { + type: 'action:group', + properties: { actions: [{ label: 'Reopen', name: 'reopen_task' }, { label: 'Close', name: 'close_task', patch: { status: 'done' } }] }, + }, + { + type: 'page:card', + properties: { + children: [{ type: 'action:menu', properties: { actions: [{ label: 'Close', name: 'close_task', patch: { status: 'done' } }] } }], + }, + }, + ], + }, + ], + }, + { + name: 'task_detail', + kind: 'slotted', + regions: [], + slots: { header: { type: 'action:button', properties: { label: 'Close', name: 'close_task', patch: { status: 'done' } } } }, + }, + ], + }; + const { stack, notices } = collectConversionNotices(artifact, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID).map((n) => n.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.patch', + 'pages[0].regions[0].components[1].properties.actions[1].patch', + 'pages[0].regions[0].components[2].properties.children[0].properties.actions[0].patch', + 'pages[1].slots.header.properties.patch', + ]); + expect(JSON.stringify(stack)).not.toContain('"patch"'); + }); + + it('CONTROL: a row without the key is unchanged — no notice, and the row comes back by reference', () => { + const row = { + name: 'task_record', + regions: [{ name: 'header', components: [{ type: 'action:button', properties: { label: 'Close', name: 'close_task', operation: 'update' } }] }], + }; + const notices: ConversionNotice[] = []; + const rehydrated = applyConversionsToStoredItem('page', row, { onNotice: (n) => notices.push(n) }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(rehydrated).toBe(row); + }); + + it("is scoped by component TYPE: the same key name on another component is not this entry's", () => { + const artifact = { + pages: [{ name: 'p', regions: [{ name: 'main', components: [{ type: 'acme:quick_edit', properties: { patch: { status: 'done' } } }] }] }], + }; + const { stack, notices } = collectConversionNotices(artifact, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toBe(artifact); + }); + + it('is idempotent by construction: a second replay converts nothing', () => { + const { stack } = collectConversionNotices({ pages: [storedPage()] }, { includeRetired: true }); + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — the authoring funnel does not rewrite a live source', () => { + const input = { pages: [storedPage()] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toEqual(input); + }); +}); + +describe("a page action row's `patch` retirement — the ADR-0087 ledger row", () => { + it('declares the two node keys under major 18', () => { + for (const key of REGISTERED_KEYS) expect(RETIRED_KEYS_BY_MAJOR[18]).toContain(key); + }); + + it('wires the D2 conversion into the step-18 chain as a retired, stamped strip', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion, 'the D2 conversion must be registered').toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('carries ONE D3 entry for the family, naming its D2 conversion', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries, 'the family needs its own D3 entry').toHaveLength(1); + const [entry] = entries; + expect(entry!.reason).toContain(`\`${CONVERSION_ID}\``); + expect(entry!.replacement).toContain('delete the row\'s `patch`'); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── 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 `tsc` does not reach a row authored through +// `definePage`/`defineStack`. This walk covers every text file under the five +// repo roots `scripts/cross-package-test-inputs.mjs` declares for +// `@objectstack/spec` (the walk shape the #15513 absence pin holds there), plus +// the example apps' own `src/` trees. +// +// The matcher judges the AUTHORING SHAPE, never a mention: a `patch` key +// INSIDE an object whose `type` is a page action row. TS / JS / JSON take the +// row's brace-balanced object; YAML takes the lines indented under the row's +// `type:` line. `patch` is an ordinary key elsewhere (`ActionSchema`, +// `bulkActionDefs`), so a bare key match would be all noise. The bound, +// stated: a row built by a helper or a spread (`{ type, properties: { ...X } }`) +// is invisible to a text scan, as is a JSX attribute on an html-tier page; and +// `docs/**`, `.claude/**`, `.github/**` and the repo-root files are outside +// the walk. +describe('tree-scoped absence: nothing inside the declared radius still authors a page action row `patch`', () => { + 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', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** 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 YAMLISH_EXT = new Set(['.yaml', '.yml', '.md', '.mdx']); + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const ROW_TYPE_KEYED = /["']?type["']?\s*:\s*(["'])action:(?:button|icon|group|menu)\1/g; + const PATCH_KEY = /(^|[^\w.$'"`-])["']?patch["']?[ \t]*:/m; + const YAML_ROW_TYPE = /^(\s*(?:-\s+)?)type:\s*["']?action:(?:button|icon|group|menu)["']?\s*$/; + const YAML_PATCH_KEY = /^\s*(?:-\s+)?patch\s*:/; + + /** 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 encloses `at`, skipping quoted strings; `undefined` when unbalanced. */ + const enclosingObject = (text: string, at: number): string | undefined => { + let depth = 0; + let start = -1; + for (let i = at - 1; i >= 0; i -= 1) { + const ch = text[i]; + if (ch === '}') depth += 1; + else if (ch === '{') { + if (depth === 0) { start = i; break; } + depth -= 1; + } + } + if (start < 0) return undefined; + let quote: string | undefined; + depth = 0; + for (let i = start; i < text.length; i += 1) { + const ch = text[i]; + if (quote) { + if (ch === '\\') { i += 1; continue; } + if (ch === quote) quote = undefined; + continue; + } + if (ch === '"' || ch === "'") { quote = ch; continue; } + if (ch === '{') depth += 1; + else if (ch === '}') { + depth -= 1; + if (depth === 0) return text.slice(start, i + 1); + } + } + return undefined; + }; + + const judgeBraces = (text: string): string | undefined => { + for (const m of text.matchAll(ROW_TYPE_KEYED)) { + const object = enclosingObject(text, m.index!); + if (object && PATCH_KEY.test(object)) return m[0]; + } + return undefined; + }; + + const judgeYaml = (text: string): string | undefined => { + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i += 1) { + const m = YAML_ROW_TYPE.exec(lines[i]); + if (!m) continue; + const col = m[1].length; + for (let j = i + 1; j < lines.length; j += 1) { + const line = lines[j]; + if (line.trim() === '') continue; + const indent = line.length - line.trimStart().length; + if (indent < col) break; + if (YAML_PATCH_KEY.test(line)) return lines[i].trim(); + } + } + return undefined; + }; + + const judge = (text: string, ext: string): string | undefined => { + const prose = stripInlineCode(text); + return judgeBraces(prose) ?? (YAMLISH_EXT.has(ext) ? judgeYaml(prose) : undefined); + }; + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired key. + */ + const EXCLUDED = new Set([ + // This pin authors the key to assert its refusal and its conversion. + THIS_FILE, + // The props-lint pin authors the key to assert the refusal (an `error`) an + // author meets at `os validate` / `os build` / `os lint`. + 'packages/lint/src/validate-component-props.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement rows on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is `component.zod.ts`. + '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 matcher recognises an authoring and ignores the neighbours (anti-vacuity)', () => { + // Offenders — the retired shape, in each syntax the walk reads. + expect(judge("{ type: 'action:button', properties: { name: 'close_task', patch: { status: 'done' } } }", '.ts')).toBeDefined(); + expect(judge("{\n type: 'action:group',\n properties: {\n actions: [{ label: 'Close', patch: { status: 'done' } }],\n },\n}", '.ts')).toBeDefined(); + expect(judge('{ "type": "action:icon", "properties": { "patch": { "status": "done" } } }', '.json')).toBeDefined(); + expect(judge('components:\n - type: action:button\n properties:\n patch:\n status: done\n', '.yaml')).toBeDefined(); + expect(judge("Prose.\n\n```ts\n{ type: 'action:menu', properties: { actions: [{ patch: { a: 1 } }] } }\n```\n", '.md')).toBeDefined(); + // Neighbours that must NOT match. + expect(judge("{ type: 'action:button', properties: { name: 'close_task', operation: 'update' } }", '.ts')).toBeUndefined(); + expect(judge("defineAction({ name: 'close_task', operation: 'update', patch: { status: 'done' } })", '.ts')).toBeUndefined(); + expect(judge("bulkActionDefs: [{ name: 'done', operation: 'update', patch: { status: 'done' } }]", '.ts')).toBeUndefined(); + expect(judge("{ type: 'action:button', properties: { label: 'x' } }, { type: 'acme:quick_edit', properties: { patch: {} } }", '.ts')).toBeUndefined(); + expect(judge('a row that said `{ type: \'action:button\', patch: {} }` reads as retired', '.md')).toBeUndefined(); + expect(judge('components:\n - type: action:button\n properties:\n label: Close\n - type: acme:quick_edit\n patch: {}\n', '.yaml')).toBeUndefined(); + }); + + it('no page action row `patch` authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + let rowSightings = 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; + ROW_TYPE_KEYED.lastIndex = 0; + if (ROW_TYPE_KEYED.test(text)) rowSightings += 1; + ROW_TYPE_KEYED.lastIndex = 0; + const hit = judge(text, ext); + if (hit) offenders.push(`${rel} authors a \`patch\` on \`${hit.trim().replace(/\s+/g, ' ')}\``); + } + }; + 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, and it really saw page action rows keyed by `type`. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(rowSightings).toBeGreaterThan(5); + expect(offenders, 'a page action row `patch` authoring means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts index 4bbd1beb83c..efb930715c7 100644 --- a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts +++ b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts @@ -244,8 +244,8 @@ const on = (types: readonly string[], paths: readonly string[], reason: Reason): // post-success block, and `operation` when they took the action's own // `operation` enum (`component-action-row-on-success-executor.test.ts`); // `patch` left it when they took the action's own record of field values, -// whose values are a `records` line below -// (`component-action-row-declarative-update.test.ts`). +// and has since retired from every row outright +// (`component-action-row-patch-retirement.test.ts`). const ACTION_RUNNER_PATHS = ['params', 'bodyExtra', 'bodyShape', 'toast', 'resultDialog']; // [#22444] `list-view` is the list view itself, so its `data` member is the // same view data source by reference. @@ -296,10 +296,9 @@ on(['object-kanban', 'object-timeline'], ['data[]'], RECORDS); on(['object-calendar'], ['data[]', 'staticData[]'], RECORDS); on(['object-form', 'object-master-detail-form'], ['initialValues{}', 'initialData{}'], RECORDS); on(['object-grid', 'list-view'], ['bulkActionDefs[].patch{}', 'bulkActionDefs[].params[].default'], RECORDS); -// A page action row's `patch` is `ActionSchema`'s record of static field -// values, by reference: each value is one field's. -on(['action:button', 'action:icon'], ['patch{}'], RECORDS); -on(['action:group', 'action:menu'], ['actions[].patch{}'], RECORDS); +// A page action row's `patch` has no line: it is a `retiredKey()` tombstone +// (the route writes the named action's own `patch`), so the row holds no +// record of field values to list (`component-action-row-patch-retirement.test.ts`). // A static board's card is a record row: `id` and `title` are typed, the rest // is the row's own values (the kanban's lane bucketing keeps them verbatim). on(['object-kanban'], ['columns[].cards[].*'], RECORDS); From 393aab6b5d8863b7474f8b9e03cd4336d76c7801 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 01:54:06 +0000 Subject: [PATCH 5/6] wip(spec): the absence walk's row-sighting floor is a measured reading Claude-Session: https://claude.ai/code/session_01WnW7HvfnfHbLg1fpkHX3Mu Co-authored-by: Claude --- .../src/ui/component-action-row-patch-retirement.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ui/component-action-row-patch-retirement.test.ts b/packages/spec/src/ui/component-action-row-patch-retirement.test.ts index d1fcab9b5d1..cc85ac0032c 100644 --- a/packages/spec/src/ui/component-action-row-patch-retirement.test.ts +++ b/packages/spec/src/ui/component-action-row-patch-retirement.test.ts @@ -512,10 +512,12 @@ describe('tree-scoped absence: nothing inside the declared radius still authors }; 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, and it really saw page action rows keyed by `type`. + // sources, and its first stage really saw page action rows keyed by `type` + // (two files at the retirement, both `@objectstack/lint` pins; the example + // apps author no page action row). expect(visited).toBeGreaterThan(1000); expect(exampleSources).toBeGreaterThan(50); - expect(rowSightings).toBeGreaterThan(5); + expect(rowSightings).toBeGreaterThan(0); expect(offenders, 'a page action row `patch` authoring means the retirement is being undone').toEqual([]); }); }); From d6976f1c92dc36b6374b64bbc270dd2a14921e5d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:36:42 +0000 Subject: [PATCH 6/6] wip(spec): changeset for the row patch retirement; list the absence pin in the repo project Claude-Session: https://claude.ai/code/session_01WnW7HvfnfHbLg1fpkHX3Mu Co-authored-by: Claude --- .../24394-page-action-row-patch-retired.md | 43 +++++++++++++++++++ packages/spec/vitest.repo-tests.json | 1 + 2 files changed, 44 insertions(+) create mode 100644 .changeset/24394-page-action-row-patch-retired.md diff --git a/.changeset/24394-page-action-row-patch-retired.md b/.changeset/24394-page-action-row-patch-retired.md new file mode 100644 index 00000000000..eea209cf670 --- /dev/null +++ b/.changeset/24394-page-action-row-patch-retired.md @@ -0,0 +1,43 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: retire a page action row's own `patch` — a row's `operation: 'update'` runs the declared action its `name` names, and the platform action route writes that action's `patch` (#24394) + +**BREAKING** — `patch` on a page action row (`action:button`, `action:icon`, and each `action:group` / `action:menu` member of `properties.actions`) is now refused. A row's `operation: 'update'` posts to the platform action route under the row's `name`. The route resolves that declared action by name and writes the action's own `patch`, merged under the request's params. The row's `patch` was never sent: the reference console read it only for its Undo snapshot. Beside `operation: 'update'` a row now needs a `name`, since without one it has no declared action to run and the console refused it before any request. A row's static `params` are written only where that action declares them, and the route refuses any other with a 400 (ADR-0104 D2). `ActionSchema.patch`, the action's own declaration, is unchanged. + +Clause-②: yes (narrowing) + +Both directions, measured on the built rows. Narrowing: every row `patch` is refused, and a row with `operation: 'update'` and no `name` (an input list or static `params` included) is refused at `name`. Widening: a row with `operation: 'update'` and a `name` but no `patch` and no `params` now parses; before, the rows refused it at `patch` as having nothing to write, though its write is the named action's `patch`. + +Measured before removal, at `71fa166e96`: no producer. No page action row in this repository's examples or apps authors `patch` (or any `operation`), and neither does any objectui page row at the pinned `0707b4ae37` or at objectui `main` `4b7ce47`. + +## FROM → TO + +| you wrote (17.7 and earlier) | write instead | +| --- | --- | +| `operation: 'update'` with a `patch` on a page action row | the values in the `patch` of the declared action the row's `name` names (`defineAction({ name, operation: 'update', patch })`), the row keeping only `operation: 'update'` and that `name` | +| `patch` on a page action row with no `operation` | nothing — delete the key; it was already refused | +| `operation: 'update'` on a page action row with no `name` | a `name` naming the declared `operation: 'update'` action | + +**The one-line fix:** move each row's `patch` values onto the declared action its `name` names, then delete the row's `patch`. + +**What an author who still writes it sees.** `tsc` fails at a typed authoring site (the rows type the key as retired), and `os validate`, `os build` and `os lint` refuse it as an `error` (`component-props-invalid`) at its own path (`properties.patch`, `properties.actions.N.patch`) with the prescription: + +> An `action:button`'s `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. 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. + +A page's own parse keeps its component `properties` as an open bag, so an existing page carrying the key is never refused at load. + +## The retirement kit + +- **A `retiredKey()` tombstone** at `patch` on `ActionButtonProps`, `ActionIconProps` and the `action:group` / `action:menu` member. `RETIRED_KEYS_BY_MAJOR[18]`: `ui/ActionButtonProps:patch`, `ui/ActionIconProps:patch` (the member's keys have no surface row of their own). The authorable-surface rows read `[RETIRED]`. +- **The rows' half of the declarative-update judge** reads the row's own surface: a row has no "`patch` without `operation`" rule of its own (its tombstone refuses every `patch`), and its "nothing to write" rule asks for the `name`. `ActionSchema`'s half of every rule, and every text it prints, is unchanged. +- **The D2 conversion `page-action-row-patch-removed`** (protocol 18, retired from the load path) deletes the key from every node and member — region-level, nested in a container's `children`, and in a slotted page's named slot — one notice per row. It is scoped by component `type`: the same key name on another component is left alone. +- **The D3 entry `ui-page-action-row-patch-retired`** records the judgement the strip cannot make: when the named action's own `patch` lacks a value the row carried, that value was never written, and adding it to the action changes what the action writes. +- **No deprecation window**, per the project's startup-stage posture. + +**Not part of it:** objectui's renderers still forward the row's `patch`, its runner still reads it for the Undo snapshot, and its component registry still lists a `patch` input on `action:button` / `action:icon` (so the tracked `sdui.manifest.json` advertises it to the html-tier page gate until the pin moves). Those reads leave on the objectui side. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index bf99cc40247..16c27b55b24 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -54,6 +54,7 @@ "src/system/email-template-category-is-system-retirement.test.ts", "src/system/email-template-floor-locale-parity.pin.test.ts", "src/ui/action-requires-confirmation-docblock.pin.test.ts", + "src/ui/component-action-row-patch-retirement.test.ts", "src/ui/component-marketplace-installed-list-retirement.test.ts", "src/ui/component-record-json-retirement.test.ts", "src/ui/element-flat-binding-retirement.test.ts",