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:
- Extract a clean station-v0.71 tarball, sys.path.insert(0, "workbench").
- import workflow_durable as WD; import primitives.integration_registry as R
- 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]".)
- 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 checksn.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] withinteg = 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.