AgentCanvas / Pages / Developer Guide / Core / Decisions / Dataflow / ADR-dataflow-004
2026-04-15 16:00
Date
2026-04-15 16:00
Status
accepted
Field
dataflow
Old ID
ADR-026

Context

ADR-dataflow-002 introduced the dual-wire architecture: data wires (typed, single-firing) and "state edges" (dashed violet lines that grant access to StateContainers). The data-structure separation was clean, but four spots blurred the boundary in the code surface and made a reader reasonably ask whether the two systems were really distinct: (1) STATE was an overloaded identifier โ€” the same string was both a wire type carrying a {position, orientation} pose dict on edges and a state-container value type used for memory slots; (2) "state edges" were named like edges and stored in graph.state_edges, but they carry no data and do not trigger firing โ€” they are access grants, not wires; (3) the optional graph-level blackboard lived in a separate GraphDefinition.graph_state field and was auto-injected into every node by the executor, bypassing the access-grant model entirely; (4) STATE_VALUE_TYPES accepted raw wire payload types (IMAGE, DEPTH, ACTION, OBSERVATION, STEP_RESULT), so a memory slot could silently be a raw frame buffer โ€” the kind of thing that should never survive across iterations. The discussion behind this ADR (.claude/notes/embodied-vs-coding-agent.md ยง3โ€“ยง4) concluded that when AgentCanvas starts supporting embodied agent-loops, wire and state container must serve two very different regimes โ€” wire = workflow dataflow (static topology, strongly typed, single firing), state container = agent memory (multi-writer, cross-iteration, checkpointable, read does not trigger firing) โ€” and that unifying them would be the wrong move. Tightening the existing split in code is the prerequisite for any later work on embodied agent-loop primitives (triggers, scopes, BaseAgentLoopNode).

Decision

Four concrete renames/rules, all landed together so that the boundary becomes visible in identifiers, schema field names, and runtime rules โ€” no new feature surface, no agent-loop primitives yet. (1) STATE wire type โ†’ POSE in standard/wire_types.py: renames the constant, validator (is_valid_state โ†’ is_valid_pose), and the STEP_RESULT inner key "state" โ†’ "pose". Also adds ANY as an official wire type in WIRE_TYPES for unstructured research data. All builtin node ports carrying pose data (LLMCallNode, BroadcastNode, IterInNode, IterOutNode) rename both the port name ("state" โ†’ "pose") and the wire type ("STATE" โ†’ "POSE"). All workspace nodesets (habitat.py, matterport3d.py, opennav*.py, basic_agent.py, workspace/nodes/navgpt_ce.py) follow the same rename. Workspace nodeset ports that were previously misusing STATE as a generic "any dict" wire type (Open-Nav candidate/tag/caption lists, MP3D trigger ports, etc.) migrate to ANY. (2) StateEdgeDef โ†’ AccessGrantDef, graph.state_edges โ†’ graph.access_grants, _state_edge_index โ†’ _access_grant_index, frontend edge component StateEdge.tsx โ†’ AccessGrantEdge.tsx, edge-type registry key "stateEdge" โ†’ "accessGrant". The dashed violet visual is unchanged; only the identifiers in code, JSON, and the React Flow edge type string change. The class/field docstrings explicitly state that access grants are not edges โ€” they carry no data and never trigger firing. (3) Drop the graph.graph_state field and its auto-inject loop. The optional graph-level blackboard is now a regular ContainerDef in graph.containers with the well-known id "graph_state". Every node that wants to read it must hold an explicit AccessGrantDef to it. The convenience binding ctx._graph_state still works, but only for nodes whose access grant set contains "graph_state" โ€” no auto-inject. GraphDefinition.from_dict retains a legacy upgrade path: if a loaded dict still has state_edges or a top-level graph_state key, the migration happens in-place on load (state_edges read as access_grants, top-level graph_state lifted into containers + one synthesised AccessGrantDef per node) so third-party graphs keep working. (4) Constrain STATE_VALUE_TYPES to {TEXT, BOOL, METRICS, POSE, POINTCLOUD, OCCUPANCY_MAP, EMBEDDING, EPISODE_CONTEXT, ANY}. Raw wire payload types (IMAGE, DEPTH, ACTION, OBSERVATION, STEP_RESULT, the legacy STATE) are removed from the registry. build_container validates each slot's value_type at container-build time and raises a clear ValueError naming the container, the state slot, the removed type, and the allowed set โ€” no silent coercion. The rule: memory is structured/aggregated data (captions, embeddings, scene graphs), not raw frame buffers. ANY is the escape hatch for prototyping. (5) Migration: scripts/migrations/migrate_wire_state_split.py rewrites all workspace/graphs/*.json in place โ€” renames wire/port strings, migrates state_edges โ†’ access_grants, lifts graph_state into containers with synthesised grants, and hard-aborts on any container slot still holding a removed value_type (graph-specific fix-up โ€” the script won't guess). Backend from_dict has a dual-read path so even un-migrated legacy graphs load. Frontend useFlowStore splits the "graph_state" container out of graph.containers into the frontend-only tab.graphState slice on load, and merges it back as id: "graph_state" on save/execute, so GraphStateBanner / StatePanel / composite subgraph navigation keep working with the well-known id instead of a special top-level field.

Alternatives

(a) Unify wire and state container into a single "blackboard channel" primitive โ€” rejected: the two systems serve genuinely different regimes (single-firing dataflow vs cross-iteration memory) and collapsing them would force one of the two to bear the other's constraints, blurring exactly what the discussion concluded should be sharpened. The .claude/notes/embodied-vs-coding-agent.md ยง4.3 conclusion is explicit: "don't unify, tighten the division of labor". (b) Leave STATE as an overloaded identifier, keep state_edges as a schema field โ€” rejected: these are the two spots a reader scanning graph_def.py or a saved JSON file first hits; every new contributor has to re-derive that state_edges aren't really edges. Rename cost is once. (c) Keep the graph.graph_state field and its auto-inject โ€” rejected: the auto-inject is the only place in the executor where a node can read state it never declared access to, which contradicts ADR-dataflow-002's dual-wire model. Making the "graph_state" container an ordinary container + explicit grants removes a special case from graph_executor.py and makes the access visible in the JSON. (d) Pure-additive rename (new names alongside legacy) โ€” rejected for in-tree workspace: in-tree graphs are git-tracked and can be migrated as part of the same commit. Leaving dual names would just delay the cleanup without saving anything. Legacy from_dict reads remain as a safety net for third-party graphs. (e) Add new scope/trigger/agent-loop primitives in the same change โ€” rejected: out of scope. This ADR is strictly about sharpening existing boundaries; the embodied agent-loop primitives (multi-scope containers, fire_when gating, BaseAgentLoopNode) are deferred to a future ADR that will land on top of this cleanup.

Rationale

The claim is small, not ambitious: make the wire/state-container boundary visible in code. After this change the rule a reader can apply is "if it fires a node, it's a wire; if it survives across firings, it's state; if it's in graph.access_grants, it's permission, not data." POSE honestly names what the wire was carrying all along ({position, orientation} โ€” the original STATE name was a historical accident, never a pose taxonomy). AccessGrantDef honestly names what that dashed violet line is โ€” authorisation, not transport. Dropping graph_state as a special field is the biggest clean-up of the four: the executor's auto-inject loop is the only spot in graph_executor.py where a node could read state without a declared grant, and deleting it makes the access-grant model universal. Constraining STATE_VALUE_TYPES is the rule that enforces the semantic: memory is not a frame buffer; if you want to remember an image, you remember its caption, its embedding, or its scene graph, not its pixels. ANY stays as the escape hatch so that prototyping isn't blocked on picking the right structured type. The rename's blast radius is wide (~20 files + 5 workspace graphs + 7 workspace nodesets + 15 frontend files) but the individual edits are mechanical, and the one-shot migration script handles the JSON side in place with a hard-abort validator for the one case (action_history in navgpt_ce.json declaring value_type: ACTION) where the right replacement is graph-specific.

Affected docs

design-docs/state-containers.md, design-docs/wire-types.md, design-docs/graph-executor.md, core/architecture.md, core/glossary.md, core/blueprint.md, core/codebase-map.md, .claude/PROJECT_OVERVIEW.md, scripts/migrations/migrate_wire_state_split.py (new)