From b43a1e5728ca2bf0b3c942eaa72468ab77ab6c4b Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 28 Aug 2026 10:38:12 +0200 Subject: [PATCH] fix(projection): qualify bare task ids before deriving their identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retiring llm-connect's five legacy rows failed on `duplicate key value violates unique constraint "tasks_pkey"`, and the cause is that a bare `T01` is not an identifier: it is unique within its workplan, not in the fleet. Unqualified, uuid5("T01") is the same UUID for every workplan in the fleet that has one — llm-connect's 91 task blocks derive 49 distinct UUIDs, so creating its workplans inserts the same task primary key repeatedly in one flush. Tasks are now qualified with their owning workplan before derivation, which is the rule `task_record_id_backfill.qualify_task_id` already applies to stored ids; the two must agree or the backfill and the projection disagree about what a task is called. Already-qualified ids are untouched. The create path's comment claimed it was "safe only because nothing exists to mis-match against: this workplan is new to the hub". That was true of other workplans and false within one: the collision was among the tasks it was inserting itself. The failed pass rolled back cleanly — llm-connect's five legacy rows are still live and progress events are intact. 742 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 | 20 ++++++++++++--- tests/test_forge_projection.py | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/api/services/forge_projection.py b/api/services/forge_projection.py index b503c78..96cc24d 100644 --- a/api/services/forge_projection.py +++ b/api/services/forge_projection.py @@ -26,6 +26,8 @@ import yaml # Same derivation as ADR-007 / repo-manager, so a forge-derived projection and a # preliminary overlay compute identical identities for the same record. +from api.services.task_record_id_backfill import qualify_task_id + _WORK_RECORD_NAMESPACE = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009") _TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL) _HEADING_RE = re.compile(r"^(#{1,4})\s+(.+?)$", re.MULTILINE) @@ -258,7 +260,7 @@ def _split_frontmatter(text: str) -> tuple[dict, str]: return (meta if isinstance(meta, dict) else {}), body -def _parse_tasks(body: str) -> list[DerivedTask]: +def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]: headings = [ (m.start(), m.group(2).strip()) for m in _HEADING_RE.finditer(body) ] @@ -279,10 +281,20 @@ def _parse_tasks(body: str) -> list[DerivedTask]: title = prev[-1] if prev else None out.append( DerivedTask( - record_id=rid, + # A bare `T01` is not an identifier: it is unique only within + # its workplan, so `uuid5("T01")` is the same UUID for every + # workplan in the fleet. llm-connect's 91 task blocks derive + # just 49 distinct UUIDs unqualified, and creating its + # workplans fails on a duplicate task primary key. + # + # Qualifying with the owning workplan is the same rule + # `task_record_id_backfill.qualify_task_id` applies to stored + # ids; both must agree or the backfill and the projection + # disagree about what a task is called. + record_id=qualify_task_id(rid, workplan_id) or rid, # Derived, not read from the file: the forge projection must not # inherit an identifier the file happens to carry. - uuid=derived_record_uuid(rid), + uuid=derived_record_uuid(qualify_task_id(rid, workplan_id) or rid), title=title, status=(str(block["status"]).strip() if block.get("status") else None), priority=(str(block["priority"]).strip() if block.get("priority") else None), @@ -319,7 +331,7 @@ def derive_from_checkout(repo_root: Path, repo_slug: str, commit: str) -> Derive status=(str(meta["status"]).strip() if meta.get("status") else None), relative_path=str(path.relative_to(repo_root).as_posix()), archived=path.parent.name == "archived", - tasks=_parse_tasks(body), + tasks=_parse_tasks(body, rid), ) ) proj.workplans.sort(key=lambda w: w.record_id) diff --git a/tests/test_forge_projection.py b/tests/test_forge_projection.py index 32eca8d..51c9e0e 100644 --- a/tests/test_forge_projection.py +++ b/tests/test_forge_projection.py @@ -845,3 +845,45 @@ class TestUuidMatchWins: uuid_at = src.index("want_by_uuid.get(str(row.id))") rekey_at = src.index("_identity_is_derived(row) or _slug_is_identifier") assert uuid_at < rekey_at, "UUID match must precede the re-key heuristics" + + +class TestTaskIdentityIsQualified: + """A bare `T01` is unique within its workplan, not in the fleet. + + Unqualified, `uuid5("T01")` is the same UUID for every workplan that has a + T01 — llm-connect's 91 task blocks derive 49 distinct UUIDs, and creating + its workplans dies on `duplicate key value violates unique constraint + "tasks_pkey"`. + """ + + BODY = """ +```task +id: T01 +status: done +``` + +```task +id: T02 +status: todo +``` +""" + + def test_short_ids_are_qualified_by_their_workplan(self): + a = fp._parse_tasks(self.BODY, "LLM-WP-0001") + b = fp._parse_tasks(self.BODY, "LLM-WP-0002") + assert [t.record_id for t in a] == ["LLM-WP-0001-T01", "LLM-WP-0001-T02"] + assert {t.uuid for t in a}.isdisjoint({t.uuid for t in b}) + + def test_an_already_qualified_id_is_left_alone(self): + body = "```task\nid: LLM-WP-0009-T03\nstatus: todo\n```" + assert fp._parse_tasks(body, "LLM-WP-0001")[0].record_id == "LLM-WP-0009-T03" + + def test_it_agrees_with_the_backfill(self): + """Both must apply the same rule or they disagree on a task's name.""" + from api.services.task_record_id_backfill import qualify_task_id + got = fp._parse_tasks(self.BODY, "LLM-WP-0001")[0].record_id + assert got == qualify_task_id("T01", "LLM-WP-0001") + + def test_uuid_follows_the_qualified_id(self): + t = fp._parse_tasks(self.BODY, "LLM-WP-0001")[0] + assert t.uuid == fp.derived_record_uuid("LLM-WP-0001-T01")