lib/openagents/do_not_build_register.ex

58e6347eeb72 · 5 KB

defmodule OpenAgents.DoNotBuildRegister do
  @moduledoc """
  Reads and screens proposals against the durable do-not-build register.

  Matching uses explicit multi-word phrases from the register. It does not
  expand keywords, stem words, or infer semantic similarity, so unrelated work
  is not blocked because it mentions a product name.
  """

  alias OpenAgents.Analytics

  @register_path "priv/api-contracts/do-not-build-v1.json"
  @decision_states ~w(retired deferred rejected superseded)

  @doc "The decoded and validated public register."
  def load do
    with {:ok, bytes} <- File.read(register_file()),
         {:ok, register} <- Jason.decode(bytes),
         :ok <- validate(register) do
      {:ok, register}
    end
  end

  @doc "The register entries, raising when the committed contract is invalid."
  def entries do
    {:ok, register} = load()
    register["entries"]
  end

  @doc "The first precise register match for a proposal, or nil."
  def match(proposal) do
    text = proposal_text(proposal)

    Enum.find(entries(), fn entry ->
      Enum.any?(entry["match_phrases"], &String.contains?(text, normalize(&1)))
    end)
  end

  @doc """
  Screens a FastFollow backlog proposal.

  A match is suppressed and recorded without proposal text. New evidence and
  an explicit decision record move the result to manual register review; they
  do not silently override the committed decision.
  """
  def screen_fast_follow(proposal, opts \\ []) when is_list(opts) do
    case match(proposal) do
      nil ->
        :allow

      entry ->
        if reconsideration_ready?(opts) do
          {:review_required, entry}
        else
          record_suppression(entry, proposal)
          {:suppressed, entry}
        end
    end
  end

  @doc "Validates the public contract and its append-only decision history."
  def validate(%{
        "contract" => "openagents.do-not-build.v1",
        "version" => 1,
        "decision_states" => @decision_states,
        "entries" => entries
      })
      when is_list(entries) do
    with :ok <- validate_unique_ids(entries),
         :ok <- validate_entries(entries) do
      :ok
    end
  end

  def validate(_register), do: {:error, :invalid_contract}

  defp validate_unique_ids(entries) do
    ids = Enum.map(entries, & &1["id"])

    if length(ids) == length(Enum.uniq(ids)),
      do: :ok,
      else: {:error, :duplicate_ids}
  end

  defp validate_entries(entries) do
    if Enum.all?(entries, &valid_entry?/1),
      do: :ok,
      else: {:error, :invalid_entry}
  end

  defp valid_entry?(%{
         "id" => id,
         "retired_scope" => scope,
         "match_phrases" => phrases,
         "current" => current,
         "history" => history
       })
       when is_binary(id) and is_binary(scope) and is_list(phrases) and is_map(current) and
              is_list(history) and history != [] do
    valid_phrases?(phrases) and valid_decision?(current) and List.last(history) == current and
      Enum.all?(history, &valid_decision?/1)
  end

  defp valid_entry?(_entry), do: false

  defp valid_phrases?(phrases) do
    phrases != [] and
      Enum.all?(phrases, fn phrase ->
        is_binary(phrase) and String.length(phrase) >= 8 and
          phrase |> String.split() |> length() >= 2
      end)
  end

  defp valid_decision?(%{
         "state" => state,
         "decision_date" => date,
         "evidence" => evidence,
         "violated_principle" => principle,
         "replacement_path" => replacement,
         "owner" => owner,
         "reconsideration_trigger" => trigger,
         "decision_record" => record
       }) do
    state in @decision_states and valid_date?(date) and non_empty_list?(evidence) and
      Enum.all?(evidence, &valid_evidence?/1) and
      Enum.all?([principle, replacement, owner, trigger, record], &non_empty_string?/1)
  end

  defp valid_decision?(_decision), do: false

  defp valid_evidence?(%{"source" => source, "summary" => summary}),
    do: non_empty_string?(source) and non_empty_string?(summary)

  defp valid_evidence?(_evidence), do: false

  defp valid_date?(date) when is_binary(date), do: match?({:ok, _}, Date.from_iso8601(date))
  defp valid_date?(_date), do: false

  defp non_empty_list?(value), do: is_list(value) and value != []
  defp non_empty_string?(value), do: is_binary(value) and String.trim(value) != ""

  defp reconsideration_ready?(opts) do
    non_empty_string?(Keyword.get(opts, :new_evidence)) and
      non_empty_string?(Keyword.get(opts, :decision_record))
  end

  defp record_suppression(entry, proposal) do
    Analytics.capture(
      "fast_follow_proposal_suppressed",
      Analytics.system_distinct_id("fast_follow"),
      %{
        "register_id" => entry["id"],
        "decision_state" => entry["current"]["state"],
        "proposal_fingerprint" => fingerprint(proposal)
      }
    )
  end

  defp fingerprint(proposal) do
    proposal
    |> proposal_text()
    |> then(&:crypto.hash(:sha256, &1))
    |> Base.encode16(case: :lower)
  end

  defp proposal_text(proposal) when is_binary(proposal), do: normalize(proposal)

  defp proposal_text(proposal) when is_map(proposal) do
    [proposal[:title], proposal["title"], proposal[:body], proposal["body"]]
    |> Enum.filter(&is_binary/1)
    |> Enum.join(" ")
    |> normalize()
  end

  defp proposal_text(_proposal), do: ""

  defp normalize(text) do
    text
    |> String.downcase()
    |> String.replace(~r/[^\p{L}\p{N}]+/u, " ")
    |> String.trim()
  end

  defp register_file, do: Application.app_dir(:openagents, @register_path)
end