From 8988a093f2c0277d4cc74db82807ef3a60c0a555 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 28 Aug 2026 22:08:21 +0200 Subject: [PATCH] feat: persist repository rename identity Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3 --- WORK-RECORDS.md | 2 +- api/models/__init__.py | 6 + api/models/managed_repo.py | 15 + api/models/repository_rename.py | 292 +++++++++ api/routers/repos.py | 19 +- api/services/repository_identity.py | 23 + ...f3c4d5e6a7b8_repository_rename_identity.py | 475 ++++++++++++++ scripts/register_from_classification.py | 3 + tests/test_repository_rename_persistence.py | 605 ++++++++++++++++++ ...85-repository-lineage-preserving-rename.md | 18 +- 10 files changed, 1454 insertions(+), 4 deletions(-) create mode 100644 api/models/repository_rename.py create mode 100644 api/services/repository_identity.py create mode 100644 migrations/versions/f3c4d5e6a7b8_repository_rename_identity.py create mode 100644 tests/test_repository_rename_persistence.py diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 4070173..8e047c3 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/api/models/__init__.py b/api/models/__init__.py index 097ab2f..1f8b110 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -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", ] diff --git a/api/models/managed_repo.py b/api/models/managed_repo.py index edf4034..993e236 100644 --- a/api/models/managed_repo.py +++ b/api/models/managed_repo.py @@ -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 "" diff --git a/api/models/repository_rename.py b/api/models/repository_rename.py new file mode 100644 index 0000000..b27a92c --- /dev/null +++ b/api/models/repository_rename.py @@ -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" + ) diff --git a/api/routers/repos.py b/api/routers/repos.py index 257a003..0cf8ede 100644 --- a/api/routers/repos.py +++ b/api/routers/repos.py @@ -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 diff --git a/api/services/repository_identity.py b/api/services/repository_identity.py new file mode 100644 index 0000000..3eb2e09 --- /dev/null +++ b/api/services/repository_identity.py @@ -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, + ), + ] + ) diff --git a/migrations/versions/f3c4d5e6a7b8_repository_rename_identity.py b/migrations/versions/f3c4d5e6a7b8_repository_rename_identity.py new file mode 100644 index 0000000..f7fc8d3 --- /dev/null +++ b/migrations/versions/f3c4d5e6a7b8_repository_rename_identity.py @@ -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()")) diff --git a/scripts/register_from_classification.py b/scripts/register_from_classification.py index 23efe11..6f684eb 100644 --- a/scripts/register_from_classification.py +++ b/scripts/register_from_classification.py @@ -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}") diff --git a/tests/test_repository_rename_persistence.py b/tests/test_repository_rename_persistence.py new file mode 100644 index 0000000..c292b34 --- /dev/null +++ b/tests/test_repository_rename_persistence.py @@ -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) diff --git a/workplans/STATE-WP-0085-repository-lineage-preserving-rename.md b/workplans/STATE-WP-0085-repository-lineage-preserving-rename.md index 885aec6..d0581e0 100644 --- a/workplans/STATE-WP-0085-repository-lineage-preserving-rename.md +++ b/workplans/STATE-WP-0085-repository-lineage-preserving-rename.md @@ -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