363 lines
12 KiB
Markdown
363 lines
12 KiB
Markdown
# 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:
|
|
|
|
```python
|
|
customer = ctx.foreach["customers"].item
|
|
order = ctx.foreach["orders"].item
|
|
```
|
|
|
|
The matching serialized graph paths are ordinary `GraphSourcePath` values:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
context.foreach.orders.item
|
|
context.foreach."orders.v2".item
|
|
```
|
|
|
|
The structural representation is authoritative:
|
|
|
|
```python
|
|
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.foreach` mapping.
|
|
- 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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
@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:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```python
|
|
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 `ForeachContext` field;
|
|
- 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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```json
|
|
{
|
|
"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.item` and `.index` produce structural literal path segments.
|
|
- A foreach id containing a dot serializes as a quoted single segment.
|
|
- The computed properties do not appear as persisted `ForeachNode` fields.
|
|
- Foreach refs work in node and subgraph input bindings.
|
|
- `GraphSourcePath` still rejects an `output` root.
|
|
|
|
### Runtime context
|
|
|
|
- A single foreach exposes one structured entry.
|
|
- A nested same-scope body exposes outer and inner entries simultaneously.
|
|
- `loop_item` and `loop_index` select 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.foreach` mapping.
|
|
- 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 `output` graph 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.
|