state-hub/migrations/versions/f3c4d5e6a7b8_repository_rename_identity.py
tegwick 8988a093f2
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
feat: persist repository rename identity
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-28 22:08:21 +02:00

475 lines
17 KiB
Python

"""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()"))