feat: prove repository rename continuity
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 24s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
This commit is contained in:
tegwick 2026-08-29 15:05:09 +02:00
parent 037c8360e2
commit 9f0a104b56
11 changed files with 1186 additions and 96 deletions

View file

@ -2,7 +2,7 @@
# Custodian Brief — state-hub
**Domain:** infotech
**Last synced:** 2026-08-29 11:07 UTC
**Last synced:** 2026-08-29 13:03 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams
@ -30,11 +30,10 @@ Progress: 1/4 done | workplan_id: `9c4b8c79-edba-5d2d-ad37-a45707e89304`
- ► Teach the derivation to use it `b22b24d9`
### Lineage-preserving repository rename workflow and adoption-plan generator
Progress: 6/9 done | workplan_id: `4f5661de-5922-55d6-bd02-5dcb00b13f73`
Progress: 7/9 done | workplan_id: `4f5661de-5922-55d6-bd02-5dcb00b13f73`
**Open tasks:**
- ! Generate and review the flex-auth adoption plan `df34f6ec`
- · Prove failure recovery and telemetry continuity `5160ad75`
- · Document operations and repository-boundary handoffs `06b6cde4`
---

View file

@ -327,6 +327,6 @@
| task | STATE-WP-0085-T04 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T05 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T06 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T07 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T07 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T08 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T09 | wait | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |

View file

@ -1,12 +1,15 @@
from __future__ import annotations
import asyncio
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.events import EventEnvelope, publish_event
from api.models.repository_rename import RepositoryRenameOperation
from api.schemas.repository_rename import (
ForgeIdentityVerifyRequest,
@ -44,10 +47,77 @@ operation_router = APIRouter(
)
def repository_rename_events(
operation: RepositoryRenameOperation,
*,
outcome: str,
no_op: bool = False,
verification: dict[str, Any] | None = None,
) -> list[tuple[str, EventEnvelope]]:
"""Build credential-free lifecycle events from the durable journal."""
telemetry = (operation.evidence or {}).get("telemetry") or {}
phase_durations = telemetry.get("phase_durations_ms") or {}
attributes = {
"operation_id": str(operation.id),
"repo_id": str(operation.repo_id),
"forge_repository_id": operation.expected_forge_repository_id,
"old_slug": operation.old_slug,
"new_slug": operation.new_slug,
"phase": operation.phase,
"actor": operation.actor,
"expected_source_commit": operation.expected_source_commit,
"outcome": outcome,
"no_op": no_op,
"phase_duration_ms": int(phase_durations.get(operation.phase) or 0),
"retries": int(telemetry.get("retries") or 0),
"failures": int(telemetry.get("failures") or 0),
"rollback_attempts": int(telemetry.get("rollback_attempts") or 0),
"rollback_outcome": telemetry.get("rollback_outcome"),
"verification_outcome": (
"passed" if verification and verification.get("ok") else
"failed" if verification else
telemetry.get("verification_outcome", "pending")
),
"error_code": operation.error_code if outcome == "failed" else None,
"evidence_ref": f"repository-renames/operations/{operation.id}",
}
subject = (
"org.statehub.repo.rename.failed"
if outcome == "failed"
else "org.statehub.repo.rename.verified"
if verification is not None
else "org.statehub.repo.rename.rolled_back"
if operation.phase == "rolled-back"
else "org.statehub.repo.rename.phase"
)
events = [(subject, EventEnvelope.new(subject, attributes=attributes))]
if operation.phase == "completed" and outcome == "succeeded":
renamed_subject = "org.statehub.repo.renamed"
events.append(
(renamed_subject, EventEnvelope.new(renamed_subject, attributes=attributes))
)
return events
def _publish_rename_events(
operation: RepositoryRenameOperation,
*,
outcome: str,
no_op: bool = False,
verification: dict[str, Any] | None = None,
) -> None:
for subject, envelope in repository_rename_events(
operation, outcome=outcome, no_op=no_op, verification=verification
):
asyncio.create_task(publish_event(subject, envelope))
def _raise(exc: RenameLifecycleError) -> None:
raise HTTPException(
status_code=exc.status_code,
detail={"code": exc.code, "message": str(exc), "details": exc.details},
detail=jsonable_encoder(
{"code": exc.code, "message": str(exc), "details": exc.details}
),
) from exc
@ -129,6 +199,7 @@ async def create_repository_rename_operation(
operation, no_op = await create_operation(session, gateway, repo_id, body)
except RenameLifecycleError as exc:
_raise(exc)
_publish_rename_events(operation, outcome="succeeded", no_op=no_op)
return _operation_read(operation, no_op=no_op)
@ -194,7 +265,11 @@ async def apply_repository_rename_phase(
session, gateway, repo_id, operation_id, phase, body
)
except RenameLifecycleError as exc:
failed = await session.get(RepositoryRenameOperation, operation_id)
if failed is not None:
_publish_rename_events(failed, outcome="failed")
_raise(exc)
_publish_rename_events(operation, outcome="succeeded", no_op=no_op)
return _operation_read(operation, no_op=no_op)
@ -210,9 +285,15 @@ async def verify_repository_rename_operation(
) -> dict[str, Any]:
try:
operation = await load_operation(session, repo_id, operation_id)
return await verify_operation(session, gateway, operation)
verification = await verify_operation(session, gateway, operation)
except RenameLifecycleError as exc:
_raise(exc)
_publish_rename_events(
operation,
outcome="succeeded" if verification["ok"] else "failed",
verification=verification,
)
return verification
@router.post(
@ -236,7 +317,11 @@ async def preflight_repository_rename_rollback(
confirmation=body.confirmation,
)
except RenameLifecycleError as exc:
failed = await session.get(RepositoryRenameOperation, operation_id)
if failed is not None:
_publish_rename_events(failed, outcome="failed")
_raise(exc)
_publish_rename_events(operation, outcome="succeeded")
return {
"operation_id": operation.id,
"repo_id": operation.repo_id,
@ -266,5 +351,9 @@ async def rollback_repository_rename_operation(
confirmation=body.confirmation,
)
except RenameLifecycleError as exc:
failed = await session.get(RepositoryRenameOperation, operation_id)
if failed is not None:
_publish_rename_events(failed, outcome="failed")
_raise(exc)
_publish_rename_events(operation, outcome="succeeded", no_op=no_op)
return _operation_read(operation, no_op=no_op)

View file

@ -101,6 +101,7 @@ class RepositoryRenameVerificationRead(BaseModel):
checks: list[dict[str, Any]]
baseline_checksum: str
current_checksum: str
relationship_checksums: dict[str, dict[str, str]]
class RepositoryRenameRollbackPreflightRead(BaseModel):

View file

@ -176,23 +176,78 @@ def _record_summary(ids: list[str]) -> dict[str, Any]:
return {"count": len(ids), "ids": ids, "checksum": checksum(ids)}
async def _relationship_rows(
session: AsyncSession, query, fields: tuple[str, ...]
) -> list[dict[str, Any]]:
def normalize(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, datetime):
return value.astimezone(timezone.utc).isoformat()
if isinstance(value, uuid.UUID):
return str(value)
if hasattr(value, "value"):
return value.value
return str(value)
result = await session.execute(query)
rows = [
{field: normalize(getattr(row, field)) for field in fields}
for row in result.all()
]
return sorted(rows, key=lambda row: canonical_json(row))
def _relationship_summary(
rows: list[dict[str, Any]], *, totals: dict[str, int] | None = None
) -> dict[str, Any]:
ids = sorted(row["id"] for row in rows)
result = {
"count": len(rows),
"ids": ids,
"checksum": checksum(ids),
"relationships": rows,
"relationship_checksum": checksum(rows),
}
if totals is not None:
result["totals"] = totals
result["totals_checksum"] = checksum(totals)
return result
async def collect_continuity_baseline(
session: AsyncSession, repo_id: uuid.UUID
) -> dict[str, Any]:
workplans = await _id_rows(
session, select(Workplan.id).where(Workplan.repo_id == repo_id)
)
workplan_uuids = [uuid.UUID(value) for value in workplans]
tasks = await _id_rows(
workplans = await _relationship_rows(
session,
select(Task.id).where(
select(
Workplan.id.label("id"),
Workplan.repo_id.label("repo_id"),
Workplan.slug.label("slug"),
).where(Workplan.repo_id == repo_id),
("id", "repo_id", "slug"),
)
workplan_uuids = [uuid.UUID(row["id"]) for row in workplans]
tasks = await _relationship_rows(
session,
select(
Task.id.label("id"),
Task.workplan_id.label("workplan_id"),
Task.record_id.label("record_id"),
).where(
Task.workplan_id.in_(workplan_uuids) if workplan_uuids else False
),
("id", "workplan_id", "record_id"),
)
task_uuids = [uuid.UUID(value) for value in tasks]
progress = await _id_rows(
task_uuids = [uuid.UUID(row["id"]) for row in tasks]
progress = await _relationship_rows(
session,
select(ProgressEvent.id).where(
select(
ProgressEvent.id.label("id"),
ProgressEvent.workplan_id.label("workplan_id"),
ProgressEvent.task_id.label("task_id"),
ProgressEvent.decision_id.label("decision_id"),
).where(
or_(
ProgressEvent.workplan_id.in_(workplan_uuids)
if workplan_uuids
@ -200,16 +255,31 @@ async def collect_continuity_baseline(
ProgressEvent.task_id.in_(task_uuids) if task_uuids else False,
)
),
("id", "workplan_id", "task_id", "decision_id"),
)
decisions = await _id_rows(
decisions = await _relationship_rows(
session,
select(Decision.id).where(
select(
Decision.id.label("id"),
Decision.workplan_id.label("workplan_id"),
).where(
Decision.workplan_id.in_(workplan_uuids) if workplan_uuids else False
),
("id", "workplan_id"),
)
token_events = await _id_rows(
token_events = await _relationship_rows(
session,
select(TokenEvent.id).where(
select(
TokenEvent.id.label("id"),
TokenEvent.repo_id.label("repo_id"),
TokenEvent.workplan_id.label("workplan_id"),
TokenEvent.task_id.label("task_id"),
TokenEvent.tokens_in.label("tokens_in"),
TokenEvent.tokens_out.label("tokens_out"),
TokenEvent.cached_input_tokens.label("cached_input_tokens"),
TokenEvent.reasoning_output_tokens.label("reasoning_output_tokens"),
TokenEvent.raw_total_tokens.label("raw_total_tokens"),
).where(
or_(
TokenEvent.repo_id == repo_id,
TokenEvent.workplan_id.in_(workplan_uuids)
@ -218,44 +288,141 @@ async def collect_continuity_baseline(
TokenEvent.task_id.in_(task_uuids) if task_uuids else False,
)
),
(
"id",
"repo_id",
"workplan_id",
"task_id",
"tokens_in",
"tokens_out",
"cached_input_tokens",
"reasoning_output_tokens",
"raw_total_tokens",
),
)
sbom_snapshots = await _id_rows(
session, select(SBOMSnapshot.id).where(SBOMSnapshot.repo_id == repo_id)
)
sbom_entries = await _id_rows(
session, select(SBOMEntry.id).where(SBOMEntry.repo_id == repo_id)
)
services = await _id_rows(
token_totals = {
field: sum(int(row[field]) for row in token_events if row[field] is not None)
for field in (
"tokens_in",
"tokens_out",
"cached_input_tokens",
"reasoning_output_tokens",
"raw_total_tokens",
)
}
sbom_snapshots = await _relationship_rows(
session,
select(ServiceFirstParty.service_id).where(ServiceFirstParty.repo_id == repo_id),
select(
SBOMSnapshot.id.label("id"),
SBOMSnapshot.repo_id.label("repo_id"),
).where(SBOMSnapshot.repo_id == repo_id),
("id", "repo_id"),
)
capabilities = await _id_rows(
sbom_entries = await _relationship_rows(
session,
select(CapabilityCatalog.id).where(CapabilityCatalog.repo_id == repo_id),
select(
SBOMEntry.id.label("id"),
SBOMEntry.repo_id.label("repo_id"),
SBOMEntry.snapshot_id.label("snapshot_id"),
).where(SBOMEntry.repo_id == repo_id),
("id", "repo_id", "snapshot_id"),
)
interface_changes = await _id_rows(
services = await _relationship_rows(
session,
select(InterfaceChange.id).where(InterfaceChange.repo_id == repo_id),
select(
ServiceFirstParty.service_id.label("id"),
ServiceFirstParty.repo_id.label("repo_id"),
).where(ServiceFirstParty.repo_id == repo_id),
("id", "repo_id"),
)
bindings = await _id_rows(
capabilities = await _relationship_rows(
session,
select(Workplan.id).where(
select(
CapabilityCatalog.id.label("id"),
CapabilityCatalog.repo_id.label("repo_id"),
).where(CapabilityCatalog.repo_id == repo_id),
("id", "repo_id"),
)
interface_changes = await _relationship_rows(
session,
select(
InterfaceChange.id.label("id"),
InterfaceChange.repo_id.label("repo_id"),
).where(InterfaceChange.repo_id == repo_id),
("id", "repo_id"),
)
bindings = await _relationship_rows(
session,
select(
Workplan.id.label("id"),
Workplan.repo_id.label("repo_id"),
Workplan.backing_relative_path.label("backing_relative_path"),
Workplan.backing_filename.label("backing_filename"),
).where(
Workplan.repo_id == repo_id,
Workplan.backing_relative_path.is_not(None),
),
("id", "repo_id", "backing_relative_path", "backing_filename"),
)
slug_rows = await _relationship_rows(
session,
select(
RepositorySlug.id.label("id"),
RepositorySlug.repo_id.label("repo_id"),
RepositorySlug.slug.label("slug"),
RepositorySlug.kind.label("kind"),
).where(RepositorySlug.repo_id == repo_id),
("id", "repo_id", "slug", "kind"),
)
known_slugs = [row["slug"] for row in slug_rows]
messages = await _relationship_rows(
session,
select(
AgentMessage.id.label("id"),
AgentMessage.from_agent.label("from_agent"),
AgentMessage.to_agent.label("to_agent"),
AgentMessage.thread_id.label("thread_id"),
).where(
or_(
AgentMessage.from_agent.in_(known_slugs),
AgentMessage.to_agent.in_(known_slugs),
)
if known_slugs
else False
),
("id", "from_agent", "to_agent", "thread_id"),
)
active_work = await _active_work(session, repo_id)
active_dispatch = [
{"id": row["id"], "repo_id": str(repo_id), "kind": "workplan"}
for row in active_work["workplans"]
] + [
{
"id": row["id"],
"workplan_id": row["workplan_id"],
"kind": "task",
}
for row in active_work["tasks"]
]
active_dispatch = sorted(active_dispatch, key=lambda row: canonical_json(row))
records = {
"workplans": _record_summary(workplans),
"tasks": _record_summary(tasks),
"progress_events": _record_summary(progress),
"decisions": _record_summary(decisions),
"token_events": _record_summary(token_events),
"sbom_snapshots": _record_summary(sbom_snapshots),
"sbom_entries": _record_summary(sbom_entries),
"services": _record_summary(services),
"capabilities": _record_summary(capabilities),
"interface_changes": _record_summary(interface_changes),
"workplan_bindings": _record_summary(bindings),
"repository": _relationship_summary(
[{"id": str(repo_id), "repo_id": str(repo_id)}]
),
"workplans": _relationship_summary(workplans),
"tasks": _relationship_summary(tasks),
"progress_events": _relationship_summary(progress),
"decisions": _relationship_summary(decisions),
"token_events": _relationship_summary(token_events, totals=token_totals),
"sbom_snapshots": _relationship_summary(sbom_snapshots),
"sbom_entries": _relationship_summary(sbom_entries),
"services": _relationship_summary(services),
"capabilities": _relationship_summary(capabilities),
"interface_changes": _relationship_summary(interface_changes),
"workplan_bindings": _relationship_summary(bindings),
"active_dispatch": _relationship_summary(active_dispatch),
"aliases": _relationship_summary(slug_rows),
"messages": _relationship_summary(messages),
}
records["continuity_checksum"] = checksum(records)
return records
@ -373,6 +540,11 @@ async def verify_forge_identity(
raise RenamePreconditionFailed(str(exc)) from exc
if snapshot is None:
raise RenamePreconditionFailed("Forge repository is absent or unreadable")
if snapshot.name != repo.slug:
raise RenamePreconditionFailed(
"Forge identity verification followed a repository redirect",
details={"expected_name": repo.slug, "actual_name": snapshot.name},
)
if snapshot.repository_id != body.forge_repository_id:
raise RenamePreconditionFailed(
"Forge repository ID does not match the asserted immutable ID",
@ -536,6 +708,15 @@ async def build_preflight(
{"code": "forge_source_absent", "message": "Canonical Forge repository is absent or unreadable"}
)
else:
if old_snapshot.name != repo.slug:
blockers.append(
{
"code": "forge_redirected",
"message": "Forge source lookup resolved to another coordinate",
"expected": repo.slug,
"actual": old_snapshot.name,
}
)
if old_snapshot.repository_id != identity.forge_repository_id:
blockers.append(
{
@ -786,6 +967,15 @@ async def create_operation(
"phases": {
"preflighted": {"at": now.isoformat(), "resumed": False}
},
"telemetry": {
"started_at": now.isoformat(),
"phase_attempts": {"preflighted": 1},
"phase_durations_ms": {"preflighted": 0},
"retries": 0,
"failures": 0,
"rollback_attempts": 0,
"verification_outcome": "pending",
},
},
)
session.add(operation)
@ -860,7 +1050,12 @@ async def list_operations(
return list(result.scalars().all())
def _assert_snapshot(operation: RepositoryRenameOperation, snapshot: ForgeRepositorySnapshot) -> None:
def _assert_snapshot(
operation: RepositoryRenameOperation,
snapshot: ForgeRepositorySnapshot,
*,
expected_name: str | None = None,
) -> None:
if snapshot.repository_id != operation.expected_forge_repository_id:
raise RenamePreconditionFailed(
"Forge repository ID changed",
@ -881,6 +1076,11 @@ def _assert_snapshot(operation: RepositoryRenameOperation, snapshot: ForgeReposi
)
if not snapshot.projection_readable or not snapshot.projection_source_present:
raise RenamePreconditionFailed("Forge work-record projection is unreadable")
if expected_name is not None and snapshot.name != expected_name:
raise RenamePreconditionFailed(
"Forge response resolved to a different repository coordinate",
details={"expected_name": expected_name, "actual_name": snapshot.name},
)
async def _forge_at(
@ -904,13 +1104,13 @@ async def _apply_forge_rename(
old = await _forge_at(gateway, operation, operation.old_slug)
new = await _forge_at(gateway, operation, operation.new_slug)
if new is not None:
_assert_snapshot(operation, new)
_assert_snapshot(operation, new, expected_name=operation.new_slug)
if old is not None:
raise RenamePreconditionFailed("Both old and new Forge names are claimed")
return {"resumed": True, "forge": new.as_dict()}
if old is None:
raise RenamePreconditionFailed("Forge repository is absent at both expected names")
_assert_snapshot(operation, old)
_assert_snapshot(operation, old, expected_name=operation.old_slug)
try:
renamed = await gateway.rename(
instance=operation.expected_forge_instance,
@ -920,7 +1120,7 @@ async def _apply_forge_rename(
)
except (ForgeRepositoryUnreadable, ForgeRepositoryConflict) as exc:
raise RenamePreconditionFailed(str(exc)) from exc
_assert_snapshot(operation, renamed)
_assert_snapshot(operation, renamed, expected_name=operation.new_slug)
return {"resumed": False, "forge": renamed.as_dict()}
@ -1005,6 +1205,7 @@ async def verify_operation(
else:
add("forge_readable", forge is not None, True, forge is not None)
if forge is not None:
add("forge_coordinate", forge.name == expected_name, expected_name, forge.name)
add("forge_repository_id", forge.repository_id == operation.expected_forge_repository_id, operation.expected_forge_repository_id, forge.repository_id)
add("source_commit", forge.head_commit == operation.expected_source_commit, operation.expected_source_commit, forge.head_commit)
add("default_branch", forge.default_branch == operation.expected_default_branch, operation.expected_default_branch, forge.default_branch)
@ -1022,6 +1223,8 @@ async def verify_operation(
baseline = (operation.evidence.get("preflight") or {}).get("baselines") or {}
expected_checksum = baseline.get("continuity_checksum", "")
missing: dict[str, list[str]] = {}
changed_relationships: dict[str, list[str]] = {}
preserved_relationship_checksums: dict[str, str] = {}
counts: dict[str, dict[str, int]] = {}
for record_type, baseline_record in baseline.items():
if record_type == "continuity_checksum" or not isinstance(baseline_record, dict):
@ -1030,6 +1233,30 @@ async def verify_operation(
absent = sorted(set(baseline_record.get("ids") or []) - set(current_record.get("ids") or []))
if absent:
missing[record_type] = absent
if record_type != "aliases":
baseline_rows = {
row["id"]: row
for row in baseline_record.get("relationships") or []
}
current_rows = {
row["id"]: row
for row in current_record.get("relationships") or []
}
changed = sorted(
record_id
for record_id, baseline_row in baseline_rows.items()
if current_rows.get(record_id) != baseline_row
)
if changed:
changed_relationships[record_type] = changed
preserved_rows = [
current_rows[record_id]
for record_id in baseline_rows
if record_id in current_rows
]
preserved_relationship_checksums[record_type] = checksum(
sorted(preserved_rows, key=lambda row: canonical_json(row))
)
counts[record_type] = {
"baseline": int(baseline_record.get("count") or 0),
"current": int(current_record.get("count") or 0),
@ -1037,13 +1264,54 @@ async def verify_operation(
# New telemetry may legitimately arrive while a phased operation is being
# executed. Continuity means every baseline identity still exists; it does
# not freeze the repository's append-only history at the preflight count.
add("relationship_continuity", not missing, {"missing": {}}, {"missing": missing})
add(
"relationship_continuity",
not missing and not changed_relationships,
{"missing": {}, "changed": {}},
{"missing": missing, "changed": changed_relationships},
)
add(
"record_counts_non_decreasing",
all(item["current"] >= item["baseline"] for item in counts.values()),
{name: item["baseline"] for name, item in counts.items()},
{name: item["current"] for name, item in counts.items()},
)
baseline_token_totals = (baseline.get("token_events") or {}).get("totals") or {}
current_token_totals = (current.get("token_events") or {}).get("totals") or {}
add(
"token_totals_non_decreasing",
all(
int(current_token_totals.get(name) or 0) >= int(value or 0)
for name, value in baseline_token_totals.items()
),
baseline_token_totals,
current_token_totals,
)
slug_result = await session.execute(
select(RepositorySlug).where(
RepositorySlug.repo_id == operation.repo_id,
RepositorySlug.slug.in_((operation.old_slug, operation.new_slug)),
)
)
slug_routes = {row.slug: row.kind for row in slug_result.scalars().all()}
if operation.phase in {"preflighted", "forge-renamed"}:
expected_routes = {operation.old_slug: "canonical"}
elif operation.phase == "rolled-back":
expected_routes = {
operation.old_slug: "canonical",
operation.new_slug: "alias",
}
else:
expected_routes = {
operation.old_slug: "alias",
operation.new_slug: "canonical",
}
add(
"slug_routes",
all(slug_routes.get(slug) == kind for slug, kind in expected_routes.items()),
expected_routes,
slug_routes,
)
return {
"operation_id": operation.id,
"repo_id": operation.repo_id,
@ -1052,12 +1320,63 @@ async def verify_operation(
"checks": checks,
"baseline_checksum": expected_checksum,
"current_checksum": current["continuity_checksum"],
"relationship_checksums": {
"baseline": {
name: value.get("relationship_checksum")
for name, value in baseline.items()
if isinstance(value, dict) and name != "aliases"
},
"preserved": preserved_relationship_checksums,
},
}
def _telemetry(operation: RepositoryRenameOperation) -> tuple[dict[str, Any], dict[str, Any]]:
journal = deepcopy(operation.evidence or {})
telemetry = dict(journal.get("telemetry") or {})
telemetry.setdefault("started_at", operation.preflighted_at.isoformat())
telemetry.setdefault("phase_attempts", {})
telemetry.setdefault("phase_durations_ms", {})
telemetry.setdefault("retries", 0)
telemetry.setdefault("failures", 0)
telemetry.setdefault("rollback_attempts", 0)
telemetry.setdefault("verification_outcome", "pending")
return journal, telemetry
def _record_retry(operation: RepositoryRenameOperation, phase: str) -> None:
journal, telemetry = _telemetry(operation)
attempts = dict(telemetry["phase_attempts"])
attempts[phase] = int(attempts.get(phase) or 0) + 1
telemetry["phase_attempts"] = attempts
telemetry["retries"] = int(telemetry["retries"]) + 1
telemetry["last_retry"] = {"phase": phase, "at": utcnow().isoformat()}
journal["telemetry"] = telemetry
operation.evidence = journal
def _record_failure(
operation: RepositoryRenameOperation, phase: str, exc: RenameLifecycleError
) -> None:
journal, telemetry = _telemetry(operation)
failures_by_phase = dict(telemetry.get("failures_by_phase") or {})
failures_by_phase[phase] = int(failures_by_phase.get(phase) or 0) + 1
telemetry["failures_by_phase"] = failures_by_phase
telemetry["failures"] = int(telemetry["failures"]) + 1
telemetry["last_failure"] = {
"phase": phase,
"code": exc.code,
"at": utcnow().isoformat(),
}
if phase.startswith("rollback"):
telemetry["rollback_outcome"] = "failed"
journal["telemetry"] = telemetry
operation.evidence = journal
def _record_phase(operation: RepositoryRenameOperation, phase: str, evidence: dict[str, Any]) -> None:
now = utcnow()
journal = deepcopy(operation.evidence or {})
journal, telemetry = _telemetry(operation)
phases = dict(journal.get("phases") or {})
# Verification payloads use native UUID/datetime values for API response
# typing. The operation journal is JSONB, so normalize at the boundary
@ -1065,6 +1384,26 @@ def _record_phase(operation: RepositoryRenameOperation, phase: str, evidence: di
safe_evidence = json.loads(canonical_json(evidence))
phases[phase] = {"at": now.isoformat(), **safe_evidence}
journal["phases"] = phases
attempts = dict(telemetry["phase_attempts"])
attempts[phase] = int(attempts.get(phase) or 0) + 1
telemetry["phase_attempts"] = attempts
durations = dict(telemetry["phase_durations_ms"])
durations[phase] = max(
0, int((now - operation.phase_changed_at).total_seconds() * 1000)
)
telemetry["phase_durations_ms"] = durations
telemetry["last_phase"] = phase
if phase == "rollback-preflight":
telemetry["rollback_attempts"] = int(telemetry["rollback_attempts"]) + 1
telemetry["rollback_outcome"] = "approved"
elif phase == "rolled-back":
telemetry["rollback_outcome"] = "completed"
verification = evidence.get("verification")
if isinstance(verification, dict):
telemetry["verification_outcome"] = (
"passed" if verification.get("ok") else "failed"
)
journal["telemetry"] = telemetry
operation.evidence = journal
operation.phase = phase
operation.phase_changed_at = now
@ -1101,6 +1440,9 @@ async def apply_phase(
current_index = _phase_index(operation.phase)
requested_index = _phase_index(requested_phase)
if current_index >= requested_index >= 0:
_record_retry(operation, requested_phase)
await session.commit()
await session.refresh(operation)
return operation, True
if operation.phase != body.expected_phase:
raise RenamePreconditionFailed(
@ -1119,17 +1461,35 @@ async def apply_phase(
forge = await _forge_at(gateway, operation, operation.new_slug)
if forge is None:
raise RenamePreconditionFailed("Forge rename is not observable")
_assert_snapshot(operation, forge)
_assert_snapshot(operation, forge, expected_name=operation.new_slug)
evidence = await _apply_statehub_rebind(session, operation)
elif requested_phase == "source-synced":
if not body.evidence:
if not body.evidence or body.evidence.get("fresh_clone") is not True:
raise RenamePreconditionFailed(
"Source synchronization requires operator evidence"
"Source synchronization requires fresh-clone operator evidence"
)
clone_repository_id = body.evidence.get("forge_repository_id")
if clone_repository_id != operation.expected_forge_repository_id:
raise RenamePreconditionFailed(
"Fresh clone points at the wrong Forge repository ID",
details={
"expected": operation.expected_forge_repository_id,
"actual": clone_repository_id,
},
)
clone_head = body.evidence.get("head_commit")
if clone_head != operation.expected_source_commit:
raise RenamePreconditionFailed(
"Fresh clone head does not match the rename baseline",
details={
"expected": operation.expected_source_commit,
"actual": clone_head,
},
)
forge = await _forge_at(gateway, operation, operation.new_slug)
if forge is None:
raise RenamePreconditionFailed("Renamed Forge repository is absent")
_assert_snapshot(operation, forge)
_assert_snapshot(operation, forge, expected_name=operation.new_slug)
evidence = {"forge": forge.as_dict(), "operator_evidence": body.evidence}
elif requested_phase == "consumers-verified":
if not body.checks or not all(body.checks.values()):
@ -1161,9 +1521,10 @@ async def apply_phase(
# phase is precisely what lets the next request discover and resume it.
await session.rollback()
failed = await load_operation(session, repo_id, operation_id, for_update=True)
_record_failure(failed, requested_phase, exc)
failed.error_code = exc.code
failed.error_message = str(exc)
failed.error_details = exc.details
failed.error_details = json.loads(canonical_json(exc.details))
failed.error_at = utcnow()
await session.commit()
raise
@ -1173,9 +1534,10 @@ async def apply_phase(
"Repository rename phase lost a database compare-and-set race"
)
failed = await load_operation(session, repo_id, operation_id, for_update=True)
_record_failure(failed, requested_phase, failure)
failed.error_code = failure.code
failed.error_message = str(failure)
failed.error_details = failure.details
failed.error_details = json.loads(canonical_json(failure.details))
failed.error_at = utcnow()
await session.commit()
raise failure from exc
@ -1225,7 +1587,7 @@ async def rollback_preflight(
if operation.phase == "preflighted":
if old is not None and new is None:
try:
_assert_snapshot(operation, old)
_assert_snapshot(operation, old, expected_name=operation.old_slug)
except RenameLifecycleError as exc:
blockers.append({"code": exc.code, "message": str(exc), **exc.details})
elif old is None and new is not None:
@ -1233,7 +1595,7 @@ async def rollback_preflight(
# principal interruption case the operation ID must recover from.
rollback_from_phase = "forge-renamed-unrecorded"
try:
_assert_snapshot(operation, new)
_assert_snapshot(operation, new, expected_name=operation.new_slug)
except RenameLifecycleError as exc:
blockers.append({"code": exc.code, "message": str(exc), **exc.details})
elif old is None:
@ -1247,7 +1609,7 @@ async def rollback_preflight(
blockers.append({"code": "renamed_forge_repository_absent"})
else:
try:
_assert_snapshot(operation, new)
_assert_snapshot(operation, new, expected_name=operation.new_slug)
except RenameLifecycleError as exc:
blockers.append({"code": exc.code, "message": str(exc), **exc.details})
irreversible = [
@ -1262,7 +1624,16 @@ async def rollback_preflight(
"irreversible": irreversible,
}
if blockers:
raise RenamePreconditionFailed("Repository rename rollback is unsafe", details=data)
failure = RenamePreconditionFailed(
"Repository rename rollback is unsafe", details=data
)
_record_failure(operation, "rollback-preflight", failure)
operation.error_code = failure.code
operation.error_message = str(failure)
operation.error_details = json.loads(canonical_json(failure.details))
operation.error_at = utcnow()
await session.commit()
raise failure
journal = deepcopy(operation.evidence or {})
journal["rollback_preflight"] = data
operation.evidence = journal
@ -1319,6 +1690,9 @@ async def apply_rollback(
) -> tuple[RepositoryRenameOperation, bool]:
operation = await load_operation(session, repo_id, operation_id, for_update=True)
if operation.phase == "rolled-back":
_record_retry(operation, "rolled-back")
await session.commit()
await session.refresh(operation)
return operation, True
if operation.phase != expected_phase or operation.phase != "rollback-preflight":
raise RenamePreconditionFailed(
@ -1330,35 +1704,56 @@ async def apply_rollback(
rollback_from = ((operation.evidence or {}).get("rollback_preflight") or {}).get("rollback_from_phase")
if not rollback_from:
raise RenamePreconditionFailed("Rollback preflight evidence is missing")
old = await _forge_at(gateway, operation, operation.old_slug)
new = await _forge_at(gateway, operation, operation.new_slug)
forge_resumed = False
if rollback_from != "preflighted":
if old is not None:
_assert_snapshot(operation, old)
if new is not None:
raise RenamePreconditionFailed("Both Forge coordinates are claimed during rollback")
forge_resumed = True
else:
if new is None:
raise RenamePreconditionFailed("Forge repository is absent during rollback")
_assert_snapshot(operation, new)
try:
restored = await gateway.rename(
instance=operation.expected_forge_instance,
owner=operation.expected_forge_owner,
old_name=operation.new_slug,
new_name=operation.old_slug,
try:
old = await _forge_at(gateway, operation, operation.old_slug)
new = await _forge_at(gateway, operation, operation.new_slug)
forge_resumed = False
if rollback_from != "preflighted":
if old is not None:
_assert_snapshot(operation, old, expected_name=operation.old_slug)
if new is not None:
raise RenamePreconditionFailed(
"Both Forge coordinates are claimed during rollback"
)
forge_resumed = True
else:
if new is None:
raise RenamePreconditionFailed(
"Forge repository is absent during rollback"
)
_assert_snapshot(operation, new, expected_name=operation.new_slug)
try:
restored = await gateway.rename(
instance=operation.expected_forge_instance,
owner=operation.expected_forge_owner,
old_name=operation.new_slug,
new_name=operation.old_slug,
)
except (ForgeRepositoryUnreadable, ForgeRepositoryConflict) as exc:
raise RenamePreconditionFailed(str(exc)) from exc
_assert_snapshot(
operation, restored, expected_name=operation.old_slug
)
except (ForgeRepositoryUnreadable, ForgeRepositoryConflict) as exc:
raise RenamePreconditionFailed(str(exc)) from exc
_assert_snapshot(operation, restored)
statehub = await _rollback_statehub(session, operation)
_record_phase(
operation,
"rolled-back",
{"rollback_from_phase": rollback_from, "forge_resumed": forge_resumed, "statehub": statehub},
)
await session.commit()
await session.refresh(operation)
return operation, False
statehub = await _rollback_statehub(session, operation)
_record_phase(
operation,
"rolled-back",
{
"rollback_from_phase": rollback_from,
"forge_resumed": forge_resumed,
"statehub": statehub,
},
)
await session.commit()
await session.refresh(operation)
return operation, False
except RenameLifecycleError as exc:
await session.rollback()
failed = await load_operation(session, repo_id, operation_id, for_update=True)
_record_failure(failed, "rolled-back", exc)
failed.error_code = exc.code
failed.error_message = str(exc)
failed.error_details = json.loads(canonical_json(exc.details))
failed.error_at = utcnow()
await session.commit()
raise

View file

@ -0,0 +1,70 @@
# Repository rename recovery and continuity matrix
Work item: `STATE-WP-0085-T07`
Scope: State Hub lifecycle only; no live `flex-auth`/`access-engine` mutation.
## Automated recovery matrix
| Hazard | Automated proof | Required result |
| --- | --- | --- |
| Interruption/failure after every forward phase | `test_every_forward_phase_failure_is_retry_safe`; `test_interrupt_resume_every_phase_and_preserve_uuid` | Journal remains at the last achieved phase; retry resumes the same operation UUID. |
| Stale preflight/head | `test_stale_head_wrong_id_target_conflict_and_queued_writes_fail_closed` | Operation creation fails before mutation. |
| Double submission | `test_client_operation_id_is_idempotent_and_globally_discoverable` | Same intent and operation UUID returns `no_op`; changed intent fails closed. |
| Conflicting target rename | `test_stale_head_wrong_id_target_conflict_and_queued_writes_fail_closed` | Claimed Forge/State Hub target blocks preflight. |
| Forge redirect | `test_redirected_forge_and_wrong_fresh_clone_identity_fail_closed_then_resume` | A redirected lookup cannot masquerade as the requested coordinate. |
| Old slug unavailable during rollback | `test_rollback_recovers_when_old_statehub_slug_temporarily_unavailable` | Failure is journaled; restoring the protected route permits a safe retry after the external Forge rollback already committed. |
| State Hub outage | `test_state_hub_outage_fails_without_losing_operation_identity_or_leaking_secrets` | CLI reports `state_hub_unavailable`; the client-owned operation ID remains the recovery key. |
| Edge outbox replay | `test_message_history_is_immutable_and_old_slug_write_replays_once`; edge outbox/relay suites | Old-slug write canonicalizes and persists exactly once under its idempotency key. |
| Unreadable private repository | `test_expired_token_bad_confirmation_and_unreadable_forge_fail_closed` | Preflight is unsafe and no token is issued. |
| Fresh clone has wrong immutable Forge ID | `test_redirected_forge_and_wrong_fresh_clone_identity_fail_closed_then_resume` | `source-synced` fails, journals the failure, and succeeds only with matching repository ID and head. |
## Continuity proof
`collect_continuity_baseline` records both identity lists and immutable
relationship tuples. Each record family has a `relationship_checksum`; token
events additionally record input, output, cached-input, reasoning-output, and
raw totals. Verification recomputes the relationships for every baseline ID,
permits append-only records, and fails when an original ID is missing or any
repository/workplan/task/snapshot binding changes.
The matrix covers repository identity, workplans, tasks, progress, decisions,
token events and totals, SBOM snapshots and entries, active dispatch, protected
slug routes, historical messages, and workplan file bindings. Alias verification
is phase-aware because the intended mutation changes the old route from
`canonical` to `alias`; all work-record and telemetry relationships remain
unchanged.
`test_equal_counts_do_not_hide_detached_work_and_telemetry` deliberately moves
an original workplan and token event to another repository, inserts replacements
with identical counts and token totals, and proves verification still fails.
## Operational evidence
The durable operation journal exposes:
- phase attempt counts and durations in milliseconds;
- total retries and failures plus failures by phase;
- rollback attempts and outcome;
- verification outcome;
- the last failure code, without operator evidence or credentials.
Credential-free NATS events publish phase, failure, verification, rollback, and
completion outcomes. Event payloads contain immutable IDs, coordinate names,
the expected source commit, metrics, and an operation evidence reference. They
never include preflight tokens, supplied operator evidence, authorization data,
remote URLs, or error details.
## Reproduction
```bash
.venv/bin/pytest -q \
tests/test_repository_rename_api.py \
tests/test_repository_rename_cli.py \
tests/test_repository_alias_routing.py \
tests/test_edge_outbox.py \
tests/test_edge_relay.py
```
Run the full repository suite before accepting this work item. Record the final
test count and revision in the workplan result rather than editing this matrix
with transient local values.

View file

@ -42,6 +42,11 @@ those publishers from colliding on the same `{noun}.{verb}` shape.
| Subject | When | Required attributes |
| ------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `org.statehub.repo.registered` | A new repo is registered via `POST /repos/` | `repo_id`, `repo_slug`, `domain_slug`, `remote_url?`, `local_path?` |
| `org.statehub.repo.rename.phase` | A rename operation starts, advances, or is safely retried | `operation_id`, `repo_id`, `forge_repository_id`, `old_slug`, `new_slug`, `phase`, `actor`, `expected_source_commit`, `outcome`, `no_op`, phase metrics, `evidence_ref` |
| `org.statehub.repo.rename.failed` | A rename or rollback phase fails closed | Rename phase attributes plus `error_code`; never error details or supplied evidence |
| `org.statehub.repo.rename.verified` | An operator/API verification evaluates continuity | Rename phase attributes plus `verification_outcome` |
| `org.statehub.repo.rename.rolled_back` | A rename operation reaches `rolled-back` | Rename phase attributes plus rollback attempts and outcome |
| `org.statehub.repo.renamed` | A rename operation reaches `completed` | Rename phase attributes; repository and Forge immutable IDs remain unchanged |
| `org.statehub.workplan.completed` | A workplan transitions to canonical status `finished` | `workplan_id`, `slug`, `title`, `topic_id`, `repo_id?`, `repo_goal_id?` |
| ~~`org.statehub.workstream.completed`~~ | **Retired 2026-07-08** (`STATE-WP-0069` T05). Use `org.statehub.workplan.completed`. | — |
| `org.statehub.decision.resolved` | A decision is resolved via `POST /decisions/{id}/resolve` | `decision_id`, `title`, `topic_id?`, `workstream_id?`, `decided_by`, `rationale_snippet` |

View file

@ -88,7 +88,7 @@ def _redact_text(value: str) -> str:
flags=re.IGNORECASE,
)
value = re.sub(
r"([?&][^=&#\s]*(?:api[_-]?key|password|secret|signature|token|credential)"
r"((?:^|[\s?&])[^=&#\s]*(?:api[_-]?key|authorization|password|secret|signature|token|credential)"
r"[^=&#\s]*=)[^&#\s]+",
r"\1[REDACTED]",
value,

View file

@ -1,16 +1,24 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
import pytest
import pytest_asyncio
from sqlalchemy import func, select
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from api.config import settings
from api.main import app
from api.models.managed_repo import ManagedRepo
from api.models.repository_rename import RepositoryRenameOperation, RepositorySlug
from api.models.agent_message import AgentMessage
from api.models.decision import Decision
from api.models.sbom_entry import Ecosystem, SBOMEntry
from api.models.sbom_snapshot import SBOMSnapshot
from api.models.token_event import TokenEvent
from api.models.workplan import Workplan
from api.routers.repository_renames import repository_rename_events
from api.services.forge_repository import (
ForgeRepositorySnapshot,
get_forge_repository_gateway,
@ -251,7 +259,17 @@ async def test_interrupt_resume_every_phase_and_preserve_uuid(client, rename_set
sequence = [
("statehub-rebound", "forge-renamed", {}),
("source-synced", "statehub-rebound", {"evidence": {"clone": "fresh"}}),
(
"source-synced",
"statehub-rebound",
{
"evidence": {
"fresh_clone": True,
"forge_repository_id": 417,
"head_commit": "a" * 40,
}
},
),
("consumers-verified", "source-synced", {"checks": {"routes": True, "security-stack": True}}),
("completed", "consumers-verified", {}),
]
@ -383,7 +401,17 @@ async def test_rollback_restores_old_canonical_and_remains_auditable(client, ren
for requested, expected, extra in [
("forge-renamed", "preflighted", {}),
("statehub-rebound", "forge-renamed", {}),
("source-synced", "statehub-rebound", {"evidence": {"clone": "fresh"}}),
(
"source-synced",
"statehub-rebound",
{
"evidence": {
"fresh_clone": True,
"forge_repository_id": 417,
"head_commit": "a" * 40,
}
},
),
]:
await _phase(client, repo["id"], operation_id, requested, expected, confirmation, **extra)
@ -441,3 +469,463 @@ async def test_rollback_recovers_unrecorded_forge_rename(client, rename_setup):
assert rollback.status_code == 200, rollback.text
assert rollback.json()["phase"] == "rolled-back"
assert forge.name == "flex-auth"
@pytest.mark.asyncio
async def test_continuity_evidence_covers_relationships_totals_routes_and_dispatch(
client, test_engine, rename_setup
):
repo, _forge = rename_setup
workplan = await create_test_workplan(
client, repo_id=repo["id"], slug="FLEX-WP-0042", status="active"
)
task = await client.post(
"/tasks/",
json={
"workplan_id": workplan["id"],
"title": "Preserve security-stack lineage",
"status": "todo",
"priority": "high",
},
)
assert task.status_code == 201, task.text
now = datetime.now(timezone.utc)
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
bound = await session.get(Workplan, uuid.UUID(workplan["id"]))
bound.backing_filename = "FLEX-WP-0042-lineage.md"
bound.backing_relative_path = "workplans/FLEX-WP-0042-lineage.md"
decision = Decision(
workplan_id=bound.id,
title="Retain immutable repository identity",
)
token = TokenEvent(
repo_id=uuid.UUID(repo["id"]),
workplan_id=bound.id,
task_id=uuid.UUID(task.json()["id"]),
tokens_in=120,
tokens_out=30,
cached_input_tokens=10,
reasoning_output_tokens=5,
raw_total_tokens=165,
)
snapshot = SBOMSnapshot(
repo_id=uuid.UUID(repo["id"]),
snapshot_at=now,
source="pytest",
entry_count=1,
created_at=now,
)
message = AgentMessage(
from_agent="security-review",
to_agent="flex-auth",
subject="Historical route",
body="Keep the recorded coordinate",
)
session.add_all([decision, token, snapshot, message])
await session.flush()
session.add(
SBOMEntry(
repo_id=uuid.UUID(repo["id"]),
snapshot_id=snapshot.id,
package_name="policy-engine",
package_version="1.0",
ecosystem=Ecosystem.python,
snapshot_at=now,
created_at=now,
)
)
await session.commit()
preflight = await _preflight(client, repo["id"])
baseline = preflight["baselines"]
assert set(baseline) >= {
"repository",
"workplans",
"tasks",
"progress_events",
"decisions",
"token_events",
"sbom_snapshots",
"active_dispatch",
"aliases",
"messages",
"workplan_bindings",
"continuity_checksum",
}
assert baseline["token_events"]["totals"] == {
"tokens_in": 120,
"tokens_out": 30,
"cached_input_tokens": 10,
"reasoning_output_tokens": 5,
"raw_total_tokens": 165,
}
assert baseline["workplan_bindings"]["relationships"][0]["repo_id"] == repo["id"]
operation, confirmation = await _operation(client, repo["id"], preflight)
for requested, expected, extra in [
("forge-renamed", "preflighted", {}),
("statehub-rebound", "forge-renamed", {}),
(
"source-synced",
"statehub-rebound",
{
"evidence": {
"fresh_clone": True,
"forge_repository_id": 417,
"head_commit": "a" * 40,
}
},
),
("consumers-verified", "source-synced", {"checks": {"dispatch": True}}),
("completed", "consumers-verified", {}),
]:
await _phase(
client,
repo["id"],
operation["id"],
requested,
expected,
confirmation,
**extra,
)
verification = await client.get(
f"/repos/{repo['id']}/rename/operations/{operation['id']}/verify"
)
assert verification.status_code == 200, verification.text
evidence = verification.json()
assert evidence["ok"] is True
for name, expected_checksum in evidence["relationship_checksums"]["baseline"].items():
assert evidence["relationship_checksums"]["preserved"][name] == expected_checksum
assert (await client.get("/repos/flex-auth")).json()["slug_status"] == "alias"
dispatch = await client.get("/repos/flex-auth/dispatch")
assert dispatch.status_code == 200
assert dispatch.json()["canonical_slug"] == "access-engine"
assert dispatch.json()["active_workplans"][0]["id"] == workplan["id"]
@pytest.mark.asyncio
async def test_equal_counts_do_not_hide_detached_work_and_telemetry(
client, test_engine, rename_setup
):
repo, _forge = rename_setup
workplan = await create_test_workplan(
client, repo_id=repo["id"], slug="FLEX-WP-0050", status="active"
)
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
original_token = TokenEvent(
repo_id=uuid.UUID(repo["id"]), tokens_in=11, tokens_out=7
)
session.add(original_token)
await session.commit()
preflight = await _preflight(client, repo["id"])
operation, _confirmation = await _operation(client, repo["id"], preflight)
other_domain = await create_test_domain(client, slug="other-domain")
other_repo = await create_test_repo(
client, domain_slug=other_domain["slug"], slug="other-repo"
)
async with factory() as session:
detached = await session.get(Workplan, uuid.UUID(workplan["id"]))
detached.repo_id = uuid.UUID(other_repo["id"])
token = await session.get(TokenEvent, original_token.id)
token.repo_id = uuid.UUID(other_repo["id"])
session.add(
Workplan(
repo_id=uuid.UUID(repo["id"]),
slug="FLEX-WP-0050-RECREATED",
title="Count-preserving replacement",
status="active",
)
)
session.add(
TokenEvent(repo_id=uuid.UUID(repo["id"]), tokens_in=11, tokens_out=7)
)
await session.commit()
verification = await client.get(
f"/repos/{repo['id']}/rename/operations/{operation['id']}/verify"
)
assert verification.status_code == 200, verification.text
result = verification.json()
assert result["ok"] is False
relationship = next(
check for check in result["checks"] if check["name"] == "relationship_continuity"
)
assert relationship["ok"] is False
assert set(relationship["actual"]["missing"]) >= {"workplans", "token_events"}
counts = next(
check for check in result["checks"] if check["name"] == "record_counts_non_decreasing"
)
assert counts["actual"]["workplans"] == counts["expected"]["workplans"]
assert counts["actual"]["token_events"] == counts["expected"]["token_events"]
@pytest.mark.asyncio
async def test_redirected_forge_and_wrong_fresh_clone_identity_fail_closed_then_resume(
client, rename_setup
):
repo, forge = rename_setup
original_inspect = forge.inspect
async def redirected(*, instance: str, owner: str, name: str):
if name == "flex-auth":
return forge.snapshot("access-engine")
if name == "access-engine":
return None
return await original_inspect(instance=instance, owner=owner, name=name)
forge.inspect = redirected
redirected_report = await _preflight(client, repo["id"])
assert redirected_report["safe_to_apply"] is False
assert {item["code"] for item in redirected_report["blockers"]} >= {"forge_redirected"}
forge.inspect = original_inspect
preflight = await _preflight(client, repo["id"])
operation, confirmation = await _operation(client, repo["id"], preflight)
await _phase(client, repo["id"], operation["id"], "forge-renamed", "preflighted", confirmation)
await _phase(client, repo["id"], operation["id"], "statehub-rebound", "forge-renamed", confirmation)
wrong = await client.post(
f"/repos/{repo['id']}/rename/operations/{operation['id']}/phases/source-synced",
json={
"expected_phase": "statehub-rebound",
"confirmation": confirmation,
"evidence": {
"fresh_clone": True,
"forge_repository_id": 999,
"head_commit": "a" * 40,
"password": "must-never-reach-an-event",
},
},
)
assert wrong.status_code == 412
status = (await client.get(f"/repository-renames/operations/{operation['id']}")).json()
assert status["phase"] == "statehub-rebound"
assert status["evidence"]["telemetry"]["failures"] == 1
resumed = await _phase(
client,
repo["id"],
operation["id"],
"source-synced",
"statehub-rebound",
confirmation,
evidence={
"fresh_clone": True,
"forge_repository_id": 417,
"head_commit": "a" * 40,
},
)
replay = await _phase(
client,
repo["id"],
operation["id"],
"source-synced",
"statehub-rebound",
confirmation,
evidence={
"fresh_clone": True,
"forge_repository_id": 417,
"head_commit": "a" * 40,
},
)
assert resumed["phase"] == "source-synced"
assert replay["no_op"] is True
assert replay["evidence"]["telemetry"]["retries"] == 1
@pytest.mark.asyncio
async def test_rollback_recovers_when_old_statehub_slug_temporarily_unavailable(
client, test_engine, rename_setup
):
repo, forge = rename_setup
preflight = await _preflight(client, repo["id"])
operation, confirmation = await _operation(client, repo["id"], preflight)
for requested, expected, extra in [
("forge-renamed", "preflighted", {}),
("statehub-rebound", "forge-renamed", {}),
(
"source-synced",
"statehub-rebound",
{
"evidence": {
"fresh_clone": True,
"forge_repository_id": 417,
"head_commit": "a" * 40,
}
},
),
]:
await _phase(client, repo["id"], operation["id"], requested, expected, confirmation, **extra)
rollback_confirmation = f"rollback:{operation['id']}"
report = await client.post(
f"/repos/{repo['id']}/rename/operations/{operation['id']}/rollback-preflight",
json={"expected_phase": "source-synced", "confirmation": rollback_confirmation},
)
assert report.status_code == 200, report.text
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
await session.execute(delete(RepositorySlug).where(RepositorySlug.slug == "flex-auth"))
await session.commit()
failed = await client.post(
f"/repos/{repo['id']}/rename/operations/{operation['id']}/rollback",
json={"expected_phase": "rollback-preflight", "confirmation": rollback_confirmation},
)
assert failed.status_code == 412
assert forge.name == "flex-auth"
async with factory() as session:
session.add(
RepositorySlug(
repo_id=uuid.UUID(repo["id"]),
slug="flex-auth",
kind="alias",
protected=True,
source_operation_id=uuid.UUID(operation["id"]),
)
)
await session.commit()
resumed = await client.post(
f"/repos/{repo['id']}/rename/operations/{operation['id']}/rollback",
json={"expected_phase": "rollback-preflight", "confirmation": rollback_confirmation},
)
assert resumed.status_code == 200, resumed.text
assert resumed.json()["phase"] == "rolled-back"
assert resumed.json()["evidence"]["telemetry"]["failures"] == 1
assert resumed.json()["evidence"]["telemetry"]["rollback_outcome"] == "completed"
@pytest.mark.asyncio
async def test_rename_events_expose_metrics_without_operator_evidence_or_secrets(
client, test_engine, rename_setup
):
repo, _forge = rename_setup
preflight = await _preflight(client, repo["id"])
operation, confirmation = await _operation(client, repo["id"], preflight)
await _phase(client, repo["id"], operation["id"], "forge-renamed", "preflighted", confirmation)
replay = await _phase(client, repo["id"], operation["id"], "forge-renamed", "preflighted", confirmation)
assert replay["evidence"]["telemetry"]["retries"] == 1
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
persisted = await session.get(RepositoryRenameOperation, uuid.UUID(operation["id"]))
events = repository_rename_events(persisted, outcome="succeeded", no_op=True)
assert [subject for subject, _event in events] == ["org.statehub.repo.rename.phase"]
attributes = events[0][1].attributes
assert attributes["repo_id"] == repo["id"]
assert attributes["phase"] == "forge-renamed"
assert attributes["phase_duration_ms"] >= 0
assert attributes["retries"] == 1
assert set(attributes) >= {
"failures",
"rollback_attempts",
"verification_outcome",
"evidence_ref",
}
serialized = events[0][1].model_dump_json().lower()
assert "preflight_token" not in serialized
assert "operator_evidence" not in serialized
assert "password" not in serialized
persisted.phase = "completed"
completion = repository_rename_events(persisted, outcome="succeeded")
assert [subject for subject, _event in completion] == [
"org.statehub.repo.rename.phase",
"org.statehub.repo.renamed",
]
persisted.phase = "forge-renamed"
persisted.error_code = "repository_rename_precondition_failed"
persisted.error_details = {"authorization": "Bearer must-not-publish"}
failure = repository_rename_events(persisted, outcome="failed")
failure_json = failure[0][1].model_dump_json()
assert failure[0][0] == "org.statehub.repo.rename.failed"
assert "must-not-publish" not in failure_json
@pytest.mark.asyncio
async def test_every_forward_phase_failure_is_retry_safe(client, rename_setup):
repo, forge = rename_setup
preflight = await _preflight(client, repo["id"])
operation, confirmation = await _operation(client, repo["id"], preflight)
operation_id = operation["id"]
async def fail_unreadable(requested: str, expected: str) -> None:
forge.unreadable = True
response = await client.post(
f"/repos/{repo['id']}/rename/operations/{operation_id}/phases/{requested}",
json={"expected_phase": expected, "confirmation": confirmation},
)
forge.unreadable = False
assert response.status_code == 412
status = await client.get(f"/repository-renames/operations/{operation_id}")
assert status.json()["phase"] == expected
await fail_unreadable("forge-renamed", "preflighted")
await _phase(client, repo["id"], operation_id, "forge-renamed", "preflighted", confirmation)
await fail_unreadable("statehub-rebound", "forge-renamed")
await _phase(client, repo["id"], operation_id, "statehub-rebound", "forge-renamed", confirmation)
wrong_clone = await client.post(
f"/repos/{repo['id']}/rename/operations/{operation_id}/phases/source-synced",
json={
"expected_phase": "statehub-rebound",
"confirmation": confirmation,
"evidence": {
"fresh_clone": True,
"forge_repository_id": 999,
"head_commit": "a" * 40,
},
},
)
assert wrong_clone.status_code == 412
await _phase(
client,
repo["id"],
operation_id,
"source-synced",
"statehub-rebound",
confirmation,
evidence={
"fresh_clone": True,
"forge_repository_id": 417,
"head_commit": "a" * 40,
},
)
bad_consumers = await client.post(
f"/repos/{repo['id']}/rename/operations/{operation_id}/phases/consumers-verified",
json={
"expected_phase": "source-synced",
"confirmation": confirmation,
"checks": {"security-stack": False},
},
)
assert bad_consumers.status_code == 412
await _phase(
client,
repo["id"],
operation_id,
"consumers-verified",
"source-synced",
confirmation,
checks={"security-stack": True},
)
await fail_unreadable("completed", "consumers-verified")
completed = await _phase(
client,
repo["id"],
operation_id,
"completed",
"consumers-verified",
confirmation,
)
assert completed["evidence"]["telemetry"]["failures"] == 5
assert completed["evidence"]["telemetry"]["verification_outcome"] == "passed"
assert set(completed["evidence"]["telemetry"]["failures_by_phase"]) == {
"forge-renamed",
"statehub-rebound",
"source-synced",
"consumers-verified",
"completed",
}

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import json
import stat
import sys
import urllib.error
import uuid
import pytest
@ -240,6 +241,27 @@ def test_rollback_requires_separate_execute_flag(monkeypatch, capsys):
assert [method for method, _path, _body in calls] == ["GET", "POST"]
def test_state_hub_outage_fails_without_losing_operation_identity_or_leaking_secrets(
monkeypatch,
):
def unavailable(*_args, **_kwargs):
raise urllib.error.URLError(
"connection refused token=super-secret authorization=Bearer-secret"
)
monkeypatch.setattr(rename_cli.urllib.request, "urlopen", unavailable)
with pytest.raises(rename_cli.RenameCLIError) as raised:
rename_cli._api_request(
"http://127.0.0.1:8000",
"GET",
f"/repository-renames/operations/{OPERATION_ID}",
)
assert raised.value.code == "state_hub_unavailable"
assert str(raised.value) == "State Hub is unavailable"
assert "super-secret" not in json.dumps(raised.value.details)
assert OPERATION_ID not in raised.value.details.get("reason", "")
def test_rollback_execute_reaches_terminal_state(monkeypatch, capsys):
calls = []

View file

@ -476,7 +476,7 @@ dashboard retains the pre-existing `/docs/intakes` broken-link warning.
```task
id: STATE-WP-0085-T07
status: todo
status: done
priority: high
state_hub_task_id: "5160ad75-d985-5559-895c-7f8012ea76bc"
```
@ -503,6 +503,27 @@ Acceptance:
- rename events and operational metrics expose phase duration, retries,
failures, rollback, and verification outcome without secrets.
Result (2026-08-29): continuity baselines now retain immutable relationship
tuples and checksums for repository identity, workplans, tasks, progress,
decisions, token events and totals, SBOM, active dispatch, protected routes,
historical messages, and file bindings. Verification compares every original
relationship while permitting append-only history, and phase-aware route checks
prove the old slug remains an alias. Equal-count workplan/token replacements
attached to another repository are rejected. Fresh-clone evidence must attest
the expected Forge numeric ID and baseline head; Forge redirects are rejected
as the wrong coordinate. The operation journal records phase durations,
attempts, retries, failures by phase, rollback attempts/outcome, and verification
outcome. Credential-free phase/failure/verification/rollback/completion events
reference the durable journal without publishing tokens, operator evidence,
URLs, or error details. Automated coverage injects failure after every forward
phase and covers stale evidence, duplicate/conflicting requests, redirects,
rollback route loss and recovery, State Hub outage, edge replay, unreadable
Forge state, and wrong-clone identity. Evidence matrix:
`docs/evidence/STATE-WP-0085-T07-repository-rename-recovery-matrix.md`.
Verification: 810 Python tests and the 70-page dashboard build pass. One known
dashboard `/docs/intakes` to `/suggestions` broken-link warning and one existing
SQLAlchemy async cancellation warning remain outside this task.
## Document operations and repository-boundary handoffs
```task