validate output of node fit state

This commit is contained in:
lda
2026-05-20 21:30:15 +07:00 Verified
parent 9c7c616212
commit e29e093ad1
3 changed files with 134 additions and 7 deletions
+9 -1
View File
@@ -147,7 +147,9 @@ A successful step should produce a logical state patch before state is mutated:
2. ensure every required output path exists 2. ensure every required output path exists
3. ensure destination state paths do not overlap 3. ensure destination state paths do not overlap
4. prepare the complete write set 4. prepare the complete write set
5. commit the write set according to state merge rules 5. stage the write set on a copy
6. validate affected declared state schemas
7. commit the write set according to state merge rules
This preserves the existing “no partial state commit before success” rule and This preserves the existing “no partial state commit before success” rule and
creates a reusable boundary for: creates a reusable boundary for:
@@ -157,6 +159,12 @@ creates a reusable boundary for:
- future parallel foreach result combination - future parallel foreach result combination
- future subgraph completion - future subgraph completion
State validation happens against the staged state before the original state is
mutated. Runtime validates the exact destination schema when declared, declared
ancestor schemas that could reject the write, and declared descendant schemas
that exist after a parent replacement. This lets strict object schemas reject
bad partial writes without committing half a patch.
## State Declarations and Merge Rules ## State Declarations and Merge Rules
State merge behavior is attached to declared exact state paths. The canonical State merge behavior is attached to declared exact state paths. The canonical
+71 -1
View File
@@ -18,6 +18,9 @@ from wf_core.paths import (
split_graph_path, split_graph_path,
) )
from wf_core.runtime.ops.merges import ReducerDefinition, apply_reducer from wf_core.runtime.ops.merges import ReducerDefinition, apply_reducer
from wf_core.runtime.ops.schemas import validate_payload_against_schema
_MISSING = object()
def apply_output_map( def apply_output_map(
@@ -87,6 +90,7 @@ def apply_output_bindings(
staged_state = deepcopy(state) staged_state = deepcopy(state)
for _destination_path, (key_path, merged_value) in prepared_patch.items(): for _destination_path, (key_path, merged_value) in prepared_patch.items():
safe_set_nested_value(staged_state, key_path, merged_value) safe_set_nested_value(staged_state, key_path, merged_value)
validate_staged_state_patch(staged_state, prepared_patch, state_fields)
state.clear() state.clear()
state.update(staged_state) state.update(staged_state)
return {str(path): value for path, value in resolved_patch.items()} return {str(path): value for path, value in resolved_patch.items()}
@@ -130,7 +134,15 @@ def write_state_value(
value, value,
reducers=reducers, reducers=reducers,
) )
safe_set_nested_value(state, key_path, merged_value) staged_state = deepcopy(state)
safe_set_nested_value(staged_state, key_path, merged_value)
validate_staged_state_patch(
staged_state,
{StatePath.parse(destination_path): (key_path, merged_value)},
workflow.state_schema.field_map(),
)
state.clear()
state.update(staged_state)
def prepare_state_value( def prepare_state_value(
@@ -179,6 +191,64 @@ def project_output(workflow: Workflow, state: dict[str, Any]) -> dict[str, Any]:
} }
def validate_staged_state_patch(
staged_state: dict[str, Any],
prepared_patch: Mapping[StatePath, tuple[list[str], Any]],
state_fields: Mapping[str, StateFieldDecl],
) -> None:
"""Validate affected declared state schemas before committing a patch.
Runtime writes are path-based, while JSON Schema is tree-based. A child
write can violate a declared parent schema, and a parent replacement can
violate declared child schemas. This helper validates every declared schema
that is on either side of a staged write path, without mutating the original
state first.
"""
for field in _affected_state_fields(prepared_patch, state_fields):
value = _get_existing_nested_value(staged_state, list(field.path.parts))
if value is _MISSING:
continue
validate_payload_against_schema(
field.validation_schema,
value,
f"state write state.{'.'.join(field.path.parts)}",
)
def _affected_state_fields(
prepared_patch: Mapping[StatePath, tuple[list[str], Any]],
state_fields: Mapping[str, StateFieldDecl],
) -> list[StateFieldDecl]:
affected: dict[str, StateFieldDecl] = {}
for destination_path in prepared_patch:
destination_parts = destination_path.parts
for key, field in state_fields.items():
field_parts = field.path.parts
if _is_prefix(field_parts, destination_parts) or _is_prefix(
destination_parts,
field_parts,
):
affected[key] = field
return sorted(
affected.values(),
key=lambda field: len(field.path.parts),
reverse=True,
)
def _is_prefix(prefix: tuple[str, ...], value: tuple[str, ...]) -> bool:
return len(prefix) <= len(value) and value[: len(prefix)] == prefix
def _get_existing_nested_value(state: Mapping[str, Any], path_parts: list[str]) -> Any:
current: Any = state
for part in path_parts:
if not isinstance(current, Mapping) or part not in current:
return _MISSING
current = current[part]
return current
def safe_set_nested_value( def safe_set_nested_value(
state: dict[str, Any], path_parts: list[str], value: Any state: dict[str, Any], path_parts: list[str], value: Any
) -> None: ) -> None:
+54 -5
View File
@@ -109,6 +109,49 @@ def test_output_bindings_commit_to_staged_state_before_mutating_original() -> No
assert state["blocked"] == "not-an-object" assert state["blocked"] == "not-an-object"
def test_output_bindings_validate_exact_state_schema_before_mutation() -> None:
workflow = _workflow(fields={"person.name": StateField(type="string")})
state = {"person": {"name": "old"}}
with pytest.raises(WorkflowExecutionError, match="state write state.person.name"):
apply_output_bindings(
workflow,
[_binding("person.name", "state.person.name")],
{"person": {"name": 7}},
state,
)
assert state["person"]["name"] == "old"
def test_output_bindings_validate_declared_parent_schema_before_mutation() -> None:
workflow = _workflow_from_state_schema(
StateSchema.model_validate(
{
"type": "object",
"properties": {
"person": {
"type": "object",
"properties": {"name": {"type": "string"}},
"additionalProperties": False,
}
},
}
)
)
state = {"person": {"name": "old"}}
with pytest.raises(WorkflowExecutionError, match="state write state.person"):
apply_output_bindings(
workflow,
[_binding("person.extra", "state.person.extra")],
{"person": {"extra": "bad"}},
state,
)
assert state["person"] == {"name": "old"}
def test_full_workflow_execution_writes_canonical_output_bindings() -> None: def test_full_workflow_execution_writes_canonical_output_bindings() -> None:
workflow = _workflow_with_node() workflow = _workflow_with_node()
run = create_run_state(workflow, {}) run = create_run_state(workflow, {})
@@ -135,17 +178,23 @@ def _binding(source: str, target: str) -> OutputBinding:
def _workflow( def _workflow(
fields: dict[str, StateField] | None = None, fields: dict[str, StateField] | None = None,
) -> Workflow: ) -> Workflow:
return Workflow( return _workflow_from_state_schema(
name="patch", StateSchema.from_field_map(
input_schema=SchemaRef(type="object", properties={}),
state_schema=StateSchema.from_field_map(
fields fields
or { or {
"person": StateField(type="object"), "person": StateField(type="object"),
"person.name": StateField(type="string"), "person.name": StateField(type="string"),
"person.extra": StateField(type="string"), "person.extra": StateField(type="string"),
} }
), )
)
def _workflow_from_state_schema(state_schema: StateSchema) -> Workflow:
return Workflow(
name="patch",
input_schema=SchemaRef(type="object", properties={}),
state_schema=state_schema,
output_schema=SchemaRef(type="object", properties={}), output_schema=SchemaRef(type="object", properties={}),
start="n", start="n",
nodes=[], nodes=[],