docs: design runtime context and step budgets
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
# Run Step Budget Design
|
||||
|
||||
## Status
|
||||
|
||||
Proposed for review on 2026-09-04. This document specifies a persisted
|
||||
run-wide guard against unbounded graph execution. It is independent of foreach
|
||||
control-region validation and structured runtime context.
|
||||
|
||||
## Purpose
|
||||
|
||||
Valid workflows may contain ordinary or data-dependent cycles:
|
||||
|
||||
```text
|
||||
a -> b
|
||||
^ |
|
||||
|____|
|
||||
```
|
||||
|
||||
Static validation cannot prove that such a cycle eventually exits. The runtime
|
||||
therefore needs a deterministic step budget that stops a runaway run without
|
||||
pretending every legal loop can be rejected during validation.
|
||||
|
||||
The intended core configuration is:
|
||||
|
||||
```python
|
||||
RunLimits(max_steps=10_000)
|
||||
```
|
||||
|
||||
The budget belongs to the run, covers all of its frames and subgraph scopes,
|
||||
and survives checkpoint/resume.
|
||||
|
||||
## Terminology
|
||||
|
||||
**Run Limits** are immutable execution limits captured when a run is created.
|
||||
They are runtime policy, not workflow graph semantics.
|
||||
|
||||
**Step Attempt** is one admitted attempt to execute a selected workflow `Step`
|
||||
in one frame. Node uses, conditions, foreach controllers, subgraph boundaries,
|
||||
interrupt nodes, joins, and explicit end nodes all count.
|
||||
|
||||
**Step Number** is the one-based ordinal assigned to an admitted step attempt
|
||||
within a run.
|
||||
|
||||
**Step Budget Exhaustion** occurs when the runtime would begin another step
|
||||
after `max_steps` attempts have already been admitted.
|
||||
|
||||
## Configuration and State
|
||||
|
||||
Core runtime state gains JSON-compatible limit and progress fields:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RunLimits:
|
||||
max_steps: int = 10_000
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunState:
|
||||
limits: RunLimits = field(default_factory=RunLimits)
|
||||
steps_executed: int = 0
|
||||
```
|
||||
|
||||
`max_steps` must be a positive integer. The first implementation does not add
|
||||
an unlimited sentinel: callers that intentionally need large runs can choose a
|
||||
larger explicit value while every run retains a finite protection boundary.
|
||||
|
||||
New execution entry points accept optional limits and capture the normalized
|
||||
value in `RunState`:
|
||||
|
||||
```python
|
||||
execute_workflow(
|
||||
workflow,
|
||||
workflow_input,
|
||||
registry,
|
||||
limits=RunLimits(max_steps=50_000),
|
||||
)
|
||||
```
|
||||
|
||||
Resume entry points use the limits stored in the run. They do not silently
|
||||
reset the counter or accept a replacement budget. A future administrative
|
||||
operation may deliberately extend a stopped run, but ordinary resume is not
|
||||
that operation.
|
||||
|
||||
The platform may enforce a lower deployment- or account-level maximum when it
|
||||
creates the core `RunLimits`. Core state still records the effective value so
|
||||
inspection and resume do not depend on mutable external configuration.
|
||||
|
||||
## Counting Semantics
|
||||
|
||||
The runtime consumes one budget unit after selecting a runnable frame and
|
||||
resolving its current `Step`, immediately before dispatching that step's
|
||||
behavior. The counter is incremented before user code or external capability
|
||||
code begins, so failures and interrupts still consume the attempt that caused
|
||||
them.
|
||||
|
||||
If `steps_executed == max_steps`, the next attempted dispatch is denied. A
|
||||
budget of one therefore admits exactly one step. The denied step does not
|
||||
increment the counter and does not invoke a handler.
|
||||
|
||||
Repeated visits count independently:
|
||||
|
||||
- each trip through an ordinary graph cycle counts each selected step;
|
||||
- each foreach-controller dispatch counts, including admissions and final
|
||||
barrier completion;
|
||||
- each foreach item body step counts in its item frame;
|
||||
- starting and later completing a subgraph boundary are separate attempts;
|
||||
- every step executed inside the child subgraph counts against the same run;
|
||||
- an interrupt activation counts once; supplying its external resume payload
|
||||
completes that admitted activation without consuming another step; and
|
||||
- an explicit `EndNode` counts, while the legacy `END` token itself does not
|
||||
because it is a transition target rather than an executable `Step`.
|
||||
|
||||
The counter is deliberately not `len(run.trace)`. Some attempts fail before a
|
||||
normal trace entry is emitted, and scheduler/control-flow implementation may
|
||||
record traces differently. Budget correctness must not depend on observability.
|
||||
|
||||
## Sync and Async Admission
|
||||
|
||||
Sync execution consumes one unit before each `step_workflow()` dispatch.
|
||||
|
||||
Async execution applies the same rule. The concurrent foreach fast path may
|
||||
claim several item frames and invoke their node handlers together; it reserves
|
||||
one step number per admitted frame in deterministic ready-queue order before
|
||||
launching any handler. It may claim at most the remaining budget.
|
||||
|
||||
For example, with three units remaining and five otherwise eligible item
|
||||
frames, the runtime admits the first three frames in ready-queue order. It does
|
||||
not start the other two. After the admitted batch settles deterministically,
|
||||
the next dispatch observes exhaustion and fails the run.
|
||||
|
||||
Reserved async attempts remain consumed even if one handler raises. This
|
||||
matches the rule that admission, rather than successful completion, consumes
|
||||
the budget and avoids making counts depend on task completion timing.
|
||||
|
||||
## Exhaustion Behavior
|
||||
|
||||
Exhaustion is a runtime failure, not a workflow outcome. The runtime raises a
|
||||
specific `WorkflowStepLimitExceeded` derived from `WorkflowExecutionError` and
|
||||
marks the run failed through the same raising-versus-result conventions used by
|
||||
existing execution entry points.
|
||||
|
||||
The error reports at least:
|
||||
|
||||
```text
|
||||
workflow name
|
||||
configured max_steps
|
||||
steps_executed
|
||||
selected frame id
|
||||
selected scope id
|
||||
next node id
|
||||
```
|
||||
|
||||
No edge may catch the failure as an `error` outcome. A node's declared error
|
||||
outcome remains ordinary graph control flow and consumes a step like every
|
||||
other completed attempt.
|
||||
|
||||
The failed run retains its frames, ready queue, state, lineages, trace, and
|
||||
counter for inspection. The denied handler is never invoked. Whether a future
|
||||
administrative rerun can raise the budget is outside ordinary resume semantics.
|
||||
|
||||
## Persistence and Resume
|
||||
|
||||
`RunLimits` and `steps_executed` are serialized inside the existing persisted
|
||||
`RunState` checkpoint. An interrupted run resumes with its original maximum and
|
||||
cumulative count.
|
||||
|
||||
The persisted run envelope may remain at version 1 because adding dataclass
|
||||
fields with defaults is structurally additive. Loading an older checkpoint
|
||||
that lacks these fields yields the default limit and a zero count. The
|
||||
repository has no declared production migration requirement for reconstructing
|
||||
historical counts that were never recorded; if real stored checkpoints exist,
|
||||
their migration policy must be established before release.
|
||||
|
||||
Subgraph scopes do not receive independent counters. They are part of the same
|
||||
run and consume the root run's budget. This prevents an outer workflow from
|
||||
bypassing its protection by repeatedly entering children.
|
||||
|
||||
## Trace and Future Time Travel
|
||||
|
||||
Each normal `TraceEntry` emitted for an admitted step should carry its assigned
|
||||
`step_number`. Step numbers are allocated in deterministic scheduler order;
|
||||
async batch traces retain those numbers regardless of handler completion
|
||||
order. The interrupt request persists its assigned number, and both the
|
||||
initial `interrupt` trace entry and its later resume-completion entry use that
|
||||
same number because they describe one admitted interrupt activation.
|
||||
|
||||
Trace step numbers support inspection and future checkpoint/trace navigation,
|
||||
but gaps are valid when an attempt fails or interrupts before a normal trace
|
||||
entry exists. `RunState.steps_executed` remains authoritative for enforcement
|
||||
and resume.
|
||||
|
||||
A future time-machine system may use step numbers to correlate checkpoints,
|
||||
trace events, frames, and state changes. This design does not require replaying
|
||||
runtime state from trace alone and does not choose event-sourcing semantics.
|
||||
|
||||
## Relationship to Other Limits
|
||||
|
||||
The step budget protects against unbounded control flow. It does not replace:
|
||||
|
||||
- foreach `max_active`, which limits simultaneously active item work;
|
||||
- foreach `max_outstanding`, which limits admitted and blocked item frames;
|
||||
- a future global active-node-call limit, which bounds simultaneous expensive
|
||||
handler calls;
|
||||
- source-, account-, or provider-specific rate limits; or
|
||||
- input-expression depth and node-count validation limits.
|
||||
|
||||
These limits measure different resources. A workflow can stay below every
|
||||
concurrency limit while looping forever, and it can exhaust concurrency or
|
||||
provider capacity long before reaching its step budget.
|
||||
|
||||
## Public Inspection
|
||||
|
||||
Core and client-facing run inspection should expose:
|
||||
|
||||
```python
|
||||
run.limits.max_steps
|
||||
run.steps_executed
|
||||
run.steps_remaining
|
||||
```
|
||||
|
||||
`steps_remaining` is a computed convenience value, not separately persisted:
|
||||
|
||||
```python
|
||||
max(run.limits.max_steps - run.steps_executed, 0)
|
||||
```
|
||||
|
||||
Run creation accepts an optional requested limit where the surrounding
|
||||
platform authorizes it. Inspection returns the effective limit actually stored
|
||||
with the run.
|
||||
|
||||
## Required Tests
|
||||
|
||||
### Core counting
|
||||
|
||||
- A budget of one admits exactly one step and denies the second.
|
||||
- Node, condition, foreach, subgraph, interrupt, join, and explicit end steps
|
||||
count.
|
||||
- A transition to legacy `END` does not create an extra attempt.
|
||||
- Handler failure still consumes its admitted step.
|
||||
- A node `error` outcome remains normal control flow and consumes one step.
|
||||
- An ordinary closed cycle fails at the configured limit.
|
||||
- A data-dependent loop that exits within the budget completes normally.
|
||||
|
||||
### Frames and scopes
|
||||
|
||||
- Foreach controller and item-frame attempts share one counter.
|
||||
- Re-entered foreach activations continue the same run budget.
|
||||
- Child subgraph attempts consume the parent run budget.
|
||||
- Interrupt resume completes the admitted interrupt activation without
|
||||
consuming a second step.
|
||||
- Execution after interrupt resume continues from the persisted cumulative
|
||||
count.
|
||||
- Budget exhaustion does not invoke the denied node handler.
|
||||
|
||||
### Async execution
|
||||
|
||||
- Concurrent foreach batching reserves one unit per item frame.
|
||||
- A batch claims no more frames than the remaining budget.
|
||||
- Step numbers follow ready-queue order rather than completion order.
|
||||
- Reserved attempts remain counted when one async handler fails.
|
||||
- Sync and async runs produce the same count for equivalent serial execution.
|
||||
|
||||
### Persistence and API
|
||||
|
||||
- Limits and counts round-trip through `dump_run_state()` and
|
||||
`load_run_state()`.
|
||||
- A stored interrupted run resumes without resetting or replacing its budget.
|
||||
- Older additive checkpoints receive documented defaults.
|
||||
- Run inspection exposes effective maximum, executed, and remaining counts.
|
||||
- Trace entries expose deterministic step numbers without becoming the source
|
||||
of enforcement truth.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Static termination proofs.
|
||||
- Treating budget exhaustion as a routable workflow outcome.
|
||||
- Per-subgraph or per-foreach step budgets.
|
||||
- Changing concurrency, rate-limit, or provider-admission policy.
|
||||
- An ordinary-resume option that resets or extends a budget.
|
||||
- Full time-machine, event-sourcing, or trace-replay semantics.
|
||||
@@ -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