diff --git a/.changeset/builtin-node-config-reconciliation.md b/.changeset/builtin-node-config-reconciliation.md
new file mode 100644
index 0000000000..407b8f36a6
--- /dev/null
+++ b/.changeset/builtin-node-config-reconciliation.md
@@ -0,0 +1,47 @@
+---
+'@objectstack/spec': minor
+'@objectstack/service-automation': minor
+---
+
+Reconcile the remaining flat builtins' declared config against what their
+executors read (#4045 — the CRUD / screen / map step, after notify / http /
+connector in #4210).
+
+**Six executor-derived Zod contracts.** `GetRecordConfigSchema`,
+`CreateRecordConfigSchema`, `UpdateRecordConfigSchema`,
+`DeleteRecordConfigSchema`, `ScreenConfigSchema` (+ `ScreenFieldConfigSchema`)
+and `MapConfigSchema` in `automation/builtin-node-config.zod.ts`, each written
+by reading the executor rather than transcribing the descriptor literal, so the
+new bidirectional ledger test is evidence rather than a tautology. Contract
+exports only — nothing parses with them yet (#4045 step 3b, gated on the #4059
+warning data).
+
+**Seven capabilities the executors honour are now authorable.** Each was read
+by the executor and offered by no form — online or offline — so it was reachable
+only by hand-written metadata:
+
+- `get_record.fields` — the query projection, passed straight into
+ `find`/`findOne`;
+- `screen.recordId` — the record `mode: 'edit'` opens; the form declared the
+ edit mode while offering no way to name its target;
+- `screen.fields[].options` / `defaultValue` / `placeholder` — all three
+ forwarded into the ScreenSpec the client renders, so a select field's choices
+ could not be authored in Studio at all. Same nested repeater position as the
+ `visibleWhen` gap #3528 was filed for;
+- `map.indexVariable` and `map.input` — the index binding and the per-item
+ subflow params.
+
+**`map`'s undeclared `flow` alias graduates to the conversion layer.** The
+executor carried `cfg.flowName ?? cfg.flow` for a spelling no schema ever
+described — the `notify.source` shape (Prime Directive #12). The bare fallback
+is deleted and `flow-node-map-flow-alias` (protocol 17, retires at 18) renames
+it at load, including the `AutomationEngine.registerFlow` rehydration seam.
+
+**`assignment` is pinned as deliberately un-reconcilable**, with the reason on
+record: with no `assignments` wrapper its top-level config keys ARE the author's
+variable names, so no fixed key set can describe it and a catchall Zod would
+reconcile vacuously. What the ledger pins instead is that the form offers
+exactly the canonical `assignments` map and that the map stays open.
+
+With this, every builtin that publishes a `configSchema` is reconciled against
+its executor, and the ones that publish none each have a recorded reason.
diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx
new file mode 100644
index 0000000000..3a599486c9
--- /dev/null
+++ b/content/docs/references/automation/builtin-node-config.mdx
@@ -0,0 +1,194 @@
+---
+title: Builtin Node Config
+description: Builtin Node Config protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
+
+@module automation/builtin-node-config
+
+Config contracts for the remaining flat builtins — the CRUD quartet
+
+(`get_record` / `create_record` / `update_record` / `delete_record`),
+
+`screen`, and `map` (#4045). Sibling of `io-node-config.zod.ts`
+
+(notify / http) and `control-flow.zod.ts` (loop / parallel / try_catch).
+
+## Provenance — written from the executors, not from the forms
+
+Each schema was derived by reading what the executor actually does with
+
+`node.config` (`service-automation/builtin/crud-nodes.ts`,
+
+`screen-nodes.ts`, `map-node.ts`), **not** by transcribing the hand-written
+
+`configSchema` literal on the node's descriptor. The two artifacts are
+
+reconciled bidirectionally by `builtin-node-form-zod-ledger.test.ts` in
+
+`service-automation`; a Zod copied from the form would make that
+
+reconciliation a tautology (#4045).
+
+Writing these against the executors is what surfaced the drift the
+
+reconciliation exists to catch — keys the executors read that no form
+
+offered anywhere (`get_record.fields`, `screen.recordId`, the screen
+
+field-item keys `options`/`defaultValue`/`placeholder`, `map.indexVariable`,
+
+`map.input`), and the undeclared `map.flow` alias, which graduated into the
+
+ADR-0087 D2 conversion layer like `notify.source` before it.
+
+## What these schemas are (and are not) wired to
+
+Contract exports only — no engine path `parse()`s a node config with them
+
+today, so registering a flow behaves exactly as before. Wiring the executors
+
+to parse is deliberately deferred until the #4059 undeclared-key warning has
+
+measured a release's worth of real metadata (#4045 step 3b).
+
+Deliberately absent:
+
+- `assignment` — its config cannot be described by a fixed key set: with no
+
+`assignments` wrapper the TOP-LEVEL config keys ARE the author's variable
+
+names (logic-nodes.ts). The ledger test pins that exemption with its
+
+reason instead of pretending a shape.
+
+- `decision` / `script` / `subflow` / `wait` / `connector_action` — the
+
+deliberately-schemaless class (config-schemas.test.ts); their contracts
+
+live elsewhere (edges, sibling blocks, conditional forms).
+
+
+**Source:** `packages/spec/src/automation/builtin-node-config.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
+import type { CreateRecordConfig, DeleteRecordConfig, GetRecordConfig, MapConfig, ScreenConfig, ScreenFieldConfig, UpdateRecordConfig } from '@objectstack/spec/automation';
+
+// Validate data
+const result = CreateRecordConfig.parse(data);
+```
+
+---
+
+## CreateRecordConfig
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **objectName** | `string` | ✅ | Object to insert into |
+| **fields** | `Record` | optional | Field values to write on the new record |
+| **outputVariable** | `string` | optional | Flow variable bound to the created record |
+
+
+---
+
+## DeleteRecordConfig
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **objectName** | `string` | ✅ | Object to delete from |
+| **filter** | `Record` | optional | Field/value pairs identifying the record(s) to delete |
+
+
+---
+
+## GetRecordConfig
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **objectName** | `string` | ✅ | Object to query |
+| **filter** | `Record` | optional | Field/value pairs to match (operator values like `{"$ne": null}` are preserved) |
+| **fields** | `string[]` | optional | Field projection — only these fields are read (default: all) |
+| **limit** | `number` | optional | Max records to return; >1 switches to a multi-record query |
+| **outputVariable** | `string` | optional | Flow variable the result is bound to |
+
+
+---
+
+## MapConfig
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **collection** | `string \| any[]` | ✅ | Template/variable resolving to the array to process (an inline array is accepted) |
+| **flowName** | `string` | ✅ | Subflow run for each item — it may pause (e.g. an approval) |
+| **iteratorVariable** | `string` | ✅ | Variable holding the current item |
+| **indexVariable** | `string` | optional | Optional variable holding the current index |
+| **itemObject** | `string` | optional | When items are records, the object they belong to (exposes each item as the child's record) |
+| **input** | `Record` | optional | Params passed to each item's subflow (interpolated per item) |
+| **outputVariable** | `string` | optional | Each item's subflow output, collected in order |
+
+
+---
+
+## ScreenConfig
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **title** | `string` | optional | Heading shown above the screen |
+| **description** | `string` | optional | Body text shown under the heading |
+| **fields** | `{ name: string; label?: string; type?: string; required?: boolean; … }[]` | optional | Input fields collected on this screen |
+| **waitForInput** | `boolean` | optional | Pause to show the screen even with no fields; false forces a server pass-through |
+| **objectName** | `string` | optional | Render this object's full create/edit form instead of a flat field list |
+| **idVariable** | `string` | optional | Object form only: variable bound to the saved record's id |
+| **mode** | `Enum<'create' \| 'edit'>` | optional | Object form only: create or edit |
+| **recordId** | `string` | optional | Object form only: id of the record to edit (required for mode: 'edit' to be useful) |
+| **defaults** | `Record` | optional | Object form only: prefilled values |
+
+
+---
+
+## ScreenFieldConfig
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **name** | `string` | ✅ | Field name (the flow variable the value binds to) |
+| **label** | `string` | optional | Display label |
+| **type** | `string` | optional | Input type |
+| **required** | `boolean` | optional | Whether a value is required to submit |
+| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field |
+| **defaultValue** | `any` | optional | Prefilled value (interpolates `{token}` templates) |
+| **placeholder** | `string` | optional | Input placeholder text |
+| **visibleWhen** | `string` | optional | CEL predicate controlling visibility, evaluated client-side |
+
+
+---
+
+## UpdateRecordConfig
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **objectName** | `string` | ✅ | Object to update |
+| **filter** | `Record` | optional | Field/value pairs identifying the record(s) to update |
+| **fields** | `Record` | optional | Field values to write |
+
+
+---
+
diff --git a/content/docs/references/automation/index.mdx b/content/docs/references/automation/index.mdx
index fed34f0c16..8bf55f5b61 100644
--- a/content/docs/references/automation/index.mdx
+++ b/content/docs/references/automation/index.mdx
@@ -8,6 +8,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta
+
diff --git a/content/docs/references/automation/meta.json b/content/docs/references/automation/meta.json
index edfc92eb72..f389964539 100644
--- a/content/docs/references/automation/meta.json
+++ b/content/docs/references/automation/meta.json
@@ -20,6 +20,7 @@
"approval",
"job",
"---More---",
+ "builtin-node-config",
"io-node-config"
]
}
\ No newline at end of file
diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md
index 7c448774a6..1c0fbf743c 100644
--- a/docs/protocol-upgrade-guide.md
+++ b/docs/protocol-upgrade-guide.md
@@ -128,6 +128,8 @@ The same graduation covers `wait`, whose fallback was not a config-to-config ren
`connector_action` gets the same lift for the opposite reason (#4045). Its contract also lives in a declared sibling block (`connectorConfig`), and the executor never read `config` at all — but the node's descriptor published a `configSchema` declaring `connectorId`/`actionId`/`input` as `config` keys, and the Studio inspector derives its form from a published schema, so schema-driven authoring wrote the trio to the wrong place and produced nodes that refused to dispatch. The conversion lifts the trio onto the declared block (declared keys win; a lift that cannot complete the required connectorId+actionId pair leaves the node untouched rather than turning a step-time refusal into a load failure), and the descriptor stops publishing the mis-rooted schema.
+The reconciliation that found those also found `map`, whose executor read a bare `cfg.flowName ?? cfg.flow` for an undeclared `flow` spelling no schema ever described (#4045). A pure rename, graduated the same way, so the executor reads only the canonical `flowName`.
+
And it removes the RLS-policy key `priority` (#3896 security audit): promised "conflict resolution" that cannot exist, because applicable policies OR-combine (most permissive wins) — there is never a conflict to order, and nothing ever read the key (call graph closed across the collection site, the projection round-trip and the compiler). A pure lossless delete: outcomes are identical with or without it; the schema tombstones the key with the same prescription.
The same close-out retires the four inert tool authoring keys (`category`, `permissions`, `active`, `builtIn`): none is part of AIToolDefinition and no execution path read them. Two were misleading in the dangerous direction — `permissions` promised an invocation gate nothing enforced, and `active: false` read as "withdrawn" while the tool kept reaching the LLM tool set. Lossless deletes; the strict ToolSchema rejects each with its prescription.
@@ -152,6 +154,7 @@ The close-out sweep finishes the enforce-or-remove worklist across the remaining
| `flow-node-notify-config-aliases` | `flow.node.notify.config` | notify flow-node config keys 'to' → 'recipients', 'subject' → 'title', 'body' → 'message', 'url' → 'actionUrl' (#3796), and nested 'source: {object, id}' → 'sourceObject' / 'sourceId' (#4045) | live — protocol 17 loader accepts the old shape |
| `flow-node-wait-event-config-lift` | `flow.node.wait.waitEventConfig` | wait flow-node loose config keys → the declared `waitEventConfig` block: 'eventType', 'timerDuration'/'duration' → 'timerDuration', 'signalName'/'signal' → 'signalName', 'timeoutMs' (#4045) | live — protocol 17 loader accepts the old shape |
| `flow-node-connector-config-lift` | `flow.node.connector_action.connectorConfig` | connector_action flow-node loose config keys 'connectorId' / 'actionId' / 'input' → the declared `connectorConfig` block (#4045) | live — protocol 17 loader accepts the old shape |
+| `flow-node-map-flow-alias` | `flow.node.map.config.flowName` | map flow-node config key 'flow' → 'flowName' (#4045 — undeclared executor fallback graduation) | live — protocol 17 loader accepts the old shape |
| `flow-node-script-config-aliases` | `flow.node.script.config` | script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796) | live — protocol 17 loader accepts the old shape |
| `permission-rls-priority-removed` | `permission.rowLevelSecurity.priority` | RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome) | retired — `migrate meta` only |
| `tool-inert-authoring-keys-removed` | `tool.category / tool.permissions / tool.active / tool.builtIn` | tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing) | retired — `migrate meta` only |
diff --git a/packages/services/service-automation/src/builtin/builtin-node-form-zod-ledger.test.ts b/packages/services/service-automation/src/builtin/builtin-node-form-zod-ledger.test.ts
new file mode 100644
index 0000000000..38474aff37
--- /dev/null
+++ b/packages/services/service-automation/src/builtin/builtin-node-form-zod-ledger.test.ts
@@ -0,0 +1,159 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * **Designer form ↔ Zod reconciliation for the CRUD quartet, `screen` and
+ * `map`** (#4045) — the third and final panel of the ledger family:
+ * `control-flow-form-zod-ledger.test.ts` (region nodes),
+ * `io-node-form-zod-ledger.test.ts` (notify / http), and this file.
+ *
+ * The Zods in `builtin-node-config.zod.ts` were written by reading the
+ * executors — not by transcribing the descriptor literals — so the two sides
+ * are independent statements and the key-set comparison is a real
+ * reconciliation. Writing them is what surfaced this round's drift: keys the
+ * executors read that NO form offered (neither the schema-driven online form
+ * nor objectui's hand-written offline group):
+ *
+ * - `get_record.fields` — query projection, wired straight into
+ * `data.find/findOne`;
+ * - `screen.recordId` — the record `mode: 'edit'` edits, without which the
+ * declared edit mode has no authorable target;
+ * - `screen.fields[].options` / `defaultValue` / `placeholder` — all three
+ * forwarded into the ScreenSpec the client renders;
+ * - `map.indexVariable` / `map.input` — the index binding and the per-item
+ * subflow params.
+ *
+ * All were reachable only by hand-authored metadata; each is a live, working
+ * capability, so the resolution is the one #4045 prescribes for current keys:
+ * DECLARE them on the descriptor form. (The undeclared `map.flow` alias went
+ * the other route — the ADR-0087 D2 conversion `flow-node-map-flow-alias` —
+ * because the declared `flowName` already owns that meaning.)
+ *
+ * `screen` additionally reconciles one level down: its `fields` repeater item
+ * keys are compared bidirectionally too, because #3528 — the incident this
+ * whole line descends from — lived in exactly that nested position.
+ *
+ * Key sets are read off `.shape` (no `zod` import) for the same dependency
+ * reason as the sibling ledgers.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ GetRecordConfigSchema,
+ CreateRecordConfigSchema,
+ UpdateRecordConfigSchema,
+ DeleteRecordConfigSchema,
+ ScreenConfigSchema,
+ ScreenFieldConfigSchema,
+ MapConfigSchema,
+} from '@objectstack/spec/automation';
+import { AutomationEngine } from '../engine.js';
+import { registerCrudNodes } from './crud-nodes.js';
+import { registerScreenNodes } from './screen-nodes.js';
+import { registerMapNode } from './map-node.js';
+import { registerLogicNodes } from './logic-nodes.js';
+
+const NODES: ReadonlyArray<{ nodeType: string; zod: unknown }> = [
+ { nodeType: 'get_record', zod: GetRecordConfigSchema },
+ { nodeType: 'create_record', zod: CreateRecordConfigSchema },
+ { nodeType: 'update_record', zod: UpdateRecordConfigSchema },
+ { nodeType: 'delete_record', zod: DeleteRecordConfigSchema },
+ { nodeType: 'screen', zod: ScreenConfigSchema },
+ { nodeType: 'map', zod: MapConfigSchema },
+];
+
+function engineWithNodes() {
+ const logger: any = {
+ info() {}, warn() {}, error() {}, debug() {},
+ child() { return logger; },
+ };
+ const ctx: any = { logger, getService() { throw new Error('none'); } };
+ const engine = new AutomationEngine(logger);
+ registerCrudNodes(engine, ctx);
+ registerScreenNodes(engine, ctx);
+ registerMapNode(engine, ctx);
+ registerLogicNodes(engine, ctx);
+ return engine;
+}
+
+const engine = engineWithNodes();
+
+function formSchema(nodeType: string) {
+ const schema = engine.getActionDescriptor(nodeType)?.configSchema as
+ | { properties?: Record } }> }
+ | undefined;
+ expect(schema, `${nodeType} should publish a configSchema`).toBeDefined();
+ return schema!;
+}
+
+/** Top-level keys the designer form offers for a node type. */
+function formKeys(nodeType: string): string[] {
+ return Object.keys(formSchema(nodeType).properties ?? {}).sort();
+}
+
+/** Top-level keys the Zod object accepts, read straight off `.shape`. */
+function zodKeys(schema: unknown): string[] {
+ const shape = (schema as { shape?: Record }).shape;
+ expect(shape, 'the Zod schema should expose a .shape').toBeDefined();
+ return Object.keys(shape ?? {}).sort();
+}
+
+describe('CRUD / screen / map form ↔ Zod reconciliation (#4045)', () => {
+ it.each(NODES)('$nodeType: the form offers exactly the keys the executor reads', ({ nodeType, zod }) => {
+ const form = formKeys(nodeType);
+ const zodded = zodKeys(zod);
+
+ // Read by the executor (per the Zod), absent from the form ⇒ reachable only
+ // by hand-authored metadata — the shape every finding above had before this
+ // change.
+ expect(
+ zodded.filter((k) => !form.includes(k)),
+ `${nodeType}: read by the executor but absent from the designer form`,
+ ).toEqual([]);
+
+ // Offered by the form, not read by the executor ⇒ an author fills in a
+ // Studio field that does nothing — the #3528 shape.
+ expect(
+ form.filter((k) => !zodded.includes(k)),
+ `${nodeType}: offered by the designer form but never read by the executor`,
+ ).toEqual([]);
+ });
+
+ it('screen.fields items: the repeater columns match the executor one level down', () => {
+ // #3528 lived in a nested repeater column (`visibleWhen`), so for `screen`
+ // the top-level comparison is not enough — reconcile the item key sets too.
+ const itemProps = formSchema('screen').properties?.fields?.items?.properties;
+ expect(itemProps, 'screen.fields.items should declare properties').toBeDefined();
+ const formItemKeys = Object.keys(itemProps ?? {}).sort();
+ const zodItemKeys = zodKeys(ScreenFieldConfigSchema);
+
+ expect(
+ zodItemKeys.filter((k) => !formItemKeys.includes(k)),
+ 'screen.fields[]: read by the executor but absent from the repeater',
+ ).toEqual([]);
+ expect(
+ formItemKeys.filter((k) => !zodItemKeys.includes(k)),
+ 'screen.fields[]: offered by the repeater but never read by the executor',
+ ).toEqual([]);
+ });
+
+ describe('assignment: pinned as un-reconcilable, with the reason on record', () => {
+ // `assignment` deliberately has NO entry in builtin-node-config.zod.ts: with
+ // no `assignments` wrapper, the TOP-LEVEL config keys ARE the author's
+ // variable names (logic-nodes.ts normalizes three shapes). A fixed key set
+ // cannot describe that surface, and a Zod with a catchall would reconcile
+ // vacuously — every key matches everything. What CAN be pinned is the
+ // canonical authoring surface the form offers, so it is:
+ it('the form offers exactly the canonical `assignments` map', () => {
+ expect(formKeys('assignment')).toEqual(['assignments']);
+ });
+
+ it('the assignments slot stays a free-form map (the openness IS the contract)', () => {
+ const prop = formSchema('assignment').properties?.assignments as
+ | { additionalProperties?: unknown; properties?: unknown }
+ | undefined;
+ expect(prop).toBeDefined();
+ expect(prop!.additionalProperties).toBe(true);
+ expect(prop!.properties).toBeUndefined();
+ });
+ });
+});
diff --git a/packages/services/service-automation/src/builtin/config-unknown-keys.test.ts b/packages/services/service-automation/src/builtin/config-unknown-keys.test.ts
index ca2893e728..9b1591d5d7 100644
--- a/packages/services/service-automation/src/builtin/config-unknown-keys.test.ts
+++ b/packages/services/service-automation/src/builtin/config-unknown-keys.test.ts
@@ -71,7 +71,9 @@ describe('unknown node config keys (#4045)', () => {
// `visibleWhen` is edit-distance 4 against `nearestName`'s threshold of 3, so
// this exact typo gets no did-you-mean. Printing the declared set is what
// makes the diagnostic actionable regardless.
- expect(hit).toContain('Declared here: name, label, type, required, visibleWhen.');
+ expect(hit).toContain(
+ 'Declared here: name, label, type, required, options, defaultValue, placeholder, visibleWhen.',
+ );
});
it('adds a did-you-mean when the typo IS within edit distance', () => {
diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts
index 9826c72d09..dda00b0184 100644
--- a/packages/services/service-automation/src/builtin/crud-nodes.ts
+++ b/packages/services/service-automation/src/builtin/crud-nodes.ts
@@ -152,6 +152,13 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
properties: {
objectName: { type: 'string', title: 'Object', xRef: { kind: 'object' } },
filter: { type: 'object', additionalProperties: true, title: 'Filter', description: 'Field/value pairs to match (e.g. status → active). Operator values like {"$ne": null} are preserved.' },
+ // Declared in #4045: the executor has always passed this
+ // straight into find/findOne as the projection, but no
+ // form offered it — authorable only by hand until now.
+ fields: {
+ type: 'array', items: { type: 'string' }, title: 'Fields',
+ description: 'Field projection — only these fields are read (default: all).',
+ },
limit: { type: 'integer', title: 'Limit' },
outputVariable: { type: 'string', title: 'Output variable' },
},
diff --git a/packages/services/service-automation/src/builtin/map-node.test.ts b/packages/services/service-automation/src/builtin/map-node.test.ts
index c236acb082..f0a81eb38c 100644
--- a/packages/services/service-automation/src/builtin/map-node.test.ts
+++ b/packages/services/service-automation/src/builtin/map-node.test.ts
@@ -188,3 +188,38 @@ describe('map descriptor configSchema (objectui #2670 Phase 3 / #3304)', () => {
expect(schema?.properties?.outputVariable?.xExpression).toBeUndefined();
});
});
+
+// #4045 — this proves the CONVERSION, not executor tolerance. The executor
+// reads only the canonical `flowName`; a stored flow on the undeclared `flow`
+// spelling reaches it because `registerFlow` applies
+// 'flow-node-map-flow-alias', which renames it at load.
+describe('map config `flow` alias (#4045)', () => {
+ it('accepts the legacy `config.flow` spelling via the conversion layer', async () => {
+ const captured: unknown[] = [];
+ const engine = setup([{ id: 'cm', type: 'itemmark' }], captured);
+ engine.registerFlow('legacy_parent', {
+ name: 'legacy_parent',
+ label: 'Legacy Parent',
+ type: 'autolaunched',
+ variables: [{ name: 'items', type: 'list', isInput: true }],
+ nodes: [
+ { id: 'ps', type: 'start', label: 'Start' },
+ {
+ id: 'do_map', type: 'map', label: 'For each',
+ config: { flow: 'child_flow', collection: '{items}', iteratorVariable: 'item', input: { val: '{item}' }, outputVariable: 'mapped' },
+ },
+ { id: 'chk', type: 'mapcheck', label: 'Check' },
+ { id: 'pe', type: 'end', label: 'End' },
+ ],
+ edges: [
+ { id: 'p1', source: 'ps', target: 'do_map' },
+ { id: 'p2', source: 'do_map', target: 'chk' },
+ { id: 'p3', source: 'chk', target: 'pe' },
+ ],
+ } as never);
+
+ const result = await engine.execute('legacy_parent', { params: { items: ['a', 'b'] } });
+ expect(result.success).toBe(true);
+ expect(captured).toEqual([[{ result: 'a' }, { result: 'b' }]]);
+ });
+});
diff --git a/packages/services/service-automation/src/builtin/map-node.ts b/packages/services/service-automation/src/builtin/map-node.ts
index 2857470879..051e4baf4f 100644
--- a/packages/services/service-automation/src/builtin/map-node.ts
+++ b/packages/services/service-automation/src/builtin/map-node.ts
@@ -64,7 +64,11 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
collection: { type: 'string', title: 'Collection', description: 'Expression resolving to the array to process, one item at a time.', xExpression: 'template' },
flowName: { type: 'string', title: 'Per-item flow', description: 'Subflow run for each item — it may pause (e.g. an approval).', xRef: { kind: 'flow' } },
iteratorVariable: { type: 'string', title: 'Item variable' },
+ // Declared in #4045 — both read by the executor, neither offered by
+ // any form (online or offline) until now.
+ indexVariable: { type: 'string', title: 'Index variable', description: 'Optional variable holding the current zero-based index.' },
itemObject: { type: 'string', title: 'Item object', description: 'When items are records, the object they belong to (exposes each item as the child’s record).', xRef: { kind: 'object' } },
+ input: { type: 'object', additionalProperties: true, title: 'Item input', description: 'Params passed to each item’s subflow, interpolated per item (the item variable is in scope).' },
outputVariable: { type: 'string', title: 'Output variable', description: 'Each item’s subflow output, collected in order.' },
},
required: ['collection', 'flowName'],
@@ -72,8 +76,12 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
}),
async execute(node, variables, context) {
const cfg = (node.config ?? {}) as Record;
- const flowName =
- typeof cfg.flowName === 'string' ? cfg.flowName : typeof cfg.flow === 'string' ? cfg.flow : undefined;
+ // The undeclared `flow` alias this used to accept via a bare `??` has
+ // graduated into the ADR-0087 D2 conversion layer as
+ // 'flow-node-map-flow-alias' (#4045), rewritten at load — including the
+ // `registerFlow` rehydration seam — so only the canonical key is read
+ // here (PD #12: no consumer-side fallbacks).
+ const flowName = typeof cfg.flowName === 'string' ? cfg.flowName : undefined;
if (!flowName) {
return refuseNode(`map '${node.id}': config.flowName (the per-item subflow) is required`);
}
diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts
index 13a164f42f..93f0cbec0e 100644
--- a/packages/services/service-automation/src/builtin/screen-nodes.ts
+++ b/packages/services/service-automation/src/builtin/screen-nodes.ts
@@ -63,6 +63,24 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
label: { type: 'string', title: 'Label' },
type: { type: 'string', title: 'Type' },
required: { type: 'boolean', title: 'Required' },
+ // Declared in #4045 — all three are forwarded into the
+ // ScreenSpec the client renders, but no form offered them, so
+ // a select field's choices (and any prefill or hint text) were
+ // authorable only by hand. Same nested position as the
+ // `visibleWhen` gap #3528 was filed for.
+ options: {
+ type: 'array', title: 'Options',
+ description: 'Choices for a select-style field.',
+ items: {
+ type: 'object',
+ properties: {
+ value: { title: 'Value', description: 'Stored value.' },
+ label: { type: 'string', title: 'Label' },
+ },
+ },
+ },
+ defaultValue: { title: 'Default', description: 'Prefilled value. Interpolates {var} references.' },
+ placeholder: { type: 'string', title: 'Placeholder' },
visibleWhen: { type: 'string', title: 'Visible when', xExpression: 'expression' },
},
},
@@ -71,6 +89,10 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
objectName: { type: 'string', title: 'Object form', xRef: { kind: 'object' }, description: 'Render this object’s full create/edit form (incl. master-detail) instead of a flat field list.' },
idVariable: { type: 'string', title: 'Saved-record variable', description: 'Object form only: variable bound to the saved record’s id, for later steps.' },
mode: { type: 'string', enum: ['create', 'edit'], default: 'create', title: 'Form mode', description: 'Object form only.' },
+ // Declared in #4045: `mode: 'edit'` needs a record to edit, and the
+ // executor reads exactly this key for it — but the form declared the
+ // mode while offering no way to name its target.
+ recordId: { type: 'string', title: 'Record to edit', description: 'Object form only: id of the record `edit` mode opens. Interpolates {var} references.' },
defaults: { type: 'object', additionalProperties: true, title: 'Form defaults', description: 'Object form only: prefilled values (e.g. account → {account_id}).' },
},
},
diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json
index b084f3526f..94f8c5b682 100644
--- a/packages/spec/api-surface.json
+++ b/packages/spec/api-surface.json
@@ -2098,6 +2098,9 @@
"ConnectorSchema (const)",
"ConnectorTrigger (type)",
"ConnectorTriggerSchema (const)",
+ "CreateRecordConfig (type)",
+ "CreateRecordConfigParsed (type)",
+ "CreateRecordConfigSchema (const)",
"DEPRECATED_APPROVER_TYPES (const)",
"DataDestinationConfig (type)",
"DataDestinationConfigSchema (const)",
@@ -2107,6 +2110,9 @@
"DataSyncConfigSchema (const)",
"DecisionOutputDef (type)",
"DecisionOutputDefSchema (const)",
+ "DeleteRecordConfig (type)",
+ "DeleteRecordConfigParsed (type)",
+ "DeleteRecordConfigSchema (const)",
"ETL (const)",
"ETLDestination (type)",
"ETLDestinationSchema (const)",
@@ -2160,6 +2166,9 @@
"FlowVersionHistory (type)",
"FlowVersionHistoryParsed (type)",
"FlowVersionHistorySchema (const)",
+ "GetRecordConfig (type)",
+ "GetRecordConfigParsed (type)",
+ "GetRecordConfigSchema (const)",
"GuardRefSchema (const)",
"HttpConfig (type)",
"HttpConfigParsed (type)",
@@ -2169,6 +2178,9 @@
"LoopConfig (type)",
"LoopConfigParsed (type)",
"LoopConfigSchema (const)",
+ "MapConfig (type)",
+ "MapConfigParsed (type)",
+ "MapConfigSchema (const)",
"MappableFlow (interface)",
"NON_AUTHORABLE_APPROVER_TYPES (const)",
"NodeExecutorDescriptor (type)",
@@ -2197,6 +2209,11 @@
"ScheduleState (type)",
"ScheduleStateParsed (type)",
"ScheduleStateSchema (const)",
+ "ScreenConfig (type)",
+ "ScreenConfigParsed (type)",
+ "ScreenConfigSchema (const)",
+ "ScreenFieldConfig (type)",
+ "ScreenFieldConfigSchema (const)",
"StateMachineConfig (type)",
"StateMachineSchema (const)",
"StateNodeConfig (type)",
@@ -2221,6 +2238,9 @@
"TryCatchConfig (type)",
"TryCatchConfigParsed (type)",
"TryCatchConfigSchema (const)",
+ "UpdateRecordConfig (type)",
+ "UpdateRecordConfigParsed (type)",
+ "UpdateRecordConfigSchema (const)",
"WAIT_EXECUTOR_DESCRIPTOR (const)",
"WaitEventType (type)",
"WaitEventTypeSchema (const)",
diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json
index cb2ebf9981..bb81c8ed5f 100644
--- a/packages/spec/authorable-surface.json
+++ b/packages/spec/authorable-surface.json
@@ -2253,6 +2253,9 @@
"automation/ConnectorTrigger:outputSchema",
"automation/ConnectorTrigger:pollingIntervalMs",
"automation/ConnectorTrigger:type",
+ "automation/CreateRecordConfig:fields",
+ "automation/CreateRecordConfig:objectName",
+ "automation/CreateRecordConfig:outputVariable",
"automation/DataDestinationConfig:connectorInstanceId",
"automation/DataDestinationConfig:externalResource",
"automation/DataDestinationConfig:mapping",
@@ -2288,6 +2291,8 @@
"automation/DecisionOutputDef:multiple",
"automation/DecisionOutputDef:required",
"automation/DecisionOutputDef:type",
+ "automation/DeleteRecordConfig:filter",
+ "automation/DeleteRecordConfig:objectName",
"automation/ETLDestination:config",
"automation/ETLDestination:connector",
"automation/ETLDestination:primaryKey",
@@ -2415,6 +2420,11 @@
"automation/FlowVersionHistory:definition",
"automation/FlowVersionHistory:flowName",
"automation/FlowVersionHistory:version",
+ "automation/GetRecordConfig:fields",
+ "automation/GetRecordConfig:filter",
+ "automation/GetRecordConfig:limit",
+ "automation/GetRecordConfig:objectName",
+ "automation/GetRecordConfig:outputVariable",
"automation/HttpConfig:body",
"automation/HttpConfig:durable",
"automation/HttpConfig:headers",
@@ -2427,6 +2437,13 @@
"automation/LoopConfig:indexVariable",
"automation/LoopConfig:iteratorVariable",
"automation/LoopConfig:maxIterations",
+ "automation/MapConfig:collection",
+ "automation/MapConfig:flowName",
+ "automation/MapConfig:indexVariable",
+ "automation/MapConfig:input",
+ "automation/MapConfig:itemObject",
+ "automation/MapConfig:iteratorVariable",
+ "automation/MapConfig:outputVariable",
"automation/NodeExecutorDescriptor:configSchemaRef",
"automation/NodeExecutorDescriptor:description",
"automation/NodeExecutorDescriptor:id",
@@ -2486,6 +2503,23 @@
"automation/ScheduleState:timezone",
"automation/ScheduleState:totalRuns",
"automation/ScheduleState:updatedAt",
+ "automation/ScreenConfig:defaults",
+ "automation/ScreenConfig:description",
+ "automation/ScreenConfig:fields",
+ "automation/ScreenConfig:idVariable",
+ "automation/ScreenConfig:mode",
+ "automation/ScreenConfig:objectName",
+ "automation/ScreenConfig:recordId",
+ "automation/ScreenConfig:title",
+ "automation/ScreenConfig:waitForInput",
+ "automation/ScreenFieldConfig:defaultValue",
+ "automation/ScreenFieldConfig:label",
+ "automation/ScreenFieldConfig:name",
+ "automation/ScreenFieldConfig:options",
+ "automation/ScreenFieldConfig:placeholder",
+ "automation/ScreenFieldConfig:required",
+ "automation/ScreenFieldConfig:type",
+ "automation/ScreenFieldConfig:visibleWhen",
"automation/StateMachine:contextSchema",
"automation/StateMachine:description",
"automation/StateMachine:id",
@@ -2523,6 +2557,9 @@
"automation/TryCatchConfig:errorVariable",
"automation/TryCatchConfig:retry",
"automation/TryCatchConfig:try",
+ "automation/UpdateRecordConfig:fields",
+ "automation/UpdateRecordConfig:filter",
+ "automation/UpdateRecordConfig:objectName",
"automation/WaitExecutorConfig:conditionMaxPolls",
"automation/WaitExecutorConfig:conditionPollIntervalMs",
"automation/WaitExecutorConfig:defaultTimeoutBehavior",
diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json
index 1bbbc4427e..d23b5be805 100644
--- a/packages/spec/json-schema.manifest.json
+++ b/packages/spec/json-schema.manifest.json
@@ -527,10 +527,12 @@
"automation/ConnectorInstance",
"automation/ConnectorOperation",
"automation/ConnectorTrigger",
+ "automation/CreateRecordConfig",
"automation/DataDestinationConfig",
"automation/DataSourceConfig",
"automation/DataSyncConfig",
"automation/DecisionOutputDef",
+ "automation/DeleteRecordConfig",
"automation/ETLDestination",
"automation/ETLEndpointType",
"automation/ETLPipeline",
@@ -553,9 +555,11 @@
"automation/FlowRegion",
"automation/FlowVariable",
"automation/FlowVersionHistory",
+ "automation/GetRecordConfig",
"automation/GuardRef",
"automation/HttpConfig",
"automation/LoopConfig",
+ "automation/MapConfig",
"automation/NodeExecutorDescriptor",
"automation/NotifyConfig",
"automation/OAuth2Config",
@@ -565,6 +569,8 @@
"automation/ParallelConfig",
"automation/RetryPolicy",
"automation/ScheduleState",
+ "automation/ScreenConfig",
+ "automation/ScreenFieldConfig",
"automation/StateMachine",
"automation/StateNode",
"automation/SyncDirection",
@@ -574,6 +580,7 @@
"automation/TimeRelativeTrigger",
"automation/Transition",
"automation/TryCatchConfig",
+ "automation/UpdateRecordConfig",
"automation/WaitEventType",
"automation/WaitExecutorConfig",
"automation/WaitResumePayload",
diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json
index f4a0776604..7aaba8330a 100644
--- a/packages/spec/spec-changes.json
+++ b/packages/spec/spec-changes.json
@@ -116,6 +116,12 @@
"conversionId": "flow-node-connector-config-lift",
"toMajor": 17
},
+ {
+ "surface": "flow.node.map.config.flowName",
+ "to": "map flow-node config key 'flow' → 'flowName' (#4045 — undeclared executor fallback graduation)",
+ "conversionId": "flow-node-map-flow-alias",
+ "toMajor": 17
+ },
{
"surface": "flow.node.script.config",
"to": "script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796)",
@@ -519,6 +525,12 @@
"conversionId": "flow-node-connector-config-lift",
"toMajor": 17
},
+ {
+ "surface": "flow.node.map.config.flowName",
+ "to": "map flow-node config key 'flow' → 'flowName' (#4045 — undeclared executor fallback graduation)",
+ "conversionId": "flow-node-map-flow-alias",
+ "toMajor": 17
+ },
{
"surface": "flow.node.script.config",
"to": "script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796)",
diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts
new file mode 100644
index 0000000000..86f96dc411
--- /dev/null
+++ b/packages/spec/src/automation/builtin-node-config.zod.ts
@@ -0,0 +1,225 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * @module automation/builtin-node-config
+ *
+ * Config contracts for the remaining flat builtins — the CRUD quartet
+ * (`get_record` / `create_record` / `update_record` / `delete_record`),
+ * `screen`, and `map` (#4045). Sibling of `io-node-config.zod.ts`
+ * (notify / http) and `control-flow.zod.ts` (loop / parallel / try_catch).
+ *
+ * ## Provenance — written from the executors, not from the forms
+ *
+ * Each schema was derived by reading what the executor actually does with
+ * `node.config` (`service-automation/builtin/crud-nodes.ts`,
+ * `screen-nodes.ts`, `map-node.ts`), **not** by transcribing the hand-written
+ * `configSchema` literal on the node's descriptor. The two artifacts are
+ * reconciled bidirectionally by `builtin-node-form-zod-ledger.test.ts` in
+ * `service-automation`; a Zod copied from the form would make that
+ * reconciliation a tautology (#4045).
+ *
+ * Writing these against the executors is what surfaced the drift the
+ * reconciliation exists to catch — keys the executors read that no form
+ * offered anywhere (`get_record.fields`, `screen.recordId`, the screen
+ * field-item keys `options`/`defaultValue`/`placeholder`, `map.indexVariable`,
+ * `map.input`), and the undeclared `map.flow` alias, which graduated into the
+ * ADR-0087 D2 conversion layer like `notify.source` before it.
+ *
+ * ## What these schemas are (and are not) wired to
+ *
+ * Contract exports only — no engine path `parse()`s a node config with them
+ * today, so registering a flow behaves exactly as before. Wiring the executors
+ * to parse is deliberately deferred until the #4059 undeclared-key warning has
+ * measured a release's worth of real metadata (#4045 step 3b).
+ *
+ * Deliberately absent:
+ * - `assignment` — its config cannot be described by a fixed key set: with no
+ * `assignments` wrapper the TOP-LEVEL config keys ARE the author's variable
+ * names (logic-nodes.ts). The ledger test pins that exemption with its
+ * reason instead of pretending a shape.
+ * - `decision` / `script` / `subflow` / `wait` / `connector_action` — the
+ * deliberately-schemaless class (config-schemas.test.ts); their contracts
+ * live elsewhere (edges, sibling blocks, conditional forms).
+ */
+
+import { z } from 'zod';
+import { lazySchema } from '../shared/lazy-schema';
+
+// ─── CRUD quartet ────────────────────────────────────────────────────
+
+/**
+ * `get_record` node config — what the executor reads.
+ *
+ * `objectName` is execute-time required (the step is refused without it).
+ * `filter` values may carry operator objects (`{"$ne": null}`) and `{token}`
+ * templates; a template that resolves to nothing REFUSES the node rather than
+ * widening the query (#3810). `limit > 1` selects `find` (a `records` list);
+ * otherwise `findOne` (a single `record`).
+ */
+export const GetRecordConfigSchema = lazySchema(() => z.object({
+ /** Object to query (execute-time required). */
+ objectName: z.string().describe('Object to query'),
+ /** Field/value pairs to match; operator objects and `{token}` templates are legal values. */
+ filter: z.record(z.string(), z.unknown()).optional()
+ .describe('Field/value pairs to match (operator values like {"$ne": null} are preserved)'),
+ /** Field projection — only these fields are read (default: all). */
+ fields: z.array(z.string()).optional()
+ .describe('Field projection — only these fields are read (default: all)'),
+ /** Max records; >1 returns a `records` list, otherwise a single `record`. */
+ limit: z.number().optional()
+ .describe('Max records to return; >1 switches to a multi-record query'),
+ /** Flow variable the result is bound to. */
+ outputVariable: z.string().optional().describe('Flow variable the result is bound to'),
+}));
+
+export type GetRecordConfig = z.input;
+export type GetRecordConfigParsed = z.infer;
+
+/** `create_record` node config — what the executor reads. `objectName` is execute-time required. */
+export const CreateRecordConfigSchema = lazySchema(() => z.object({
+ /** Object to insert into (execute-time required). */
+ objectName: z.string().describe('Object to insert into'),
+ /** Field values to write on the new record; values interpolate `{token}` templates. */
+ fields: z.record(z.string(), z.unknown()).optional()
+ .describe('Field values to write on the new record'),
+ /** Flow variable bound to the created record (`{var.id}` works even when the driver returns a bare id). */
+ outputVariable: z.string().optional()
+ .describe('Flow variable bound to the created record'),
+}));
+
+export type CreateRecordConfig = z.input;
+export type CreateRecordConfigParsed = z.infer;
+
+/**
+ * `update_record` node config — what the executor reads. No `outputVariable`:
+ * the executor does not read one (recorded in #4045 so nobody re-chases it).
+ */
+export const UpdateRecordConfigSchema = lazySchema(() => z.object({
+ /** Object to update (execute-time required). */
+ objectName: z.string().describe('Object to update'),
+ /** Field/value pairs identifying the record(s) to update; an erased template condition refuses the node (#3810). */
+ filter: z.record(z.string(), z.unknown()).optional()
+ .describe('Field/value pairs identifying the record(s) to update'),
+ /** Field values to write; values interpolate `{token}` templates. */
+ fields: z.record(z.string(), z.unknown()).optional().describe('Field values to write'),
+}));
+
+export type UpdateRecordConfig = z.input;
+export type UpdateRecordConfigParsed = z.infer;
+
+/** `delete_record` node config — what the executor reads. The erased-condition guard matters most here (#3810). */
+export const DeleteRecordConfigSchema = lazySchema(() => z.object({
+ /** Object to delete from (execute-time required). */
+ objectName: z.string().describe('Object to delete from'),
+ /** Field/value pairs identifying the record(s) to delete. */
+ filter: z.record(z.string(), z.unknown()).optional()
+ .describe('Field/value pairs identifying the record(s) to delete'),
+}));
+
+export type DeleteRecordConfig = z.input;
+export type DeleteRecordConfigParsed = z.infer;
+
+// ─── screen ──────────────────────────────────────────────────────────
+
+/**
+ * One input field on a flat-list `screen` (what the executor forwards into the
+ * `ScreenSpec` the client renders). `visibleWhen` is forwarded RAW — the client
+ * re-evaluates it against the values collected so far (#3528).
+ */
+export const ScreenFieldConfigSchema = lazySchema(() => z.object({
+ /** Field name — an item with an empty name is dropped. */
+ name: z.string().describe('Field name (the flow variable the value binds to)'),
+ /** Display label. */
+ label: z.string().optional().describe('Display label'),
+ /** Input type (text, number, select, …). */
+ type: z.string().optional().describe('Input type'),
+ /** Whether the runner requires a value before resume. */
+ required: z.boolean().optional().describe('Whether a value is required to submit'),
+ /** Choices for a select-style field. */
+ options: z.array(z.object({
+ value: z.unknown().describe('Stored value'),
+ label: z.string().describe('Display label'),
+ })).optional().describe('Choices for a select-style field'),
+ /** Prefilled value; interpolates `{token}` templates at suspend time. */
+ defaultValue: z.unknown().optional().describe('Prefilled value (interpolates {token} templates)'),
+ /** Input placeholder text. */
+ placeholder: z.string().optional().describe('Input placeholder text'),
+ /** Bare-CEL predicate the client re-evaluates as values change (#3528). */
+ visibleWhen: z.string().optional().describe('CEL predicate controlling visibility, evaluated client-side'),
+}));
+
+export type ScreenFieldConfig = z.input;
+
+/**
+ * `screen` node config — what the executor reads.
+ *
+ * Two mutually exclusive shapes share the key set: a FLAT screen (`fields`,
+ * suspends when non-empty unless `waitForInput === false`) and an OBJECT-FORM
+ * screen (`objectName` set → the object's full create/edit form; `mode`,
+ * `recordId`, `defaults`, `idVariable` apply only there). `recordId` is what
+ * makes `mode: 'edit'` usable — it names the record the form edits.
+ */
+export const ScreenConfigSchema = lazySchema(() => z.object({
+ /** Heading (falls back to the node label). Interpolates `{token}`. */
+ title: z.string().optional().describe('Heading shown above the screen'),
+ /** Body text. Interpolates `{token}`. */
+ description: z.string().optional().describe('Body text shown under the heading'),
+ /** Input fields for a flat screen; empty means message-only. */
+ fields: z.array(ScreenFieldConfigSchema).optional()
+ .describe('Input fields collected on this screen'),
+ /** Force a pause with no fields (true) or a pass-through despite fields (false). */
+ waitForInput: z.boolean().optional()
+ .describe('Pause to show the screen even with no fields; false forces a server pass-through'),
+ /** Object-form screen: render this object's full create/edit form instead. */
+ objectName: z.string().optional()
+ .describe("Render this object's full create/edit form instead of a flat field list"),
+ /** Object form only: variable bound to the saved record's id. */
+ idVariable: z.string().optional()
+ .describe("Object form only: variable bound to the saved record's id"),
+ /** Object form only: create (default) or edit. Any value other than 'edit' behaves as create. */
+ mode: z.enum(['create', 'edit']).optional().describe('Object form only: create or edit'),
+ /** Object form only: id of the record `mode: 'edit'` edits. Interpolates `{token}`. */
+ recordId: z.string().optional()
+ .describe("Object form only: id of the record to edit (required for mode: 'edit' to be useful)"),
+ /** Object form only: prefilled values; interpolates `{token}` templates. */
+ defaults: z.record(z.string(), z.unknown()).optional()
+ .describe('Object form only: prefilled values'),
+}));
+
+export type ScreenConfig = z.input;
+export type ScreenConfigParsed = z.infer;
+
+// ─── map ─────────────────────────────────────────────────────────────
+
+/**
+ * `map` node config — what the executor reads (ADR-0037 sequential
+ * multi-instance: a per-item subflow that may durably pause).
+ *
+ * `flowName` and `collection` are execute-time required. The historical
+ * undeclared `flow` alias is NOT part of this contract: the ADR-0087 D2
+ * conversion `flow-node-map-flow-alias` rewrites it at load, so the executor
+ * only ever sees `flowName` (#4045 — the `notify.source` graduation path).
+ */
+export const MapConfigSchema = lazySchema(() => z.object({
+ /** The collection — a `{token}` template / bare variable name, or an inline array. */
+ collection: z.union([z.string(), z.array(z.unknown())])
+ .describe('Template/variable resolving to the array to process (an inline array is accepted)'),
+ /** The per-item subflow (execute-time required). */
+ flowName: z.string().describe('Subflow run for each item — it may pause (e.g. an approval)'),
+ /** Variable the current item is bound to inside each item's scope. */
+ iteratorVariable: z.string().default('item').describe('Variable holding the current item'),
+ /** Optional variable the zero-based index is bound to. */
+ indexVariable: z.string().optional().describe('Optional variable holding the current index'),
+ /** When items are records, the object they belong to (exposes each item as the child's record). */
+ itemObject: z.string().optional()
+ .describe("When items are records, the object they belong to (exposes each item as the child's record)"),
+ /** Params passed to each item's subflow; interpolated with the item variable bound. */
+ input: z.record(z.string(), z.unknown()).optional()
+ .describe("Params passed to each item's subflow (interpolated per item)"),
+ /** Variable the ordered list of per-item outputs is bound to. */
+ outputVariable: z.string().optional()
+ .describe("Each item's subflow output, collected in order"),
+}));
+
+export type MapConfig = z.input;
+export type MapConfigParsed = z.infer;
diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts
index 2fa96245db..1743d77764 100644
--- a/packages/spec/src/automation/index.ts
+++ b/packages/spec/src/automation/index.ts
@@ -4,6 +4,7 @@
export * from './flow.zod';
export * from './control-flow.zod';
export * from './io-node-config.zod';
+export * from './builtin-node-config.zod';
export { flowForm } from './flow.form';
export * from './execution.zod';
export * from './webhook.zod';
diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts
index f331f74de5..b88599bae5 100644
--- a/packages/spec/src/conversions/registry.ts
+++ b/packages/spec/src/conversions/registry.ts
@@ -1233,6 +1233,57 @@ const flowNodeWaitEventConfigLift: MetadataConversion = {
},
};
+/**
+ * Map flow-node config key alias → canonical (protocol 17, #4045).
+ *
+ * `flowName` is the canonical key — it is what the descriptor's `configSchema`
+ * declares, what the designer offers, and what the showcase authors. The
+ * executor nonetheless accepted a bare `cfg.flowName ?? cfg.flow` fallback for
+ * an undeclared `flow` spelling that no schema ever described: the
+ * `notify.source` shape (PD #12), found by writing `MapConfigSchema` from the
+ * executor for the #4045 reconciliation.
+ *
+ * A pure key rename with unchanged values, so the pair mechanism expresses it
+ * directly. **Live window**; retires at 18.
+ */
+const flowNodeMapFlowAlias: MetadataConversion = {
+ id: 'flow-node-map-flow-alias',
+ toMajor: 17,
+ surface: 'flow.node.map.config.flowName',
+ summary: "map flow-node config key 'flow' → 'flowName' (#4045 — undeclared executor fallback graduation)",
+ apply(stack, emit) {
+ return renameFlowConfigAliases(stack, new Set(['map']), [['flow', 'flowName']], emit);
+ },
+ fixture: {
+ before: {
+ flows: [
+ {
+ name: 'batch_signoff',
+ nodes: [
+ { id: 'n1', type: 'start' },
+ { id: 'n2', type: 'map', config: { collection: '{tasks}', flow: 'one_task_signoff' } },
+ // canonical already present → the shadowed alias is left alone (no notice)
+ { id: 'n3', type: 'map', config: { collection: '{rows}', flowName: 'per_row', flow: 'ignored' } },
+ ],
+ },
+ ],
+ },
+ after: {
+ flows: [
+ {
+ name: 'batch_signoff',
+ nodes: [
+ { id: 'n1', type: 'start' },
+ { id: 'n2', type: 'map', config: { collection: '{tasks}', flowName: 'one_task_signoff' } },
+ { id: 'n3', type: 'map', config: { collection: '{rows}', flowName: 'per_row', flow: 'ignored' } },
+ ],
+ },
+ ],
+ },
+ expectedNotices: 1,
+ },
+};
+
/** The `config` keys a mis-taught `connector_action` node carries, in declared-block order. */
const CONNECTOR_CONFIG_LIFTS = ['connectorId', 'actionId', 'input'] as const;
@@ -2029,6 +2080,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly