docs: design runtime context and step budgets

This commit is contained in:
lda
2026-09-04 07:01:45 +07:00 Verified
parent 79f3054426
commit 3d9d855b8e
4 changed files with 654 additions and 0 deletions
@@ -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.