service-automation: refuse a structural flow condition that is neither CEL text nor an expression - #15792
Conversation
…e two new automation exports
…ructural-condition-refusal
TS7030 — the IIFE had a fall-through path with no return. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift CheckThis PR changes 3 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 129 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 71b45f6bfc17658ba5f13dc92a777332437d20bf && git checkout 71b45f6bfc17658ba5f13dc92a777332437d20bf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5315098dfb55d19ea79bd27c28b3864f32f8fad9 77142e3fa40a1357830e8cc496c55c0e558d2629 && git checkout -B drift-repro 5315098dfb55d19ea79bd27c28b3864f32f8fad9 && git merge --no-ff 77142e3fa40a1357830e8cc496c55c0e558d2629
node scripts/docs-audit/affected-docs.mjs --json 5315098dfb55d19ea79bd27c28b3864f32f8fad9
|
Clause-② contract review — PR #15792 (card #15662), head
|
| surface | authored | post-parse | reaches the engine arm as |
|---|---|---|---|
top-level edge.condition |
"record.status == 'x'" |
{dialect:'cel', source:…} |
envelope — every authored string |
loop.config.body.edges[].condition |
'it.x == 1' |
{dialect:'cel', source:…} |
envelope (region edges are z.array(z.lazy(() => FlowEdgeSchema)), control-flow.zod.ts:144,243) |
top-level edge.condition |
42, true, ['a'], '', {}, {dialect:'cel'}, {source:1}, {source:'x'} (no dialect), {dialect:'cel', source:''}, {dialect:'js', source:'x'} |
— | all refused by ExpressionInputSchema at parse |
node config.condition (top-level and loop-body node) |
string / envelope / 42 / true / ['a'] / {source:1} / {source:'x'} / ast-only |
verbatim | the open z.record lowers nothing |
evaluateCondition |
{dialect:'cel', source:'1 == 1'} → true; {source:'1 == 1'} → true; 42/true/['a']/' '/{dialect:'cel', source:' '} → false; {source:1} → TypeError: exprStr.trim is not a function (pre-fix path, now intercepted) |
⇒ Two shapes are legitimate on the arm; the PR's admit-set follows from the measurement, not the other way round. I tried to find an authored shape the measurement missed and found two adjacent facts, neither of which changes the admit-set:
- Region edges survive
FlowSchema.parseraw.parseFlowNodeRegionsis lenient (slot.schema.safeParse(slot.raw); if (!parsed.success) continue;), so a loop-bodyedge.condition: 42is not refused by the parse — it is refused one step later byvalidateControlFlow's re-parse (loop 'lp' body: invalid region — Invalid input), whichcanonicalizeStoredFlowruns beforevalidateFlowExpressions. So the engine's edge arm stays unreachable throughregisterFlow, but the PR's sentence "already refused byExpressionInputSchemaatFlowSchema.parse" is the top-level story; for a region the refusing layer isvalidateControlFlow(note 3). {dialect:'cron'|'template', source}passes the parse at an edge and thez.recordat a node, is admitted by the structural refusal (stringsource), and is refused by the pre-existingcheck()pass — measured:edge 'e1' (start→branch) condition: expected a CEL expression but got a \cron` dialect. The silentfalseevaluateCondition` answers for a cron envelope is therefore unreachable through registration. Not this card's class.
2. M4 — reproduces, and it is the measurement that matters
Each spec-side leg: mutate → tsup (JS pass) → ablation-dist-preflight present-mode → suites → restore. Marker proved in 2 built files (dist/automation/index.js, index.mjs; 2 sourcemap hits not counted) on both legs.
| leg | spec | service-automation | lint | what went red |
|---|---|---|---|---|
M1 (structuralConditionRefusal → always undefined) |
2 | 9 | 5 | only refusal pins; every control green (envelope, no-dialect, whitespace, brace-trap RED CONTROL) |
M4 (arm := predicateSlotRefusal, the ledger rule) |
5 | 9 | 6 | the envelope controls, in both consumers: automation "an envelope on an EDGE still registers" + "an expression ENVELOPE at config.condition still registers"; lint "an envelope on an EDGE is legitimate" + "an expression ENVELOPE on a node condition is legitimate here" |
Numbers match the PR's table exactly, and the M4 reds are the right ones: the automation edge control is fed a plain string '1 == 1' and still goes red, because post-parse it is an envelope — that is "every conditional edge in every flow", shown rather than argued. The two rules are not interchangeable; the separate refusal is not duplication.
Restore proven: source blob 5e34032f4 == HEAD, full spec rebuild (34/34 declarations back), --absent preflight for all three markers → "absent from all 217 built files", whole-tree git status --porcelain empty.
3. Admit-set boundaries — hold; the refine was read, not re-derived
Grid driven through ExpressionSchema.safeParse, ExpressionInputSchema.safeParse, structuralConditionRefusal, predicateSlotRefusal:
- Admitted here:
'x','',' ',undefined,null,{dialect:'cel', source:'x'},{source:'x'},{dialect:'cel', ast:{}},{ast:{}},{dialect:'cel', ast:null},{dialect:'cel', source:''},{dialect:'cel', source:' '},{dialect:'js'|'cron', source:'x'}. - Refused here (attribution
source: ''on every one):42,0,NaN,true,false,['a'],[],new Date(), a function, a symbol, a bigint,{dialect:'cel'},{},{source:1},{source:['x']},{dialect:'cel', ast:undefined}. expression.zod.ts:88refine is exactlye.source !== undefined || e.ast !== undefined; the refusal'stypeof rec.source === 'string' || rec.ast !== undefinedis that refine with the field's own string-ness folded in — read, not re-derived.ast: nullis admitted by both (null !== undefined), as the refine says.- The only deltas from
ExpressionSchemaare (i)source: ''— admitted here as a string, refused there bymin(1); consistent with the card's "strings untouched" ruling, and at an edge unreachable anyway (parse refuses'', lowers' '); (ii)dialectmissing/invalid — the deliberate deference, and the invalid case is caught by the existingcheck()pass (above). {source:1}atregisterFlow→ the located structural refusal, noTypeError(verified through the engine, not only the unit).
Dialect precision (note 2): "an envelope without one is CEL, which is what evaluateCondition already does with it" is slightly stronger than the code. isEnvelope requires a dialect key, so {source} is read exactly as the bare string is — sniffed for {var} holes. Measured: {source:'{x} == 1'} took the legacy template path (\{x}` did not resolve), {dialect:'cel', source:'{x} == 1'} took CEL (Expected COLON, got RBRACE`). Same outcome as the bare-string spelling (#4336), both loud; no silent case is introduced. The docblock could say "read as the bare string is" rather than "is CEL".
4. The ast admission — reasoning holds, datum verified, no duplicate card
- Datum re-driven:
{dialect:'cel', ast:{kind:'const', value:true}}→false;{ast:…}(no dialect) →false; the same envelope registers at a decision node and at an edge.evaluateConditionreadsexpression?.source ?? ''and neverast— the quiet half of spec/formula:ExpressionSchemaaccepts anast-only envelope that no engine can evaluate — it validates, it registers, it faults at run time #15430's class, exactly as the comment says. - spec/formula:
ExpressionSchemaaccepts anast-only envelope that no engine can evaluate — it validates, it registers, it faults at run time #15430 comment5550509137exists, is framed as a datum only, and names the one clause (rec.ast !== undefined) to revisit. Issue search for the class returns only service-automation:evaluateConditionanswers a silentfalsefor a non-string predicate, and a non-stringconfig.conditionregisters clean #15662, service-automation: adecisioncondition accepts a CEL envelope that neither validator can see — a malformed one evaluates tofalseSILENTLY at run time and takes the wrong branch #15572 and the closed ancestors (Bare-string flow conditions bypass the CEL engine and silently string-compare — wrong branches, no error #4336, applyConversionsToFlow does not recurse into loop bodies — conditions inside aloopare never converted to CEL and the gate silently never opens #4347, flow designer: the Start node's Entry condition is raw-CEL-only — ConditionBuilder ships in five other inspectors but not the one condition most users meet #11940): no duplicate card. - The deference is correct: the seat's own claim on spec/formula:
ExpressionSchemaaccepts anast-only envelope that no engine can evaluate — it validates, it registers, it faults at run time #15430 (5549282927) scopes disposition 1 to the evaluated slots inbuiltin-node-config.zod.tsand leavesExpressionSchemaalone; refusingasthere would decide that from the consumer side.
5. Reachable vs defensive — both halves verified
- Engine edge arm — defensive, unpinned, and that reading is right.
validateFlowExpressionshas exactly one caller (registerFlow, onparsed). Top-level non-envelope values are refused byExpressionInputSchema; region edges byvalidateControlFlow(§1). Extra leg M2e (un-wire the engine edge call site) → 13/13 green — the unpinned state made concrete. Engine node arm: M2 → 9 red, matches. - Lint edge arm — reachable, pinned, discriminates.
validateStackExpressionsiteratesrecordsOf(stack.flows)with noFlowSchema.parseon the way — the raw authored stack. M3b → exactly 1 red (the edge pin); M3a → 4 red with the edge pin green. Both match. - Bonus reach not claimed by the PR: a loop-body node
config.condition: 42→ the structural refusal, scope-labelled, atregisterFlow. Not pinned for a region in either consumer; minor, since the walk iscollectFlowGraphs's and applyConversionsToFlow does not recurse into loop bodies — conditions inside aloopare never converted to CEL and the gate silently never opens #4347 pins that walk.
6. Void leg — the safety net really refuses it
ablation-dist-preflight --self-test → all cases pass (incl. "sourcemap-only hit is RED" and the filesystem leg). Real reproduction: comment-only marker → tsup → preflight exit 1: "marker found ONLY in 2 sourcemap files and in no executable output … Treat this run as void." Marker moved into executable code → exit 0, 2 built files. Other cards in this lane can lean on it.
7. Generated baselines — genuine
gen:api-surface ("17 entries, 5276 exports") and gen:export-origins re-run; both files hash identical to HEAD (3a9077df7, d1c390eac), porcelain clean. For the record: the generator refuses to write against a declaration-less dist ("holds no .d.ts declarations") — a cache-restored spec dist had none; after a real build the output was byte-identical.
8. Gate accounting — reproduced; none masks a failure
| gate | my exit | the script's own words |
|---|---|---|
check:test-completeness |
3 | "NOT MEASURED … not a red, and there is nothing here to fix" (EXIT_PREREQUISITE_NOT_MET = 3) |
pm/check-half-states |
3 | "unread instrument, never a quiet board" |
check:partof-closing-keyword |
2 → 0 with PR_BODY |
"NOT WIRED … judged nothing" → "carries no Part-of/closing-keyword contradiction" (body reconstructed from the API read — gh is absent in this sandbox — content-equivalent for what the gate reads: Fixes #15662, no Part-of) |
check:single-claim-paths |
2 | "NOT WIRED — PR_NUMBER is not set" |
check:react-declaration-parity |
1 | cannotRun: "MANIFEST is not set … This gate did NOT run. That is a failure, not a skip (#4690)" — NOT MEASURED is right about what it says of this diff; the script itself insists the reading stays red until CI's manifest supplies the registry side |
check-dev-prereqs |
1 | "65 of 67 workspace packages declare an entry point under dist/ that is not on disk" in my fresh tree (dev: 46/67) — a property of the worktree |
pr-labels.mjs |
1 (usage) / 0 --self-test |
"VERDICT: pr-labels self-test PASSED" |
check:nul-bytes |
0 | 7621 files scanned |
Head baselines: spec targeted file 25/25, structural-condition-shape.test.ts 13/13, validate-expressions.test.ts 282/282.
Notes (PASS with it noted)
- PR body §4: the lint block is 10 cases by vitest's own count (PR says 11); spec 6 and service-automation 13 are right.
flow-node-expression-paths.tsdocblock / PR §2: a dialect-less envelope is read as the bare string is (template-sniffed), not unconditionally as CEL — same behaviour, both loud.- PR §"defensive only": for region edges the refusing layer is
validateControlFlow, notFlowSchema.parse. - Boundary for the record:
{dialect:'cel', source:''}atconfig.conditionregisters and answersfalse— the whitespace-string ruling applied;ExpressionSchemawould refuse it (min(1)). If spec/formula:ExpressionSchemaaccepts anast-only envelope that no engine can evaluate — it validates, it registers, it faults at run time #15430's disposition 1 narrows blank sources on evaluated slots, this is the second line to revisit beside theastclause.
NOT MEASURED by this review: the full three-package suites (the 12717 / 1316 / 3337 totals are the dev's; I ran the three targeted files, the lint file whole), typecheck, repo-wide pnpm lint, and the ~44 gates outside the nine above (ADR-0087 was PM-verified). Limit: a sibling agent held the heavy-verify lock throughout; my single-file vitest runs and builds were unlocked.
Generated by Claude Code
Generated by Claude Code
PM note on landing — four precision corrections, none blockingPosted by the
What the review added beyond confirming the PR⭐ M2e — a mutation the reviewer invented to test the PR's own claim. The PR declares the engine's edge arm defensive only and therefore deliberately unpinned. Rather than accept that, the reviewer un-wired the arm and got 13/13 green — confirming it is unpinned exactly as declared — and separately confirmed it is unreachable through ⭐ M4 reproduced exactly — spec 5 / automation 9 / lint 6, with the envelope controls red in both consumers. One detail is the blast-radius finding demonstrating itself: the automation edge control fed a plain ⭐ The void-leg safety net was reproduced from both ends: the preflight's own self-test asserts "a sourcemap-only hit is RED", and the real case reproduces — a comment-only marker survives only into 2 sourcemap files after ⭐ Baselines regenerate byte-identical ( NOT MEASURED by the review (the dev's own numbers stand for these): the full three-package suites, Generated by Claude Code |
…cord-field (resolve validate-expressions.test.ts with #15792) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
Fixes #15662
evaluateConditionderives its source astypeof expression === 'string' ? expression : (expression?.source ?? ''). For a value that is neither a string nor envelope-shaped the read yieldsundefined, the??supplies'', and the empty-source arm returnsfalse— the documented "an unauthored branch must not open" rule, applied to a value that was very much authored.config.conditionis also the key a start node's trigger gate is read from, so a value like42there could gate a whole flow shut forever with nothing said at any layer.1. Blast radius — measured first, before any refusal was written
The card named this as the trap and it is the deliverable that decides the rule. Driven on this branch's base (
AutomationEngineconstructed directly;FlowSchema.parse,canonicalizeStoredFlow/graftConditionEnvelopes,registerFlowandevaluateConditionall exercised), not inferred from the code:FlowSchema.parseedge.condition"record.status == 'x'"{"dialect":"cel","source":"record.status == 'x'"}edge.condition42,['a']config.condition"record.rating >= 4""record.rating >= 4"z.record)config.condition{dialect:'cel',source:'…'}canonicalizeStoredFlowconfig.conditionevaluateCondition, same run:{dialect:'cel',source:'1 == 1'}→true;{source:'1 == 1'}(no dialect) →true;42→false;true→false;['a']→false;{source:1}→TypeError: exprStr.trim is not a function. ThroughregisterFlow, adecisionnode withconfig: { condition: 42 | true | ['a'] }→ REGISTERED, and the same values on the start node → REGISTERED.⇒ Two shapes are legitimate on this arm: bare CEL text, and the expression envelope. The envelope is not a tolerated accident — after the parse it is the only shape an edge condition ever has, and at
config.conditionit is accepted by the schema and evaluated correctly by design (#4336: the dialect is decided by the source, so both spellings evaluate the same).2. The refusal, and why it is not the ledger arm's rule
STRUCTURAL_CONDITION_SHAPE_REFUSAL/structuralConditionRefusal, new in@objectstack/spec/automationbesidepredicateSlotRefusal. One notion, derived once, read byregisterFlowand byobjectstack validateso build time and author time cannot disagree about the shape.null; any object carrying a stringsourceor anast—ExpressionSchema's ownsource-or-astrule, read rather than re-derived, withdialectoptional becauseevaluateConditionalready treats a dialect-less envelope as CEL.{ source: 1 },{ dialect: 'cel' }with no source and no ast,{}.⛔
PREDICATE_SLOT_STRING_REFUSAL(#15572) was not copied onto this arm. That rule refuses every non-string because those slots are declaredz.string();FlowEdgeSchema.conditionisExpressionInputSchemaandFlowNodeSchema.configis an openz.record, so applying it here would refuse every conditional edge in every flow. That is not an argument — it is mutation M4 below, which drives exactly that substitution and turns the envelope controls red in both consumers.Two boundaries held, deliberately:
falseon both sides. Ruled correct, pinned as a control, untouched.packages/spec/src/shared/expression.zod.tsis not touched (spec/formula:ExpressionSchemaaccepts anast-only envelope that no engine can evaluate — it validates, it registers, it faults at run time #15430's surface), and neither isevaluateCondition's empty-source arm.Secondary fix in the same class:
{ source: 1 }used to reachexprStr.trim()and throw a bareTypeErrorout of the validator — a refusal by accident, with no location and no rule. It is now the located refusal, pinned.3. Trigger gate
Covered. The gate is
startNode.config.condition, read byresolveTriggerBinding; it is the same key the node arm walks, so the refusal reaches it before a binding is ever built. Pinned separately from the decision-node case at bothregisterFlowandobjectstack validate, and mutation M2 shows those start-node pins go red on their own.4. Tests, and the mutation that proves each one discriminates
New:
packages/services/service-automation/src/structural-condition-shape.test.ts(13), astructuralConditionRefusalblock inpackages/spec/src/automation/flow-node-expression-paths.test.ts(6), and astructural condition shape (#15662)block inpackages/lint/src/validate-expressions.test.ts(11) — each with a RED CONTROL (the brace-trap string on the same slot, through the same call) so a zero elsewhere in the block cannot be a harness that reached nothing.@objectstack/specis consumed through itsexportsby both other packages (neither aliases it invitest.config), so every cross-package leg rebuilds and proves the marker indist/viascripts/ablation-dist-preflight.mjsbefore its colour is read. Each mutation was proved on disk first (injected-text and removed-anchorgrep -c, not a baregit diff --stat), carried atrap … EXIT INT TERM, and its restore was proved by a whole-treegit status --porcelainplus agit hash-objectvsHEADblob comparison.structuralConditionRefusalreturnsundefinedalwayspredicateSlotRefusal(the ledger rule)./engine.js)A first attempt at M1 was recorded as void, not re-rolled quietly: the marker was a comment, tsup stripped it, and
ablation-dist-preflightrefused the run ("marker found ONLY in 2 sourcemap files … treat this run as void"). The marker was moved into executable code and the leg re-run.Restore leg: spec rebuilt from
HEADandablation-dist-preflight … --absentverified for both markers (✓ marker absent from all 217 built files,✓ working tree clean against HEAD).All at head
77142e3fa, every heavy run serialized throughscripts/pm/os-verify-lock.sh(verdict line read, never a bare$?):pnpm --filter @objectstack/spec test→ 473 files, 12717 passed, exit 0pnpm --filter @objectstack/service-automation test→ 110 files, 1316 passed, exit 0pnpm --filter @objectstack/lint test→ 97 files, 3337 passed, exit 0pnpm --filter … typecheckfor all three → exit 0. Not a vacuous green:service-automation'stsc --noEmitdid compile the new test file — it caught aTS7030in it, which is fixed in the last commit.pnpm lint(eslint . --no-inline-config, the whole repo, not a narrowed subset) → exit 05. Gates
Family re-derived from the real change set with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(the--repoassertion holds against this checkout'sorigin), not from a hand-written list — and re-run on the final head77142e3faafter the last commit, so the ratchet readings are about the tree that is actually here. 53 gates +check:nul-bytes+ the ADR-0087 control; exit codes captured by redirect, never through a pipe. 48 exit 0. The other 7 are prerequisite/wiring states, each recorded as what it is rather than as a pass:check:test-completeness→ exit 3 = NOT MEASURED (noturbo run testlog to parse; its own text: "not a red, and there is nothing here to fix").pm/check-half-states→ exit 3 = NOT MEASURED (unread instrument, not a quiet board).check:partof-closing-keyword,check:single-claim-paths→ exit 2 = NOT WIRED (noPR_BODY/PR_NUMBERlocally). The first was then re-run withPR_BODYset to this text: exit 0, "this PR carries no Part-of/closing-keyword contradiction".check:react-declaration-parity→ NOT MEASURED:MANIFEST is not set — this gate did NOT run; the registry side is objectui's browser-producedsdui.manifest.json, which CI supplies.check-dev-prereqs→ exit 1 reporting 46 of 67 workspace packages have nodist/on disk. That is a property of a fresh per-task worktree in which only this change's closures were built, not of this diff.pr-labels.mjs→ needsPR_NUMBER; its--self-testcontrol passed.Controls checked rather than assumed:
check-adr-0087-registration --self-test→ exit 0 (and the gate itself → exit 0),pr-labels --self-test→ PASSED.Generated artifacts regenerated with the
gen:*commands the gates name, never hand-edited:packages/spec/api-surface/automation.jsonandpackages/spec/export-origins/automation.json(+2 exports each, additive).check:api-surface,check:export-origins,check:generated,check:liveness,check:spec-changesall exit 0.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code