"""add tenant-safe savings value ledger and append-only audit events Revision ID: 20260716_0015 Revises: 20260716_0014 Create Date: 2026-07-16 20:10:00 """ from __future__ import annotations from collections.abc import Sequence import sqlalchemy as sa from alembic import op revision: str = "20260716_0015" down_revision: str | None = "20260716_0014" 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_0015 only supports PostgreSQL; " f"refusing to mutate {dialect_name} without transactional constraint DDL" ) def _require_empty_ledger_for_downgrade() -> None: bind = op.get_bind() counts = { table_name: int( bind.scalar(sa.text(f"SELECT COUNT(*) FROM {table_name}")) or 0 ) for table_name in ( "profile_baseline_snapshots", "savings_opportunities", "savings_realizations", "savings_evidence_links", "savings_events", ) } if any(counts.values()): summary = ", ".join(f"{name}={count}" for name, count in counts.items()) raise RuntimeError( "cannot downgrade savings value ledger: immutable value facts exist " f"({summary})" ) def _json_object_default() -> sa.TextClause: return sa.text("'{}'::json") def _json_array_default() -> sa.TextClause: return sa.text("'[]'::json") def upgrade() -> None: _require_postgresql() op.create_table( "profile_baseline_snapshots", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("tenant_id", sa.String(length=64), nullable=False), sa.Column("baseline_key", sa.String(length=160), nullable=False), sa.Column("baseline_type", sa.String(length=32), nullable=False), sa.Column("dimension_type", sa.String(length=50), nullable=False), sa.Column("dimension_id", sa.String(length=160), nullable=False), sa.Column("metric_key", sa.String(length=100), nullable=False), sa.Column("unit", sa.String(length=30), nullable=False), sa.Column("original_currency", sa.String(length=3), nullable=True), sa.Column("baseline_value", sa.Numeric(20, 4), nullable=False), sa.Column("window_start", sa.DateTime(timezone=True), nullable=True), sa.Column("window_end", sa.DateTime(timezone=True), nullable=True), sa.Column("sample_count", sa.Integer(), nullable=False, server_default="0"), sa.Column("method", sa.String(length=80), nullable=False), sa.Column("query_fingerprint", sa.String(length=80), nullable=False), sa.Column("data_quality_status", sa.String(length=20), nullable=False), sa.Column("data_quality_score", sa.Numeric(5, 4), nullable=False), sa.Column( "quality_issues_json", sa.JSON(), nullable=False, server_default=_json_array_default(), ), sa.Column("algorithm_version", sa.String(length=80), nullable=False), sa.Column("policy_version", sa.String(length=120), nullable=True), sa.Column("policy_effective_from", sa.Date(), nullable=True), sa.Column("policy_effective_to", sa.Date(), nullable=True), sa.Column("target_resource_type", sa.String(length=50), nullable=True), sa.Column("target_resource_id", sa.String(length=160), nullable=True), sa.Column("frozen_at", sa.DateTime(timezone=True), nullable=False), sa.Column("frozen_by", sa.String(length=120), nullable=False), sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True), sa.Column("version", sa.Integer(), nullable=False, server_default="1"), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.CheckConstraint( "baseline_type IN ('historical_cohort', 'policy_counterfactual', 'manual')", name="ck_profile_baseline_snapshots_type", ), sa.CheckConstraint( "data_quality_status IN ('complete', 'partial', 'insufficient', 'invalid')", name="ck_profile_baseline_snapshots_quality_status", ), sa.CheckConstraint( "baseline_value >= 0 AND sample_count >= 0", name="ck_profile_baseline_snapshots_values", ), sa.CheckConstraint( "data_quality_score >= 0 AND data_quality_score <= 1", name="ck_profile_baseline_snapshots_quality_score", ), sa.CheckConstraint( "window_end IS NULL OR window_start IS NOT NULL", name="ck_profile_baseline_snapshots_window_pair", ), sa.CheckConstraint( "window_start IS NULL OR window_end IS NULL OR window_end >= window_start", name="ck_profile_baseline_snapshots_window_order", ), sa.CheckConstraint( "baseline_type != 'historical_cohort' OR " "(window_start IS NOT NULL AND window_end IS NOT NULL AND sample_count > 0)", name="ck_profile_baseline_snapshots_historical_shape", ), sa.CheckConstraint( "baseline_type != 'policy_counterfactual' OR " "(policy_version IS NOT NULL AND length(trim(policy_version)) > 0 " "AND policy_effective_from IS NOT NULL AND target_resource_type IS NOT NULL " "AND target_resource_id IS NOT NULL)", name="ck_profile_baseline_snapshots_policy_shape", ), sa.CheckConstraint( "policy_effective_to IS NULL OR policy_effective_from IS NOT NULL", name="ck_profile_baseline_snapshots_policy_pair", ), sa.CheckConstraint( "policy_effective_from IS NULL OR policy_effective_to IS NULL " "OR policy_effective_to >= policy_effective_from", name="ck_profile_baseline_snapshots_policy_order", ), sa.CheckConstraint( "valid_until IS NULL OR valid_until >= frozen_at", name="ck_profile_baseline_snapshots_validity", ), sa.CheckConstraint( "length(trim(baseline_key)) > 0 AND length(trim(query_fingerprint)) > 0", name="ck_profile_baseline_snapshots_keys", ), sa.CheckConstraint("version >= 1", name="ck_profile_baseline_snapshots_version"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint( "tenant_id", "id", name="uq_profile_baseline_snapshots_tenant_id" ), sa.UniqueConstraint( "tenant_id", "baseline_key", name="uq_profile_baseline_snapshots_tenant_key", ), ) op.create_index( "ix_profile_baseline_snapshots_lookup", "profile_baseline_snapshots", [ "tenant_id", "baseline_type", "dimension_type", "dimension_id", "metric_key", "frozen_at", ], ) op.create_index( "ix_profile_baseline_snapshots_quality", "profile_baseline_snapshots", ["tenant_id", "data_quality_status", "frozen_at"], ) op.create_table( "savings_opportunities", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("tenant_id", sa.String(length=64), nullable=False), sa.Column("opportunity_key", sa.String(length=180), nullable=False), sa.Column("benefit_key", sa.String(length=180), nullable=False), sa.Column("expense_case_id", sa.String(length=36), nullable=False), # expense_claims/items 仍由 legacy bootstrap 管理,仅保存已校验软引用。 sa.Column("claim_id", sa.String(length=36), nullable=False), sa.Column("claim_no_snapshot", sa.String(length=80), nullable=False), sa.Column("claim_item_id", sa.String(length=36), nullable=True), sa.Column("discovery_business_event_id", sa.String(length=36), nullable=True), sa.Column("source_type", sa.String(length=50), nullable=False), sa.Column("source_id", sa.String(length=160), nullable=False), sa.Column("category", sa.String(length=60), nullable=False), sa.Column("value_kind", sa.String(length=20), nullable=False), sa.Column("title", sa.String(length=200), nullable=False), sa.Column("description", sa.Text(), nullable=False), sa.Column("exposure_amount", sa.Numeric(20, 4), nullable=False), sa.Column("baseline_snapshot_id", sa.String(length=36), nullable=False), sa.Column("baseline_amount", sa.Numeric(20, 4), nullable=False), sa.Column("target_amount", sa.Numeric(20, 4), nullable=False), sa.Column("estimated_gross", sa.Numeric(20, 4), nullable=False), sa.Column("estimated_cost", sa.Numeric(20, 4), nullable=False), sa.Column("estimated_net", sa.Numeric(20, 4), nullable=False), sa.Column("estimated_low", sa.Numeric(20, 4), nullable=False), sa.Column("estimated_high", sa.Numeric(20, 4), nullable=False), sa.Column("confidence", sa.Numeric(5, 4), nullable=False), sa.Column("currency", sa.String(length=3), nullable=False), sa.Column("reporting_currency", sa.String(length=3), nullable=False), sa.Column("attribution_method", sa.String(length=60), nullable=False), sa.Column("ai_decision_id", sa.String(length=36), nullable=True), sa.Column("suggested_action", sa.Text(), nullable=False), sa.Column("owner_id", sa.String(length=120), nullable=False), sa.Column("owner_name", sa.String(length=120), nullable=False), sa.Column("owner_role", sa.String(length=60), nullable=False), sa.Column("due_at", sa.DateTime(timezone=True), nullable=True), sa.Column("status", sa.String(length=24), nullable=False, server_default="identified"), sa.Column("version", sa.Integer(), nullable=False, server_default="1"), sa.Column( "dimension_json", sa.JSON(), nullable=False, server_default=_json_object_default() ), sa.Column( "baseline_snapshot_json", sa.JSON(), nullable=False, server_default=_json_object_default(), ), sa.Column( "evidence_json", sa.JSON(), nullable=False, server_default=_json_array_default() ), sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True), sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), sa.Column("realized_at", sa.DateTime(timezone=True), nullable=True), sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True), sa.Column("closed_at", sa.DateTime(timezone=True), 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( "value_kind IN ('cash', 'labor')", name="ck_savings_opportunities_value_kind", ), sa.CheckConstraint( "status IN ('identified', 'accepted', 'in_progress', 'realized', " "'verified', 'reversed', 'rejected', 'expired')", name="ck_savings_opportunities_status", ), sa.CheckConstraint( "exposure_amount >= 0 AND baseline_amount >= 0 AND target_amount >= 0 " "AND estimated_gross >= 0 AND estimated_cost >= 0 AND estimated_net >= 0 " "AND estimated_low >= 0 AND estimated_high >= 0", name="ck_savings_opportunities_amounts", ), sa.CheckConstraint( "estimated_net = estimated_gross - estimated_cost", name="ck_savings_opportunities_net_math", ), sa.CheckConstraint( "estimated_low <= estimated_net AND estimated_net <= estimated_high", name="ck_savings_opportunities_interval", ), sa.CheckConstraint( "confidence >= 0 AND confidence <= 1", name="ck_savings_opportunities_confidence", ), sa.CheckConstraint("version >= 1", name="ck_savings_opportunities_version"), sa.CheckConstraint( "length(trim(benefit_key)) > 0 AND length(trim(opportunity_key)) > 0", name="ck_savings_opportunities_keys", ), sa.CheckConstraint( "length(trim(currency)) = 3 AND length(trim(reporting_currency)) = 3", name="ck_savings_opportunities_currencies", ), sa.CheckConstraint( "status NOT IN ('accepted', 'in_progress', 'realized', 'verified', 'reversed') " "OR accepted_at IS NOT NULL", name="ck_savings_opportunities_acceptance", ), sa.CheckConstraint( "status NOT IN ('in_progress', 'realized', 'verified', 'reversed') " "OR started_at IS NOT NULL", name="ck_savings_opportunities_started", ), sa.CheckConstraint( "status NOT IN ('realized', 'verified', 'reversed') OR realized_at IS NOT NULL", name="ck_savings_opportunities_realized", ), sa.CheckConstraint( "status NOT IN ('verified', 'reversed') OR verified_at IS NOT NULL", name="ck_savings_opportunities_verified", ), sa.CheckConstraint( "status NOT IN ('verified', 'reversed', 'rejected', 'expired') " "OR closed_at IS NOT NULL", name="ck_savings_opportunities_closed", ), sa.ForeignKeyConstraint( ["tenant_id", "expense_case_id"], ["expense_cases.tenant_id", "expense_cases.id"], name="fk_savings_opportunities_tenant_case", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["tenant_id", "expense_case_id", "discovery_business_event_id"], [ "business_events.tenant_id", "business_events.expense_case_id", "business_events.id", ], name="fk_savings_opportunities_tenant_event", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["tenant_id", "baseline_snapshot_id"], ["profile_baseline_snapshots.tenant_id", "profile_baseline_snapshots.id"], name="fk_savings_opportunities_tenant_baseline", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["tenant_id", "expense_case_id", "ai_decision_id"], ["ai_decisions.tenant_id", "ai_decisions.expense_case_id", "ai_decisions.id"], name="fk_savings_opportunities_tenant_ai_decision", ondelete="RESTRICT", ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("tenant_id", "id", name="uq_savings_opportunities_tenant_id"), sa.UniqueConstraint( "tenant_id", "opportunity_key", name="uq_savings_opportunities_tenant_key" ), ) op.create_index( "ix_savings_opportunities_tenant_status_due", "savings_opportunities", ["tenant_id", "status", "due_at"], ) op.create_index( "ix_savings_opportunities_tenant_case", "savings_opportunities", ["tenant_id", "expense_case_id", "created_at"], ) op.create_index( "ix_savings_opportunities_tenant_benefit", "savings_opportunities", ["tenant_id", "benefit_key"], ) op.create_index( "ix_savings_opportunities_tenant_owner", "savings_opportunities", ["tenant_id", "owner_id", "status"], ) op.create_table( "savings_realizations", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("tenant_id", sa.String(length=64), nullable=False), sa.Column("realization_key", sa.String(length=180), nullable=False), sa.Column("opportunity_id", sa.String(length=36), nullable=False), sa.Column("expense_case_id", sa.String(length=36), nullable=False), sa.Column("claim_id", sa.String(length=36), nullable=False), sa.Column("claim_item_id", sa.String(length=36), nullable=True), sa.Column("business_event_id", sa.String(length=36), nullable=True), sa.Column("realization_type", sa.String(length=20), nullable=False), sa.Column("reversal_of_realization_id", sa.String(length=36), nullable=True), sa.Column("realized_at", sa.DateTime(timezone=True), nullable=False), sa.Column("recorded_by_id", sa.String(length=120), nullable=False), sa.Column("recorded_by_name", sa.String(length=120), nullable=False), sa.Column("actual_gross", sa.Numeric(20, 4), nullable=False), sa.Column("incremental_cost", sa.Numeric(20, 4), nullable=False), sa.Column("actual_net", sa.Numeric(20, 4), nullable=False), sa.Column("original_currency", sa.String(length=3), nullable=False), sa.Column("reporting_amount", sa.Numeric(20, 4), nullable=False), sa.Column("reporting_currency", sa.String(length=3), nullable=False), sa.Column("fx_rate", sa.Numeric(20, 8), nullable=False), sa.Column("fx_source", sa.String(length=80), nullable=False), sa.Column("fx_date", sa.Date(), nullable=False), sa.Column("fx_version", sa.String(length=80), nullable=False), sa.Column("attribution_method", sa.String(length=60), nullable=False), sa.Column("attribution_ratio", sa.Numeric(7, 6), nullable=False), sa.Column("benefit_key", sa.String(length=180), nullable=False), sa.Column( "dedupe_status", sa.String(length=24), nullable=False, server_default="pending_review", ), sa.Column("canonical_realization_id", sa.String(length=36), nullable=True), sa.Column( "status", sa.String(length=24), nullable=False, server_default="pending_confirmation", ), sa.Column("finance_confirmer_id", sa.String(length=120), nullable=True), sa.Column("finance_confirmer_name", sa.String(length=120), nullable=True), sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True), sa.Column("confirmation_note", sa.Text(), nullable=True), sa.Column("rejected_by_id", sa.String(length=120), nullable=True), sa.Column("rejected_by_name", sa.String(length=120), nullable=True), sa.Column("rejected_at", sa.DateTime(timezone=True), nullable=True), sa.Column("rejection_reason", sa.Text(), nullable=True), sa.Column("reversed_by_id", sa.String(length=120), nullable=True), sa.Column("reversed_by_name", sa.String(length=120), nullable=True), sa.Column("reversed_at", sa.DateTime(timezone=True), nullable=True), sa.Column("reversal_reason", sa.Text(), nullable=True), sa.Column( "baseline_snapshot_json", sa.JSON(), nullable=False, server_default=_json_object_default(), ), sa.Column( "final_snapshot_json", sa.JSON(), nullable=False, server_default=_json_object_default(), ), sa.Column( "evidence_json", sa.JSON(), nullable=False, server_default=_json_array_default() ), sa.Column("version", sa.Integer(), nullable=False, server_default="1"), 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( "realization_type IN ('actual', 'reversal')", name="ck_savings_realizations_type", ), sa.CheckConstraint( "dedupe_status IN ('pending_review', 'canonical', 'duplicate', 'excluded')", name="ck_savings_realizations_dedupe_status", ), sa.CheckConstraint( "status IN ('pending_confirmation', 'finance_confirmed', 'rejected', 'reversed')", name="ck_savings_realizations_status", ), sa.CheckConstraint( "attribution_ratio > 0 AND attribution_ratio <= 1", name="ck_savings_realizations_attribution", ), sa.CheckConstraint( "incremental_cost >= 0 AND fx_rate > 0", name="ck_savings_realizations_cost_fx", ), sa.CheckConstraint( "actual_net = actual_gross - incremental_cost", name="ck_savings_realizations_net_math", ), sa.CheckConstraint( "(realization_type = 'actual' AND reversal_of_realization_id IS NULL " "AND actual_gross >= 0 AND actual_net >= 0 AND reporting_amount >= 0) OR " "(realization_type = 'reversal' AND reversal_of_realization_id IS NOT NULL " "AND actual_gross <= 0 AND actual_net <= 0 AND reporting_amount <= 0)", name="ck_savings_realizations_amount_direction", ), sa.CheckConstraint( "(dedupe_status = 'duplicate' AND canonical_realization_id IS NOT NULL " "AND canonical_realization_id <> id) OR " "(dedupe_status != 'duplicate' AND canonical_realization_id IS NULL)", name="ck_savings_realizations_duplicate_target", ), sa.CheckConstraint( "status != 'finance_confirmed' OR " "(finance_confirmer_id IS NOT NULL AND finance_confirmer_name IS NOT NULL " "AND confirmed_at IS NOT NULL AND confirmation_note IS NOT NULL " "AND (realization_type = 'reversal' OR finance_confirmer_id <> recorded_by_id) " "AND dedupe_status = 'canonical')", name="ck_savings_realizations_confirmation", ), sa.CheckConstraint( "status != 'rejected' OR (rejected_by_id IS NOT NULL " "AND rejected_by_name IS NOT NULL AND rejected_at IS NOT NULL " "AND rejection_reason IS NOT NULL)", name="ck_savings_realizations_rejection", ), sa.CheckConstraint( "status != 'reversed' OR (reversed_by_id IS NOT NULL " "AND reversed_by_name IS NOT NULL AND reversed_at IS NOT NULL " "AND reversal_reason IS NOT NULL)", name="ck_savings_realizations_reversal", ), sa.CheckConstraint("version >= 1", name="ck_savings_realizations_version"), sa.CheckConstraint( "length(trim(realization_key)) > 0 AND length(trim(benefit_key)) > 0", name="ck_savings_realizations_keys", ), sa.CheckConstraint( "length(trim(original_currency)) = 3 " "AND length(trim(reporting_currency)) = 3", name="ck_savings_realizations_currencies", ), sa.ForeignKeyConstraint( ["tenant_id", "opportunity_id"], ["savings_opportunities.tenant_id", "savings_opportunities.id"], name="fk_savings_realizations_tenant_opportunity", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["tenant_id", "expense_case_id"], ["expense_cases.tenant_id", "expense_cases.id"], name="fk_savings_realizations_tenant_case", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["tenant_id", "expense_case_id", "business_event_id"], [ "business_events.tenant_id", "business_events.expense_case_id", "business_events.id", ], name="fk_savings_realizations_tenant_event", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( [ "tenant_id", "opportunity_id", "benefit_key", "reversal_of_realization_id" ], [ "savings_realizations.tenant_id", "savings_realizations.opportunity_id", "savings_realizations.benefit_key", "savings_realizations.id", ], name="fk_savings_realizations_tenant_reversal", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["tenant_id", "benefit_key", "canonical_realization_id"], [ "savings_realizations.tenant_id", "savings_realizations.benefit_key", "savings_realizations.id", ], name="fk_savings_realizations_tenant_canonical", ondelete="RESTRICT", ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("tenant_id", "id", name="uq_savings_realizations_tenant_id"), sa.UniqueConstraint( "tenant_id", "realization_key", name="uq_savings_realizations_tenant_key" ), sa.UniqueConstraint( "tenant_id", "opportunity_id", "benefit_key", "id", name="uq_savings_realizations_tenant_opportunity_benefit_id", ), sa.UniqueConstraint( "tenant_id", "benefit_key", "id", name="uq_savings_realizations_tenant_benefit_id", ), ) op.create_index( "uq_savings_realizations_actual_canonical_benefit", "savings_realizations", ["tenant_id", "benefit_key"], unique=True, postgresql_where=sa.text( "realization_type = 'actual' AND dedupe_status = 'canonical'" ), ) op.create_index( "ix_savings_realizations_tenant_status_time", "savings_realizations", ["tenant_id", "status", "realized_at"], ) op.create_index( "ix_savings_realizations_tenant_opportunity", "savings_realizations", ["tenant_id", "opportunity_id", "realized_at"], ) op.create_index( "ix_savings_realizations_tenant_benefit", "savings_realizations", ["tenant_id", "benefit_key", "dedupe_status"], ) op.create_table( "savings_evidence_links", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("tenant_id", sa.String(length=64), nullable=False), sa.Column("evidence_key", sa.String(length=180), nullable=False), sa.Column("entity_type", sa.String(length=20), nullable=False), sa.Column("entity_id", sa.String(length=36), nullable=False), sa.Column("baseline_snapshot_id", sa.String(length=36), nullable=True), sa.Column("opportunity_id", sa.String(length=36), nullable=True), sa.Column("realization_id", sa.String(length=36), nullable=True), sa.Column("evidence_role", sa.String(length=50), nullable=False), sa.Column("resource_type", sa.String(length=50), nullable=False), sa.Column("resource_id", sa.String(length=160), nullable=False), sa.Column("source_system", sa.String(length=80), nullable=False), sa.Column("external_event_id", sa.String(length=160), nullable=True), sa.Column("content_hash", sa.String(length=80), nullable=False), sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), sa.Column("collected_at", sa.DateTime(timezone=True), nullable=False), sa.Column( "verification_status", sa.String(length=20), nullable=False, server_default="unverified", ), sa.Column("verified_by", sa.String(length=120), nullable=True), sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True), sa.Column( "metadata_json", sa.JSON(), nullable=False, server_default=_json_object_default() ), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() ), sa.CheckConstraint( "entity_type IN ('baseline', 'opportunity', 'realization')", name="ck_savings_evidence_links_entity_type", ), sa.CheckConstraint( "(entity_type = 'baseline' AND baseline_snapshot_id = entity_id " "AND opportunity_id IS NULL AND realization_id IS NULL) OR " "(entity_type = 'opportunity' AND opportunity_id = entity_id " "AND baseline_snapshot_id IS NULL AND realization_id IS NULL) OR " "(entity_type = 'realization' AND realization_id = entity_id " "AND baseline_snapshot_id IS NULL AND opportunity_id IS NULL)", name="ck_savings_evidence_links_entity_shape", ), sa.CheckConstraint( "verification_status IN ('unverified', 'verified', 'rejected', 'unavailable')", name="ck_savings_evidence_links_verification", ), sa.CheckConstraint( "verification_status != 'verified' OR " "(verified_by IS NOT NULL AND verified_at IS NOT NULL)", name="ck_savings_evidence_links_verifier", ), sa.CheckConstraint( "length(trim(evidence_key)) > 0 AND length(trim(content_hash)) > 0", name="ck_savings_evidence_links_keys", ), sa.ForeignKeyConstraint( ["tenant_id", "baseline_snapshot_id"], ["profile_baseline_snapshots.tenant_id", "profile_baseline_snapshots.id"], name="fk_savings_evidence_links_tenant_baseline", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["tenant_id", "opportunity_id"], ["savings_opportunities.tenant_id", "savings_opportunities.id"], name="fk_savings_evidence_links_tenant_opportunity", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["tenant_id", "realization_id"], ["savings_realizations.tenant_id", "savings_realizations.id"], name="fk_savings_evidence_links_tenant_realization", ondelete="RESTRICT", ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("tenant_id", "id", name="uq_savings_evidence_links_tenant_id"), sa.UniqueConstraint( "tenant_id", "evidence_key", name="uq_savings_evidence_links_tenant_key" ), ) op.create_index( "ix_savings_evidence_links_entity", "savings_evidence_links", ["tenant_id", "entity_type", "entity_id", "collected_at"], ) op.create_index( "ix_savings_evidence_links_resource", "savings_evidence_links", ["tenant_id", "resource_type", "resource_id"], ) op.create_table( "savings_events", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("tenant_id", sa.String(length=64), nullable=False), sa.Column("aggregate_type", sa.String(length=20), nullable=False), sa.Column("aggregate_id", sa.String(length=36), nullable=False), sa.Column("baseline_snapshot_id", sa.String(length=36), nullable=True), sa.Column("opportunity_id", sa.String(length=36), nullable=True), sa.Column("realization_id", sa.String(length=36), nullable=True), sa.Column("action", sa.String(length=60), nullable=False), sa.Column("actor_id", sa.String(length=120), nullable=False), sa.Column("actor_name", sa.String(length=120), nullable=False), sa.Column("actor_type", sa.String(length=20), nullable=False), sa.Column("request_id", sa.String(length=120), nullable=False), sa.Column("expected_version", sa.Integer(), nullable=False), sa.Column("result_version", sa.Integer(), nullable=False), sa.Column("payload_fingerprint", sa.String(length=80), nullable=False), sa.Column( "payload_json", sa.JSON(), nullable=False, server_default=_json_object_default() ), sa.Column("before_json", sa.JSON(), nullable=False, server_default=_json_object_default()), sa.Column("after_json", sa.JSON(), nullable=False, server_default=_json_object_default()), sa.Column( "response_json", sa.JSON(), nullable=False, server_default=_json_object_default() ), sa.Column("correlation_id", sa.String(length=64), nullable=True), sa.Column("causation_id", sa.String(length=64), nullable=True), sa.Column( "occurred_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() ), sa.CheckConstraint( "aggregate_type IN ('baseline', 'opportunity', 'realization')", name="ck_savings_events_aggregate_type", ), sa.CheckConstraint( "(aggregate_type = 'baseline' AND baseline_snapshot_id = aggregate_id " "AND opportunity_id IS NULL AND realization_id IS NULL) OR " "(aggregate_type = 'opportunity' AND opportunity_id = aggregate_id " "AND baseline_snapshot_id IS NULL AND realization_id IS NULL) OR " "(aggregate_type = 'realization' AND realization_id = aggregate_id " "AND baseline_snapshot_id IS NULL AND opportunity_id IS NULL)", name="ck_savings_events_aggregate_shape", ), sa.CheckConstraint("length(trim(action)) > 0", name="ck_savings_events_action"), sa.CheckConstraint( "actor_type IN ('user', 'system', 'agent', 'service')", name="ck_savings_events_actor_type", ), sa.CheckConstraint( "expected_version >= 0 AND result_version >= 1 " "AND result_version >= expected_version", name="ck_savings_events_version", ), sa.CheckConstraint( "length(trim(request_id)) > 0 AND length(trim(payload_fingerprint)) > 0", name="ck_savings_events_request", ), sa.ForeignKeyConstraint( ["tenant_id", "baseline_snapshot_id"], ["profile_baseline_snapshots.tenant_id", "profile_baseline_snapshots.id"], name="fk_savings_events_tenant_baseline", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["tenant_id", "opportunity_id"], ["savings_opportunities.tenant_id", "savings_opportunities.id"], name="fk_savings_events_tenant_opportunity", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["tenant_id", "realization_id"], ["savings_realizations.tenant_id", "savings_realizations.id"], name="fk_savings_events_tenant_realization", ondelete="RESTRICT", ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("tenant_id", "id", name="uq_savings_events_tenant_id"), sa.UniqueConstraint( "tenant_id", "actor_id", "request_id", name="uq_savings_events_actor_request" ), sa.UniqueConstraint( "tenant_id", "aggregate_type", "aggregate_id", "result_version", name="uq_savings_events_aggregate_version", ), ) op.create_index( "ix_savings_events_tenant_aggregate_time", "savings_events", ["tenant_id", "aggregate_type", "aggregate_id", "occurred_at"], ) op.create_index( "ix_savings_events_tenant_correlation", "savings_events", ["tenant_id", "correlation_id", "occurred_at"], ) op.execute( """ CREATE OR REPLACE FUNCTION prevent_savings_events_mutation() RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'savings_events are append-only'; END; $$ LANGUAGE plpgsql """ ) op.execute( """ CREATE TRIGGER trg_savings_events_append_only BEFORE UPDATE OR DELETE ON savings_events FOR EACH ROW EXECUTE FUNCTION prevent_savings_events_mutation() """ ) def downgrade() -> None: _require_postgresql() _require_empty_ledger_for_downgrade() op.execute("DROP TRIGGER IF EXISTS trg_savings_events_append_only ON savings_events") op.execute("DROP FUNCTION IF EXISTS prevent_savings_events_mutation()") op.drop_table("savings_events") op.drop_table("savings_evidence_links") op.drop_table("savings_realizations") op.drop_table("savings_opportunities") op.drop_table("profile_baseline_snapshots")