workflows: the planner must admit it cannot route, and a for node inverts the envelope rule - #147
Open
jklaassenjc wants to merge 3 commits into
Open
workflows: the planner must admit it cannot route, and a for node inverts the envelope rule#147jklaassenjc wants to merge 3 commits into
jklaassenjc wants to merge 3 commits into
Conversation
…erts the envelope rule Two of five findings from a self-directed test pass. A third is NOT here, and why is the most useful part of this commit. THE PLANNER SILENTLY DEFAULTED A DATA-DRIVEN SWITCH. pickBranch called evalBool and, on error, discarded it and fell through to the default branch. A `when` reading a prior step's response body cannot be evaluated without one — which is what all four corrected templates do — so the plan confidently named the default branch, and compare_run then reported every run that branched otherwise as ran-but-planned-skip. That is the verdict the tool's own how_to_read calls "the direction worth acting on", and unresolved stayed 0 while it happened. The proof it was not evaluating at all: the plan was IDENTICAL across two runs with opposite data, agreeing with the second by accident. The if-guard path had always reported this honestly, so there was a correct reference implementation sitting next to the defect. An unevaluable `when` now leaves that switch's targets unresolved rather than skipped, and the switch says "cannot route" with the expression and the evaluation error. A switch the planner CAN evaluate still routes, and a default is still chosen when every `when` evaluated and none matched — that is a decision, not an inability to decide, and both are pinned. A `for` NODE INVERTS THE ENVELOPE RULE. Established by running both outcomes: loop completed -> node_output NULL, iteration_count 7 loop failed -> node_output POPULATED, iteration_count 1 Every other node type means "it ran" by carrying node_output. The `for` type was not known to State() at all, so a completed loop fell through to unknown — safe, but useless — while the rule as written would have read a loop that iterated seven times as a step that never ran. On a fleet-wide sweep that is the difference between "we processed everyone" and "we processed nobody". An empty loop is now skipped rather than ran, since reporting a run would hide an empty extract. NOT SHIPPED: a lint for a branch target with no `then`. The report's rule — "once a switch routes, a branch target without an explicit then ends the run" — is over-general, and building the lint surfaced it: the warning fired on jc's own corrected deprovisioning template, where getManagerDetails is a branch target with no `then` and two email tasks after it. Rather than exempt the case that motivated the rule, I ran it. A workflow whose switch routes to a branch target with no `then`, followed by another task: [ok] route (Switch evaluated.) [ok] branchTarget GET /api/systemusers -> 200 [ok] afterBranchTarget GET /api/systemusers -> 200 Fall-through HAPPENS. The corrected template is fine and the lint would have fired on correct documents, which is how a check gets switched off. Something did stop that pass's `done` task from running, and diagnosing it needs their DSL — but it is not the stated rule. Both shipped fixes mutation-verified. Tenant left clean. Refs KLA-485 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2jW5Bj6eH38HMLG2noMym
…ritten down
The last two findings from the self-directed pass.
`.extracted` IS NOT A FIELD. A for.in of ${ actions.listUsers.extracted }
failed at run time with "no value found for actions.listUsers.extracted"
and iterated ZERO times, while the bare task name over the same extract
iterated seven. Validate accepted both, so the only signal was a failed
run.
The mistake is a natural analogy — actions.X.body is real, so
actions.X.extracted looks like it should be — and it was made
independently twice in this project.
Only that suffix is flagged, deliberately. actions.X.body is legitimate
and the corrected templates use it, and which other paths a for.in
accepts has not been established, so banning suffixes generally would
reject working documents. The unknown-task case was ALREADY caught; this
closes the case where the task is real and the field is not.
The hint carries the working form. Telling an author what is wrong
without telling them what is right is half a finding.
LOOP HALT-ON-ERROR IS NOW DOCUMENTED, in the `jc workflows` long help
where someone authoring a sweep will meet it, and beside the existing
engine notes in the corrected-templates package.
A for.each does not isolate its iterations. A sweep over 500 users dies
on the first bad record: later iterations never happen, every task after
the loop is skipped, and the run reports one failure that says nothing
about how much of the fleet went untouched. Verified live — a loop over
three ids with a nonexistent one in the middle reported iteration_count
1, failed_at_iteration 2, and never attempted the third.
None of the twelve shipped templates loops over a fallible call, so
nobody copying them learns this. The mitigation is the idiom the
corrections already use, applied inside the loop: branch with switch/when
BEFORE the fallible call; an `if` after it is dead code because the run
is already over.
Documentation rather than a lint, which is what was asked for. A warning
on every loop containing a fallible call would fire on zero shipped
templates and on every real sweep — worth considering, but it is a
judgement about how much noise a true statement is worth, and that is not
mine to make unilaterally.
Both mutation-verified. docs/site regenerated.
Refs KLA-485
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2jW5Bj6eH38HMLG2noMym
jtaylorjc
previously approved these changes
Sep 10, 2026
…bad records
The lint I declined to ship, now shipped — and on its first run against the
template catalog it found a defect in a shipped template.
A for.each does not isolate its iterations. I verified this myself rather
than take the report's word, having accepted its branch-target rule
without testing and found that one wrong:
[FAILED] sweep For loop failed at iteration 2 ... 404 Not Found
[skipped] afterLoop (not reached — the run failed at an earlier task)
Three ids, the middle one nonexistent. The third was never attempted and
the task after the loop never ran. A sweep over 500 users dies on the
first bad record and reports one failure that says nothing about how much
of the fleet went untouched.
The rule fires on a jc_operation or connector_operation inside a loop
body with nothing to filter out bad records, and STAYS QUIET when the
author has already pre-filtered:
an `if` ON the call is a real pre-filter — it decides from data already
in hand. (An `if` AFTER the call is dead code; the run is over by then,
and the hint says so.)
a `switch` earlier in the body routes past the record entirely.
Doing the right thing silences it. A warning nobody can satisfy is one
people learn to scroll past, and then it protects nothing. One warning
per loop, not one per call: three unguarded calls is one problem with one
fix.
WHAT IT FOUND. "Notify Users with Non-Compliant Endpoints" loops over
policy results and calls getApiSystemsById, getApiSystemusersById and a
group-add on each, all unguarded. One system deleted between the policy
result being recorded and the workflow running kills the entire
notification sweep, silently, partway through.
No corrected copy is offered, and that is deliberate. The status==200
family was correctable because the fix was a deletion. This is not: you
cannot pre-filter a 404 on a system whose existence you can only
establish by calling it. The mitigation is architectural, and inventing
one and shipping it as jc: would be a guess dressed as a repair.
Also recorded while probing: for.in must be a STRING expression. A literal
JSON array is rejected at create time with "cannot unmarshal array into
Go struct field ForTaskConfiguration.for.in of type string".
Mutation-verified. Templates now lint 16 checked / 11 clean / 5 warnings /
0 errors — the fifth is the finding above, not a regression.
Refs KLA-485
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2jW5Bj6eH38HMLG2noMym
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two of five findings from a self-directed test pass. A third is deliberately not here, and why is the most useful part of this PR.
1. The planner silently defaulted a data-driven switch — the one worth acting on
pickBranchcalledevalBooland, on error, discarded it and fell through to the default branch.A
whenreading a prior step's response body cannot be evaluated without one — which is what all four corrected templates do. So the plan confidently named the default branch, andcompare_runthen reported every run that branched otherwise asran-but-planned-skip.That is the verdict the tool's own
how_to_readcalls "the direction worth acting on", andunresolvedstayed 0 while it happened.The proof it was not evaluating at all: the plan was identical across two runs with opposite data, agreeing with the second by accident.
The
if-guard path had always reported this honestly, so there was a correct reference implementation sitting next to the defect. Now:whenleaves that switch's targets unresolved, not skippedcannot route:with the expression and the evaluation errorwhenevaluated and none matched — that is a decision, not an inability to decideThe last two are pinned, because a fix that blinded the planner to all switches would be worse than the bug.
2. A
fornode inverts the envelope ruleEstablished by running both outcomes:
node_outputiteration_countEvery other node type means "it ran" by carrying
node_output.forwas not known toState()at all, so a completed loop fell through tounknown— safe, but useless — while the rule as written would read a loop that iterated seven times as a step that never ran. On a fleet-wide sweep that is the difference between "we processed everyone" and "we processed nobody".An empty loop is now
skippedrather thanran, since reporting a run would hide an empty extract.3. Not shipped: the branch-target lint, because the rule is wrong
The report proposed linting a branch target with no
then, on the rule "once a switch routes, a branch target without an explicitthenends the run".Building it surfaced the problem immediately: the warning fired on jc's own corrected deprovisioning template, where
getManagerDetailsis a branch target with nothenand has two email tasks after it.Rather than exempt the case that motivated the rule, I ran it. A workflow whose switch routes to a branch target with no
then, followed by another task:Fall-through happens. The corrected template is fine, and the lint would have fired on correct documents — which is how a check gets switched off.
Something did stop that pass's
donetask from running, and diagnosing it needs their DSL. But it is not the stated rule, and shipping a lint built on it would have made every converging workflow noisy.Verification
Both shipped fixes mutation-verified.
go test ./...green. Tenant left clean — the probe workflow was created, run, and deleted.Refs KLA-485
🤖 Generated with Claude Code