← Community
bugfixed

/api/workflow/plan/reapprove raises NameError on a missing workflow: it calls `_refused()`, a closure defined inside a different function

ShwetaShweta#139d ago · 78 views
affected: station-v0.97fixed in: station-v1.3

station-v0.97 adds sealed refusal receipts to the live dag-run path: every
pre-run gate that refuses now mints a receipt so a schedule row saying REFUSED
can point at why. The helper that does this, _refused(payload, gate), is
defined as a nested closure inside _handle_dag_run (routes/dispatch_workflow.py
line 683), because it closes over that handler's local dry_run, channel and
body.

Nine of its ten call sites are inside _handle_dag_run, where the closure is in
scope. The tenth is at line 578, inside a different function entirely —
_handle_reapprove_plan:

cp = os.path.join(WS, "workflows", wf_id + ".json")
if not os.path.isfile(cp):
return _refused({"ok": False, "error": "no such workflow on disk"},
gate="missing_workflow")

_refused is not defined at module level and is not in this module's PEP-562
_LATE late-binding list, so the bare name resolves against module globals()
and raises NameError. The module __getattr__ cannot rescue it: that mechanism
fires only for external attribute access (dispatch_workflow._refused), never
for a bare name inside a function defined in the same module — the same scoping
mechanism behind the earlier _MODEL_TIER_MAP crash in routes/llm.py.

Reproduction steps:

  1. From an authenticated Studio session (the route requires channel "session"),

POST /api/workflow/plan/reapprove with a body whose id matches
^[A-Za-z0-9_-]{1,80}$ but has no corresponding file in WS/workflows/ —
for example {"id": "no_such_workflow"}. A workflow that was deleted, or a
mistyped id, reaches the same line.

  1. Observe the response and the server log.

Static confirmation without a running server: parse
routes/dispatch_workflow.py with ast, collect every FunctionDef named
_refused and every Call to the bare name _refused, and compare their
enclosing top-level functions. The single definition is nested in
_handle_dag_run; the call at line 578 is enclosed by _handle_reapprove_plan.
_refused appears in neither the module-level function names nor _LATE.

Expected:

The documented response for this case — {"ok": false, "error": "no such
workflow on disk"} — matching how the sibling handler _handle_dag_run reports
the identical condition, and matching every other validation failure in this
route.

Actual:

NameError: name '_refused' is not defined, raised out of the handler. The
caller gets an unhandled server error instead of the intended error object, so
"this workflow id does not exist" is indistinguishable from a real server
fault.

Root cause:

routes/dispatch_workflow.py — _refused() is defined at line 683 as a closure
inside _handle_dag_run, but is called at line 578 from
_handle_reapprove_plan. The refusal-receipt change appears to have been
applied to both occurrences of the "no such workflow on disk" refusal, while
the helper it introduced is reachable from only one of them.

A second, opposite-direction facet of the same edit is worth fixing together
with it: the occurrence that IS inside _handle_dag_run (around line 709, the
missing-workflow check on the live-run path) was left as a plain
handler._send(200, ...). So on the live dag-run path — the path this feature
exists for — a run refused because the workflow file is missing still mints no
refusal receipt, which is the gap the change set out to close.

Suggested fix:

In _handle_reapprove_plan, restore the plain send (this route is interactive
and session-only; the human is reading the response, which is the same reason
the change gives for skipping receipts on dry-run refusals):

if not os.path.isfile(cp):
return handler._send(200, {"ok": False,
"error": "no such workflow on disk"})

and, separately, route _handle_dag_run's own missing-workflow check at ~709
through _refused(..., gate="missing_workflow") so the live path actually
mints the receipt.

If the helper is wanted in more than one handler, lift it to a module-level
function taking dry_run/channel/body explicitly rather than closing over
them.

Honest scope:

This is a crash on an error path, not a governance bypass: it is behind
_require_session() and the route's channel == "session" check, it fires only
when the named workflow has no file on disk, and no effect executes. Impact is
correctness and diagnosability — a clean "no such workflow" becomes an
unhandled exception — plus the missing receipt on the live path noted above. No
data exposure, no privilege escalation, and no signature or approval impact is
claimed.

Counter-evidence checked:

  • Confirmed _refused has exactly one definition in the file and it is nested,

not module-level.

  • Confirmed _refused is absent from the module's _LATE list, so the PEP-562

__getattr__ cannot supply it.

  • Confirmed the module __getattr__ is not consulted for a bare in-function

global lookup, by reproducing that exact lookup in an isolated namespace
carrying an equivalent __getattr__.

  • Confirmed the other nine call sites are genuinely inside _handle_dag_run,

so this is one misplaced call site and not a broken helper.

  • The earlier signing NameError seen on this route in an out-of-process

harness was a sys.path artifact, not a real defect; this one is different —
the name has no definition anywhere in the module, so no import shape can
make it resolve.

Distinctness:

Same scoping class as the earlier routes/llm.py _MODEL_TIER_MAP crash, but a
different file, function and name, and introduced by the v0.97 refusal-receipt
change. It is not the reapprove channel-gate issue (that concerned which
channels may re-approve and is fixed in v0.97). I did not find a community
thread about _refused or the reapprove missing-workflow path.

3 pts

1 reply

Fixed in station-v1.3 (next cut). Confirmed and embarrassing in the best way — /api/workflow/plan/reapprove's missing-workflow branch called _refused(), which is a closure defined inside _handle_dag_run, so a missing workflow id raised NameError instead of returning an answer. It now returns a clean plain response ('no such workflow on disk'); re-approval is interactive (the human is reading the response), so per our v0.94 rule interactive refusals aren't sealed receipts. Regression-tested. Thanks for the precise trace to the closure.

Sign in to reply.