15 KiB
State Schema and Reducer Ref Path Sweep Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Remove remaining dotted-string ambiguity from state field paths, then prepare ReducerRef to stop treating reducer capability names as opaque dotted strings.
Architecture: Do this in two independent passes. First, make StateSchema / StateFieldDecl preserve StatePath semantics internally and serialize state paths structurally where possible. Second, introduce a structural reducer capability ref while keeping string reducer names as parse-only shorthand. The state-schema pass is the immediate correctness fix; reducer refs are a follow-up because they touch artifacts/source dependencies.
Tech Stack: Python 3.14, Pydantic v2, wf_core.paths.StatePath, wf_platform.refs.CapabilityRef, wf_core.models.schemas, pytest, basedpyright, ruff.
Why This Plan Exists
We just moved graph/node bindings toward structural paths:
{ "root": "state", "parts": ["person.name"] }
But state schema indexing still builds rootless dotted strings in places:
path = f"{prefix}.{name}"
StatePath.of(path)
That can corrupt JSON Schema property names containing dots:
{
"type": "object",
"properties": {
"person.name": { "type": "string", "reducer": "wf.std.replace" }
}
}
The intended state path is:
state -> "person.name"
not:
state -> person -> name
Reducer refs have a similar-looking but different issue. wf.std.add is not a graph path; it is a capability ref. That cleanup should use CapabilityRef, not StatePath.
Scope
In Scope Now
- Keep exact JSON Schema property names as literal
StatePath.parts. - Add typed state-field indexing keyed by
StatePath. - Keep old string-keyed
field_map()compatibility. - Make
StateFieldDecl.pathdumps structural if the rest of the core path dump has already moved structural. - Add tests proving literal dotted property names are not split.
- Document that state schema paths and reducer refs are different domains.
Follow-Up Scope
- Change
ReducerRefto carry a structuralCapabilityRef. - Keep
ReducerRef(name="wf.std.add")shorthand as parse-only compatibility. - Update artifact dependency extraction to use structural reducer refs.
Do not mix these two passes unless the state schema work forces a reducer model touch.
Current State
Relevant files:
-
src/wf_core/models/schemas.pyStateFieldDecl.path: StatePathStateFieldDecl._serialize_path()currently returnsstr(path)StateSchema.field_map()returnsdict[str, StateFieldDecl]_iter_state_field_declarations(...)buildspathas dotted string_set_state_property_schema(...)receivespath_parts
-
src/wf_core/models/reducers.pyReducerRef.name: str
-
src/wf_authoring/schemas.py_iter_model_metadata(...)builds rootless dotted strings from Pydantic field names_flatten_state_properties(...)and_lookup_mutable_property_schema(...)split strings with.
-
src/wf_core/runtime/ops/state.py- uses
workflow.state_schema.field_map()and string keys
- uses
Important distinction:
State path: graph data path, should use StatePath
Reducer name: source capability ref, should use CapabilityRef later
Task 1: Pin Literal Dotted State Property Behavior
Files:
-
Test:
tests/core/test_nested_state_paths.py -
Test:
tests/core/test_schema_validation.py -
Step 1: Add failing StateSchema field-index test
Add to tests/core/test_nested_state_paths.py:
def test_state_schema_preserves_literal_dotted_property_names() -> None:
schema = StateSchema.model_validate({
"type": "object",
"properties": {
"person.name": {"type": "string", "reducer": "wf.std.replace"}
},
})
fields = schema.field_index()
assert set(fields) == {StatePath(("person.name",))}
assert fields[StatePath(("person.name",))].path == StatePath(("person.name",))
Expected failure: field_index() does not exist, or the path is split as ("person", "name").
- Step 2: Add compatibility
field_map()test
Add:
def test_state_schema_field_map_keeps_display_key_for_literal_dotted_property() -> None:
schema = StateSchema.model_validate({
"type": "object",
"properties": {
"person.name": {"type": "string", "reducer": "wf.std.replace"}
},
})
fields = schema.field_map()
assert set(fields) == {"person.name"}
assert fields["person.name"].path == StatePath(("person.name",))
This keeps old callers alive but makes the value typed/correct.
- Step 3: Run focused tests to verify red
uv run --with pytest pytest tests/core/test_nested_state_paths.py::test_state_schema_preserves_literal_dotted_property_names tests/core/test_nested_state_paths.py::test_state_schema_field_map_keeps_display_key_for_literal_dotted_property -q
Expected: fail before implementation.
Task 2: Add Typed State Field Index
Files:
-
Modify:
src/wf_core/models/schemas.py -
Test:
tests/core/test_nested_state_paths.py -
Step 1: Add path-parts traversal helper
In src/wf_core/models/schemas.py, replace string-prefix recursion with tuple path parts.
Add helper:
def _append_state_part(prefix: tuple[str, ...], name: str) -> tuple[str, ...]:
"""Append one JSON Schema property name as one literal StatePath segment."""
return (*prefix, name)
- Step 2: Add
field_index()
Add to StateSchema:
def field_index(self) -> dict[StatePath, StateFieldDecl]:
"""Return reducer-aware declarations keyed by exact typed state path."""
root_schema = self.model_dump(mode="json", exclude_none=True)
return {
path: field
for path, field in _iter_state_field_declarations(
self.properties,
root_schema,
prefix=(),
)
}
- Step 3: Make
field_map()compatibility wrapper
Change field_map() to:
def field_map(self) -> dict[str, StateFieldDecl]:
"""Return reducer-aware declarations keyed by rootless display path."""
return {".".join(path.parts): field for path, field in self.field_index().items()}
Note: this display map is ambiguous for literal dotted segments, but values are correct. New runtime code should move to field_index().
- Step 4: Update
_iter_state_field_declarationssignature
Change from string prefix:
prefix: str
) -> Iterator[tuple[str, StateFieldDecl]]:
to typed prefix:
prefix: tuple[str, ...]
) -> Iterator[tuple[StatePath, StateFieldDecl]]:
Inside loop:
path_parts = _append_state_part(prefix, name)
path = StatePath(path_parts)
display_path = ".".join(path.parts)
Use display_path only in error messages and reducer validation labels.
- Step 5: Update yielded
StateFieldDeclconstruction
Change:
"path": StatePath.of(path),
to:
"path": path,
- Step 6: Update recursive calls
Pass:
prefix=path.parts
not a dotted string.
- Step 7: Run focused tests
uv run --with pytest pytest tests/core/test_nested_state_paths.py::test_state_schema_preserves_literal_dotted_property_names tests/core/test_nested_state_paths.py::test_state_schema_field_map_keeps_display_key_for_literal_dotted_property -q
Expected: pass.
Task 3: Move Runtime Lookup to Typed State Paths
Files:
-
Modify:
src/wf_core/runtime/ops/state.py -
Test:
tests/core/test_nested_state_paths.py -
Test:
tests/core/test_atomic_state_patches.py -
Step 1: Inspect current runtime lookup
Current likely shape:
state_fields = workflow.state_schema.field_map()
field = state_fields.get(".".join(path.parts))
This should move to field_index() where available.
- Step 2: Update runtime type hints
Change helpers from:
state_fields: Mapping[str, StateFieldDecl]
to:
state_fields: Mapping[StatePath, StateFieldDecl]
- Step 3: Use typed lookup
When resolving reducer for a write target:
declared_field = state_fields.get(target)
where target is already a StatePath.
If code currently has only path parts, construct:
target = StatePath(tuple(path_parts))
- Step 4: Update affected-field overlap helper
If _affected_state_fields(...) compares string prefixes, make it compare tuple parts:
def _is_prefix(prefix: tuple[str, ...], parts: tuple[str, ...]) -> bool:
return parts[: len(prefix)] == prefix
This preserves exact path semantics without reparsing dotted display text.
- Step 5: Run runtime-focused tests
uv run --with pytest pytest tests/core/test_nested_state_paths.py tests/core/test_atomic_state_patches.py -q
Expected: pass.
Task 4: Structural StateFieldDecl.path Dump
Files:
-
Modify:
src/wf_core/models/schemas.py -
Test:
tests/core/test_nested_state_paths.py -
Test:
tests/core/test_schema_validation.py -
Step 1: Check current expectations
Existing tests may expect:
{"path": "state.person.name"}
Decide based on current path model direction. Since StatePath now serializes structurally elsewhere, prefer:
{ "path": { "root": "state", "parts": ["person.name"] } }
- Step 2: Change serializer
Remove this serializer:
@field_serializer("path")
def _serialize_path(self, path: StatePath) -> str:
return str(path)
or change it to:
@field_serializer("path")
def _serialize_path(self, path: StatePath) -> dict[str, str | list[str]]:
return StatePath._serialize(path)
Prefer removal if Pydantic uses the existing StatePath serializer correctly.
- Step 3: Update tests
Update or add:
def test_state_field_decl_model_dump_serializes_path_structurally() -> None:
field = StateFieldDecl(path=StatePath(("person.name",)), schema={"type": "string"})
dumped = field.model_dump(mode="json")
assert dumped["path"] == {"root": "state", "parts": ["person.name"]}
- Step 4: Run focused schema tests
uv run --with pytest pytest tests/core/test_nested_state_paths.py tests/core/test_schema_validation.py -q
Expected: pass after expectation updates.
Task 5: Authoring State Metadata Path Sweep
Files:
-
Modify:
src/wf_authoring/schemas.py -
Test:
tests/authoring/test_schemas.py -
Step 1: Add failing authoring test for literal dotted Pydantic field alias if possible
If Pydantic field aliases are already used in this project, add:
class DotAliasState(BaseModel):
person_name: Annotated[
str,
Field(alias="person.name"),
state_field(reducer="wf.std.replace"),
]
def test_state_schema_from_preserves_literal_dotted_alias_paths() -> None:
schema = state_schema_from(DotAliasState)
fields = schema.field_index()
assert StatePath(("person.name",)) in fields
If field aliases are not supported by the current authoring schema flow, document that Python model field names remain Python identifiers and alias path support is out of scope.
- Step 2: Replace string path traversal with tuple parts
In src/wf_authoring/schemas.py, update metadata collection helpers:
def _iter_model_metadata(
model_type: type[BaseModel],
*,
prefix: tuple[str, ...] = (),
) -> Iterator[tuple[tuple[str, ...], StateFieldMetadata]]:
Use one literal segment per field name or alias:
field_name = field_info.alias or name
path = (*prefix, field_name)
- Step 3: Update lookup helpers to accept tuple parts
Change:
_lookup_mutable_property_schema(schema_payload, path)
_state_field_default(value, path, property_schema)
to tuple-based forms:
_lookup_mutable_property_schema(schema_payload, path_parts)
_state_field_default(value, path_parts, property_schema)
Use field name lookup carefully; default lookup for nested aliases may need to remain conservative.
- Step 4: Run authoring schema tests
uv run --with pytest pytest tests/authoring/test_schemas.py -q
Expected: pass.
Task 6: ReducerRef Capability Ref Plan Stub
Files:
-
Modify:
docs/structural_refs.md -
Create:
docs/superpowers/plans/YYYY-MM-DD-reducer-ref-structural-capability.md -
Step 1: Document reducer refs are capability refs
In docs/structural_refs.md, add:
Reducer refs are capability refs, not graph paths. `wf.std.add` is shorthand
for source `wf.std`, capability key `add`. The reducer cleanup should move
ReducerRef toward structural CapabilityRef while keeping string reducer names
as parse-only shorthand.
- Step 2: Create follow-up plan stub
Create a separate plan with only the intended boundary:
ReducerRef.name: strremains compatibility display/shorthand for now.- Add
ReducerRef.ref: CapabilityRefor replacenamewith aCapabilityRefafter artifact/source dependency code is ready. - Update artifact dependency extraction from reducer refs.
- Keep configured reducers as
{ref/name, config}.
Do not implement reducer structural refs in the state-schema path sweep unless the user explicitly asks to combine them.
Task 7: Verification
Files:
-
All touched files.
-
Step 1: Run focused core tests
uv run --with pytest pytest tests/core/test_nested_state_paths.py tests/core/test_atomic_state_patches.py tests/core/test_schema_validation.py -q
Expected: pass.
- Step 2: Run authoring schema tests
uv run --with pytest pytest tests/authoring/test_schemas.py -q
Expected: pass.
- Step 3: Run full tests
uv run --with pytest pytest -q
Expected: pass.
- Step 4: Run lint/type checks
uvx ruff check src/wf_core src/wf_authoring tests/core tests/authoring
uvx ruff format --check src/wf_core src/wf_authoring tests/core tests/authoring
uv run basedpyright --level error src/wf_core src/wf_authoring tests/core tests/authoring
Expected:
- ruff check passes
- format check passes
- basedpyright reports
0 errors
Self-Review Checklist
- JSON Schema property names containing dots stay one
StatePathsegment. - Runtime reducer lookup uses
StatePath, not rootless dotted strings. field_map()remains available for compatibility but is not the preferred internal API.StateFieldDecl.pathno longer forces string serialization if the project has moved to structural path JSON.- Reducer refs are documented as capability refs, not graph paths.
- Reducer structural ref implementation is not accidentally mixed into the state schema path sweep.