feat: persist repository rename identity
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-28 22:08:21 +02:00
parent 13f7a10018
commit 8988a093f2
10 changed files with 1454 additions and 4 deletions

View file

@ -322,7 +322,7 @@
| task | STATE-WP-0084-T03 | progress | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
| 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 | todo | — | 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-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 |

View file

@ -43,6 +43,11 @@ from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
from api.models.write_idempotency_key import WriteIdempotencyKey
from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias
from api.models.review_contract import ReviewContract, ReviewReceipt
from api.models.repository_rename import (
RepositoryForgeIdentity,
RepositoryRenameOperation,
RepositorySlug,
)
from api.models.suggestion import (
Suggestion,
SuggestionNote,
@ -85,5 +90,6 @@ __all__ = [
"WriteIdempotencyKey",
"WorkRecordIdentifierAlias",
"ReviewContract", "ReviewReceipt",
"RepositoryForgeIdentity", "RepositoryRenameOperation", "RepositorySlug",
"Suggestion", "SuggestionNote", "SuggestionRelevanceBump", "SuggestionStage",
]

View file

@ -53,6 +53,21 @@ class ManagedRepo(Base, TimestampMixin):
"RepoGoal", back_populates="repo", lazy="selectin"
)
forge_identity: Mapped["RepositoryForgeIdentity | None"] = relationship( # noqa: F821
"RepositoryForgeIdentity",
back_populates="repo",
lazy="selectin",
uselist=False,
)
slug_records: Mapped[list["RepositorySlug"]] = relationship( # noqa: F821
"RepositorySlug", back_populates="repo", lazy="selectin"
)
rename_operations: Mapped[list["RepositoryRenameOperation"]] = relationship( # noqa: F821
"RepositoryRenameOperation", back_populates="repo", lazy="selectin"
)
@property
def domain_slug(self) -> str:
return self.domain.slug if self.domain is not None else ""

View file

@ -0,0 +1,292 @@
import uuid
from datetime import datetime
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid
FORGE_IDENTITY_STATES = ("unverified", "verified")
REPOSITORY_SLUG_KINDS = ("canonical", "alias")
REPOSITORY_RENAME_PHASES = (
"draft",
"preflighted",
"forge-renamed",
"statehub-rebound",
"source-synced",
"consumers-verified",
"completed",
"rollback-preflight",
"rolled-back",
)
TERMINAL_REPOSITORY_RENAME_PHASES = ("completed", "rolled-back")
class RepositoryForgeIdentity(Base, TimestampMixin):
"""One explicitly verified (or explicitly unverified) Forge identity per repo."""
__tablename__ = "repository_forge_identities"
__table_args__ = (
UniqueConstraint("repo_id", name="uq_repository_forge_identities_repo_id"),
UniqueConstraint(
"id",
"repo_id",
"verification_state",
name="uq_repository_forge_identity_verified_ref",
),
UniqueConstraint(
"provider",
"forge_instance",
"forge_owner",
"forge_repository_id",
name="uq_repository_forge_identity_tuple",
),
CheckConstraint(
"verification_state IN ('unverified', 'verified')",
name="ck_repository_forge_identity_state",
),
CheckConstraint(
"forge_repository_id IS NULL OR forge_repository_id > 0",
name="ck_repository_forge_id_positive",
),
CheckConstraint(
"verification_state != 'verified' OR "
"(provider IS NOT NULL AND forge_instance IS NOT NULL "
"AND forge_owner IS NOT NULL AND forge_repository_id IS NOT NULL "
"AND verified_at IS NOT NULL AND verified_by IS NOT NULL)",
name="ck_repository_forge_verified_complete",
),
CheckConstraint(
"verification_state = 'verified' OR "
"(verified_at IS NULL AND verified_by IS NULL)",
name="ck_repository_forge_unverified_has_no_attestation",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
repo_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
nullable=False,
)
provider: Mapped[str | None] = mapped_column(String(40), nullable=True)
forge_instance: Mapped[str | None] = mapped_column(String(255), nullable=True)
forge_owner: Mapped[str | None] = mapped_column(String(255), nullable=True)
forge_repository_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
verification_state: Mapped[str] = mapped_column(
String(20), nullable=False, default="unverified", server_default="unverified"
)
verified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
verified_by: Mapped[str | None] = mapped_column(String(160), nullable=True)
verification_evidence: Mapped[dict] = mapped_column(
JSONB, nullable=False, default=dict, server_default="{}"
)
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
"ManagedRepo", back_populates="forge_identity", lazy="selectin"
)
rename_operations: Mapped[list["RepositoryRenameOperation"]] = relationship(
"RepositoryRenameOperation",
back_populates="forge_identity",
lazy="selectin",
viewonly=True,
)
class RepositoryRenameOperation(Base, TimestampMixin):
"""Append-preserving journal for one phased canonical-coordinate change."""
__tablename__ = "repository_rename_operations"
__table_args__ = (
CheckConstraint(
"phase IN ('draft', 'preflighted', 'forge-renamed', "
"'statehub-rebound', 'source-synced', 'consumers-verified', "
"'completed', 'rollback-preflight', 'rolled-back')",
name="ck_repository_rename_phase",
),
CheckConstraint(
"old_slug != new_slug", name="ck_repository_rename_distinct_slugs"
),
CheckConstraint(
"expected_forge_repository_id > 0",
name="ck_repository_rename_forge_id_positive",
),
CheckConstraint(
"forge_identity_state = 'verified'",
name="ck_repository_rename_verified_identity",
),
CheckConstraint(
"phase = 'draft' OR preflighted_at IS NOT NULL",
name="ck_repository_rename_preflight_timestamp",
),
CheckConstraint(
"phase != 'completed' OR completed_at IS NOT NULL",
name="ck_repository_rename_completed_timestamp",
),
CheckConstraint(
"phase != 'rolled-back' OR rolled_back_at IS NOT NULL",
name="ck_repository_rename_rollback_timestamp",
),
Index(
"uq_repository_rename_active_repo",
"repo_id",
unique=True,
postgresql_where=text("phase NOT IN ('completed', 'rolled-back')"),
),
Index(
"uq_repository_rename_active_new_slug",
"new_slug",
unique=True,
postgresql_where=text("phase NOT IN ('completed', 'rolled-back')"),
),
Index(
"ix_repository_rename_phase", "phase"
),
ForeignKeyConstraint(
["forge_identity_id", "repo_id", "forge_identity_state"],
[
"repository_forge_identities.id",
"repository_forge_identities.repo_id",
"repository_forge_identities.verification_state",
],
name="fk_repository_rename_verified_identity",
ondelete="RESTRICT",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
repo_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
forge_identity_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), nullable=False
)
forge_identity_state: Mapped[str] = mapped_column(
String(20), nullable=False, default="verified", server_default="verified"
)
expected_provider: Mapped[str] = mapped_column(String(40), nullable=False)
expected_forge_instance: Mapped[str] = mapped_column(String(255), nullable=False)
expected_forge_owner: Mapped[str] = mapped_column(String(255), nullable=False)
expected_forge_repository_id: Mapped[int] = mapped_column(
BigInteger, nullable=False
)
expected_source_commit: Mapped[str] = mapped_column(String(64), nullable=False)
expected_default_branch: Mapped[str] = mapped_column(String(255), nullable=False)
old_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
new_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
old_coordinates: Mapped[dict] = mapped_column(JSONB, nullable=False)
new_coordinates: Mapped[dict] = mapped_column(JSONB, nullable=False)
phase: Mapped[str] = mapped_column(
String(32), nullable=False, default="draft", server_default="draft"
)
actor: Mapped[str] = mapped_column(String(160), nullable=False)
phase_changed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
preflighted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
preflight_expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
rolled_back_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
evidence: Mapped[dict] = mapped_column(
JSONB, nullable=False, default=dict, server_default="{}"
)
error_code: Mapped[str | None] = mapped_column(String(80), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
error_details: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
error_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
"ManagedRepo", back_populates="rename_operations", lazy="selectin"
)
forge_identity: Mapped[RepositoryForgeIdentity] = relationship(
"RepositoryForgeIdentity",
back_populates="rename_operations",
lazy="selectin",
viewonly=True,
)
slugs: Mapped[list["RepositorySlug"]] = relationship(
"RepositorySlug", back_populates="source_operation", lazy="selectin"
)
class RepositorySlug(Base, TimestampMixin):
"""Global uniqueness boundary for current and protected prior slugs."""
__tablename__ = "repository_slugs"
__table_args__ = (
UniqueConstraint("slug", name="uq_repository_slugs_slug"),
CheckConstraint(
"kind IN ('canonical', 'alias')", name="ck_repository_slug_kind"
),
CheckConstraint(
"kind != 'alias' OR protected",
name="ck_repository_slug_alias_protected",
),
Index(
"uq_repository_slugs_one_canonical",
"repo_id",
unique=True,
postgresql_where=text("kind = 'canonical'"),
),
Index("ix_repository_slugs_repo_kind", "repo_id", "kind"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
repo_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
nullable=False,
)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
kind: Mapped[str] = mapped_column(String(16), nullable=False)
protected: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true"
)
source_operation_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("repository_rename_operations.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
"ManagedRepo", back_populates="slug_records", lazy="selectin"
)
source_operation: Mapped[RepositoryRenameOperation | None] = relationship(
"RepositoryRenameOperation", back_populates="slugs", lazy="selectin"
)

View file

@ -11,6 +11,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import case, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload
@ -28,6 +29,7 @@ from api.models.domain import Domain
from api.models.interface_change import InterfaceChange
from api.models.managed_repo import ManagedRepo
from api.models.repo_goal import RepoGoal
from api.models.repository_rename import RepositorySlug
from api.models.tpsc import TPSCSnapshot
from api.models.task import Task
from api.models.workplan import Workplan
@ -51,6 +53,7 @@ from api.schemas.managed_repo import (
from api.services.sbom_nexus import SBOMNexusError
from api.services.sbom_nexus import get_json as get_sbom_nexus_json
from api.services.sbom_nexus import reads_from_nexus
from api.services.repository_identity import stage_initial_repository_identity
from hub_core.routers.repos import create_repos_router
router = APIRouter(prefix="/repos", tags=["repos"])
@ -150,7 +153,10 @@ async def register_repo(
if domain_obj is None:
raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found")
existing = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.slug))
if existing.scalar_one_or_none():
claimed_slug = await session.execute(
select(RepositorySlug).where(RepositorySlug.slug == body.slug)
)
if existing.scalar_one_or_none() or claimed_slug.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists")
payload = body.model_dump()
@ -179,7 +185,16 @@ async def register_repo(
standard_version=body.standard_version,
)
session.add(repo)
await session.commit()
await session.flush()
stage_initial_repository_identity(session, repo)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise HTTPException(
status_code=409,
detail=f"Repo slug '{body.slug}' is already claimed",
) from exc
await session.refresh(repo)
await _publish_repo_registered(repo, body, domain_obj)
return repo

View file

@ -0,0 +1,23 @@
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.managed_repo import ManagedRepo
from api.models.repository_rename import RepositoryForgeIdentity, RepositorySlug
def stage_initial_repository_identity(
session: AsyncSession, repo: ManagedRepo
) -> None:
"""Attach non-guessed identity state to a newly flushed repository row."""
if repo.id is None:
raise ValueError("repository must be flushed before identity records are staged")
session.add_all(
[
RepositoryForgeIdentity(repo_id=repo.id),
RepositorySlug(
repo_id=repo.id,
slug=repo.slug,
kind="canonical",
protected=True,
),
]
)

View file

@ -0,0 +1,475 @@
"""repository identity, protected slugs, and rename journal (STATE-WP-0085-T02)
The existing repository UUID remains the durable State Hub identity. This
migration adds only new rows around it: an explicit unverified Forge identity,
one canonical slug record, and empty operation history. It deliberately does
not infer Forge coordinates from a slug, remote URL, checkout, or Git commit.
Revision ID: f3c4d5e6a7b8
Revises: e2b3c4d5f6a7
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, UUID
revision = "f3c4d5e6a7b8"
down_revision = "e2b3c4d5f6a7"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"repository_forge_identities",
sa.Column(
"id",
UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"repo_id",
UUID(as_uuid=True),
sa.ForeignKey("managed_repos.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("provider", sa.String(length=40), nullable=True),
sa.Column("forge_instance", sa.String(length=255), nullable=True),
sa.Column("forge_owner", sa.String(length=255), nullable=True),
sa.Column("forge_repository_id", sa.BigInteger(), nullable=True),
sa.Column(
"verification_state",
sa.String(length=20),
nullable=False,
server_default="unverified",
),
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("verified_by", sa.String(length=160), nullable=True),
sa.Column(
"verification_evidence", JSONB(), nullable=False, server_default="{}"
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.UniqueConstraint(
"repo_id", name="uq_repository_forge_identities_repo_id"
),
sa.UniqueConstraint(
"id",
"repo_id",
"verification_state",
name="uq_repository_forge_identity_verified_ref",
),
sa.UniqueConstraint(
"provider",
"forge_instance",
"forge_owner",
"forge_repository_id",
name="uq_repository_forge_identity_tuple",
),
sa.CheckConstraint(
"verification_state IN ('unverified', 'verified')",
name="ck_repository_forge_identity_state",
),
sa.CheckConstraint(
"forge_repository_id IS NULL OR forge_repository_id > 0",
name="ck_repository_forge_id_positive",
),
sa.CheckConstraint(
"verification_state != 'verified' OR "
"(provider IS NOT NULL AND forge_instance IS NOT NULL "
"AND forge_owner IS NOT NULL AND forge_repository_id IS NOT NULL "
"AND verified_at IS NOT NULL AND verified_by IS NOT NULL)",
name="ck_repository_forge_verified_complete",
),
sa.CheckConstraint(
"verification_state = 'verified' OR "
"(verified_at IS NULL AND verified_by IS NULL)",
name="ck_repository_forge_unverified_has_no_attestation",
),
)
op.create_table(
"repository_rename_operations",
sa.Column(
"id",
UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"repo_id",
UUID(as_uuid=True),
sa.ForeignKey("managed_repos.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("forge_identity_id", UUID(as_uuid=True), nullable=False),
sa.Column(
"forge_identity_state",
sa.String(length=20),
nullable=False,
server_default="verified",
),
sa.Column("expected_provider", sa.String(length=40), nullable=False),
sa.Column(
"expected_forge_instance", sa.String(length=255), nullable=False
),
sa.Column("expected_forge_owner", sa.String(length=255), nullable=False),
sa.Column("expected_forge_repository_id", sa.BigInteger(), nullable=False),
sa.Column("expected_source_commit", sa.String(length=64), nullable=False),
sa.Column("expected_default_branch", sa.String(length=255), nullable=False),
sa.Column("old_slug", sa.String(length=100), nullable=False),
sa.Column("new_slug", sa.String(length=100), nullable=False),
sa.Column("old_coordinates", JSONB(), nullable=False),
sa.Column("new_coordinates", JSONB(), nullable=False),
sa.Column(
"phase", sa.String(length=32), nullable=False, server_default="draft"
),
sa.Column("actor", sa.String(length=160), nullable=False),
sa.Column("phase_changed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("preflighted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("preflight_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("rolled_back_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("evidence", JSONB(), nullable=False, server_default="{}"),
sa.Column("error_code", sa.String(length=80), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("error_details", JSONB(), nullable=True),
sa.Column("error_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.CheckConstraint(
"phase IN ('draft', 'preflighted', 'forge-renamed', "
"'statehub-rebound', 'source-synced', 'consumers-verified', "
"'completed', 'rollback-preflight', 'rolled-back')",
name="ck_repository_rename_phase",
),
sa.CheckConstraint(
"old_slug != new_slug", name="ck_repository_rename_distinct_slugs"
),
sa.CheckConstraint(
"expected_forge_repository_id > 0",
name="ck_repository_rename_forge_id_positive",
),
sa.CheckConstraint(
"forge_identity_state = 'verified'",
name="ck_repository_rename_verified_identity",
),
sa.CheckConstraint(
"phase = 'draft' OR preflighted_at IS NOT NULL",
name="ck_repository_rename_preflight_timestamp",
),
sa.CheckConstraint(
"phase != 'completed' OR completed_at IS NOT NULL",
name="ck_repository_rename_completed_timestamp",
),
sa.CheckConstraint(
"phase != 'rolled-back' OR rolled_back_at IS NOT NULL",
name="ck_repository_rename_rollback_timestamp",
),
sa.ForeignKeyConstraint(
["forge_identity_id", "repo_id", "forge_identity_state"],
[
"repository_forge_identities.id",
"repository_forge_identities.repo_id",
"repository_forge_identities.verification_state",
],
name="fk_repository_rename_verified_identity",
ondelete="RESTRICT",
),
)
op.create_index(
"ix_repository_rename_operations_repo_id",
"repository_rename_operations",
["repo_id"],
)
op.create_index(
"ix_repository_rename_operations_old_slug",
"repository_rename_operations",
["old_slug"],
)
op.create_index(
"ix_repository_rename_operations_new_slug",
"repository_rename_operations",
["new_slug"],
)
op.create_index(
"ix_repository_rename_phase",
"repository_rename_operations",
["phase"],
)
op.create_index(
"uq_repository_rename_active_repo",
"repository_rename_operations",
["repo_id"],
unique=True,
postgresql_where=sa.text("phase NOT IN ('completed', 'rolled-back')"),
)
op.create_index(
"uq_repository_rename_active_new_slug",
"repository_rename_operations",
["new_slug"],
unique=True,
postgresql_where=sa.text("phase NOT IN ('completed', 'rolled-back')"),
)
op.create_table(
"repository_slugs",
sa.Column(
"id",
UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"repo_id",
UUID(as_uuid=True),
sa.ForeignKey("managed_repos.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("slug", sa.String(length=100), nullable=False),
sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column(
"protected", sa.Boolean(), nullable=False, server_default=sa.true()
),
sa.Column(
"source_operation_id",
UUID(as_uuid=True),
sa.ForeignKey("repository_rename_operations.id", ondelete="RESTRICT"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.UniqueConstraint("slug", name="uq_repository_slugs_slug"),
sa.CheckConstraint(
"kind IN ('canonical', 'alias')", name="ck_repository_slug_kind"
),
sa.CheckConstraint(
"kind != 'alias' OR protected",
name="ck_repository_slug_alias_protected",
),
)
op.create_index(
"ix_repository_slugs_source_operation_id",
"repository_slugs",
["source_operation_id"],
)
op.create_index(
"ix_repository_slugs_repo_kind",
"repository_slugs",
["repo_id", "kind"],
)
op.create_index(
"uq_repository_slugs_one_canonical",
"repository_slugs",
["repo_id"],
unique=True,
postgresql_where=sa.text("kind = 'canonical'"),
)
# Explicitly mark every existing repository unverified. Do not parse or
# infer Forge coordinates from current mutable fields.
op.execute(
sa.text(
"""
INSERT INTO repository_forge_identities
(id, repo_id, verification_state, verification_evidence,
created_at, updated_at)
SELECT gen_random_uuid(), id, 'unverified', '{}'::jsonb, now(), now()
FROM managed_repos
"""
)
)
op.execute(
sa.text(
"""
CREATE FUNCTION guard_repository_forge_identity_history()
RETURNS trigger AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'repository Forge identity rows are durable';
END IF;
IF OLD.verification_state = 'verified' AND ROW(
NEW.repo_id,
NEW.provider,
NEW.forge_instance,
NEW.forge_owner,
NEW.forge_repository_id,
NEW.verification_state,
NEW.verified_at,
NEW.verified_by,
NEW.verification_evidence
) IS DISTINCT FROM ROW(
OLD.repo_id,
OLD.provider,
OLD.forge_instance,
OLD.forge_owner,
OLD.forge_repository_id,
OLD.verification_state,
OLD.verified_at,
OLD.verified_by,
OLD.verification_evidence
) THEN
RAISE EXCEPTION 'verified repository Forge identity is immutable';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
"""
)
)
op.execute(
sa.text(
"""
CREATE TRIGGER repository_forge_identity_history_guard
BEFORE UPDATE OR DELETE ON repository_forge_identities
FOR EACH ROW EXECUTE FUNCTION guard_repository_forge_identity_history()
"""
)
)
op.execute(
sa.text(
"""
CREATE 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
"""
)
)
op.execute(
sa.text(
"""
CREATE TRIGGER repository_slug_history_guard
BEFORE UPDATE OR DELETE ON repository_slugs
FOR EACH ROW EXECUTE FUNCTION guard_repository_slug_history()
"""
)
)
op.execute(
sa.text(
"""
CREATE FUNCTION guard_repository_rename_operation_history()
RETURNS trigger AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'repository rename operation rows are durable';
END IF;
IF ROW(
NEW.repo_id,
NEW.forge_identity_id,
NEW.forge_identity_state,
NEW.expected_provider,
NEW.expected_forge_instance,
NEW.expected_forge_owner,
NEW.expected_forge_repository_id,
NEW.expected_source_commit,
NEW.expected_default_branch,
NEW.old_slug,
NEW.new_slug,
NEW.old_coordinates,
NEW.new_coordinates,
NEW.actor,
NEW.created_at
) IS DISTINCT FROM ROW(
OLD.repo_id,
OLD.forge_identity_id,
OLD.forge_identity_state,
OLD.expected_provider,
OLD.expected_forge_instance,
OLD.expected_forge_owner,
OLD.expected_forge_repository_id,
OLD.expected_source_commit,
OLD.expected_default_branch,
OLD.old_slug,
OLD.new_slug,
OLD.old_coordinates,
OLD.new_coordinates,
OLD.actor,
OLD.created_at
) THEN
RAISE EXCEPTION 'repository rename operation assertions are immutable';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
"""
)
)
op.execute(
sa.text(
"""
CREATE TRIGGER repository_rename_operation_history_guard
BEFORE UPDATE OR DELETE ON repository_rename_operations
FOR EACH ROW EXECUTE FUNCTION guard_repository_rename_operation_history()
"""
)
)
op.execute(
sa.text(
"""
INSERT INTO repository_slugs
(id, repo_id, slug, kind, protected, created_at, updated_at)
SELECT gen_random_uuid(), id, slug, 'canonical', true, now(), now()
FROM managed_repos
"""
)
)
def downgrade() -> None:
# These tables are additive. Dropping them restores the prior schema and
# cannot rewrite or delete any pre-existing work or telemetry row.
op.drop_table("repository_slugs")
op.drop_table("repository_rename_operations")
op.drop_table("repository_forge_identities")
op.execute(sa.text("DROP FUNCTION guard_repository_slug_history()"))
op.execute(sa.text("DROP FUNCTION guard_repository_rename_operation_history()"))
op.execute(sa.text("DROP FUNCTION guard_repository_forge_identity_history()"))

View file

@ -39,6 +39,7 @@ from api.config import settings # noqa: E402
from api.database import async_session_factory, engine # noqa: E402
from api.models.domain import Domain # noqa: E402
from api.models.managed_repo import ManagedRepo # noqa: E402
from api.services.repository_identity import stage_initial_repository_identity # noqa: E402
try:
import httpx
@ -286,6 +287,8 @@ async def _upsert_via_db(
)
_apply_classification(repo, data, domain_id)
session.add(repo)
await session.flush()
stage_initial_repository_identity(session, repo)
await session.commit()
report.add(
RowResult(slug, str(git_root), "registered", f"domain={data.domain}")

View file

@ -0,0 +1,605 @@
from __future__ import annotations
import importlib
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy import inspect, select, text
from sqlalchemy.exc import DBAPIError, IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from api.models.decision import Decision
from api.models.domain import Domain
from api.models.managed_repo import ManagedRepo
from api.models.progress_event import ProgressEvent
from api.models.repository_rename import (
RepositoryForgeIdentity,
RepositoryRenameOperation,
RepositorySlug,
)
from api.models.sbom_snapshot import SBOMSnapshot
from api.models.task import Task
from api.models.token_event import TokenEvent
from api.models.workplan import Workplan
from tests.conftest import create_test_domain, create_test_repo
def _operation(
*,
repo_id: uuid.UUID,
identity_id: uuid.UUID,
old_slug: str,
new_slug: str,
forge_repository_id: int,
phase: str = "draft",
) -> RepositoryRenameOperation:
now = datetime.now(timezone.utc)
return RepositoryRenameOperation(
repo_id=repo_id,
forge_identity_id=identity_id,
forge_identity_state="verified",
expected_provider="forgejo",
expected_forge_instance="https://forgejo.coulomb.social",
expected_forge_owner="coulomb",
expected_forge_repository_id=forge_repository_id,
expected_source_commit="a" * 40,
expected_default_branch="main",
old_slug=old_slug,
new_slug=new_slug,
old_coordinates={"slug": old_slug},
new_coordinates={"slug": new_slug},
phase=phase,
actor="test",
phase_changed_at=now,
preflighted_at=now if phase != "draft" else None,
preflight_expires_at=(now + timedelta(minutes=30)) if phase != "draft" else None,
)
@pytest.mark.asyncio
async def test_repo_registration_creates_unverified_identity_and_canonical_slug(
client, test_engine
):
await create_test_domain(client)
repo = await create_test_repo(client, slug="identity-test")
factory = async_sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
async with factory() as session:
identity = await session.scalar(
select(RepositoryForgeIdentity).where(
RepositoryForgeIdentity.repo_id == uuid.UUID(repo["id"])
)
)
slug = await session.scalar(
select(RepositorySlug).where(
RepositorySlug.repo_id == uuid.UUID(repo["id"])
)
)
assert identity is not None
assert identity.verification_state == "unverified"
assert identity.provider is None
assert identity.forge_instance is None
assert identity.forge_owner is None
assert identity.forge_repository_id is None
assert slug is not None
assert (slug.slug, slug.kind, slug.protected) == (
"identity-test",
"canonical",
True,
)
@pytest.mark.asyncio
async def test_protected_alias_cannot_be_claimed_by_another_repository(
client, test_engine
):
await create_test_domain(client)
source = await create_test_repo(client, slug="access-engine")
other = await create_test_repo(client, slug="another-repo")
factory = async_sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
async with factory() as session:
session.add(
RepositorySlug(
repo_id=uuid.UUID(source["id"]),
slug="flex-auth",
kind="alias",
protected=True,
)
)
await session.commit()
response = await client.post(
"/repos/",
json={
"domain_slug": "infotech",
"slug": "flex-auth",
"name": "Conflicting repository",
},
)
assert response.status_code == 409
async with factory() as session:
session.add(
RepositorySlug(
repo_id=uuid.UUID(other["id"]),
slug="flex-auth",
kind="alias",
protected=True,
)
)
with pytest.raises(IntegrityError):
await session.commit()
async with factory() as session:
session.add(
RepositorySlug(
repo_id=uuid.UUID(source["id"]),
slug="second-canonical",
kind="canonical",
protected=True,
)
)
with pytest.raises(IntegrityError):
await session.commit()
@pytest.mark.asyncio
async def test_forge_identity_is_unique_and_unverified_repo_cannot_open_operation(
client, test_engine
):
await create_test_domain(client)
first = await create_test_repo(client, slug="first-repo")
second = await create_test_repo(client, slug="second-repo")
factory = async_sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
now = datetime.now(timezone.utc)
async with factory() as session:
first_identity = await session.scalar(
select(RepositoryForgeIdentity).where(
RepositoryForgeIdentity.repo_id == uuid.UUID(first["id"])
)
)
assert first_identity is not None
first_identity.provider = "forgejo"
first_identity.forge_instance = "https://forgejo.coulomb.social"
first_identity.forge_owner = "coulomb"
first_identity.forge_repository_id = 42
first_identity.verification_state = "verified"
first_identity.verified_at = now
first_identity.verified_by = "test"
first_identity.verification_evidence = {"source": "forge-api"}
await session.commit()
async with factory() as session:
second_identity = await session.scalar(
select(RepositoryForgeIdentity).where(
RepositoryForgeIdentity.repo_id == uuid.UUID(second["id"])
)
)
assert second_identity is not None
session.add(
_operation(
repo_id=uuid.UUID(second["id"]),
identity_id=second_identity.id,
old_slug="second-repo",
new_slug="second-repo-renamed",
forge_repository_id=43,
)
)
with pytest.raises(IntegrityError):
await session.commit()
async with factory() as session:
second_identity = await session.scalar(
select(RepositoryForgeIdentity).where(
RepositoryForgeIdentity.repo_id == uuid.UUID(second["id"])
)
)
assert second_identity is not None
second_identity.provider = "forgejo"
second_identity.forge_instance = "https://forgejo.coulomb.social"
second_identity.forge_owner = "coulomb"
second_identity.forge_repository_id = 42
second_identity.verification_state = "verified"
second_identity.verified_at = now
second_identity.verified_by = "test"
with pytest.raises(IntegrityError):
await session.commit()
@pytest.mark.asyncio
async def test_only_one_active_operation_can_hold_a_repo_or_target_slug(
client, test_engine
):
await create_test_domain(client)
first = await create_test_repo(client, slug="first-active")
second = await create_test_repo(client, slug="second-active")
factory = async_sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
now = datetime.now(timezone.utc)
identities: dict[str, RepositoryForgeIdentity] = {}
async with factory() as session:
for repo, forge_repository_id in ((first, 101), (second, 102)):
identity = await session.scalar(
select(RepositoryForgeIdentity).where(
RepositoryForgeIdentity.repo_id == uuid.UUID(repo["id"])
)
)
assert identity is not None
identity.provider = "forgejo"
identity.forge_instance = "https://forgejo.coulomb.social"
identity.forge_owner = "coulomb"
identity.forge_repository_id = forge_repository_id
identity.verification_state = "verified"
identity.verified_at = now
identity.verified_by = "test"
identities[repo["slug"]] = identity
await session.commit()
async with factory() as session:
session.add(
_operation(
repo_id=uuid.UUID(first["id"]),
identity_id=identities["first-active"].id,
old_slug="first-active",
new_slug="shared-target",
forge_repository_id=101,
)
)
await session.commit()
async with factory() as session:
session.add(
_operation(
repo_id=uuid.UUID(first["id"]),
identity_id=identities["first-active"].id,
old_slug="first-active",
new_slug="another-target",
forge_repository_id=101,
)
)
with pytest.raises(IntegrityError):
await session.commit()
async with factory() as session:
session.add(
_operation(
repo_id=uuid.UUID(second["id"]),
identity_id=identities["second-active"].id,
old_slug="second-active",
new_slug="shared-target",
forge_repository_id=102,
)
)
with pytest.raises(IntegrityError):
await session.commit()
@pytest.mark.asyncio
async def test_flex_auth_preflight_representation_preserves_all_existing_ids(test_engine):
factory = async_sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
repo_id = uuid.UUID("fda8ad85-a7d7-4055-8f21-902a533e59df")
domain_id = uuid.uuid4()
workplan_id = uuid.uuid4()
task_id = uuid.uuid4()
progress_id = uuid.uuid4()
decision_id = uuid.uuid4()
token_id = uuid.uuid4()
sbom_id = uuid.uuid4()
identity_id = uuid.uuid4()
now = datetime.now(timezone.utc)
async with factory() as session:
session.add_all(
[
Domain(id=domain_id, slug="net-kingdom", name="Net Kingdom"),
ManagedRepo(
id=repo_id,
domain_id=domain_id,
slug="flex-auth",
name="Flex Auth",
),
]
)
await session.flush()
session.add_all(
[
RepositoryForgeIdentity(
id=identity_id,
repo_id=repo_id,
provider="forgejo",
forge_instance="https://forgejo.coulomb.social",
forge_owner="coulomb",
forge_repository_id=42,
verification_state="verified",
verified_at=now,
verified_by="test",
verification_evidence={"source": "forge-api", "repository_id": 42},
),
RepositorySlug(
repo_id=repo_id,
slug="flex-auth",
kind="canonical",
protected=True,
),
Workplan(
id=workplan_id,
repo_id=repo_id,
slug="flex-wp-0001",
title="Existing work",
),
TokenEvent(
id=token_id,
repo_id=repo_id,
tokens_in=10,
tokens_out=5,
),
SBOMSnapshot(
id=sbom_id,
repo_id=repo_id,
snapshot_at=now,
entry_count=0,
created_at=now,
),
]
)
await session.flush()
session.add_all(
[
Task(
id=task_id,
workplan_id=workplan_id,
record_id="FLEX-WP-0001-T01",
title="Existing task",
),
Decision(
id=decision_id,
workplan_id=workplan_id,
title="Existing decision",
),
]
)
await session.flush()
session.add(
ProgressEvent(
id=progress_id,
workplan_id=workplan_id,
task_id=task_id,
decision_id=decision_id,
event_type="note",
summary="Existing evidence",
)
)
session.add(
_operation(
repo_id=repo_id,
identity_id=identity_id,
old_slug="flex-auth",
new_slug="access-engine",
forge_repository_id=42,
phase="preflighted",
)
)
await session.flush()
assert await session.scalar(
select(ManagedRepo.slug).where(ManagedRepo.id == repo_id)
) == "flex-auth"
assert await session.scalar(
select(RepositoryRenameOperation.expected_forge_repository_id).where(
RepositoryRenameOperation.repo_id == repo_id
)
) == 42
assert await session.scalar(select(Workplan.id)) == workplan_id
assert await session.scalar(select(Task.id)) == task_id
assert await session.scalar(select(ProgressEvent.id)) == progress_id
assert await session.scalar(select(Decision.id)) == decision_id
assert await session.scalar(select(TokenEvent.id)) == token_id
assert await session.scalar(select(SBOMSnapshot.id)) == sbom_id
# No commit: this proves representability without touching either live
# State Hub or Forgejo and leaves the test database unchanged.
await session.rollback()
@pytest.mark.asyncio
async def test_migration_upgrade_and_downgrade_are_additive(test_engine):
migration = importlib.import_module(
"migrations.versions.f3c4d5e6a7b8_repository_rename_identity"
)
schema = f"rename_migration_{uuid.uuid4().hex}"
repo_ids = [uuid.uuid4(), uuid.uuid4()]
sentinel_ids = {
"workplans": uuid.uuid4(),
"tasks": uuid.uuid4(),
"progress_events": uuid.uuid4(),
"decisions": uuid.uuid4(),
"token_events": uuid.uuid4(),
"sbom_snapshots": uuid.uuid4(),
}
async with test_engine.begin() as connection:
def run(sync_connection):
quoted_schema = sync_connection.dialect.identifier_preparer.quote(schema)
sync_connection.exec_driver_sql(f"CREATE SCHEMA {quoted_schema}")
sync_connection.exec_driver_sql(
f"SET LOCAL search_path TO {quoted_schema}, public"
)
sync_connection.exec_driver_sql(
"CREATE TABLE managed_repos "
"(id uuid PRIMARY KEY, slug varchar(100) NOT NULL UNIQUE, name text)"
)
for table_name in sentinel_ids:
sync_connection.exec_driver_sql(
f"CREATE TABLE {table_name} (id uuid PRIMARY KEY, marker text)"
)
sync_connection.execute(
text(
"INSERT INTO managed_repos (id, slug, name) "
"VALUES (:first, 'flex-auth', 'Flex Auth'), "
"(:second, 'state-hub', 'State Hub')"
),
{"first": repo_ids[0], "second": repo_ids[1]},
)
for table_name, sentinel_id in sentinel_ids.items():
sync_connection.execute(
text(f"INSERT INTO {table_name} (id, marker) VALUES (:id, 'keep')"),
{"id": sentinel_id},
)
original_op = migration.op
migration.op = Operations(MigrationContext.configure(sync_connection))
try:
migration.upgrade()
tables = set(inspect(sync_connection).get_table_names(schema=schema))
assert {
"repository_forge_identities",
"repository_rename_operations",
"repository_slugs",
}.issubset(tables)
identity_rows = sync_connection.execute(
text(
"SELECT repo_id, verification_state, provider, forge_repository_id "
"FROM repository_forge_identities ORDER BY repo_id"
)
).all()
assert len(identity_rows) == 2
assert all(
row.verification_state == "unverified"
and row.provider is None
and row.forge_repository_id is None
for row in identity_rows
)
slug_rows = sync_connection.execute(
text(
"SELECT repo_id, slug, kind, protected "
"FROM repository_slugs ORDER BY slug"
)
).all()
assert {(row.slug, row.kind, row.protected) for row in slug_rows} == {
("flex-auth", "canonical", True),
("state-hub", "canonical", True),
}
identity_id = sync_connection.execute(
text(
"UPDATE repository_forge_identities SET "
"provider = 'forgejo', "
"forge_instance = 'https://forgejo.coulomb.social', "
"forge_owner = 'coulomb', forge_repository_id = 42, "
"verification_state = 'verified', verified_at = now(), "
"verified_by = 'migration-test' "
"WHERE repo_id = :repo_id RETURNING id"
),
{"repo_id": repo_ids[0]},
).scalar_one()
with pytest.raises(DBAPIError):
with sync_connection.begin_nested():
sync_connection.execute(
text(
"UPDATE repository_forge_identities "
"SET forge_repository_id = 43 WHERE id = :id"
),
{"id": identity_id},
)
with pytest.raises(DBAPIError):
with sync_connection.begin_nested():
sync_connection.execute(
text(
"DELETE FROM repository_slugs "
"WHERE repo_id = :repo_id"
),
{"repo_id": repo_ids[0]},
)
operation_id = uuid.uuid4()
sync_connection.execute(
text(
"INSERT INTO repository_rename_operations ("
"id, repo_id, forge_identity_id, forge_identity_state, "
"expected_provider, expected_forge_instance, "
"expected_forge_owner, expected_forge_repository_id, "
"expected_source_commit, expected_default_branch, "
"old_slug, new_slug, old_coordinates, new_coordinates, "
"phase, actor, phase_changed_at, evidence, created_at, updated_at"
") VALUES ("
":id, :repo_id, :identity_id, 'verified', 'forgejo', "
"'https://forgejo.coulomb.social', 'coulomb', 42, "
":commit, 'main', 'flex-auth', 'access-engine', "
"'{\"slug\": \"flex-auth\"}'::jsonb, "
"'{\"slug\": \"access-engine\"}'::jsonb, "
"'draft', 'migration-test', now(), '{}'::jsonb, now(), now()"
")"
),
{
"id": operation_id,
"repo_id": repo_ids[0],
"identity_id": identity_id,
"commit": "a" * 40,
},
)
sync_connection.execute(
text(
"UPDATE repository_rename_operations SET "
"phase = 'preflighted', preflighted_at = now(), "
"phase_changed_at = now() WHERE id = :id"
),
{"id": operation_id},
)
with pytest.raises(DBAPIError):
with sync_connection.begin_nested():
sync_connection.execute(
text(
"UPDATE repository_rename_operations "
"SET old_slug = 'rewritten' WHERE id = :id"
),
{"id": operation_id},
)
with pytest.raises(DBAPIError):
with sync_connection.begin_nested():
sync_connection.execute(
text(
"DELETE FROM repository_rename_operations "
"WHERE id = :id"
),
{"id": operation_id},
)
assert sync_connection.execute(
text("SELECT id, slug FROM managed_repos ORDER BY slug")
).all() == sorted(
[(repo_ids[0], "flex-auth"), (repo_ids[1], "state-hub")],
key=lambda item: item[1],
)
for table_name, sentinel_id in sentinel_ids.items():
assert sync_connection.execute(
text(f"SELECT id, marker FROM {table_name}")
).one() == (sentinel_id, "keep")
migration.downgrade()
tables = set(inspect(sync_connection).get_table_names(schema=schema))
assert "repository_forge_identities" not in tables
assert "repository_rename_operations" not in tables
assert "repository_slugs" not in tables
for table_name, sentinel_id in sentinel_ids.items():
assert sync_connection.execute(
text(f"SELECT id, marker FROM {table_name}")
).one() == (sentinel_id, "keep")
finally:
migration.op = original_op
sync_connection.exec_driver_sql("SET LOCAL search_path TO public")
sync_connection.exec_driver_sql(f"DROP SCHEMA {quoted_schema} CASCADE")
await connection.run_sync(run)

View file

@ -188,7 +188,7 @@ are explicit target-workplan boundaries.
```task
id: STATE-WP-0085-T02
status: todo
status: done
priority: high
state_hub_task_id: "c9e6c8c5-c254-5f4d-982c-c161f6ed6d29"
```
@ -219,6 +219,22 @@ Acceptance:
- no migration rewrites workplan, task, progress, decision, token, or SBOM
identities.
Result (2026-08-28): migration `f3c4d5e6a7b8` adds an explicitly unverified or
verified Forge identity per managed-repository UUID, a global canonical/alias
slug registry, and a phased rename-operation journal. Composite foreign keys
prevent operations for unverified or mismatched repository identities; partial
unique indexes reserve one canonical slug, one active operation per repository,
and one active target slug. Database history guards prevent verified Forge
identity, protected slug, and operation-assertion rewrites or deletion while
leaving phase/evidence/error progress updateable. Existing repositories are
backfilled only with an unverified marker and their current canonical slug;
Forge coordinates are not guessed. Both API and direct classification
registration create the same initial records. PostgreSQL tests prove protected
alias conflicts, unique Forge identity, active-operation locks, a non-mutating
`flex-auth`/Forgejo-id-42 preflight representation with all existing identities
preserved, isolated migration upgrade/downgrade, and full-chain migration. The
full Python suite passes (765 tests).
## Implement preflight, apply, resume, verify, and rollback APIs
```task