fix: preserve stale task identity history
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 26s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
This commit is contained in:
tegwick 2026-08-31 02:02:35 +02:00
parent ddce470944
commit a32112e5ab
4 changed files with 65 additions and 44 deletions

View file

@ -884,7 +884,7 @@ async def reset_repository_projection(
""" """
from datetime import datetime, timezone 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.managed_repo import ManagedRepo
from api.models.task import Task from api.models.task import Task
@ -1200,10 +1200,13 @@ async def reset_repository_projection(
row = matched.get(key) row = matched.get(key)
if row is None: if row is None:
continue continue
desired_record_ids = {
task.record_id.strip().lower() for task in workplan.tasks
}
groups: dict[str, list[Any]] = {} groups: dict[str, list[Any]] = {}
for task_row in tasks_by_wp.get(row.id, []): for task_row in tasks_by_wp.get(row.id, []):
record_id = (task_row.record_id or "").strip().lower() 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) groups.setdefault(record_id, []).append(task_row)
for task_rows in groups.values(): for task_rows in groups.values():
if len(task_rows) > 1: if len(task_rows) > 1:
@ -1226,9 +1229,11 @@ async def reset_repository_projection(
return outcome return outcome
# Check every task the reset would create in one query. A derived UUID held # 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 # by another workplan is a global identity collision. A repeated record_id
# human-readable rows happen to have different titles. Letting the INSERT # on a different workplan is not sufficient by itself: terminal rows from
# discover this would turn a deterministic refusal into an IntegrityError. # 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]] = {} creating_tasks: dict[str, tuple[DerivedWorkplan, DerivedTask]] = {}
for key, workplan in want.items(): for key, workplan in want.items():
row = matched.get(key) row = matched.get(key)
@ -1247,10 +1252,6 @@ async def reset_repository_projection(
creating_tasks[task.uuid] = (workplan, task) creating_tasks[task.uuid] = (workplan, task)
if creating_tasks: 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)] current_holder_filters = [Workplan.projection_retired_at.is_(None)]
if stale: if stale:
# The caller has already acknowledged these retirements (the # The caller has already acknowledged these retirements (the
@ -1267,11 +1268,8 @@ async def reset_repository_projection(
.join(Workplan, Workplan.id == Task.workplan_id) .join(Workplan, Workplan.id == Task.workplan_id)
.where( .where(
*current_holder_filters, *current_holder_filters,
or_( Task.id.in_(
Task.id.in_( [uuid.UUID(task_id) for task_id in creating_tasks]
[uuid.UUID(task_id) for task_id in creating_tasks]
),
func.lower(Task.record_id).in_(creating_tasks_by_record_id),
), ),
) )
) )
@ -1280,15 +1278,10 @@ async def reset_repository_projection(
if task_holders: if task_holders:
outcome.status = "refused" outcome.status = "refused"
for holder in task_holders: for holder in task_holders:
candidate = creating_tasks.get(str(holder.id)) workplan, task = creating_tasks[str(holder.id)]
if candidate is None:
candidate = creating_tasks_by_record_id[
(holder.record_id or "").strip().lower()
]
workplan, task = candidate
outcome.refused.append( outcome.refused.append(
{ {
"reason": "task identity already belongs to another current workplan", "reason": "derived task identifier already belongs to another current workplan",
"kind": "task", "kind": "task",
"record_id": task.record_id, "record_id": task.record_id,
"uuid": task.uuid, "uuid": task.uuid,

View file

@ -11,12 +11,13 @@
"same_workplan_groups": 0, "same_workplan_groups": 0,
"cross_repository_groups": 31 "cross_repository_groups": 31
}, },
"projection_classification": { "slug_tombstone_shape": {
"one_current_claimant_with_retired_history": 104, "exactly_one_untombstoned_workplan_slug": 104,
"retired_history_only": 15, "tombstoned_workplan_slugs_only": 15,
"multiple_non_retired_claimants": 12 "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", "record_id_prefix": "CUST-WP-0010b-T",
"task_identity_groups": 2, "task_identity_groups": 2,
@ -25,7 +26,7 @@
"cust-wp-0010" "cust-wp-0010"
], ],
"workplan_status": "finished", "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", "record_id_prefix": "SECRETS-WP-0002-T",
@ -35,14 +36,20 @@
"secrets-wp-0002" "secrets-wp-0002"
], ],
"workplan_status": "finished", "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": { "decision": {
"retired_rows": "preserve as historical evidence; exclude from current projection ambiguity", "historical_rows": "preserve; a repeated label outside the owning workplan's desired task set is not current projection ambiguity",
"duplicate_inside_matched_workplan": "refuse reset and report every claimant UUID", "duplicate_inside_matched_workplan_desired_set": "refuse reset and report every claimant UUID",
"duplicate_inside_forge_projection": "refuse reset; require file-level disposition", "duplicate_inside_forge_projection": "refuse reset; require file-level disposition",
"new_task_identity_held_by_other_current_workplan": "refuse before insert", "new_derived_task_uuid_held_by_other_current_workplan": "refuse before insert",
"duplicate_across_retired_or_displaced_workplans": "does not block current projection reset" "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"
} }
} }

View file

@ -1156,6 +1156,23 @@ class TestExistingWorkplanTasks:
assert out.refused[0]["reason"].startswith("current workplan contains duplicate") assert out.refused[0]["reason"].startswith("current workplan contains duplicate")
assert session.added == [] 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 @pytest.mark.asyncio
async def test_derived_task_uuid_held_elsewhere_is_refused_before_insert(self): 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") 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) out = await fp.reset_repository_projection(session, "demo", derived=derived)
assert out.status == "refused" 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 == [] assert session.added == []
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -378,24 +378,28 @@ canonical task identities, so T02 remains `progress`.
**Duplicate identity disposition (2026-08-31).** A fresh primary audit found **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 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 the reset safety boundary: all 131 cross workplan boundaries, while none repeats
claimant plus retired history, and 15 exist only under retired workplans. Those inside one workplan. A task has no projection-retirement field, so workplan slug
rows are retained as history and do not compete with the matched current tombstones are only a diagnostic clue; current task membership is whether the
workplan. 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 `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 by reconciling the owning workplans while preserving their completed historical
tasks. Evidence: 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`. `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 diff now reports an explicit `ambiguous` class instead of silently choosing
the last row in a dictionary. Reset refuses duplicate identities declared by the last row in a dictionary. Reset refuses duplicate identities declared by
the forge, duplicate identified rows inside the matched current workplan, and 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 any task creation whose derived UUID is held by another current workplan. A
workplan. These checks are batched; retired/displaced workplan tasks are outside repeated human-readable id outside the owning workplan's current desired set is
the current matching set and remain preserved. Live deployment and a clean historical evidence, not a competing projection. These checks are batched;
repository diff remain before T02 can close. 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 ## Restore a migration mechanism for central