12 KiB
Structured Runtime Context Design
Status
Proposed for review on 2026-09-04. This document specifies structured foreach context inside one runtime scope. It complements the foreach back-edge design without expanding that implementation slice.
Purpose
Let nested foreach bodies read every active same-scope iteration explicitly and with useful schemas:
customer = ctx.foreach["customers"].item
order = ctx.foreach["orders"].item
The matching serialized graph paths are ordinary GraphSourcePath values:
context.foreach.customers.item
context.foreach.orders.item
This replaces the current innermost-only runtime representation as the
canonical model. Existing loop_item, loop_index, and configured aliases
remain convenience fields for the innermost active foreach while callers move
to structured paths.
Existing Path Model
The existing path types describe two different namespaces and should remain separate:
| Type | Purpose | Whole value |
|---|---|---|
GraphSourcePath |
Read the current workflow scope | Named root |
StatePath |
Write a state field | Not supported |
LocalPath |
Address one boundary payload | . |
The named graph roots are input, state, and context. LocalPath applies
to temporary node, subgraph-boundary, and workflow-output payloads.
GraphSourcePath does not gain an output root. A node result is a local
payload whose selected fields are committed through OutputBinding into
state. A workflow output is another local payload projected at completion from
input, state, or context. A graph-level output root would be ambiguous
about the producing node, dynamic activation, foreach item, and lineage.
Path serialization continues to use TOML key syntax. Bare identifiers stay compact, while identifiers containing punctuation are quoted as one literal segment:
context.foreach.orders.item
context.foreach."orders.v2".item
The structural representation is authoritative:
GraphSourcePath(
root="context",
parts=("foreach", "orders.v2", "item"),
)
Code constructing paths from node identifiers must append literal tuple segments rather than reparsing identifiers as dotted expressions.
Scope Boundary
Structured foreach context is local to one RuntimeScope.
- Nested foreach activations in the same workflow scope are inherited.
- A subgraph starts a new runtime scope and does not inherit its caller's
context.foreachmapping. - A caller passes required values through the subgraph's declared input bindings.
- A child workflow reads those values from
input, not from its caller's context. - Parent and child workflows may use the same foreach node identifier without collision.
For example:
orders = parent.foreach(
id="orders",
over=state_path("orders"),
as_="order",
)
process = parent.subgraph(
workflow=child_workflow,
input=[
{
"target": "order",
"path": orders.item,
}
],
)
Inside child_workflow, the value is input.order. This keeps a saved
subgraph reusable across call sites and preserves RuntimeScope as the state,
input, and context boundary for one workflow invocation.
Context Shape
Python runtime context gains a typed entry for each active same-scope foreach:
@dataclass(frozen=True, slots=True)
class ForeachContext:
node_id: str
activation_id: str
frame_id: str
scope_id: str
lineage_id: str
index: int
item: object
@dataclass(slots=True)
class RuntimeContext:
# Existing execution fields remain.
foreach: Mapping[str, ForeachContext] = field(default_factory=dict)
The mapping key is the static ForeachNode.id within the current workflow
scope. It is not the configured alias and does not contain a dynamic suffix.
The value discloses the dynamic foreach activation, item frame, scope, and
lineage identities when advanced runtime code needs them.
The mapping contains active entries in outermost-to-innermost insertion order. Lookup semantics do not depend on that order; the order exists for inspection and deterministic serialization only. A valid control region cannot contain the same foreach node identifier twice, so lookup by static id is unambiguous within one scope.
The graph-visible context object has the equivalent JSON-compatible shape:
{
"foreach": {
"customers": {
"node_id": "customers",
"activation_id": "act_1234567890123",
"frame_id": "frame_1234567890123",
"scope_id": "root",
"lineage_id": "lineage_1234567890123",
"index": 0,
"item": {"name": "Ada"}
},
"orders": {
"node_id": "orders",
"activation_id": "act_2345678901234",
"frame_id": "frame_2345678901234",
"scope_id": "root",
"lineage_id": "lineage_2345678901234",
"index": 2,
"item": {"sku": "A-17"}
}
},
"loop_item": {"sku": "A-17"},
"loop_index": 2,
"customer": {"name": "Ada"},
"order": {"sku": "A-17"}
}
loop_item and loop_index refer to the innermost active foreach. Configured
aliases for every active same-scope foreach are also available when their names
are unique. Validation rejects collisions between simultaneously active aliases
instead of allowing an inner foreach to shadow an outer value.
Runtime Derivation
Structured context is derived from persisted frame ancestry rather than copied as one flattened object into every frame.
For the selected frame, the runtime walks parent_frame_id while ancestors
remain in the same scope_id. Each foreach item frame contributes one typed
entry from its validated metadata. The collected entries are reversed into
outermost-to-innermost order and materialized into both RuntimeContext.foreach
and the JSON-compatible context mapping used by input bindings.
Traversal stops at a runtime-scope boundary even though a subgraph root frame has a scheduling parent in the caller. Frame ancestry describes scheduling ownership; it does not grant cross-scope context visibility.
The required persisted item metadata is:
foreach node id
foreach activation id
item index
item value
configured alias
scope id
lineage id
scope_id and lineage_id may remain first-class ExecutionFrame fields
rather than being duplicated inside metadata. The structured context builder
must read typed metadata helpers and fail on malformed foreach item metadata;
corrupt persisted state is not equivalent to a missing context value.
Static Context Analysis
Context analysis uses the full static foreach-owner stack established by the foreach back-edge design:
control_region(work_node) == ("customers", "orders")
At work_node, the generated context schema contains both entries. Each
.item schema is the item schema inferred from its owning foreach over
collection. .index is an integer, and identity fields are strings.
Validation rejects a structured foreach context path when:
- the referenced foreach id does not exist in the current workflow;
- the referenced foreach is not active in the consuming node's control region;
- the path asks for an unknown
ForeachContextfield; - active configured aliases collide with one another or with reserved context fields; or
- a child workflow tries to address a caller's foreach context instead of receiving a declared input.
The analyzer must not represent context with a single active foreach id. It assigns one owner stack per reachable node and derives all active entries from that stack. Unreachable nodes remain validation errors rather than receiving a fabricated root context.
Authoring Surface
The ForeachNode returned by WorkflowBuilder.foreach() already acts as the
step reference. It gains non-serialized computed path properties instead of a
second wrapper hierarchy:
orders = graph.foreach(
id="orders",
over=state_path("orders"),
as_="order",
)
orders.item
# GraphSourcePath("context", ("foreach", "orders", "item"))
orders.index
# GraphSourcePath("context", ("foreach", "orders", "index"))
These values work anywhere an existing GraphSourcePath works:
charge = graph.use(
charge_order,
input=[{"target": "order", "path": orders.item}],
)
graph.set_route(orders, "loop", charge)
graph.set_route(charge, "ok", orders)
The compiled binding remains ordinary protocol data:
{
"target": "order",
"path": "context.foreach.orders.item"
}
No serialized ForeachRef, node-address type, or dynamic activation path is
introduced. Field-selection sugar beneath .item can be considered with the
broader ergonomic Python DSL; it is not required for structured context.
Trace, Checkpoints, and Inspection
The resumable source of truth remains RunState: frames, scopes, lineages,
ready queue, barriers, interrupt route, and foreach activation metadata. The
runtime recreates structured context from that state after loading a
checkpoint.
Trace remains chronological history. It may expose the same stable frame_id,
scope_id, lineage_id, and foreach activation identities for inspection, but
runtime context must not be reconstructed from trace entries alone. The current
trace does not contain every live scheduler or barrier invariant required to
resume safely.
A future time-machine design may combine checkpoints with trace or richer events. This design preserves stable identities for that work without choosing event sourcing, checkpoint navigation, or rerun-from-step semantics now.
Compatibility and Migration
The structured foreach field is additive. Existing GraphSourcePath,
StatePath, and LocalPath serialized forms remain unchanged.
The current convenience fields remain during this migration:
context.loop_item;context.loop_index; and- each active foreach's configured alias when unambiguous.
They are derived from the same structured entries so Python context, input binding resolution, schema analysis, and trace inspection cannot disagree. Future removal requires evidence that public callers and stored workflow artifacts no longer use them; this design does not create an indefinite compatibility promise.
Required Tests
Path and authoring
ForeachNode.itemand.indexproduce structural literal path segments.- A foreach id containing a dot serializes as a quoted single segment.
- The computed properties do not appear as persisted
ForeachNodefields. - Foreach refs work in node and subgraph input bindings.
GraphSourcePathstill rejects anoutputroot.
Runtime context
- A single foreach exposes one structured entry.
- A nested same-scope body exposes outer and inner entries simultaneously.
loop_itemandloop_indexselect the innermost entry.- Unique outer and inner aliases remain available together.
- Inner completion restores the outer entry and removes the inner entry.
- Concurrent item frames receive distinct activation/frame/lineage values.
- Interrupt resume recreates the same structured entries from persisted state.
- Malformed persisted foreach metadata fails closed.
Scope isolation
- A child subgraph does not inherit the caller's
context.foreachmapping. - A parent can map a foreach item into child workflow input.
- Parent and child foreach nodes may reuse the same static id.
- Context ancestry traversal stops at the child runtime scope.
Validation and schemas
- The item schema under each structured entry matches the foreach collection's item schema.
- Referencing an inactive, missing, or cross-scope foreach is rejected.
- Nested active alias collisions are rejected.
- The analyzer handles legal nested cycles without losing the owner stack.
- Unreachable nodes do not receive structured context schemas.
Non-Goals
- Direct node-output dataflow or an
outputgraph root. - Cross-subgraph implicit context inheritance.
- A universal static node-address or dynamic execution-path type.
- Time-machine behavior or event-sourced resume.
- Fork/gather context, scheduling context, or run step limits.
- The broader doubly ergonomic Python DSL.