Skip to content

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
mainfrom
juergen/workflows-for-node-and-switch-plan
Open

workflows: the planner must admit it cannot route, and a for node inverts the envelope rule#147
jklaassenjc wants to merge 3 commits into
mainfrom
juergen/workflows-for-node-and-switch-plan

Conversation

@jklaassenjc

Copy link
Copy Markdown
Collaborator

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

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. Now:

  • an unevaluable when leaves that switch's targets unresolved, not skipped
  • the switch reports cannot route: with the expression and the evaluation error
  • a switch the planner can evaluate still routes
  • a default is still chosen when every when evaluated and none matched — that is a decision, not an inability to decide

The last two are pinned, because a fix that blinded the planner to all switches would be worse than the bug.

2. A for node inverts the envelope rule

Established by running both outcomes:

outcome node_output iteration_count
loop completed null 7
loop failed populated 1

Every other node type means "it ran" by carrying node_output. for was not known to State() at all, so a completed loop fell through to unknown — 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 skipped rather than ran, 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 explicit then ends the run".

Building it surfaced the problem immediately: the warning fired on jc's own corrected deprovisioning template, where getManagerDetails is a branch target with no then and 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:

[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, 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

…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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Development

Successfully merging this pull request may close these issues.

3 participants