# Lineage State Runtime Design Status: partially implemented Lineage is the missing primitive between the scheduler frame model and future native subgraphs / fork-gather. A frame says where execution is. A scope says which workflow state root execution belongs to. A lineage says what pending writes execution can see inside that scope. The current runtime stores committed state directly on `RunState.state` and uses foreach-specific barrier metadata to emulate item-local overlays. That worked for concurrent foreach, but native subgraphs and future fork/gather need the same state-visibility rule in a reusable core concept. ## Current Implementation Status The first compatibility slices are implemented: - `StateWrite` records reducer-aware `incoming_value` and `visible_value`. - `StatePatch` preserves ordered writes while keeping `changes` for trace and compatibility. - `LineageStateView` materializes committed state plus lineage-visible writes. - Concurrent foreach item reads use `visible_value`, while barriers replay `incoming_value`. - Foreach pending result metadata persists write records and `lineage_id`. - Frames and runtime context carry `scope_id`, `lineage_id`, and `parent_lineage_id`. - Generic non-root frame writes are buffered into `RunState.lineages`. - New concurrent foreach item writes are stored in `RunState.lineages`; `ForeachBarrierState` keeps item result metadata plus compatibility patches for old serialized barrier data. The full native subgraph use of `RuntimeScope` is not implemented yet. `ForeachBarrierState` still owns scheduling/barrier metadata, but no longer has to be the primary write store for new concurrent foreach item results. Direct node commits now use a scope-root decision: the top-level root commits to `RunState.state`, while a prepared native-subgraph root commits to its child scope state. Descendant item/branch lineages buffer writes until a barrier or future gather commits them. ## Problem `RunState` currently owns too many meanings: - run status - scheduler cursor - frame set - trace - interrupt request - committed workflow state - temporary state visibility hacks through frame/barrier metadata This creates pressure when a workflow branches: ```text root fork -> A -> B ``` Branch `A` must not see branch `B` writes before an explicit merge point. Branch `B` must not see branch `A` writes either. A future gather then decides how to merge both branches back into a parent state view. The same problem already exists in concurrent foreach item frames. Each item is a sibling lineage. Current foreach solves it locally with barrier pending patches. That solution should become a general runtime primitive. Native subgraphs add one more missing concept: a child workflow has its own committed state root. A lineage alone is not enough for subgraphs because a child workflow initializes state from child input/defaults, not from the parent's state dictionary. ## Vocabulary ```text Scope = workflow state root and workflow identity Frame = scheduler/control-flow position inside a scope Lineage = pending write ownership and read overlay inside a scope ``` A frame owns execution lifecycle: ```python ExecutionFrame( id="root:each:0", scope_id="root", kind="foreach_iteration", node_id="work", status="pending", parent_frame_id="root", lineage_id="root:each:0", ) ``` A lineage owns ordered pending writes: ```python LineageState( id="root:each:0", scope_id="root", parent_id="root", writes=[ StateWrite( path=StatePath(("count",)), incoming_value=3, visible_value=5, reducer=ReducerRef(name="wf.std.add"), ), ], ) ``` The frame, scope, and lineage ids may match in simple cases, but they are not the same concept and runtime logic must not depend on parsing any of them. ## Core Invariants - Every executable frame belongs to exactly one scope. - Every executable frame points at exactly one lineage in that scope. - The root frame points at the root scope and root lineage. - `RunState.state` remains the committed root scope state during the migration. - A non-root lineage stores replayable write records, not a full copied state snapshot and not only final visible values. - A frame reads through its lineage state view. - A non-root frame write updates its lineage writes and does not mutate `RunState.state`. - A barrier/gather commits lineage write records into a parent lineage or scope root state by replaying incoming values through reducers. - Trace `state_changes` means committed changes only. - Buffered lineage writes may be visible to later frames in the same lineage, but they are not public committed state changes until a barrier commits them. ## Why Write Records Instead of Full State Lineages should store write records, not full state snapshots. Full snapshots make reads easy but make merges difficult. At gather time the runtime would have to diff branch snapshots against a base snapshot to discover what changed. That becomes fragile with nested objects, defaults, reducers, missing fields, and JSON Schema validation. Write-record-owned lineages make the merge boundary explicit: ```text lineage A wrote incoming value X to state.person.name lineage B wrote incoming value Y to state.person.email ``` The barrier can then validate conflicts and apply reducers deterministically. Runtime may cache or materialize state views for performance later. The source of truth should still be ordered lineage write records. ## Incoming Values vs Visible Values Existing `StatePatch.changes` is trace-facing. For normal node patches it stores the incoming values from node output bindings. That is useful for trace and for barrier replay. It is not enough by itself for same-lineage reads when reducers are involved. Example: ```text committed state.count = 2 node output delta = 3 reducer = add visible state.count after write = 5 ``` Trace should be able to say the node emitted `3`. A later node in the same lineage must see `5`. A future gather must still replay `3` into the parent, not replay the visible value `5`. So the runtime needs ordered writes: ```python StateWrite( path=StatePath(("count",)), incoming_value=3, # trace and barrier replay value visible_value=5, # same-lineage read value reducer=ReducerRef(name="wf.std.add"), ) ``` `StatePatch` should preserve ordered `StateWrite` records. Convenience views can derive incoming trace changes and same-lineage visible values, but lineage must not discard incoming values. This matters for fork/gather: ```text root state.number = 2 fork A and B A applies add(3), visible in A is 5 B applies add(1), visible in B is 3 gather must commit 2 + 3 + 1 = 6 ``` If lineage stored only visible values, gather would incorrectly replay `5` and `3` instead of `3` and `1`. ## State View The visible state for a frame is: ```text committed scope root state + ancestor lineage visible values + current lineage visible values ``` For v1, materializing this with a deep copy is acceptable because correctness is more important than performance. The implementation should keep this behind one helper so copy-on-write or structural sharing can replace it later. ```python def lineage_state_view( run: RunState, scope_id: str, lineage_id: str ) -> dict[str, Any]: ... ``` ## Relationship to Concurrent Foreach Concurrent foreach is the first real user of lineage. Current behavior: - parent foreach frame stores pending item patches in barrier metadata - `state_view_for_frame` knows about foreach metadata - node finalization knows about foreach item ownership Target behavior: - each concurrent item frame gets its own lineage - `state_view_for_frame` delegates to lineage helpers - node finalization only decides root commit vs lineage buffer - foreach barrier records completed lineage ids - barrier commit replays completed lineage write records into the existing reducer/conflict validation path This removes foreach-specific state overlay logic from node execution. ## Relationship to Native Subgraphs Native subgraphs should build on scopes plus lineage. A subgraph creates a child scope because the child workflow has a separate committed state root initialized from child workflow input and child state defaults. The subgraph root frame runs in that child scope and receives the child scope's root lineage. Child workflow internal frames may create further child lineages for foreach or future fork/gather. Parent state changes only happen when the subgraph boundary completes and applies explicit output bindings. Child internal writes remain inside the child scope until that boundary. This prevents child workflow internals from leaking into parent state and gives interrupt/resume a stable state-visibility boundary. ## Relationship to Future Fork/Gather Fork creates sibling lineages within the same scope. Gather consumes declared lineage ids and commits their writes into a parent lineage or scope root. Conceptually: ```text fork parent lineage P -> child lineage A -> child lineage B gather A+B into P ``` The gather operation should reuse the same conflict validation and reducer rules as concurrent foreach barrier commits: - same-path sibling writes require a mergeable reducer - ancestor/descendant sibling writes are rejected until a deeper merge policy is explicitly designed - reducer application order is deterministic ## RunState Shape Initial additive shape: ```python @dataclass(slots=True) class StateWrite: path: StatePath incoming_value: Any visible_value: Any reducer: ReducerRef @dataclass(slots=True) class RuntimeScope: id: str workflow_name: str committed_state: dict[str, Any] = field(default_factory=dict) @dataclass(slots=True) class LineageState: id: str scope_id: str parent_id: str | None = None writes: list[StateWrite] = field(default_factory=list) @dataclass(slots=True) class ExecutionFrame: ... scope_id: str = "root" lineage_id: str = "root" @dataclass(slots=True) class RunState: ... state: dict[str, Any] scopes: dict[str, RuntimeScope] = field(default_factory=dict) lineages: dict[str, LineageState] = field(default_factory=dict) ``` `RunState.state` remains committed root scope state for compatibility. The root scope may initially mirror `RunState.state`; native subgraphs should use `RuntimeScope.committed_state` for child scopes instead of writing child state into `RunState.state`. ## Explicit END and Gather An explicit `EndNode` is best understood as a degenerate gather: ```text END = gather exactly one active lineage and finalize output/outcome ``` That does not mean `EndNode` should secretly merge siblings. It means `EndNode` and future `GatherNode` should eventually share completion machinery. For native subgraphs, explicit child end nodes would make it possible for a child graph to dispatch both: - final child output - child completion outcome For v1 lineage work, no explicit end node is required. The lineage migration should only avoid blocking that future design. ## Serialization Scopes and lineages must be serializable with `RunState.to_dict()`. This is required for future run stores and resume. Lineage state should avoid storing unserializable objects. Values in `StateWrite` are expected to be JSON-compatible because they come from validated node outputs and state writes. If a future node output can produce non-JSON Python objects, that should be handled by schema/runtime validation before it reaches lineage state. ## Error Handling Malformed scope or lineage state is runtime corruption and should fail fast with `WorkflowExecutionError`. Examples: - frame references an unknown scope id - frame references an unknown lineage id - lineage references an unknown parent lineage id - lineage belongs to a different scope than its frame - a lineage path is not a valid `StatePath` - a barrier tries to commit a lineage that is still active or failed - a barrier sees sibling writes without a mergeable reducer ## Migration Strategy Do not rewrite the whole runtime at once. 1. Add scope and lineage models while preserving current root-state behavior. 2. Add ordered state write records to `StatePatch`. 3. Route state reads through lineage helpers. 4. Buffer non-root frame writes in lineage state. 5. Move concurrent foreach item overlays onto lineages. 6. Remove foreach-specific overlay coupling from node execution. 7. Document native subgraphs as depending on scopes plus lineage. This gives the project a stable primitive before adding native subgraphs. ## Out of Scope - Native subgraph implementation. - Fork/gather implementation. - Persistent run store. - Copy-on-write state view optimization. - Recursive workflow detection. - Multiple subgraph outcome schemas. ## Open Design Questions - Should serial foreach item frames also get item lineages immediately, or only concurrent foreach item frames in the first migration? - Should v1 implement non-root lineage commits into parent lineage, or can v1 only commit to root scope through foreach barriers? - Should `TraceEntry` gain explicit `scope_id` and `lineage_id` fields, or should those ids stay inspectable through frames and run state? - Should `LineageState` store all ordered `StateWrite` records indefinitely, or compact same-lineage writes by path while preserving enough incoming values for future gather replay? ## Recommendation Implement lineage before native subgraphs, and include runtime scopes in the design now even if root scope is the only implemented scope in the first code slice. Start with concurrent foreach because it already has the same semantics in a localized form. Once foreach no longer owns custom overlay logic, native subgraph design gets much cleaner: child workflow state becomes another scope with its own lineage tree instead of another special case in `RunState`.