lib/openagents/migration_lineage.ex

58e6347eeb72 · 10 KB

defmodule OpenAgents.MigrationLineage do
  @moduledoc """
  Classifies and safely baselines the known nonempty database lineage.

  The baseline is an explicit compatibility bridge, not a general migration
  repair tool. It refuses unknown and partially modified lineages. Applying it
  requires a bounded snapshot reference and is admitted only in tests or in
  staging at Gate 13 or later.
  """

  alias Ecto.Adapters.SQL

  @lock_id 42_424_242
  @map_file "migration_lineages/prior-2026-08-19.json"
  @snapshot_pattern ~r/\A[a-z0-9][a-z0-9-]{7,127}\z/

  @type classification ::
          :empty | :current | :prior | :prior_baselined | :prior_partial | :unknown

  @doc "Returns a bounded classification without changing the database."
  @spec classify(module()) :: {:ok, map()} | {:error, atom()}
  def classify(repo) when is_atom(repo) do
    with :ok <- ensure_admitted() do
      {:ok, classify_repo(repo, load_map!())}
    end
  end

  @doc "Applies the known baseline bridge after a staging snapshot exists."
  @spec baseline(module(), String.t()) :: {:ok, map()} | {:error, atom()}
  def baseline(repo, snapshot_ref) when is_atom(repo) and is_binary(snapshot_ref) do
    with :ok <- ensure_admitted(),
         :ok <- validate_snapshot_ref(snapshot_ref) do
      map = load_map!()

      repo.transaction(
        fn ->
          _lock = SQL.query!(repo, "SELECT pg_advisory_xact_lock($1)", [@lock_id])
          before = classify_repo(repo, map)

          case before.classification do
            "prior" ->
              apply_bridge!(repo)
              insert_baseline_versions!(repo, baseline_versions(map))
              after_baseline = classify_repo(repo, map)

              if after_baseline.classification != "prior_baselined" do
                repo.rollback(:baseline_postcondition_failed)
              end

              result(after_baseline, snapshot_ref, true)

            "prior_baselined" ->
              result(before, snapshot_ref, false)

            "prior_partial" ->
              repo.rollback(:partial_lineage_forbidden)

            _other ->
              repo.rollback(:lineage_not_baselineable)
          end
        end,
        timeout: 120_000
      )
      |> normalize_transaction()
    end
  end

  def baseline(_repo, _snapshot_ref), do: {:error, :invalid_baseline_request}

  @doc "Returns one bounded JSON result for the release operator command."
  @spec command!(module(), String.t(), String.t() | nil) :: String.t()
  def command!(repo, "check", _snapshot_ref) do
    case classify(repo) do
      {:ok, result} -> encode_result("checked", result)
      {:error, reason} -> raise "migration lineage check refused: #{bounded_reason(reason)}"
    end
  end

  def command!(repo, "apply", snapshot_ref) do
    case baseline(repo, snapshot_ref) do
      {:ok, result} -> encode_result("baselined", result)
      {:error, reason} -> raise "migration lineage baseline refused: #{bounded_reason(reason)}"
    end
  end

  def command!(_repo, _mode, _snapshot_ref),
    do: raise("migration lineage command mode is invalid")

  @doc "Loads the reviewed baseline map and its source digest."
  @spec map!() :: map()
  def map!, do: load_map!()

  defp classify_repo(repo, map) do
    versions = migration_versions(repo)
    baseline_versions = baseline_versions(map)
    prior? = subset?(map.data["prior_signature_versions"], versions)
    current? = Enum.any?(map.data["current_root_versions"], &MapSet.member?(versions, &1))
    baseline_count = Enum.count(baseline_versions, &MapSet.member?(versions, &1))

    fact_counts =
      fact_counts(repo, map.data, if(baseline_count == 0, do: :before, else: :after))

    bridge = bridge_state(repo)
    application_table_count = application_table_count(repo)

    classification =
      cond do
        application_table_count == 0 ->
          :empty

        prior? and baseline_count == 0 and bridge == :absent and fact_counts.missing == 0 ->
          :prior

        prior? and baseline_count == length(baseline_versions) and bridge == :complete and
            fact_counts.missing == 0 ->
          :prior_baselined

        prior? and (baseline_count > 0 or bridge != :absent) ->
          :prior_partial

        current? and not prior? ->
          :current

        true ->
          :unknown
      end

    %{
      schema: "openagents.migration-lineage-status.v1",
      classification: Atom.to_string(classification),
      lineage_id: map.data["lineage_id"],
      map_digest: map.digest,
      migration_count: MapSet.size(versions),
      baseline_entries_present: baseline_count,
      baseline_entries_required: length(baseline_versions),
      required_facts_present: fact_counts.present,
      required_facts_missing: fact_counts.missing,
      bridge_state: Atom.to_string(bridge)
    }
  end

  defp fact_counts(repo, map, phase) do
    phase_indexes =
      if phase == :before,
        do: map["pre_baseline_required_indexes"],
        else: []

    facts =
      Enum.map(map["required_tables"], &table_exists?(repo, &1)) ++
        Enum.flat_map(map["required_columns"], fn {table, columns} ->
          Enum.map(columns, &column_exists?(repo, table, &1))
        end) ++
        Enum.map(map["required_column_types"], fn {qualified_column, type} ->
          [table, column] = String.split(qualified_column, ".", parts: 2)
          column_type?(repo, table, column, type)
        end) ++
        Enum.map(map["required_constraints"], &constraint_exists?(repo, &1)) ++
        Enum.map(map["required_indexes"], &index_exists?(repo, &1)) ++
        Enum.map(phase_indexes, &index_exists?(repo, &1)) ++
        Enum.map(map["forbidden_constraints"], &(not constraint_exists?(repo, &1))) ++
        Enum.map(map["forbidden_tables"], &(not table_exists?(repo, &1)))

    present = Enum.count(facts, & &1)
    %{present: present, missing: length(facts) - present}
  end

  defp bridge_state(repo) do
    column? = column_exists?(repo, "users", "browser_key_hash")
    index? = index_exists?(repo, "users_browser_key_hash_index")

    case {column?, index?} do
      {false, false} -> :absent
      {true, true} -> :complete
      _partial -> :partial
    end
  end

  defp application_table_count(repo) do
    %{rows: [[count]]} =
      SQL.query!(repo, """
      SELECT count(*)
        FROM pg_catalog.pg_tables
       WHERE schemaname = current_schema()
         AND tablename <> 'schema_migrations'
      """)

    count
  end

  defp migration_versions(repo) do
    if table_exists?(repo, "schema_migrations") do
      %{rows: rows} =
        SQL.query!(repo, "SELECT version FROM schema_migrations ORDER BY version", [])

      rows |> Enum.map(fn [version] -> version end) |> MapSet.new()
    else
      MapSet.new()
    end
  end

  defp table_exists?(repo, table) do
    exists?(
      repo,
      "SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1",
      [table]
    )
  end

  defp column_exists?(repo, table, column) do
    exists?(
      repo,
      "SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2",
      [table, column]
    )
  end

  defp column_type?(repo, table, column, type) do
    exists?(
      repo,
      "SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2 AND udt_name = $3",
      [table, column, type]
    )
  end

  defp constraint_exists?(repo, constraint) do
    exists?(
      repo,
      "SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema = current_schema() AND constraint_name = $1",
      [constraint]
    )
  end

  defp index_exists?(repo, index) do
    exists?(
      repo,
      "SELECT 1 FROM pg_catalog.pg_indexes WHERE schemaname = current_schema() AND indexname = $1",
      [index]
    )
  end

  defp exists?(repo, query, parameters) do
    case SQL.query!(repo, query, parameters) do
      %{num_rows: count} when count > 0 -> true
      _none -> false
    end
  end

  defp apply_bridge!(repo) do
    _column = SQL.query!(repo, "ALTER TABLE users ADD COLUMN browser_key_hash bytea", [])

    _index =
      SQL.query!(repo, """
      CREATE UNIQUE INDEX users_browser_key_hash_index
          ON users (browser_key_hash)
       WHERE browser_key_hash IS NOT NULL
      """)

    :ok
  end

  defp insert_baseline_versions!(repo, versions) do
    Enum.each(versions, fn version ->
      _result =
        SQL.query!(
          repo,
          "INSERT INTO schema_migrations (version, inserted_at) VALUES ($1, NOW()) ON CONFLICT (version) DO NOTHING",
          [version]
        )
    end)
  end

  defp baseline_versions(map) do
    Enum.map(map.data["baseline_entries"], & &1["current_version"])
  end

  defp subset?(required, actual) do
    Enum.all?(required, &MapSet.member?(actual, &1))
  end

  defp load_map! do
    path = :openagents |> :code.priv_dir() |> to_string() |> Path.join(@map_file)
    bytes = File.read!(path)
    data = Jason.decode!(bytes)

    if data["schema"] != "openagents.migration-lineage.v1" or
         data["lineage_id"] != "prior-2026-08-19" do
      raise "migration lineage map has an invalid identity"
    end

    %{data: data, digest: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower)}
  end

  defp ensure_admitted do
    environment = Application.get_env(:openagents, :runtime_environment)
    staging_gate = Application.get_env(:openagents, :staging_gate, 0)

    if environment == :test or (environment == :staging and staging_gate >= 13) do
      :ok
    else
      {:error, :migration_lineage_not_admitted}
    end
  end

  defp validate_snapshot_ref(snapshot_ref) do
    if Regex.match?(@snapshot_pattern, snapshot_ref),
      do: :ok,
      else: {:error, :invalid_snapshot_reference}
  end

  defp result(classification, snapshot_ref, changed?) do
    classification
    |> Map.put(:snapshot_ref, snapshot_ref)
    |> Map.put(:changed, changed?)
  end

  defp normalize_transaction({:ok, result}), do: {:ok, result}
  defp normalize_transaction({:error, reason}), do: {:error, reason}

  defp encode_result(status, result) do
    result
    |> Map.put(:status, status)
    |> Jason.encode!()
  end

  defp bounded_reason(reason) when is_atom(reason), do: Atom.to_string(reason)
  defp bounded_reason(_reason), do: "lineage_operation_failed"
end