Skip to content

Commit d449b0c

Browse files
os-zhuangclaude
andauthored
fix(cli): gate the two routing shapes that can never work, flag the inert condition (#4414) (#4493)
Two follow-ups to #4440, both about metadata that reads like a guard and is not one. `flow-branch-label-unmatched` and `flow-default-edge-with-condition` now FAIL the build. The bar is restated at the top of the file, because the old one — "a guaranteed runtime failure" — no longer described the set: it is now **no reading of the author's metadata does what it says, deterministically, on every run**. A branch label no out-edge carries cannot route; an edge that is both `isDefault` and conditional always lets the condition win, so the marker routes nothing. Neither FAILS; both are wrong every time and silently. The other two stay advisory, and the policy now says why. A decision with one guarded and one unconditional out-edge is usually a guard that does not guard, but it is also a legal "maybe notify, always continue" fan-out, and two default edges can genuinely mean "when nothing matched, do both". The bar is provability, not severity of consequence — failing a customer's build on a shape we cannot prove wrong is the worse trade. No wiring change: the rule is already `tier: 'gating'` across all three commands (#4409). `flow-inert-node-condition` is new. `config.condition` is the trigger gate on a `start` node and is read by no other node type — the engine parse-validates it everywhere and then ignores it, so on a `decision`, where the name makes it read as the branch predicate, it is a guard that gates nothing. Two of the three bundled apps had one: app-todo's `check_recurring` and app-showcase's `needs_exec`, each a third copy of a predicate its out-edges were already enforcing. The showcase even carried a comment saying the node condition "is not evaluated by the engine" and kept it anyway — the residue this rule exists to stop accumulating. Both are now plain exclusive gateways. Advisory: the surrounding edges usually still route correctly, so it is dead weight rather than a provable misroute. The node-type list is a closed set of builtins whose executors were actually read, not "everything but `start`" — ADR-0018 keeps `node.type` open and a plugin executor may legitimately declare and read its own `config.condition`. Claude-Session: https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c93465 commit d449b0c

6 files changed

Lines changed: 268 additions & 25 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"@objectstack/cli": minor
3+
"@objectstack/example-showcase": patch
4+
"@objectstack/example-todo": patch
5+
---
6+
7+
fix(cli): gate the two decision-routing shapes that can never work, and flag the inert `config.condition` (#4414)
8+
9+
Two follow-ups to #4440, both about metadata that reads like a guard and is not
10+
one.
11+
12+
## Two rules promoted to `error`
13+
14+
`flow-branch-label-unmatched` and `flow-default-edge-with-condition` now FAIL the
15+
build instead of warning. The bar for that — restated at the top of
16+
`lint-flow-patterns.ts`, because the old one no longer described the set — is
17+
**no reading of the author's metadata does what it says, deterministically, on
18+
every run**. Both qualify: a branch label no out-edge carries cannot route, and
19+
an edge that is both `isDefault` and conditional always lets the condition win,
20+
so the marker routes nothing. Neither *fails*; both are wrong every time and
21+
silently, which is worse.
22+
23+
The other two stay advisory on purpose, and the policy now says why:
24+
`flow-decision-unconditional-branch` is usually a guard that does not guard, but
25+
one guarded plus one unconditional out-edge is also a legal "maybe notify,
26+
always continue" fan-out, and `flow-multiple-default-edges` can genuinely mean
27+
"when nothing matched, do both". The bar is about *provability*, not severity of
28+
consequence — failing a customer's build on a shape we cannot prove wrong is the
29+
worse trade.
30+
31+
No wiring change was needed: `lintFlowPatterns` is already registered as
32+
`tier: 'gating'` across all three commands (#4409), which is exactly the seam
33+
`authoring-rule-wiring.test.ts` exists to guard.
34+
35+
## New rule: `flow-inert-node-condition`
36+
37+
`config.condition` is the trigger gate on a `start` node and is read by **no
38+
other node type** — the engine parse-validates it everywhere (so a malformed one
39+
is caught) and then ignores it. On a `decision` the name makes it read as the
40+
branch predicate, which is exactly how it got authored.
41+
42+
Three of the three bundled apps had one. `app-todo`'s `check_recurring` and
43+
`app-showcase`'s `needs_exec` both carried a predicate their out-edges were
44+
already enforcing — a third copy doing nothing. The showcase even had a comment
45+
next to it saying the node condition "is not evaluated by the engine", and kept
46+
it anyway; that is the residue this rule exists to stop accumulating. Both are
47+
now plain exclusive gateways.
48+
49+
Advisory, not gating: the surrounding edges usually still route correctly, so
50+
this is dead weight rather than a provable misroute. The node-type list is a
51+
closed set of builtins we have actually read, not "everything that isn't
52+
`start`" — ADR-0018 keeps `node.type` open and a plugin executor may legitimately
53+
declare and read its own `config.condition`.
54+
55+
## Studio
56+
57+
`objectstack-ai/objectui` carries the matching help-text fixes: the branch editor
58+
said a `true` branch **is** the default/else path (it is how you *ask* for one —
59+
the marker goes on the out-edge), and the legacy single `Condition` field said
60+
"prefer Branches above", which reads as "this works, but the other is better".
61+
It does not work at all.

examples/app-showcase/src/automation/flows/index.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,12 @@ export const BudgetApprovalFlow = defineFlow({
211211
// load, but the showcase should demonstrate the declared spelling.
212212
waitEventConfig: { eventType: 'signal', signalName: 'budget_revision' },
213213
},
214-
{
215-
id: 'needs_exec',
216-
type: 'decision',
217-
label: 'Budget Above $500k?',
218-
config: { condition: 'budget > 500000' },
219-
},
214+
// A plain exclusive gateway: the predicate is on the out-edges (e4/e5).
215+
// It also carried `config.condition` — inert on every node but `start`, and
216+
// the comment on those edges already said so. Keeping a copy that nothing
217+
// reads is the shape #4414 is about, so it is gone; `os validate` reports
218+
// it as `flow-inert-node-condition`.
219+
{ id: 'needs_exec', type: 'decision', label: 'Budget Above $500k?' },
220220
{
221221
id: 'exec_review',
222222
type: 'approval',
@@ -236,10 +236,11 @@ export const BudgetApprovalFlow = defineFlow({
236236
{ id: 'e1', source: 'start', target: 'manager_review' },
237237
{ id: 'e2', source: 'manager_review', target: 'needs_exec', label: 'approve' },
238238
{ id: 'e3', source: 'manager_review', target: 'rejected', label: 'reject' },
239-
// Decision branching is edge-condition driven (flow spec): the engine
240-
// routes a decision node by evaluating each out-edge's `condition`. Carry
241-
// the predicate on the edges (the node `config.condition` alone is not
242-
// evaluated by the engine), so budgets ≤ $500k skip the executive step.
239+
// Decision branching is edge-condition driven: the engine routes a decision
240+
// by evaluating each out-edge's `condition`, so the predicate lives here and
241+
// budgets ≤ $500k skip the executive step. These two are complementary, so
242+
// exactly one runs; the other correct spelling is one `condition` plus
243+
// `isDefault: true` on the fallback edge (#4414).
243244
{ id: 'e4', source: 'needs_exec', target: 'exec_review', label: 'true', condition: 'budget > 500000' },
244245
{ id: 'e5', source: 'needs_exec', target: 'approved', label: 'false', condition: 'budget <= 500000' },
245246
{ id: 'e6', source: 'exec_review', target: 'approved', label: 'approve' },

examples/app-todo/src/flows/task.flow.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,12 @@ export const TaskCompletionFlow: Flow = {
131131
id: 'get_task', type: 'get_record', label: 'Get Completed Task',
132132
config: { objectName: 'todo_task', filter: { id: '{taskId}' }, outputVariable: 'completedTask' },
133133
},
134-
{
135-
id: 'check_recurring', type: 'decision', label: 'Is Recurring Task?',
136-
config: { condition: 'vars.completedTask.is_recurring == true' },
137-
},
134+
// A plain exclusive gateway — the branching is on the OUT-EDGES (e3/e4
135+
// carry the predicate and its negation). It used to also set
136+
// `config.condition`, which no executor reads: that key is the trigger gate
137+
// on a `start` node and inert everywhere else, so it was a third copy of the
138+
// same predicate, doing nothing (#4414).
139+
{ id: 'check_recurring', type: 'decision', label: 'Is Recurring Task?' },
138140
{
139141
id: 'create_next_task', type: 'create_record', label: 'Create Next Recurring Task',
140142
config: {

packages/cli/src/lint/authoring-rules.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,8 +419,11 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
419419
run: (stack) => validateVisibilityPredicates(stack),
420420
},
421421
// #1874 — flow authoring anti-patterns. Advisory by default; a finding marked
422-
// `error` gates (#3760 promoted `flow-runas-unscoped`, which flags metadata
423-
// the runtime now REFUSES to execute).
422+
// `error` gates. Three do today: `flow-runas-unscoped` (#3760 — metadata the
423+
// runtime REFUSES to execute), plus `flow-branch-label-unmatched` and
424+
// `flow-default-edge-with-condition` (#4414 — a declaration that is inert, so
425+
// the route silently differs from what the author wrote). The bar for
426+
// promoting one is stated at the top of `lint-flow-patterns.ts`.
424427
{
425428
name: 'lintFlowPatterns',
426429
tier: 'gating',

packages/cli/src/utils/lint-flow-patterns.test.ts

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
FLOW_DECISION_UNCONDITIONAL_BRANCH,
1818
FLOW_DEFAULT_EDGE_WITH_CONDITION,
1919
FLOW_MULTIPLE_DEFAULT_EDGES,
20+
FLOW_INERT_NODE_CONDITION,
2021
} from './lint-flow-patterns.js';
2122

2223
const CEL = (source: string) => ({ dialect: 'cel', source });
@@ -189,10 +190,16 @@ describe('lintFlowPatterns — wrong interpolation syntax (#1315)', () => {
189190
expect(rules(nodeFlow({ objectName: 'm', fields: { price: '$5.00', label: 'Total $5' } }))).toEqual([]);
190191
});
191192
it('a CEL condition (skipped — not a template value)', () => {
192-
expect(rules({ flows: [{ name: 'd', nodes: [
193+
// Scoped to the #1315 interpolation rules on purpose: this shape DOES
194+
// trip `flow-inert-node-condition` (#4414 — a decision never reads
195+
// `config.condition`), a different finding about a different defect,
196+
// which must not make this case read as a brace mistake.
197+
const found = rules({ flows: [{ name: 'd', nodes: [
193198
{ id: 'start', type: 'start', config: {} },
194199
{ id: 'dec', type: 'decision', config: { condition: 'record.amount > 100' } },
195-
], edges: [] }] })).toEqual([]);
200+
], edges: [] }] });
201+
expect(found).not.toContain(FLOW_DOUBLE_BRACE_INTERP);
202+
expect(found).not.toContain(FLOW_BARE_DOLLAR_REF);
196203
});
197204
});
198205
});
@@ -500,6 +507,8 @@ describe('flow-branch-label-unmatched (#4414)', () => {
500507
})).filter((f) => f.rule === FLOW_BRANCH_LABEL_UNMATCHED);
501508

502509
expect(fnds).toHaveLength(1);
510+
// Gating: a label nothing claims cannot route under any reading (#4414).
511+
expect(fnds[0].severity).toBe('error');
503512
expect(fnds[0].where).toContain("decision 'check'");
504513
expect(fnds[0].message).toContain("'yes — already converted'");
505514
expect(fnds[0].message).toContain("'no — proceed'");
@@ -541,6 +550,10 @@ describe('flow-decision-unconditional-branch (#4414)', () => {
541550
(f) => f.rule === FLOW_DECISION_UNCONDITIONAL_BRANCH,
542551
);
543552
expect(fnds).toHaveLength(1);
553+
// Advisory, deliberately: one guarded + one unconditional out-edge is also
554+
// a legal "maybe notify, always continue" fan-out, so this shape cannot be
555+
// proved wrong the way the two gating rules can.
556+
expect(fnds[0].severity).toBeUndefined();
544557
expect(fnds[0].message).toContain("'proceed'");
545558
expect(fnds[0].message).toContain('EVERY pass');
546559
expect(fnds[0].hint).toContain('isDefault');
@@ -589,6 +602,8 @@ describe('flow-default-edge-with-condition / flow-multiple-default-edges (#4414)
589602
proceed: { isDefault: true, condition: "lead.status != 'converted'" },
590603
})).filter((f) => f.rule === FLOW_DEFAULT_EDGE_WITH_CONDITION);
591604
expect(fnds).toHaveLength(1);
605+
// Gating: the condition always wins, so the marker never routes (#4414).
606+
expect(fnds[0].severity).toBe('error');
592607
expect(fnds[0].message).toContain('contradictory');
593608
});
594609

@@ -598,10 +613,76 @@ describe('flow-default-edge-with-condition / flow-multiple-default-edges (#4414)
598613
extra: [{ id: 'e_also', source: 'check', target: 'abort', isDefault: true }],
599614
})).filter((f) => f.rule === FLOW_MULTIPLE_DEFAULT_EDGES);
600615
expect(fnds).toHaveLength(1);
616+
// Advisory: two defaults can genuinely mean "when nothing matched, do both".
617+
expect(fnds[0].severity).toBeUndefined();
601618
expect(fnds[0].where).toContain("node 'check'");
602619
});
603620

604621
it('does NOT flag one default edge per node', () => {
605622
expect(lintFlowPatterns(guardFlow({ proceed: { isDefault: true } }))).toHaveLength(0);
606623
});
607624
});
625+
626+
/**
627+
* #4414 — `config.condition` on a node that never reads it.
628+
*
629+
* The key is LIVE on `start` (the trigger gate) and dead on every other
630+
* builtin. `app-todo`'s `check_recurring` carried one for years: a third copy
631+
* of a predicate its out-edges were already enforcing.
632+
*/
633+
function conditionNodeFlow(nodeType: string, config: Record<string, unknown>) {
634+
return {
635+
flows: [{
636+
name: 'cond_flow',
637+
nodes: [
638+
{ id: 'start', type: 'start', config: { objectName: 'todo_task', triggerType: 'record-after-update' } },
639+
{ id: 'n', type: nodeType, config },
640+
],
641+
edges: [{ id: 'e1', source: 'start', target: 'n' }],
642+
}],
643+
};
644+
}
645+
646+
describe('flow-inert-node-condition (#4414)', () => {
647+
it('flags `config.condition` on a decision, pointing at the out-edges', () => {
648+
const fnds = lintFlowPatterns(
649+
conditionNodeFlow('decision', { condition: 'vars.completedTask.is_recurring == true' }),
650+
).filter((f) => f.rule === FLOW_INERT_NODE_CONDITION);
651+
expect(fnds).toHaveLength(1);
652+
expect(fnds[0].where).toContain("node 'n' (decision)");
653+
expect(fnds[0].message).toContain('nothing reads it');
654+
expect(fnds[0].hint).toContain('isDefault');
655+
// Advisory: the surrounding edges usually still route correctly.
656+
expect(fnds[0].severity).toBeUndefined();
657+
});
658+
659+
it('flags it on a non-decision node too, with the generic hint', () => {
660+
const fnds = lintFlowPatterns(
661+
conditionNodeFlow('update_record', { objectName: 'todo_task', condition: 'a == b' }),
662+
).filter((f) => f.rule === FLOW_INERT_NODE_CONDITION);
663+
expect(fnds).toHaveLength(1);
664+
expect(fnds[0].hint).toContain("incoming edge's `condition`");
665+
});
666+
667+
it('does NOT flag the start node — that is where the key is read', () => {
668+
expect(lintFlowPatterns({
669+
flows: [{
670+
name: 'gated',
671+
runAs: 'system',
672+
nodes: [{ id: 'start', type: 'start', config: { triggerType: 'schedule', schedule: 'cron:0 9 * * *', condition: 'record.active == true' } }],
673+
edges: [],
674+
}],
675+
}).filter((f) => f.rule === FLOW_INERT_NODE_CONDITION)).toHaveLength(0);
676+
});
677+
678+
it('does NOT flag a node with no condition, or an empty one', () => {
679+
expect(lintFlowPatterns(conditionNodeFlow('decision', {}))).toHaveLength(0);
680+
expect(lintFlowPatterns(conditionNodeFlow('decision', { condition: ' ' }))).toHaveLength(0);
681+
});
682+
683+
it('does NOT flag a PLUGIN node type — its executor may legitimately read it', () => {
684+
// ADR-0018 keeps `node.type` open; we can only prove the key inert for the
685+
// builtins we ship.
686+
expect(lintFlowPatterns(conditionNodeFlow('acme_custom_step', { condition: 'a == b' }))).toHaveLength(0);
687+
});
688+
});

0 commit comments

Comments
 (0)