Guard Forge retirement with an exact expected UUID set
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 33s

Blanket acknowledgement is unchanged when the UUID list is omitted. A present
expected_retirement_uuids set must equal the stale projections, and the call
retires nothing when that set mismatches, a stale row still has tasks or
dependency edges, or both authorizations are set together.

Assistant: grok
Assistant-Session: 01a10946-16b1-7973-b1dd-71fc58b8fcf0
This commit is contained in:
tegwick 2026-10-05 01:46:06 +02:00
parent 3fc5f72076
commit 968a8117f4
5 changed files with 448 additions and 5 deletions

View file

@ -131,6 +131,7 @@ async def reconcile_repository_work_records(
session,
slug,
acknowledge_retirements=body.acknowledge_retirements,
expected_retirement_uuids=body.expected_retirement_uuids,
derived=derived,
)
if outcome.status in {"applied", "noop"} or outcome.released:

View file

@ -14,6 +14,10 @@ from api.schemas.workplan_dependency import WorkplanDependencyRead
class RepositoryProjectionReconcile(BaseModel):
expected_commit: str
acknowledge_retirements: bool = False
# None keeps today's blanket acknowledgement. An empty list is present and
# means the caller expects zero retirements. Combined with
# acknowledge_retirements, the call is ambiguous and retires nothing.
expected_retirement_uuids: list[uuid.UUID] | None = None
@field_validator("expected_commit")
@classmethod

View file

@ -1071,6 +1071,81 @@ def _tombstone_slug(slug: str, when: datetime) -> str:
RETIRE_REASON = "no longer derived from the forge"
def _uuid_key(value: Any) -> str:
return str(uuid.UUID(str(value)))
async def _exact_retirement_blockers(session: Any, stale: list[Any]) -> list[dict[str, Any]]:
"""Block an exact retirement when any stale row still has tasks or edges.
Zero means zero. Terminal tasks count, and so does a dependency edge in
either direction. The caller retires nothing unless this returns empty.
"""
if not stale:
return []
from sqlalchemy import or_, select
from api.models.task import Task
from api.models.workplan_dependency import WorkplanDependency
stale_ids = [row.id for row in stale]
tasks = list(
(
await session.execute(select(Task).where(Task.workplan_id.in_(stale_ids)))
).scalars()
)
edges = list(
(
await session.execute(
select(WorkplanDependency).where(
or_(
WorkplanDependency.from_workplan_id.in_(stale_ids),
WorkplanDependency.to_workplan_id.in_(stale_ids),
)
)
)
).scalars()
)
refused: list[dict[str, Any]] = []
for row in stale:
key = str(row.id)
row_tasks = [task for task in tasks if str(task.workplan_id) == key]
row_edges = [
edge
for edge in edges
if str(edge.from_workplan_id) == key
or (edge.to_workplan_id is not None and str(edge.to_workplan_id) == key)
]
if row_tasks:
refused.append(
{
"reason": "stale projection has tasks",
"uuid": key,
"slug": row.slug,
"task_ids": [str(task.id) for task in row_tasks],
}
)
if row_edges:
refused.append(
{
"reason": "stale projection has dependency edges",
"uuid": key,
"slug": row.slug,
"dependency_ids": [str(edge.id) for edge in row_edges],
}
)
if (tasks or edges) and not refused:
refused.append(
{
"reason": "stale projection has tasks or dependency edges",
"task_ids": [str(task.id) for task in tasks],
"dependency_ids": [str(edge.id) for edge in edges],
}
)
return refused
def _coerce_task_status(raw: str | None) -> Any:
from api.models.task import TaskStatus
@ -1202,6 +1277,7 @@ async def reset_repository_projection(
repo_slug: str,
*,
acknowledge_retirements: bool = False,
expected_retirement_uuids: list[uuid.UUID] | None = None,
forge_base: str = DEFAULT_FORGE_BASE,
derived: DerivedProjection | None = None,
) -> ResetOutcome:
@ -1215,7 +1291,10 @@ async def reset_repository_projection(
Retirement is refused by default. A record that stops deriving may mean the
file was removed deliberately — or that someone pointed this at the wrong
branch. The caller must say which.
branch. The caller must say which, either by blanket acknowledgement or by
the exact hub UUIDs expected to retire. The two authorizations cannot be
combined. Omitting the UUID list keeps blanket acknowledgement unchanged;
an empty list expects zero retirements.
Scope: workplans, and their tasks. Tasks of existing workplans are matched
by ``record_id`` (STATE-WP-0083-T06); rows with no ``record_id`` are left
@ -1480,6 +1559,24 @@ async def reset_repository_projection(
r for k, r in matched.items()
if k not in want and r.projection_retired_at is None
] + [r for r in displaced if r.projection_retired_at is None]
# Exact UUIDs and blanket acknowledgement are different authorizations.
# A call that sets both retires nothing, before any retirement mutation.
if expected_retirement_uuids is not None and acknowledge_retirements:
outcome.status = "refused"
outcome.refused.append(
{
"reason": "ambiguous authorization",
"detail": (
"expected_retirement_uuids and acknowledge_retirements "
"cannot both be set"
),
}
)
outcome.notes.append(
"Nothing was retired. Pass either an exact retirement set or "
"blanket acknowledgement, not both."
)
return outcome
if stale and not derived.retirement_eligible:
# Acknowledgement cannot authorise this. The caller is confirming that
# records which stopped deriving should be retired; here nothing has
@ -1501,7 +1598,37 @@ async def reset_repository_projection(
"regardless of acknowledgement (STATE-WP-0084-T01)."
)
return outcome
if stale and not acknowledge_retirements:
if expected_retirement_uuids is not None:
# `stale` is the unmatched live rows plus the displaced live rows.
# A row that still matches, including a historical parent, is not in it.
actual = {_uuid_key(row.id) for row in stale}
expected = {_uuid_key(value) for value in expected_retirement_uuids}
missing = sorted(actual - expected)
extra = sorted(expected - actual)
if missing or extra:
outcome.status = "refused"
outcome.refused.append(
{
"reason": "expected retirement set does not match stale projections",
"missing": missing,
"extra": extra,
}
)
outcome.notes.append(
"Nothing was retired. The expected set must equal the stale "
"set; partial retirement is not applied."
)
return outcome
blockers = await _exact_retirement_blockers(session, stale)
if blockers:
outcome.status = "refused"
outcome.refused.extend(blockers)
outcome.notes.append(
"Nothing was retired. A stale projection with tasks or "
"dependency edges cannot be retired."
)
return outcome
elif stale and not acknowledge_retirements:
outcome.status = "refused"
for r in stale:
outcome.refused.append(