Files
lda-wf/docs/historical/superpowers/plans/2026-05-21-builder-canonical-bindings.md
T

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 / OutputBinding can 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:

  • input cannot be mixed with in_map
  • input cannot be mixed with input_values
  • output cannot be mixed with out_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 / output parameters to use() and use_ref().
    • Add canonical/deprecated mixing checks.
    • Use canonical binding normalization when provided.
    • Warn when deprecated map-sugar args are explicitly used.
  • Modify: src/wf_authoring/builder/mapping.py

    • Add InputBindingArg, OutputBindingArg aliases.
    • Add normalize_input_bindings(...).
    • Add normalize_output_bindings(...).
    • Keep map normalizers as deprecated/sugar internals.
  • Modify: docs/structural_refs.md

    • Document canonical input / output list usage for structural path dicts.
    • State that structural dicts are not supported as map keys.
  • Modify: docs/authoring_sketch.md or docs/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.py only 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_values still exists, but emits DeprecationWarning when explicitly used.
  • in_map and out_map still exist, but emit DeprecationWarning when 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 NodeUse output remains canonical input / output; deprecated map fields do not reappear in dumps.