From c41b219d78d7a43570244172ab3080f6003a020b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 16:33:11 +0000 Subject: [PATCH] fix(spec,service-automation): reconcile the CRUD/screen/map declared config with what the executors read (#4045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the declared-vs-read reconciliation across the flat builtins, after notify/http/connector in #4210. Six executor-derived Zod contracts land in automation/builtin-node-config.zod.ts (get/create/update/delete_record, screen + its field item, map), each written by READING the executor rather than transcribing the descriptor literal — so the new bidirectional ledger test is evidence, not a tautology. Contract exports only; nothing parses with them yet (step 3b, gated on the #4059 warning data). Writing them against the executors surfaced seven live capabilities that no form offered, online or offline — authorable only by hand-written metadata: get_record.fields (the projection passed into find/findOne), screen.recordId (the record `mode: 'edit'` opens — the form declared the mode but not 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 — the same nested repeater position as #3528), and map.indexVariable/map.input. All are declared on their descriptors now. map's undeclared `flow` alias went the other route: the executor carried `cfg.flowName ?? cfg.flow` for a spelling no schema ever described — the notify.source shape (PD #12). The bare fallback is deleted and flow-node-map-flow-alias (protocol 17, retires at 18) renames it at load, including the registerFlow rehydration seam. assignment is pinned as deliberately un-reconcilable with its reason on record: with no `assignments` wrapper its top-level config keys ARE the author's variable names, so no fixed key set describes it and a catchall Zod would reconcile vacuously. The ledger pins what CAN be pinned — the form offers exactly the canonical assignments map, and that map stays open. Every builtin publishing a configSchema is now reconciled against its executor; those publishing none each have a recorded reason. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UDhMtxPLLoFaGtdpNA7xTU --- .../builtin-node-config-reconciliation.md | 47 ++++ .../automation/builtin-node-config.mdx | 194 +++++++++++++++ content/docs/references/automation/index.mdx | 1 + content/docs/references/automation/meta.json | 1 + docs/protocol-upgrade-guide.md | 3 + .../builtin-node-form-zod-ledger.test.ts | 159 +++++++++++++ .../src/builtin/config-unknown-keys.test.ts | 4 +- .../src/builtin/crud-nodes.ts | 7 + .../src/builtin/map-node.test.ts | 35 +++ .../src/builtin/map-node.ts | 12 +- .../src/builtin/screen-nodes.ts | 22 ++ packages/spec/api-surface.json | 20 ++ packages/spec/authorable-surface.json | 37 +++ packages/spec/json-schema.manifest.json | 7 + packages/spec/spec-changes.json | 12 + .../src/automation/builtin-node-config.zod.ts | 225 ++++++++++++++++++ packages/spec/src/automation/index.ts | 1 + packages/spec/src/conversions/registry.ts | 52 ++++ packages/spec/src/migrations/registry.ts | 5 + 19 files changed, 841 insertions(+), 3 deletions(-) create mode 100644 .changeset/builtin-node-config-reconciliation.md create mode 100644 content/docs/references/automation/builtin-node-config.mdx create mode 100644 packages/services/service-automation/src/builtin/builtin-node-form-zod-ledger.test.ts create mode 100644 packages/spec/src/automation/builtin-node-config.zod.ts 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