priv/repo/migrations/20260817003500_create_collective_generalization_receipts.exs

58e6347eeb72 · 4 KB

defmodule Sarah.Repo.Migrations.CreateCollectiveGeneralizationReceipts do
  use Ecto.Migration

  def up do
    drop constraint(:collective_candidates, :collective_candidate_state_check)

    create constraint(:collective_candidates, :collective_candidate_state_check,
             check:
               "status IN ('consented','generalized','rejected','withdrawn','revocation_pending')"
           )

    execute("""
    CREATE OR REPLACE FUNCTION enforce_collective_candidate_transition()
    RETURNS trigger AS $$
    BEGIN
      IF ROW(
        OLD.visitor_id, OLD.consent_receipt_id, OLD.source_scope_digest,
        OLD.provenance_refs, OLD.redaction_policy_id, OLD.redaction_policy_version,
        OLD.redaction_policy_digest, OLD.generalized_kind
      ) IS DISTINCT FROM ROW(
        NEW.visitor_id, NEW.consent_receipt_id, NEW.source_scope_digest,
        NEW.provenance_refs, NEW.redaction_policy_id, NEW.redaction_policy_version,
        NEW.redaction_policy_digest, NEW.generalized_kind
      ) THEN
        RAISE EXCEPTION 'collective candidate private identity is immutable';
      END IF;

      IF OLD.status <> 'consented' AND ROW(OLD.generalized_payload, OLD.evaluator_ref)
        IS DISTINCT FROM ROW(NEW.generalized_payload, NEW.evaluator_ref) THEN
        RAISE EXCEPTION 'collective generalized payload is immutable';
      END IF;

      IF OLD.status IN ('withdrawn','revocation_pending') AND OLD.status IS DISTINCT FROM NEW.status THEN
        RAISE EXCEPTION 'withdrawn collective candidate is terminal';
      END IF;

      IF OLD.status = 'consented' AND NEW.status NOT IN ('consented','generalized','rejected','withdrawn','revocation_pending') THEN
        RAISE EXCEPTION 'invalid collective candidate transition';
      END IF;

      RETURN NEW;
    END;
    $$ LANGUAGE plpgsql;
    """)

    create table(:collective_generalization_receipts, primary_key: false) do
      add :id, :binary_id, primary_key: true

      add :candidate_id,
          references(:collective_candidates, type: :binary_id, on_delete: :delete_all),
          null: false

      add :visitor_id, references(:visitors, type: :binary_id, on_delete: :delete_all),
        null: false

      add :candidate_digest, :string, null: false
      add :source_digest, :string, null: false
      add :policy_id, :string, null: false
      add :policy_version, :integer, null: false
      add :policy_digest, :string, null: false
      add :generalizer_id, :string, null: false
      add :generalizer_version, :integer, null: false
      add :generalizer_digest, :string, null: false
      add :status, :string, null: false
      add :reason_codes, {:array, :string}, null: false, default: []
      add :risk, :string, null: false
      add :utility, :string, null: false
      add :support_signal, :string
      add :source_count, :integer, null: false
      add :output_digest, :string
      timestamps(type: :utc_datetime_usec, updated_at: false)
    end

    create unique_index(:collective_generalization_receipts, [:candidate_id])
    create index(:collective_generalization_receipts, [:visitor_id, :status])

    create constraint(
             :collective_generalization_receipts,
             :collective_generalization_receipt_shape_check,
             check:
               "(status = 'generalized' AND risk = 'low' AND utility = 'sufficient' AND support_signal IS NOT NULL AND output_digest ~ '^[0-9a-f]{64}$') OR (status = 'rejected' AND risk = 'high' AND utility = 'insufficient' AND support_signal IS NULL AND output_digest IS NULL)"
           )

    create constraint(
             :collective_generalization_receipts,
             :collective_generalization_receipt_digest_check,
             check:
               "candidate_digest ~ '^[0-9a-f]{64}$' AND source_digest ~ '^[0-9a-f]{64}$' AND policy_digest ~ '^[0-9a-f]{64}$' AND generalizer_digest ~ '^[0-9a-f]{64}$'"
           )

    execute("""
    CREATE FUNCTION reject_collective_generalization_receipt_mutation()
    RETURNS trigger AS $$
    BEGIN
      RAISE EXCEPTION 'collective generalization receipts are append-only';
    END;
    $$ LANGUAGE plpgsql;
    """)

    execute("""
    CREATE TRIGGER collective_generalization_receipts_append_only
    BEFORE UPDATE OR DELETE ON collective_generalization_receipts
    FOR EACH ROW EXECUTE FUNCTION reject_collective_generalization_receipt_mutation();
    """)
  end

  def down do
    execute(
      "DROP TRIGGER IF EXISTS collective_generalization_receipts_append_only ON collective_generalization_receipts"
    )

    execute("DROP FUNCTION IF EXISTS reject_collective_generalization_receipt_mutation()")
    drop table(:collective_generalization_receipts)
    drop constraint(:collective_candidates, :collective_candidate_state_check)

    create constraint(:collective_candidates, :collective_candidate_state_check,
             check: "status IN ('consented','withdrawn','revocation_pending')"
           )
  end
end