"""add typed risk disposition state and append-only events Revision ID: 20260716_0011 Revises: 20260716_0010 Create Date: 2026-07-16 16:20:00 """ from __future__ import annotations from collections.abc import Sequence import sqlalchemy as sa from alembic import op revision: str = "20260716_0011" down_revision: str | None = "20260716_0010" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def _require_postgresql() -> None: dialect_name = op.get_bind().dialect.name if dialect_name != "postgresql": raise RuntimeError( "20260716_0011 only supports PostgreSQL; " f"refusing to mutate {dialect_name} without transactional constraint DDL" ) def _require_empty_audit_chain_for_downgrade() -> None: bind = op.get_bind() disposition_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM risk_dispositions")) or 0) event_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM risk_disposition_events")) or 0) if disposition_count or event_count: raise RuntimeError( "cannot downgrade risk dispositions: audit chain is not empty " f"({disposition_count} dispositions, {event_count} events)" ) def upgrade() -> None: _require_postgresql() op.create_unique_constraint( "uq_risk_observations_tenant_id", "risk_observations", ["tenant_id", "id"], ) op.create_table( "risk_dispositions", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("tenant_id", sa.String(length=64), nullable=False), sa.Column("observation_id", sa.String(length=36), nullable=False), sa.Column( "adjudication", sa.String(length=24), nullable=False, server_default="unreviewed", ), sa.Column( "lifecycle_status", sa.String(length=32), nullable=False, server_default="open", ), sa.Column("version", sa.Integer(), nullable=False, server_default="0"), sa.Column("assignee", sa.String(length=120), nullable=True), sa.Column("due_at", sa.DateTime(timezone=True), nullable=True), sa.Column("resolution", sa.Text(), nullable=True), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.CheckConstraint( "adjudication IN ('unreviewed', 'confirmed', 'false_positive')", name="ck_risk_dispositions_adjudication", ), sa.CheckConstraint( "lifecycle_status IN ('open', 'supplement_requested', " "'remediation_in_progress', 'waiver_requested', 'resolved')", name="ck_risk_dispositions_lifecycle", ), sa.CheckConstraint("version >= 0", name="ck_risk_dispositions_version"), sa.ForeignKeyConstraint( ["tenant_id", "observation_id"], ["risk_observations.tenant_id", "risk_observations.id"], name="fk_risk_dispositions_tenant_observation", ondelete="RESTRICT", ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint( "tenant_id", "id", name="uq_risk_dispositions_tenant_id", ), sa.UniqueConstraint( "tenant_id", "observation_id", name="uq_risk_dispositions_tenant_observation", ), ) op.create_index( "ix_risk_dispositions_tenant_lifecycle_due", "risk_dispositions", ["tenant_id", "lifecycle_status", "due_at"], unique=False, ) op.create_index( "ix_risk_dispositions_assignee", "risk_dispositions", ["tenant_id", "assignee"], unique=False, ) op.create_table( "risk_disposition_events", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("tenant_id", sa.String(length=64), nullable=False), sa.Column("disposition_id", sa.String(length=36), nullable=False), sa.Column("observation_id", sa.String(length=36), nullable=False), sa.Column("version", sa.Integer(), nullable=False), sa.Column("action", sa.String(length=32), nullable=False), sa.Column("actor_id", sa.String(length=120), nullable=False), sa.Column("actor_name", sa.String(length=120), nullable=False), sa.Column("request_id", sa.String(length=120), nullable=False), sa.Column("payload_fingerprint", sa.String(length=64), nullable=False), sa.Column("comment", sa.Text(), nullable=True), sa.Column("before_json", sa.JSON(), nullable=False), sa.Column("after_json", sa.JSON(), nullable=False), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.CheckConstraint( "action IN ('confirm', 'false_positive', 'request_supplement', " "'start_remediation', 'resolve', 'request_waiver')", name="ck_risk_disposition_events_action", ), sa.CheckConstraint( "version > 0", name="ck_risk_disposition_events_version", ), sa.ForeignKeyConstraint( ["tenant_id", "disposition_id"], ["risk_dispositions.tenant_id", "risk_dispositions.id"], name="fk_risk_disposition_events_tenant_disposition", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["tenant_id", "observation_id"], ["risk_observations.tenant_id", "risk_observations.id"], name="fk_risk_disposition_events_tenant_observation", ondelete="RESTRICT", ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint( "tenant_id", "request_id", name="uq_risk_disposition_events_tenant_request", ), sa.UniqueConstraint( "disposition_id", "version", name="uq_risk_disposition_events_version", ), ) op.create_index( "ix_risk_disposition_events_disposition_id", "risk_disposition_events", ["disposition_id"], unique=False, ) op.create_index( "ix_risk_disposition_events_tenant_observation_time", "risk_disposition_events", ["tenant_id", "observation_id", "created_at"], unique=False, ) op.execute( "CREATE FUNCTION reject_risk_disposition_event_mutation() " "RETURNS trigger AS $$ " "BEGIN " "RAISE EXCEPTION 'risk_disposition_events is append-only'; " "RETURN OLD; " "END; " "$$ LANGUAGE plpgsql" ) op.execute( "CREATE TRIGGER trg_risk_disposition_events_append_only " "BEFORE UPDATE OR DELETE ON risk_disposition_events " "FOR EACH ROW EXECUTE FUNCTION reject_risk_disposition_event_mutation()" ) def downgrade() -> None: _require_postgresql() # 审计链不可静默销毁;数据归档或迁移必须由显式运维流程完成。 _require_empty_audit_chain_for_downgrade() op.execute("DROP TRIGGER trg_risk_disposition_events_append_only ON risk_disposition_events") op.execute("DROP FUNCTION reject_risk_disposition_event_mutation()") op.drop_index( "ix_risk_disposition_events_tenant_observation_time", table_name="risk_disposition_events", ) op.drop_index( "ix_risk_disposition_events_disposition_id", table_name="risk_disposition_events", ) op.drop_table("risk_disposition_events") op.drop_index( "ix_risk_dispositions_assignee", table_name="risk_dispositions", ) op.drop_index( "ix_risk_dispositions_tenant_lifecycle_due", table_name="risk_dispositions", ) op.drop_table("risk_dispositions") op.drop_constraint( "uq_risk_observations_tenant_id", "risk_observations", type_="unique", )