lib/openagents/delegations/target.ex

main at 58e6347eeb72 · 8 KB

defmodule OpenAgents.Delegations.Target do
  @moduledoc """
  One vocabulary for addressing a delegation target, reading its lifecycle, and
  advertising what the asking caller may do to it right now.

  Five surfaces want to hand work to a computer: the Work path, chat, text,
  voice, and the authenticated API. They differ in who is asking and how the
  answer is drawn. They share three questions, and this module is the only
  place that answers them.

  1. **How is it addressed?** By the kind-prefixed reference `OpenAgents.Delegations`
     already parses — `box:{uuid}` or `computer:{uuid}`. Nothing here mints a
     second identifier.
  2. **What is it doing?** `lifecycle/0` is one word drawn from the
     `openagents.cloud_computer.v1` state enum, computed from whichever
     substrate owns the target. The substrate's own state travels beside it
     untouched, because the substrate is still the authority and the unified
     word is a projection of it.
  3. **What may this caller do to it?** `capabilities` is an explicit list,
     computed once from the lifecycle and the authority the caller already
     proved. A surface renders that list. It never re-derives it.

  The third question is the one that earns the module. Reach is scoped per
  target kind (IDENTITY-009), so a surface that decides for itself whether a
  caller may start work on a target is a surface that can widen reach past the
  substrate. Computing it here makes the other four surfaces render-only.

  ## Custody is structural, never inferred

  `custody/1` answers whether a target is OpenAgents-managed or the customer's
  own premises, and it is derived from the target kind alone. It cannot be
  reached from the route that received the request, from whether the caller
  typed or spoke, or from anything else about the asking. A Box is provisioned
  and reclaimed by this application; a Computer is somebody's laptop. That is a
  property of the thing, not of the question.

  ## What is deliberately absent

  There is no runtime class for a Box. `OpenAgents.Capacity.Catalog` admits
  `standard`, `strong`, `batch`, and `connected`, and each names an isolation,
  an egress posture, and a data location. A paired computer is `connected` by
  construction and the capacity evidence already counts it as one. A Box is
  rented from a third party and nothing in this repository establishes its
  egress posture, so calling it `standard` would publish a containment claim
  no test backs. `runtime_class/1` returns `nil` for a Box until that evidence
  exists. See `docs/2026-08-25-delegation-target-seam.md`.
  """

  alias OpenAgents.Machines.Machine

  @lifecycles ~w(cold queued starting active stopping failed destroyed)
  @delegation_lifecycles ~w(queued starting active succeeded failed cancelled)
  @custodies ~w(openagents_managed customer_premises)
  @kinds ~w(box computer)

  @doc """
  The target lifecycle vocabulary.

  These are the seven states of `openagents.cloud_computer.v1` verbatim. The
  Elixir side maps onto the contract rather than inventing a parallel set, so
  a target described here keeps its word when a control plane starts producing
  one.
  """
  @spec lifecycles() :: [String.t()]
  def lifecycles, do: @lifecycles

  @doc "The delegation lifecycle vocabulary. A delegation ends; a target persists."
  @spec delegation_lifecycles() :: [String.t()]
  def delegation_lifecycles, do: @delegation_lifecycles

  @spec custodies() :: [String.t()]
  def custodies, do: @custodies

  @spec kinds() :: [String.t()]
  def kinds, do: @kinds

  @doc """
  Who holds the target, from the target kind alone.

  This never reads the caller, the surface, or the route.
  """
  @spec custody(String.t()) :: String.t()
  def custody("box"), do: "openagents_managed"
  def custody("computer"), do: "customer_premises"

  @doc """
  The `OpenAgents.Capacity.Catalog` class this kind of target belongs to, or
  `nil` when no admitted class describes it.
  """
  @spec runtime_class(String.t()) :: String.t() | nil
  def runtime_class("computer"), do: "connected"
  def runtime_class("box"), do: nil

  @doc """
  The unified lifecycle of a Box, from its last observed substrate state.

  A Box that is provisioned but not yet bootstrapped reads as `starting`
  rather than `active`. Refusing to advertise work on a half-ready Box costs a
  caller one poll; advertising it costs a failed command.

  Two producers feed this. A `conversation_boxes` row carries one of
  `OpenAgents.Box.ConversationBox.states/0`. A fan-out item that admission has
  not yet let through carries the synthetic `queued` that
  `OpenAgents.Box.Fleet` gives it — a logical Box that has cost nothing and
  called nothing. That item is the only thing in this application that reaches
  the contract's `queued` state today.
  """
  @spec box_lifecycle(String.t()) :: String.t()
  def box_lifecycle(state) when is_binary(state) do
    case state do
      "queued" -> "queued"
      "init" -> "starting"
      "provisioning" -> "starting"
      "cloning" -> "starting"
      "provisioned" -> "starting"
      "ready" -> "active"
      "idle" -> "active"
      "running" -> "active"
      "archiving" -> "stopping"
      "archived" -> "destroyed"
      "error" -> "failed"
      _unknown -> "failed"
    end
  end

  @doc """
  The unified lifecycle of a Computer.

  `online?` is a live reachability read, so it is passed in rather than taken.
  A revoked Computer is `destroyed` — the credential is gone and no pairing
  brings the same record back. An unreachable one is `cold`: the laptop is
  shut, and nothing this application can do opens it.
  """
  @spec computer_lifecycle(Machine.t(), boolean()) :: String.t()
  def computer_lifecycle(%Machine{} = machine, online?) when is_boolean(online?) do
    cond do
      machine.status == "revoked" or not is_nil(machine.revoked_at) -> "destroyed"
      machine.status == "active" and online? -> "active"
      true -> "cold"
    end
  end

  @doc "The unified lifecycle of one Box run."
  @spec run_lifecycle(String.t()) :: String.t()
  def run_lifecycle(state) when is_binary(state) do
    case state do
      "admitted" -> "queued"
      "dispatched" -> "starting"
      "running" -> "active"
      "completed" -> "succeeded"
      "cancelled" -> "cancelled"
      "failed" -> "failed"
      "timed_out" -> "failed"
      "lost" -> "failed"
      _unknown -> "failed"
    end
  end

  @doc """
  The unified lifecycle of one Computer delegation, from its `work_jobs` status.

  `interrupted` and `budget_exhausted` both read as `failed`. The distinction
  survives in the job's own status, which every projection carries beside this
  word; collapsing it here keeps one cross-kind vocabulary from growing a
  branch per substrate.
  """
  @spec job_lifecycle(String.t()) :: String.t()
  def job_lifecycle(status) when is_binary(status) do
    case status do
      "queued" -> "queued"
      "running" -> "active"
      "completed" -> "succeeded"
      "cancelled" -> "cancelled"
      "failed" -> "failed"
      "interrupted" -> "failed"
      "budget_exhausted" -> "failed"
      _unknown -> "failed"
    end
  end

  @doc """
  The seam fields for one target: custody, runtime class, lifecycle, the
  capabilities this caller may exercise, and why it may not.

  `authorized?` is the per-kind reach the caller already proved. It is required
  rather than assumed, so a surface that forgets to check cannot get a
  capability list by omission.

  `unavailable_reason` is the lifecycle itself when the target is not startable,
  and `not_authorized` when the lifecycle allows what the caller does not. It
  deliberately introduces no third vocabulary: the kind and the lifecycle
  together already separate a shut laptop from a reclaimed Box.
  """
  @spec seam(String.t(), String.t(), boolean()) :: map()
  def seam(kind, lifecycle, authorized?)
      when kind in @kinds and lifecycle in @lifecycles and is_boolean(authorized?) do
    startable? = lifecycle == "active" and authorized?

    %{
      "custody" => custody(kind),
      "runtime_class" => runtime_class(kind),
      "lifecycle" => lifecycle,
      "capabilities" => if(startable?, do: ["start"], else: []),
      "unavailable_reason" => unavailable_reason(lifecycle, authorized?)
    }
  end

  @doc """
  The seam fields for one delegation: its unified lifecycle and whether this
  caller can still cancel it.

  A caller that resolved the delegation at all has already passed the same
  per-kind reach check the target required, so cancellation turns only on
  whether the delegation is still running.
  """
  @spec delegation_seam(String.t()) :: map()
  def delegation_seam(lifecycle) when lifecycle in @delegation_lifecycles do
    %{
      "lifecycle" => lifecycle,
      "capabilities" => if(lifecycle in ~w(queued starting active), do: ["cancel"], else: [])
    }
  end

  defp unavailable_reason("active", true), do: nil
  defp unavailable_reason("active", false), do: "not_authorized"
  defp unavailable_reason(lifecycle, _authorized?), do: lifecycle
end