diff --git a/api/services/forge_projection.py b/api/services/forge_projection.py index 144376c..49e43a6 100644 --- a/api/services/forge_projection.py +++ b/api/services/forge_projection.py @@ -884,7 +884,7 @@ async def reset_repository_projection( """ from datetime import datetime, timezone - from sqlalchemy import func, or_, select + from sqlalchemy import select from api.models.managed_repo import ManagedRepo from api.models.task import Task @@ -1200,10 +1200,13 @@ async def reset_repository_projection( row = matched.get(key) if row is None: continue + desired_record_ids = { + task.record_id.strip().lower() for task in workplan.tasks + } groups: dict[str, list[Any]] = {} for task_row in tasks_by_wp.get(row.id, []): record_id = (task_row.record_id or "").strip().lower() - if record_id: + if record_id and record_id in desired_record_ids: groups.setdefault(record_id, []).append(task_row) for task_rows in groups.values(): if len(task_rows) > 1: @@ -1226,9 +1229,11 @@ async def reset_repository_projection( return outcome # Check every task the reset would create in one query. A derived UUID held - # by another workplan is a global identity collision, even when the two - # human-readable rows happen to have different titles. Letting the INSERT - # discover this would turn a deterministic refusal into an IntegrityError. + # by another workplan is a global identity collision. A repeated record_id + # on a different workplan is not sufficient by itself: terminal rows from + # older, wrong projections retain that label as history and tasks have no + # projection-retirement marker. Letting the INSERT discover a UUID collision + # would turn a deterministic refusal into an IntegrityError. creating_tasks: dict[str, tuple[DerivedWorkplan, DerivedTask]] = {} for key, workplan in want.items(): row = matched.get(key) @@ -1247,10 +1252,6 @@ async def reset_repository_projection( creating_tasks[task.uuid] = (workplan, task) if creating_tasks: - creating_tasks_by_record_id = { - task.record_id.strip().lower(): (workplan, task) - for workplan, task in creating_tasks.values() - } current_holder_filters = [Workplan.projection_retired_at.is_(None)] if stale: # The caller has already acknowledged these retirements (the @@ -1267,11 +1268,8 @@ async def reset_repository_projection( .join(Workplan, Workplan.id == Task.workplan_id) .where( *current_holder_filters, - or_( - Task.id.in_( - [uuid.UUID(task_id) for task_id in creating_tasks] - ), - func.lower(Task.record_id).in_(creating_tasks_by_record_id), + Task.id.in_( + [uuid.UUID(task_id) for task_id in creating_tasks] ), ) ) @@ -1280,15 +1278,10 @@ async def reset_repository_projection( if task_holders: outcome.status = "refused" for holder in task_holders: - candidate = creating_tasks.get(str(holder.id)) - if candidate is None: - candidate = creating_tasks_by_record_id[ - (holder.record_id or "").strip().lower() - ] - workplan, task = candidate + workplan, task = creating_tasks[str(holder.id)] outcome.refused.append( { - "reason": "task identity already belongs to another current workplan", + "reason": "derived task identifier already belongs to another current workplan", "kind": "task", "record_id": task.record_id, "uuid": task.uuid, diff --git a/docs/evidence/STATE-WP-0083-task-identity-audit-2026-08-31.json b/docs/evidence/STATE-WP-0083-task-identity-audit-2026-08-31.json index 4828e60..9959bd8 100644 --- a/docs/evidence/STATE-WP-0083-task-identity-audit-2026-08-31.json +++ b/docs/evidence/STATE-WP-0083-task-identity-audit-2026-08-31.json @@ -11,12 +11,13 @@ "same_workplan_groups": 0, "cross_repository_groups": 31 }, - "projection_classification": { - "one_current_claimant_with_retired_history": 104, - "retired_history_only": 15, - "multiple_non_retired_claimants": 12 + "slug_tombstone_shape": { + "exactly_one_untombstoned_workplan_slug": 104, + "tombstoned_workplan_slugs_only": 15, + "multiple_untombstoned_workplan_slugs": 12, + "note": "Slug tombstones are an operational clue, not task-level projection membership. Tasks have no projection-retirement field." }, - "multiple_non_retired_claimants": [ + "multiple_untombstoned_workplan_slugs": [ { "record_id_prefix": "CUST-WP-0010b-T", "task_identity_groups": 2, @@ -25,7 +26,7 @@ "cust-wp-0010" ], "workplan_status": "finished", - "cause": "duplicate workplan projection over one backing file" + "cause": "stale completed task rows left by an earlier wrong workplan/backing-file projection" }, { "record_id_prefix": "SECRETS-WP-0002-T", @@ -35,14 +36,20 @@ "secrets-wp-0002" ], "workplan_status": "finished", - "cause": "duplicate workplan projection over one backing file" + "cause": "stale completed task rows left by an earlier wrong workplan/backing-file projection" } ], "decision": { - "retired_rows": "preserve as historical evidence; exclude from current projection ambiguity", - "duplicate_inside_matched_workplan": "refuse reset and report every claimant UUID", + "historical_rows": "preserve; a repeated label outside the owning workplan's desired task set is not current projection ambiguity", + "duplicate_inside_matched_workplan_desired_set": "refuse reset and report every claimant UUID", "duplicate_inside_forge_projection": "refuse reset; require file-level disposition", - "new_task_identity_held_by_other_current_workplan": "refuse before insert", - "duplicate_across_retired_or_displaced_workplans": "does not block current projection reset" + "new_derived_task_uuid_held_by_other_current_workplan": "refuse before insert", + "duplicate_record_id_across_other_workplans": "preserve as historical evidence; do not block unless the forge repeats the identity or the derived UUID collides" + }, + "live_reconcile": { + "state-hub": "noop at ddce470944e25b2f79927e7b8e2dfe3104cc4528", + "the-custodian": "applied 74 workplan and 169 task field updates; zero creates, retirements, cancellations, or refusals", + "secrets-engine": "applied 10 workplan and 26 task field updates; zero creates, retirements, cancellations, or refusals", + "second_pass": "the-custodian and secrets-engine both returned noop with every count zero" } } diff --git a/tests/test_forge_projection.py b/tests/test_forge_projection.py index d3fda39..d47965a 100644 --- a/tests/test_forge_projection.py +++ b/tests/test_forge_projection.py @@ -1156,6 +1156,23 @@ class TestExistingWorkplanTasks: assert out.refused[0]["reason"].startswith("current workplan contains duplicate") assert session.added == [] + @pytest.mark.asyncio + async def test_duplicate_stale_history_inside_current_workplan_does_not_block(self): + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + desired = _TaskRow("DEMO-WP-0001-T01", status="todo", workplan_id=row.id) + stale_a = _TaskRow("DEMO-WP-0001-T09", status="done", workplan_id=row.id) + stale_b = _TaskRow("demo-wp-0001-t09", status="done", workplan_id=row.id) + session = _FakeSession( + repo=_Repo(), rows=[row], task_rows=[desired, stale_a, stale_b] + ) + out = await fp.reset_repository_projection( + session, + "demo", + derived=self._derived(("DEMO-WP-0001-T01", "Do it", "todo")), + ) + assert out.status in {"applied", "noop"} + assert out.refused == [] + @pytest.mark.asyncio async def test_derived_task_uuid_held_elsewhere_is_refused_before_insert(self): row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") @@ -1170,7 +1187,7 @@ class TestExistingWorkplanTasks: ) out = await fp.reset_repository_projection(session, "demo", derived=derived) assert out.status == "refused" - assert out.refused[0]["reason"].startswith("task identity already belongs") + assert out.refused[0]["reason"].startswith("derived task identifier already belongs") assert session.added == [] @pytest.mark.asyncio diff --git a/workplans/STATE-WP-0083-forge-derived-projection-reset.md b/workplans/STATE-WP-0083-forge-derived-projection-reset.md index edb0fa7..58dfa3a 100644 --- a/workplans/STATE-WP-0083-forge-derived-projection-reset.md +++ b/workplans/STATE-WP-0083-forge-derived-projection-reset.md @@ -378,24 +378,28 @@ canonical task identities, so T02 remains `progress`. **Duplicate identity disposition (2026-08-31).** A fresh primary audit found 131 duplicate task `record_id` groups across 274 rows. The global count is not -the reset safety boundary: 104 groups have exactly one current projection -claimant plus retired history, and 15 exist only under retired workplans. Those -rows are retained as history and do not compete with the matched current -workplan. +the reset safety boundary: all 131 cross workplan boundaries, while none repeats +inside one workplan. A task has no projection-retirement field, so workplan slug +tombstones are only a diagnostic clue; current task membership is whether the +Forge still derives that id for that owning workplan. -The remaining 12 groups are two duplicated finished workplan projections: +The 12 groups with multiple untombstoned owning workplan slugs come from two +earlier wrong workplan/backing-file projections: `CUST-WP-0010b-T01..T02` and `SECRETS-WP-0002-T01..T10`. They must be resolved -by retiring the displaced workplan projection, not by deleting or re-keying its -tasks. Evidence: +by reconciling the owning workplans while preserving their completed historical +task rows, not by deleting or re-keying those rows. Live exact-commit reconciles +did that with zero creates, retirements, cancellations, or refusals. Evidence: `docs/evidence/STATE-WP-0083-task-identity-audit-2026-08-31.json`. The diff now reports an explicit `ambiguous` class instead of silently choosing the last row in a dictionary. Reset refuses duplicate identities declared by the forge, duplicate identified rows inside the matched current workplan, and -any task creation whose canonical id or derived UUID is held by another current -workplan. These checks are batched; retired/displaced workplan tasks are outside -the current matching set and remain preserved. Live deployment and a clean -repository diff remain before T02 can close. +any task creation whose derived UUID is held by another current workplan. A +repeated human-readable id outside the owning workplan's current desired set is +historical evidence, not a competing projection. These checks are batched; +retired/displaced workplan tasks remain preserved. The second exact-commit pass +for both affected repositories returned `noop` with every count zero. Final +deployment of the refined boundary remains before T02 can close. ## Restore a migration mechanism for central