fix(projection): refuse slug collisions, and record the first fleet pass
The identifier refusal checked id only. slug carries its own unique constraint across the whole table, so two repositories can derive different identifiers whose slugs still collide — which left disaster-control raising IntegrityError. First fleet-wide pass over 121 repositories: 91 applied (737 updated, 8 created), 16 refused covering 64 records, 12 errored. 745 workplans now carry the commit they derived from, satisfying ADR-012 decision 2 for the first time. 64 is the measured size of the stale-row problem CUST-WP-0068-T09 has waited on. Eleven of the twelve errors are private repositories the pod cannot clone anonymously — a real limit on "the forge is the projection source", since their absence currently looks like an error rather than a policy. 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:
parent
5d09e8f5b0
commit
6c1262ef6e
3 changed files with 119 additions and 3 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue