feat: add repository rename lifecycle API
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 25s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
This commit is contained in:
tegwick 2026-08-29 03:17:37 +02:00
parent 9e77a4a9a2
commit 82ea38b180
11 changed files with 2464 additions and 3 deletions

View file

@ -323,7 +323,7 @@
| task | STATE-WP-0084-T04 | wait | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
| task | STATE-WP-0085-T01 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T02 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T03 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T03 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T04 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T05 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T06 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |

View file

@ -34,6 +34,14 @@ class Settings(BaseSettings):
sbom_nexus_read_mode: Literal["legacy", "nexus"] = "legacy"
sbom_nexus_write_mode: Literal["legacy", "nexus"] = "legacy"
sbom_nexus_timeout_seconds: float = 5.0
# Repository renames are central-only, compare-and-set operations. The
# secret signs short-lived, non-persistent preflight evidence; it must be
# supplied by the deployment (normally through OpenBao), never recorded in
# State Hub. With no secret the read-only report remains available but no
# mutation token can be issued.
repository_rename_preflight_secret: str | None = None
repository_rename_preflight_ttl_seconds: int = 900
repository_rename_forge_timeout_seconds: float = 10.0
settings = Settings()

View file

@ -25,6 +25,7 @@ from api.routers import fabric
from api.routers import legacy_meter
from api.routers import review_contracts
from api.routers import identifier_migrations
from api.routers import repository_renames
class ETagMiddleware(BaseHTTPMiddleware):
@ -109,6 +110,7 @@ app.include_router(recently_on_scope.hourly_router)
app.include_router(recently_on_scope.router)
app.include_router(consistency_sweep.router)
app.include_router(repos.router)
app.include_router(repository_renames.router)
app.include_router(topics.router)
app.include_router(workstreams.router)
app.include_router(workstreams.workplan_router)

View file

@ -0,0 +1,251 @@
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.repository_rename import RepositoryRenameOperation
from api.schemas.repository_rename import (
ForgeIdentityVerifyRequest,
RepositoryRenameOperationCreate,
RepositoryRenameOperationRead,
RepositoryRenamePhaseApply,
RepositoryRenamePreflightRead,
RepositoryRenamePreflightRequest,
RepositoryRenameRollbackPreflightRead,
RepositoryRenameRollbackRequest,
RepositoryRenameVerificationRead,
)
from api.services.forge_repository import (
ForgeRepositoryGateway,
get_forge_repository_gateway,
)
from api.services.repository_rename import (
RenameLifecycleError,
apply_phase,
apply_rollback,
build_preflight,
create_operation,
list_operations,
load_operation,
rollback_preflight,
verify_forge_identity,
verify_operation,
)
router = APIRouter(prefix="/repos/{repo_id}", tags=["repository-renames"])
def _raise(exc: RenameLifecycleError) -> None:
raise HTTPException(
status_code=exc.status_code,
detail={"code": exc.code, "message": str(exc), "details": exc.details},
) from exc
def _operation_read(operation: RepositoryRenameOperation, *, no_op: bool = False) -> dict[str, Any]:
return {
"id": operation.id,
"repo_id": operation.repo_id,
"phase": operation.phase,
"old_slug": operation.old_slug,
"new_slug": operation.new_slug,
"expected_forge_repository_id": operation.expected_forge_repository_id,
"expected_source_commit": operation.expected_source_commit,
"expected_default_branch": operation.expected_default_branch,
"actor": operation.actor,
"phase_changed_at": operation.phase_changed_at,
"preflighted_at": operation.preflighted_at,
"preflight_expires_at": operation.preflight_expires_at,
"completed_at": operation.completed_at,
"rolled_back_at": operation.rolled_back_at,
"evidence": operation.evidence,
"error_code": operation.error_code,
"error_message": operation.error_message,
"error_details": operation.error_details,
"error_at": operation.error_at,
"no_op": no_op,
}
@router.post("/forge-identity/verify")
async def verify_repository_forge_identity(
repo_id: uuid.UUID,
body: ForgeIdentityVerifyRequest,
session: AsyncSession = Depends(get_session),
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]:
try:
identity = await verify_forge_identity(session, gateway, repo_id, body)
except RenameLifecycleError as exc:
_raise(exc)
return {
"id": identity.id,
"repo_id": identity.repo_id,
"provider": identity.provider,
"forge_instance": identity.forge_instance,
"forge_owner": identity.forge_owner,
"forge_repository_id": identity.forge_repository_id,
"verification_state": identity.verification_state,
"verified_at": identity.verified_at,
"verified_by": identity.verified_by,
"verification_evidence": identity.verification_evidence,
}
@router.post("/rename/preflight", response_model=RepositoryRenamePreflightRead)
async def repository_rename_preflight(
repo_id: uuid.UUID,
body: RepositoryRenamePreflightRequest,
session: AsyncSession = Depends(get_session),
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]:
try:
return await build_preflight(session, gateway, repo_id, body)
except RenameLifecycleError as exc:
_raise(exc)
@router.post(
"/rename/operations",
response_model=RepositoryRenameOperationRead,
status_code=status.HTTP_201_CREATED,
)
async def create_repository_rename_operation(
repo_id: uuid.UUID,
body: RepositoryRenameOperationCreate,
session: AsyncSession = Depends(get_session),
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]:
try:
operation = await create_operation(session, gateway, repo_id, body)
except RenameLifecycleError as exc:
_raise(exc)
return _operation_read(operation)
@router.get(
"/rename/operations", response_model=list[RepositoryRenameOperationRead]
)
async def list_repository_rename_operations(
repo_id: uuid.UUID,
active_only: bool = False,
session: AsyncSession = Depends(get_session),
) -> list[dict[str, Any]]:
try:
operations = await list_operations(session, repo_id, active_only=active_only)
except RenameLifecycleError as exc:
_raise(exc)
return [_operation_read(operation) for operation in operations]
@router.get(
"/rename/operations/{operation_id}", response_model=RepositoryRenameOperationRead
)
async def get_repository_rename_operation(
repo_id: uuid.UUID,
operation_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
try:
operation = await load_operation(session, repo_id, operation_id)
except RenameLifecycleError as exc:
_raise(exc)
return _operation_read(operation)
@router.post(
"/rename/operations/{operation_id}/phases/{phase}",
response_model=RepositoryRenameOperationRead,
)
async def apply_repository_rename_phase(
repo_id: uuid.UUID,
operation_id: uuid.UUID,
phase: str,
body: RepositoryRenamePhaseApply,
session: AsyncSession = Depends(get_session),
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]:
try:
operation, no_op = await apply_phase(
session, gateway, repo_id, operation_id, phase, body
)
except RenameLifecycleError as exc:
_raise(exc)
return _operation_read(operation, no_op=no_op)
@router.get(
"/rename/operations/{operation_id}/verify",
response_model=RepositoryRenameVerificationRead,
)
async def verify_repository_rename_operation(
repo_id: uuid.UUID,
operation_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]:
try:
operation = await load_operation(session, repo_id, operation_id)
return await verify_operation(session, gateway, operation)
except RenameLifecycleError as exc:
_raise(exc)
@router.post(
"/rename/operations/{operation_id}/rollback-preflight",
response_model=RepositoryRenameRollbackPreflightRead,
)
async def preflight_repository_rename_rollback(
repo_id: uuid.UUID,
operation_id: uuid.UUID,
body: RepositoryRenameRollbackRequest,
session: AsyncSession = Depends(get_session),
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]:
try:
operation, report = await rollback_preflight(
session,
gateway,
repo_id,
operation_id,
expected_phase=body.expected_phase,
confirmation=body.confirmation,
)
except RenameLifecycleError as exc:
_raise(exc)
return {
"operation_id": operation.id,
"repo_id": operation.repo_id,
**report,
"operation": _operation_read(operation),
}
@router.post(
"/rename/operations/{operation_id}/rollback",
response_model=RepositoryRenameOperationRead,
)
async def rollback_repository_rename_operation(
repo_id: uuid.UUID,
operation_id: uuid.UUID,
body: RepositoryRenameRollbackRequest,
session: AsyncSession = Depends(get_session),
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]:
try:
operation, no_op = await apply_rollback(
session,
gateway,
repo_id,
operation_id,
expected_phase=body.expected_phase,
confirmation=body.confirmation,
)
except RenameLifecycleError as exc:
_raise(exc)
return _operation_read(operation, no_op=no_op)

View file

@ -0,0 +1,112 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
class EdgeWriteEvidence(BaseModel):
id: str
status: str = "queued"
source_host: str | None = None
path: str | None = None
class RepositoryRenamePreflightRequest(BaseModel):
new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$")
queued_edge_writes: list[EdgeWriteEvidence] = Field(default_factory=list)
class RepositoryRenamePreflightRead(BaseModel):
schema_version: Literal["state-hub.repository-rename-preflight.v1"]
repo_id: uuid.UUID
old_slug: str
new_slug: str
safe_to_apply: bool
blockers: list[dict[str, Any]]
warnings: list[dict[str, Any]]
current: dict[str, Any]
target: dict[str, Any]
baselines: dict[str, Any]
active_work: dict[str, Any]
affected: dict[str, Any]
queued_edge_writes: list[dict[str, Any]]
proposed_mutations: list[dict[str, Any]]
retained_history: list[dict[str, Any]]
report_checksum: str
preflight_token: str | None
preflighted_at: datetime
expires_at: datetime | None
class ForgeIdentityVerifyRequest(BaseModel):
provider: Literal["forgejo"] = "forgejo"
forge_instance: str
forge_owner: str
forge_repository_id: int = Field(gt=0)
verified_by: str = Field(min_length=1, max_length=160)
class RepositoryRenameOperationCreate(BaseModel):
new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$")
preflight_token: str
confirmation: str
actor: str = Field(min_length=1, max_length=160)
queued_edge_writes: list[EdgeWriteEvidence] = Field(default_factory=list)
class RepositoryRenamePhaseApply(BaseModel):
expected_phase: str
confirmation: str
checks: dict[str, bool] = Field(default_factory=dict)
evidence: dict[str, Any] = Field(default_factory=dict)
class RepositoryRenameRollbackRequest(BaseModel):
expected_phase: str
confirmation: str
class RepositoryRenameOperationRead(BaseModel):
id: uuid.UUID
repo_id: uuid.UUID
phase: str
old_slug: str
new_slug: str
expected_forge_repository_id: int
expected_source_commit: str
expected_default_branch: str
actor: str
phase_changed_at: datetime
preflighted_at: datetime | None
preflight_expires_at: datetime | None
completed_at: datetime | None
rolled_back_at: datetime | None
evidence: dict[str, Any]
error_code: str | None
error_message: str | None
error_details: dict[str, Any] | None
error_at: datetime | None
no_op: bool = False
class RepositoryRenameVerificationRead(BaseModel):
operation_id: uuid.UUID
repo_id: uuid.UUID
phase: str
ok: bool
checks: list[dict[str, Any]]
baseline_checksum: str
current_checksum: str
class RepositoryRenameRollbackPreflightRead(BaseModel):
operation_id: uuid.UUID
repo_id: uuid.UUID
rollback_from_phase: str
safe_to_rollback: bool
blockers: list[dict[str, Any]]
irreversible: list[dict[str, Any]]
operation: RepositoryRenameOperationRead

View file

@ -0,0 +1,203 @@
"""Narrow Forgejo repository boundary used by repository rename operations.
The lifecycle service depends on this protocol instead of calling Forgejo
directly. Tests replace it with an in-memory gateway, which makes interruption
and resume tests deterministic and guarantees that API tests cannot rename a
live repository.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
from urllib.parse import quote
import httpx
from api.config import settings
from api.services.forge_credential import forge_read_token
WRITE_TOKEN_ENV = "FORGE_RENAME_TOKEN"
WRITE_TOKEN_FILE_ENV = "FORGE_RENAME_TOKEN_FILE"
class ForgeRepositoryError(RuntimeError):
pass
class ForgeRepositoryUnreadable(ForgeRepositoryError):
pass
class ForgeRepositoryConflict(ForgeRepositoryError):
pass
@dataclass(frozen=True)
class ForgeRepositorySnapshot:
repository_id: int
owner: str
name: str
full_name: str
default_branch: str
head_commit: str
clone_url: str | None
html_url: str | None
projection_readable: bool
projection_source_present: bool
projection_entry_count: int | None
def as_dict(self) -> dict:
return {
"repository_id": self.repository_id,
"owner": self.owner,
"name": self.name,
"full_name": self.full_name,
"default_branch": self.default_branch,
"head_commit": self.head_commit,
"clone_url": self.clone_url,
"html_url": self.html_url,
"projection_readable": self.projection_readable,
"projection_source_present": self.projection_source_present,
"projection_entry_count": self.projection_entry_count,
}
class ForgeRepositoryGateway(Protocol):
async def inspect(
self, *, instance: str, owner: str, name: str
) -> ForgeRepositorySnapshot | None: ...
async def rename(
self, *, instance: str, owner: str, old_name: str, new_name: str
) -> ForgeRepositorySnapshot: ...
def _write_token() -> str | None:
path = os.environ.get(WRITE_TOKEN_FILE_ENV)
if path:
try:
return Path(path).read_text(encoding="utf-8").strip() or None
except OSError:
return None
return (os.environ.get(WRITE_TOKEN_ENV) or "").strip() or None
class ForgejoRepositoryGateway:
"""Forgejo v1 API adapter with separate read and rename credentials."""
@staticmethod
def _headers(token: str | None) -> dict[str, str]:
return {"Authorization": f"token {token}"} if token else {}
@staticmethod
def _api(instance: str, owner: str, name: str) -> str:
base = instance.rstrip("/")
return f"{base}/api/v1/repos/{quote(owner, safe='')}/{quote(name, safe='')}"
async def _inspect_with_token(
self, *, instance: str, owner: str, name: str, token: str | None
) -> ForgeRepositorySnapshot | None:
url = self._api(instance, owner, name)
timeout = settings.repository_rename_forge_timeout_seconds
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(url, headers=self._headers(token))
if response.status_code == 404:
if token is None:
raise ForgeRepositoryUnreadable(
"Forge returned not-found without an authenticated absence proof"
)
return None
response.raise_for_status()
repo = response.json()
branch_name = str(repo.get("default_branch") or "")
if not branch_name:
raise ForgeRepositoryUnreadable("Forge repository has no default branch")
branch = await client.get(
f"{url}/branches/{quote(branch_name, safe='')}",
headers=self._headers(token),
)
branch.raise_for_status()
branch_data = branch.json()
commit = branch_data.get("commit") or {}
head = str(commit.get("id") or commit.get("sha") or "")
if not head:
raise ForgeRepositoryUnreadable("Forge default branch has no readable head")
projection = await client.get(
f"{url}/contents/workplans",
params={"ref": head},
headers=self._headers(token),
)
source_present = projection.status_code == 200
if projection.status_code not in {200, 404}:
projection.raise_for_status()
entries = projection.json() if source_present else None
except ForgeRepositoryError:
raise
except (httpx.HTTPError, ValueError, KeyError) as exc:
raise ForgeRepositoryUnreadable(
f"Forge repository inspection failed ({type(exc).__name__})"
) from exc
return ForgeRepositorySnapshot(
repository_id=int(repo["id"]),
owner=str((repo.get("owner") or {}).get("login") or owner),
name=str(repo["name"]),
full_name=str(repo.get("full_name") or f"{owner}/{name}"),
default_branch=branch_name,
head_commit=head,
clone_url=repo.get("clone_url"),
html_url=repo.get("html_url"),
projection_readable=True,
projection_source_present=source_present,
projection_entry_count=(len(entries) if isinstance(entries, list) else None),
)
async def inspect(
self, *, instance: str, owner: str, name: str
) -> ForgeRepositorySnapshot | None:
return await self._inspect_with_token(
instance=instance, owner=owner, name=name, token=forge_read_token()
)
async def rename(
self, *, instance: str, owner: str, old_name: str, new_name: str
) -> ForgeRepositorySnapshot:
token = _write_token()
if not token:
raise ForgeRepositoryUnreadable("Forge rename credential is unavailable")
url = self._api(instance, owner, old_name)
try:
async with httpx.AsyncClient(
timeout=settings.repository_rename_forge_timeout_seconds
) as client:
response = await client.patch(
url,
json={"name": new_name},
headers=self._headers(token),
)
if response.status_code in {409, 422}:
raise ForgeRepositoryConflict("Forge rejected the target repository name")
response.raise_for_status()
except ForgeRepositoryError:
raise
except httpx.HTTPError as exc:
raise ForgeRepositoryUnreadable(
f"Forge repository rename failed ({type(exc).__name__})"
) from exc
renamed = await self._inspect_with_token(
instance=instance, owner=owner, name=new_name, token=token
)
if renamed is None:
raise ForgeRepositoryUnreadable("Renamed Forge repository is not readable")
return renamed
_gateway = ForgejoRepositoryGateway()
def get_forge_repository_gateway() -> ForgeRepositoryGateway:
return _gateway

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,89 @@
"""allow only operation-guarded alias restoration during rollback
Revision ID: a4d5e6f7b8c9
Revises: f3c4d5e6a7b8
"""
from alembic import op
import sqlalchemy as sa
revision = "a4d5e6f7b8c9"
down_revision = "f3c4d5e6a7b8"
branch_labels = None
depends_on = None
def upgrade() -> None:
# T02 made aliases immutable. Rollback needs exactly one controlled
# exception: the alias created by this operation may become canonical while
# that same operation is in rollback-preflight. Ownership, spelling,
# protection, and provenance remain immutable.
op.execute(
sa.text(
"""
CREATE OR REPLACE FUNCTION guard_repository_slug_history()
RETURNS trigger AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'repository slug rows are durable';
END IF;
IF NEW.repo_id IS DISTINCT FROM OLD.repo_id
OR NEW.slug IS DISTINCT FROM OLD.slug THEN
RAISE EXCEPTION 'repository slug ownership is immutable';
END IF;
IF OLD.kind = 'alias' AND ROW(
NEW.kind, NEW.protected, NEW.source_operation_id
) IS DISTINCT FROM ROW(
OLD.kind, OLD.protected, OLD.source_operation_id
) THEN
IF NOT (
NEW.kind = 'canonical'
AND NEW.protected
AND NEW.source_operation_id = OLD.source_operation_id
AND EXISTS (
SELECT 1
FROM repository_rename_operations operation
WHERE operation.id = OLD.source_operation_id
AND operation.repo_id = OLD.repo_id
AND operation.phase = 'rollback-preflight'
)
) THEN
RAISE EXCEPTION 'protected repository alias is immutable';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
"""
)
)
def downgrade() -> None:
op.execute(
sa.text(
"""
CREATE OR REPLACE FUNCTION guard_repository_slug_history()
RETURNS trigger AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'repository slug rows are durable';
END IF;
IF NEW.repo_id IS DISTINCT FROM OLD.repo_id
OR NEW.slug IS DISTINCT FROM OLD.slug THEN
RAISE EXCEPTION 'repository slug ownership is immutable';
END IF;
IF OLD.kind = 'alias' AND ROW(
NEW.kind, NEW.protected, NEW.source_operation_id
) IS DISTINCT FROM ROW(
OLD.kind, OLD.protected, OLD.source_operation_id
) THEN
RAISE EXCEPTION 'protected repository alias is immutable';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
"""
)
)

View file

@ -0,0 +1,394 @@
from __future__ import annotations
import uuid
import pytest
import pytest_asyncio
from sqlalchemy import 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.services.forge_repository import (
ForgeRepositorySnapshot,
get_forge_repository_gateway,
)
from tests.conftest import (
create_test_domain,
create_test_repo,
create_test_workplan,
)
class FakeForge:
def __init__(self, *, name: str = "flex-auth", repository_id: int = 417, head: str = "a" * 40):
self.name = name
self.repository_id = repository_id
self.head = head
self.rename_calls = 0
self.extra: dict[str, int] = {}
self.unreadable = False
def snapshot(self, name: str, repository_id: int | None = None) -> ForgeRepositorySnapshot:
return ForgeRepositorySnapshot(
repository_id=repository_id or self.repository_id,
owner="coulomb",
name=name,
full_name=f"coulomb/{name}",
default_branch="main",
head_commit=self.head,
clone_url=f"https://forge.example/coulomb/{name}.git",
html_url=f"https://forge.example/coulomb/{name}",
projection_readable=True,
projection_source_present=True,
projection_entry_count=1,
)
async def inspect(self, *, instance: str, owner: str, name: str):
if self.unreadable:
from api.services.forge_repository import ForgeRepositoryUnreadable
raise ForgeRepositoryUnreadable("simulated unreadable forge")
if name == self.name:
return self.snapshot(name)
if name in self.extra:
return self.snapshot(name, self.extra[name])
return None
async def rename(self, *, instance: str, owner: str, old_name: str, new_name: str):
assert old_name == self.name
assert new_name not in self.extra
self.rename_calls += 1
self.name = new_name
return self.snapshot(new_name)
@pytest_asyncio.fixture
async def rename_setup(client, monkeypatch):
monkeypatch.setattr(settings, "repository_rename_preflight_secret", "test-only-preflight-secret")
domain = await create_test_domain(client)
repo = await create_test_repo(
client,
domain_slug=domain["slug"],
slug="flex-auth",
remote_url="https://forge.example/coulomb/flex-auth.git",
local_path="/srv/flex-auth",
host_paths={"workstation": "/home/operator/flex-auth"},
)
forge = FakeForge()
app.dependency_overrides[get_forge_repository_gateway] = lambda: forge
verified = await client.post(
f"/repos/{repo['id']}/forge-identity/verify",
json={
"provider": "forgejo",
"forge_instance": "https://forge.example",
"forge_owner": "coulomb",
"forge_repository_id": forge.repository_id,
"verified_by": "pytest",
},
)
assert verified.status_code == 200, verified.text
return repo, forge
async def _preflight(client, repo_id: str, new_slug: str = "access-engine"):
response = await client.post(
f"/repos/{repo_id}/rename/preflight", json={"new_slug": new_slug}
)
assert response.status_code == 200, response.text
return response.json()
async def _operation(client, repo_id: str, preflight: dict):
confirmation = f"rename:{repo_id}:flex-auth:access-engine"
response = await client.post(
f"/repos/{repo_id}/rename/operations",
json={
"new_slug": "access-engine",
"preflight_token": preflight["preflight_token"],
"confirmation": confirmation,
"actor": "pytest",
},
)
assert response.status_code == 201, response.text
return response.json(), confirmation
async def _phase(client, repo_id: str, operation_id: str, phase: str, expected: str, confirmation: str, **extra):
response = await client.post(
f"/repos/{repo_id}/rename/operations/{operation_id}/phases/{phase}",
json={
"expected_phase": expected,
"confirmation": confirmation,
**extra,
},
)
assert response.status_code == 200, response.text
return response.json()
@pytest.mark.asyncio
async def test_dry_run_has_no_persistent_changes(client, test_engine, rename_setup):
repo, _forge = rename_setup
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
before_operations = await session.scalar(select(func.count()).select_from(RepositoryRenameOperation))
before_slugs = await session.scalar(select(func.count()).select_from(RepositorySlug))
before_repo = await session.get(ManagedRepo, uuid.UUID(repo["id"]))
before = (before_operations, before_slugs, before_repo.slug, before_repo.updated_at)
report = await _preflight(client, repo["id"])
assert report["safe_to_apply"] is True
assert report["preflight_token"]
assert report["current"]["statehub"]["repo_id"] == repo["id"]
assert report["current"]["forge"]["repository_id"] == 417
assert report["retained_history"]
async with factory() as session:
after_operations = await session.scalar(select(func.count()).select_from(RepositoryRenameOperation))
after_slugs = await session.scalar(select(func.count()).select_from(RepositorySlug))
after_repo = await session.get(ManagedRepo, uuid.UUID(repo["id"]))
after = (after_operations, after_slugs, after_repo.slug, after_repo.updated_at)
assert after == before
@pytest.mark.asyncio
async def test_interrupt_resume_every_phase_and_preserve_uuid(client, rename_setup):
repo, forge = rename_setup
workplan = await create_test_workplan(
client, repo_id=repo["id"], slug="FLEX-WP-0001", status="active"
)
task = await client.post(
"/tasks/",
json={
"workplan_id": workplan["id"],
"title": "Security migration",
"status": "todo",
"priority": "high",
},
)
assert task.status_code == 201, task.text
progress = await client.post(
"/progress/",
json={"workplan_id": workplan["id"], "task_id": task.json()["id"], "summary": "baseline", "event_type": "note"},
)
assert progress.status_code == 201, progress.text
preflight = await _preflight(client, repo["id"])
operation, confirmation = await _operation(client, repo["id"], preflight)
operation_id = operation["id"]
discover = await client.get(
f"/repos/{repo['id']}/rename/operations", params={"active_only": True}
)
assert discover.status_code == 200
assert [item["id"] for item in discover.json()] == [operation_id]
# Simulate the hardest interruption: Forge committed the rename but the
# State Hub request died before recording forge-renamed.
await forge.rename(
instance="https://forge.example",
owner="coulomb",
old_name="flex-auth",
new_name="access-engine",
)
phase = await _phase(
client, repo["id"], operation_id, "forge-renamed", "preflighted", confirmation
)
assert phase["phase"] == "forge-renamed"
assert phase["evidence"]["phases"]["forge-renamed"]["resumed"] is True
assert forge.rename_calls == 1
sequence = [
("statehub-rebound", "forge-renamed", {}),
("source-synced", "statehub-rebound", {"evidence": {"clone": "fresh"}}),
("consumers-verified", "source-synced", {"checks": {"routes": True, "security-stack": True}}),
("completed", "consumers-verified", {}),
]
for requested, expected, extra in sequence:
read = await client.get(f"/repos/{repo['id']}/rename/operations/{operation_id}")
assert read.status_code == 200
assert read.json()["phase"] == expected
phase = await _phase(
client, repo["id"], operation_id, requested, expected, confirmation, **extra
)
assert phase["phase"] == requested
if requested == "source-synced":
appended = await client.post(
"/progress/",
json={
"workplan_id": workplan["id"],
"task_id": task.json()["id"],
"summary": "rename phase completed",
"event_type": "note",
},
)
assert appended.status_code == 201, appended.text
replay = await _phase(
client, repo["id"], operation_id, requested, expected, confirmation, **extra
)
assert replay["id"] == operation_id
assert replay["no_op"] is True
verification = await client.get(
f"/repos/{repo['id']}/rename/operations/{operation_id}/verify"
)
assert verification.status_code == 200, verification.text
assert verification.json()["ok"] is True
discover = await client.get(
f"/repos/{repo['id']}/rename/operations", params={"active_only": True}
)
assert discover.json() == []
current = await client.get(f"/repos/by-id/{repo['id']}")
assert current.status_code == 200, current.text
assert current.json()["id"] == repo["id"]
assert current.json()["slug"] == "access-engine"
@pytest.mark.asyncio
async def test_stale_head_wrong_id_target_conflict_and_queued_writes_fail_closed(client, rename_setup):
repo, forge = rename_setup
queued = await client.post(
f"/repos/{repo['id']}/rename/preflight",
json={
"new_slug": "access-engine",
"queued_edge_writes": [{"id": "edge-1", "source_host": "laptop"}],
},
)
assert queued.status_code == 200
assert queued.json()["safe_to_apply"] is False
assert queued.json()["preflight_token"] is None
forge.extra["access-engine"] = 999
conflict = await _preflight(client, repo["id"])
assert conflict["safe_to_apply"] is False
assert {item["code"] for item in conflict["blockers"]} >= {"forge_target_claimed"}
del forge.extra["access-engine"]
forge.repository_id = 999
wrong_id = await _preflight(client, repo["id"])
assert wrong_id["safe_to_apply"] is False
assert {item["code"] for item in wrong_id["blockers"]} >= {"wrong_forge_repository_id"}
forge.repository_id = 417
preflight = await _preflight(client, repo["id"])
forge.head = "b" * 40
create = await client.post(
f"/repos/{repo['id']}/rename/operations",
json={
"new_slug": "access-engine",
"preflight_token": preflight["preflight_token"],
"confirmation": f"rename:{repo['id']}:flex-auth:access-engine",
"actor": "pytest",
},
)
assert create.status_code == 412, create.text
assert "stale" in create.json()["detail"]["message"].lower()
@pytest.mark.asyncio
async def test_expired_token_bad_confirmation_and_unreadable_forge_fail_closed(
client, rename_setup, monkeypatch
):
repo, forge = rename_setup
monkeypatch.setattr(settings, "repository_rename_preflight_ttl_seconds", -1)
expired = await _preflight(client, repo["id"])
response = await client.post(
f"/repos/{repo['id']}/rename/operations",
json={
"new_slug": "access-engine",
"preflight_token": expired["preflight_token"],
"confirmation": f"rename:{repo['id']}:flex-auth:access-engine",
"actor": "pytest",
},
)
assert response.status_code == 412
assert "expired" in response.json()["detail"]["message"].lower()
monkeypatch.setattr(settings, "repository_rename_preflight_ttl_seconds", 900)
preflight = await _preflight(client, repo["id"])
response = await client.post(
f"/repos/{repo['id']}/rename/operations",
json={
"new_slug": "access-engine",
"preflight_token": preflight["preflight_token"],
"confirmation": "yes",
"actor": "pytest",
},
)
assert response.status_code == 412
forge.unreadable = True
unreadable = await _preflight(client, repo["id"])
assert unreadable["safe_to_apply"] is False
assert {item["code"] for item in unreadable["blockers"]} >= {"forge_unreadable"}
@pytest.mark.asyncio
async def test_rollback_restores_old_canonical_and_remains_auditable(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"]
for requested, expected, extra in [
("forge-renamed", "preflighted", {}),
("statehub-rebound", "forge-renamed", {}),
("source-synced", "statehub-rebound", {"evidence": {"clone": "fresh"}}),
]:
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
assert report.json()["safe_to_rollback"] is True
assert report.json()["irreversible"]
rollback = await client.post(
f"/repos/{repo['id']}/rename/operations/{operation_id}/rollback",
json={"expected_phase": "rollback-preflight", "confirmation": rollback_confirmation},
)
assert rollback.status_code == 200, rollback.text
assert rollback.json()["phase"] == "rolled-back"
assert forge.name == "flex-auth"
current = await client.get(f"/repos/by-id/{repo['id']}")
assert current.json()["id"] == repo["id"]
assert current.json()["slug"] == "flex-auth"
history = rollback.json()["evidence"]["phases"]
assert set(history) >= {"preflighted", "forge-renamed", "statehub-rebound", "source-synced", "rollback-preflight", "rolled-back"}
replay = await client.post(
f"/repos/{repo['id']}/rename/operations/{operation_id}/rollback",
json={"expected_phase": "rollback-preflight", "confirmation": rollback_confirmation},
)
assert replay.status_code == 200
assert replay.json()["no_op"] is True
@pytest.mark.asyncio
async def test_rollback_recovers_unrecorded_forge_rename(client, rename_setup):
repo, forge = rename_setup
preflight = await _preflight(client, repo["id"])
operation, _confirmation = await _operation(client, repo["id"], preflight)
await forge.rename(
instance="https://forge.example",
owner="coulomb",
old_name="flex-auth",
new_name="access-engine",
)
rollback_confirmation = f"rollback:{operation['id']}"
report = await client.post(
f"/repos/{repo['id']}/rename/operations/{operation['id']}/rollback-preflight",
json={"expected_phase": "preflighted", "confirmation": rollback_confirmation},
)
assert report.status_code == 200, report.text
assert report.json()["rollback_from_phase"] == "forge-renamed-unrecorded"
rollback = await client.post(
f"/repos/{repo['id']}/rename/operations/{operation['id']}/rollback",
json={"expected_phase": "rollback-preflight", "confirmation": rollback_confirmation},
)
assert rollback.status_code == 200, rollback.text
assert rollback.json()["phase"] == "rolled-back"
assert forge.name == "flex-auth"

View file

@ -421,6 +421,9 @@ async def test_migration_upgrade_and_downgrade_are_additive(test_engine):
migration = importlib.import_module(
"migrations.versions.f3c4d5e6a7b8_repository_rename_identity"
)
rollback_migration = importlib.import_module(
"migrations.versions.a4d5e6f7b8c9_allow_guarded_slug_rollback"
)
schema = f"rename_migration_{uuid.uuid4().hex}"
repo_ids = [uuid.uuid4(), uuid.uuid4()]
sentinel_ids = {
@ -462,9 +465,12 @@ async def test_migration_upgrade_and_downgrade_are_additive(test_engine):
)
original_op = migration.op
original_rollback_op = rollback_migration.op
migration.op = Operations(MigrationContext.configure(sync_connection))
rollback_migration.op = migration.op
try:
migration.upgrade()
rollback_migration.upgrade()
tables = set(inspect(sync_connection).get_table_names(schema=schema))
assert {
"repository_forge_identities",
@ -568,6 +574,57 @@ async def test_migration_upgrade_and_downgrade_are_additive(test_engine):
),
{"id": operation_id},
)
# A forward cutover may make the old canonical an alias. T03
# permits restoring it only while its own operation is in the
# durable rollback-preflight phase.
sync_connection.execute(
text(
"UPDATE repository_slugs SET kind = 'alias', "
"source_operation_id = :operation_id "
"WHERE repo_id = :repo_id AND slug = 'flex-auth'"
),
{"operation_id": operation_id, "repo_id": repo_ids[0]},
)
sync_connection.execute(
text(
"INSERT INTO repository_slugs "
"(id, repo_id, slug, kind, protected, source_operation_id, created_at, updated_at) "
"VALUES (gen_random_uuid(), :repo_id, 'access-engine', 'canonical', true, "
":operation_id, now(), now())"
),
{"operation_id": operation_id, "repo_id": repo_ids[0]},
)
sync_connection.execute(
text(
"UPDATE repository_slugs SET kind = 'alias' "
"WHERE repo_id = :repo_id AND slug = 'access-engine'"
),
{"repo_id": repo_ids[0]},
)
with pytest.raises(DBAPIError):
with sync_connection.begin_nested():
sync_connection.execute(
text(
"UPDATE repository_slugs SET kind = 'canonical' "
"WHERE repo_id = :repo_id AND slug = 'flex-auth'"
),
{"repo_id": repo_ids[0]},
)
sync_connection.execute(
text(
"UPDATE repository_rename_operations SET "
"phase = 'rollback-preflight', phase_changed_at = now() "
"WHERE id = :id"
),
{"id": operation_id},
)
sync_connection.execute(
text(
"UPDATE repository_slugs SET kind = 'canonical' "
"WHERE repo_id = :repo_id AND slug = 'flex-auth'"
),
{"repo_id": repo_ids[0]},
)
with pytest.raises(DBAPIError):
with sync_connection.begin_nested():
sync_connection.execute(
@ -588,6 +645,7 @@ async def test_migration_upgrade_and_downgrade_are_additive(test_engine):
text(f"SELECT id, marker FROM {table_name}")
).one() == (sentinel_id, "keep")
rollback_migration.downgrade()
migration.downgrade()
tables = set(inspect(sync_connection).get_table_names(schema=schema))
assert "repository_forge_identities" not in tables
@ -599,6 +657,7 @@ async def test_migration_upgrade_and_downgrade_are_additive(test_engine):
).one() == (sentinel_id, "keep")
finally:
migration.op = original_op
rollback_migration.op = original_rollback_op
sync_connection.exec_driver_sql("SET LOCAL search_path TO public")
sync_connection.exec_driver_sql(f"DROP SCHEMA {quoted_schema} CASCADE")

View file

@ -8,7 +8,7 @@ status: active
owner: codex
topic_slug: infotech
created: "2026-08-28"
updated: "2026-08-28"
updated: "2026-08-29"
reviewed_at: "2026-08-28"
reviewed_by: codex
reviewed_against_commit: "41d80429a77ffeb640f4b34091ebee5c91585373"
@ -239,7 +239,7 @@ full Python suite passes (765 tests).
```task
id: STATE-WP-0085-T03
status: todo
status: done
priority: high
state_hub_task_id: "2cb4d7ac-0192-5d46-aa87-7412e99b0cef"
```
@ -276,6 +276,24 @@ Acceptance:
- API tests interrupt and resume after every phase;
- the managed-repository UUID is asserted unchanged throughout.
Implemented 2026-08-29. UUID-addressed endpoints now verify immutable Forgejo
identity, produce HMAC-signed non-mutating preflights, create discoverable
operation journals, apply one compare-and-set phase at a time, verify baseline
identity continuity, and preflight/apply bounded rollback. The Forge adapter
uses authenticated absence proofs and a separate rename credential; API tests
replace it with an in-memory boundary and cannot touch live Forgejo. Resume
recognizes a Forge rename that committed before its journal phase, completed
phase replays are no-ops with the same operation UUID, and rollback recognizes
the corresponding unrecorded-Forge state. Baseline IDs must remain present
while append-only telemetry may grow during the operation. The follow-on
migration permits a protected alias to become canonical only for its own
operation in `rollback-preflight`; all other alias mutation remains rejected.
Tests cover dry-run persistence, expiry and confirmation, occupied slugs,
queued edge writes, wrong Forge ID, moved head/stale evidence, unreadable Forge,
phase interruption/replay, telemetry append continuity, rollback, trigger
guarding, and full migration upgrade/downgrade. The full Python suite passes
(771 tests).
## Make State Hub reads and routing alias-aware
```task