fix(projection): retirement must release the identifier it holds
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 0s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s

`slug` is unique across the whole table, so retiring a row by timestamp alone
leaves its identifier locked to a record nothing derives any more — and the
repository that legitimately owns it can never claim it. core-hub's inherited
REPO-WP-0001/0002 rows retired cleanly and repo-seed stayed refused with
"slug already belongs to another repository", pointing at two retired rows.

Retirement now stamps the slug `<slug>@retired-YYYYMMDD`. The row, its UUID and
its progress events are untouched, so history stays attached to the record it
happened under; only the human-facing identifier is freed. Re-retiring an
already-stamped row replaces the mark rather than stacking it, or the 100-char
column overflows after a few passes.

The UUIDs never collided here: core-hub's rows predate derived identity and
carry v4 UUIDs, while repo-seed derives v5 ones. The identifier was the only
thing in the way.

733 pass.

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-28 01:15:02 +02:00
parent 4b121f3796
commit 70c585bb39
3 changed files with 60 additions and 11 deletions

View file

@ -585,6 +585,28 @@ class ResetOutcome:
}
RETIRED_SLUG_MARK = "@retired-"
def _tombstone_slug(slug: str, when: datetime) -> str:
"""Release the identifier a retired row was holding.
`slug` is unique across the whole table, so retirement that only sets a
timestamp leaves the identifier locked to a record nothing derives any
more and the repository that legitimately owns it can never claim it.
That is what kept repo-seed refused after core-hub's inherited REPO-WP rows
were retired.
The row, its UUID, and its progress events are untouched; only the
human-facing identifier is stamped, so history stays attached to the record
it happened under. Re-retiring an already-stamped row must not stack marks,
or the column overflows after a few passes.
"""
base = (slug or "").split(RETIRED_SLUG_MARK)[0]
stamped = f"{base}{RETIRED_SLUG_MARK}{when:%Y%m%d}"
return stamped[:100]
RETIRE_REASON = "no longer derived from the forge"
@ -864,9 +886,10 @@ async def reset_repository_projection(
outcome.updated.append(w.record_id)
for r in stale:
outcome.retired.append(r.slug or str(r.id))
r.projection_retired_at = now
r.projection_retired_reason = RETIRE_REASON
outcome.retired.append(r.slug or str(r.id))
r.slug = _tombstone_slug(r.slug or str(r.id), now)
if outcome.created or outcome.updated or outcome.retired:
outcome.status = "applied"