audit: validate-first routing, read-only activation lookup, delta-log barrier patches

This commit is contained in:
lda
2026-09-04 12:48:01 +07:00 Verified
parent 47e57ce598
commit 7b2f718ad7
6 changed files with 256 additions and 15 deletions
+34 -6
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
from typing import Any, Literal, overload
from wf_core.errors import WorkflowExecutionError
from wf_core.run_state import ExecutionFrame, RunState
@@ -303,9 +303,13 @@ class ForeachBarrierState:
def _activation_entry(
frame: ExecutionFrame, table: dict[str, Any], foreach_node_id: str
frame: ExecutionFrame,
table: dict[str, Any] | None,
foreach_node_id: str,
) -> dict[str, Any] | None:
"""Return the mutable activation entry or fail fast on corrupt state."""
if table is None:
return None
entry = table.get(foreach_node_id)
if entry is None:
return None
@@ -368,7 +372,7 @@ def save_foreach_activation(
frame: ExecutionFrame, activation: ForeachActivationState
) -> None:
"""Persist barrier progress for the named active activation."""
table = _activation_table(frame)
table = _activation_table(frame, create=False)
entry = _activation_entry(frame, table, activation.foreach_node_id)
if entry is None:
raise WorkflowExecutionError(
@@ -391,7 +395,7 @@ def close_foreach_activation(
The barrier is removed so a later visit starts fresh; the sequence keeps
increasing so child and lineage ids cannot collide across visits.
"""
table = _activation_table(frame)
table = _activation_table(frame, create=False)
entry = _activation_entry(frame, table, activation.foreach_node_id)
if entry is None:
raise WorkflowExecutionError(
@@ -413,8 +417,11 @@ def load_foreach_activation(
A child result naming a closed or different activation must fail closed in
the caller rather than buffering into the wrong barrier.
This is a read-only lookup: a missing table or entry raises without
mutating frame metadata.
"""
table = _activation_table(frame)
table = _activation_table(frame, create=False)
entry = _activation_entry(frame, table, foreach_node_id)
if entry is None:
raise WorkflowExecutionError(
@@ -469,9 +476,30 @@ def item_frame_owner(frame: ExecutionFrame) -> ForeachItemOwner | None:
)
def _activation_table(frame: ExecutionFrame) -> dict[str, Any]:
@overload
def _activation_table(
frame: ExecutionFrame, *, create: Literal[True] = True
) -> dict[str, Any]: ...
@overload
def _activation_table(
frame: ExecutionFrame, *, create: Literal[False]
) -> dict[str, Any] | None: ...
def _activation_table(
frame: ExecutionFrame, *, create: bool = True
) -> dict[str, Any] | None:
"""Return the activation table, optionally creating it.
Read-only lookups pass ``create=False`` so a failed lookup leaves
frame metadata untouched. Only ``load_or_begin`` creates the table.
"""
raw = frame.metadata.get(_ACTIVATION_METADATA_KEY)
if raw is None:
if not create:
return None
table: dict[str, Any] = {}
frame.metadata[_ACTIVATION_METADATA_KEY] = table
return table
+25 -7
View File
@@ -249,6 +249,15 @@ def build_barrier_patch(
committed aggregate values. A barrier trace is the single visible state
commit for all buffered item patches, so showing raw per-item incoming
values would hide what actually landed in `RunState.state`.
The emitted `writes` log keeps every constituent item write in order
instead of one merged write per path. A combined patch buffered in a
lineage can itself be re-merged by an outer barrier, and replaying merged
cumulative values would duplicate whatever was already committed when the
constituents were built. Replaying the original per-item deltas stays
correct at any nesting depth. Each kept write still carries the merged
aggregate as its `visible_value`, so overlay reads and `visible_values`
keep showing the final value.
"""
state_fields = workflow.state_schema.field_index()
validate_barrier_writes(item_patches, state_fields, reducers=reducers)
@@ -269,22 +278,31 @@ def build_barrier_patch(
safe_set_nested_value(staged_state, key_path, merged_value)
prepared_patch[destination_path] = (key_path, merged_value)
committed_changes[str(destination_path)] = merged_value
merged_visible = {
destination_path: merged_value
for destination_path, (_key_path, merged_value) in prepared_patch.items()
}
writes = [
StateWrite(
path=destination_path,
incoming_value=merged_value,
visible_value=merged_value,
reducer=reducer_for_state_path(destination_path, state_fields),
path=write.path,
incoming_value=write.incoming_value,
visible_value=merged_visible[write.path],
reducer=write.reducer,
)
for destination_path, (_key_path, merged_value) in prepared_patch.items()
for item_patch in item_patches
for write in item_patch.writes
]
validate_staged_state_patch(staged_state, prepared_patch, state_fields)
return StatePatch(
changes=committed_changes,
combined = StatePatch(
writes=writes,
_prepared_writes=prepared_patch,
_staged_state=staged_state,
)
# The trace-facing view reports the aggregate, while the replay log above
# intentionally carries per-item deltas (see docstring). Assign it after
# construction: passing both to the constructor requires them to agree.
combined.changes = committed_changes
return combined
def validate_barrier_writes(