Receive source-owned depends_on edges through Forge projection (STATE-WP-0094-T05)

Derive frontmatter and task-block depends_on edges from the pinned commit and
reconcile workplan_dependencies under an explicit ownership contract: only
forge:<repo> rows are created or removed, external relations are preserved,
missing/ambiguous/self targets are recorded and hold owned edges, failures
roll back, and repeat receipts are noop. Additive migration b3c4d5e6f7a8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 671493@bnt-lap001
Assistant-Session: 60d7b11f-78f9-4731-9367-918511b2762d
This commit is contained in:
tegwick 2026-10-04 12:54:57 +02:00
parent b2af3c1c1d
commit fceb5f27f0
6 changed files with 637 additions and 0 deletions

View file

@ -0,0 +1,204 @@
"""Source-owned dependency edges through normal Forge receiving (STATE-WP-0094-T05).
Runs the real reconcile endpoint against the PostgreSQL test database, as the
qualifier receiving tests do. Contract: docs/forge-dependency-edge-receiving.md.
"""
from pathlib import Path
import pytest
from api.services import forge_projection as fp
from tests.conftest import create_test_domain, create_test_repo, create_test_workplan
TARGET = """---
id: TGT-WP-0001
type: workplan
title: Target
status: active
---
## Target task
```task
id: TGT-WP-0001-T01
status: todo
```
"""
def _source(frontmatter_deps: str, wp2_deps: str = "", t02_target: str = "MISSING-WP-0009") -> str:
return {
"workplans/SRC-WP-0001.md": (
"---\nid: SRC-WP-0001\ntype: workplan\ntitle: Source\nstatus: active\n"
+ frontmatter_deps + "---\n\n## Wait\n\n```task\nid: SRC-WP-0001-T01\nstatus: wait\n"
"depends_on: [SRC-WP-0002]\n```\n\n## Missing\n\n```task\nid: T02\nstatus: wait\n"
f"depends_on: {t02_target}\n```\n\n## Closed\n\n```task\nid: SRC-WP-0001-T03\n"
"status: done\ndepends_on: TGT-WP-0001\n```\n"
),
"workplans/SRC-WP-0002.md": (
"---\nid: SRC-WP-0002\ntype: workplan\ntitle: Sibling\nstatus: active\n"
+ wp2_deps + "---\n"
),
}
def _write(root: Path, files: dict[str, str]) -> None:
for rel, text in files.items():
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
@pytest.mark.asyncio
async def test_receiving_owns_only_source_edges(client, monkeypatch, tmp_path):
await create_test_domain(client)
await create_test_repo(client, slug="edge-target")
await create_test_repo(client, slug="edge-source")
from api.config import settings
from api.routers import work_record_projection as route
monkeypatch.setattr(settings, "state_hub_instance_role", "primary")
monkeypatch.setattr(settings, "state_hub_instance_label", "railiance01")
roots = {"edge-target": tmp_path / "target", "edge-source": tmp_path / "source"}
revision = {"edge-target": "a" * 40, "edge-source": "b" * 40}
_write(roots["edge-target"], {"workplans/TGT-WP-0001.md": TARGET})
_write(roots["edge-source"], _source("depends_on: [TGT-WP-0001, TGT-WP-0001-T01]\n"))
monkeypatch.setattr(
route, "derive_from_forge",
lambda slug: fp.derive_from_checkout(roots[slug], slug, revision[slug]),
)
async def reconcile(slug, key):
response = await client.post(
f"/repos/{slug}/work-record-projection/reconcile",
json={"expected_commit": revision[slug]}, headers={"Idempotency-Key": key},
)
assert response.status_code == 200, response.text
return response.json()["outcome"]
async def edges(record_id):
wp_id = fp.derived_record_uuid(record_id)
response = await client.get(f"/workplans/{wp_id}/dependencies/")
assert response.status_code == 200, response.text
return [row for row in response.json() if row["from_workplan_id"] == wp_id]
def counts(outcome):
c = outcome["counts"]
return (c["created_edges"], c["removed_edges"], c["unresolved_edges"], c["external_edges"])
await reconcile("edge-target", "edges:target")
first = await reconcile("edge-source", "edges:first")
assert first["status"] == "applied"
# wp->wp, wp->task, task->sibling wp; T02 target missing; closed T03 not desired.
assert counts(first) == (3, 0, 1, 0)
assert first["unresolved_edges"][0] == {
"workplan": "SRC-WP-0001", "from": "SRC-WP-0001-T02",
"target": "MISSING-WP-0009", "reason": "missing",
}
rows = await edges("SRC-WP-0001")
assert len(rows) == 3
assert {r["source_owner"] for r in rows} == {"forge:edge-source"}
assert {r["source_commit"] for r in rows} == {"b" * 40}
assert {r["relationship_type"] for r in rows} == {"blocks", "starts_after"}
assert sum(1 for r in rows if r["from_task_id"]) == 1
# Repeat of the same immutable commit is a noop.
repeat = await reconcile("edge-source", "edges:repeat")
assert repeat["status"] == "noop"
assert counts(repeat) == (0, 0, 1, 0)
# Externally authored relations: one undeclared, one the source will declare.
sibling = fp.derived_record_uuid("SRC-WP-0002")
target_wp = fp.derived_record_uuid("TGT-WP-0001")
other_repo = await create_test_repo(client, slug="edge-other")
other = await create_test_workplan(client, other_repo["id"], slug="other-wp", title="Other")
for to_id in (target_wp, other["id"]):
r = await client.post(f"/workplans/{sibling}/dependencies/",
json={"to_workplan_id": to_id, "relationship_type": "blocks"})
assert r.status_code == 201, r.text
# Source drops the task-target edge and declares the externally present one.
_write(roots["edge-source"], _source(
"depends_on: [TGT-WP-0001]\n", wp2_deps="depends_on: TGT-WP-0001\n"))
revision["edge-source"] = "c" * 40
second = await reconcile("edge-source", "edges:second")
assert second["status"] == "applied"
assert counts(second) == (0, 1, 1, 1)
assert second["removed_edges"][0]["to_task_id"] is not None
assert len(await edges("SRC-WP-0001")) == 2
sibling_edges = await edges("SRC-WP-0002")
assert len(sibling_edges) == 2 and all(r["source_owner"] is None for r in sibling_edges)
assert (await reconcile("edge-source", "edges:second-repeat"))["status"] == "noop"
# Dropping the declaration of the external edge does not remove it.
_write(roots["edge-source"], _source("depends_on: [TGT-WP-0001]\n"))
revision["edge-source"] = "d" * 40
third = await reconcile("edge-source", "edges:third")
assert counts(third) == (0, 0, 1, 0)
assert len(await edges("SRC-WP-0002")) == 2
# Ambiguous target: refuse to choose, and retain the from-side's owned edges.
r = await client.post("/tasks/", json={"workplan_id": other["id"], "title": "Clash",
"record_id": "TGT-WP-0001-T01"})
assert r.status_code == 201, r.text
clash_task = r.json()["id"]
target_task = fp.derived_record_uuid("TGT-WP-0001-T01")
_write(roots["edge-source"], _source("depends_on: [TGT-WP-0001-T01]\n", t02_target="SRC-WP-0002"))
revision["edge-source"] = "e" * 40
fourth = await reconcile("edge-source", "edges:fourth")
assert counts(fourth) == (1, 0, 1, 0)
ambiguous = fourth["unresolved_edges"][0]
assert ambiguous["reason"] == "ambiguous" and ambiguous["target"] == "TGT-WP-0001-T01"
assert set(ambiguous["candidates"]) == {target_task, clash_task}
# The undeclared wp->TGT edge is held, not removed, while the side is unresolved.
assert len(await edges("SRC-WP-0001")) == 3
# Transactional failure: an error mid-reconcile leaves nothing applied.
files = _source("", t02_target="SRC-WP-0002")
files["workplans/SRC-WP-0001.md"] = files["workplans/SRC-WP-0001.md"].replace(
"title: Source\n", "title: Source renamed\n")
_write(roots["edge-source"], files)
revision["edge-source"] = "f" * 40
calls = {"n": 0}
real_flush_target = fp._resolve_edge_target
async def failing(session, record_id, cache):
calls["n"] += 1
raise RuntimeError("injected edge resolution failure")
monkeypatch.setattr(fp, "_resolve_edge_target", failing)
with pytest.raises(RuntimeError):
await client.post("/repos/edge-source/work-record-projection/reconcile",
json={"expected_commit": revision["edge-source"]},
headers={"Idempotency-Key": "edges:fail"})
assert calls["n"] >= 1
monkeypatch.setattr(fp, "_resolve_edge_target", real_flush_target)
assert len(await edges("SRC-WP-0001")) == 3
wp = (await client.get(f"/workplans/{fp.derived_record_uuid('SRC-WP-0001')}")).json()
assert wp["title"] == "Source"
# The same commit then applies cleanly: frontmatter edge removed only.
final = await reconcile("edge-source", "edges:final")
assert counts(final) == (0, 1, 0, 0)
assert "SRC-WP-0001" in final["updated"]
assert len(await edges("SRC-WP-0001")) == 2
assert (await reconcile("edge-source", "edges:final-repeat"))["status"] == "noop"
def test_malformed_dependency_declaration_refuses_derivation(tmp_path):
_write(tmp_path, {"workplans/SRC-WP-0001.md": (
"---\nid: SRC-WP-0001\ntype: workplan\nstatus: active\ndepends_on: {a: b}\n---\n")})
with pytest.raises(fp.ForgeDeriveError):
fp.derive_from_checkout(tmp_path, "edge-source", "a" * 40)
def test_dependency_targets_split_and_qualify(tmp_path):
_write(tmp_path, _source("depends_on: [TGT-WP-0001, TGT-WP-0001-T01, T04]\n"
"depends_on_workplans: TGT-WP-0001\n"))
derived = fp.derive_from_checkout(tmp_path, "edge-source", "a" * 40)
wp = {w.record_id: w for w in derived.workplans}["SRC-WP-0001"]
assert wp.depends_on_workplans == ["TGT-WP-0001"]
assert wp.depends_on_tasks == ["TGT-WP-0001-T01", "SRC-WP-0001-T04"]
assert [t.depends_on for t in wp.tasks] == [["SRC-WP-0002"], ["MISSING-WP-0009"], ["TGT-WP-0001"]]
assert derived.to_dict()["workplans"][0]["depends_on_tasks"] == ["TGT-WP-0001-T01", "SRC-WP-0001-T04"]