389 lines
12 KiB
Markdown
389 lines
12 KiB
Markdown
# Safe Compatibility Merges Implementation Plan
|
|
|
|
**Status:** Completed
|
|
|
|
> **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:** Prevent compatibility-map merges from silently changing canonical step and workflow-output bindings.
|
|
|
|
**Architecture:** Keep the existing map operations, but add semantic preflight in `WorkflowDraftApi` before any patch. Step maps must round-trip their existing canonical lists exactly; workflow-output maps reject only requested sources that identify more than one existing binding.
|
|
|
|
**Tech Stack:** Python 3.14, Pydantic draft models, revisioned draft workspace store, pytest, Typer.
|
|
|
|
## Global Constraints
|
|
|
|
- Preserve canonical ordered binding lists as the source of truth.
|
|
- Reject unsafe compatibility merges before mutation.
|
|
- Preserve simple representable `--merge` behavior.
|
|
- Do not add a new binding model or change runtime binding semantics.
|
|
- Stale revision conflicts must win after request-envelope validation.
|
|
- Use focused pytest commands before broad verification.
|
|
|
|
---
|
|
|
|
### Task 1: Guard Step Input Compatibility Merges
|
|
|
|
**Files:**
|
|
- Modify: `src/wf_api/drafts.py`
|
|
- Modify: `src/wf_api/draft_authoring.py`
|
|
- Test: `tests/wf_api/test_drafts_service.py`
|
|
|
|
**Interfaces:**
|
|
- Produces: `WorkflowDraftApi._workspace_if_revision_matches`
|
|
- Produces: `_require_lossless_step_input_map_round_trip(payload, *, step_id)`
|
|
- Preserves: `WorkflowDraftApi.set_step_input_map`
|
|
|
|
- [x] **Step 1: Write failing tests for lossless and lossy input merges**
|
|
|
|
Add focused tests beside
|
|
`test_step_map_helpers_merge_with_existing_bindings`:
|
|
|
|
```python
|
|
@pytest.mark.asyncio
|
|
async def test_step_input_map_merge_rejects_canonical_source_fan_out(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api, _service, authoring = _draft_api(
|
|
FileWorkflowArtifactStore(tmp_path / "input_merge_fan_out"),
|
|
register_echo=True,
|
|
)
|
|
await api.create_draft_workspace(
|
|
workspace_id="report",
|
|
draft=_structured_report_draft(),
|
|
)
|
|
await authoring.set_step_input_bindings(
|
|
workspace_id="report",
|
|
revision=1,
|
|
step_id="report",
|
|
bindings=[
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("title"),
|
|
target=LocalPath.of("request", "title"),
|
|
),
|
|
InputPathBinding(
|
|
path=GraphSourcePath.state("title"),
|
|
target=LocalPath.of("audit", "title"),
|
|
),
|
|
],
|
|
)
|
|
before = await api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="complete canonical binding list"):
|
|
await api.set_step_input_map(
|
|
workspace_id="report",
|
|
revision=2,
|
|
step_id="report",
|
|
input_map={"input.body": "request.body"},
|
|
merge=True,
|
|
)
|
|
|
|
after = await api.get_draft_workspace(
|
|
workspace_id="report",
|
|
include_draft=True,
|
|
)
|
|
assert after == before
|
|
```
|
|
|
|
Add a second rejection test with a path binding before a literal binding,
|
|
proving the compatibility serializer cannot move that literal to its normal
|
|
literal-prefix position. Keep the existing simple merge test as the positive
|
|
representable case.
|
|
|
|
Add a stale-revision test where the draft contains fan-out but the request uses
|
|
an old revision. Assert a conflict payload, not the lossless-merge `ValueError`.
|
|
|
|
- [x] **Step 2: Run the focused tests and verify RED**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_api/test_drafts_service.py -k "step_input_map_merge" -q
|
|
```
|
|
|
|
Expected: the fan-out and interleaving tests fail because the merge succeeds or
|
|
rewrites the list; the stale-revision test fails if preflight runs first.
|
|
|
|
- [x] **Step 3: Add the revision preflight and exact round-trip guard**
|
|
|
|
In `WorkflowDraftApi`, add:
|
|
|
|
```python
|
|
def _workspace_if_revision_matches(
|
|
self,
|
|
*,
|
|
workspace_id: str,
|
|
revision: int,
|
|
) -> WorkflowDraftWorkspace | dict[str, Any]:
|
|
"""Load one workspace or return its canonical revision-conflict payload."""
|
|
```
|
|
|
|
Use the same summary/diagnostic shape already produced by
|
|
`WorkflowDraftAuthoringApi._workspace_if_revision_matches`. Refactor that
|
|
authoring helper to delegate to `self.drafts._workspace_if_revision_matches`
|
|
instead of maintaining two conflict implementations.
|
|
|
|
Add a module helper:
|
|
|
|
```python
|
|
def _require_lossless_step_input_map_round_trip(
|
|
payload: object,
|
|
*,
|
|
step_id: str,
|
|
) -> tuple[dict[str, str], dict[str, Any]]:
|
|
"""Return compatibility maps only when they reproduce the binding list."""
|
|
input_map, input_values = _input_maps_from_payload(payload)
|
|
rebuilt = _draft_input_bindings_payload(input_map, input_values)
|
|
if rebuilt != payload:
|
|
raise ValueError(
|
|
f"step {step_id!r} inputs cannot be safely merged through a "
|
|
"compatibility map; replace the complete canonical binding list instead"
|
|
)
|
|
return input_map, input_values
|
|
```
|
|
|
|
Update `set_step_input_map` so `merge=True`:
|
|
|
|
1. calls `_workspace_if_revision_matches`;
|
|
2. immediately returns a conflict payload when stale;
|
|
3. reads the selected step from that workspace;
|
|
4. calls the round-trip guard;
|
|
5. overlays the requested map;
|
|
6. delegates the final mutation to `patch_draft_workspace`.
|
|
|
|
Do not run the guard for replacement mode (`merge=False`).
|
|
|
|
- [x] **Step 4: Run focused tests and verify GREEN**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_api/test_drafts_service.py -k "step_input_map_merge or step_map_helpers_merge" -q
|
|
```
|
|
|
|
Expected: all selected tests pass.
|
|
|
|
- [x] **Step 5: Commit Task 1**
|
|
|
|
```bash
|
|
git add src/wf_api/drafts.py src/wf_api/draft_authoring.py tests/wf_api/test_drafts_service.py
|
|
git commit -m "fix: reject lossy step input map merges"
|
|
```
|
|
|
|
### Task 2: Guard Step Output And Workflow Output Merges
|
|
|
|
**Files:**
|
|
- Modify: `src/wf_api/drafts.py`
|
|
- Test: `tests/wf_api/test_drafts_service.py`
|
|
|
|
**Interfaces:**
|
|
- Produces: `_require_lossless_step_output_map_round_trip(payload, *, step_id)`
|
|
- Preserves: `WorkflowDraftApi.set_step_output_map`
|
|
- Preserves: `WorkflowDraftApi.set_workflow_output_map`
|
|
|
|
- [x] **Step 1: Write failing step-output fan-out tests**
|
|
|
|
Create a draft whose step output contains:
|
|
|
|
```python
|
|
[
|
|
{"source": "report.title", "target": "state.report.title"},
|
|
{"source": "report.title", "target": "state.audit.title"},
|
|
]
|
|
```
|
|
|
|
Call:
|
|
|
|
```python
|
|
await api.set_step_output_map(
|
|
workspace_id="report",
|
|
revision=2,
|
|
step_id="report",
|
|
output_map={"rendered": "state.rendered"},
|
|
merge=True,
|
|
)
|
|
```
|
|
|
|
with an unrelated new mapping.
|
|
Assert `ValueError` contains `complete canonical binding list`, then assert the
|
|
full workspace and revision remain unchanged.
|
|
|
|
Add a stale-revision variant and retain the existing unique-source merge test as
|
|
the positive case.
|
|
|
|
- [x] **Step 2: Write failing workflow-output ambiguity tests**
|
|
|
|
Create workflow output bindings:
|
|
|
|
```python
|
|
[
|
|
{"path": "state.title", "target": "report.title"},
|
|
{"path": "state.title", "target": "audit.title"},
|
|
{"value": "markdown", "target": "format"},
|
|
]
|
|
```
|
|
|
|
Add two tests:
|
|
|
|
1. merging `{"state.title": "renamed"}` rejects and does not mutate;
|
|
2. merging `{"state.other": "other"}` succeeds while preserving both
|
|
`state.title` bindings, the literal, and their order.
|
|
|
|
- [x] **Step 3: Run focused tests and verify RED**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_api/test_drafts_service.py -k "step_output_map_merge or workflow_output_map_merge" -q
|
|
```
|
|
|
|
Expected: fan-out step output is collapsed and the ambiguous workflow-output
|
|
source updates multiple bindings.
|
|
|
|
- [x] **Step 4: Implement minimal guards**
|
|
|
|
Add:
|
|
|
|
```python
|
|
def _require_lossless_step_output_map_round_trip(
|
|
payload: object,
|
|
*,
|
|
step_id: str,
|
|
) -> dict[str, str]:
|
|
"""Return a compatibility map only when it reproduces the output list."""
|
|
output_map = _output_map_from_payload(payload)
|
|
rebuilt = _draft_output_bindings_payload(output_map)
|
|
if rebuilt != payload:
|
|
raise ValueError(
|
|
f"step {step_id!r} outputs cannot be safely merged through a "
|
|
"compatibility map; replace the complete canonical binding list instead"
|
|
)
|
|
return output_map
|
|
```
|
|
|
|
Apply the same revision-first ordering used by Task 1.
|
|
|
|
For workflow outputs, count existing path bindings only for keys in the
|
|
requested `output_map`. Before constructing the merged list, reject a source
|
|
whose count is greater than one:
|
|
|
|
```python
|
|
ambiguous = next(
|
|
(
|
|
source
|
|
for source in output_map
|
|
if sum(
|
|
1
|
|
for binding in output_payload
|
|
if isinstance(binding, dict) and binding.get("path") == source
|
|
)
|
|
> 1
|
|
),
|
|
None,
|
|
)
|
|
if ambiguous is not None:
|
|
raise ValueError(
|
|
f"workflow output source {ambiguous!r} has multiple bindings and "
|
|
"cannot be updated through a compatibility map; replace the complete "
|
|
"canonical binding list instead"
|
|
)
|
|
```
|
|
|
|
Leave unrequested duplicate sources and literal records untouched.
|
|
|
|
- [x] **Step 5: Run focused and API regression tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_api/test_drafts_service.py -k "map_helpers or set_step_input_map or set_step_output_map or set_workflow_output_map" -q
|
|
```
|
|
|
|
Expected: all selected tests pass.
|
|
|
|
- [x] **Step 6: Commit Task 2**
|
|
|
|
```bash
|
|
git add src/wf_api/drafts.py tests/wf_api/test_drafts_service.py
|
|
git commit -m "fix: reject ambiguous output map merges"
|
|
```
|
|
|
|
### Task 3: Align CLI Guidance, Issues, And Verification
|
|
|
|
**Files:**
|
|
- Modify: `src/wf_cli/commands/drafts.py`
|
|
- Modify: `tests/wf_cli/test_app.py`
|
|
- Modify: `ISSUES.md`
|
|
|
|
**Interfaces:**
|
|
- Consumes: compatibility merge errors from Tasks 1-2
|
|
- Produces: CLI help that describes rejection rather than acceptable loss
|
|
|
|
- [x] **Step 1: Write the failing CLI help assertion**
|
|
|
|
Update `test_wf_draft_map_help_explains_replace_merge_and_validate` to require:
|
|
|
|
```python
|
|
assert "rejects existing bindings that cannot round-trip safely" in input_help
|
|
assert "replace the complete canonical binding list" in output_help
|
|
assert "ambiguous fan-out sources" in workflow_output_help
|
|
```
|
|
|
|
Remove assertions that describe compatibility merge as merely
|
|
`potentially lossy`.
|
|
|
|
- [x] **Step 2: Run the CLI help test and verify RED**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_cli/test_app.py::test_wf_draft_map_help_explains_replace_merge_and_validate -q
|
|
```
|
|
|
|
Expected: FAIL because current help still says `potentially lossy`.
|
|
|
|
- [x] **Step 3: Update command help and issue state**
|
|
|
|
Revise the `set-input`, `set-output`, and `set-workflow-output` docstrings and
|
|
`--merge` help text. State that merge:
|
|
|
|
- is compatibility-only;
|
|
- preserves simple map-shaped bindings;
|
|
- rejects canonical lists it cannot reproduce;
|
|
- requires canonical replacement for fan-out or ambiguous records.
|
|
|
|
Mark the `ISSUES.md` compatibility step map item complete and summarize the
|
|
guarded behavior without claiming the map representation gained fan-out.
|
|
|
|
- [x] **Step 4: Run focused verification**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_api/test_drafts_service.py tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q
|
|
uv run ruff check src/wf_api/drafts.py src/wf_api/draft_authoring.py src/wf_cli/commands/drafts.py tests/wf_api/test_drafts_service.py tests/wf_cli/test_app.py
|
|
uv run basedpyright --level error src/wf_api src/wf_cli
|
|
git diff --check
|
|
```
|
|
|
|
Expected: tests pass, Ruff reports no issues, basedpyright reports zero errors,
|
|
and `git diff --check` emits no errors.
|
|
|
|
- [x] **Step 5: Commit Task 3**
|
|
|
|
```bash
|
|
git add src/wf_cli/commands/drafts.py tests/wf_cli/test_app.py ISSUES.md
|
|
git commit -m "docs: explain safe compatibility merges"
|
|
```
|
|
|
|
## Post-Review Hardening
|
|
|
|
The final review found that `bind_draft` still reconstructed canonical input
|
|
and output lists through compatibility maps. The completed slice therefore
|
|
also updates focused binds directly on typed canonical lists, preserving
|
|
unrelated fan-out, literals, and ordering while rejecting ambiguous requested
|
|
sources before mutation. Remote CLI regressions cover canonical-replacement
|
|
guidance for step inputs, step outputs, and workflow outputs.
|