lib/openagents/modules/routing_policy.ex

main at 58e6347eeb72 · 5 KB

defmodule OpenAgents.Modules.RoutingPolicy do
  @moduledoc "Explicit host-owned constraints for deterministic module selection."

  alias OpenAgents.Provenance.Canonical

  @enforce_keys [
    :id,
    :version,
    :allowed_publishers,
    :allowed_costs,
    :allowed_qualities,
    :allowed_privacy,
    :allowed_residencies,
    :allowed_jurisdictions,
    :allowed_censorship_resistance,
    :allowed_approval_classes,
    :allowed_side_effects,
    :maximum_cost_units,
    :runtime_version,
    :digest
  ]
  defstruct @enforce_keys

  @type t :: %__MODULE__{}

  @spec default() :: t()
  def default do
    fields = %{
      id: "sarah.routing.policy.default.v1",
      version: 1,
      allowed_publishers: ["OpenAgentsInc"],
      allowed_costs: ["included_first_party"],
      allowed_qualities: ["host_validated"],
      allowed_privacy: [
        "browser_conversation",
        "browser_scoped",
        "public_catalog_metadata_only",
        "signed_browser_owner"
      ],
      allowed_residencies: [
        "application_postgres",
        "application_process",
        "host",
        "operator_machine"
      ],
      allowed_jurisdictions: ["operator_unspecified"],
      allowed_censorship_resistance: ["not_claimed"],
      allowed_approval_classes: ["exact_current_user_consent", "host_policy"],
      allowed_side_effects: ["read_only", "reversible_write"],
      maximum_cost_units: 0,
      runtime_version: 1,
      digest: String.duplicate("0", 64)
    }

    policy = struct!(__MODULE__, fields)
    %{policy | digest: digest(policy)}
  end

  @doc """
  Policy for owners with an active paired computer: pairing approval on
  /computers is the operator's explicit approval, so computer-effect modules
  (`external_effect` with `explicit_operator_approval`) become routable.
  Execution still requires a matching approval receipt per module.
  """
  @spec paired_machine() :: t()
  def paired_machine do
    {:ok, policy} =
      new(%{
        id: "sarah.routing.policy.paired-machine.v1",
        allowed_approval_classes: [
          "exact_current_user_consent",
          "explicit_operator_approval",
          "host_policy"
        ],
        allowed_side_effects: ["external_effect", "read_only", "reversible_write"]
      })

    policy
  end

  @doc """
  Policy for an OpenAgents operator using capacity owned by OpenAgents.

  This policy admits the SCV module's external effect and residency into the
  routing stage. Execution still requires the module-specific operator receipt,
  and `OpenAgents.SCV.Deployments` independently verifies the operator again
  before it spends capacity.
  """
  @spec operator() :: t()
  def operator do
    {:ok, policy} =
      new(%{
        id: "sarah.routing.policy.operator.v1",
        allowed_residencies: [
          "application_postgres",
          "application_process",
          "host",
          "openagents_capacity",
          "operator_machine"
        ],
        allowed_approval_classes: [
          "exact_current_user_consent",
          "explicit_operator_approval",
          "host_policy"
        ],
        allowed_side_effects: ["external_effect", "read_only", "reversible_write"]
      })

    policy
  end

  @spec new(map()) :: {:ok, t()} | {:error, atom()}
  def new(attributes) when is_map(attributes) do
    base = default()

    fields =
      base
      |> Map.from_struct()
      |> Map.merge(attributes)
      |> Map.put(:digest, String.duplicate("0", 64))

    try do
      policy = struct!(__MODULE__, fields)
      policy = %{policy | digest: digest(policy)}

      case validate(policy) do
        :ok -> {:ok, policy}
        {:error, reason} -> {:error, reason}
      end
    rescue
      _exception -> {:error, :routing_policy_invalid}
    end
  end

  def new(_attributes), do: {:error, :routing_policy_invalid}

  @spec validate(t()) :: :ok | {:error, atom()}
  def validate(%__MODULE__{} = policy) do
    list_fields = [
      policy.allowed_publishers,
      policy.allowed_costs,
      policy.allowed_qualities,
      policy.allowed_privacy,
      policy.allowed_residencies,
      policy.allowed_jurisdictions,
      policy.allowed_censorship_resistance,
      policy.allowed_approval_classes,
      policy.allowed_side_effects
    ]

    cond do
      not bounded?(policy.id, 128) or not is_integer(policy.version) or policy.version < 1 ->
        {:error, :routing_policy_identity_invalid}

      not is_integer(policy.runtime_version) or policy.runtime_version < 1 ->
        {:error, :routing_policy_runtime_invalid}

      not is_integer(policy.maximum_cost_units) or policy.maximum_cost_units < 0 ->
        {:error, :routing_policy_budget_invalid}

      not Enum.all?(list_fields, &bounded_list?/1) ->
        {:error, :routing_policy_constraints_invalid}

      policy.digest != digest(policy) ->
        {:error, :routing_policy_digest_invalid}

      true ->
        :ok
    end
  end

  @spec digest(t()) :: String.t()
  def digest(%__MODULE__{} = policy) do
    policy
    |> Map.from_struct()
    |> Map.delete(:digest)
    |> Canonical.digest!()
  end

  defp bounded_list?(values) when is_list(values) and values != [] and length(values) <= 32,
    do: values == Enum.sort(Enum.uniq(values)) and Enum.all?(values, &bounded?(&1, 128))

  defp bounded_list?(_values), do: false
  defp bounded?(value, maximum), do: is_binary(value) and byte_size(value) in 1..maximum
end