Skip to content

Commit a0cc51f

Browse files
fix(service-automation): a connector_action evaluates a CEL envelope in connectorConfig.input (#27980)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DCaRwnRt6LWFUubxDRhe1CA)_ Fixes #27327 ## What changes - `connector-nodes.ts`: the `connector_action` executor resolves `connectorConfig.input` through `resolveValueSlotMap` (the one per-key value-slot resolver `subflow`/`map` `input`, `script` `inputs`, `screen` `defaults` and `notify` `templateData` use) before dispatch, and hands the handler the result. A top-level CEL envelope is evaluated in the run's scope; every other value is handed on as a copy of what was written. Resolved before the `try`, so an envelope that faults fails the step with `input.<key>` and its source, and the handler is never called. - `value-slot-map.ts`: the docblock lists the new slot. - `engine.ts` (comment only): the `evaluateValueEnvelope` docblock no longer says only ledger-declared slots reach it; it names `connectorConfig.input` as the one undeclared slot and points to #27968. - New `connector-input-value-slot.test.ts`; changeset (`@objectstack/service-automation` minor). ## Why Before, the input map reached the handler as authored, so an envelope reading a record field arrived as the object `{ dialect: 'cel', source: '…' }` and a declarative connector put `[object Object]` into the request. Triage ruling 6487582764: enforce through the same value-slot map, one evaluator, constant payloads unchanged. ## Acceptance notes - **Registration half is NOT in this PR: #27968.** `registerFlow` and `objectstack validate` judge value slots only through the spec ledger `FLOW_NODE_EXPRESSION_PATHS`, whose paths point into `node.config`; `connectorConfig` is a sibling block the ledger cannot address without a `packages/spec` change. Measured on `registerFlow`: `{ dialect: 'cel', source: 'rec.' }`, a `template` envelope and `'{rec.id}'` all REGISTER under `connectorConfig.input`, while the same three under `subflow` `config.input` are REFUSED. Until #27968 lands there are two reject sets for this slot: a malformed envelope registers and fails the step at run time (with its source) instead of being refused at the door. Before this PR it was sent to the vendor verbatim. Seat ruling B; no `packages/spec` edit and no service-automation-only judge here. - A `{token}` string in `connectorConfig.input` stays a literal, as before (the refusal of the retired dialect rides the ledger too: #27968). - **Skills-docs:** three files under `skills/` still teach the old limit, and are left to a skills-lane edit (fable tier, outside this card's surface): `skills/objectstack-automation/references/integration-recipes.md:18`, `skills/objectstack-automation/SKILL.md:88`, `skills/objectstack-automation/evals/integrations-declared.json:7`. Nothing under `content/docs` states it. ## Verification - Measured first: the new test is red on `main` (handler receives the envelope objects); the constant-payload and no-input controls pass on both. - Ablation (`scripts/ablation-replace.mjs`, call site back to `cfg.input ?? {}`): 3 of 4 cells red (evaluated, copied, fault); restored. - `service-automation`: 221 files / 2830 tests green after merging `main` (including #27941); `tsc --noEmit` clean. - `dispatch-gates --ran`: 62 derived families, 61 run green, 1 NOT MEASURED (`check:dual-build-cjs-loads`, exit 3, needs a whole-tree build); `pnpm lint` green. Clause-②: no Metadata-protocol: semantic ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4czwdEVkNtLnwVK4j6Z --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5e1342a commit a0cc51f

5 files changed

Lines changed: 169 additions & 2 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
fix(service-automation): a `connector_action` node evaluates a CEL envelope in `connectorConfig.input`, so a flow can send a per-record value to a connector's action
6+
7+
Clause-②: no
8+
Metadata-protocol: semantic
9+
10+
`connectorConfig.input` is now a value-slot map, resolved by the same per-key resolver as a `subflow` or `map` `input`, a `script`'s `inputs`, a `screen`'s `defaults` and a `notify` node's `templateData`:
11+
12+
- **An envelope is evaluated.** A top-level value written as `{ dialect: 'cel', source: '…' }` is evaluated in the run's scope, and the action's handler receives the value it computes, its type kept (a string, a number, a list, a record). Before, the handler received the envelope object itself, so a declarative connector that put the value into a request path sent `[object Object]` and the run reported success.
13+
- **A constant payload is unchanged.** Every other value, an envelope-shaped object nested inside a literal included, reaches the handler as written. The handler now receives a copy, so a handler that writes into its input no longer writes into the stored flow.
14+
- **A fault fails the step.** An envelope that faults on the live variables (an absent key, a type error) fails the node with the slot (`input.<key>`) and its source, and the handler is not called, so nothing is sent upstream.
15+
16+
To keep a value an author meant as a literal envelope-shaped object, nest it inside another literal; only a top-level value of the map is evaluated.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* **A `connector_action` node's `connectorConfig.input` is a value slot**
5+
* (#27327) — evaluated through the one per-key resolver every other value
6+
* map uses (`resolveValueSlotMap`), so a flow can send a per-record value to a
7+
* connector's action.
8+
*
9+
* Before this, the executor handed `cfg.input` to the connector's handler as
10+
* written: an envelope reading a record field reached the provider as the
11+
* object `{ dialect: 'cel', source: '…' }` it spells, and a declarative
12+
* connector interpolating it into a request path sent `[object Object]`, with
13+
* the run reporting success. Pinned through the shipped `AutomationEngine`:
14+
*
15+
* 1. **Evaluated.** A top-level envelope is evaluated in the run's scope and
16+
* reaches the handler as the raw value it computes — a string, a number,
17+
* a record — next to literal keys handed on as written.
18+
* 2. **A constant payload is unchanged.** Every literal reaches the handler
19+
* as it was authored, a nested envelope-shaped object included, and as a
20+
* copy: a handler that writes into its input never writes into the node.
21+
* 3. **A fault fails the step.** An envelope that faults on the live
22+
* variables fails the node with its source, and the handler is never
23+
* called — nothing is sent upstream.
24+
*/
25+
26+
import { describe, expect, it } from 'vitest';
27+
import { AutomationEngine } from '../engine.js';
28+
import { installBuiltinNodes } from './index.js';
29+
import type { Connector } from '@objectstack/spec/integration';
30+
31+
function silentLogger(): any {
32+
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } };
33+
}
34+
function ctx(): any {
35+
return { logger: silentLogger(), getService() { return undefined; } };
36+
}
37+
38+
const CONNECTOR = {
39+
name: 'vendor',
40+
label: 'Vendor',
41+
type: 'api',
42+
authentication: { type: 'none' },
43+
actions: [{ key: 'lookup', label: 'Lookup' }],
44+
} as Connector;
45+
46+
/** An engine with every builtin node and one connector whose action records each input it is handed. */
47+
function makeEngine(): { engine: AutomationEngine; received: Record<string, unknown>[] } {
48+
const engine = new AutomationEngine(silentLogger());
49+
installBuiltinNodes(engine, ctx());
50+
const received: Record<string, unknown>[] = [];
51+
engine.registerConnector(CONNECTOR, {
52+
async lookup(input) {
53+
received.push(input);
54+
return { ok: true };
55+
},
56+
});
57+
return { engine, received };
58+
}
59+
60+
/** start → a `connector_action` with `input` → end, the record an input variable. */
61+
function flowWith(name: string, input: Record<string, unknown>): any {
62+
return {
63+
name, label: name, type: 'autolaunched',
64+
variables: [{ name: 'rec', type: 'object', isInput: true }],
65+
nodes: [
66+
{ id: 'start', type: 'start', label: 'Start' },
67+
{ id: 'call', type: 'connector_action', label: 'Call', connectorConfig: { connectorId: 'vendor', actionId: 'lookup', input } },
68+
{ id: 'end', type: 'end', label: 'End' },
69+
],
70+
edges: [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'end' }],
71+
};
72+
}
73+
74+
const REC = { id: 'cus_42', name: 'Acme', seats: 7 };
75+
76+
describe('1. an envelope in `connectorConfig.input` is evaluated in the run\'s scope', () => {
77+
it('the handler receives the values it computes, raw, beside literals as written', async () => {
78+
const { engine, received } = makeEngine();
79+
engine.registerFlow('per_record', flowWith('per_record', {
80+
customerId: { dialect: 'cel', source: 'rec.id' },
81+
seats: { dialect: 'cel', source: 'rec.seats + 1' },
82+
record: { dialect: 'cel', source: 'rec' },
83+
greeting: { dialect: 'cel', source: "'Hi ' + rec.name" },
84+
region: 'eu',
85+
}));
86+
const run = await engine.execute('per_record', { params: { rec: REC } } as any);
87+
expect(run.success, run.error).toBe(true);
88+
expect(received).toEqual([{ customerId: 'cus_42', seats: 8, record: REC, greeting: 'Hi Acme', region: 'eu' }]);
89+
});
90+
});
91+
92+
describe('2. a constant payload reaches the handler exactly as authored', () => {
93+
it('literals, a nested envelope-shaped object included, are handed on as written, and as a copy', async () => {
94+
const { engine, received } = makeEngine();
95+
const input = {
96+
path: '/v1/customers',
97+
limit: 10,
98+
tags: ['a', 'b'],
99+
body: { filter: { dialect: 'cel', source: 'rec.id' } },
100+
};
101+
const flow = flowWith('constant', input);
102+
engine.registerFlow('constant', flow);
103+
const run = await engine.execute('constant', { params: { rec: REC } } as any);
104+
expect(run.success, run.error).toBe(true);
105+
expect(received).toEqual([input]);
106+
expect(received[0]).not.toBe(flow.nodes[1].connectorConfig.input);
107+
expect(received[0].body).not.toBe(input.body);
108+
});
109+
110+
it('a node with no `input` hands the handler an empty map, as before', async () => {
111+
const { engine, received } = makeEngine();
112+
const flow = flowWith('no_input', {});
113+
delete flow.nodes[1].connectorConfig.input;
114+
engine.registerFlow('no_input', flow);
115+
const run = await engine.execute('no_input', { params: { rec: REC } } as any);
116+
expect(run.success, run.error).toBe(true);
117+
expect(received).toEqual([{}]);
118+
});
119+
});
120+
121+
describe('3. an envelope that faults fails the step with its source, and nothing is sent', () => {
122+
it('a read of an absent key fails the node, naming the slot and the source; the handler is never called', async () => {
123+
const { engine, received } = makeEngine();
124+
engine.registerFlow('faulting', flowWith('faulting', {
125+
customerId: { dialect: 'cel', source: 'rec.missing.id' },
126+
}));
127+
const run = await engine.execute('faulting', { params: { rec: REC } } as any);
128+
expect(run.success).toBe(false);
129+
expect(run.error).toContain('input.customerId');
130+
expect(run.error).toContain('rec.missing.id');
131+
expect(received).toEqual([]);
132+
});
133+
});

‎packages/services/service-automation/src/builtin/connector-nodes.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { PluginContext } from '@objectstack/core';
44
import { defineActionDescriptor } from '@objectstack/spec/automation';
55
import type { AutomationEngine, ConnectorActionContext } from '../engine.js';
66
import { refuseNode } from '../guard-refusal.js';
7+
import { resolveValueSlotMap } from './value-slot-map.js';
78

89
/**
910
* Connector built-in node — `connector_action` (generic integration dispatch).
@@ -106,8 +107,17 @@ export function registerConnectorNodes(engine: AutomationEngine, ctx: PluginCont
106107
// success and the failure path read one value.
107108
const effect = engine.resolveConnectorActionEffect(cfg.connectorId, cfg.actionId);
108109

110+
// #27327 — `input` is a value-slot map: a top-level CEL envelope is
111+
// evaluated in the run's scope, every other value handed on as a
112+
// copy of what was written. Before, the map went to the handler as
113+
// written, so an envelope reading a record field reached the
114+
// provider as the object it spells. Resolved BEFORE the `try`: an
115+
// envelope that faults throws with its source, like every value
116+
// slot's, and nothing is sent upstream.
117+
const input = resolveValueSlotMap(engine, cfg.input, variables, 'input', context);
118+
109119
try {
110-
const output = await handler((cfg.input ?? {}) as Record<string, unknown>, handlerCtx);
120+
const output = await handler(input, handlerCtx);
111121
return {
112122
success: true,
113123
output,

‎packages/services/service-automation/src/builtin/value-slot-map.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import type { VariableMap } from './template.js';
2222
* and an object form's `recordId`);
2323
* - `notify` `templateData` (#19939) — handed to the messaging service (and,
2424
* through {@link resolveValueSlot}, each `recipients` value or list entry,
25-
* and `sourceId`).
25+
* and `sourceId`);
26+
* - `connector_action` `connectorConfig.input` (#27327) — handed to the
27+
* registered connector's action handler as its input.
2628
*
2729
* A `loop`'s and a `map`'s `collection` (#19939) is a value slot too, whose
2830
* literal is a list: {@link resolveCollectionSlot}.

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8966,6 +8966,12 @@ export class AutomationEngine implements IAutomationService {
89668966
* `assignments`
89678967
* through `builtin/value-slot-map.ts`, the one per-slot resolver
89688968
* (`resolveValueSlotMap`, `resolveValueSlot`, `resolveCollectionSlot`).
8969+
* One slot reaches it undeclared: a `connector_action`'s
8970+
* `connectorConfig.input` map (#27327), evaluated through the same
8971+
* resolver. The ledger addresses only `node.config`, and `connectorConfig`
8972+
* is a sibling block, so registration does not judge an envelope there
8973+
* yet — a malformed one registers and faults at run time — until #27968
8974+
* declares it.
89698975
* The two legacy
89708976
* `assignment` shapes the executor still normalizes (the
89718977
* `assignments: [{ variable, value }]` array and the bare

0 commit comments

Comments
 (0)