Files
lda-wf/docs/historical/superpowers/plans/2026-05-21-state-schema-and-reducer-ref-path-sweep.md
T

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.path dumps 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 ReducerRef to carry a structural CapabilityRef.
  • 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.py

    • StateFieldDecl.path: StatePath
    • StateFieldDecl._serialize_path() currently returns str(path)
    • StateSchema.field_map() returns dict[str, StateFieldDecl]
    • _iter_state_field_declarations(...) builds path as dotted string
    • _set_state_property_schema(...) receives path_parts
  • src/wf_core/models/reducers.py

    • ReducerRef.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

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_declarations signature

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 StateFieldDecl construction

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: str remains compatibility display/shorthand for now.
  • Add ReducerRef.ref: CapabilityRef or replace name with a CapabilityRef after 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 StatePath segment.
  • Runtime reducer lookup uses StatePath, not rootless dotted strings.
  • field_map() remains available for compatibility but is not the preferred internal API.
  • StateFieldDecl.path no 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.