Skip to content

Commit 31e0be9

Browse files
os-zhuangclaude
andauthored
fix(spec,service-automation,lint): flow metadata is canonicalized inside structured regions (#4347) (#4381)
`registerFlow` canonicalizes a stored flow through three passes — the ADR-0087 conversion table, `FlowSchema.parse`, and the ADR-0032 predicate validation — and every one of them walked `flow.nodes` / `flow.edges` only. An ADR-0031 container keeps a whole sub-graph in its open `config`, so all three stopped at the container and metadata came out position-dependent: the same node converted at the top level and did not one level in, and the same predicate was stored as a `{ dialect: 'cel', source }` envelope on a top-level edge and left a bare string on a loop-body edge. - `mapFlowNodes` recurses into `loop.config.body`, `parallel.config.branches[]` and `try_catch.config.try`/`.catch`, to any depth. Notice paths carry the region. Matters most for the conversions that change behaviour rather than spelling: a `webhook` callout inside a loop body kept a type no executor owns, and a `delete_record` kept `config.filters`, leaving the canonical `filter` the executor reads absent. - New `normalizeControlFlowRegions`, called at the load seam after `validateControlFlow`: each region parses through its own schema, recursively, so nested edges and nodes carry the same canonical shapes as top-level ones. A region that does not parse is left untouched, so which flows register is unchanged. - New `collectFlowGraphs` yields a flow's own graph plus every nested region with a scope label. Both predicate validators iterate it — the engine's `validateFlowExpressions` and lint's `validateStackExpressions` — so the `{record.x}` brace-trap they exist to catch is caught inside a region too. - `evaluateCondition`'s legacy `{var}` path refuses an unresolved dotted reference instead of comparing it as a string: `'oppRecord.amount > 500000'` compared `'o'` against `'5'` and was constantly true regardless of the amount. The `try/catch { return false }` around that block goes with it — nothing in it throws, so it guarded nothing and would have swallowed the refusal. Claude-Session: https://claude.ai/code/session_01HSBbKMdDgHjGpQdrvQUQXj Co-authored-by: Claude <noreply@anthropic.com>
1 parent 081aa6f commit 31e0be9

10 files changed

Lines changed: 1362 additions & 144 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/service-automation': patch
4+
'@objectstack/lint': patch
5+
---
6+
7+
Flow metadata is canonicalized inside structured regions, not just at the top level (#4347).
8+
9+
`registerFlow` canonicalizes a stored flow through three passes — the ADR-0087 conversion
10+
table, `FlowSchema.parse`, and the ADR-0032 predicate validation — and every one of them
11+
walked `flow.nodes` / `flow.edges` only. An ADR-0031 container keeps a whole sub-graph in
12+
its open `config` (`loop.config.body`, `parallel.config.branches[]`,
13+
`try_catch.config.try`/`.catch`), so all three stopped at the container and metadata came
14+
out **position-dependent**: the same node converted at the top level and did not one level
15+
in, and the same predicate was stored as a `{ dialect: 'cel', source }` envelope on a
16+
top-level edge and left a bare string on a loop-body edge.
17+
18+
The reporting app shipped three sweeps whose gates never opened. Each run reported
19+
`success: true`, queried correctly, selected exactly the right records, and then did
20+
nothing — which is indistinguishable from "this sweep had no work to do" unless you assert
21+
on records written.
22+
23+
- **`mapFlowNodes` recurses into regions**, to any depth. Every conversion in the table now
24+
reaches a nested node, which matters most for the two that change behaviour rather than
25+
spelling: a `webhook` / `http_request` callout inside a loop body kept a type no executor
26+
owns (the run failed), and a `delete_record` kept `config.filters`, leaving the canonical
27+
`filter` the executor reads absent — the erased-condition hazard
28+
`flow-node-crud-filter-alias` exists to prevent. Notice paths carry the region
29+
(`flows[0].nodes[3].config.body.nodes[1].config.filter`), so the warning points at the
30+
node to edit.
31+
- **New `normalizeControlFlowRegions`**, called at the load seam after
32+
`validateControlFlow`: each region is parsed through its own schema (recursively — regions
33+
nest), so nested edges and nodes carry the same canonical shapes as top-level ones. A
34+
region that does not parse is left untouched; rejecting one stays `validateControlFlow`'s
35+
job, so which flows register is unchanged.
36+
- **New `collectFlowGraphs`** yields a flow's own graph plus every nested region, each with
37+
a scope label. Both predicate validators iterate it instead of `flow.nodes` — the engine's
38+
`validateFlowExpressions` and `@objectstack/lint`'s author-time
39+
`validateStackExpressions` — so the `{record.x}` brace-trap they exist to catch is now
40+
caught inside a loop body too, naming the region (`loop 'sweep' body · edge 'b1' …`). It
41+
used to pass `objectstack validate`, pass registration, and fail at run time with the
42+
diagnostic suppressed.
43+
44+
The container executors already parse their own config at run time (`parseNodeConfig`,
45+
#4277), so a nested predicate did evaluate correctly on current `main` — what was still
46+
wrong is everything that reads a region *without* re-parsing it (the Studio designer,
47+
`getFlow`, the version history), and every conversion, none of which the executors replay.
48+
49+
Also hardened, per the issue's secondary finding: `evaluateCondition`'s legacy `{var}`
50+
template path **refuses an unresolved dotted reference** instead of comparing it as a
51+
string. `'oppRecord.amount > 500000'` was compared `'oppRecord.amount' > '500000'``'o'`
52+
against `'5'` — so it was constantly true regardless of the amount: silently wrong in the
53+
*true* direction, a gate that reports success while never gating. It now throws with the
54+
source and the fix (a CEL envelope, or brace the reference if the `{var}` dialect was
55+
meant), the same "never swallow a broken predicate" rule ADR-0032 §1c set for the CEL path.
56+
The `try { … } catch { return false }` around that block went with it: nothing in it throws,
57+
so it guarded nothing and would have swallowed the new refusal straight back into the silent
58+
wrong answer. Bare-word comparisons (`'{status} == active'`) and `{var}` templates are
59+
unchanged — only dotted references, which substitution can never leave behind, are refused.

packages/lint/src/validate-expressions.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,82 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
597597
expect(issues).toHaveLength(0);
598598
});
599599

600+
/**
601+
* #4347 — the walk used to stop at a container, so a predicate written in
602+
* the wrong dialect inside a `loop` body passed `objectstack validate` and
603+
* shipped, while the identical predicate one level out was a build error.
604+
*/
605+
describe('structured regions', () => {
606+
const flowWith = (container: Record<string, unknown>) => ({
607+
objects,
608+
flows: [{
609+
name: 'sweep',
610+
nodes: [{ id: 'start', type: 'start', config: { objectName: 'crm_lead' } }, container],
611+
edges: [],
612+
}],
613+
});
614+
const badRegion = () => ({
615+
nodes: [
616+
{ id: 'gate', type: 'decision', config: { condition: '{record.rating} >= 4' } },
617+
{ id: 'act', type: 'update_record' },
618+
],
619+
edges: [{ id: 'b1', source: 'gate', target: 'act', condition: '{record.status} == "open"' }],
620+
});
621+
622+
it('flags a brace-in-CEL predicate inside a loop body, naming the region', () => {
623+
const issues = validateStackExpressions(flowWith({
624+
id: 'loop_1', type: 'loop', config: { collection: '{rows}', body: badRegion() },
625+
}));
626+
// Both the body node's `config.condition` and the body edge's condition.
627+
expect(issues).toHaveLength(2);
628+
for (const issue of issues) expect(issue.where).toContain("loop 'loop_1' body");
629+
expect(issues.map(i => i.source)).toEqual(['{record.rating} >= 4', '{record.status} == "open"']);
630+
});
631+
632+
it('flags them in parallel branches and try_catch regions too', () => {
633+
expect(validateStackExpressions(flowWith({
634+
id: 'par', type: 'parallel', config: { branches: [badRegion(), badRegion()] },
635+
}))).toHaveLength(4);
636+
637+
const tc = validateStackExpressions(flowWith({
638+
id: 'tc', type: 'try_catch', config: { try: badRegion(), catch: badRegion() },
639+
}));
640+
expect(tc).toHaveLength(4);
641+
expect(tc.filter(i => i.where.includes("try_catch 'tc' catch"))).toHaveLength(2);
642+
});
643+
644+
it('reaches a container nested inside another region', () => {
645+
const issues = validateStackExpressions(flowWith({
646+
id: 'outer', type: 'loop',
647+
config: {
648+
collection: '{rows}',
649+
body: {
650+
nodes: [{ id: 'inner', type: 'loop', config: { collection: '{cols}', body: badRegion() } }],
651+
edges: [],
652+
},
653+
},
654+
}));
655+
expect(issues).toHaveLength(2);
656+
for (const issue of issues) expect(issue.where).toContain("loop 'outer' body → loop 'inner' body");
657+
});
658+
659+
it('leaves a correct region alone', () => {
660+
expect(validateStackExpressions(flowWith({
661+
id: 'loop_1', type: 'loop',
662+
config: {
663+
collection: '{rows}',
664+
body: {
665+
nodes: [
666+
{ id: 'gate', type: 'decision', config: { condition: 'record.rating >= 4' } },
667+
{ id: 'act', type: 'update_record' },
668+
],
669+
edges: [{ id: 'b1', source: 'gate', target: 'act', condition: 'record.status == "open"' }],
670+
},
671+
},
672+
}))).toHaveLength(0);
673+
});
674+
});
675+
600676
it('tolerates a screen with no fields, a non-array fields, and no config', () => {
601677
expect(validateStackExpressions(screenFlow([]))).toHaveLength(0);
602678
expect(validateStackExpressions({

packages/lint/src/validate-expressions.ts

Lines changed: 73 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
*/
1919

2020
import { validateExpression } from '@objectstack/formula';
21-
import { resolveFlowNodeExpressions } from '@objectstack/spec/automation';
21+
import { collectFlowGraphs, resolveFlowNodeExpressions } from '@objectstack/spec/automation';
22+
import type { FlowNodeParsed } from '@objectstack/spec/automation';
2223

2324
export interface ExprIssue {
2425
where: string;
@@ -130,76 +131,85 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
130131
for (const flow of asArray(stack.flows)) {
131132
const flowName = typeof flow.name === 'string' ? flow.name : '(unnamed flow)';
132133
const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : [];
133-
const edges = Array.isArray(flow.edges) ? (flow.edges as AnyRec[]) : [];
134134
// The record-change target object — `record.*` refs resolve against it.
135135
const startNode = nodes.find(n => n.type === 'start');
136136
const startCfg = (startNode?.config ?? {}) as AnyRec;
137137
const objectName = typeof startCfg.objectName === 'string' ? startCfg.objectName : undefined;
138138

139-
for (const node of nodes) {
140-
const cfg = (node.config ?? {}) as AnyRec;
141-
check(`flow '${flowName}' · node '${node.id}' (${node.type}) condition`, cfg.condition, objectName);
139+
// #4347 — every graph in the flow, not just `flow.nodes`/`flow.edges`. An
140+
// ADR-0031 container keeps a whole sub-graph in its `config`, so the
141+
// top-level walk validated PART of the flow while reporting on all of it: a
142+
// predicate written in the wrong dialect inside a `loop` body passed
143+
// `objectstack validate` and shipped. This is the author-time half of the
144+
// same traversal the engine's registration pass now does; `scope` names the
145+
// region so the located message still points at one edge.
146+
for (const graph of collectFlowGraphs(flow as { nodes?: FlowNodeParsed[] })) {
147+
const at = graph.scope ? `flow '${flowName}' · ${graph.scope}` : `flow '${flowName}'`;
148+
for (const node of graph.nodes as unknown as AnyRec[]) {
149+
const cfg = (node.config ?? {}) as AnyRec;
150+
check(`${at} · node '${node.id}' (${node.type}) condition`, cfg.condition, objectName);
142151

143-
// Descriptor-declared expression slots (#4027). Before this, the traversal
144-
// hardcoded `condition` and assumed every other node string was a `{var}`
145-
// template — so `screen.fields[].visibleWhen`, declared bare CEL since
146-
// #3304, was validated by nobody and #3528 shipped a template-dialect
147-
// predicate through compile, validate and run time in silence.
148-
// Only `predicate` slots are checkable: `flow-template` slots take the
149-
// single-brace `{var}` dialect `interpolate()` implements, which no
150-
// validator covers (the `template` role enforces ADR-0032 §3's
151-
// double-brace text template and would reject every correct
152-
// `loop.collection`). The ledger records them regardless, so the
153-
// reconciliation ratchet still sees the marker.
154-
const nodeType = typeof node.type === 'string' ? node.type : '';
155-
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
156-
if (found.entry.role !== 'predicate') continue;
157-
checkDeclaredPredicate(
158-
`flow '${flowName}' · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`,
159-
found.value,
160-
);
161-
}
162-
// #1870 — a `script` node must declare a callable target (`actionType` or
163-
// `function`). A node with neither is a silent no-op that otherwise passes
164-
// build. (Function *existence* isn't checkable here — functions are code,
165-
// not serialized into the artifact — so this is a structural check; the
166-
// runtime verifies the named function is actually registered.)
167-
if (node.type === 'script') {
168-
// `function` is canonical; a pre-parse source may still carry the
169-
// `functionName` alias during the protocol-17 window, until the
170-
// 'flow-node-script-config-aliases' conversion (#3796) canonicalizes it.
171-
const fn =
172-
(typeof cfg.function === 'string' ? cfg.function.trim() : '') ||
173-
(typeof cfg.functionName === 'string' ? cfg.functionName.trim() : '');
174-
const action = typeof cfg.actionType === 'string' ? cfg.actionType.trim() : '';
175-
// Inline `config.script` (a JS body) is also a declared form — the
176-
// built-in runtime doesn't execute it (warned at run time), but the node
177-
// is not the empty no-op this check targets, so don't flag it.
178-
const inline = typeof cfg.script === 'string' ? cfg.script.trim() : '';
179-
if (!fn && !action && !inline) {
180-
issues.push({
181-
where: `flow '${flowName}' · node '${node.id}' (script) callable`,
182-
message:
183-
`script node declares neither \`actionType\` nor \`function\` — it would do nothing at runtime. ` +
184-
`Name a built-in action (e.g. \`actionType: 'email'\`) or a registered function ` +
185-
`(\`function: 'my_fn'\`, registered via \`defineStack({ functions })\`).`,
186-
source: JSON.stringify({ id: node.id, type: node.type, config: cfg }),
187-
});
188-
} else if (action === 'invoke_function' && !fn) {
189-
// `actionType: 'invoke_function'` is a marker that names no callable on
190-
// its own — the function name must be in `function`/`functionName`.
191-
issues.push({
192-
where: `flow '${flowName}' · node '${node.id}' (script) callable`,
193-
message:
194-
`script node uses \`actionType: 'invoke_function'\` but no \`function\` (or \`functionName\`) — ` +
195-
`it names no callable. Set \`function: 'my_fn'\` and register it via \`defineStack({ functions })\`.`,
196-
source: JSON.stringify({ id: node.id, type: node.type, config: cfg }),
197-
});
152+
// Descriptor-declared expression slots (#4027). Before this, the traversal
153+
// hardcoded `condition` and assumed every other node string was a `{var}`
154+
// template — so `screen.fields[].visibleWhen`, declared bare CEL since
155+
// #3304, was validated by nobody and #3528 shipped a template-dialect
156+
// predicate through compile, validate and run time in silence.
157+
// Only `predicate` slots are checkable: `flow-template` slots take the
158+
// single-brace `{var}` dialect `interpolate()` implements, which no
159+
// validator covers (the `template` role enforces ADR-0032 §3's
160+
// double-brace text template and would reject every correct
161+
// `loop.collection`). The ledger records them regardless, so the
162+
// reconciliation ratchet still sees the marker.
163+
const nodeType = typeof node.type === 'string' ? node.type : '';
164+
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
165+
if (found.entry.role !== 'predicate') continue;
166+
checkDeclaredPredicate(
167+
`${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`,
168+
found.value,
169+
);
170+
}
171+
// #1870 — a `script` node must declare a callable target (`actionType` or
172+
// `function`). A node with neither is a silent no-op that otherwise passes
173+
// build. (Function *existence* isn't checkable here — functions are code,
174+
// not serialized into the artifact — so this is a structural check; the
175+
// runtime verifies the named function is actually registered.)
176+
if (node.type === 'script') {
177+
// `function` is canonical; a pre-parse source may still carry the
178+
// `functionName` alias during the protocol-17 window, until the
179+
// 'flow-node-script-config-aliases' conversion (#3796) canonicalizes it.
180+
const fn =
181+
(typeof cfg.function === 'string' ? cfg.function.trim() : '') ||
182+
(typeof cfg.functionName === 'string' ? cfg.functionName.trim() : '');
183+
const action = typeof cfg.actionType === 'string' ? cfg.actionType.trim() : '';
184+
// Inline `config.script` (a JS body) is also a declared form — the
185+
// built-in runtime doesn't execute it (warned at run time), but the node
186+
// is not the empty no-op this check targets, so don't flag it.
187+
const inline = typeof cfg.script === 'string' ? cfg.script.trim() : '';
188+
if (!fn && !action && !inline) {
189+
issues.push({
190+
where: `${at} · node '${node.id}' (script) callable`,
191+
message:
192+
`script node declares neither \`actionType\` nor \`function\` — it would do nothing at runtime. ` +
193+
`Name a built-in action (e.g. \`actionType: 'email'\`) or a registered function ` +
194+
`(\`function: 'my_fn'\`, registered via \`defineStack({ functions })\`).`,
195+
source: JSON.stringify({ id: node.id, type: node.type, config: cfg }),
196+
});
197+
} else if (action === 'invoke_function' && !fn) {
198+
// `actionType: 'invoke_function'` is a marker that names no callable on
199+
// its own — the function name must be in `function`/`functionName`.
200+
issues.push({
201+
where: `${at} · node '${node.id}' (script) callable`,
202+
message:
203+
`script node uses \`actionType: 'invoke_function'\` but no \`function\` (or \`functionName\`) — ` +
204+
`it names no callable. Set \`function: 'my_fn'\` and register it via \`defineStack({ functions })\`.`,
205+
source: JSON.stringify({ id: node.id, type: node.type, config: cfg }),
206+
});
207+
}
198208
}
199209
}
200-
}
201-
for (const edge of edges) {
202-
check(`flow '${flowName}' · edge '${edge.id}' (${edge.source}${edge.target}) condition`, edge.condition, objectName);
210+
for (const edge of graph.edges as unknown as AnyRec[]) {
211+
check(`${at} · edge '${edge.id}' (${edge.source}${edge.target}) condition`, edge.condition, objectName);
212+
}
203213
}
204214
}
205215

0 commit comments

Comments
 (0)