From 70c585bb39e9f7111bec436f2e153143531f7da2 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 28 Aug 2026 01:15:02 +0200 Subject: [PATCH] fix(projection): retirement must release the identifier it holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 `@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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 2583210@bnt-lap001 Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006 --- WORK-RECORDS.md | 20 ++++++++++---------- api/services/forge_projection.py | 25 ++++++++++++++++++++++++- tests/test_forge_projection.py | 26 ++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index bc330a2..aef17f1 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -8,9 +8,9 @@ | Kind | ID | Status | Lane | Source | | --- | --- | --- | --- | --- | -| workplan | ADHOC-2026-06-04 | finished | — | workplans/ADHOC-2026-06-04.md | -| workplan | ADHOC-2026-07-01 | finished | — | workplans/ADHOC-2026-07-01.md | -| workplan | ADHOC-2026-08-08 | finished | — | workplans/ADHOC-2026-08-08.md | +| workplan | STATE-WP-ADHOC-2026-06-04 | finished | — | workplans/ADHOC-2026-06-04.md | +| workplan | STATE-WP-ADHOC-2026-07-01 | finished | — | workplans/ADHOC-2026-07-01.md | +| workplan | STATE-WP-ADHOC-2026-08-08 | finished | — | workplans/ADHOC-2026-08-08.md | | workplan | STATE-WP-ADHOC-2026-08-23 | finished | — | workplans/ADHOC-2026-08-23.md | | workplan | CUST-WP-0003 | finished | — | workplans/CUST-WP-0003-whi-kpi-card.md | | workplan | CUST-WP-0012 | finished | — | workplans/CUST-WP-0012-multi-user-onboarding.md | @@ -55,13 +55,13 @@ | workplan | STATE-WP-0082 | finished | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md | | workplan | STATE-WP-0083 | active | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md | | workplan | STATE-WP-0084 | active | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md | -| task | ADHOC-2026-06-04-T01 | done | — | workplans/ADHOC-2026-06-04.md | -| task | ADHOC-2026-07-01-T01 | done | — | workplans/ADHOC-2026-07-01.md | -| task | ADHOC-2026-07-01-T02 | done | — | workplans/ADHOC-2026-07-01.md | -| task | ADHOC-2026-08-08-T01 | done | — | workplans/ADHOC-2026-08-08.md | -| task | ADHOC-2026-08-08-T02 | done | — | workplans/ADHOC-2026-08-08.md | -| task | ADHOC-2026-08-08-T03 | done | — | workplans/ADHOC-2026-08-08.md | -| task | ADHOC-2026-08-08-T04 | done | — | workplans/ADHOC-2026-08-08.md | +| task | STATE-WP-ADHOC-2026-06-04-T01 | done | — | workplans/ADHOC-2026-06-04.md | +| task | STATE-WP-ADHOC-2026-07-01-T01 | done | — | workplans/ADHOC-2026-07-01.md | +| task | STATE-WP-ADHOC-2026-07-01-T02 | done | — | workplans/ADHOC-2026-07-01.md | +| task | STATE-WP-ADHOC-2026-08-08-T01 | done | — | workplans/ADHOC-2026-08-08.md | +| task | STATE-WP-ADHOC-2026-08-08-T02 | done | — | workplans/ADHOC-2026-08-08.md | +| task | STATE-WP-ADHOC-2026-08-08-T03 | done | — | workplans/ADHOC-2026-08-08.md | +| task | STATE-WP-ADHOC-2026-08-08-T04 | done | — | workplans/ADHOC-2026-08-08.md | | task | STATE-WP-ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md | | task | CUST-WP-0003-T01 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | | task | CUST-WP-0003-T02 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | diff --git a/api/services/forge_projection.py b/api/services/forge_projection.py index ee38ddd..3c8c57f 100644 --- a/api/services/forge_projection.py +++ b/api/services/forge_projection.py @@ -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" diff --git a/tests/test_forge_projection.py b/tests/test_forge_projection.py index c77ee55..a6dbd57 100644 --- a/tests/test_forge_projection.py +++ b/tests/test_forge_projection.py @@ -752,3 +752,29 @@ class TestRekeyIsNotRename: def test_title_slugs_keep_path_matching(self, slug): """Hub-first rows never claimed an identifier; the path is their only link.""" assert not fp._slug_is_identifier(slug) + + +class TestRetirementReleasesTheIdentifier: + """`slug` is unique table-wide, so retirement must free it. + + Retirement that only sets a timestamp leaves the identifier locked to a + record nothing derives, and the repository that owns it can never claim it. + """ + + def test_the_identifier_is_released(self): + from datetime import datetime, timezone + when = datetime(2026, 8, 28, tzinfo=timezone.utc) + assert fp._tombstone_slug("repo-wp-0001", when) == "repo-wp-0001@retired-20260828" + + def test_re_retiring_does_not_stack_marks(self): + """Otherwise the 100-char column overflows after a few passes.""" + from datetime import datetime, timezone + a = fp._tombstone_slug("repo-wp-0001", datetime(2026, 8, 28, tzinfo=timezone.utc)) + b = fp._tombstone_slug(a, datetime(2026, 9, 1, tzinfo=timezone.utc)) + assert b == "repo-wp-0001@retired-20260901" + assert b.count(fp.RETIRED_SLUG_MARK) == 1 + + def test_a_long_slug_stays_within_the_column(self): + from datetime import datetime, timezone + out = fp._tombstone_slug("x" * 140, datetime(2026, 8, 28, tzinfo=timezone.utc)) + assert len(out) <= 100