From b2efadf2549f4ca752c0a8e4dc6983fd72d2dd5f Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 28 Aug 2026 00:38:50 +0200 Subject: [PATCH] fix(projection): a re-keyed record is new, not a renamed file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ad-hoc requalification changed 30 identifiers while deliberately keeping the filenames, per canon. The matcher's path fallback therefore matched every old row to its new record and updated it in place — so the files said CUST-WP-ADHOC-2026-08-25 while the hub still said adhoc-2026-08-25, with the row's UUID still encoding the old identifier. Exactly the file/hub identity divergence ADR-007 exists to prevent. Path matching cannot distinguish a re-key from a rename when the path does not change. Identity can: a derived row is UUIDv5 over the record id, so a changed id is a different record — the old row retires and the new one is created. Rows predating derived identity carry v4 UUIDs, where the identifier is a label rather than the identity; those keep path matching, so rename detection still works where it is the right answer. The unmatched old row keeps its own slug as key, so it lands in `stale` and becomes a retirement candidate rather than disappearing from the outcome. 720 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 --- api/services/forge_projection.py | 30 +++++++++++++++++++++++ tests/test_forge_projection.py | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/api/services/forge_projection.py b/api/services/forge_projection.py index e04f6a3..343caf3 100644 --- a/api/services/forge_projection.py +++ b/api/services/forge_projection.py @@ -32,6 +32,18 @@ _HEADING_RE = re.compile(r"^(#{1,4})\s+(.+?)$", re.MULTILINE) DEFAULT_FORGE_BASE = "https://forgejo.coulomb.social/coulomb" +def _identity_is_derived(row: Any) -> bool: + """Whether this row's UUID was derived from its identifier (ADR-007). + + Derived rows are UUIDv5 over the work-record namespace. Rows predating + derived identity carry a random v4 UUID, and for those the identifier is a + label rather than the identity — so the path fallback still applies, which + is what keeps rename detection working for legacy records. + """ + rid = getattr(row, "id", None) + return getattr(rid, "version", None) == 5 + + def derived_record_uuid(record_id: str) -> str: return str(uuid.uuid5(_WORK_RECORD_NAMESPACE, f"helixforge\n{record_id}")) @@ -629,6 +641,24 @@ async def reset_repository_projection( for row in rows: key = (row.slug or "").strip().lower() if key not in want: + if _identity_is_derived(row): + # ADR-007: a derived record's identity *is* a function of its + # identifier, so a changed identifier means a different record — + # the old one retires and the new one is created. Matching it to + # the file by path instead would update the row in place while + # its UUID still encodes the old identifier, leaving the file + # and the hub disagreeing about what the record is called. + # + # This is not hypothetical: the ad-hoc requalification + # (CUST-WP-0066) deliberately keeps the filename, so for those + # records a re-key *never* changes the path. Path matching + # cannot tell a re-key from a rename there, and silently chose + # rename for all 30 of them. + # + # The row keeps its own slug as key, so it lands in `stale` and + # becomes a retirement candidate rather than vanishing. + matched[key] = row + continue bp = row.backing_relative_path if bp and _path_key(bp) in want_paths: key = want_paths[_path_key(bp)] diff --git a/tests/test_forge_projection.py b/tests/test_forge_projection.py index 0c2ca38..b9b2bac 100644 --- a/tests/test_forge_projection.py +++ b/tests/test_forge_projection.py @@ -684,3 +684,44 @@ class TestForgeCredential: monkeypatch.setattr(fp.subprocess, "run", fake_run) assert fp._run_git("status") == "ok" assert "GIT_CONFIG_COUNT" not in seen["env"] + + +class TestRekeyIsNotRename: + """A changed identifier is a new record, not a moved file. + + The ad-hoc requalification (CUST-WP-0066) keeps the filename by design, so + for those records a re-key never changes the path. Matching on path there + cannot distinguish the two cases, and choosing rename updates a row whose + UUID still encodes the old identifier — leaving the file and the hub + disagreeing about what the record is called. + """ + + class _Row: + def __init__(self, id, slug, path): + self.id = id + self.slug = slug + self.backing_relative_path = path + self.projection_retired_at = None + self.status = "finished" + + def test_a_derived_row_is_not_matched_by_path(self): + """uuid5 means identity is a function of the identifier.""" + import uuid as _u + old = self._Row( + _u.UUID(fp.derived_record_uuid("ADHOC-2026-08-25")), + "adhoc-2026-08-25", + "workplans/ADHOC-2026-08-25.md", + ) + assert fp._identity_is_derived(old) + + def test_a_legacy_row_keeps_path_matching(self): + """v4 rows predate derived identity; there the identifier is a label.""" + import uuid as _u + legacy = self._Row(_u.uuid4(), "repo-wp-0001", "workplans/REPO-WP-0001.md") + assert not fp._identity_is_derived(legacy) + + def test_the_two_uuids_actually_differ(self): + """Guards the premise: if they were equal the distinction is moot.""" + assert fp.derived_record_uuid("ADHOC-2026-08-25") != fp.derived_record_uuid( + "CUST-WP-ADHOC-2026-08-25" + )