Reproduction steps
- Build a workflow whose effect node returns an envelope naming two parties.
An audit event is the ordinary case:
{"status": "ok",
"data": {"actor": {"id": "00uACTOR", "type": "User", "name": "Alice Admin"},
"target": {"id": "00uTARGET", "type": "User", "name": "Bob Leaver"}}}
- In a downstream node, bind
{{nodes.evt.id}}, meaning the target. - Observe what arrives. It is the actor's id.
typeandnamecollide the
same way.
- In another node, bind
{{nodes.evt.data}}, meaning the object. It resolves
to None.
repro/flatten_fix_test.py reproduces both with no dependencies, on
station-v1.5.26 and station-v1.5.40, and identically under Ubuntu on WSL with
Python 3.12.3.
id received data is object status
CURRENT '00uACTOR' False 'ok'
Expected behavior
A two level binding either resolves to the field it names or fails visibly. It
does not resolve to a different sub object's value that happens to share a key
name.
Actual behavior
_flatten shapes an effect result before it becomes outputs[node_id], which
is what {{nodes.<id>.<field>}} reads. It walks the whole result recursively
and lifts every scalar at any depth into one flat namespace, first occurrence
winning:
def _flatten(d: Any) -> Dict[str, Any]:
flat: Dict[str, Any] = {}
def walk(o):
if isinstance(o, dict):
for k, v in o.items():
if isinstance(v, (str, int, float, bool)) and k not in flat:
flat[k] = v # first occurrence wins, at any depth
walk(v) # recurses into every sub object
elif isinstance(o, list):
for x in o:
walk(x)
walk(d)
if isinstance(d, dict):
flat.setdefault("_", d)
return flat
So a result naming two things, each with an id, collapses to one id. The
binding still resolves. It returns the wrong one, and nothing in the receipt or
the logs says so.
Reachability: the effect apply path returns {"output": _flatten(result), ...},
the runner stores that as outputs[n["id"]], and _lookup resolves a two level
binding with node_out.get(b). The flat namespace is exactly what an ordinary
binding reads.
Two further consequences of the same root:
Nested objects are dropped. Only scalars are lifted, so{{nodes.<id>.data}} where data is an object resolves to None. A transform
guarding with input or {} then runs on an empty dict and passes. Two gate
nodes in my own workflow passed vacuously this way until I ran it for real
against a live provider.
_resolve interpolates with str(). A binding inside a larger string
inserts a Python repr:
"removing {{nodes.plan.remove}} now" -> "removing ['u1', 'u2'] now"
"value is {{nodes.plan.missing}}!" -> "value is None!"
Single quotes, so a string built that way is not valid JSON, and a missing
binding inserts the literal word None rather than failing or emptying.
Honest scope and trigger
This is not a contrived shape. Your own shipped example,station/workflows/seo_client_report.json, binds {{nodes.report.subject}},{{nodes.report.markdown}} and {{nodes.report.html}}, so reading a two level
field of an effect is the intended pattern. Envelopes naming two parties are
everywhere: actor and target on an audit event, before and after on a diff,
user and group on a membership change, customer and invoice on a billing
result. Each pair repeats id, type, name, status.
What I have not established. I have not audited the published workflow corpus
for one that relies today on a scalar hoisted out of a nested object, and fix 1
below would change that case while fix 2 would not. I have not shown that any
published workflow is returning a wrong value right now. I am reporting that the
engine can, through the documented binding pattern, on ordinary result shapes.
No hosted service was probed; this is the installed source and local scripts.
The reason I think it is worth your time anyway: the failure is silent. The
binding resolves, the node runs, the receipt is signed, and the value is wrong.
On a platform whose purpose is that the approved change is the change that
happens, a wrong id reaching a governed write is the failure mode the product
exists to prevent.
Suggested fix
Fix 1, recommended. Project the result's own top level fields, values
intact, objects included, and keep the whole result under _. No recursion, so
nested keys cannot collide, and a nested object resolves as itself.
def _flatten(d: Any) -> Dict[str, Any]:
"""Expose a result's OWN top level fields for {{nodes.<id>.<field>}}, and the
whole result under _.
This used to recurse and hoist every nested scalar into one namespace, so two
sub objects sharing a key (an event's actor.id and target.id) collided and a
binding silently returned whichever the walk reached first. Projecting only
the top level removes the collision, and keeping nested objects rather than
only scalars lets {{nodes.<id>.<obj>}} resolve to the object instead of None.
"""
if not isinstance(d, dict):
return {"_": d} if d is not None else {}
flat = dict(d)
flat.setdefault("_", d)
return flat
On the envelope above: id becomes None, a visible absence the author fixes
by binding ._ and reading the field in a transform; data is the real object;status is still 'ok'. The shipped seo workflow is unaffected, because its
three bound fields are its result's own top level fields.
Fix 2, if you want zero change on non colliding results. Keep the recursive
hoist, but when a key reappears with a different value, remove it, so the
binding resolves to None rather than to one of the candidates.
if isinstance(v, (str, int, float, bool)):
if k in flat and flat[k] != v:
killed.add(k)
elif k not in flat:
flat[k] = v
...
for k in killed:
flat.pop(k, None)
Both are in the repro with assertions, and both leave a non colliding result
untouched.
For the interpolation defect, json.dumps for non scalars, and either an empty
string or a refusal for an unresolved binding, would both be less surprising
than str().