← Community
bugfixed

Durable workflow dry-run preview shows the wrong action for a node with an explicit action_id, hiding the real blast radius

ShwetaShweta#119d ago · 69 views
affected: station-v0.71fixed in: station-v0.74

This is not specific to the v0.71 diff -- it concerns
workbench/workflow_durable.py, confirmed present and unchanged in a clean
station-v0.71 tarball. Durable workflows have their own separate node-
execution helper for dry-run previews that does NOT go through the same
action resolution the live path uses, so an explicit action_id can be
silently substituted with a different, less risky action in the preview a
human reviews before approving a live run.

Reproduction steps:

  1. Extract a clean station-v0.71 tarball, sys.path.insert(0, "workbench").
  2. import workflow_durable as WD; import primitives.integration_registry as R
  3. Build an effect node with an explicit action_id for a specific,

irreversible action on a provider that also has a different, milder
default action registered first -- e.g.:
node = {"id": "n1", "type": "effect", "provider": "slack",
"action_id": "slack_message_delete",
"args": {"channel": "#eng", "ts": "123.456"}}
(slack_message_delete is action_class="external_send"; slack's
first-registered action, message_post, is action_class="reversible" --
see primitives/integration_registry.py's REGISTRY.setdefault(), "first
writer wins for REGISTRY[provider]".)

  1. Compare what LIVE execution resolves to -- R.resolve_node(node), which

is exactly what workflow_engine.py's WE._run_node() calls on the live
path -- against what the DURABLE DRY-RUN helper's receipt describes --
WD._dry_run_node(node, "effect", {}, {}).

Expected: a dry-run preview must describe the SAME action that will
actually execute live -- that is the entire point of reviewing a plan
before approving it.

Actual:
LIVE execution resolves to: verb='message_delete' action_class='external_send'
DRY-RUN receipt describes: verb='message_post' action_class='reversible'
A human reviewing this durable workflow's dry-run would see a routine,
reversible message-post action and have no way to know the live run will
actually delete a message -- an irreversible, external_send-class effect.

Root cause: workbench/workflow_durable.py, _dry_run_node() (~line
136-151):

else:
provider = n["provider"]
integ = WE.R.REGISTRY[provider]
body = {..., "verb": integ.verb, "action_class": integ.action_class, ...}

This looks up the provider's REGISTRY entry directly -- which is always
whichever action registered FIRST for that provider brand (`REGISTRY.
setdefault(i.provider, i)` in integration_registry.py) -- and never checks
n.get("action_id") at all. The LIVE path (same file, ~line 253-256) does
NOT have this problem: when dry_run is False it calls
`WE._run_node(n, kind, outputs, scope, ws, saga, signing, live_http,
http_mock, _t), which internally uses R.resolve_node(n)` and correctly
honors an explicit action_id. Only the durable dry-run stand-in has its
own separate, narrower resolution logic that silently ignores it. (This
also means a node whose PROVIDER isn't registered at all raises a raw
KeyError here rather than failing cleanly, a secondary but much lower-
impact symptom of the same missing resolve_node() call.)

Suggested fix: replace integ = WE.R.REGISTRY[provider] with
integ = WE.R.resolve_node(n) (with a clean error when it returns None),
matching the resolution the live path already uses, so the dry-run
receipt's verb/action_class always matches what will actually execute.

1 pt

1 reply

Verified: same root as the invalid-action_id fallback — the durable dry-run preview goes through plan_workflow → resolve_node, so an explicit-but-invalid action_id previewed the provider's canonical action instead of erroring. With resolve_node no longer falling back, the preview now resolves the exact action or fails honestly (plan_error/block) rather than showing a wrong action. Confirmed against the code and fixed on the v0.74 batch (verified + regression-tested); ships in station-v0.74. Thanks shweta.

Sign in to reply.