Files
lda-wf/docs/historical/superpowers/plans/2026-05-21-authoring-path-inputs.md
T

14 KiB

Authoring Path Inputs 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 wf_authoring understand structural, string, iterable, and vararg path inputs consistently across DSL helpers and WorkflowBuilder.use().

Architecture: Keep wf_core path objects as the canonical runtime model. Add one authoring coercion layer that turns ergonomic inputs into GraphSourcePath, StatePath, and LocalPath. Single-string helper calls parse TOML dotted-key expressions; varargs and iterables are literal path parts. Builder maps should normalize into typed path objects instead of string-to-string maps so the authoring layer stops being another dotted-string boundary.

Tech Stack: Python 3.14, stdlib tomllib, wf_core.paths, wf_authoring.dsl, WorkflowBuilder, pytest.


Current State

The core now supports structural path objects:

{ "root": "state", "parts": ["person.name", "three and four"] }

But wf_authoring still stores paths as strings:

  • wf_authoring.dsl.paths.GraphPath.value: str
  • wf_authoring.dsl.mapping.PathArg = str | GraphPath
  • WorkflowBuilder.use(..., in_map=..., out_map=...) normalizes maps to dict[str, str]
  • builder internals call LocalPath.parse(...), GraphSourcePath.parse(...), and StatePath.parse(...)

That means authoring helpers still risk ambiguity:

state("person.name")

Today this is dotted shorthand. To express a literal field named person.name, users need structural/literal segment input.


Semantics

Single String Argument

Parse as TOML dotted-key expression:

state("person.name")
# parts: ["person", "name"]

state('"person.name"')
# parts: ["person.name"]

state('person."three and four"')
# parts: ["person", "three and four"]

Varargs

Treat each argument as a literal path segment:

state("person.name", "email address")
# parts: ["person.name", "email address"]

state_path("oh", "my", "days")
# parts: ["oh", "my", "days"]

Iterable Input

Treat iterable items as literal path segments:

state(("person.name",))
# parts: ["person.name"]

input_path(["user", "email"])
# parts: ["user", "email"]

Existing Path Objects

Pass through path objects without reparsing:

state_path(StatePath(("person.name",)))
input_path(GraphSourcePath.input("user"))

Structural Path Dicts

Accept structural core path dicts at authoring boundaries when data is already model-shaped:

state_path({"root": "state", "parts": ["person.name"]})
input_path({"root": "input", "parts": ["user", "email"]})

This keeps MCP / JSON-facing callers from converting canonical objects back into display strings just to pass through wf_authoring.


File Structure

  • Create: src/wf_authoring/dsl/path_inputs.py

    • Own PathInput type alias.
    • Own TOML key-expression parser using tomllib.
    • Own coercion functions for graph/local/state paths.
  • Modify: src/wf_authoring/dsl/paths.py

    • Make GraphPath wrap GraphSourcePath, not a string.
    • Update graph_path, input_path, state_path, context_path.
  • Modify: src/wf_authoring/dsl/conditions.py

    • Use typed GraphSourcePath directly from GraphPath / PathExpr.
    • Update state(...), input(...), and context(...) to accept PathInput.
  • Modify: src/wf_authoring/dsl/mapping.py

    • Expand PathArg to include structural/core path objects and iterable parts.
    • Keep bind_fields / bind_state APIs stable, but normalize through the new coercers.
  • Modify: src/wf_authoring/builder/mapping.py

    • Normalize MapArg to typed paths, not strings.
    • Keep legacy string map support.
  • Modify: src/wf_authoring/builder/core.py

    • Change _canonical_input_bindings / _canonical_output_bindings to accept typed path mappings.
    • Stop reparsing paths from strings when already typed.
  • Test:

    • tests/authoring/test_path_inputs.py
    • tests/authoring/test_builder.py
    • tests/authoring/test_conditions.py

Task 1: Add Path Input Coercion Module

Files:

  • Create: src/wf_authoring/dsl/path_inputs.py

  • Test: tests/authoring/test_path_inputs.py

  • Step 1: Write failing tests

from wf_authoring.dsl.path_inputs import (
    coerce_graph_path,
    coerce_local_path,
    coerce_state_path,
)
from wf_core.paths import GraphSourcePath, LocalPath, StatePath


def test_single_string_path_input_uses_toml_dotted_key_syntax() -> None:
    assert coerce_state_path("person.name") == StatePath(("person", "name"))
    assert coerce_state_path('"person.name"') == StatePath(("person.name",))
    assert coerce_state_path('person."three and four"') == StatePath(
        ("person", "three and four")
    )


def test_vararg_path_input_treats_parts_as_literal_segments() -> None:
    assert coerce_state_path("person.name", "email address") == StatePath(
        ("person.name", "email address")
    )


def test_iterable_path_input_treats_items_as_literal_segments() -> None:
    assert coerce_local_path(("payload.text",)) == LocalPath(("payload.text",))


def test_existing_path_objects_pass_through() -> None:
    source = GraphSourcePath("state", ("person.name",))
    assert coerce_graph_path(source) is source


def test_structural_path_dicts_validate_through_core_models() -> None:
    assert coerce_graph_path({"root": "state", "parts": ["person.name"]}) == (
        GraphSourcePath("state", ("person.name",))
    )
  • Step 2: Run tests to verify red
uv run --with pytest pytest tests/authoring/test_path_inputs.py -q

Expected: fails because module does not exist.

  • Step 3: Implement parser using tomllib

Implement:

import tomllib
from collections.abc import Iterable, Mapping
from typing import TypeAlias

from wf_core.paths import GraphSourcePath, LocalPath, StatePath

PathInput: TypeAlias = (
    str
    | Iterable[str]
    | Mapping[str, object]
    | GraphSourcePath
    | StatePath
    | LocalPath
)

Parser approach:

def _parse_toml_key_expr(expr: str) -> tuple[str, ...]:
    parsed = tomllib.loads(f"{expr} = true")
    ...

Walk the nested dict until the leaf value is True; each nested key is one path segment.

Rules:

  • one str argument parses as TOML key expression

  • multiple str arguments are literal segments

  • one iterable argument is literal segments

  • existing path object passes through when compatible

  • structural dicts validate through the matching core path model

  • invalid TOML raises ValueError with message mentioning TOML key expression

  • Step 4: Run tests to verify green

uv run --with pytest pytest tests/authoring/test_path_inputs.py -q

Expected: all tests pass.


Task 2: Make DSL Path Helpers Typed

Files:

  • Modify: src/wf_authoring/dsl/paths.py

  • Modify: src/wf_authoring/dsl/conditions.py

  • Test: tests/authoring/test_path_inputs.py

  • Test: tests/authoring/test_conditions.py

  • Step 1: Write failing helper tests

Add:

from wf_authoring import state, state_path
from wf_core.paths import GraphSourcePath


def test_state_path_helper_supports_toml_strings_and_literal_varargs() -> None:
    assert state_path('"person.name"').path == GraphSourcePath(
        "state", ("person.name",)
    )
    assert state_path("person.name", "email address").path == GraphSourcePath(
        "state", ("person.name", "email address")
    )


def test_state_expr_helper_uses_same_path_input_rules() -> None:
    condition = state('"person.name"').eq("Ada").to_condition()

    assert condition.left.path == GraphSourcePath("state", ("person.name",))
  • Step 2: Run tests to verify red
uv run --with pytest pytest tests/authoring/test_path_inputs.py tests/authoring/test_conditions.py -q

Expected: old helpers either split incorrectly or do not accept these signatures.

  • Step 3: Update GraphPath

Change:

@dataclass(frozen=True, slots=True)
class GraphPath:
    path: GraphSourcePath

    @property
    def value(self) -> str:
        return str(self.path)

Keep .value as compatibility display output.

  • Step 4: Update helper signatures
def input_path(first: PathInput, *parts: str) -> GraphPath: ...
def state_path(first: PathInput, *parts: str) -> GraphPath: ...
def context_path(first: PathInput, *parts: str) -> GraphPath: ...

Use coercers from path_inputs.py.

  • Step 5: Update conditions

Make PathExpr store GraphSourcePath, while keeping .path display property if needed:

@dataclass(frozen=True, slots=True)
class PathExpr:
    source: GraphSourcePath

    @property
    def path(self) -> str:
        return str(self.source)

Use PathOperand(path=self.source) instead of reparsing strings.

  • Step 6: Run tests
uv run --with pytest pytest tests/authoring/test_path_inputs.py tests/authoring/test_conditions.py -q

Expected: all pass.


Task 3: Make Builder Maps Accept Typed Path Inputs

Files:

  • Modify: src/wf_authoring/builder/mapping.py

  • Modify: src/wf_authoring/builder/core.py

  • Modify: src/wf_authoring/dsl/mapping.py

  • Test: tests/authoring/test_builder.py

  • Step 1: Write failing builder tests

Add:

from wf_authoring import input_path, state_path
from wf_core.paths import GraphSourcePath, LocalPath, StatePath


def test_builder_use_accepts_typed_paths_and_literal_iterable_paths() -> None:
    builder = WorkflowBuilder(...)
    step = builder.use(
        auto_bind_node,
        in_map={input_path('"text.with.dot"'): ("payload.text",)},
        out_map={("payload.text",): state_path("state field")},
    )

    assert step.input[0].path == GraphSourcePath("input", ("text.with.dot",))
    assert step.input[0].target == LocalPath(("payload.text",))
    assert step.output[0].source == LocalPath(("payload.text",))
    assert step.output[0].target == StatePath(("state field",))

Use existing builder test fixtures in tests/authoring/test_builder.py.

  • Step 2: Run tests to verify red
uv run --with pytest pytest tests/authoring/test_builder.py -q

Expected: tuple/typed map values fail.

  • Step 3: Update map normalization

In builder/mapping.py, introduce typed mapping aliases:

InputMap = dict[GraphSourcePath, LocalPath]
OutputMap = dict[LocalPath, StatePath]

Add:

normalize_input_mapping(mapping: MapArg | None) -> InputMap
normalize_output_mapping(mapping: MapArg | None) -> OutputMap

Rules:

  • input map key = graph source path
  • input map value = local path
  • output map key = local path
  • output map value = state path

Legacy strings still parse through the new coercers.

  • Step 4: Update builder core

Change _canonical_input_bindings:

def _canonical_input_bindings(
    in_map: Mapping[GraphSourcePath, LocalPath],
    input_values: Mapping[LocalPath, Any],
) -> list[InputBinding]:

Change _canonical_output_bindings:

def _canonical_output_bindings(
    out_map: Mapping[LocalPath, StatePath],
) -> list[OutputBinding]:

InputValueBinding.target should also accept typed/local path input.

  • Step 5: Update DSL mapping helpers

bind_fields(**mapping) and bind_state(**mapping) can keep returning dicts, but values should be normalized display/typed consistently. Prefer returning typed path maps if that does not break tests; otherwise keep their public shape and let builder normalize.

  • Step 6: Run tests
uv run --with pytest pytest tests/authoring/test_builder.py tests/authoring/test_demo_workflow.py tests/authoring/test_ops.py -q

Expected: all pass.


Task 3.5: Foreach Boundary Check

Files:

  • Inspect: src/wf_authoring/builder/core.py
  • Inspect: src/wf_core/models/steps.py or current foreach model location

WorkflowBuilder.foreach(over=...) also accepts path-like input today, but the core foreach model may still store the source path as a string. Do not let this block WorkflowBuilder.use() map normalization.

  • Step 1: Inspect foreach field type

If core foreach already accepts GraphSourcePath, normalize over through the new graph-path coercer and add one focused test.

If core foreach still accepts only strings, keep the existing string serialization path and leave a short comment at the call site:

foreach path input should move to typed GraphSourcePath when the core foreach
model is upgraded.
  • Step 2: Avoid partial semantic claims

Do not document foreach as fully structural until the core field is structural.


Task 4: Docs and Examples

Files:

  • Modify: docs/structural_refs.md

  • Modify or create an authoring docs/example if one already exists.

  • Step 1: Add authoring examples

Add:

state("person.name")          # TOML/dotted expression
state('"person.name"')        # literal dotted field
state("person.name", "email") # literal segments
state(("person.name",))       # literal iterable
  • Step 2: Mention builder maps

Add:

g.use(
    node,
    in_map={input_path('"email.address"'): ("payload.email",)},
    out_map={("result.score",): state_path("score")},
)

Task 5: Verification

  • Step 1: Run focused authoring tests
uv run --with pytest pytest tests/authoring -q
  • Step 2: Run full tests
uv run --with pytest pytest -q
  • Step 3: Run checks
uvx ruff check src/wf_authoring src/wf_core/paths.py tests/authoring
uv run basedpyright --level error src/wf_authoring src/wf_core/paths.py tests/authoring

Self-Review Notes

  • Do not roll a custom TOML parser. Use stdlib tomllib.
  • Canonical saved JSON remains structural root / parts.
  • Single strings are ergonomic expressions. Varargs and iterables are literal segments.
  • Keep .value / str(...) as display compatibility only.
  • WorkflowBuilder.use() should stop being a string-to-string path boundary.
  • If Pydantic path models are not hashable enough for dict keys, normalize maps into explicit binding-pair lists internally instead of falling back to display strings.