feat(projection): reconcile a repository's projection against the forge
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 26s

Implements ADR-012 decision 7 as amended (STATE-WP-0083-T03). Creates what the
forge has and the hub lacks, updates what differs, retires what no longer
derives. It never deletes: hub-native records reference workplans with ON DELETE
RESTRICT, and destroying a progress event to tidy a derived projection would
lose hub-native truth to fix a derived-state problem.

Retirement is refused by default. A record that stops deriving may mean a
deliberately deleted file or a caller pointed at the wrong branch; only the
caller can say which.

Verified against live data and rolled back: whitehat-security applied 5 updates
with no retirements; the-custodian refused, naming the four hub-first records
confirmed by hand to have no backing file.

Tasks of existing workplans are deliberately untouched — hub tasks carry no
canonical identifier, so matching is by title and a renamed heading would
destroy and recreate a record. Tasks are created only alongside a new workplan,
where nothing exists to mis-match. Tracked as T06.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
This commit is contained in:
tegwick 2026-08-26 01:19:10 +02:00
parent 6bb1fe823a
commit 43ffe883c3
3 changed files with 365 additions and 1 deletions

View file

@ -96,3 +96,151 @@ def test_clone_failure_is_reported_not_swallowed(monkeypatch):
monkeypatch.setattr(fp, "_run_git", boom)
with pytest.raises(fp.ForgeDeriveError, match="not found"):
fp.derive_from_forge("nope")
class TestReset:
"""Applying the reset (STATE-WP-0083-T03).
The properties worth guarding are the refusals, not the happy path. A reset
that quietly retires a record someone still needs is worse than one that
does nothing.
"""
@staticmethod
def _derived(*records):
wps = []
for rid, status, path in records:
wps.append(
fp.DerivedWorkplan(
record_id=rid, uuid=fp.derived_record_uuid(rid), title=rid,
status=status, relative_path=path, archived=False, tasks=[],
)
)
return fp.DerivedProjection(repo_slug="demo", commit="c0ffee", workplans=wps)
@pytest.mark.asyncio
async def test_refuses_retirement_unless_acknowledged(self, monkeypatch):
"""A record that stops deriving may mean a deleted file — or a wrong branch."""
session = _FakeSession(
repo=_Repo(),
rows=[_Row(slug="demo-wp-0001", status="active", path="workplans/a.md")],
)
out = await fp.reset_repository_projection(
session, "demo", derived=self._derived() # forge has nothing
)
assert out.status == "refused"
assert out.refused and out.refused[0]["slug"] == "demo-wp-0001"
assert out.retired == []
assert session.committed is False
@pytest.mark.asyncio
async def test_retires_when_acknowledged(self):
row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md")
session = _FakeSession(repo=_Repo(), rows=[row])
out = await fp.reset_repository_projection(
session, "demo", derived=self._derived(), acknowledge_retirements=True
)
assert out.status == "applied" and out.retired == ["demo-wp-0001"]
assert row.projection_retired_at is not None
assert row.projection_retired_reason == fp.RETIRE_REASON
@pytest.mark.asyncio
async def test_retirement_is_not_deletion(self):
"""Hub-native records reference workplans with RESTRICT; the row survives."""
row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md")
session = _FakeSession(repo=_Repo(), rows=[row])
await fp.reset_repository_projection(
session, "demo", derived=self._derived(), acknowledge_retirements=True
)
assert row in session.rows
assert session.deleted == []
@pytest.mark.asyncio
async def test_unretires_a_record_that_derives_again(self):
from datetime import datetime, timezone
row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md")
row.projection_retired_at = datetime.now(tz=timezone.utc)
row.projection_retired_reason = fp.RETIRE_REASON
session = _FakeSession(repo=_Repo(), rows=[row])
out = await fp.reset_repository_projection(
session, "demo",
derived=self._derived(("DEMO-WP-0001", "active", "workplans/a.md")),
)
assert out.status == "applied"
assert row.projection_retired_at is None
@pytest.mark.asyncio
async def test_unregistered_repository_is_refused_not_created(self):
session = _FakeSession(repo=None, rows=[])
out = await fp.reset_repository_projection(session, "nope", derived=self._derived())
assert out.status == "refused"
assert "not registered" in out.refused[0]["reason"]
@pytest.mark.asyncio
async def test_records_the_commit_it_derived_from(self):
row = _Row(slug="demo-wp-0001", status="proposed", path="workplans/a.md")
session = _FakeSession(repo=_Repo(), rows=[row])
await fp.reset_repository_projection(
session, "demo",
derived=self._derived(("DEMO-WP-0001", "active", "workplans/a.md")),
)
assert row.derived_from_commit == "c0ffee"
assert row.status == "active"
class _Repo:
def __init__(self):
import uuid as _u
self.id = _u.uuid4()
self.topic_id = None
class _Row:
def __init__(self, slug, status, path):
import uuid as _u
self.id = _u.uuid4()
self.slug = slug
self.status = status
self.backing_relative_path = path
self.backing_filename = path.rsplit("/", 1)[-1]
self.backing_archived = False
self.projection_retired_at = None
self.projection_retired_reason = None
self.derived_from_commit = None
class _FakeSession:
"""Stands in for AsyncSession: enough to prove intent without a database."""
def __init__(self, repo, rows):
self._repo = repo
self.rows = list(rows)
self.added = []
self.deleted = []
self.committed = False
self._calls = 0
async def execute(self, *_a, **_k):
self._calls += 1
repo, rows = self._repo, self.rows
class R:
def scalar_one_or_none(self_inner):
return repo
def scalars(self_inner):
return iter(rows)
return R()
def add(self, obj):
self.added.append(obj)
def delete(self, obj):
self.deleted.append(obj)
async def flush(self):
return None
async def commit(self):
self.committed = True