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

571 lines
15 KiB
Markdown

# 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:
```json
{ "root": "state", "parts": ["person.name"] }
```
But state schema indexing still builds rootless dotted strings in places:
```python
path = f"{prefix}.{name}"
StatePath.of(path)
```
That can corrupt JSON Schema property names containing dots:
```json
{
"type": "object",
"properties": {
"person.name": { "type": "string", "reducer": "wf.std.replace" }
}
}
```
The intended state path is:
```text
state -> "person.name"
```
not:
```text
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:
```text
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`:
```python
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:
```python
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**
```bash
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:
```python
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`:
```python
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:
```python
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:
```python
prefix: str
) -> Iterator[tuple[str, StateFieldDecl]]:
```
to typed prefix:
```python
prefix: tuple[str, ...]
) -> Iterator[tuple[StatePath, StateFieldDecl]]:
```
Inside loop:
```python
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:
```python
"path": StatePath.of(path),
```
to:
```python
"path": path,
```
- [ ] **Step 6: Update recursive calls**
Pass:
```python
prefix=path.parts
```
not a dotted string.
- [ ] **Step 7: Run focused tests**
```bash
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:
```python
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:
```python
state_fields: Mapping[str, StateFieldDecl]
```
to:
```python
state_fields: Mapping[StatePath, StateFieldDecl]
```
- [ ] **Step 3: Use typed lookup**
When resolving reducer for a write target:
```python
declared_field = state_fields.get(target)
```
where `target` is already a `StatePath`.
If code currently has only path parts, construct:
```python
target = StatePath(tuple(path_parts))
```
- [ ] **Step 4: Update affected-field overlap helper**
If `_affected_state_fields(...)` compares string prefixes, make it compare tuple parts:
```python
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**
```bash
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:
```python
{"path": "state.person.name"}
```
Decide based on current path model direction. Since `StatePath` now serializes structurally elsewhere, prefer:
```json
{ "path": { "root": "state", "parts": ["person.name"] } }
```
- [ ] **Step 2: Change serializer**
Remove this serializer:
```python
@field_serializer("path")
def _serialize_path(self, path: StatePath) -> str:
return str(path)
```
or change it to:
```python
@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:
```python
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**
```bash
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:
```python
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:
```python
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:
```python
field_name = field_info.alias or name
path = (*prefix, field_name)
```
- [ ] **Step 3: Update lookup helpers to accept tuple parts**
Change:
```python
_lookup_mutable_property_schema(schema_payload, path)
_state_field_default(value, path, property_schema)
```
to tuple-based forms:
```python
_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**
```bash
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:
```text
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**
```bash
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**
```bash
uv run --with pytest pytest tests/authoring/test_schemas.py -q
```
Expected: pass.
- [ ] **Step 3: Run full tests**
```bash
uv run --with pytest pytest -q
```
Expected: pass.
- [ ] **Step 4: Run lint/type checks**
```bash
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.