lib/openagents/inference/grant.ex

main at 58e6347eeb72 · 5 KB

defmodule OpenAgents.Inference.Grant do
  @moduledoc """
  A fenced inference grant (`sarah.inference_grant.v1`).

  Authority for one bounded body of work to call the Sarah inference proxy,
  metered against the owner's account. It is **not** a provider credential: the
  OpenAI key never leaves the server (RELEASE-002). A grant is budgeted (tokens
  / calls / estimated cost), time-bounded, and revocable. Only the token digest
  is stored; the plaintext is returned once at mint and injected into the probe
  process by the controller at spawn.

  Every grant names exactly one fence — a thread or a conversation, never both
  and never neither (THREAD-001). The conversation fence is the original one
  and is unchanged; the thread fence is what lets a coding session reach a
  model without borrowing the account's single conversation.
  """

  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  @timestamps_opts [type: :utc_datetime_usec]

  @statuses ~w(active exhausted revoked expired)
  @terminal_statuses ~w(exhausted revoked expired)

  @type t :: %__MODULE__{}

  schema "inference_grants" do
    field :owner_visitor_id, :binary_id
    field :conversation_id, :binary_id
    field :thread_id, :binary_id
    field :machine_id, :binary_id
    field :model_id, :string
    field :token_digest, :binary, redact: true
    field :status, :string, default: "active"

    field :max_total_tokens, :integer
    field :max_calls, :integer
    field :max_cost_microusd, :integer

    field :call_count, :integer, default: 0
    field :usage, :map, default: %{}

    field :expires_at, :utc_datetime_usec
    field :exhausted_at, :utc_datetime_usec
    field :revoked_at, :utc_datetime_usec
    timestamps()
  end

  @doc "The immutable capture at mint time."
  def mint_changeset(attrs) do
    %__MODULE__{}
    |> cast(attrs, [
      :owner_visitor_id,
      :conversation_id,
      :thread_id,
      :machine_id,
      :model_id,
      :token_digest,
      :max_total_tokens,
      :max_calls,
      :max_cost_microusd,
      :expires_at
    ])
    # machine_id is nil for Sarah-internal grants (a coding job metering its
    # own runtime, #122); computer-bound probe delegations always set it.
    # `expires_at` is cast but not required: nil is a grant with no clock. A
    # thread's authority is bounded by budget and revocation, not by how long
    # the reader has been working. A computer-bound delegation still sets one,
    # where the deadline is a security bound rather than a convenience.
    |> validate_required([:owner_visitor_id, :model_id, :token_digest])
    # A ceiling is optional and nil is unbounded, but a ceiling that is present
    # and non-positive is a grant that could never buy a call.
    # `validate_number/3` does not run on a nil change, so these still hold
    # wherever a figure was given.
    |> validate_number(:max_total_tokens, greater_than: 0)
    |> validate_number(:max_calls, greater_than: 0)
    |> validate_number(:max_cost_microusd, greater_than: 0)
    |> check_constraint(:max_calls, name: :inference_grant_positive_ceilings)
    |> validate_exactly_one_fence()
    |> unique_constraint(:token_digest)
    |> unique_constraint(:thread_id, name: :inference_grants_one_active_thread_index)
    |> check_constraint(:conversation_id, name: :inference_grant_exactly_one_fence)
  end

  # A grant with no fence is unattributable spend; a grant with two is spend
  # attributed twice. PostgreSQL refuses both independently
  # (`inference_grant_exactly_one_fence`); this is the same refusal, earlier.
  defp validate_exactly_one_fence(changeset) do
    case {get_field(changeset, :conversation_id), get_field(changeset, :thread_id)} do
      {conversation_id, nil} when is_binary(conversation_id) ->
        changeset

      {nil, thread_id} when is_binary(thread_id) ->
        changeset

      _neither_or_both ->
        add_error(changeset, :thread_id, "must name exactly one thread or conversation")
    end
  end

  @doc "Record one metered call: forward-only usage/count, terminal on budget."
  def usage_changeset(%__MODULE__{} = grant, usage, next_status, now) do
    grant
    |> change(%{
      usage: usage,
      call_count: grant.call_count + 1,
      status: next_status
    })
    |> maybe_stamp_terminal(next_status, now)
    |> check_constraint(:status, name: :inference_grant_status)
  end

  @doc "Terminal transition without a metered call (revoke, expire)."
  def terminal_changeset(%__MODULE__{} = grant, status, now) when status in @terminal_statuses do
    grant
    |> change(%{status: status})
    |> maybe_stamp_terminal(status, now)
    |> check_constraint(:status, name: :inference_grant_status)
  end

  defp maybe_stamp_terminal(changeset, "revoked", now),
    do: put_change(changeset, :revoked_at, now)

  defp maybe_stamp_terminal(changeset, "exhausted", now),
    do: put_change(changeset, :exhausted_at, now)

  defp maybe_stamp_terminal(changeset, "expired", now),
    do: put_change(changeset, :exhausted_at, now)

  defp maybe_stamp_terminal(changeset, _active, _now), do: changeset

  def statuses, do: @statuses
  def terminal_statuses, do: @terminal_statuses
end