diff --git a/api/services/forge_projection.py b/api/services/forge_projection.py index 4ae3313..1df0d73 100644 --- a/api/services/forge_projection.py +++ b/api/services/forge_projection.py @@ -510,6 +510,42 @@ async def reset_repository_projection( ) ).scalars() ) + # `slug` carries its own unique constraint across the whole table, so an + # identifier check alone is not enough: two repositories can derive + # different identifiers whose slugs still collide. Missing this is what + # left disaster-control raising IntegrityError after the identifier + # refusal was added. + slug_clash = list( + ( + await session.execute( + select(Workplan).where( + Workplan.slug.in_([w.record_id.lower() for w in creating]), + Workplan.repo_id != repo.id, + ) + ) + ).scalars() + ) + if slug_clash: + held = {(r.slug or "").lower(): r for r in slug_clash} + outcome.status = "refused" + for w in creating: + row = held.get(w.record_id.lower()) + if row is None: + continue + outcome.refused.append( + { + "reason": "slug already belongs to another repository", + "record_id": w.record_id, + "slug": w.record_id.lower(), + "held_by_id": str(row.id), + } + ) + outcome.notes.append( + "Identifier collision is an identity decision, not a projection " + "one; acknowledging retirements does not authorise it." + ) + return outcome + if foreign: owned = {str(r.id): r for r in foreign} outcome.status = "refused" diff --git a/tests/test_forge_projection.py b/tests/test_forge_projection.py index 103b4b4..706be1a 100644 --- a/tests/test_forge_projection.py +++ b/tests/test_forge_projection.py @@ -212,10 +212,11 @@ class _Row: class _FakeSession: """Stands in for AsyncSession: enough to prove intent without a database.""" - def __init__(self, repo, rows, foreign=None): + def __init__(self, repo, rows, foreign=None, slug_clash=None): self._repo = repo self.rows = list(rows) self._foreign = list(foreign or []) + self._slug_clash = list(slug_clash or []) self.added = [] self.deleted = [] self.committed = False @@ -226,7 +227,16 @@ class _FakeSession: repo, rows = self._repo, self.rows # 1st call resolves the repo, 2nd loads its workplans, 3rd is the # foreign-identifier lookup. - payload = rows if self._calls == 2 else self._foreign + # 1 resolves the repo, 2 loads its workplans, 3 is the identifier + # lookup, 4 the slug lookup. + if self._calls == 2: + payload = rows + elif self._calls == 3: + payload = self._foreign + elif self._calls == 4: + payload = self._slug_clash + else: + payload = [] class R: def scalar_one_or_none(self_inner): @@ -384,3 +394,26 @@ class TestFleetReset: await fp.reset_fleet_projection(self._Factory(s), ["applier", "refuser"]) assert s[0].committed is True assert s[1].committed is False + + +class TestSlugCollisionRefusal: + """slug carries its own unique constraint (STATE-WP-0083-T04). + + Checking the identifier alone left disaster-control raising IntegrityError: + two repositories can derive different identifiers whose slugs still collide. + """ + + @pytest.mark.asyncio + async def test_refuses_when_the_slug_belongs_elsewhere(self): + derived = fp.DerivedProjection( + repo_slug="demo", commit="c0ffee", + workplans=[fp.DerivedWorkplan( + record_id="REPO-WP-0001", uuid=fp.derived_record_uuid("REPO-WP-0001"), + title="x", status="active", relative_path="workplans/a.md", + archived=False, tasks=[])]) + clash = _Row(slug="repo-wp-0001", status="finished", path="workplans/other.md") + session = _FakeSession(repo=_Repo(), rows=[], foreign=[], slug_clash=[clash]) + out = await fp.reset_repository_projection(session, "demo", derived=derived) + assert out.status == "refused" + assert out.refused[0]["reason"].startswith("slug already belongs") + assert session.added == [] diff --git a/workplans/STATE-WP-0083-forge-derived-projection-reset.md b/workplans/STATE-WP-0083-forge-derived-projection-reset.md index ee90ea8..c7c4fde 100644 --- a/workplans/STATE-WP-0083-forge-derived-projection-reset.md +++ b/workplans/STATE-WP-0083-forge-derived-projection-reset.md @@ -583,4 +583,51 @@ reset was reporting the truth about a fleet whose files could not be read. That is the argument for `T02` existing separately from `T03`. Had the reset applied on first run, it would have retired live work in at least five -repositories, and every one of those retirements would have looked like tidy-up. \ No newline at end of file +repositories, and every one of those retirements would have looked like tidy-up. + +## First fleet-wide pass (2026-08-26) + +121 repositories, refuse mode. The first measured answer to whether the hub +matches the forge. + +| | | +|---|---| +| applied | 91 — 737 updated, 8 created | +| refused | 16 repositories, **64 records** | +| noop | 2 | +| errored | 12 | + +**745 workplans now carry the commit they derived from.** `ADR-012` decision 2 +is satisfied for the first time: until today no record could name its source, and +`git_fingerprint` had held the initial commit since the repository began. Zero +records were retired and hub-native progress events were untouched, as intended. + +**64 is the real size of the stale-row problem** that `CUST-WP-0068-T09` has been +waiting on — measured rather than estimated. `railiance-platform` (20), +`vergabe-teilnahme` (16) and `railiance-apps` (8) hold more than two thirds. + +### Private repositories are invisible to central + +Eleven of the twelve errors are the same: + +```text +fatal: could not read Username for 'https://forgejo.coulomb.social' +``` + +Those repositories are private, and the pod clones anonymously. `rapp-core-hub`, +`rapp-issue-core`, `rapp-openbao`, `rapp-policy-nexus` and seven others cannot be +derived at all. + +This is a genuine limit on `ADR-012` decision 1: *the forge is the projection +source* holds only for repositories central can read. Until it has a deploy token +or equivalent, a whole class of repositories can never be reset — and, worse, +their absence looks like an error rather than a policy, so nothing distinguishes +"cannot read" from "does not exist". + +### A second collision dimension + +`disaster-control` raised `IntegrityError: Key (slug)=(repo-wp-0001) already +exists`. The identifier refusal added earlier checks `id`; `slug` carries its own +unique constraint across the whole table, so two repositories can derive +different identifiers whose slugs still collide. Now refused with the holder +named, and covered by test.