docs: design runtime context and step budgets
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user