priv/repo/migrations/20260816224054_govern_voice_context_tools_and_chronology.exs

58e6347eeb72 · 17 KB

defmodule OpenAgents.Repo.Migrations.GovernVoiceContextToolsAndChronology do
  use Ecto.Migration

  def up do
    create constraint(:messages, :messages_modality_check, check: "modality IN ('text', 'voice')")

    create constraint(:messages, :messages_voice_provenance_check,
             check:
               "(modality = 'text' AND voice_session_id IS NULL AND provider_item_id IS NULL AND transcript_kind IS NULL AND interrupted = false) OR " <>
                 "(modality = 'voice' AND voice_session_id IS NOT NULL AND provider_item_id IS NOT NULL AND transcript_kind IN ('provider_input_transcription', 'provider_output_transcript'))"
           )

    execute("""
    CREATE FUNCTION enforce_voice_message_transition() RETURNS trigger AS $$
    BEGIN
      IF OLD.modality = 'voice' THEN
        IF ROW(
          OLD.conversation_id, OLD.role, OLD.content, OLD.modality,
          OLD.voice_session_id, OLD.provider_item_id, OLD.transcript_kind
        ) IS DISTINCT FROM ROW(
          NEW.conversation_id, NEW.role, NEW.content, NEW.modality,
          NEW.voice_session_id, NEW.provider_item_id, NEW.transcript_kind
        ) THEN
          RAISE EXCEPTION 'voice message evidence is immutable';
        END IF;

        IF OLD.status IN ('complete', 'failed', 'cancelled') AND
           ROW(OLD.status, OLD.interrupted) IS DISTINCT FROM
           ROW(NEW.status, NEW.interrupted) THEN
          RAISE EXCEPTION 'terminal voice message is immutable';
        END IF;

        IF OLD.interrupted = true AND NEW.interrupted = false THEN
          RAISE EXCEPTION 'voice interruption is irreversible';
        END IF;
      END IF;
      RETURN NEW;
    END;
    $$ LANGUAGE plpgsql;
    """)

    execute("""
    CREATE TRIGGER messages_enforce_voice_transition
    BEFORE UPDATE ON messages
    FOR EACH ROW EXECUTE FUNCTION enforce_voice_message_transition();
    """)

    alter table(:voice_sessions) do
      add :instructions, :text, null: false, default: ""

      add :tool_catalog, :map,
        null: false,
        default: %{"schema" => "sarah.realtime_tool_catalog.v1", "tools" => []}

      add :blueprint_revision, :string
      add :blueprint_digest, :string
      add :program_artifact_id, :string
      add :program_artifact_digest, :string
      add :program_artifact_receipt, :map
    end

    create constraint(:voice_sessions, :voice_sessions_context_digest_check,
             check:
               "(blueprint_digest IS NULL OR blueprint_digest ~ '^[0-9a-f]{64}$') AND " <>
                 "(program_artifact_digest IS NULL OR program_artifact_digest ~ '^[0-9a-f]{64}$') AND " <>
                 "octet_length(instructions) <= 65536 AND octet_length(tool_catalog::text) <= 65536"
           )

    create constraint(:voice_sessions, :voice_sessions_tool_catalog_check,
             check:
               "jsonb_typeof(tool_catalog) = 'object' AND " <>
                 "tool_catalog->>'schema' = 'sarah.realtime_tool_catalog.v1' AND " <>
                 "jsonb_typeof(tool_catalog->'tools') = 'array'"
           )

    create constraint(:voice_sessions, :voice_sessions_program_capture_check,
             check:
               "program_artifact_receipt IS NULL OR (" <>
                 "jsonb_typeof(program_artifact_receipt) = 'object' AND " <>
                 "program_artifact_receipt->>'schema' = 'sarah.program_capture.v1' AND " <>
                 "program_artifact_receipt->>'artifact_id' IS NOT DISTINCT FROM program_artifact_id AND " <>
                 "program_artifact_receipt->>'artifact_digest' IS NOT DISTINCT FROM program_artifact_digest AND " <>
                 "jsonb_typeof(program_artifact_receipt->'degraded') = 'boolean' AND " <>
                 "octet_length(program_artifact_receipt::text) <= 4096)"
           )

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

      add :voice_session_id,
          references(:voice_sessions, type: :binary_id, on_delete: :delete_all),
          null: false

      add :generation, :integer, null: false

      add :user_message_id,
          references(:messages, type: :binary_id, on_delete: :restrict),
          null: false

      add :provider_input_item_id, :string, null: false
      add :instructions, :text, null: false
      add :instruction_digest, :string, null: false
      add :memory_snapshot_ref, :string, null: false
      add :profile_memory_snapshot_ref, :string, null: false
      add :selected_evidence, :map, null: false
      add :selected_source_refs, {:array, :string}, null: false, default: []
      add :program_artifact_id, :string
      add :program_artifact_digest, :string
      add :program_artifact_receipt, :map, null: false
      add :captured_at, :utc_datetime_usec, null: false
      timestamps(type: :utc_datetime_usec, updated_at: false)
    end

    create unique_index(
             :voice_response_contexts,
             [:voice_session_id, :generation, :provider_input_item_id],
             name: :voice_response_context_input_item_index
           )

    create unique_index(:voice_response_contexts, [:user_message_id])

    create constraint(:voice_response_contexts, :voice_response_contexts_digest_check,
             check:
               "instruction_digest ~ '^[0-9a-f]{64}$' AND " <>
                 "(program_artifact_digest IS NULL OR program_artifact_digest ~ '^[0-9a-f]{64}$')"
           )

    create constraint(:voice_response_contexts, :voice_response_contexts_snapshot_check,
             check:
               "memory_snapshot_ref ~ '^message:[0-9a-f-]{36}$' AND " <>
                 "profile_memory_snapshot_ref ~ '^profile-memory-snapshot:v1:[0-9a-f-]{36}$'"
           )

    create constraint(:voice_response_contexts, :voice_response_contexts_payload_check,
             check:
               "octet_length(instructions) BETWEEN 1 AND 65536 AND " <>
                 "octet_length(selected_evidence::text) <= 65536 AND " <>
                 "octet_length(program_artifact_receipt::text) <= 4096"
           )

    alter table(:voice_response_receipts) do
      add :response_context_id,
          references(:voice_response_contexts, type: :binary_id, on_delete: :restrict)

      add :assistant_message_id, references(:messages, type: :binary_id, on_delete: :restrict)
      add :used_source_refs, {:array, :string}, null: false, default: []
      add :used_tool_step_refs, {:array, :string}, null: false, default: []

      add :used_memory_evidence, :map,
        null: false,
        default: %{"schema" => "sarah.memory_evidence_usage.v1", "items" => []}
    end

    alter table(:voice_transcript_items) do
      add :message_id, references(:messages, type: :binary_id, on_delete: :restrict)
    end

    create unique_index(:voice_transcript_items, [:message_id], where: "message_id IS NOT NULL")

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

      add :voice_session_id,
          references(:voice_sessions, type: :binary_id, on_delete: :delete_all),
          null: false

      add :voice_response_receipt_id,
          references(:voice_response_receipts, type: :binary_id, on_delete: :delete_all),
          null: false

      add :generation, :integer, null: false
      add :sequence, :integer, null: false
      add :provider_call_id, :string, null: false
      add :provider_item_id, :string, null: false
      add :provider_response_id, :string, null: false
      add :tool_name, :string, null: false
      add :tool_version, :integer, null: false
      add :module_id, :string, null: false
      add :catalog_digest, :string, null: false
      add :argument_digest, :string, null: false
      add :status, :string, null: false, default: "requested"
      add :outcome_digest, :string
      add :result, :map
      add :error, :map
      add :executor_id, :string
      add :executor_disclosure, :string
      add :target_receipt_refs, {:array, :string}, null: false, default: []
      add :attribution_refs, {:array, :string}, null: false, default: []
      add :requested_at, :utc_datetime_usec, null: false
      add :started_at, :utc_datetime_usec
      add :completed_at, :utc_datetime_usec
      timestamps(type: :utc_datetime_usec)
    end

    create unique_index(:voice_tool_steps, [:voice_session_id, :generation, :sequence],
             name: :voice_tool_steps_sequence_index
           )

    create unique_index(:voice_tool_steps, [:voice_session_id, :generation, :provider_call_id],
             name: :voice_tool_steps_provider_call_index
           )

    create constraint(:voice_tool_steps, :voice_tool_steps_sequence_check,
             check: "sequence > 0 AND sequence <= 32"
           )

    create constraint(:voice_tool_steps, :voice_tool_steps_status_check,
             check:
               "status IN ('requested', 'running', 'succeeded', 'failed', 'refused', 'cancelled', 'unavailable', 'interrupted')"
           )

    create constraint(:voice_tool_steps, :voice_tool_steps_digest_check,
             check:
               "catalog_digest ~ '^[0-9a-f]{64}$' AND argument_digest ~ '^[0-9a-f]{64}$' AND (outcome_digest IS NULL OR outcome_digest ~ '^[0-9a-f]{64}$')"
           )

    create constraint(:voice_tool_steps, :voice_tool_steps_lifecycle_shape_check,
             check:
               "(status = 'requested' AND started_at IS NULL AND completed_at IS NULL AND outcome_digest IS NULL AND result IS NULL AND error IS NULL) OR " <>
                 "(status = 'running' AND started_at IS NOT NULL AND completed_at IS NULL AND outcome_digest IS NULL AND result IS NULL AND error IS NULL) OR " <>
                 "(status = 'succeeded' AND completed_at IS NOT NULL AND outcome_digest IS NOT NULL AND result IS NOT NULL AND error IS NULL AND executor_id IS NOT NULL AND executor_disclosure IS NOT NULL) OR " <>
                 "(status IN ('failed', 'refused', 'cancelled', 'unavailable', 'interrupted') AND completed_at IS NOT NULL AND outcome_digest IS NOT NULL AND result IS NULL AND error IS NOT NULL AND executor_id IS NOT NULL AND executor_disclosure IS NOT NULL)"
           )

    execute("""
    CREATE OR REPLACE FUNCTION enforce_voice_session_identity_immutable() RETURNS trigger AS $$
    BEGIN
      IF NEW.conversation_id IS DISTINCT FROM OLD.conversation_id
         OR NEW.generation IS DISTINCT FROM OLD.generation
         OR NEW.architecture IS DISTINCT FROM OLD.architecture
         OR NEW.provider_id IS DISTINCT FROM OLD.provider_id
         OR NEW.model_id IS DISTINCT FROM OLD.model_id
         OR NEW.voice_artifact_id IS DISTINCT FROM OLD.voice_artifact_id
         OR NEW.persona_id IS DISTINCT FROM OLD.persona_id
         OR NEW.persona_digest IS DISTINCT FROM OLD.persona_digest
         OR NEW.role_id IS DISTINCT FROM OLD.role_id
         OR NEW.role_digest IS DISTINCT FROM OLD.role_digest
         OR NEW.role_selection IS DISTINCT FROM OLD.role_selection
         OR NEW.instruction_digest IS DISTINCT FROM OLD.instruction_digest
         OR NEW.instructions IS DISTINCT FROM OLD.instructions
         OR NEW.tool_catalog_digest IS DISTINCT FROM OLD.tool_catalog_digest
         OR NEW.tool_catalog IS DISTINCT FROM OLD.tool_catalog
         OR NEW.blueprint_revision IS DISTINCT FROM OLD.blueprint_revision
         OR NEW.blueprint_digest IS DISTINCT FROM OLD.blueprint_digest
         OR NEW.program_artifact_id IS DISTINCT FROM OLD.program_artifact_id
         OR NEW.program_artifact_digest IS DISTINCT FROM OLD.program_artifact_digest
         OR NEW.program_artifact_receipt IS DISTINCT FROM OLD.program_artifact_receipt
         OR (OLD.provider_session_id IS NOT NULL AND
             NEW.provider_session_id IS DISTINCT FROM OLD.provider_session_id)
         OR NEW.started_at IS DISTINCT FROM OLD.started_at THEN
        RAISE EXCEPTION 'voice session identity is immutable';
      END IF;
      RETURN NEW;
    END;
    $$ LANGUAGE plpgsql;
    """)

    execute("""
    CREATE TRIGGER voice_response_context_generation_matches
    BEFORE INSERT OR UPDATE ON voice_response_contexts
    FOR EACH ROW EXECUTE FUNCTION enforce_voice_child_generation();
    """)

    execute("""
    CREATE TRIGGER voice_tool_step_generation_matches
    BEFORE INSERT OR UPDATE ON voice_tool_steps
    FOR EACH ROW EXECUTE FUNCTION enforce_voice_child_generation();
    """)

    execute("""
    CREATE FUNCTION enforce_voice_tool_step_transition()
    RETURNS trigger AS $$
    BEGIN
      IF ROW(
        OLD.voice_session_id, OLD.voice_response_receipt_id, OLD.generation,
        OLD.sequence, OLD.provider_call_id, OLD.provider_item_id,
        OLD.provider_response_id, OLD.tool_name, OLD.tool_version,
        OLD.module_id, OLD.catalog_digest, OLD.argument_digest, OLD.requested_at
      ) IS DISTINCT FROM ROW(
        NEW.voice_session_id, NEW.voice_response_receipt_id, NEW.generation,
        NEW.sequence, NEW.provider_call_id, NEW.provider_item_id,
        NEW.provider_response_id, NEW.tool_name, NEW.tool_version,
        NEW.module_id, NEW.catalog_digest, NEW.argument_digest, NEW.requested_at
      ) THEN
        RAISE EXCEPTION 'voice tool step identity is immutable';
      END IF;

      IF OLD.status = 'requested' AND NEW.status NOT IN (
        'requested', 'running', 'succeeded', 'failed', 'refused',
        'cancelled', 'unavailable', 'interrupted'
      ) THEN
        RAISE EXCEPTION 'invalid requested voice tool step transition';
      END IF;

      IF OLD.status = 'running' AND NEW.status NOT IN (
        'running', 'succeeded', 'failed', 'refused', 'cancelled',
        'unavailable', 'interrupted'
      ) THEN
        RAISE EXCEPTION 'invalid running voice tool step transition';
      END IF;

      IF OLD.status NOT IN ('requested', 'running') AND ROW(
        OLD.status, OLD.outcome_digest, OLD.result, OLD.error,
        OLD.executor_id, OLD.executor_disclosure, OLD.target_receipt_refs,
        OLD.attribution_refs, OLD.started_at, OLD.completed_at
      ) IS DISTINCT FROM ROW(
        NEW.status, NEW.outcome_digest, NEW.result, NEW.error,
        NEW.executor_id, NEW.executor_disclosure, OLD.target_receipt_refs,
        NEW.attribution_refs, NEW.started_at, NEW.completed_at
      ) THEN
        RAISE EXCEPTION 'terminal voice tool step is immutable';
      END IF;

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

    execute("""
    CREATE TRIGGER voice_tool_steps_enforce_transition
    BEFORE UPDATE ON voice_tool_steps
    FOR EACH ROW EXECUTE FUNCTION enforce_voice_tool_step_transition();
    """)
  end

  def down do
    execute("DROP TRIGGER IF EXISTS voice_tool_steps_enforce_transition ON voice_tool_steps")
    execute("DROP FUNCTION IF EXISTS enforce_voice_tool_step_transition()")
    execute("DROP TRIGGER IF EXISTS voice_tool_step_generation_matches ON voice_tool_steps")

    execute(
      "DROP TRIGGER IF EXISTS voice_response_context_generation_matches ON voice_response_contexts"
    )

    execute("DROP TRIGGER IF EXISTS messages_enforce_voice_transition ON messages")
    execute("DROP FUNCTION IF EXISTS enforce_voice_message_transition()")

    drop table(:voice_tool_steps)

    alter table(:voice_transcript_items) do
      remove :message_id
    end

    alter table(:voice_response_receipts) do
      remove :used_memory_evidence
      remove :used_tool_step_refs
      remove :used_source_refs
      remove :assistant_message_id
      remove :response_context_id
    end

    drop table(:voice_response_contexts)

    alter table(:voice_sessions) do
      remove :program_artifact_receipt
      remove :program_artifact_digest
      remove :program_artifact_id
      remove :blueprint_digest
      remove :blueprint_revision
      remove :tool_catalog
      remove :instructions
    end

    execute("""
    CREATE OR REPLACE FUNCTION enforce_voice_session_identity_immutable() RETURNS trigger AS $$
    BEGIN
      IF NEW.conversation_id IS DISTINCT FROM OLD.conversation_id
         OR NEW.generation IS DISTINCT FROM OLD.generation
         OR NEW.architecture IS DISTINCT FROM OLD.architecture
         OR NEW.provider_id IS DISTINCT FROM OLD.provider_id
         OR NEW.model_id IS DISTINCT FROM OLD.model_id
         OR NEW.voice_artifact_id IS DISTINCT FROM OLD.voice_artifact_id
         OR NEW.persona_id IS DISTINCT FROM OLD.persona_id
         OR NEW.persona_digest IS DISTINCT FROM OLD.persona_digest
         OR NEW.role_id IS DISTINCT FROM OLD.role_id
         OR NEW.role_digest IS DISTINCT FROM OLD.role_digest
         OR NEW.role_selection IS DISTINCT FROM OLD.role_selection
         OR NEW.instruction_digest IS DISTINCT FROM OLD.instruction_digest
         OR NEW.instructions IS DISTINCT FROM OLD.instructions
         OR NEW.tool_catalog_digest IS DISTINCT FROM OLD.tool_catalog_digest
         OR NEW.tool_catalog IS DISTINCT FROM OLD.tool_catalog
         OR NEW.blueprint_revision IS DISTINCT FROM OLD.blueprint_revision
         OR NEW.blueprint_digest IS DISTINCT FROM OLD.blueprint_digest
         OR NEW.program_artifact_id IS DISTINCT FROM OLD.program_artifact_id
         OR NEW.program_artifact_digest IS DISTINCT FROM OLD.program_artifact_digest
         OR NEW.program_artifact_receipt IS DISTINCT FROM OLD.program_artifact_receipt
         OR (OLD.provider_session_id IS NOT NULL AND
             NEW.provider_session_id IS DISTINCT FROM OLD.provider_session_id)
         OR NEW.started_at IS DISTINCT FROM OLD.started_at THEN
        RAISE EXCEPTION 'voice session identity is immutable';
      END IF;
      RETURN NEW;
    END;
    $$ LANGUAGE plpgsql;
    """)

    drop constraint(:messages, :messages_voice_provenance_check)
    drop constraint(:messages, :messages_modality_check)
  end
end