From 0670808a8f556075923d3825f6daeb4b74c4292f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 05:54:20 +0000 Subject: [PATCH 1/2] feat(spec,automation): publish executor-derived config contracts for the schemaless flow nodes (#4278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The descriptor-schemaless builtins (decision/script/subflow/wait/ connector_action) deliberately publish no configSchema, so their Studio forms live only in objectui's hand-written FLOW_NODE_CONFIG table — and nothing reconciled that table against the executors. Publish the machine-readable half of that reconciliation from spec, written from the executors, not from any form: - automation/schemaless-node-config.zod.ts: ScriptConfigSchema / SubflowConfigSchema / DecisionConfigSchema (+ DecisionConditionSchema), plus SCRIPT_BUILTIN_ACTION_TYPES and SCRIPT_INVOKE_FUNCTION_ACTION_TYPE. Contract exports only — no engine path parses node config with them. wait / connector_action need no new schema: their contracts are the existing FlowNodeSchema sibling blocks. - The script executor now builds its dispatch set from the published constant, so the designer's options, the dispatch set, and the "not a built-in action" failure message cannot disagree. New pins: every published action type runs the built-in branch, the failure message names exactly the published set, and the Zod accepts the canonical authoring shapes. - subflow's undeclared `flow` alias graduates into the ADR-0087 D2 conversion layer (flow-node-subflow-flow-alias, protocol 17, live window) and the bare `cfg.flowName ?? cfg.flow` executor fallback is deleted (PD #12 — the map.flow path). Proved at the registerFlow seam. objectui's flow-node-config.spec-reconciliation test is the other half: it compares the hand-written form groups against these exports, closing the cross-repo seam #4278 documented (script offered three broken options and could not author the function path that works). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ki9WjmmSY19koz9hNkeL1P --- .../schemaless-node-config-contracts.md | 39 ++++ .../automation/builtin-node-config.mdx | 14 +- content/docs/references/automation/index.mdx | 1 + content/docs/references/automation/meta.json | 3 +- .../automation/schemaless-node-config.mdx | 147 ++++++++++++++ docs/protocol-upgrade-guide.md | 1 + .../src/builtin/config-schemas.test.ts | 6 + .../src/builtin/screen-nodes.test.ts | 64 ++++++ .../src/builtin/screen-nodes.ts | 13 +- .../src/builtin/subflow-node.test.ts | 19 ++ .../src/builtin/subflow-node.ts | 6 +- packages/spec/api-surface.json | 14 ++ packages/spec/authorable-surface.json | 14 ++ packages/spec/json-schema.manifest.json | 6 +- packages/spec/spec-changes.json | 12 ++ .../src/automation/builtin-node-config.zod.ts | 9 +- packages/spec/src/automation/index.ts | 1 + .../automation/schemaless-node-config.zod.ts | 189 ++++++++++++++++++ packages/spec/src/conversions/registry.ts | 53 +++++ packages/spec/src/migrations/registry.ts | 1 + 20 files changed, 599 insertions(+), 13 deletions(-) create mode 100644 .changeset/schemaless-node-config-contracts.md create mode 100644 content/docs/references/automation/schemaless-node-config.mdx create mode 100644 packages/spec/src/automation/schemaless-node-config.zod.ts diff --git a/.changeset/schemaless-node-config-contracts.md b/.changeset/schemaless-node-config-contracts.md new file mode 100644 index 0000000000..8562d47f4e --- /dev/null +++ b/.changeset/schemaless-node-config-contracts.md @@ -0,0 +1,39 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-automation": patch +--- + +feat(spec,automation): publish executor-derived config contracts for the schemaless flow nodes (#4278) + +The five descriptor-schemaless builtins (`decision` / `script` / `subflow` / +`wait` / `connector_action`) deliberately publish no `configSchema`, so their +Studio form lives only in objectui's hand-written `FLOW_NODE_CONFIG` table — +and nothing reconciled that table against the executors. `script` had drifted: +the form offered an `outputVariables` key nothing reads, two `actionType` +options (`sms` / `notification`) that fail every run, a no-op default (`code`), +and could not author the `function` / `inputs` / `outputVariable` path that +works. + +New in `@objectstack/spec/automation` (contract exports only — no engine path +parses node config with them): + +- `ScriptConfigSchema` / `SubflowConfigSchema` / `DecisionConfigSchema` (+ + `DecisionConditionSchema`) — written from the executors in + `service-automation`, the machine-readable half of the cross-repo + reconciliation objectui's `flow-node-config` test now performs. `wait` and + `connector_action` need no new schema — their contracts are the existing + `FlowNodeSchema` sibling blocks (`waitEventConfig` / `connectorConfig`). +- `SCRIPT_BUILTIN_ACTION_TYPES` (`['email', 'slack']`) and + `SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` (`'invoke_function'`) — the `script` + executor now builds its dispatch set from the published constant, so the + designer's options, the dispatch set, and the "not a built-in action" + failure message can no longer disagree. + +Undeclared-alias graduation in the same change (Prime Directive #12, the +`map.flow` path): the `subflow` executor's bare `cfg.flowName ?? cfg.flow` +fallback is deleted, replaced by the ADR-0087 D2 conversion +`flow-node-subflow-flow-alias` — a stored `subflow` node authored with +`config.flow` is rewritten to the canonical `config.flowName` at load +(including the `AutomationEngine.registerFlow` rehydration seam). FROM +`config.flow` TO `config.flowName`; one-line fix for hand-maintained sources: +rename the key. diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index 3a599486c9..0bd44540fe 100644 --- a/content/docs/references/automation/builtin-node-config.mdx +++ b/content/docs/references/automation/builtin-node-config.mdx @@ -65,9 +65,19 @@ reason instead of pretending a shape. - `decision` / `script` / `subflow` / `wait` / `connector_action` — the -deliberately-schemaless class (config-schemas.test.ts); their contracts +descriptor-schemaless class (config-schemas.test.ts). `wait` and -live elsewhere (edges, sibling blocks, conditional forms). +`connector_action` keep their contracts in FlowNodeSchema's sibling + +blocks (`waitEventConfig` / `connectorConfig`); the other three publish + +executor-derived config contracts in `schemaless-node-config.zod.ts` + +(#4278) — separate from this module because they must NOT grow into + +descriptor `configSchema`s (the forms they describe stay hand-written in + +objectui, reconciled by a test there). **Source:** `packages/spec/src/automation/builtin-node-config.zod.ts` diff --git a/content/docs/references/automation/index.mdx b/content/docs/references/automation/index.mdx index 8bf55f5b61..11d43f78da 100644 --- a/content/docs/references/automation/index.mdx +++ b/content/docs/references/automation/index.mdx @@ -15,6 +15,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 f389964539..9a54bd03d2 100644 --- a/content/docs/references/automation/meta.json +++ b/content/docs/references/automation/meta.json @@ -21,6 +21,7 @@ "job", "---More---", "builtin-node-config", - "io-node-config" + "io-node-config", + "schemaless-node-config" ] } \ No newline at end of file diff --git a/content/docs/references/automation/schemaless-node-config.mdx b/content/docs/references/automation/schemaless-node-config.mdx new file mode 100644 index 0000000000..30c9d57be0 --- /dev/null +++ b/content/docs/references/automation/schemaless-node-config.mdx @@ -0,0 +1,147 @@ +--- +title: Schemaless Node Config +description: Schemaless 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/schemaless-node-config + +Config contracts for the **descriptor-schemaless** builtins whose designer + +form lives ONLY in objectui's hand-written `FLOW_NODE_CONFIG` table — + +`script`, `subflow` and `decision` (#4278). + +## Why these nodes publish no descriptor `configSchema` — and still need this + +`config-schemas.test.ts` in `service-automation` pins the schemaless class + +with each member's reason: `decision`'s virtual Target column is derived from + +the out-edges, `script`'s form switches on `actionType`, `subflow` carries a + +top-level `timeoutMs` — a published partial schema would DROP those editors + +(the #4210 `connector_action` incident). So the Studio form for these types + +is objectui's hand-written group, and until #4278 **nothing reconciled that + +hand-written table against the executors**: `script`'s form offered an + +`outputVariables` key nothing reads, two `actionType` options that fail every + +run, a no-op default — and could not author the `function`/`inputs`/ + +`outputVariable` path that works. + +These schemas are the machine-readable half of that reconciliation. They are + +**written from the executors** (`service-automation/builtin/screen-nodes.ts` + +for `script`, `subflow-node.ts`, `logic-nodes.ts` for `decision`), not from + +any form, and objectui's `flow-node-config` reconciliation test compares its + +hand-written key sets against them — the same bidirectional ledger the + +descriptor-schema'd builtins get from `builtin-node-form-zod-ledger.test.ts`, + +carried across the repo seam by the `@objectstack/spec` dependency objectui + +already has. + +`wait` and `connector_action` — the other two schemaless members — need no + +entry here: their contracts are the spec-structured sibling blocks on + +`FlowNodeSchema` (`waitEventConfig` / `connectorConfig`), which the + +same objectui test reconciles directly. + +## What these schemas are (and are not) wired to + +Contract exports only — like `builtin-node-config.zod.ts`, no engine path + +`parse()`s a node config with them today, so registering a flow behaves + +exactly as before. + +Undeclared aliases are NOT part of these contracts: `subflow`'s historical + +`flow` spelling graduated into the ADR-0087 D2 conversion + +`flow-node-subflow-flow-alias` (the `map.flow` path), so the executor only + +ever sees `flowName`. + + +**Source:** `packages/spec/src/automation/schemaless-node-config.zod.ts` + + +## TypeScript Usage + +```typescript +import { DecisionCondition, DecisionConfig, ScriptConfig, SubflowConfig } from '@objectstack/spec/automation'; +import type { DecisionCondition, DecisionConfig, ScriptConfig, SubflowConfig } from '@objectstack/spec/automation'; + +// Validate data +const result = DecisionCondition.parse(data); +``` + +--- + +## DecisionCondition + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | ✅ | Branch label; the winning branch resumes down the out-edge with this label ('true' expression = default/else path) | +| **expression** | `string` | ✅ | Bare CEL predicate deciding this branch | + + +--- + +## DecisionConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **conditions** | `{ label: string; expression: string }[]` | optional | Ordered decision branches (first true expression wins; omit to branch purely on edge conditions) | + + +--- + +## ScriptConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **actionType** | `string` | optional | How this step runs: a built-in side effect ('email' \| 'slack'), the 'invoke_function' marker, or shorthand for a registered-function name | +| **function** | `string` | optional | Registered function to call (defineStack(`{ functions }`)); takes precedence over actionType | +| **inputs** | `Record` | optional | Inputs passed to the function (values interpolate `{token}` templates) | +| **outputVariable** | `string` | optional | Flow variable the function's return value is bound to | +| **template** | `string` | optional | Built-in side effects only: message template id | +| **recipients** | `string[]` | optional | Built-in side effects only: recipients (user ids, field refs, or addresses) | +| **variables** | `Record` | optional | Built-in side effects only: values injected into the template | +| **script** | `string` | optional | Inline JS source — recognized but not executed by the built-in runtime; use a registered function via `function` instead | + + +--- + +## SubflowConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **flowName** | `string` | ✅ | Flow invoked as this step (it may pause — approval / screen / wait) | +| **input** | `Record` | optional | Values passed to the subflow's input variables (interpolate `{token}` templates) | +| **outputVariable** | `string` | optional | Parent flow variable the subflow's output is bound to | + + +--- + diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index bee5068a0a..f64700ec6f 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -157,6 +157,7 @@ One flow key changes WITHOUT a lossless target: `errorHandling.maxRetries` (#424 | `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-subflow-flow-alias` | `flow.node.subflow.config.flowName` | subflow flow-node config key 'flow' → 'flowName' (#4278 — 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/config-schemas.test.ts b/packages/services/service-automation/src/builtin/config-schemas.test.ts index 71d631c053..2c70cabe59 100644 --- a/packages/services/service-automation/src/builtin/config-schemas.test.ts +++ b/packages/services/service-automation/src/builtin/config-schemas.test.ts @@ -17,6 +17,12 @@ * and `connector_action` (top-level `connectorConfig` block, #4045) — a * partial schema would drop those editors, and a schema rooted at `config` * would actively re-route sibling-block authoring to keys nothing reads. + * + * Schemaless ≠ contractless (#4278): `script` / `subflow` / `decision` publish + * executor-derived config Zods in spec `automation/schemaless-node-config.zod.ts` + * (wait / connector_action keep theirs as FlowNodeSchema sibling blocks), and + * objectui reconciles its hand-written form groups against those exports. The + * descriptors below still publish NO configSchema — that is what this file pins. */ import { describe, it, expect } from 'vitest'; diff --git a/packages/services/service-automation/src/builtin/screen-nodes.test.ts b/packages/services/service-automation/src/builtin/screen-nodes.test.ts index 3a9fe4c59d..c856742282 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.test.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.test.ts @@ -1,6 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeEach } from 'vitest'; +import { + SCRIPT_BUILTIN_ACTION_TYPES, + SCRIPT_INVOKE_FUNCTION_ACTION_TYPE, + ScriptConfigSchema, +} from '@objectstack/spec/automation'; import { AutomationEngine, type FlowFunctionHandler } from '../engine.js'; import { registerScreenNodes } from './screen-nodes.js'; @@ -155,6 +160,65 @@ it('canonicalizes a stored `functionName` key to `function` at load (#1870 DX, # }); }); +/** + * #4278 — the script node's contract is the spec-published one. The designer + * form for `script` is objectui's hand-written group (this node deliberately + * publishes no descriptor configSchema — config-schemas.test.ts), so the only + * machine-readable statement of what it accepts is + * `SCRIPT_BUILTIN_ACTION_TYPES` / `ScriptConfigSchema` in + * `@objectstack/spec/automation`. These pins are the objectstack half of the + * cross-repo reconciliation: the executor dispatches exactly the published + * built-in set (it now builds its dispatch set FROM the constant), and its + * failure message names that same set — objectui's side reconciles its form + * options and key set against the same exports. + */ +describe('script contract ↔ spec-published constants (#4278)', () => { + let engine: AutomationEngine; + + beforeEach(() => { + engine = new AutomationEngine(createTestLogger()); + registerScreenNodes(engine, createCtx()); + }); + + it.each([...SCRIPT_BUILTIN_ACTION_TYPES])( + "every published built-in actionType runs the built-in branch: '%s'", + async (actionType) => { + engine.registerFlow('script_flow', scriptFlow({ actionType, template: 't', recipients: ['a'] })); + const result = await engine.execute('script_flow', {} as any); + expect(result.success).toBe(true); + }, + ); + + it('an actionType outside the published set fails naming exactly that set (the #4278 sms repro)', async () => { + // The old objectui form offered 'sms' / 'notification'; neither is in + // the published set, so they resolve as function names and fail. The + // error must name the published members — it is the message the #4278 + // report quoted, and the form's options now come from the same constant. + engine.registerFlow('script_flow', scriptFlow({ actionType: 'sms' })); + const result = await engine.execute('script_flow', {} as any); + expect(result.success).toBe(false); + for (const builtin of SCRIPT_BUILTIN_ACTION_TYPES) { + expect(result.error).toContain(builtin); + } + expect(result.error).toMatch(/'sms' is not a built-in action/); + }); + + it('the published Zod accepts the canonical authoring shapes (contract sanity)', () => { + // Function path — the only shape that does real work. + expect(ScriptConfigSchema.parse({ + actionType: SCRIPT_INVOKE_FUNCTION_ACTION_TYPE, + function: 'score_lead', + inputs: { leadId: '{record.id}' }, + outputVariable: 'score', + })).toMatchObject({ function: 'score_lead' }); + // Built-in side effect. + expect(ScriptConfigSchema.parse({ actionType: 'email', template: 't', recipients: ['a'], variables: { x: 1 } })) + .toMatchObject({ actionType: 'email' }); + // Inline script — recognized (and documented as not executed). + expect(ScriptConfigSchema.parse({ script: 'return 1;' })).toMatchObject({ script: 'return 1;' }); + }); +}); + /** A one-`screen`-node flow whose screen node carries `config`. */ function screenFlow(config: Record) { return { diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index 93f0cbec0e..743698e9d4 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { PluginContext } from '@objectstack/core'; -import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { defineActionDescriptor, SCRIPT_BUILTIN_ACTION_TYPES } from '@objectstack/spec/automation'; import type { AutomationEngine } from '../engine.js'; import { interpolate } from './template.js'; @@ -29,9 +29,12 @@ import { interpolate } from './template.js'; /** * Built-in `script` side-effect action types with a (logger-backed) handler. - * Anything else is treated as a registered-function name (#1870). + * Anything else is treated as a registered-function name (#1870). The member + * list is the spec-published `SCRIPT_BUILTIN_ACTION_TYPES` — the same constant + * the designer's `actionType` options reconcile against (#4278), so the form, + * this dispatch set, and the failure message below cannot disagree. */ -const SCRIPT_BUILTIN_ACTION_TYPES = new Set(['email', 'slack']); +const SCRIPT_BUILTINS = new Set(SCRIPT_BUILTIN_ACTION_TYPES); export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext): void { // screen — server-side pass-through (input vars already injected by engine). @@ -206,7 +209,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext // Built-in side-effect actions keep their logger-backed behavior — but // only when an explicit `function` isn't set (that always wins). - if (!fnName && actionType && SCRIPT_BUILTIN_ACTION_TYPES.has(actionType)) { + if (!fnName && actionType && SCRIPT_BUILTINS.has(actionType)) { ctx.logger.info( `[Script:${actionType}] template=${String(cfg.template)} ` + `recipients=${JSON.stringify(cfg.recipients)} ` + @@ -255,7 +258,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext success: false, error: `script node '${node.id}': '${target}' is not a built-in action ` + - `(${[...SCRIPT_BUILTIN_ACTION_TYPES].join(', ')}) and no function named '${target}' is registered. ` + + `(${[...SCRIPT_BUILTINS].join(', ')}) and no function named '${target}' is registered. ` + `Register it via \`defineStack({ functions: { '${target}': fn } })\`, or fix the name (#1870).`, }; } diff --git a/packages/services/service-automation/src/builtin/subflow-node.test.ts b/packages/services/service-automation/src/builtin/subflow-node.test.ts index d6ba5ef66b..b29e62bc80 100644 --- a/packages/services/service-automation/src/builtin/subflow-node.test.ts +++ b/packages/services/service-automation/src/builtin/subflow-node.test.ts @@ -362,4 +362,23 @@ describe('subflow node executor', () => { expect(result.success).toBe(false); expect(result.error).toMatch(/depth|recursive/i); }); + + // #4278 — this proves the CONVERSION, not executor tolerance. The executor + // reads only the canonical `flowName` (its bare `cfg.flow` fallback was + // deleted with the graduation); a stored flow on the undeclared `flow` + // spelling reaches it because `registerFlow` applies + // 'flow-node-subflow-flow-alias', which renames it at load. + it('accepts the legacy `config.flow` spelling via the conversion layer (#4278)', async () => { + engine.registerFlow('parent_flow', parentFlow({ + flow: 'child_flow', + input: { msg: '{greeting}' }, + outputVariable: 'subResult', + })); + + const result = await engine.execute('parent_flow', { params: { greeting: 'hi' } }); + + expect(result.success).toBe(true); + expect(ran).toEqual(['child']); + expect(captured).toEqual([{ result: 'CHILD_DONE' }]); + }); }); diff --git a/packages/services/service-automation/src/builtin/subflow-node.ts b/packages/services/service-automation/src/builtin/subflow-node.ts index 6e797ee11f..4997ef709b 100644 --- a/packages/services/service-automation/src/builtin/subflow-node.ts +++ b/packages/services/service-automation/src/builtin/subflow-node.ts @@ -53,8 +53,10 @@ export function registerSubflowNode(engine: AutomationEngine, ctx: PluginContext }), 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 historical `flow` alias is canonicalized at load by the ADR-0087 D2 + // conversion 'flow-node-subflow-flow-alias' (#4278), so only the + // canonical key is read here (contract: SubflowConfigSchema). + const flowName = typeof cfg.flowName === 'string' ? cfg.flowName : undefined; if (!flowName) { return refuseNode(`subflow '${node.id}': config.flowName is required`); } diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 41a8e22c07..dfe5516db3 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -2071,6 +2071,11 @@ "DataSourceConfigSchema (const)", "DataSyncConfig (type)", "DataSyncConfigSchema (const)", + "DecisionCondition (type)", + "DecisionConditionSchema (const)", + "DecisionConfig (type)", + "DecisionConfigParsed (type)", + "DecisionConfigSchema (const)", "DecisionOutputDef (type)", "DecisionOutputDefSchema (const)", "DeleteRecordConfig (type)", @@ -2169,6 +2174,8 @@ "ResolvedFlowNodeExpression (interface)", "RetryPolicy (type)", "RetryPolicySchema (const)", + "SCRIPT_BUILTIN_ACTION_TYPES (const)", + "SCRIPT_INVOKE_FUNCTION_ACTION_TYPE (const)", "ScheduleState (type)", "ScheduleStateParsed (type)", "ScheduleStateSchema (const)", @@ -2177,10 +2184,17 @@ "ScreenConfigSchema (const)", "ScreenFieldConfig (type)", "ScreenFieldConfigSchema (const)", + "ScriptBuiltinActionType (type)", + "ScriptConfig (type)", + "ScriptConfigParsed (type)", + "ScriptConfigSchema (const)", "StateMachineConfig (type)", "StateMachineSchema (const)", "StateNodeConfig (type)", "StateNodeSchema (const)", + "SubflowConfig (type)", + "SubflowConfigParsed (type)", + "SubflowConfigSchema (const)", "Sync (const)", "SyncDirection (type)", "SyncDirectionSchema (const)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index e1b4c4759e..cc3231db19 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -2286,6 +2286,9 @@ "automation/DataSyncConfig:syncMode", "automation/DataSyncConfig:tags", "automation/DataSyncConfig:validation", + "automation/DecisionCondition:expression", + "automation/DecisionCondition:label", + "automation/DecisionConfig:conditions", "automation/DecisionOutputDef:key", "automation/DecisionOutputDef:label", "automation/DecisionOutputDef:multiple", @@ -2520,6 +2523,14 @@ "automation/ScreenFieldConfig:required", "automation/ScreenFieldConfig:type", "automation/ScreenFieldConfig:visibleWhen", + "automation/ScriptConfig:actionType", + "automation/ScriptConfig:function", + "automation/ScriptConfig:inputs", + "automation/ScriptConfig:outputVariable", + "automation/ScriptConfig:recipients", + "automation/ScriptConfig:script", + "automation/ScriptConfig:template", + "automation/ScriptConfig:variables", "automation/StateMachine:contextSchema", "automation/StateMachine:description", "automation/StateMachine:id", @@ -2534,6 +2545,9 @@ "automation/StateNode:on", "automation/StateNode:states", "automation/StateNode:type", + "automation/SubflowConfig:flowName", + "automation/SubflowConfig:input", + "automation/SubflowConfig:outputVariable", "automation/SyncExecutionResult:completedAt", "automation/SyncExecutionResult:durationMs", "automation/SyncExecutionResult:errors", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 862edf9a14..f789d534fa 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1,5 +1,5 @@ { - "description": "Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. Auto-appended when new schemas are added (commit the change). A listed schema that a build no longer emits fails gen:schema \u2014 remove a key ONLY for a deliberate retirement. See #2978.", + "description": "Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. Auto-appended when new schemas are added (commit the change). A listed schema that a build no longer emits fails gen:schema — remove a key ONLY for a deliberate retirement. See #2978.", "schemas": [ "ai/AIModelConfig", "ai/AIUsageRecord", @@ -531,6 +531,8 @@ "automation/DataDestinationConfig", "automation/DataSourceConfig", "automation/DataSyncConfig", + "automation/DecisionCondition", + "automation/DecisionConfig", "automation/DecisionOutputDef", "automation/DeleteRecordConfig", "automation/ETLDestination", @@ -571,8 +573,10 @@ "automation/ScheduleState", "automation/ScreenConfig", "automation/ScreenFieldConfig", + "automation/ScriptConfig", "automation/StateMachine", "automation/StateNode", + "automation/SubflowConfig", "automation/SyncDirection", "automation/SyncExecutionResult", "automation/SyncExecutionStatus", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 5b0d24a1cb..a99193c14d 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -122,6 +122,12 @@ "conversionId": "flow-node-map-flow-alias", "toMajor": 17 }, + { + "surface": "flow.node.subflow.config.flowName", + "to": "subflow flow-node config key 'flow' → 'flowName' (#4278 — undeclared executor fallback graduation)", + "conversionId": "flow-node-subflow-flow-alias", + "toMajor": 17 + }, { "surface": "flow.node.script.config", "to": "script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796)", @@ -538,6 +544,12 @@ "conversionId": "flow-node-map-flow-alias", "toMajor": 17 }, + { + "surface": "flow.node.subflow.config.flowName", + "to": "subflow flow-node config key 'flow' → 'flowName' (#4278 — undeclared executor fallback graduation)", + "conversionId": "flow-node-subflow-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 index 86f96dc411..c59768f132 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -38,8 +38,13 @@ * 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). + * descriptor-schemaless class (config-schemas.test.ts). `wait` and + * `connector_action` keep their contracts in FlowNodeSchema's sibling + * blocks (`waitEventConfig` / `connectorConfig`); the other three publish + * executor-derived config contracts in `schemaless-node-config.zod.ts` + * (#4278) — separate from this module because they must NOT grow into + * descriptor `configSchema`s (the forms they describe stay hand-written in + * objectui, reconciled by a test there). */ import { z } from 'zod'; diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index 1743d77764..4e4840a6bd 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -5,6 +5,7 @@ export * from './flow.zod'; export * from './control-flow.zod'; export * from './io-node-config.zod'; export * from './builtin-node-config.zod'; +export * from './schemaless-node-config.zod'; export { flowForm } from './flow.form'; export * from './execution.zod'; export * from './webhook.zod'; diff --git a/packages/spec/src/automation/schemaless-node-config.zod.ts b/packages/spec/src/automation/schemaless-node-config.zod.ts new file mode 100644 index 0000000000..30dbae9e4d --- /dev/null +++ b/packages/spec/src/automation/schemaless-node-config.zod.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @module automation/schemaless-node-config + * + * Config contracts for the **descriptor-schemaless** builtins whose designer + * form lives ONLY in objectui's hand-written `FLOW_NODE_CONFIG` table — + * `script`, `subflow` and `decision` (#4278). + * + * ## Why these nodes publish no descriptor `configSchema` — and still need this + * + * `config-schemas.test.ts` in `service-automation` pins the schemaless class + * with each member's reason: `decision`'s virtual Target column is derived from + * the out-edges, `script`'s form switches on `actionType`, `subflow` carries a + * top-level `timeoutMs` — a published partial schema would DROP those editors + * (the #4210 `connector_action` incident). So the Studio form for these types + * is objectui's hand-written group, and until #4278 **nothing reconciled that + * hand-written table against the executors**: `script`'s form offered an + * `outputVariables` key nothing reads, two `actionType` options that fail every + * run, a no-op default — and could not author the `function`/`inputs`/ + * `outputVariable` path that works. + * + * These schemas are the machine-readable half of that reconciliation. They are + * **written from the executors** (`service-automation/builtin/screen-nodes.ts` + * for `script`, `subflow-node.ts`, `logic-nodes.ts` for `decision`), not from + * any form, and objectui's `flow-node-config` reconciliation test compares its + * hand-written key sets against them — the same bidirectional ledger the + * descriptor-schema'd builtins get from `builtin-node-form-zod-ledger.test.ts`, + * carried across the repo seam by the `@objectstack/spec` dependency objectui + * already has. + * + * `wait` and `connector_action` — the other two schemaless members — need no + * entry here: their contracts are the spec-structured sibling blocks on + * {@link FlowNodeSchema} (`waitEventConfig` / `connectorConfig`), which the + * same objectui test reconciles directly. + * + * ## What these schemas are (and are not) wired to + * + * Contract exports only — like `builtin-node-config.zod.ts`, no engine path + * `parse()`s a node config with them today, so registering a flow behaves + * exactly as before. + * + * Undeclared aliases are NOT part of these contracts: `subflow`'s historical + * `flow` spelling graduated into the ADR-0087 D2 conversion + * `flow-node-subflow-flow-alias` (the `map.flow` path), so the executor only + * ever sees `flowName`. + */ + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; + +// ─── script ────────────────────────────────────────────────────────── + +/** + * `script` action types with a built-in (logger-backed) side-effect handler. + * Any other non-marker `actionType` is treated as a registered-function name + * (#1870). The executor builds its dispatch set from THIS constant, and the + * designer's `actionType` options must stay within + * `[SCRIPT_INVOKE_FUNCTION_ACTION_TYPE, ...SCRIPT_BUILTIN_ACTION_TYPES]` — + * the #4278 drift was the form offering `sms` / `notification`, which are in + * neither set and so failed every run as unresolvable function names. + */ +export const SCRIPT_BUILTIN_ACTION_TYPES = ['email', 'slack'] as const; +export type ScriptBuiltinActionType = (typeof SCRIPT_BUILTIN_ACTION_TYPES)[number]; + +/** + * The `actionType` MARKER meaning "call the registered function named by + * `config.function`" — it is not itself a function name. The executor fails + * the step with a clear message when this marker is set and `function` is not. + */ +export const SCRIPT_INVOKE_FUNCTION_ACTION_TYPE = 'invoke_function'; + +/** + * `script` node config — what the executor reads (screen-nodes.ts). + * + * Dispatch precedence, verbatim from the executor: + * + * 1. `function` set → resolve and call that registered function (always wins). + * 2. `actionType` ∈ {@link SCRIPT_BUILTIN_ACTION_TYPES} → the built-in + * logger-backed side effect, fed by `template` / `recipients` / `variables`. + * 3. `script` set (no `function`) → **recognized but NOT executed**: the + * built-in runtime has no server-side JS sandbox, so the node warns loudly + * and completes as a no-op. Authoring is steered to a registered function. + * 4. Anything left in `actionType` (except the + * {@link SCRIPT_INVOKE_FUNCTION_ACTION_TYPE} marker) is shorthand for a + * function name; a name that resolves to nothing fails the step LOUDLY. + */ +export const ScriptConfigSchema = lazySchema(() => z.object({ + /** Built-in side-effect id, the `invoke_function` marker, or (shorthand) a registered-function name. */ + actionType: z.string().optional() + .describe("How this step runs: a built-in side effect ('email' | 'slack'), the 'invoke_function' marker, or shorthand for a registered-function name"), + /** Registered function to call (`defineStack({ functions })`) — always wins over `actionType`. */ + function: z.string().optional() + .describe('Registered function to call (defineStack({ functions })); takes precedence over actionType'), + /** Inputs passed to the function; values interpolate `{token}` templates against the live flow variables. */ + inputs: z.record(z.string(), z.unknown()).optional() + .describe('Inputs passed to the function (values interpolate {token} templates)'), + /** Flow variable the function's RETURN value is bound to (pure-function pattern — data I/O stays on the graph). */ + outputVariable: z.string().optional() + .describe("Flow variable the function's return value is bound to"), + /** Built-in side effects only: message template id. */ + template: z.string().optional() + .describe('Built-in side effects only: message template id'), + /** Built-in side effects only: recipient list (user ids, field refs, addresses). */ + recipients: z.array(z.string()).optional() + .describe('Built-in side effects only: recipients (user ids, field refs, or addresses)'), + /** Built-in side effects only: values injected into the template. */ + variables: z.record(z.string(), z.unknown()).optional() + .describe('Built-in side effects only: values injected into the template'), + /** + * Inline JS source — recognized but NOT executed by the built-in runtime (no + * server-side JS sandbox): the node warns and completes as a no-op. Kept in + * the contract because the executor reads it; deliberately NOT offered for + * new authoring (the designer renders a stored value read-only-style and + * steers authors to `function`). + */ + script: z.string().optional() + .describe('Inline JS source — recognized but not executed by the built-in runtime; use a registered function via `function` instead'), +})); + +export type ScriptConfig = z.input; +export type ScriptConfigParsed = z.infer; + +// ─── subflow ───────────────────────────────────────────────────────── + +/** + * `subflow` node config — what the executor reads (subflow-node.ts). + * + * `flowName` is execute-time required (the step is refused without it). The + * historical undeclared `flow` alias is NOT part of this contract: the + * ADR-0087 D2 conversion `flow-node-subflow-flow-alias` rewrites it at load + * (#4278 — the `map.flow` graduation path), so the executor only ever sees + * `flowName`. The node-level `timeoutMs` lives on {@link FlowNodeSchema}, not + * here — a subflow step's timeout is the engine's per-node guard. + */ +export const SubflowConfigSchema = lazySchema(() => z.object({ + /** The flow to invoke (execute-time required). */ + flowName: z.string().describe('Flow invoked as this step (it may pause — approval / screen / wait)'), + /** Values passed to the child's input variables; `{token}` templates resolve against the parent's variables. */ + input: z.record(z.string(), z.unknown()).optional() + .describe("Values passed to the subflow's input variables (interpolate {token} templates)"), + /** Parent flow variable the child's output is bound to. */ + outputVariable: z.string().optional() + .describe("Parent flow variable the subflow's output is bound to"), +})); + +export type SubflowConfig = z.input; +export type SubflowConfigParsed = z.infer; + +// ─── decision ──────────────────────────────────────────────────────── + +/** + * One `decision` branch — what the executor reads per condition + * (logic-nodes.ts): the first branch whose bare-CEL `expression` evaluates + * true wins, and the run continues down the out-edge labelled `label` + * (no match → the edge labelled `default`). + * + * The designer's branch rows also show a **Target** column — that is a + * VIRTUAL column projected from the node's out-edges by the designer + * (objectui `flow-decision-edges`), never stored on the branch, so it is + * deliberately absent here. + */ +export const DecisionConditionSchema = lazySchema(() => z.object({ + /** Branch label — must match an out-edge's `label` to route anywhere. */ + label: z.string().describe("Branch label; the winning branch resumes down the out-edge with this label ('true' expression = default/else path)"), + /** Bare-CEL predicate (ADR-0032) — `{…}` template braces are the #1491 trap. */ + expression: z.string().describe('Bare CEL predicate deciding this branch'), +})); + +export type DecisionCondition = z.input; + +/** + * `decision` node config — what the executor reads. + * + * A decision may also carry no `conditions` at all and rely purely on + * condition-bearing OUT-EDGES (`edge.condition`, evaluated by the engine's + * traversal) — that is the legacy shape. The legacy singular + * `config.condition` is a structural surface the engine parse-validates on + * every node at registration but the decision executor never reads; branching + * predicates live in `conditions[]` or on the edges. + */ +export const DecisionConfigSchema = lazySchema(() => z.object({ + /** Ordered branches; first true expression wins, else the `default`-labelled edge. */ + conditions: z.array(DecisionConditionSchema).optional() + .describe('Ordered decision branches (first true expression wins; omit to branch purely on edge conditions)'), +})); + +export type DecisionConfig = z.input; +export type DecisionConfigParsed = z.infer; diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index cc3b3a2890..2c6d7c70a0 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -1288,6 +1288,58 @@ const flowNodeMapFlowAlias: MetadataConversion = { }, }; +/** + * Subflow flow-node config key alias → canonical (protocol 17, #4278). + * + * The same undeclared `flow` spelling {@link flowNodeMapFlowAlias} retired for + * `map`, found on `subflow` by writing `SubflowConfigSchema` from its executor + * for the #4278 schemaless-node reconciliation: the executor carried a bare + * `cfg.flowName ?? cfg.flow` fallback no schema or form ever described (PD + * #12). The fallback is deleted in the same change; this conversion is what + * keeps a stored `flow` spelling loading — rewritten to the canonical key, + * including the `AutomationEngine.registerFlow` rehydration seam, so the + * executor reads `cfg.flowName` only. + * + * A pure key rename with unchanged values. **Live window**; retires at 18. + */ +const flowNodeSubflowFlowAlias: MetadataConversion = { + id: 'flow-node-subflow-flow-alias', + toMajor: 17, + surface: 'flow.node.subflow.config.flowName', + summary: "subflow flow-node config key 'flow' → 'flowName' (#4278 — undeclared executor fallback graduation)", + apply(stack, emit) { + return renameFlowConfigAliases(stack, new Set(['subflow']), [['flow', 'flowName']], emit); + }, + fixture: { + before: { + flows: [ + { + name: 'escalate_case', + nodes: [ + { id: 'n1', type: 'start' }, + { id: 'n2', type: 'subflow', config: { flow: 'escalation_flow', input: { caseId: '{record.id}' } } }, + // canonical already present → the shadowed alias is left alone (no notice) + { id: 'n3', type: 'subflow', config: { flowName: 'audit_flow', flow: 'ignored' } }, + ], + }, + ], + }, + after: { + flows: [ + { + name: 'escalate_case', + nodes: [ + { id: 'n1', type: 'start' }, + { id: 'n2', type: 'subflow', config: { flowName: 'escalation_flow', input: { caseId: '{record.id}' } } }, + { id: 'n3', type: 'subflow', config: { flowName: 'audit_flow', 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; @@ -2191,6 +2243,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly Date: Fri, 31 Jul 2026 06:06:42 +0000 Subject: [PATCH 2/2] docs(audits): declare schemaless-node-config.zod.ts in the strictness ledger (#4278) The strictness-ledger gate requires every new *.zod.ts with z.object sites under a triaged directory to carry a Class verdict. The new module's four sites are authorable (the #4045 node-config family, third panel), with the drift check living in objectui's cross-repo reconciliation test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ki9WjmmSY19koz9hNkeL1P --- docs/audits/2026-07-unknown-key-strictness-ledger.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index f6bf6bb4ac..c02b07efc6 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -165,7 +165,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). | `external-catalog.zod.ts` | 4 | wire (p) | | | `field-value.zod.ts` / `seed.zod.ts` / `validation.zod.ts` | 1 ea | mixed (p) | | -### `automation/` — 89 sites +### `automation/` — 93 sites | File | Sites | Class | Note | |---|---|---|---| @@ -180,6 +180,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). | `node-executor.zod.ts` | 4 | wire | executor contract | | `io-node-config.zod.ts` | 2 | authorable | `NotifyConfigSchema` / `HttpConfigSchema` (#4045) — the sibling contracts that validate the **open** `config` slot on flow `notify` / `http` nodes. Authored per-node, so the open-slot exemption above does not extend to them; candidate once the executors' own drift is verified | | `builtin-node-config.zod.ts` | 8 | authorable | Same family (#4045): the CRUD quartet, `screen`, `map`. Written from what the executors read rather than from the descriptors' `configSchema` literals, and reconciled bidirectionally by `builtin-node-form-zod-ledger.test.ts` — so unlike most rows here, this one already has a drift check of its own. Same candidacy note as `io-node-config` | +| `schemaless-node-config.zod.ts` | 4 | authorable | Same family, third panel (#4278): `script` / `subflow` / `decision` (+ the decision branch item) — the descriptor-schemaless nodes whose form lives in objectui's hand-written table. Written from the executors; the drift check is objectui's `flow-node-config.spec-reconciliation` test (cross-repo, via the published exports). Contract exports only — nothing parses node config with them yet, so strictness candidacy follows `io-node-config` | | `webhook.zod.ts` | 1 | authorable (p) | spec-only (#3461) | ### `security/` — 20 sites