23 KiB
Builder Canonical Bindings 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: Make WorkflowBuilder.use() and WorkflowBuilder.use_ref() expose canonical input / output binding lists, while keeping in_map, input_values, and out_map as deprecated Python sugar.
Architecture: wf_core.NodeUse already stores canonical binding structs: InputPathBinding, InputValueBinding, and OutputBinding. The builder should accept those same structs/dicts directly, normalize them through core models, and reject mixed canonical/deprecated arguments. Map sugar remains for pleasant Python authoring, but JSON/MCP-facing callers should use binding lists so structural path dicts live inside structs, not as unhashable mapping keys.
Tech Stack: Python 3.14, Pydantic models from wf_core.models.steps, wf_authoring.WorkflowBuilder, pytest, basedpyright, ruff.
Why the Previous Plan Did Not Finish This
2026-05-21-authoring-path-inputs.md focused on path coercion:
- single-string TOML path parsing
- iterable/vararg literal segments
- typed
GraphPath - map normalization from
dict[str, str]toward typed paths
That plan made in_map, input_values, and out_map safer, but it did not change the public builder API shape. So the current state is still incomplete:
g.use(node, in_map=..., input_values=..., out_map=...)
exists, but:
g.use(node, input=[...], output=[...])
does not.
That matters because structural path dicts cannot be Python dict keys. The JSON/MCP-friendly shape must be list-of-structs:
{
"input": [
{
"target": { "root": "local", "parts": ["payload.email"] },
"path": { "root": "input", "parts": ["email.address"] }
}
],
"output": [
{
"source": { "root": "local", "parts": ["result.score"] },
"target": { "root": "state", "parts": ["score"] }
}
]
}
Current State
Core already has the right canonical models in src/wf_core/models/steps.py:
class InputPathBinding(BaseModel):
target: LocalPath
path: GraphSourcePath
class InputValueBinding(BaseModel):
target: LocalPath
value: object
class OutputBinding(BaseModel):
source: LocalPath
target: StatePath
class NodeUse(BaseModel):
input: list[InputBinding] = Field(default_factory=list)
output: list[OutputBinding] = Field(default_factory=list)
Builder currently has only deprecated/sugar arguments in src/wf_authoring/builder/core.py:
def use(
self,
spec: NodeSpec[Any, Any],
*,
id: str | None = None,
in_map: MapArg | None = None,
input_values: Mapping[Any, Any] | None = None,
out_map: MapArg | None = None,
desc: str | None = None,
) -> NodeUse:
...
This is the API gap.
Public Semantics
Canonical Builder Inputs
Add input and output parameters:
g.use(
node,
input=[
{
"target": {"root": "local", "parts": ["payload.email"]},
"path": {"root": "input", "parts": ["email.address"]},
},
{
"target": {"root": "local", "parts": ["static.limit"]},
"value": 10,
},
],
output=[
{
"source": {"root": "local", "parts": ["result.score"]},
"target": {"root": "state", "parts": ["score"]},
},
],
)
Accepted item shapes:
- existing
InputPathBinding - existing
InputValueBinding - existing
OutputBinding - dicts that
InputPathBinding/InputValueBinding/OutputBindingcan validate
Deprecated Sugar Inputs
Keep these for Python authors:
g.use(node, in_map={state_path("text"): "payload.text"})
g.use(node, input_values={"limit": 10})
g.use(node, out_map={"result.score": state_path("score")})
But mark them as deprecated in docstrings and warn when explicitly used.
Auto-mapping still uses the same internal sugar when both canonical and deprecated args are absent.
Mixing Rules
Reject ambiguous combinations:
inputcannot be mixed within_mapinputcannot be mixed withinput_valuesoutputcannot be mixed without_map
Exact error examples:
cannot mix canonical input with deprecated in_map/input_values
cannot mix canonical output with deprecated out_map
Use built-in TypeError for these authoring API misuse errors. This is similar
in spirit to Pydantic's user-error category: the caller supplied an invalid API
shape, not invalid workflow data.
Structural Dict Key Rule
Do not support structural dicts as mapping keys. Python dict keys must be hashable, and adding frozendict support is not worth it.
If a user needs structural dict paths, they should use canonical binding lists:
input=[{"target": {"root": "local", "parts": ["payload"]}, "path": {...}}]
Map sugar is for hashable Python authoring values only.
File Structure
-
Modify:
src/wf_authoring/builder/core.py- Add
input/outputparameters touse()anduse_ref(). - Add canonical/deprecated mixing checks.
- Use canonical binding normalization when provided.
- Warn when deprecated map-sugar args are explicitly used.
- Add
-
Modify:
src/wf_authoring/builder/mapping.py- Add
InputBindingArg,OutputBindingArgaliases. - Add
normalize_input_bindings(...). - Add
normalize_output_bindings(...). - Keep map normalizers as deprecated/sugar internals.
- Add
-
Modify:
docs/structural_refs.md- Document canonical
input/outputlist usage for structural path dicts. - State that structural dicts are not supported as map keys.
- Document canonical
-
Modify:
docs/authoring_sketch.mdordocs/core_state_mapping_and_merge.md- Replace older “builder uses in_map/out_map” framing with “builder accepts canonical lists; maps are sugar.”
-
Test:
tests/authoring/test_builder.py- Maybe
tests/authoring/test_path_inputs.pyonly if structural dict errors belong there.
Task 1: Add Canonical Binding Normalizers
Files:
-
Modify:
src/wf_authoring/builder/mapping.py -
Test:
tests/authoring/test_builder.py -
Step 1: Write failing tests for canonical dict bindings
Add to tests/authoring/test_builder.py:
def test_builder_use_accepts_canonical_binding_dicts_with_structural_paths() -> None:
builder = WorkflowBuilder(
name="canonical_binding_dicts",
input_schema=AutoBindInput,
state_schema=AutoBindState,
output_schema=AutoBindOutput,
)
step = builder.use(
auto_bind_node,
input=[
{
"target": {"root": "local", "parts": ["payload.text"]},
"path": {"root": "input", "parts": ["text.with.dot"]},
},
{
"target": {"root": "local", "parts": ["static.limit"]},
"value": 3,
},
],
output=[
{
"source": {"root": "local", "parts": ["payload.text"]},
"target": {"root": "state", "parts": ["text.with.dot"]},
}
],
)
assert isinstance(step.input[0], InputPathBinding)
assert step.input[0].path == GraphSourcePath("input", ("text.with.dot",))
assert step.input[0].target == LocalPath(("payload.text",))
assert isinstance(step.input[1], InputValueBinding)
assert step.input[1].target == LocalPath(("static.limit",))
assert step.input[1].value == 3
assert step.output[0].source == LocalPath(("payload.text",))
assert step.output[0].target == StatePath(("text.with.dot",))
Update imports:
from wf_core.models.steps import InputPathBinding, InputValueBinding
- Step 2: Run test to verify red
uv run --with pytest pytest tests/authoring/test_builder.py::test_builder_use_accepts_canonical_binding_dicts_with_structural_paths -q
Expected: fails because WorkflowBuilder.use() has no input / output parameters.
- Step 3: Add normalizer aliases and functions
In src/wf_authoring/builder/mapping.py, add:
from wf_core.models.steps import InputBinding, InputPathBinding, InputValueBinding, OutputBinding
InputBindingArg: TypeAlias = InputBinding | Mapping[str, object]
OutputBindingArg: TypeAlias = OutputBinding | Mapping[str, object]
Add:
def normalize_input_bindings(bindings: Sequence[InputBindingArg] | None) -> list[InputBinding]:
"""Validate canonical input binding structs for WorkflowBuilder.use()."""
if bindings is None:
return []
normalized: list[InputBinding] = []
for binding in bindings:
if isinstance(binding, InputPathBinding | InputValueBinding):
normalized.append(binding)
continue
if not isinstance(binding, Mapping):
raise TypeError(f"unsupported input binding {binding!r}")
if "path" in binding:
normalized.append(InputPathBinding.model_validate(binding))
elif "value" in binding:
normalized.append(InputValueBinding.model_validate(binding))
else:
raise ValueError("input binding must contain either 'path' or 'value'")
return normalized
Add:
def normalize_output_bindings(bindings: Sequence[OutputBindingArg] | None) -> list[OutputBinding]:
"""Validate canonical output binding structs for WorkflowBuilder.use()."""
if bindings is None:
return []
normalized: list[OutputBinding] = []
for binding in bindings:
if isinstance(binding, OutputBinding):
normalized.append(binding)
continue
if not isinstance(binding, Mapping):
raise TypeError(f"unsupported output binding {binding!r}")
normalized.append(OutputBinding.model_validate(binding))
return normalized
- Step 4: Run focused normalizer-related test
uv run --with pytest pytest tests/authoring/test_builder.py::test_builder_use_accepts_canonical_binding_dicts_with_structural_paths -q
Expected: still fails until builder signatures are updated.
Task 2: Add input / output to use()
Files:
-
Modify:
src/wf_authoring/builder/core.py -
Test:
tests/authoring/test_builder.py -
Step 1: Update imports
In src/wf_authoring/builder/core.py, import new aliases/functions:
from .mapping import (
InputBindingArg,
OutputBindingArg,
normalize_input_bindings,
normalize_output_bindings,
)
- Step 2: Update
use()signature
Change:
def use(
self,
spec: NodeSpec[Any, Any],
*,
id: str | None = None,
in_map: MapArg | None = None,
input_values: Mapping[Any, Any] | None = None,
out_map: MapArg | None = None,
desc: str | None = None,
) -> NodeUse:
to:
def use(
self,
spec: NodeSpec[Any, Any],
*,
id: str | None = None,
input: Sequence[InputBindingArg] | None = None,
output: Sequence[OutputBindingArg] | None = None,
in_map: MapArg | None = None,
input_values: Mapping[Any, Any] | None = None,
out_map: MapArg | None = None,
desc: str | None = None,
) -> NodeUse:
Import Sequence from collections.abc.
- Step 3: Add mixing guard helper
Add near the canonical binding helpers:
def _reject_mixed_binding_styles(
*,
input: object | None,
output: object | None,
in_map: object | None,
input_values: object | None,
out_map: object | None,
) -> None:
"""Keep canonical binding lists and deprecated map sugar from mixing."""
if input is not None and (in_map is not None or input_values is not None):
raise TypeError("cannot mix canonical input with deprecated in_map/input_values")
if output is not None and out_map is not None:
raise TypeError("cannot mix canonical output with deprecated out_map")
- Step 4: Use canonical bindings when provided
In use():
_reject_mixed_binding_styles(
input=input,
output=output,
in_map=in_map,
input_values=input_values,
out_map=out_map,
)
if input is not None:
node_input = normalize_input_bindings(input)
else:
raw_in_map = auto_input_map(...) if in_map is None else in_map
node_input = _canonical_input_bindings(
normalize_input_mapping(raw_in_map),
normalize_input_values(input_values),
)
if output is not None:
node_output = normalize_output_bindings(output)
else:
raw_out_map = auto_output_map(...) if out_map is None else out_map
node_output = _canonical_output_bindings(normalize_output_mapping(raw_out_map))
Then pass:
input=node_input,
output=node_output,
- Step 5: Run focused test
uv run --with pytest pytest tests/authoring/test_builder.py::test_builder_use_accepts_canonical_binding_dicts_with_structural_paths -q
Expected: pass.
Task 3: Add input / output to use_ref()
Files:
-
Modify:
src/wf_authoring/builder/core.py -
Test:
tests/authoring/test_builder.py -
Step 1: Write failing test
Add:
def test_builder_use_ref_accepts_canonical_binding_dicts() -> None:
builder = WorkflowBuilder(
name="external_ref_canonical_bindings",
input_schema={},
state_schema={"fields": {}},
output_schema={},
)
step = builder.use_ref(
"demo.echo",
id="echo",
input=[
{
"target": {"root": "local", "parts": ["text"]},
"path": {"root": "input", "parts": ["text"]},
}
],
output=[
{
"source": {"root": "local", "parts": ["echoed"]},
"target": {"root": "state", "parts": ["echoed"]},
}
],
)
assert step.node == "demo.echo"
assert isinstance(step.input[0], InputPathBinding)
assert step.input[0].path == GraphSourcePath.input("text")
assert step.output[0].target == StatePath.of("echoed")
- Step 2: Update
use_ref()signature
Add:
input: Sequence[InputBindingArg] | None = None,
output: Sequence[OutputBindingArg] | None = None,
before deprecated map args.
- Step 3: Use same mixing guard and normalization
use_ref() has no auto-map fallback, so logic is simpler:
_reject_mixed_binding_styles(...)
node_input = (
normalize_input_bindings(input)
if input is not None
else _canonical_input_bindings(
normalize_input_mapping(in_map),
normalize_input_values(input_values),
)
)
node_output = (
normalize_output_bindings(output)
if output is not None
else _canonical_output_bindings(normalize_output_mapping(out_map))
)
- Step 4: Run focused test
uv run --with pytest pytest tests/authoring/test_builder.py::test_builder_use_ref_accepts_canonical_binding_dicts -q
Expected: pass.
Task 4: Deprecate Map Sugar Explicitly
Files:
-
Modify:
src/wf_authoring/builder/core.py -
Test:
tests/authoring/test_builder.py -
Step 1: Add warning helper
Add:
def _warn_deprecated_binding_sugar(
*,
in_map: object | None,
input_values: object | None,
out_map: object | None,
) -> None:
"""Warn when callers explicitly use map sugar instead of canonical bindings."""
used = [
name
for name, value in (
("in_map", in_map),
("input_values", input_values),
("out_map", out_map),
)
if value is not None
]
if not used:
return
warnings.warn(
f"{', '.join(used)} are deprecated WorkflowBuilder sugar; use canonical "
"input/output binding lists instead",
DeprecationWarning,
stacklevel=3,
)
Auto-mapping when args are omitted must not warn.
- Step 2: Add warning tests
Add:
def test_builder_warns_when_explicit_deprecated_maps_are_used() -> None:
builder = WorkflowBuilder(
name="deprecated_maps",
input_schema=AutoBindInput,
state_schema=AutoBindState,
output_schema=AutoBindOutput,
)
with pytest.warns(DeprecationWarning, match="canonical input/output"):
builder.use(
auto_bind_node,
in_map={"input.text": "text"},
out_map={"text": "state.text"},
)
Add:
def test_builder_auto_mapping_does_not_warn() -> None:
builder = WorkflowBuilder(
name="auto_map_no_warning",
input_schema=AutoBindInput,
state_schema=AutoBindState,
output_schema=AutoBindOutput,
)
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
builder.use(auto_bind_node)
Import warnings in the test file.
- Step 3: Call warning helper
In use() and use_ref(), after the mixing guard:
_warn_deprecated_binding_sugar(
in_map=in_map,
input_values=input_values,
out_map=out_map,
)
- Step 4: Run focused warning tests
uv run --with pytest pytest tests/authoring/test_builder.py::test_builder_warns_when_explicit_deprecated_maps_are_used tests/authoring/test_builder.py::test_builder_auto_mapping_does_not_warn -q
Expected: both pass.
Task 5: Reject Mixed Styles and Dict Keys Clearly
Files:
-
Modify:
src/wf_authoring/builder/core.py -
Modify:
src/wf_authoring/builder/mapping.py -
Test:
tests/authoring/test_builder.py -
Step 1: Add mixed-style tests
Add:
def test_builder_rejects_mixed_canonical_and_deprecated_input_styles() -> None:
builder = WorkflowBuilder(
name="mixed_input_styles",
input_schema=AutoBindInput,
state_schema=AutoBindState,
output_schema=AutoBindOutput,
)
with pytest.raises(TypeError, match="cannot mix canonical input"):
builder.use(
auto_bind_node,
input=[{"target": "text", "path": "input.text"}],
in_map={"input.text": "text"},
)
Add:
def test_builder_rejects_mixed_canonical_and_deprecated_output_styles() -> None:
builder = WorkflowBuilder(
name="mixed_output_styles",
input_schema=AutoBindInput,
state_schema=AutoBindState,
output_schema=AutoBindOutput,
)
with pytest.raises(TypeError, match="cannot mix canonical output"):
builder.use(
auto_bind_node,
output=[{"source": "text", "target": "state.text"}],
out_map={"text": "state.text"},
)
- Step 2: Add dict-key diagnostic test
Python literal dicts cannot contain dict keys, so test the normalizer directly with a custom Mapping that yields a structural dict key:
class _StructuralKeyMap:
def items(self):
return [
(
{"root": "input", "parts": ["email.address"]},
"payload.email",
)
]
def test_input_map_rejects_structural_dict_keys_with_clear_message() -> None:
with pytest.raises(TypeError, match="structural path dicts cannot be map keys"):
normalize_input_mapping(_StructuralKeyMap())
Import normalize_input_mapping from wf_authoring.builder.mapping.
- Step 3: Implement dict-key guard
In normalize_input_mapping():
def _reject_mapping_path_key(value: object, *, field_name: str) -> None:
if isinstance(value, Mapping):
raise TypeError(
f"structural path dicts cannot be map keys in {field_name}; "
"use canonical input/output binding lists instead"
)
Call it on source keys for input maps and source keys for output maps before coercion.
Do not reject structural dict values, because values are allowed:
out_map={"result": {"root": "state", "parts": ["score"]}}
- Step 4: Run focused tests
uv run --with pytest pytest tests/authoring/test_builder.py::test_builder_rejects_mixed_canonical_and_deprecated_input_styles tests/authoring/test_builder.py::test_builder_rejects_mixed_canonical_and_deprecated_output_styles tests/authoring/test_builder.py::test_input_map_rejects_structural_dict_keys_with_clear_message -q
Expected: all pass.
Task 6: Docs
Files:
-
Modify:
docs/structural_refs.md -
Modify:
docs/authoring_sketch.md -
Modify:
docs/core_state_mapping_and_merge.md -
Step 1: Update structural refs authoring example
In docs/structural_refs.md, replace the current map-sugar-first example with canonical binding list example:
g.use(
node,
input=[
{
"target": {"root": "local", "parts": ["payload.email"]},
"path": {"root": "input", "parts": ["email.address"]},
}
],
output=[
{
"source": {"root": "local", "parts": ["result.score"]},
"target": {"root": "state", "parts": ["score"]},
}
],
)
Then state:
`in_map`, `input_values`, and `out_map` remain deprecated Python sugar.
Structural path dicts are not valid map keys; use canonical binding lists when
working from JSON/MCP.
- Step 2: Update authoring sketch
In docs/authoring_sketch.md, update the API sketch from:
use(node_spec, id=..., in_map=..., out_map=...)
to:
use(node_spec, id=..., input=[...], output=[...])
Then mention:
`in_map`, `input_values`, and `out_map` are compatibility sugar for Python
authors, not the preferred saved or MCP-facing shape.
- Step 3: Update core mapping docs
In docs/core_state_mapping_and_merge.md, ensure the docs say:
The canonical public shape is list-of-binding structs. Deprecated map fields
are parse-only compatibility inputs at core level and Python sugar at builder
level.
Task 7: Verification
Files:
-
All touched files.
-
Step 1: Run focused authoring builder tests
uv run --with pytest pytest tests/authoring/test_builder.py -q
Expected: pass.
- Step 2: Run authoring tests
uv run --with pytest pytest tests/authoring -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_authoring tests/authoring
uvx ruff format --check src/wf_authoring tests/authoring docs/structural_refs.md docs/authoring_sketch.md docs/core_state_mapping_and_merge.md
uv run basedpyright --level error src/wf_authoring tests/authoring
Expected:
- ruff check passes
- format check passes or reports only markdown files if ruff does not handle them
- basedpyright reports
0 errors
Self-Review Checklist
g.use(input=[...], output=[...])exists.g.use_ref(input=[...], output=[...])exists.input_valuesstill exists, but emitsDeprecationWarningwhen explicitly used.in_mapandout_mapstill exist, but emitDeprecationWarningwhen explicitly used.- Auto-mapping does not warn.
- Canonical list inputs support structural path dicts inside binding structs.
- Structural dicts as map keys are rejected with a clear message.
- Saved/core
NodeUseoutput remains canonicalinput/output; deprecated map fields do not reappear in dumps.