Skip to content

Commit 49aa77b

Browse files
os-warrenclaude
andauthored
fix(service-automation): evaluation refuses a malformed condition shape with registration's own refusal (#16438)
`evaluateCondition` derived `exprStr` with a `typeof` guard covering only the bare-string arm, so an envelope whose `source` was present and not a string became that value and `.trim()` threw a bare `TypeError` naming no flow, no node and no expression. Two sibling arms shared the same unguarded read: a value that is neither text nor envelope-shaped read as an EMPTY condition and answered a silent `false` — on the same key a start node's trigger gate is read from — and a malformed envelope under a non-predicate dialect answered `false` one statement earlier still, at the dialect check. #15662 closed this reject set at the producer; the evaluator was left disagreeing with it in a different vocabulary. Per the maintainer's ruling (decision batch #57, option A) evaluation now calls `structuralConditionRefusal` — the SAME constructor `registerFlow` calls, not a second hand-written envelope that could drift — as the method's first statement, above the dialect check so all three arms are covered. Controls pinned alongside: bare CEL text and both envelope spellings still evaluate; an `ast`-only envelope still answers `false` (that population is #15430/#15807's); a well-formed `cron` envelope still answers `false` rather than being refused; absent/`null`/empty/whitespace conditions are still "not authored"; and a malformed STRING still earns the brace trap or the §1c CEL fault, never the shape refusal. Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1875340 commit 49aa77b

3 files changed

Lines changed: 205 additions & 0 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
---
4+
5+
`evaluateCondition` now refuses a malformed condition shape with the same `STRUCTURAL_CONDITION_SHAPE_REFUSAL` registration already raises — evaluation and registration share one refusal, so a shape that slipped past registration can never surface as a raw `TypeError` or as a silent `false`.
6+
7+
#15662 closed the reject set at the producer: `registerFlow` refuses a structural condition (`config.condition` on a node, `edge.condition`) that is neither CEL text nor an expression envelope. The evaluator was left saying the opposite thing in a different vocabulary, and that half matters because `evaluateCondition` is a **public method on an exported class** — a plugin reaches it directly regardless of what `registerFlow` admits, and a flow stored before that gate landed replays through it.
8+
9+
The unguarded read had three arms, all of them now refused by the shared `structuralConditionRefusal` — the same call `registerFlow` makes, not a second hand-written envelope that could drift from it:
10+
11+
- an envelope whose `source` is present and **not a string** (`{ source: 1 }`, `{ dialect: 'cel', source: 1 }`) reached `.trim()` and threw `TypeError: exprStr.trim is not a function`, naming no flow, no node and no expression;
12+
- a value that is neither text nor envelope-shaped (`42`, `true`, `['a']`, `{}`, `{ dialect: 'cel' }`) was read as an **empty condition** and answered `false` — the "an unauthored branch must not open" rule applied to a value that was very much authored, on the same key a start node's **trigger gate** is read from;
13+
- a malformed envelope carrying a non-predicate dialect (`{ dialect: 'cron', source: 1 }`) answered `false` one statement earlier still, at the dialect check, never reaching the source derivation at all.
14+
15+
**What still evaluates is unchanged, and is pinned as controls.** Bare CEL text and both envelope spellings evaluate exactly as before; an `ast`-only envelope still answers `false`; a well-formed non-predicate dialect (`{ dialect: 'cron', source: '0 0 * * *' }`) still answers `false` rather than being refused; absent, `null`, empty and whitespace-only conditions are still "not authored", not malformed. A malformed **string** still earns its own verdict — the brace trap or the ADR-0032 §1c CEL fault — never the shape refusal.
16+
17+
An app whose stored flow carries one of the refused shapes in a node or edge `condition` now fails that run loudly with a message carrying the rule, instead of skipping a branch in silence or faulting unattributed; the fix is to write the condition as bare CEL text (`record.rating >= 4`) or as an expression envelope.

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8709,8 +8709,45 @@ export class AutomationEngine implements IAutomationService {
87098709
* throw: an explicit `dialect: 'cel'` is the author saying "this is CEL", and
87108710
* `{…}` is a map literal there. The sniff only applies where the dialect was
87118711
* never stated.
8712+
*
8713+
* ## The shape gate, shared with registration (#16038)
8714+
*
8715+
* The FIRST statement, above everything else, is `structuralConditionRefusal`
8716+
* — the same call `registerFlow` makes on the same slots, not a second
8717+
* hand-written envelope that would drift from it. #15662 closed the reject
8718+
* set at the producer; this closes it at the evaluator, so the two are one
8719+
* set by construction rather than by agreement. It matters because
8720+
* `evaluateCondition` is a public method on an exported class: a plugin
8721+
* reaches it directly regardless of what `registerFlow` admits, and a flow
8722+
* stored before that gate landed replays through here.
8723+
*
8724+
* It sits ABOVE the dialect check, not at the `exprStr` derivation, because
8725+
* the unguarded read has three arms and the derivation is only two of them:
8726+
* a non-string `source` threw a bare `TypeError: exprStr.trim is not a
8727+
* function` naming nothing; a value that is neither text nor envelope read
8728+
* as an EMPTY condition and answered `false` — on the same key a start
8729+
* node's trigger gate is read from; and a malformed envelope carrying a
8730+
* non-predicate dialect (`{ dialect: 'cron', source: 1 }`) answered `false`
8731+
* one statement earlier still, never reaching the derivation at all.
8732+
*
8733+
* What it does NOT refuse is what the constructor admits, and those are
8734+
* controls, not oversights: every string (a malformed one still earns the
8735+
* #1491 brace trap or the §1c CEL fault below), absent/`null`, and an
8736+
* envelope carrying a string `source` or an `ast` — the `ast`-only arm
8737+
* still falls through to `false`, since that population is #15430/#15807's
8738+
* and not this ruling's.
87128739
*/
87138740
evaluateCondition(expression: string | { dialect?: string; source?: string; ast?: unknown }, variables: Map<string, unknown>): boolean {
8741+
const shapeRefusal = structuralConditionRefusal(expression);
8742+
if (shapeRefusal) {
8743+
// ADR-0032 §1d — the error carries its source. `structuralConditionRefusal`
8744+
// attributes an empty one for the shape it is refusing here (a non-string
8745+
// `source` cannot be the attribution), which is its documented choice.
8746+
throw new Error(
8747+
`condition evaluation error: ${shapeRefusal.message} — source: \`${shapeRefusal.source}\``,
8748+
);
8749+
}
8750+
87148751
const isEnvelope = typeof expression === 'object' && expression != null && 'dialect' in expression;
87158752
const dialect = isEnvelope ? (expression as { dialect?: string }).dialect : undefined;
87168753
const exprStr = typeof expression === 'string' ? expression : ((expression as { source?: string })?.source ?? '');

packages/services/service-automation/src/structural-condition-shape.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,154 @@ describe('#15662 — a structural condition that is neither text nor an expressi
144144
});
145145
});
146146
});
147+
148+
/**
149+
* #16038 — the EVALUATION half of #15662's principle, ruled by the maintainer
150+
* on 2026-09-06 (decision batch #57, option A): `evaluateCondition` refuses a
151+
* malformed condition shape with the SAME `STRUCTURAL_CONDITION_SHAPE_REFUSAL`
152+
* registration already raises, so a shape that slipped past registration —
153+
* older stored data, or a direct caller of this public method on an exported
154+
* class — can never surface as a raw `TypeError`, nor as a silent `false`.
155+
*
156+
* ## The enumeration, measured on `3e7ef9c23` before the fix
157+
*
158+
* There is ONE unguarded read — `exprStr`, derived at the top of
159+
* `evaluateCondition` — and it has three distinct failure arms. Every helper
160+
* the method delegates to (`templateHoles`, `celScope`,
161+
* `refuseUnresolvedTemplateHole`, `refuseUnresolvedCelOperand`,
162+
* `compareValues`) is handed `exprStr` and nothing else, so a guard above the
163+
* derivation closes the whole delegation tree. One test per arm, below:
164+
*
165+
* - **A — a raw `TypeError`.** An envelope whose `source` is PRESENT and not a
166+
* string, under a predicate dialect. `?? ''` covers only absent/`null`, so
167+
* `exprStr` becomes the non-string value and `.trim()` throws
168+
* `TypeError: exprStr.trim is not a function` — naming no flow, no node and
169+
* no expression. This is the reported arm.
170+
* - **B — a silent `false`.** A value that is neither text nor envelope-shaped:
171+
* the read yields `undefined`, `?? ''` supplies the empty source, and the
172+
* "an unauthored branch must not open" arm answers `false` for a value that
173+
* was very much authored — on the same key a start node's TRIGGER GATE is
174+
* read from.
175+
* - **C — a silent `false` one statement earlier.** A malformed envelope
176+
* carrying a NON-predicate dialect (`{ dialect: 'cron', source: 1 }`) returns
177+
* `false` at the dialect pre-check, BEFORE the trim. This arm is why the
178+
* guard is the method's first statement rather than a patch at the reported
179+
* line: a fix written at `exprStr` never reaches it.
180+
*
181+
* ## The sibling value path is NOT a site — measured, not assumed
182+
*
183+
* `evaluateValueEnvelope` already derives its verdict from
184+
* `valueEnvelopeRefusals`, the same call `registerFlow` makes (#15137), so it
185+
* is already this shape one door over with its own shared constructor. Driven
186+
* before the fix, `{ source: 1 }`, `{ dialect: 'cel', source: 1 }`,
187+
* `{ dialect: 'cel', source: {} }`, `{ ast, source: 1 }`, `{ dialect: 'cel' }`,
188+
* `42`, `['a']` and `{}` each threw an ATTRIBUTED error leading with
189+
* `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` or a located CEL fault — zero raw
190+
* `TypeError`s. Nothing to move there, which is why nothing here does.
191+
*
192+
* ## No caller depended on the `TypeError`
193+
*
194+
* Also measured, because the ruling's landing shape turns on it: repo-wide,
195+
* every occurrence of `is not a function` on this path is PROSE recording the
196+
* pre-fix symptom, never an assertion and never a `catch` that branches. The
197+
* two engine-internal callers (the start gate and the edge gate) call it bare,
198+
* so a throw propagates to `execute()`'s catch and is recorded as a loud flow
199+
* failure — ADR-0032 §1c's prescribed handling, not a regression.
200+
*/
201+
const REFUSED_AT_EVALUATION: Array<[label: string, value: unknown, arm: string]> = [
202+
// Arm A — reached `.trim()` and threw a bare `TypeError`.
203+
['`{ source: 1 }` (the reproduction)', { source: 1 }, 'A'],
204+
['a `cel` envelope with a number source', { dialect: 'cel', source: 1 }, 'A'],
205+
['a `cel` envelope with an object source', { dialect: 'cel', source: {} }, 'A'],
206+
['a `template` envelope with a number source', { dialect: 'template', source: 1 }, 'A'],
207+
// Arm C — returned `false` at the dialect pre-check, one statement earlier.
208+
['a non-predicate-dialect envelope with a number source', { dialect: 'cron', source: 1 }, 'C'],
209+
// Arm B — returned `false` off the empty-source arm.
210+
['a number', 42, 'B'],
211+
['a boolean', true, 'B'],
212+
['an array', ['a'], 'B'],
213+
['an object that is neither', {}, 'B'],
214+
['an envelope with no source and no ast', { dialect: 'cel' }, 'B'],
215+
];
216+
217+
describe('#16038 — evaluation refuses the same shapes registration does', () => {
218+
const evaluate = (value: unknown) => () =>
219+
new AutomationEngine(silentLogger).evaluateCondition(
220+
value as never,
221+
new Map<string, unknown>([['record', { rating: 5 }]]),
222+
);
223+
224+
for (const [label, value, arm] of REFUSED_AT_EVALUATION) {
225+
it(`refuses ${label} (arm ${arm})`, () => {
226+
expect(evaluate(value)).toThrow(STRUCTURAL_CONDITION_SHAPE_REFUSAL);
227+
});
228+
}
229+
230+
it('never answers a malformed shape with a bare TypeError again', () => {
231+
// The filed symptom, asserted as an absence: the refusal must REPLACE
232+
// the `TypeError`, not sit beside it. Without this an implementation
233+
// that threw the refusal only on the `false` arms would pass every
234+
// assertion above except the arm-A rows.
235+
for (const [, value] of REFUSED_AT_EVALUATION) {
236+
expect(evaluate(value)).not.toThrow('is not a function');
237+
}
238+
});
239+
240+
it('attributes the refusal — ADR-0032 §1d, the error carries its source', () => {
241+
expect(evaluate({ source: 1 })).toThrow(/source:/);
242+
});
243+
244+
/**
245+
* The property the ruling is actually about, asserted mechanically rather
246+
* than described: ONE population walked through BOTH doors, refused by both
247+
* with the same published sentence. Two hand-written envelopes that drifted
248+
* apart would fail here while every per-site test above stayed green.
249+
*/
250+
it('the reject set of registration and the reject set of evaluation are ONE set', () => {
251+
for (const [label, value] of REFUSED_AT_EVALUATION) {
252+
expect(register(flowWith({ decisionCondition: value })), `registration: ${label}`)
253+
.toThrow(STRUCTURAL_CONDITION_SHAPE_REFUSAL);
254+
expect(evaluate(value), `evaluation: ${label}`)
255+
.toThrow(STRUCTURAL_CONDITION_SHAPE_REFUSAL);
256+
}
257+
});
258+
259+
describe('CONTROLS — the shapes evaluation must still answer, not refuse', () => {
260+
it('bare CEL text, and both envelope spellings, still evaluate', () => {
261+
expect(evaluate('record.rating >= 4')()).toBe(true);
262+
expect(evaluate({ source: 'record.rating >= 4' })()).toBe(true);
263+
expect(evaluate({ dialect: 'cel', source: 'record.rating >= 4' })()).toBe(true);
264+
});
265+
266+
it('an `ast`-only envelope still answers `false` — that population is #15430/#15807', () => {
267+
// `structuralConditionRefusal` admits an `ast`, so this must fall
268+
// through to the empty-source arm exactly as before. If this ever
269+
// throws, the guard swallowed a different card's population.
270+
expect(evaluate({ dialect: 'cel', ast: { kind: 'const' } })()).toBe(false);
271+
});
272+
273+
it('a WELL-FORMED non-predicate dialect still answers `false`, not a refusal', () => {
274+
// The arm-C boundary: `cron` is not a boolean predicate here, but a
275+
// string source makes the SHAPE authorable, so the pre-existing
276+
// `false` stands. Only the malformed spelling moved.
277+
expect(evaluate({ dialect: 'cron', source: '0 0 * * *' })()).toBe(false);
278+
});
279+
280+
it('an unauthored condition is not a malformed one', () => {
281+
expect(evaluate(null)()).toBe(false);
282+
expect(evaluate(undefined)()).toBe(false);
283+
expect(evaluate('')()).toBe(false);
284+
expect(evaluate(' ')()).toBe(false);
285+
});
286+
287+
it('a malformed STRING still earns its own verdict, not the shape refusal', () => {
288+
// RED CONTROL — the shape gate must not shadow the #1491 brace trap
289+
// or the §1c CEL fault. If this goes green the guard is refusing
290+
// strings, which `structuralConditionRefusal` admits by design.
291+
expect(evaluate({ dialect: 'cel', source: '{record.rating} >= 4' }))
292+
.toThrow(/template braces|failed to evaluate as CEL/);
293+
expect(evaluate({ dialect: 'cel', source: '{record.rating} >= 4' }))
294+
.not.toThrow(STRUCTURAL_CONDITION_SHAPE_REFUSAL);
295+
});
296+
});
297+
});

0 commit comments

Comments
 (0)