Reproduction steps:
- A for_each effect node whose list comes from a PRIOR node's output (so it
cannot resolve at plan time — effect/http/model nodes do not run then), with
a declared for_each_max_size:
node = {"id":"charge_each","type":"effect","for_each":"{{nodes.fetch.rows}}",
"for_each_max_size":1,"provider":"stripe","verb":"charge_create",
"action_class":"compensable","args":{"amount_cents":5000}}
- Plan-time estimate:
workflow_engine._estimate_for_each_iterations(node, {}, {}) # -> (1, False)
- Runtime fan-out (exactly run_workflow's lines ~845-847), with fetch's output
present:
resolved = workflow_engine._resolve(node["for_each"],
{"fetch":{"rows":list(range(10000))}}, {})
items = resolved if isinstance(resolved, list) else [resolved] # -> 10000
Expected:
The number of governed effects the run executes matches what the human approved
and what plan_pin sealed. If for_each_max_size is an "upper bound" (the
estimator's own words), the runtime must not exceed it without re-approval.
Actual:
Plan : _estimate_for_each_iterations -> 1 iteration -> blast radius = 1 x $50
-> the human approves ONE $50 charge; the require_human floor is NOT
tripped (a small BOUNDED estimate, not "unbounded").
Run : items = the full resolved list = 10,000 -> 10,000 governed charges
($500,000). for_each_max_size is never consulted in the fan-out loop.
for_each_max_size bounds only the plan-time estimate, so the human-facing blast
radius AND plan_pin's approved-spend ceiling both under-state the run. Because
the run-time re-plan reproduces the same estimate of 1 (fetch still hasn't run
at re-plan), plan_pin.check() also passes. Absent a separately declared
capabilities.max_spend_cents (which is optional), nothing caps the fan-out — a
workflow approved for one $50 charge fires an arbitrary number of real charges/
sends. Same class as the platform's highest-consequence "plan misrepresents
actual execution" bugs.
Suggested fix:
Make the declared bound load-bearing at runtime. In run_workflow's fan-out,
after resolving the list, refuse (or require re-approval) when it exceeds the
author-declared for_each_max_size — the value the plan/approval was computed
against:
if n.get("for_each"):
resolved = _resolve(n["for_each"], outputs, ctx)
items = resolved if isinstance(resolved, list) else [resolved]
_hint = n.get("for_each_max_size")
if isinstance(_hint, int) and _hint > 0 and len(items) > _hint:
raise ForEachBoundExceeded(
f"node {n['id']}: for_each resolved to {len(items)} items but the "
f"approved plan covered for_each_max_size={_hint} — re-approve for "
f"the larger fan-out")
Alternatively, treat a runtime-resolved (non-literal) for_each with no bound as
UNBOUNDED at plan time (estimator case 5) even when for_each_max_size is
declared, unless the runtime enforces that bound — so the plan cannot show a
small bounded spend the run will exceed. Either way plan and run must agree on
how many effects fire