From 7abeaf45663a6585f29bf8e6c7f32ae3495b503d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 11:49:32 +0000 Subject: [PATCH 1/6] wip(spec): the expression ledger roots a path in connectorConfig Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzGExkj8nxREmV7FjX --- packages/lint/src/validate-expressions.ts | 11 +- .../services/service-automation/src/engine.ts | 10 +- .../src/flow-registration-checks.ts | 20 ++- .../automation/flow-node-config-refusals.ts | 7 +- .../automation/flow-node-expression-paths.ts | 133 ++++++++++++++++-- .../automation/flow-value-slot-template.ts | 41 +++++- packages/spec/src/automation/flow.zod.ts | 30 +++- 7 files changed, 207 insertions(+), 45 deletions(-) diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index a4aad9daff4..fbb6e807cc1 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -2769,8 +2769,11 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression severity: 'error', }); } - for (const found of resolveFlowNodeExpressions(nodeType, cfg)) { - const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`; + // [#27968] Handed the node, the ledger reaches a slot rooted in a + // sibling block too — a `connector_action` node's + // `connectorConfig.input` — and names the block it is in. + for (const found of resolveFlowNodeExpressions(nodeType, cfg, node)) { + const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at ${found.block}.${found.path}`; // [#15137] `value` slots are checkable too, by their own rule — see // `checkDeclaredValue`. Not shadowing-checked: the shadow diagnostic is // about a bare identifier resolving to a variable instead of a field, @@ -2828,9 +2831,9 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // (`flowNodeValueTemplateRefusals`), walking the ledger's `value` slots // and the two legacy `assignment` shapes, so build and registration // give one verdict, and each refusal names the token's CEL spelling. - for (const templateRefusal of flowNodeValueTemplateRefusals(nodeType, cfg)) { + for (const templateRefusal of flowNodeValueTemplateRefusals(nodeType, cfg, node)) { issues.push({ - where: `${at} · node '${node.id}' (${nodeType}) ${templateRefusal.label} at config.${templateRefusal.path}`, + where: `${at} · node '${node.id}' (${nodeType}) ${templateRefusal.label} at ${templateRefusal.block}.${templateRefusal.path}`, message: templateRefusal.message, source: templateRefusal.source, severity: 'error', diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 83ea311d762..6bc427b21a8 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -9000,12 +9000,10 @@ export class AutomationEngine implements IAutomationService { * `assignments` * through `builtin/value-slot-map.ts`, the one per-slot resolver * (`resolveValueSlotMap`, `resolveValueSlot`, `resolveCollectionSlot`). - * One slot reaches it undeclared: a `connector_action`'s - * `connectorConfig.input` map (#27327), evaluated through the same - * resolver. The ledger addresses only `node.config`, and `connectorConfig` - * is a sibling block, so registration does not judge an envelope there - * yet — a malformed one registers and faults at run time — until #27968 - * declares it. + * So does a `connector_action`'s `connectorConfig.input` map (#27327), + * evaluated through the same resolver — the one entry the ledger roots in + * a sibling block rather than `config` (#27968), so registration judges an + * envelope there like every other. * The two legacy * `assignment` shapes the executor still normalizes (the * `assignments: [{ variable, value }]` array and the bare diff --git a/packages/services/service-automation/src/flow-registration-checks.ts b/packages/services/service-automation/src/flow-registration-checks.ts index 70ab76342be..e10c1e8e9d5 100644 --- a/packages/services/service-automation/src/flow-registration-checks.ts +++ b/packages/services/service-automation/src/flow-registration-checks.ts @@ -591,10 +591,14 @@ export function validateFlowExpressions( // `FlowValueSlotSchema` on the CRUD nodes, the same call on the // `assignment` node — so a flow that registers is never refused // there, and vice versa. - for (const refusal of flowNodeValueTemplateRefusals(node.type, node.config)) { + // + // [#27968] The node is handed for its sibling blocks: a + // `connector_action` node's `connectorConfig.input` is a value slot + // the ledger roots in that block, located as `connectorConfig.…`. + for (const refusal of flowNodeValueTemplateRefusals(node.type, node.config, node)) { refuse( - `${nodePath}.config.${refusal.path}`, - `${at}node '${node.id}' (${node.type}) ${refusal.label} at config.${refusal.path}`, + `${nodePath}.${refusal.block}.${refusal.path}`, + `${at}node '${node.id}' (${node.type}) ${refusal.label} at ${refusal.block}.${refusal.path}`, refusal.message, refusal.source, ); @@ -630,10 +634,14 @@ export function validateFlowExpressions( // envelope as a CEL value (#15137) — the two roles the ledger has // since #19939 retired the single-brace one with the `loop` / `map` // `collection` it recorded. - for (const found of resolveFlowNodeExpressions(node.type, node.config)) { - const slotPath = `${nodePath}.config.${found.path}`; + // + // [#27968] Handed the node, the ledger reaches a slot rooted in a + // sibling block too — a `connector_action` node's + // `connectorConfig.input` — and names the block it is in. + for (const found of resolveFlowNodeExpressions(node.type, node.config, node)) { + const slotPath = `${nodePath}.${found.block}.${found.path}`; const slotWhere = - `${at}node '${node.id}' (${node.type}) ${found.entry.label} at config.${found.path}`; + `${at}node '${node.id}' (${node.type}) ${found.entry.label} at ${found.block}.${found.path}`; // [#15137] `value` slots — the ruled `assignment.assignments.*` // and, since #19938, `create_record` / `update_record` // `fields.*`. The resolver emits ONLY envelope-shaped objects diff --git a/packages/spec/src/automation/flow-node-config-refusals.ts b/packages/spec/src/automation/flow-node-config-refusals.ts index 7199602764f..945d6b8d7a9 100644 --- a/packages/spec/src/automation/flow-node-config-refusals.ts +++ b/packages/spec/src/automation/flow-node-config-refusals.ts @@ -247,7 +247,9 @@ function insideRegion(nodeType: string, path: ReadonlyArray): boole */ function insideValueSlot(nodeType: string, path: ReadonlyArray): boolean { return FLOW_NODE_EXPRESSION_PATHS.some((entry) => { - if (entry.nodeType !== nodeType || entry.role !== 'value') return false; + // [#27968] A path here is inside `config`: an entry rooted in a sibling + // block (`connectorConfig`) names no position in it. + if (entry.nodeType !== nodeType || entry.role !== 'value' || entry.block !== undefined) return false; const segments = ledgerSlotSegments(entry.path); return path.length > segments.length && startsWithLedgerSlot(segments, path); }); @@ -341,7 +343,8 @@ function atOrInsideJudgedLedgerSlot( path: ReadonlyArray, ): boolean { return FLOW_NODE_EXPRESSION_PATHS.some((entry) => { - if (entry.nodeType !== nodeType || !LEDGER_JUDGED_ROLES.has(entry.role)) return false; + // [#27968] `config`-rooted entries only, as in `insideValueSlot`. + if (entry.nodeType !== nodeType || !LEDGER_JUDGED_ROLES.has(entry.role) || entry.block !== undefined) return false; const segments = ledgerSlotSegments(entry.path); if (!startsWithLedgerSlot(segments, path)) return false; if (entry.role !== 'value') return true; diff --git a/packages/spec/src/automation/flow-node-expression-paths.ts b/packages/spec/src/automation/flow-node-expression-paths.ts index 54829972778..0208b348ebe 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.ts @@ -172,13 +172,56 @@ export type FlowNodeExpressionRole = */ | 'value'; +/** + * [#27968] The SIBLING blocks of a node's `config` a ledger path can be rooted + * in, each with the one node type whose executor reads it. + * + * Two node types keep their contract in a declared block of `FlowNodeSchema` + * beside `config` rather than in `config` (`flow-sibling-block-loose-keys.ts`): + * `wait` (`waitEventConfig`) and `connector_action` (`connectorConfig`). Only + * the second holds a value slot — `connectorConfig.input`, evaluated by the + * `connector_action` executor through the same per-key resolver as every other + * value map — so it is the one listed. `waitEventConfig` holds literals only. + * + * The marker that declares a slot here rides `FlowNodeSchema` itself (the + * block's own Zod, `.meta({ xExpression })`), and the reconciliation ratchet + * (`config-expression-ledger.test.ts` in `service-automation`) reads it from + * there, mapping each block to its node type through this table — so a marker + * on a block this table does not name fails it, and so does an entry rooted in + * a block no marker declares. + */ +export const FLOW_NODE_SIBLING_EXPRESSION_BLOCKS = { + connectorConfig: 'connector_action', +} as const satisfies Readonly>; + +/** A sibling block of `config` a ledger path can be rooted in ({@link FLOW_NODE_SIBLING_EXPRESSION_BLOCKS}). */ +export type FlowNodeSiblingExpressionBlock = keyof typeof FLOW_NODE_SIBLING_EXPRESSION_BLOCKS; + +/** The node block a ledger path is rooted in: `config`, or a declared sibling block. */ +export type FlowNodeExpressionBlock = 'config' | FlowNodeSiblingExpressionBlock; + +/** + * [#27968] The sibling blocks of one node, as the resolvers read them — the + * node itself satisfies it. A caller that hands none is answered for the + * `config`-rooted entries only. + */ +export type FlowNodeSiblingBlocks = { readonly [Block in FlowNodeSiblingExpressionBlock]?: unknown }; + /** One expression-bearing config slot on a builtin flow node type. */ export interface FlowNodeExpressionPath { /** Registry node type the path belongs to (`node.type`). */ readonly nodeType: string; /** - * Dot path into `node.config`, using `[]` for "every element of this array" - * and a `*` segment for "every key of this object" (#14149). + * [#27968] The node block the path is rooted in, when it is not `config`: a + * declared sibling block ({@link FLOW_NODE_SIBLING_EXPRESSION_BLOCKS}) — + * today `connectorConfig`, for a `connector_action` node's `input` map. + * Absent means `config`, which every entry before #27968 is. + */ + readonly block?: FlowNodeSiblingExpressionBlock; + /** + * Dot path into the node's {@link FlowNodeExpressionPath.block} — `config` + * unless the entry names another — using `[]` for "every element of this + * array" and a `*` segment for "every key of this object" (#14149). * `'fields[].visibleWhen'` reads `config.fields[i].visibleWhen` for each `i`; * `'assignments.*'` reads `config.assignments[k]` for each authored key `k` * — the spelling for a map whose keys are the author's own names (variable @@ -337,6 +380,13 @@ export interface FlowNodeExpressionPath { * open map with no marker). The entry carries * {@link FlowNodeExpressionPath.filterOperands}: the walks emit each operand * at its own path, never the filter itself. + * + * So is, since #27968, a `connector_action` node's `input` map — the one entry + * rooted outside `config` ({@link FlowNodeExpressionPath.block}): the node's + * contract is its `connectorConfig` sibling block, and the executor hands each + * value of `connectorConfig.input` to the action through the per-key resolver + * the callee maps use. Declared on `FlowNodeSchema.connectorConfig`'s `input` + * map value (`.meta({ xExpression: 'value' })`). */ export const FLOW_NODE_EXPRESSION_PATHS: readonly FlowNodeExpressionPath[] = [ { @@ -590,8 +640,37 @@ export const FLOW_NODE_EXPRESSION_PATHS: readonly FlowNodeExpressionPath[] = [ label: 'delete_record filter operand', filterOperands: true, }, + { + // [#27968] The input map a `connector_action` node hands to the action — + // `{ : }`, keys named by the connector's action — + // evaluated in the run's scope before dispatch, through the one per-key + // resolver the callee maps above use (`resolveValueSlotMap`). Rooted in the + // node's `connectorConfig` sibling block, not `config`: that block is the + // node's whole contract, and the executor reads nothing else. Declared + // through the block's own Zod (`FlowNodeSchema.connectorConfig`'s `input` + // map value carries `.meta({ xExpression: 'value' })`). + nodeType: 'connector_action', + block: 'connectorConfig', + path: 'input.*', + role: 'value', + label: 'connector_action input value', + }, ]; +/** The block `entry`'s path is rooted in. */ +export function flowNodeExpressionBlock(entry: FlowNodeExpressionPath): FlowNodeExpressionBlock { + return entry.block ?? 'config'; +} + +/** + * The value `entry`'s path is walked from on one node: its `config`, or — for + * an entry rooted in a sibling block — that block, read off `siblings` + * (`undefined` when the caller handed none). + */ +function entryRoot(entry: FlowNodeExpressionPath, config: unknown, siblings: FlowNodeSiblingBlocks | undefined): unknown { + return entry.block === undefined ? config : siblings?.[entry.block]; +} + /** One operand of a flow node's query filter ({@link flowFilterOperands}). */ export interface FlowFilterOperand { /** @@ -703,7 +782,14 @@ function operandPath(prefix: string, path: readonly (string | number)[]): string export interface ResolvedFlowNodeExpression { /** The ledger entry that matched. */ readonly entry: FlowNodeExpressionPath; - /** Concrete location, with array indices filled in (`fields[1].visibleWhen`). */ + /** + * [#27968] The node block {@link ResolvedFlowNodeExpression.path} is inside + * — `config`, or the entry's sibling block — so a consumer locates the value + * as `.` (`config.fields[1].visibleWhen`, + * `connectorConfig.input.ticket`). + */ + readonly block: FlowNodeExpressionBlock; + /** Concrete location inside {@link ResolvedFlowNodeExpression.block}, with array indices filled in (`fields[1].visibleWhen`). */ readonly path: string; /** The authored value sitting at that location. */ readonly value: unknown; @@ -786,28 +872,38 @@ export function isExpressionEnvelopeShaped(value: unknown): value is { dialect: * each element of an array as a value of its own, at its index (#19939). An * entry with {@link FlowNodeExpressionPath.filterOperands} reads each operand * of the filter as a value of its own, at its path (#19939). + * + * [#27968] An entry rooted in a sibling block ({@link FlowNodeExpressionPath.block}) + * is walked from that block, read off `siblings` — pass the node itself — and + * each result names the block it is in ({@link ResolvedFlowNodeExpression.block}). + * A caller that hands no `siblings` is answered for the `config`-rooted entries + * only: every door that judges a whole node (`FlowSchema.parse`, + * `AutomationEngine.registerFlow`, `objectstack validate`) hands the node. */ export function resolveFlowNodeExpressions( nodeType: string, config: unknown, + siblings?: FlowNodeSiblingBlocks, ): ResolvedFlowNodeExpression[] { - if (config == null || typeof config !== 'object') return []; const out: ResolvedFlowNodeExpression[] = []; for (const entry of FLOW_NODE_EXPRESSION_PATHS) { - if (entry.nodeType !== nodeType || !readOn(entry, config)) continue; - walk(config as Record, entry.path.split('.'), '', slotEmitter(entry, (path, value) => { + if (entry.nodeType !== nodeType) continue; + const root = entryRoot(entry, config, siblings); + if (root == null || typeof root !== 'object' || !readOn(entry, root)) continue; + const block = flowNodeExpressionBlock(entry); + walk(root, entry.path.split('.'), '', slotEmitter(entry, (path, value) => { if (entry.role === 'value') { - if (isExpressionEnvelopeShaped(value)) out.push({ entry, path, value }); + if (isExpressionEnvelopeShaped(value)) out.push({ entry, block, path, value }); } else if (typeof value === 'string') { // #17493 — every string in a `predicate` slot is emitted, a BLANK one // included, for the consumer to refuse (see `predicateSlotRefusal`). - out.push({ entry, path, value }); + out.push({ entry, block, path, value }); } else if (value != null || entry.required) { // #15572 — a non-string in a predicate slot. Emitted so a consumer can // REFUSE it (see `predicateSlotRefusal`), never so it can be parsed. // #19961 — on a REQUIRED slot, absent and `null` are emitted too: the // walk hands over `undefined` for a key an existing element lacks. - out.push({ entry, path, value }); + out.push({ entry, block, path, value }); } })); } @@ -832,15 +928,22 @@ export function resolveFlowNodeExpressions( * filter at its own path. * * Pure path resolution, like its sibling — no judgement about what a value - * says. + * says. Reads a sibling-block entry off `siblings` exactly as its sibling does + * (#27968). */ -export function resolveFlowNodeValueSlots(nodeType: string, config: unknown): ResolvedFlowNodeExpression[] { - if (config == null || typeof config !== 'object') return []; +export function resolveFlowNodeValueSlots( + nodeType: string, + config: unknown, + siblings?: FlowNodeSiblingBlocks, +): ResolvedFlowNodeExpression[] { const out: ResolvedFlowNodeExpression[] = []; for (const entry of FLOW_NODE_EXPRESSION_PATHS) { - if (entry.nodeType !== nodeType || entry.role !== 'value' || !readOn(entry, config)) continue; - walk(config as Record, entry.path.split('.'), '', slotEmitter(entry, (path, value) => { - if (value !== undefined) out.push({ entry, path, value }); + if (entry.nodeType !== nodeType || entry.role !== 'value') continue; + const root = entryRoot(entry, config, siblings); + if (root == null || typeof root !== 'object' || !readOn(entry, root)) continue; + const block = flowNodeExpressionBlock(entry); + walk(root, entry.path.split('.'), '', slotEmitter(entry, (path, value) => { + if (value !== undefined) out.push({ entry, block, path, value }); })); } return out; diff --git a/packages/spec/src/automation/flow-value-slot-template.ts b/packages/spec/src/automation/flow-value-slot-template.ts index 462c75fd917..52d64806f57 100644 --- a/packages/spec/src/automation/flow-value-slot-template.ts +++ b/packages/spec/src/automation/flow-value-slot-template.ts @@ -259,7 +259,12 @@ * read the dialect two ways. */ -import { isExpressionEnvelopeShaped, resolveFlowNodeValueSlots } from './flow-node-expression-paths'; +import { + isExpressionEnvelopeShaped, + resolveFlowNodeValueSlots, + type FlowNodeExpressionBlock, + type FlowNodeSiblingBlocks, +} from './flow-node-expression-paths'; // [#19939] The query placeholders a flow filter operand hands on untouched — // the vocabulary the query engine's own resolver reads (`resolveFilterTokens` // in `@objectstack/core`), never re-spelled here. @@ -744,7 +749,15 @@ export function valueSlotTemplateRefusals( /** One refused string in a node's config, located for a door's report. */ export interface FlowNodeValueTemplateRefusal { - /** Path into `node.config` — `fields.total`, `assignments.digest`, `assignments[0].value`, `total`, `fields.meta.note`. */ + /** + * [#27968] The node block {@link FlowNodeValueTemplateRefusal.path} is inside: + * `config`, or — for a `connector_action` node's `input` map — `connectorConfig`. + */ + readonly block: FlowNodeExpressionBlock; + /** + * Path into the node's {@link FlowNodeValueTemplateRefusal.block} — `fields.total`, `assignments.digest`, + * `assignments[0].value`, `total`, `fields.meta.note`, `input.ticket`. + */ readonly path: string; /** The slot, as a door names it — `create_record field value`, `assignment value`. */ readonly label: string; @@ -960,8 +973,17 @@ function joinPath(prefix: string, inner: readonly (string | number)[]): string { * operand of a CRUD node's `filter` a whole query placeholder passes, and a * token is refused with the filter operand's remedy * ({@link ValueSlotTemplateOptions.filterOperand}). + * + * [#27968] A value slot rooted in a sibling block — a `connector_action` + * node's `connectorConfig.input` — is reached through `siblings` (pass the + * node itself), and each refusal names its block; a caller that hands none is + * answered for `config` only. */ -export function flowNodeValueTemplateRefusals(nodeType: string, config: unknown): FlowNodeValueTemplateRefusal[] { +export function flowNodeValueTemplateRefusals( + nodeType: string, + config: unknown, + siblings?: FlowNodeSiblingBlocks, +): FlowNodeValueTemplateRefusal[] { const out: FlowNodeValueTemplateRefusal[] = []; const judge = ( path: string, @@ -969,15 +991,24 @@ export function flowNodeValueTemplateRefusals(nodeType: string, config: unknown) value: unknown, options: ValueSlotTemplateOptions = {}, callee?: (refusal: ValueSlotTemplateRefusal) => string | undefined, + block: FlowNodeExpressionBlock = 'config', ): void => { for (const refusal of valueSlotTemplateRefusals(value, options)) { const sentence = callee?.(refusal); const message = sentence === undefined ? refusal.message : `${refusal.message} ${sentence}`; - out.push({ path: joinPath(path, refusal.path), label, message, source: refusal.source }); + out.push({ block, path: joinPath(path, refusal.path), label, message, source: refusal.source }); } }; - for (const found of resolveFlowNodeValueSlots(nodeType, config)) { + for (const found of resolveFlowNodeValueSlots(nodeType, config, siblings)) { const entryKey = `${found.entry.nodeType} ${found.entry.path}`; + if (found.block !== 'config') { + // [#27968] A sibling block's value slot — today a `connector_action` + // node's `connectorConfig.input` — takes the generic remedy: the action + // was handed the token's text as written, so no sentence about what a + // callee received instead of nothing applies. + judge(found.path, found.entry.label, found.value, {}, undefined, found.block); + continue; + } if (COLLECTION_SLOTS.has(entryKey)) { judge(found.path, found.entry.label, found.value, { collection: true }); continue; diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 7e0d87b2702..451ab50f94b 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -23,7 +23,7 @@ import { NON_BLANK_STRING } from '../shared/refinement-projection'; import { retryPolicyShape } from '../shared/retry-policy.zod'; import { strictObject } from '../shared/strict-object'; import { collectFlowGraphs, parseFlowNodeRegions } from './control-flow.zod'; -import { predicateSlotRefusal, resolveFlowNodeExpressions } from './flow-node-expression-paths'; +import { predicateSlotRefusal, resolveFlowNodeExpressions, type FlowNodeSiblingBlocks } from './flow-node-expression-paths'; import { flowNodeConfigRefusals } from './flow-node-config-refusals'; import { apiTriggerSecretRefRefusals } from './api-trigger-secret-ref.zod'; // [#23543 F3] The loose `config` keys of the two sibling-block node types — one @@ -667,8 +667,19 @@ function flowNodeObject() { return strictObject( // outbound credential belongs in the connector's own `auth.credentialRef` // (ADR-0097 §3), never here. `@objectstack/lint`'s // `flow-credential-literal` advisory names a literal that reads as one. - input: z.record(z.string(), z.unknown()).optional().describe( - 'Mapped inputs for the action. The flow definition, this map included, is served to every member who can ' + // + // [#27968] Every value is a value slot of the expression ledger + // (`FLOW_NODE_EXPRESSION_PATHS`' `connector_action` entry, rooted in this + // block): the marker below declares it, the executor evaluates a CEL + // envelope there before dispatch, and `registerFlow` and + // `objectstack validate` judge it — a malformed envelope, and a `{…}` + // template token in a string, are refused there. The value stays + // `z.unknown()`: like every value slot in a node's open `config`, the + // flow parse does not judge it. + input: z.record(z.string(), z.unknown().meta({ xExpression: 'value' })).optional().describe( + 'Mapped inputs for the action. Each value is a literal, handed to the action as written, or a CEL value ' + + 'envelope `{ dialect: \'cel\', source }` evaluated in the run\'s scope before dispatch; a `{…}` template ' + + 'token in a string is refused. The flow definition, this map included, is served to every member who can ' + 'read flows, so never put a credential here: the connector authenticates through its own ' + '`auth.credentialRef`.', ), @@ -947,8 +958,9 @@ export const FlowEdgeSchema = lazySchema(() => strictObject( * { id: "check_amount", type: "decision", label: "Check Amount", position: {x: 0, y: 100} }, * { id: "auto_approve", type: "update_record", label: "Auto Approve", position: {x: -100, y: 200}, * config: { objectName: "order", filter: { id: { dialect: "cel", source: "record.id" } }, fields: { status: "approved" } } }, - * // `connectorConfig.input` is handed to the action as written: no template - * // is read there, so it holds literals. + * // Each `connectorConfig.input` value is a literal, handed to the action as + * // written, or a CEL value envelope evaluated in the run's scope before + * // dispatch, e.g. `{ amount: { dialect: "cel", source: "record.amount" } }`. * { id: "submit_for_approval", type: "connector_action", label: "Submit", position: {x: 100, y: 200}, * connectorConfig: { connectorId: "approvals_desk", actionId: "submit", input: { queue: "large_orders" } } } * ], @@ -1495,14 +1507,18 @@ export const FlowSchema = lazySchema(() => strictObject( graph.nodes.forEach((node, index) => { const type: unknown = (node as { type?: unknown } | null)?.type; if (typeof type !== 'string') return; - for (const found of resolveFlowNodeExpressions(type, (node as { config?: unknown }).config)) { + for (const found of resolveFlowNodeExpressions( + type, + (node as { config?: unknown }).config, + node as FlowNodeSiblingBlocks, + )) { if (found.entry.role !== 'predicate') continue; if (typeof found.value !== 'string' && !(found.entry.required && found.value == null)) continue; const refusal = predicateSlotRefusal(found.value); if (!refusal) continue; ctx.addIssue({ code: 'custom', - path: [...graph.path, 'nodes', index, 'config', ...ledgerPathSegments(found.path)], + path: [...graph.path, 'nodes', index, found.block, ...ledgerPathSegments(found.path)], message: refusal.message, }); } From 489409a82ca831b4881c0a766888fb96ac64f2ee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 11:54:17 +0000 Subject: [PATCH 2/6] wip: consumers hand the node; ratchet reads the sibling-block channel Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzGExkj8nxREmV7FjX --- .../builtin/config-expression-ledger.test.ts | 61 +++++++++++++++-- .../flow-node-expression-paths.test.ts | 68 ++++++++++++++++++- .../flow-value-slot-template.test.ts | 46 +++++++++++-- packages/spec/src/automation/flow.zod.ts | 34 ++++++++++ 4 files changed, 199 insertions(+), 10 deletions(-) diff --git a/packages/services/service-automation/src/builtin/config-expression-ledger.test.ts b/packages/services/service-automation/src/builtin/config-expression-ledger.test.ts index 383b66a8bbf..699b17ebe79 100644 --- a/packages/services/service-automation/src/builtin/config-expression-ledger.test.ts +++ b/packages/services/service-automation/src/builtin/config-expression-ledger.test.ts @@ -28,7 +28,9 @@ import { describe, it, expect } from 'vitest'; import { FLOW_NODE_EXPRESSION_PATHS, + FLOW_NODE_SIBLING_EXPRESSION_BLOCKS, flowNodeConfigRefusals, + getFlowNodeSiblingBlockJsonSchemas, getSchemalessNodeConfigJsonSchemas, resolveFlowNodeExpressions, type FlowNodeExpressionRole, @@ -126,7 +128,7 @@ function collectExpressionProps( const engine = new AutomationEngine(silentLogger()); installBuiltinNodes(engine, ctx()); -type DeclaredSlot = { nodeType: string; path: string; role: FlowNodeExpressionRole; required: boolean }; +type DeclaredSlot = { nodeType: string; block?: string; path: string; role: FlowNodeExpressionRole; required: boolean }; /** Resolve an `xExpression` marker to its ledger role, failing loudly on an unknown one. */ function roleOf(nodeType: string, path: string, marker: string): FlowNodeExpressionRole { @@ -177,12 +179,39 @@ function declaredFromSchemalessConfigs(): DeclaredSlot[] { return found; } -/** Every declared expression slot, from BOTH declaration channels. */ +/** + * [#27968] Declared expression slots in a node's SIBLING blocks — the blocks + * of `FlowNodeSchema` beside `config` (`connectorConfig`, `waitEventConfig`, + * …), whose own Zod carries the marker. The ledger roots such a slot in its + * block (`block`), and the block names its node type through + * `FLOW_NODE_SIBLING_EXPRESSION_BLOCKS`. Every block is walked, so a marker on + * one the table does not name fails here: a slot no consumer can reach. + */ +function declaredFromSiblingBlocks(): DeclaredSlot[] { + const found: DeclaredSlot[] = []; + const nodeTypeOf: Readonly> = FLOW_NODE_SIBLING_EXPRESSION_BLOCKS; + for (const [block, json] of Object.entries(getFlowNodeSiblingBlockJsonSchemas())) { + for (const { path, marker, required } of collectExpressionProps(json as SchemaNode)) { + const nodeType = nodeTypeOf[block]; + expect( + nodeType, + `FlowNodeSchema.${block}.${path} declares an xExpression marker on a block the ledger cannot root a path in — ` + + 'name the block and its node type in FLOW_NODE_SIBLING_EXPRESSION_BLOCKS', + ).toBeDefined(); + found.push({ nodeType: nodeType!, block, path, role: roleOf(nodeType!, `${block}.${path}`, marker), required }); + } + } + return found; +} + +/** Every declared expression slot, from ALL THREE declaration channels. */ function declaredEverywhere(): DeclaredSlot[] { - return [...declaredFromDescriptors(), ...declaredFromSchemalessConfigs()]; + return [...declaredFromDescriptors(), ...declaredFromSchemalessConfigs(), ...declaredFromSiblingBlocks()]; } -const key = (e: { nodeType: string; path: string; role: string }) => `${e.nodeType}.${e.path} (${e.role})`; +/** `nodeType.path (role)`, the path prefixed with its block when it is not `config` (#27968). */ +const key = (e: { nodeType: string; block?: string; path: string; role: string }) => + `${e.nodeType}.${e.block === undefined ? '' : `${e.block}.`}${e.path} (${e.role})`; describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => { it('every xExpression property a builtin declares is in the ledger', () => { @@ -206,6 +235,7 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => { it.each([ ['descriptor configSchema', declaredFromDescriptors], ['schemaless-node-config.zod.ts', declaredFromSchemalessConfigs], + ['FlowNodeSchema sibling-block', declaredFromSiblingBlocks], ] as const)('derives at least one slot from the %s channel', (_channel, derive) => { expect(derive().length).toBeGreaterThan(0); }); @@ -274,6 +304,28 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => { } }); + /** + * [#27968] A `connector_action` node's `connectorConfig.input` is a `value` + * slot rooted in the node's sibling block, declared on that block's own Zod + * (`FlowNodeSchema.connectorConfig`'s `input` map value) and on no `config` + * channel — the node publishes no `configSchema` and has no `config` + * contract. Before #27968 the ledger could address `config` only, so the + * slot the executor evaluates was judged by neither door. + */ + it('connector_action connectorConfig.input.* is covered — the #27968 sibling-block value slot', () => { + const slot = FLOW_NODE_EXPRESSION_PATHS.find( + (e) => e.nodeType === 'connector_action' && e.block === 'connectorConfig' && e.path === 'input.*', + ); + expect(slot, 'the slot the connector_action executor evaluates through resolveValueSlotMap').toBeDefined(); + expect(slot!.role).toBe('value'); + expect(declaredFromSiblingBlocks().map(key)).toEqual(['connector_action.connectorConfig.input.* (value)']); + expect(declaredFromDescriptors().filter((d) => d.nodeType === 'connector_action')).toEqual([]); + expect(declaredFromSchemalessConfigs().filter((d) => d.nodeType === 'connector_action')).toEqual([]); + // The table names exactly the blocks a slot is declared in — no block an + // entry could be rooted in with nothing declaring it. + expect(Object.keys(FLOW_NODE_SIBLING_EXPRESSION_BLOCKS)).toEqual(['connectorConfig']); + }); + it('decision.conditions[].expression is covered — the #4439 hole', () => { const decision = FLOW_NODE_EXPRESSION_PATHS.find( (e) => e.nodeType === 'decision' && e.path === 'conditions[].expression', @@ -423,6 +475,7 @@ describe('configSchema ↔ expression-ledger reconciliation (#4027)', () => { const markers = [ ...engine.getActionDescriptors().flatMap((d) => collectExpressionProps(d.configSchema as SchemaNode)), ...Object.values(getSchemalessNodeConfigJsonSchemas()).flatMap((json) => collectExpressionProps(json as SchemaNode)), + ...Object.values(getFlowNodeSiblingBlockJsonSchemas()).flatMap((json) => collectExpressionProps(json as SchemaNode)), ].map((p) => p.marker); expect(markers.length).toBeGreaterThan(0); expect(markers).not.toContain('template'); diff --git a/packages/spec/src/automation/flow-node-expression-paths.test.ts b/packages/spec/src/automation/flow-node-expression-paths.test.ts index 33e97f30d95..ad1219b7546 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.test.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.test.ts @@ -18,7 +18,9 @@ import { describe, expect, it } from 'vitest'; import { FLOW_NODE_EXPRESSION_PATHS, + FLOW_NODE_SIBLING_EXPRESSION_BLOCKS, flowFilterOperands, + flowNodeExpressionBlock, isExpressionEnvelopeShaped, resolveFlowNodeExpressions, resolveFlowNodeValueSlots, @@ -550,7 +552,8 @@ describe('isExpressionEnvelopeShaped — the recognizer a value slot discriminat }); describe('every entry older than the value role resolves byte-identically (the ratchet\'s fixtures, restated)', () => { - const byKey = (e: FlowNodeExpressionPath) => `${e.nodeType}.${e.path} (${e.role})`; + const byKey = (e: FlowNodeExpressionPath) => + `${e.nodeType}.${e.block === undefined ? '' : `${e.block}.`}${e.path} (${e.role})`; it('the entries that existed before are still declared exactly as they were — each value-slot widening only appended rows', () => { // The census: five rows before #19938, seven after, ten after #19939's @@ -563,7 +566,9 @@ describe('every entry older than the value role resolves byte-identically (the r // `flow-template` role to `value` IN PLACE (`screen.fields[].visibleWhen` // keeps its `predicate` row beside the screen's value rows). Stage S4 // appended the three `notify` value rows, stage S5 the `http` body's, and - // stage S6 the three CRUD `filter` operand rows. + // stage S6 the three CRUD `filter` operand rows. #27968 appended the first + // row rooted in a sibling block rather than `config`: a `connector_action` + // node's `connectorConfig.input` map. expect(FLOW_NODE_EXPRESSION_PATHS.map(byKey)).toEqual([ 'screen.fields[].visibleWhen (predicate)', 'decision.conditions[].expression (predicate)', @@ -585,6 +590,7 @@ describe('every entry older than the value role resolves byte-identically (the r 'get_record.filter (value)', 'update_record.filter (value)', 'delete_record.filter (value)', + 'connector_action.connectorConfig.input.* (value)', ]); }); @@ -878,3 +884,61 @@ describe('every entry older than the value role resolves byte-identically (the r }); }); }); + +/** + * [#27968] A ledger path rooted in a node's SIBLING block — a + * `connector_action` node's `connectorConfig.input`, the map its executor + * evaluates through the per-key value-slot resolver. The resolvers read the + * block off the node they are handed, name the block in each result, and + * answer a caller that hands only `config` for `config` alone. + */ +describe('a path rooted in a sibling block (#27968)', () => { + const ENVELOPE = { dialect: 'cel', source: 'record.id' }; + const connectorNode = (input: Record) => ({ + type: 'connector_action', + connectorConfig: { connectorId: 'helpdesk', actionId: 'open_ticket', input }, + }); + + it('declares connector_action input.* in the connectorConfig block, as a value slot', () => { + const entry = FLOW_NODE_EXPRESSION_PATHS.find((e) => e.nodeType === 'connector_action'); + expect(entry).toMatchObject({ block: 'connectorConfig', path: 'input.*', role: 'value' }); + expect(FLOW_NODE_SIBLING_EXPRESSION_BLOCKS).toEqual({ connectorConfig: 'connector_action' }); + expect(flowNodeExpressionBlock(entry!)).toBe('connectorConfig'); + // Every other entry is rooted in `config`. + expect(FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.block !== undefined)).toEqual([entry]); + }); + + it('resolves the envelope in the block, located in it — the node handed, no config', () => { + const node = connectorNode({ ticket: ENVELOPE, queue: 'support' }); + expect(resolveFlowNodeExpressions('connector_action', undefined, node)).toEqual([ + { entry: expect.objectContaining({ block: 'connectorConfig' }), block: 'connectorConfig', path: 'input.ticket', value: ENVELOPE }, + ]); + // The value walk hands the literal over too, for the template judge. + expect(resolveFlowNodeValueSlots('connector_action', undefined, node).map((f) => [f.block, f.path, f.value])).toEqual([ + ['connectorConfig', 'input.ticket', ENVELOPE], + ['connectorConfig', 'input.queue', 'support'], + ]); + }); + + it('answers a caller that hands only config for config alone', () => { + const node = connectorNode({ ticket: ENVELOPE }); + expect(resolveFlowNodeExpressions('connector_action', undefined)).toEqual([]); + expect(resolveFlowNodeValueSlots('connector_action', {})).toEqual([]); + // A loose `config.input` (refused by the flow parse) is no position the + // entry names: the block, never `config`, is read. + expect(resolveFlowNodeExpressions('connector_action', { input: { ticket: ENVELOPE } }, { connectorConfig: {} })).toEqual([]); + expect(resolveFlowNodeExpressions('connector_action', undefined, node)).toHaveLength(1); + }); + + it('names `config` as the block of every config-rooted result', () => { + const found = resolveFlowNodeExpressions('assignment', TWO_VARIABLE_ASSIGNMENT, connectorNode({ ticket: ENVELOPE })); + expect(found.map((f) => [f.block, f.path])).toEqual([['config', 'assignments.digest']]); + }); + + it('skips a block that is absent, null or not an object', () => { + for (const connectorConfig of [undefined, null, 'x', 42]) { + expect(resolveFlowNodeValueSlots('connector_action', undefined, { connectorConfig })).toEqual([]); + } + expect(resolveFlowNodeValueSlots('connector_action', undefined, { connectorConfig: { connectorId: 'a', actionId: 'b' } })).toEqual([]); + }); +}); diff --git a/packages/spec/src/automation/flow-value-slot-template.test.ts b/packages/spec/src/automation/flow-value-slot-template.test.ts index 3d486fcbccd..c1e1e65c695 100644 --- a/packages/spec/src/automation/flow-value-slot-template.test.ts +++ b/packages/spec/src/automation/flow-value-slot-template.test.ts @@ -52,12 +52,13 @@ import { celExpression, celPath, } from './flow-template-token'; -import { FLOW_NODE_EXPRESSION_PATHS } from './flow-node-expression-paths'; +import { FLOW_NODE_EXPRESSION_PATHS, type FlowNodeExpressionPath } from './flow-node-expression-paths'; import { VALUE_SLOT_TEMPLATE_REFUSAL, classifyFlowFilterOperandString, flowNodeValueTemplateRefusals, valueSlotTemplateRefusals, + type FlowNodeValueTemplateRefusal, } from './flow-value-slot-template'; /** @@ -87,6 +88,18 @@ function configForEntry( return entry.onlyWithKey === undefined ? config : { ...config, [entry.onlyWithKey]: { nodes: [], edges: [] } }; } +/** + * [#27968] The template refusals of a node carrying `value` at `entry`'s + * position — in `config`, or in the sibling block the entry is rooted in, + * handed over as the node's sibling blocks. + */ +function refusalsForEntry(entry: FlowNodeExpressionPath, value: unknown): FlowNodeValueTemplateRefusal[] { + const at = configForEntry(entry, value); + return entry.block === undefined + ? flowNodeValueTemplateRefusals(entry.nodeType, at) + : flowNodeValueTemplateRefusals(entry.nodeType, undefined, { [entry.block]: at }); +} + /** The one refusal a string draws, or a failure naming what came back. */ function refusalOf(value: unknown): string { const refusals = valueSlotTemplateRefusals(value); @@ -588,6 +601,31 @@ describe('`flowNodeValueTemplateRefusals` — every value position of a node, lo const [refusal] = flowNodeValueTemplateRefusals('subflow', { flowName: 'child', input: { list: '{rows}' } }); expect(refusal!.message).toContain("{ dialect: 'cel', source: 'rows' }"); }); + + // [#27968] The one value slot rooted outside `config`: reached through the + // node's sibling blocks, located in `connectorConfig`, with the generic + // remedy — the action was handed the token's text, not nothing. + it('a connector_action node\'s `connectorConfig.input` — the node handed for its sibling block', () => { + const node = { + type: 'connector_action', + connectorConfig: { + connectorId: 'helpdesk', + actionId: 'open_ticket', + input: { recordId: '{rec.id}', queue: 'support', tags: ['{rec.tag}'] }, + }, + }; + const refusals = flowNodeValueTemplateRefusals('connector_action', undefined, node); + expect(refusals.map((r) => [r.block, r.path, r.label, r.source])).toEqual([ + ['connectorConfig', 'input.recordId', 'connector_action input value', '{rec.id}'], + ['connectorConfig', 'input.tags[0]', 'connector_action input value', '{rec.tag}'], + ]); + expect(refusals[0]!.message.startsWith(VALUE_SLOT_TEMPLATE_REFUSAL)).toBe(true); + expect(refusals[0]!.message).toContain("{ dialect: 'cel', source: 'rec.id' }"); + expect(refusals[0]!.message).not.toMatch(/child flow|the function/); + // Handed only `config`, the block is not read; every config refusal names `config`. + expect(flowNodeValueTemplateRefusals('connector_action', undefined)).toEqual([]); + expect(flowNodeValueTemplateRefusals('assignment', { assignments: { t: '{a}' } }, node).map((r) => r.block)).toEqual(['config']); + }); }); /** @@ -731,7 +769,7 @@ describe('a callee\'s input map: the refusal says what the guarded form hands th const valueEntries = FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.role === 'value'); expect(valueEntries.filter((e) => CALLEE.has(`${e.nodeType} ${e.path}`))).toHaveLength(3); for (const entry of valueEntries) { - const refusals = flowNodeValueTemplateRefusals(entry.nodeType, configForEntry(entry, '{x}')); + const refusals = refusalsForEntry(entry, '{x}'); expect(refusals, `${entry.nodeType} ${entry.path}`).toHaveLength(1); const carries = /is (the child flow|each item's child flow|the function)'s/.test(refusals[0]!.message); expect(carries, `${entry.nodeType} ${entry.path}`).toBe(CALLEE.has(`${entry.nodeType} ${entry.path}`)); @@ -836,7 +874,7 @@ describe('a screen\'s value slots: the refusal says what the client is served in const valueEntries = FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.role === 'value'); expect(valueEntries.filter((e) => SCREEN.has(`${e.nodeType} ${e.path}`))).toHaveLength(3); for (const entry of valueEntries) { - const [refusal] = flowNodeValueTemplateRefusals(entry.nodeType, configForEntry(entry, '{x}')); + const [refusal] = refusalsForEntry(entry, '{x}'); const carries = /prefills (the object form|this screen field)|names the record an `edit` form opens/.test(refusal!.message); expect(carries, `${entry.nodeType} ${entry.path}`).toBe(SCREEN.has(`${entry.nodeType} ${entry.path}`)); } @@ -891,7 +929,7 @@ describe('a notify node\'s value slots: located per position, a recipient\'s ref it('only `recipients` carries it — one list: the ledger', () => { const valueEntries = FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.role === 'value'); for (const entry of valueEntries) { - const [refusal] = flowNodeValueTemplateRefusals(entry.nodeType, configForEntry(entry, '{x}')); + const [refusal] = refusalsForEntry(entry, '{x}'); const carries = /names who the notification goes to/.test(refusal!.message); expect(carries, `${entry.nodeType} ${entry.path}`).toBe(`${entry.nodeType} ${entry.path}` === 'notify recipients'); } diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 451ab50f94b..e170b7bdd17 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -861,6 +861,40 @@ function flowNodeObject() { return strictObject( }).optional().describe("Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it.").meta({ title: 'Boundary Event' }), }); } +/** + * [#27968] Every property of a flow node OTHER than `config` — the sibling + * blocks `connectorConfig`, `waitEventConfig`, `boundaryConfig` and the rest — + * as JSON Schema, keyed by the property name, memoized. + * + * The third channel the expression ledger's reconciliation ratchet reads + * (`config-expression-ledger.test.ts` in `service-automation`), beside a + * descriptor's `configSchema` and `getSchemalessNodeConfigJsonSchemas()`: a + * `.meta({ xExpression })` marker inside a block declares a slot the ledger + * roots in that block (`FlowNodeExpressionPath.block`), and the ratchet maps + * the block to its node type through `FLOW_NODE_SIBLING_EXPRESSION_BLOCKS` — + * every block is handed over, not only the listed ones, so a marker on a block + * the ledger cannot address fails it instead of going unseen. + * + * Derived in `input` mode, like the schemaless channel, which is what carries + * the markers through verbatim. + */ +let cachedFlowNodeSiblingBlockJsonSchemas: Readonly> | undefined; +export function getFlowNodeSiblingBlockJsonSchemas(): Readonly> { + if (cachedFlowNodeSiblingBlockJsonSchemas === undefined) { + const json = z.toJSONSchema(flowNodeObject(), { + target: 'draft-2020-12', + io: 'input', + unrepresentable: 'any', + }) as { properties?: Record }; + const out: Record = {}; + for (const [key, schema] of Object.entries(json.properties ?? {})) { + if (key !== 'config') out[key] = schema; + } + cachedFlowNodeSiblingBlockJsonSchemas = out; + } + return cachedFlowNodeSiblingBlockJsonSchemas; +} + /** * Flow Edge Schema * Connections between nodes. From a3e248185b042f856c7b6287111487729f471a21 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 11:57:54 +0000 Subject: [PATCH 3/6] wip: registration rejection tests for connectorConfig.input Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzGExkj8nxREmV7FjX --- .../connector-input-registration.test.ts | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 packages/services/service-automation/src/builtin/connector-input-registration.test.ts diff --git a/packages/services/service-automation/src/builtin/connector-input-registration.test.ts b/packages/services/service-automation/src/builtin/connector-input-registration.test.ts new file mode 100644 index 00000000000..4a917a514cb --- /dev/null +++ b/packages/services/service-automation/src/builtin/connector-input-registration.test.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **A `connector_action` node's `connectorConfig.input` is judged at + * registration** (#27968) — the registration half of #27327. + * + * #27327 made the executor evaluate the map through the one per-key value-slot + * resolver (`connector-input-value-slot.test.ts`). Registration judges value + * slots only through the spec's expression ledger, whose paths were rooted in + * `node.config`, so it could not name a slot in the `connectorConfig` sibling + * block: a malformed CEL envelope, a template-dialect envelope and a `{…}` + * token string all REGISTERED there, while the same three under a `subflow`'s + * `config.input` were refused. One slot, two reject sets. The ledger now roots + * the slot in the block, and `registerFlow` judges it like every other value + * slot, located at `connectorConfig.input.`: + * + * 1. the three shapes are refused, each at its key, each with the value + * slot's own refusal — and side by side with the `subflow` control, which + * answers the same sentence; + * 2. a valid envelope and literals still register and run. + */ + +import { describe, expect, it } from 'vitest'; +import { VALUE_ENVELOPE_REFUSAL, VALUE_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation'; +import type { Connector } from '@objectstack/spec/integration'; +import { AutomationEngine } from '../engine.js'; +import { installBuiltinNodes } from './index.js'; + +function silentLogger(): any { + return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } }; +} +function ctx(): any { + return { logger: silentLogger(), getService() { return undefined; } }; +} + +const CONNECTOR = { + name: 'vendor', + label: 'Vendor', + type: 'api', + authentication: { type: 'none' }, + actions: [{ key: 'lookup', label: 'Lookup' }], +} as Connector; + +function makeEngine(): { engine: AutomationEngine; received: Record[] } { + const engine = new AutomationEngine(silentLogger()); + installBuiltinNodes(engine, ctx()); + const received: Record[] = []; + engine.registerConnector(CONNECTOR, { + async lookup(input) { + received.push(input); + return { ok: true }; + }, + }); + return { engine, received }; +} + +/** start → `node` → end, the record an input variable. */ +function flowWith(name: string, node: Record): any { + return { + name, label: name, type: 'autolaunched', + variables: [{ name: 'rec', type: 'object', isInput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'call', label: 'Call', ...node }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'end' }], + }; +} + +const connectorNode = (input: Record) => ({ + type: 'connector_action', + connectorConfig: { connectorId: 'vendor', actionId: 'lookup', input }, +}); +const subflowNode = (input: Record) => ({ + type: 'subflow', + config: { flowName: 'child', input }, +}); + +/** The message `registerFlow` throws for `node`, or `''` when it registers. */ +function registrationError(node: Record): string { + const { engine } = makeEngine(); + try { + engine.registerFlow('judged', flowWith('judged', node)); + } catch (e) { + return (e as Error).message; + } + return ''; +} + +/** The three shapes the #27327 thread measured registering under `connectorConfig.input`. */ +const SHAPES = [ + ['a malformed CEL envelope', { dialect: 'cel', source: 'rec.' }, ''], + ['a template-dialect envelope', { dialect: 'template', source: '{rec.id}' }, VALUE_ENVELOPE_REFUSAL], + ['a bare `{…}` token string', '{rec.id}', VALUE_SLOT_TEMPLATE_REFUSAL], +] as const; + +describe('1. registerFlow refuses the three shapes at `connectorConfig.input.`', () => { + it.each(SHAPES)('%s', (_shape, value, lead) => { + const message = registrationError(connectorNode({ customerId: value, region: 'eu' })); + expect(message).toContain(`node 'call' (connector_action) connector_action input value at connectorConfig.input.customerId: ${lead}`); + // Only the key that holds the shape is named; the literal beside it is not. + expect(message).not.toContain('connectorConfig.input.region'); + // The same shape under a `subflow`'s input map draws the same refusal — + // one slot rule, two positions, one reject set. + const control = registrationError(subflowNode({ customerId: value })); + expect(control).toContain(`node 'call' (subflow) subflow input value at config.input.customerId: ${lead}`); + const detail = (m: string) => m.slice(m.indexOf('input.customerId: ') + 'input.customerId: '.length).split('\n')[0]; + expect(detail(message)).toBe(detail(control)); + }); + + it('a refused flow is not registered: it cannot be run', async () => { + const { engine, received } = makeEngine(); + expect(() => engine.registerFlow('refused', flowWith('refused', connectorNode({ customerId: '{rec.id}' })))).toThrow(); + const run = await engine.execute('refused', { params: { rec: { id: 'cus_42' } } } as any).catch((e: Error) => ({ success: false, error: e.message })); + expect(run.success).toBe(false); + expect(received).toEqual([]); + }); +}); + +describe('2. a valid envelope and literals still register, and the run evaluates the envelope', () => { + it('registers, and the action receives the computed value beside the literal', async () => { + const { engine, received } = makeEngine(); + engine.registerFlow('valid', flowWith('valid', connectorNode({ + customerId: { dialect: 'cel', source: 'rec.id' }, + region: 'eu', + body: { filter: { dialect: 'cel', source: 'rec.id' } }, + }))); + const run = await engine.execute('valid', { params: { rec: { id: 'cus_42' } } } as any); + expect(run.success, run.error).toBe(true); + expect(received).toEqual([{ customerId: 'cus_42', region: 'eu', body: { filter: { dialect: 'cel', source: 'rec.id' } } }]); + }); + + it('a node with no `input` registers, as before', () => { + expect(registrationError({ type: 'connector_action', connectorConfig: { connectorId: 'vendor', actionId: 'lookup' } })).toBe(''); + }); +}); From 5428619e174d4fa972ae1eb0fe132a0b6221ccd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:04:21 +0000 Subject: [PATCH 4/6] wip: validate tests, ADR-0087 D3 entry, changesets Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzGExkj8nxREmV7FjX --- .changeset/27968-ledger-sibling-block-root.md | 19 ++++ ...968-registration-judges-connector-input.md | 18 ++++ .../27968-validate-judges-connector-input.md | 18 ++++ ...lidate-expressions.connector-input.test.ts | 90 +++++++++++++++++++ .../connector-input-registration.test.ts | 8 +- ...onnector-action-input-value-slot-judged.ts | 60 +++++++++++++ 6 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 .changeset/27968-ledger-sibling-block-root.md create mode 100644 .changeset/27968-registration-judges-connector-input.md create mode 100644 .changeset/27968-validate-judges-connector-input.md create mode 100644 packages/lint/src/validate-expressions.connector-input.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.connector-action-input-value-slot-judged.ts diff --git a/.changeset/27968-ledger-sibling-block-root.md b/.changeset/27968-ledger-sibling-block-root.md new file mode 100644 index 00000000000..713fbe9e348 --- /dev/null +++ b/.changeset/27968-ledger-sibling-block-root.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: the flow-node expression ledger roots a path in a node's sibling block, and declares a `connector_action` node's `connectorConfig.input` a value slot + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing of `connectorConfig.input` on `connector_action` flow nodes, served at `AutomationEngine.registerFlow` and `objectstack validate` (see the `@objectstack/service-automation` and `@objectstack/lint` entries). This package carries the ledger both doors read. + +- **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` could address a node's `config` only, so the one value map the executor reads from a sibling block — `connectorConfig.input`, evaluated before dispatch since the executor half landed — was declared nowhere and judged by no door. An entry may now name `block: 'connectorConfig'` (absent means `config`); `FLOW_NODE_SIBLING_EXPRESSION_BLOCKS` lists the blocks a path can be rooted in with the node type that reads each, and `FlowNodeExpressionBlock` / `FlowNodeSiblingExpressionBlock` / `FlowNodeSiblingBlocks` type them. The new entry is `connector_action` · `connectorConfig` · `input.*` · `value`. +- **The resolvers.** `resolveFlowNodeExpressions`, `resolveFlowNodeValueSlots` and `flowNodeValueTemplateRefusals` take an optional third argument, the node's sibling blocks (pass the node itself), and each result names the `block` its `path` is inside. A caller that passes none is answered for `config` only, exactly as before; every `config`-rooted result reads `block: 'config'`. +- **The declaration.** `FlowNodeSchema.connectorConfig.input`'s map value carries `.meta({ xExpression: 'value' })`, and `getFlowNodeSiblingBlockJsonSchemas()` hands the node's blocks other than `config` to the reconciliation ratchet as its third channel. The value stays `z.unknown()`: `FlowSchema.parse` does not judge the map, as it judges no value slot of a node's open `config`. +- **Unchanged.** Every `config`-rooted entry resolves byte-identically. The `FlowSchema` example comment no longer says `connectorConfig.input` is handed to the action as written. + +**The remedy.** Write a computed input value as a CEL value envelope, `{ dialect: 'cel', source: 'record.id' }`, and a constant as the literal it is; a string that carried a `{…}` token takes the CEL spelling its refusal names (`'{rec.id}'` → `{ dialect: 'cel', source: 'rec.id' }`). diff --git a/.changeset/27968-registration-judges-connector-input.md b/.changeset/27968-registration-judges-connector-input.md new file mode 100644 index 00000000000..9de8420c6d0 --- /dev/null +++ b/.changeset/27968-registration-judges-connector-input.md @@ -0,0 +1,18 @@ +--- +'@objectstack/service-automation': minor +--- + +fix(service-automation)!: `registerFlow` refuses a malformed envelope and a `{…}` token in a `connector_action` node's `connectorConfig.input` + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing at `AutomationEngine.registerFlow`. + +- **Before.** The executor evaluates `connectorConfig.input` as a value-slot map, but registration judged value slots through a ledger that could address `config` only. A malformed CEL envelope (`{ dialect: 'cel', source: 'rec.' }`), a template envelope (`{ dialect: 'template', … }`) and a `{…}` token string (`'{rec.id}'`) all registered there, while the same three under a `subflow`'s `config.input` were refused. The malformed envelope failed the step at run time; the token string was handed to the action as its literal text. +- **Now.** The value-envelope and value-slot template checks read the ledger's `connectorConfig` entry, so all three are refused at registration, each located at `connectorConfig.input.` with the same refusal a `subflow` input value draws. A flow row in `sys_metadata` carrying one is skipped at boot with a `warn` naming it, its trigger not armed. +- **Unchanged.** Literals and valid CEL envelopes register and dispatch as before; an envelope nested inside a literal is data; `input` stays optional. + +**The remedy.** Write a computed value as `{ dialect: 'cel', source: '…' }` and a constant as a literal; follow the CEL spelling each refusal names. diff --git a/.changeset/27968-validate-judges-connector-input.md b/.changeset/27968-validate-judges-connector-input.md new file mode 100644 index 00000000000..360b1d21709 --- /dev/null +++ b/.changeset/27968-validate-judges-connector-input.md @@ -0,0 +1,18 @@ +--- +'@objectstack/lint': minor +--- + +fix(lint)!: `objectstack validate` judges a `connector_action` node's `connectorConfig.input` as a value slot + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing at `validateStackExpressions` (`objectstack validate`), and so at the runtime authoring gate that runs the same pass on a flow write. + +- **Before.** A malformed CEL envelope, a template envelope and a `{…}` token string in `connectorConfig.input` passed clean; the same three under a `subflow`'s `config.input` were `error`s. +- **Now.** Each is an `error` at `node 'ID' (connector_action) connector_action input value at connectorConfig.input.KEY`, with the message a `subflow` input value draws. A valid envelope is also held to the value slots' other checks, the unbound-root judgment included, as every value slot is. +- **Unchanged.** Literals and valid CEL envelopes pass. + +**The remedy.** Write a computed value as `{ dialect: 'cel', source: '…' }` and a constant as a literal; follow the CEL spelling each refusal names. diff --git a/packages/lint/src/validate-expressions.connector-input.test.ts b/packages/lint/src/validate-expressions.connector-input.test.ts new file mode 100644 index 00000000000..97ca4748fb0 --- /dev/null +++ b/packages/lint/src/validate-expressions.connector-input.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **`objectstack validate` judges a `connector_action` node's + * `connectorConfig.input`** (#27968) — the build door's half of the slot + * `registerFlow` now judges too. + * + * The expression pass reads value slots only through the spec's expression + * ledger, whose paths were rooted in `node.config`; `connectorConfig` is a + * sibling block, so a malformed CEL envelope, a template-dialect envelope and a + * `{…}` token string there passed `validateStackExpressions` clean while the + * same three under a `subflow`'s `config.input` were refused. The ledger now + * roots the slot in the block, and this pass judges it like every other value + * slot, located at `connectorConfig.input.`, at `error`. The runtime + * authoring gate runs the same pass, so a flow write is refused there as well. + */ + +import { describe, expect, it } from 'vitest'; +import { VALUE_ENVELOPE_REFUSAL, VALUE_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation'; + +import { runRuntimeAuthoringRules } from './runtime-gate.js'; +import { validateStackExpressions, type ExprIssue } from './validate-expressions.js'; + +type AnyRec = Record; + +/** start → `node` → end, the record an input variable. */ +function flowWith(node: AnyRec): AnyRec { + return { + name: 'f', + label: 'F', + type: 'autolaunched', + variables: [{ name: 'rec', type: 'object', isInput: true }], + nodes: [ + { id: 'start', type: 'start', config: {} }, + { id: 'call', label: 'Call', ...node }, + { id: 'done', type: 'end', config: {} }, + ], + edges: [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'done' }], + }; +} + +const connectorNode = (input: AnyRec): AnyRec => ({ + type: 'connector_action', + connectorConfig: { connectorId: 'vendor', actionId: 'lookup', input }, +}); +const subflowNode = (input: AnyRec): AnyRec => ({ type: 'subflow', config: { flowName: 'child', input } }); + +const errorsOf = (flow: AnyRec): ExprIssue[] => + validateStackExpressions({ flows: [flow] }).filter((i) => (i.severity ?? 'error') === 'error'); + +/** The three shapes the #27327 thread measured passing the build door under `connectorConfig.input`. */ +const SHAPES = [ + ['a malformed CEL envelope', { dialect: 'cel', source: 'rec.' }, VALUE_ENVELOPE_REFUSAL], + ['a template-dialect envelope', { dialect: 'template', source: '{rec.id}' }, VALUE_ENVELOPE_REFUSAL], + ['a bare `{…}` token string', '{rec.id}', VALUE_SLOT_TEMPLATE_REFUSAL], +] as const; + +describe('objectstack validate refuses the three shapes at `connectorConfig.input.`', () => { + it.each(SHAPES)('%s', (_shape, value, lead) => { + const found = errorsOf(flowWith(connectorNode({ customerId: value, region: 'eu' }))); + expect(found.map((i) => i.where)).toEqual([ + "flow 'f' · node 'call' (connector_action) connector_action input value at connectorConfig.input.customerId", + ]); + expect(found[0]!.message.startsWith(lead)).toBe(true); + // The same shape under a `subflow`'s input map: the same refusal, at its own position. + const control = errorsOf(flowWith(subflowNode({ customerId: value }))); + expect(control.map((i) => i.where)).toEqual([ + "flow 'f' · node 'call' (subflow) subflow input value at config.input.customerId", + ]); + // The connector's refusal is the subflow's, minus the callee sentence a + // child flow's input value adds: the action was handed the text, not nothing. + expect(control[0]!.message.startsWith(found[0]!.message)).toBe(true); + }); + + it('a valid envelope and literals pass', () => { + expect(errorsOf(flowWith(connectorNode({ + customerId: { dialect: 'cel', source: 'rec.id' }, + region: 'eu', + body: { filter: { dialect: 'cel', source: 'rec.id' } }, + })))).toEqual([]); + expect(errorsOf(flowWith({ type: 'connector_action', connectorConfig: { connectorId: 'vendor', actionId: 'lookup' } }))).toEqual([]); + }); + + it('the runtime authoring gate refuses a flow write carrying one, through the same pass', () => { + const result = runRuntimeAuthoringRules({ type: 'flow', item: flowWith(connectorNode({ customerId: '{rec.id}' })) }); + const refused = result.errors.filter((f) => f.where.includes('connectorConfig.input.customerId')); + expect(refused.map((f) => f.rule)).toEqual(['expression-invalid']); + expect(refused[0]!.message.startsWith(VALUE_SLOT_TEMPLATE_REFUSAL)).toBe(true); + }); +}); diff --git a/packages/services/service-automation/src/builtin/connector-input-registration.test.ts b/packages/services/service-automation/src/builtin/connector-input-registration.test.ts index 4a917a514cb..b0f95456426 100644 --- a/packages/services/service-automation/src/builtin/connector-input-registration.test.ts +++ b/packages/services/service-automation/src/builtin/connector-input-registration.test.ts @@ -90,7 +90,7 @@ function registrationError(node: Record): string { /** The three shapes the #27327 thread measured registering under `connectorConfig.input`. */ const SHAPES = [ - ['a malformed CEL envelope', { dialect: 'cel', source: 'rec.' }, ''], + ['a malformed CEL envelope', { dialect: 'cel', source: 'rec.' }, VALUE_ENVELOPE_REFUSAL], ['a template-dialect envelope', { dialect: 'template', source: '{rec.id}' }, VALUE_ENVELOPE_REFUSAL], ['a bare `{…}` token string', '{rec.id}', VALUE_SLOT_TEMPLATE_REFUSAL], ] as const; @@ -105,8 +105,10 @@ describe('1. registerFlow refuses the three shapes at `connectorConfig.input. m.slice(m.indexOf('input.customerId: ') + 'input.customerId: '.length).split('\n')[0]; - expect(detail(message)).toBe(detail(control)); + // The connector's refusal is the subflow's, minus the callee sentence a + // child flow's input value adds: the action was handed the text, not nothing. + const detail = (m: string) => m.slice(m.indexOf('input.customerId: ') + 'input.customerId: '.length).split('\n')[0]!; + expect(detail(control).startsWith(detail(message))).toBe(true); }); it('a refused flow is not registered: it cannot be run', async () => { diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-action-input-value-slot-judged.ts b/packages/spec/src/migrations/entries/semantic/18.connector-action-input-value-slot-judged.ts new file mode 100644 index 00000000000..85ab338d10c --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.connector-action-input-value-slot-judged.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// A connector_action node's connectorConfig.input joins the value slots the +// expression ledger declares, rooted in the node's sibling block, so the two +// build doors judge it as they judge a subflow's input map. Its executor half +// (the map evaluated through the per-key value-slot resolver) landed first. +// +// Form D: no tracker number anywhere in the author-shown text; the decision is +// stated in words. +// +// No backticks in `surface` — build-upgrade-guide.ts renders it inside a code +// span already, and a nested backtick would close it. +export const entry: SemanticMigration = { + id: 'connector-action-input-value-slot-judged', + surface: + 'Each value of connectorConfig.input on a type: \'connector_action\' flow node, at any depth of the flow, ' + + 'including an ADR-0031 region body. Two values that registered and passed objectstack validate are now ' + + 'refused there: an expression envelope (an object naming a dialect) that is not a valid CEL value ' + + 'envelope — a CEL source that does not parse, a blank or missing source, a dialect other than cel, a ' + + 'template envelope among them — and a string carrying a single-brace {…} token of the retired flow ' + + 'template dialect, anywhere in the value (a string inside a nested list or object included). Reachable ' + + 'wherever a flow is authored or stored: defineStack({ flows }) sources and objectstack validate, a flow ' + + 'written through the runtime authoring gate (a flow saved from the Studio flow designer), and a flow row ' + + 'already sitting in sys_metadata, judged when the engine registers it at boot', + replacement: + 'Write a computed value as a CEL value envelope, `{ dialect: \'cel\', source: \'record.id\' }`, evaluated ' + + 'in the run\'s scope before the action is dispatched; write a constant as the literal it is. For a ' + + 'string that carried a token, the refusal names the token\'s CEL spelling: `\'{rec.id}\'` becomes ' + + '`{ dialect: \'cel\', source: \'rec.id\' }`, and text with a token inside it becomes a CEL string ' + + 'expression (`\'Ticket for \' + rec.name`). For a malformed envelope, correct its source or write the ' + + 'value as a literal', + reason: + 'The connector_action executor evaluates every top-level CEL envelope in `connectorConfig.input` before ' + + 'dispatch, through the same per-key value-slot resolver a subflow\'s `input` map uses, and fails the ' + + 'step on a malformed one. The two build doors judge value slots only through the spec\'s expression ' + + 'ledger, whose paths could address a node\'s `config` and no sibling block, so `registerFlow` and ' + + '`objectstack validate` admitted a malformed envelope, a template envelope and a `{…}` token in this ' + + 'map while refusing the same three under a subflow\'s `config.input`: one slot, two reject sets, and ' + + 'the malformed envelope met first by a run. A token string was never read as a template here; the ' + + 'action was handed its text as written. The ledger now roots a path in the node\'s `connectorConfig` ' + + 'block, so both doors judge this map by the value-slot rule every other value map takes. ' + + '⚠️ No D2 conversion: no token has a lossless CEL spelling (the value-slot template retirement measured ' + + 'each), and a malformed envelope carries no source the platform could correct. ' + + '⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or ' + + '`sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not armed, while the flows ' + + 'beside it register; a `defineStack({ flows })` source fails `objectstack validate`. The flow parse ' + + '(`FlowSchema.parse`) does not judge the map, as it judges no value slot of a node\'s open `config`. ' + + 'ADR-0087, ADR-0032.', + acceptanceCriteria: + 'Run `objectstack validate` over every stack authored in config files, and boot every deployed stack. ' + + 'Each refusal names the node and the key: `objectstack validate` prints `node \'ID\' (connector_action) ' + + 'connector_action input value at connectorConfig.input.KEY`, and `registerFlow` names the same position, ' + + 'its message leading with the value-slot refusal sentence. For each hit, rewrite the value per the ' + + 'replacement. Two proofs. (1) For a stack authored in config files, `objectstack validate` is clean. ' + + '(2) Boot the stack and confirm each flow REGISTERS: no `failed to register flow` warn for it — that warn ' + + 'line is the locator for a row that exists only in `sys_metadata`. A connector node whose input holds ' + + 'literals and valid CEL envelopes registers and dispatches as before, and `input` stays optional.', +}; From 872f446b890f34e98af6d09354d15e22f1208d80 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:18:40 +0000 Subject: [PATCH 5/6] wip: regenerate spec artefacts Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzGExkj8nxREmV7FjX --- content/docs/references/automation/flow.mdx | 2 +- packages/spec/api-surface/automation.json | 6 ++++++ packages/spec/export-origins/automation.json | 6 ++++++ packages/spec/src/automation/flow.zod.ts | 2 +- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index 208077b5660..16278f56b12 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -150,7 +150,7 @@ const result = FlowSchema.parse(data); | :--- | :--- | :--- | :--- | | **connectorId** | `string` | ✅ | Registered connector name | | **actionId** | `string` | ✅ | Action key declared by the connector | -| **input** | `Record` | optional | Mapped inputs for the action. The flow definition, this map included, is served to every member who can read flows, so never put a credential here: the connector authenticates through its own `auth.credentialRef`. | +| **input** | `Record` | optional | Mapped inputs for the action. Each value is a literal, handed to the action as written, or a CEL value envelope `{ dialect: 'cel', source }` evaluated in the run's scope before dispatch; a `{…}` template token in a string is refused when the flow is registered or validated. The flow definition, this map included, is served to every member who can read flows, so never put a credential here: the connector authenticates through its own `auth.credentialRef`. | ### Nested Shape: `FlowNode.inputSchema[string]` diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index d703423254d..cf0aed10900 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -126,6 +126,7 @@ "ExecutionStepSkipReasonSchema (const)", "FLOW_BUILTIN_NODE_TYPES (const)", "FLOW_NODE_EXPRESSION_PATHS (const)", + "FLOW_NODE_SIBLING_EXPRESSION_BLOCKS (const)", "FLOW_NODE_TEXT_SLOTS (const)", "FLOW_PAUSE_CAPABLE_NODE_TYPES (const)", "FLOW_REGION_CONFIG_KEYS (const)", @@ -162,10 +163,13 @@ "FlowNodeAction (type)", "FlowNodeConfigRefusal (type)", "FlowNodeConfigRefusalCode (type)", + "FlowNodeExpressionBlock (type)", "FlowNodeExpressionPath (interface)", "FlowNodeExpressionRole (type)", "FlowNodeParsed (type)", "FlowNodeSchema (const)", + "FlowNodeSiblingBlocks (type)", + "FlowNodeSiblingExpressionBlock (type)", "FlowNodeTextSlot (interface)", "FlowNodeTextSlotSource (interface)", "FlowNodeValueTemplateRefusal (interface)", @@ -320,11 +324,13 @@ "flowFilterOperands (function)", "flowForm (const)", "flowNodeConfigRefusals (function)", + "flowNodeExpressionBlock (function)", "flowNodeTextSlotSources (function)", "flowNodeValueTemplateRefusals (function)", "flowScreenFieldOptionKey (function)", "getApprovalNodeConfigJsonSchema (function)", "getBuiltinNodeConfigContracts (function)", + "getFlowNodeSiblingBlockJsonSchemas (function)", "getSchemalessNodeConfigJsonSchemas (function)", "importBpmnToConstructs (function)", "isExpressionEnvelopeShaped (function)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index d42a65df58b..2946bb76c5f 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -121,6 +121,7 @@ "ExecutionStepSkipReasonSchema": "src/automation/execution.zod.ts#ExecutionStepSkipReasonSchema (const)", "FLOW_BUILTIN_NODE_TYPES": "src/automation/flow.zod.ts#FLOW_BUILTIN_NODE_TYPES (const)", "FLOW_NODE_EXPRESSION_PATHS": "src/automation/flow-node-expression-paths.ts#FLOW_NODE_EXPRESSION_PATHS (const)", + "FLOW_NODE_SIBLING_EXPRESSION_BLOCKS": "src/automation/flow-node-expression-paths.ts#FLOW_NODE_SIBLING_EXPRESSION_BLOCKS (const)", "FLOW_NODE_TEXT_SLOTS": "src/automation/flow-text-slot-template.ts#FLOW_NODE_TEXT_SLOTS (const)", "FLOW_PAUSE_CAPABLE_NODE_TYPES": "src/automation/flow.zod.ts#FLOW_PAUSE_CAPABLE_NODE_TYPES (const)", "FLOW_REGION_CONFIG_KEYS": "src/automation/region-slots.ts#FLOW_REGION_CONFIG_KEYS (const)", @@ -156,10 +157,13 @@ "FlowNodeAction": "src/automation/flow.zod.ts#FlowNodeAction (type)", "FlowNodeConfigRefusal": "src/automation/flow-node-expression-paths.ts#FlowNodeConfigRefusal (type)", "FlowNodeConfigRefusalCode": "src/automation/flow-node-expression-paths.ts#FlowNodeConfigRefusalCode (type)", + "FlowNodeExpressionBlock": "src/automation/flow-node-expression-paths.ts#FlowNodeExpressionBlock (type)", "FlowNodeExpressionPath": "src/automation/flow-node-expression-paths.ts#FlowNodeExpressionPath (interface)", "FlowNodeExpressionRole": "src/automation/flow-node-expression-paths.ts#FlowNodeExpressionRole (type)", "FlowNodeParsed": "src/automation/flow.zod.ts#FlowNodeParsed (type)", "FlowNodeSchema": "src/automation/flow.zod.ts#FlowNodeSchema (const)", + "FlowNodeSiblingBlocks": "src/automation/flow-node-expression-paths.ts#FlowNodeSiblingBlocks (type)", + "FlowNodeSiblingExpressionBlock": "src/automation/flow-node-expression-paths.ts#FlowNodeSiblingExpressionBlock (type)", "FlowNodeTextSlot": "src/automation/flow-text-slot-template.ts#FlowNodeTextSlot (interface)", "FlowNodeTextSlotSource": "src/automation/flow-text-slot-template.ts#FlowNodeTextSlotSource (interface)", "FlowNodeValueTemplateRefusal": "src/automation/flow-value-slot-template.ts#FlowNodeValueTemplateRefusal (interface)", @@ -313,11 +317,13 @@ "flowFilterOperands": "src/automation/flow-node-expression-paths.ts#flowFilterOperands (function)", "flowForm": "src/automation/flow.form.ts#flowForm (const)", "flowNodeConfigRefusals": "src/automation/flow-node-config-refusals.ts#flowNodeConfigRefusals (function)", + "flowNodeExpressionBlock": "src/automation/flow-node-expression-paths.ts#flowNodeExpressionBlock (function)", "flowNodeTextSlotSources": "src/automation/flow-text-slot-template.ts#flowNodeTextSlotSources (function)", "flowNodeValueTemplateRefusals": "src/automation/flow-value-slot-template.ts#flowNodeValueTemplateRefusals (function)", "flowScreenFieldOptionKey": "src/automation/flow-screen-option-key.ts#flowScreenFieldOptionKey (function)", "getApprovalNodeConfigJsonSchema": "src/automation/approval.zod.ts#getApprovalNodeConfigJsonSchema (function)", "getBuiltinNodeConfigContracts": "src/automation/flow-node-config-refusals.ts#getBuiltinNodeConfigContracts (function)", + "getFlowNodeSiblingBlockJsonSchemas": "src/automation/flow.zod.ts#getFlowNodeSiblingBlockJsonSchemas (function)", "getSchemalessNodeConfigJsonSchemas": "src/automation/schemaless-node-config.zod.ts#getSchemalessNodeConfigJsonSchemas (function)", "importBpmnToConstructs": "src/automation/bpmn-mapping.ts#importBpmnToConstructs (function)", "isExpressionEnvelopeShaped": "src/automation/flow-node-expression-paths.ts#isExpressionEnvelopeShaped (function)", diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index e170b7bdd17..086bb631224 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -679,7 +679,7 @@ function flowNodeObject() { return strictObject( input: z.record(z.string(), z.unknown().meta({ xExpression: 'value' })).optional().describe( 'Mapped inputs for the action. Each value is a literal, handed to the action as written, or a CEL value ' + 'envelope `{ dialect: \'cel\', source }` evaluated in the run\'s scope before dispatch; a `{…}` template ' - + 'token in a string is refused. The flow definition, this map included, is served to every member who can ' + + 'token in a string is refused when the flow is registered or validated. The flow definition, this map included, is served to every member who can ' + 'read flows, so never put a credential here: the connector authenticates through its own ' + '`auth.credentialRef`.', ), From c2454bbc3b0a710e6f7d52269ea621c46ebfe514 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:22:14 +0000 Subject: [PATCH 6/6] fix(examples,tests): write connector input values as CEL envelopes The showcase Slack flow's text and the DARK credential fixtures carried a single-brace token in connectorConfig.input, which registration and validate now refuse. Rewrite them as CEL value envelopes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzGExkj8nxREmV7FjX --- examples/app-showcase/src/automation/flows/index.ts | 5 ++++- .../cli/test/flow-credential-literal-doors.e2e.test.ts | 9 +++++++-- ...time-authoring-gate.flow-credential-literal.test.ts | 10 ++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 86e04584a3d..786ab4d904e 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -349,7 +349,10 @@ export const TaskCompletedSlackFlow = defineFlow({ actionId: 'chat.postMessage', input: { channel: 'C0WINS000', - text: '✅ Task done: {record.title}', + // Each input value is a literal or a CEL value envelope evaluated + // before dispatch (#27968); a `{…}` token in a string is refused. + // `title` is a required field, so it is read unguarded. + text: { dialect: 'cel', source: "'✅ Task done: ' + record.title" }, }, }, }, diff --git a/packages/cli/test/flow-credential-literal-doors.e2e.test.ts b/packages/cli/test/flow-credential-literal-doors.e2e.test.ts index 7041f51c3c9..4bf05b48faf 100644 --- a/packages/cli/test/flow-credential-literal-doors.e2e.test.ts +++ b/packages/cli/test/flow-credential-literal-doors.e2e.test.ts @@ -15,7 +15,8 @@ * query parameter, a nested connector input); DARK is the same flow with * templates in those three positions — a `{{ var }}` hole at the http url and * header (text slots, where a single-brace token is refused since #19939), a - * `{var}` token at the connector input — and must stay silent. Every + * CEL value envelope computing the connector input's `auth` map (a value slot + * since #27968, where a `{…}` token is refused) — and must stay silent. Every * value is a probe sentinel, not a credential. * * Integration tier: it spawns the source CLI (`packages/cli/vitest-tiers.ts`). @@ -85,7 +86,11 @@ const stackOf = (literal: boolean) => ({ connectorConfig: { connectorId: 'rest', actionId: 'request', - input: { method: 'GET', path: '/p', auth: { clientSecret: literal ? SENTINELS[2] : '{api_token}' } }, + input: { + method: 'GET', + path: '/p', + auth: literal ? { clientSecret: SENTINELS[2] } : { dialect: 'cel', source: "{'clientSecret': api_token}" }, + }, }, }, { id: 'end', type: 'end', label: 'End' }, diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.flow-credential-literal.test.ts b/packages/metadata-protocol/src/runtime-authoring-gate.flow-credential-literal.test.ts index 9476eada1d8..5015850a0c6 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.flow-credential-literal.test.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.flow-credential-literal.test.ts @@ -24,7 +24,9 @@ const SENTINELS = ['door-header-sentinel', 'door-url-sentinel', 'door-input-sent /** * One flow, three served positions. `literal: false` is the same flow with templates: * a `{{ var }}` hole at the http `url` and header — text slots, where a single-brace - * token is refused at this door since #19939 — and a `{var}` token at the connector input. + * token is refused at this door since #19939 — and, at the connector input, a CEL value + * envelope computing the `auth` map: the input is a value slot since #27968, where a + * `{…}` token is refused. */ const credentialFlow = (literal: boolean) => ({ name: literal ? 'door_cred_lit' : 'door_cred_dark', @@ -55,7 +57,11 @@ const credentialFlow = (literal: boolean) => ({ connectorConfig: { connectorId: 'rest', actionId: 'request', - input: { method: 'GET', path: '/p', auth: { clientSecret: literal ? SENTINELS[2] : '{api_token}' } }, + input: { + method: 'GET', + path: '/p', + auth: literal ? { clientSecret: SENTINELS[2] } : { dialect: 'cel', source: "{'clientSecret': api_token}" }, + }, }, }, { id: 'end', type: 'end', label: 'End' },