Give every delegation target one address, lifecycle, and capability

3011a18599e3 · AtlantisPleb · · parent bc446048d1ba

Give every delegation target one address, lifecycle, and capability

Issue #37 asks to integrate cloud computers with Work, chat, text, voice,
and the API. Those are one integration and four renderings. They ask the
same three questions of a target: how it is addressed, what it is doing,
and what this caller may do to it.

The first was settled — the kind travels in the identifier. The second had
four answers that did not agree: ten Box states, eight run states, seven
job statuses, and a Computer's status beside a live reachability read. The
third had none, so each surface re-derived it from kind, state, and scopes.
Reach is scoped per kind, so a surface that decides that for itself can
widen reach past the substrate. Five surfaces is five chances to get it
wrong, and four of them get written by reading the fifth.

OpenAgents.Delegations.Target computes it once: custody, runtime class, one
lifecycle word, the capabilities the caller may exercise, and why it may
not. The API and the chat panel both read it.

Custody is derived from the target kind alone, so it cannot be reached from
the route or from whether the caller typed or spoke — the acceptance
criterion becomes a property of the type rather than a rule to remember.
The lifecycle vocabulary is the openagents.cloud_computer.v1 state enum
verbatim rather than a parallel set. A Box claims no runtime class: calling
it standard would assert a policy-broker egress nothing here establishes.

The tests derive their inputs from the substrate schemas, so a new state
fails the suite the day it lands instead of reading as failed on five
surfaces, and the authority cases assert the negative for every kind and
lifecycle pair so a capability cannot leak by omission.

Cloud computer is recorded in the taxonomy as a collision, not a term: a
Computer is never provisioned, so the audit's cloud computer is a Box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnhfrafYx5ZGaMbzZEJQ2d
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 387 · 2026-08-25T14:50:21.091680Z

Changed files

  • added docs/2026-08-25-delegation-target-seam.md
  • modified docs/taxonomy.md
  • modified lib/openagents/delegations.ex
  • added lib/openagents/delegations/target.ex
  • added test/openagents/delegations/target_test.exs

Diff

5 files changed, +724 -7

docs/2026-08-25-delegation-target-seam.md added +203

@@ -0,0 +1,203 @@

1
# The delegation target seam
2
3
**Date:** 2026-08-25
4
**Issue:** [#37](https://openagents.com/OpenAgentsInc/openagents.com/issues/37), *Integrate cloud computers with Work, chat, text, voice, and API*
5
**Source:** `docs/2026-08-22-cloud-computer-scale-architecture-audit.md`
6
**Status:** The seam in section 4 shipped with this document. Sections 5 and 6 are the decisions it defers.
7
8
Issue #37 asks for five integrations: Work, chat, text, voice, and the
9
authenticated API. This document argues they are one integration and four
10
renderings, names the part they share, and records what shipped.
11
12
---
13
14
## 1. "Cloud computer" is not a noun this codebase gets
15
16
`docs/taxonomy.md` already settled the word, and #37 collides with it:
17
18
> **computer** — someone's own machine, connected or absent, never provisioned
19
> **box** — a sandbox VM this application provisions, caps, and reclaims
20
21
So an OpenAgents-managed cloud computer cannot be called a computer here. It is
22
a **Box**, and #37's "customer-connected computer" is a **Computer**. The
23
delegation target kind is already `box` or `computer`, the kind already travels
24
in the identifier (`box:{uuid}`, `computer:{uuid}`), and authority is already
25
scoped per kind.
26
27
Adding a third kind named `cloud_computer` would create exactly the collision
28
the taxonomy exists to prevent: three nouns, two of which are provisioned, one
29
of which is not, and a reader who cannot tell from the name which failure a
30
missing one is. A Box that is gone was reclaimed and you can provision another.
31
A Computer that is gone is somebody's laptop.
32
33
**Decision.** #37 adds no target kind. It makes the two that exist addressable,
34
observable, and advertisable through one seam.
35
36
---
37
38
## 2. What exists today
39
40
Measured by direct reading, not by documentation.
41
42
**Real, in this repository:**
43
44
| Thing | Where | What it does |
45
| --- | --- | --- |
46
| Delegation facade | `OpenAgents.Delegations` | One `start` / `get` / `cancel` / `inventory` across both kinds, at `/api/v1/conversations/{id}/delegations`. Stores nothing; derives everything. IDENTITY-009. |
47
| Box substrate | `OpenAgents.Box`, `OpenAgents.BoxRuns` | Rents VMs from the Box Public API at `ascii.dev`. Ledgers `conversation_boxes`, `box_runs`, `box_fanout_items`. |
48
| Computer substrate | `OpenAgents.Machines`, `OpenAgents.Computer`, `OpenAgents.ComputerAgentJobs` | Pairing, credential, live WebSocket control, durable ACP delegations as `work_jobs` rows. |
49
| Capacity and matching | `OpenAgents.Capacity` | Runtime classes `standard`, `strong`, `batch`, `connected`, each with an isolation, egress, and data location. Published at `/api/v1/capacity` and `/api/v1/capacity/matches`. Issue #76. |
50
51
**Documented, and not real anywhere:**
52
53
- The logical-computer record, the runtime-lease record, and the command
54
  journal with `may_have_started`. None exists in this repository.
55
- The provider-neutral control-plane `Req` client #37 asks for. **There is no
56
  endpoint for it to call.** In the `OpenAgentsInc/openagents` monorepo,
57
  `crates/openagents-cloud-contract/src/cloud_computer_v1.rs` and the 34-file
58
  `packages/khala-sync-server/src/cloud-computer-*.ts` set are real, tested
59
  code that no binary imports and no route exposes; there are no fixtures for
60
  `cloud_computer.v1` and no OpenAPI, protobuf, or JSON schema describing any
61
  cloud-computer request or response. The one machine-readable artifact,
62
  `schema/cloud_computer.v1.schema.json`, describes a record, not an endpoint.
63
64
That last finding decides the shape of this work. A `Req` client written today
65
would be a client for something that does not answer.
66
67
---
68
69
## 3. The seam, and why five surfaces share exactly one
70
71
Work, chat, text, voice, and the API differ in who is asking and how the answer
72
is drawn. They ask the same three questions of a target:
73
74
1. **How is it addressed?** By the kind-prefixed reference the facade already
75
   parses. Settled, and nothing here mints a second identifier.
76
2. **What is it doing?** Today, four vocabularies answer this and none of them
77
   agree. `conversation_boxes.state` has ten values; `box_runs.state` has eight;
78
   `work_jobs.status` has seven; a Computer has `status` plus a live
79
   reachability read. A surface that wants to say "this computer is busy" must
80
   know all four.
81
3. **What may this caller do to it?** Nothing answered this at all. Every
82
   surface had to re-derive it from kind, state, and scopes.
83
84
The third question is the one that earns a module. Reach is scoped per kind
85
(IDENTITY-009), so a surface that decides for itself whether a caller may start
86
work on a target is a surface that can widen reach past the substrate. Five
87
surfaces means five chances to get that wrong, and four of them would be
88
written by someone reading the fifth.
89
90
Before this change there were also three divergent projections of "what can I
91
delegate to": the `computer_list` tool's own result schema, which cannot see a
92
Box at all; the API's `openagents.delegation_targets.v1`; and the chat panel's
93
fleet projection. Three answers to one question.
94
95
---
96
97
## 4. What shipped
98
99
`OpenAgents.Delegations.Target` — pure, no database, no process — computes five
100
fields for any target, and two for any delegation.
101
102
| Field | Meaning |
103
| --- | --- |
104
| `custody` | `openagents_managed` or `customer_premises`, **derived from the target kind alone**. |
105
| `runtime_class` | The `OpenAgents.Capacity.Catalog` class, or `nil` when none describes the kind. |
106
| `lifecycle` | One word from the seven-state enum of `openagents.cloud_computer.v1`. |
107
| `capabilities` | The operations this caller may exercise now. `["start"]` or `[]`. |
108
| `unavailable_reason` | The lifecycle when the target is not startable, `not_authorized` when authority is what is missing, `nil` when nothing is. |
109
110
A delegation carries `lifecycle` from a six-word vocabulary and
111
`capabilities` of `["cancel"]` or `[]`.
112
113
Three decisions inside it are worth stating.
114
115
**Custody is structural.** It reads the target kind and nothing else. It cannot
116
be reached from the route that received the request, from whether the caller
117
typed or spoke, or from anything about the asking. This is what makes #37's
118
acceptance criterion — *never infer customer-owned versus OpenAgents-managed
119
from text versus voice or from the route* — a property of the type rather than
120
a rule five surfaces must remember.
121
122
**The lifecycle vocabulary is borrowed, not invented.** The seven words are the
123
`state` enum of `openagents.cloud_computer.v1` verbatim. The Elixir projection
124
maps onto the contract that already exists upstream, so a target described here
125
keeps its word if a control plane ever starts producing one. The substrate's own
126
state travels beside it untouched, because the substrate is still the authority
127
and this is a projection of it.
128
129
**A Box claims no runtime class.** `Capacity.Catalog`'s `standard` class asserts
130
`policy_broker` egress and `managed_standard` isolation. A Box is rented from a
131
third party and nothing in this repository establishes its egress posture, so
132
calling it `standard` would publish a containment claim no test backs.
133
`runtime_class("box")` returns `nil` until that evidence exists. A Computer is
134
`connected` by construction, and `Capacity.Connected` already counts machines as
135
that class.
136
137
The tests derive their inputs from the substrate schemas rather than listing
138
them: every value of `ConversationBox.states/0`, `Run.states/0`, and
139
`Job.statuses/0` must map into the vocabulary, and a new state fails the suite
140
the day it lands instead of silently reading as `failed` on five surfaces. The
141
authority tests assert the negative case for every kind and lifecycle pair, so
142
a capability cannot leak by omission.
143
144
Both consumers now read it: `Delegations.inventory/2` for the API, and
145
`Delegations.projection/2` for the chat panel, including the fan-out queue —
146
which is the only thing in this application that reaches the contract's
147
`queued` state today.
148
149
---
150
151
## 5. Why the remaining surfaces are a rendering, not four integrations
152
153
**Chat and #38.** #38 wants each logical computer shown with a stable label and
154
one of `cold | queued | starting | active | stopping | failed | destroyed`, the
155
reason a computer is queued, and owner controls "only when the current
156
capability allows them". Those are `lifecycle`, `unavailable_reason`, and
157
`capabilities` on a projection the chat panel already receives. #38 is a
158
template and a set of tests over fields that now exist.
159
160
**Text and voice.** Both already reach the same `OpenAgents.Work` path;
161
`work_jobs.surface` is `text` or `voice` and nothing else about the two differs.
162
Because custody is derived from the target kind, the equivalence #37 demands —
163
that a text and a voice request produce the same target, authority, and custody
164
— holds by construction rather than by a test that watches for drift.
165
166
**The API.** Already served. The seam is additive on
167
`openagents.delegation_targets.v1`.
168
169
**Model tools.** A tool renders the same document. It is deliberately not
170
shipped here: the tool catalog is at a **zero base** by policy
171
(`docs/2026-08-23-agent-tools-zero-base.md`), seven read-only tools ship, and
172
none of the five computer tools is among them. Re-admission runs through that
173
document's six criteria and is a change to both `config/config.exs` and
174
`shipped_catalog_test.exs`. #37's line about adding a model tool predates that
175
policy. The seam is built so re-admitting one is a config line and a rendering;
176
the admission decision belongs to section 6 of that document, not to this issue.
177
178
**Work.** `Capacity.match/2` returns a runtime *class* and never a thing you can
179
address; `Delegations` returns addressable targets and, until now, no class.
180
A surface that wanted to choose a target had to bridge those itself. The
181
descriptor is that bridge's near half. The far half is section 6.
182
183
---
184
185
## 6. What this does not do
186
187
Named plainly so nothing here reads as more than it is.
188
189
- **No control-plane client.** There is no endpoint. Section 2.
190
- **No logical-computer or runtime-lease record**, no generation fence, no
191
  command journal, and so no `may_have_started`. #37's recovery criterion is
192
  unmet and stays unmet until those exist upstream.
193
- **No Box capacity evidence.** `OpenAgents.Capacity` counts connected
194
  computers and broker-reported managed classes. It cannot see a Box — the one
195
  OpenAgents-managed compute this application actually provisions. Closing that
196
  loop means classifying the Box's isolation and egress, which is the decision
197
  `runtime_class("box") == nil` is holding open.
198
- **No new operations.** `start` and `cancel` are what the facade serves, so
199
  they are what it advertises. #37's `attach`, `stop`, `checkpoint`, `restore`,
200
  `fork`, and `destroy` are not capabilities this seam can honestly offer, and
201
  a capability list that names operations no route serves is worse than none.
202
  A Box can in fact be stopped, but only through `Fleet.stop/2` by label, on a
203
  path the facade does not own.
docs/taxonomy.md modified +40

@@ -534,6 +534,46 @@ The distinction decides what a failure means. A Box that is gone was reclaimed,

534 534
and you can provision another. A Computer that is gone is somebody's laptop,
535 535
and waiting is the only recovery.
536 536
537
**"Cloud computer" is not a term here** — it is a collision, and issue #37
538
settled it. The `docs/2026-08-22-cloud-computer-scale-architecture-audit.md`
539
program calls an OpenAgents-managed sandbox a *cloud computer*, but a Computer
540
in this application is never provisioned, so that phrase names a Box. Read the
541
audit's "cloud computer" as **Box** and its "customer-connected computer" as
542
**Computer**, and do not introduce a third target kind for it. The upstream
543
contract keeps its own name: `openagents.cloud_computer.v1` is a schema in the
544
`OpenAgentsInc/openagents` monorepo, and its `state` enum is where this
545
application's target lifecycle vocabulary comes from.
546
547
**Target seam** — the five fields `OpenAgents.Delegations.Target` computes for
548
any delegation target, and that every surface renders rather than derives.
549
`docs/2026-08-25-delegation-target-seam.md` holds the reasoning.
550
551
- **Custody** — `openagents_managed` or `customer_premises`, derived from the
552
  target kind alone. It is never read from the route that received the request
553
  or from whether the caller typed or spoke. A Box is managed; a Computer is
554
  the customer's premises.
555
- **Target lifecycle** — one of `cold`, `queued`, `starting`, `active`,
556
  `stopping`, `failed`, `destroyed`, taken verbatim from the
557
  `openagents.cloud_computer.v1` state enum. It is a projection of the
558
  substrate state, which stays the authority and travels beside it. Four
559
  substrate vocabularies feed it: `conversation_boxes.state`, `box_runs.state`,
560
  `work_jobs.status`, and a Computer's `status` plus its live reachability read.
561
- **Delegation lifecycle** — one of `queued`, `starting`, `active`,
562
  `succeeded`, `failed`, `cancelled`. A delegation ends; a target persists, so
563
  the two vocabularies are deliberately different words.
564
- **Capability** — the operations the asking caller may exercise *now*, as an
565
  explicit list: `["start"]` on a target, `["cancel"]` on a delegation, or `[]`
566
  with an `unavailable_reason`. A surface that derives this for itself can
567
  widen reach past the substrate, which is what IDENTITY-009 forbids.
568
569
**Runtime class** — an `OpenAgents.Capacity.Catalog` id: `standard`, `strong`,
570
`batch`, or `connected`, each naming an isolation, an egress posture, and a
571
data location. A Computer is `connected`. **A Box has no class yet**: naming it
572
`standard` would assert `policy_broker` egress that nothing here establishes,
573
so `runtime_class` is `nil` for a Box until that evidence exists. A class is
574
not a target — matching returns a class, and it takes a target reference to
575
address anything.
576
537 577
**Computer, not machine** — "computer" is both the product word and the
538 578
current code word. `OpenAgents.Computer` owns live control,
539 579
`OpenAgents.ComputerAgentJobs` owns durable ACP delegations,
lib/openagents/delegations.ex modified +39 -7

@@ -5,6 +5,12 @@ defmodule OpenAgents.Delegations do

5 5
  The Box run and Work delegation ledgers remain authoritative. This module
6 6
  stores no delegation state and derives every identifier and projection from
7 7
  those substrate records.
8
9
  Every target and every delegation this module returns carries the seam
10
  `OpenAgents.Delegations.Target` computes: custody, runtime class, one
11
  lifecycle word across kinds, and the capabilities the asking caller may
12
  exercise. Surfaces render those fields rather than deriving them, so no
13
  surface can decide for itself that a caller may start work on a target.
8 14
  """
9 15
10 16
  import Ecto.Query

@@ -15,6 +21,7 @@ defmodule OpenAgents.Delegations do

15 21
  alias OpenAgents.BoxRuns
16 22
  alias OpenAgents.ComputerAgentJobs
17 23
  alias OpenAgents.ComputerProjection
24
  alias OpenAgents.Delegations.Target
18 25
  alias OpenAgents.Conversations.{Conversation, Visitor}
19 26
  alias OpenAgents.Machines
20 27
  alias OpenAgents.Machines.Machine

@@ -31,8 +38,10 @@ defmodule OpenAgents.Delegations do

31 38
  @spec inventory(caller(), String.t()) :: {:ok, map()} | {:error, atom()}
32 39
  def inventory(caller, conversation_id) when is_map(caller) and is_binary(conversation_id) do
33 40
    with {:ok, owners} <- inventory_owners(caller, conversation_id) do
34
      boxes = Enum.flat_map(owners.boxes, &box_targets(&1, conversation_id))
35
      computers = Enum.flat_map(owners.computers, &computer_targets/1)
41
      # `inventory_owners/2` already resolved per-kind reach: a kind the caller
42
      # cannot reach yields no owner, so everything listed here is authorized.
43
      boxes = Enum.flat_map(owners.boxes, &box_targets(&1, conversation_id, true))
44
      computers = Enum.flat_map(owners.computers, &computer_targets(&1, true))
36 45
37 46
      {:ok,
38 47
       %{

@@ -113,15 +122,32 @@ defmodule OpenAgents.Delegations do

113 122
  @spec projection(User.t(), String.t()) :: map()
114 123
  def projection(%User{} = user, conversation_id) when is_binary(conversation_id) do
115 124
    base = Fleet.projection(conversation_id)
116
    computers = computer_targets(user)
125
    computers = computer_targets(user, true)
117 126
    jobs = latest_jobs(user.id, conversation_id)
118 127
119 128
    Map.merge(base, %{
129
      boxes: Enum.map(base.boxes, &box_fleet_seam/1),
130
      queued: Enum.map(base.queued, &box_fleet_seam/1),
120 131
      computers: Enum.map(computers, &computer_fleet_view(&1, Map.get(jobs, &1["computer_id"]))),
121 132
      maximum_computers: @maximum_targets
122 133
    })
123 134
  end
124 135
136
  # The chat panel reads the same seam the API does. `OpenAgents.Box.Fleet`
137
  # keys its projection with atoms, so the seam is restated in that convention
138
  # rather than mixing key types inside one map.
139
  defp box_fleet_seam(%{state: state} = view) do
140
    seam = Target.seam("box", Target.box_lifecycle(state), true)
141
142
    Map.merge(view, %{
143
      custody: seam["custody"],
144
      runtime_class: seam["runtime_class"],
145
      lifecycle: seam["lifecycle"],
146
      capabilities: seam["capabilities"],
147
      unavailable_reason: seam["unavailable_reason"]
148
    })
149
  end
150
125 151
  defp conversation_owner(%{user: %User{} = user}, conversation_id) do
126 152
    case OpenAgents.Conversations.get_conversation_for_user(user, conversation_id) do
127 153
      %Conversation{} -> {:ok, user}

@@ -146,7 +172,7 @@ defmodule OpenAgents.Delegations do

146 172
  defp conversation_owned?(%User{} = user, conversation_id),
147 173
    do: not is_nil(OpenAgents.Conversations.get_conversation_for_user(user, conversation_id))
148 174
149
  defp box_targets(%User{id: user_id}, conversation_id) do
175
  defp box_targets(%User{id: user_id}, conversation_id, authorized?) do
150 176
    Repo.all(
151 177
      from box in ConversationBox,
152 178
        join: conversation in Conversation,

@@ -166,20 +192,24 @@ defmodule OpenAgents.Delegations do

166 192
        "box_id" => box.box_id,
167 193
        "stopped_at" => iso8601(box.stopped_at)
168 194
      }
195
      |> Map.merge(Target.seam("box", Target.box_lifecycle(box.state), authorized?))
169 196
    end)
170 197
  end
171 198
172
  defp computer_targets(%User{id: user_id}) do
199
  defp computer_targets(%User{id: user_id}, authorized?) do
173 200
    Machines.list_machines(user_id)
174 201
    |> Enum.take(@maximum_targets)
175 202
    |> Enum.map(fn machine ->
176
      machine
177
      |> ComputerProjection.project()
203
      projection = ComputerProjection.project(machine)
204
      lifecycle = Target.computer_lifecycle(machine, projection["online"] == true)
205
206
      projection
178 207
      |> Map.merge(%{
179 208
        "id" => "computer:" <> machine.id,
180 209
        "kind" => "computer",
181 210
        "computer_id" => machine.id
182 211
      })
212
      |> Map.merge(Target.seam("computer", lifecycle, authorized?))
183 213
    end)
184 214
  end
185 215

@@ -294,6 +324,7 @@ defmodule OpenAgents.Delegations do

294 324
      "failure_reason" => bounded(run.failure_reason, 500),
295 325
      "finished_at" => iso8601(run.finished_at)
296 326
    }
327
    |> Map.merge(Target.delegation_seam(Target.run_lifecycle(run.state)))
297 328
  end
298 329
299 330
  defp delegation_projection("computer", %Job{} = job) do

@@ -312,6 +343,7 @@ defmodule OpenAgents.Delegations do

312 343
      "failure_reason" => bounded(job.error_code, 500),
313 344
      "finished_at" => iso8601(job.completed_at)
314 345
    }
346
    |> Map.merge(Target.delegation_seam(Target.job_lifecycle(job.status)))
315 347
  end
316 348
317 349
  defp computer_fleet_view(target, nil), do: Map.put(target, "delegation", nil)
lib/openagents/delegations/target.ex added +227

@@ -0,0 +1,227 @@

1
defmodule OpenAgents.Delegations.Target do
2
  @moduledoc """
3
  One vocabulary for addressing a delegation target, reading its lifecycle, and
4
  advertising what the asking caller may do to it right now.
5
6
  Five surfaces want to hand work to a computer: the Work path, chat, text,
7
  voice, and the authenticated API. They differ in who is asking and how the
8
  answer is drawn. They share three questions, and this module is the only
9
  place that answers them.
10
11
  1. **How is it addressed?** By the kind-prefixed reference `OpenAgents.Delegations`
12
     already parses — `box:{uuid}` or `computer:{uuid}`. Nothing here mints a
13
     second identifier.
14
  2. **What is it doing?** `lifecycle/0` is one word drawn from the
15
     `openagents.cloud_computer.v1` state enum, computed from whichever
16
     substrate owns the target. The substrate's own state travels beside it
17
     untouched, because the substrate is still the authority and the unified
18
     word is a projection of it.
19
  3. **What may this caller do to it?** `capabilities` is an explicit list,
20
     computed once from the lifecycle and the authority the caller already
21
     proved. A surface renders that list. It never re-derives it.
22
23
  The third question is the one that earns the module. Reach is scoped per
24
  target kind (IDENTITY-009), so a surface that decides for itself whether a
25
  caller may start work on a target is a surface that can widen reach past the
26
  substrate. Computing it here makes the other four surfaces render-only.
27
28
  ## Custody is structural, never inferred
29
30
  `custody/1` answers whether a target is OpenAgents-managed or the customer's
31
  own premises, and it is derived from the target kind alone. It cannot be
32
  reached from the route that received the request, from whether the caller
33
  typed or spoke, or from anything else about the asking. A Box is provisioned
34
  and reclaimed by this application; a Computer is somebody's laptop. That is a
35
  property of the thing, not of the question.
36
37
  ## What is deliberately absent
38
39
  There is no runtime class for a Box. `OpenAgents.Capacity.Catalog` admits
40
  `standard`, `strong`, `batch`, and `connected`, and each names an isolation,
41
  an egress posture, and a data location. A paired computer is `connected` by
42
  construction and the capacity evidence already counts it as one. A Box is
43
  rented from a third party and nothing in this repository establishes its
44
  egress posture, so calling it `standard` would publish a containment claim
45
  no test backs. `runtime_class/1` returns `nil` for a Box until that evidence
46
  exists. See `docs/2026-08-25-delegation-target-seam.md`.
47
  """
48
49
  alias OpenAgents.Machines.Machine
50
51
  @lifecycles ~w(cold queued starting active stopping failed destroyed)
52
  @delegation_lifecycles ~w(queued starting active succeeded failed cancelled)
53
  @custodies ~w(openagents_managed customer_premises)
54
  @kinds ~w(box computer)
55
56
  @doc """
57
  The target lifecycle vocabulary.
58
59
  These are the seven states of `openagents.cloud_computer.v1` verbatim. The
60
  Elixir side maps onto the contract rather than inventing a parallel set, so
61
  a target described here keeps its word when a control plane starts producing
62
  one.
63
  """
64
  @spec lifecycles() :: [String.t()]
65
  def lifecycles, do: @lifecycles
66
67
  @doc "The delegation lifecycle vocabulary. A delegation ends; a target persists."
68
  @spec delegation_lifecycles() :: [String.t()]
69
  def delegation_lifecycles, do: @delegation_lifecycles
70
71
  @spec custodies() :: [String.t()]
72
  def custodies, do: @custodies
73
74
  @spec kinds() :: [String.t()]
75
  def kinds, do: @kinds
76
77
  @doc """
78
  Who holds the target, from the target kind alone.
79
80
  This never reads the caller, the surface, or the route.
81
  """
82
  @spec custody(String.t()) :: String.t()
83
  def custody("box"), do: "openagents_managed"
84
  def custody("computer"), do: "customer_premises"
85
86
  @doc """
87
  The `OpenAgents.Capacity.Catalog` class this kind of target belongs to, or
88
  `nil` when no admitted class describes it.
89
  """
90
  @spec runtime_class(String.t()) :: String.t() | nil
91
  def runtime_class("computer"), do: "connected"
92
  def runtime_class("box"), do: nil
93
94
  @doc """
95
  The unified lifecycle of a Box, from its last observed substrate state.
96
97
  A Box that is provisioned but not yet bootstrapped reads as `starting`
98
  rather than `active`. Refusing to advertise work on a half-ready Box costs a
99
  caller one poll; advertising it costs a failed command.
100
101
  Two producers feed this. A `conversation_boxes` row carries one of
102
  `OpenAgents.Box.ConversationBox.states/0`. A fan-out item that admission has
103
  not yet let through carries the synthetic `queued` that
104
  `OpenAgents.Box.Fleet` gives it — a logical Box that has cost nothing and
105
  called nothing. That item is the only thing in this application that reaches
106
  the contract's `queued` state today.
107
  """
108
  @spec box_lifecycle(String.t()) :: String.t()
109
  def box_lifecycle(state) when is_binary(state) do
110
    case state do
111
      "queued" -> "queued"
112
      "init" -> "starting"
113
      "provisioning" -> "starting"
114
      "cloning" -> "starting"
115
      "provisioned" -> "starting"
116
      "ready" -> "active"
117
      "idle" -> "active"
118
      "running" -> "active"
119
      "archiving" -> "stopping"
120
      "archived" -> "destroyed"
121
      "error" -> "failed"
122
      _unknown -> "failed"
123
    end
124
  end
125
126
  @doc """
127
  The unified lifecycle of a Computer.
128
129
  `online?` is a live reachability read, so it is passed in rather than taken.
130
  A revoked Computer is `destroyed` — the credential is gone and no pairing
131
  brings the same record back. An unreachable one is `cold`: the laptop is
132
  shut, and nothing this application can do opens it.
133
  """
134
  @spec computer_lifecycle(Machine.t(), boolean()) :: String.t()
135
  def computer_lifecycle(%Machine{} = machine, online?) when is_boolean(online?) do
136
    cond do
137
      machine.status == "revoked" or not is_nil(machine.revoked_at) -> "destroyed"
138
      machine.status == "active" and online? -> "active"
139
      true -> "cold"
140
    end
141
  end
142
143
  @doc "The unified lifecycle of one Box run."
144
  @spec run_lifecycle(String.t()) :: String.t()
145
  def run_lifecycle(state) when is_binary(state) do
146
    case state do
147
      "admitted" -> "queued"
148
      "dispatched" -> "starting"
149
      "running" -> "active"
150
      "completed" -> "succeeded"
151
      "cancelled" -> "cancelled"
152
      "failed" -> "failed"
153
      "timed_out" -> "failed"
154
      "lost" -> "failed"
155
      _unknown -> "failed"
156
    end
157
  end
158
159
  @doc """
160
  The unified lifecycle of one Computer delegation, from its `work_jobs` status.
161
162
  `interrupted` and `budget_exhausted` both read as `failed`. The distinction
163
  survives in the job's own status, which every projection carries beside this
164
  word; collapsing it here keeps one cross-kind vocabulary from growing a
165
  branch per substrate.
166
  """
167
  @spec job_lifecycle(String.t()) :: String.t()
168
  def job_lifecycle(status) when is_binary(status) do
169
    case status do
170
      "queued" -> "queued"
171
      "running" -> "active"
172
      "completed" -> "succeeded"
173
      "cancelled" -> "cancelled"
174
      "failed" -> "failed"
175
      "interrupted" -> "failed"
176
      "budget_exhausted" -> "failed"
177
      _unknown -> "failed"
178
    end
179
  end
180
181
  @doc """
182
  The seam fields for one target: custody, runtime class, lifecycle, the
183
  capabilities this caller may exercise, and why it may not.
184
185
  `authorized?` is the per-kind reach the caller already proved. It is required
186
  rather than assumed, so a surface that forgets to check cannot get a
187
  capability list by omission.
188
189
  `unavailable_reason` is the lifecycle itself when the target is not startable,
190
  and `not_authorized` when the lifecycle allows what the caller does not. It
191
  deliberately introduces no third vocabulary: the kind and the lifecycle
192
  together already separate a shut laptop from a reclaimed Box.
193
  """
194
  @spec seam(String.t(), String.t(), boolean()) :: map()
195
  def seam(kind, lifecycle, authorized?)
196
      when kind in @kinds and lifecycle in @lifecycles and is_boolean(authorized?) do
197
    startable? = lifecycle == "active" and authorized?
198
199
    %{
200
      "custody" => custody(kind),
201
      "runtime_class" => runtime_class(kind),
202
      "lifecycle" => lifecycle,
203
      "capabilities" => if(startable?, do: ["start"], else: []),
204
      "unavailable_reason" => unavailable_reason(lifecycle, authorized?)
205
    }
206
  end
207
208
  @doc """
209
  The seam fields for one delegation: its unified lifecycle and whether this
210
  caller can still cancel it.
211
212
  A caller that resolved the delegation at all has already passed the same
213
  per-kind reach check the target required, so cancellation turns only on
214
  whether the delegation is still running.
215
  """
216
  @spec delegation_seam(String.t()) :: map()
217
  def delegation_seam(lifecycle) when lifecycle in @delegation_lifecycles do
218
    %{
219
      "lifecycle" => lifecycle,
220
      "capabilities" => if(lifecycle in ~w(queued starting active), do: ["cancel"], else: [])
221
    }
222
  end
223
224
  defp unavailable_reason("active", true), do: nil
225
  defp unavailable_reason("active", false), do: "not_authorized"
226
  defp unavailable_reason(lifecycle, _authorized?), do: lifecycle
227
end
test/openagents/delegations/target_test.exs added +215

@@ -0,0 +1,215 @@

1
defmodule OpenAgents.Delegations.TargetTest do
2
  @moduledoc """
3
  The seam is only worth having if it is total.
4
5
  These tests derive their inputs from the substrate schemas rather than
6
  listing them, so a new Box state, run state, or work-job status fails here
7
  the day it lands instead of silently reading as `failed` on five surfaces.
8
  """
9
10
  use ExUnit.Case, async: true
11
12
  alias OpenAgents.Box.ConversationBox
13
  alias OpenAgents.Box.Run
14
  alias OpenAgents.Delegations.Target
15
  alias OpenAgents.Machines.Machine
16
  alias OpenAgents.Work.Job
17
18
  describe "the lifecycle vocabulary" do
19
    test "target lifecycles are the openagents.cloud_computer.v1 state enum" do
20
      assert Target.lifecycles() == ~w(cold queued starting active stopping failed destroyed)
21
    end
22
23
    test "every declared Box state maps into the vocabulary" do
24
      for state <- ConversationBox.states() do
25
        assert Target.box_lifecycle(state) in Target.lifecycles(),
26
               "Box state #{state} has no lifecycle"
27
      end
28
    end
29
30
    test "a fan-out item that admission has not admitted reads as queued" do
31
      assert Target.box_lifecycle("queued") == "queued"
32
    end
33
34
    test "no declared Box state silently reads as failed" do
35
      # `error` is the one Box state that means failure. If another state
36
      # starts reading as `failed`, it fell through the catch-all.
37
      failed = Enum.filter(ConversationBox.states(), &(Target.box_lifecycle(&1) == "failed"))
38
      assert failed == ["error"]
39
    end
40
41
    test "a Box is only startable once it is ready, idle, or already running" do
42
      active = Enum.filter(ConversationBox.states(), &(Target.box_lifecycle(&1) == "active"))
43
      assert Enum.sort(active) == ~w(idle ready running)
44
    end
45
46
    test "every declared Box run state maps into the delegation vocabulary" do
47
      for state <- Run.states() do
48
        assert Target.run_lifecycle(state) in Target.delegation_lifecycles(),
49
               "Box run state #{state} has no lifecycle"
50
      end
51
    end
52
53
    test "every declared work job status maps into the delegation vocabulary" do
54
      for status <- Job.statuses() do
55
        assert Target.job_lifecycle(status) in Target.delegation_lifecycles(),
56
               "work job status #{status} has no lifecycle"
57
      end
58
    end
59
60
    test "a lost or timed-out run and an interrupted or exhausted job all read as failed" do
61
      assert Target.run_lifecycle("lost") == "failed"
62
      assert Target.run_lifecycle("timed_out") == "failed"
63
      assert Target.job_lifecycle("interrupted") == "failed"
64
      assert Target.job_lifecycle("budget_exhausted") == "failed"
65
    end
66
67
    test "a terminal run and a terminal job agree on the word" do
68
      for state <- Run.terminal_states(), state != "lost" do
69
        refute Target.run_lifecycle(state) in ~w(queued starting active),
70
               "terminal run state #{state} still reads as live"
71
      end
72
    end
73
  end
74
75
  describe "a Computer's lifecycle" do
76
    test "an active, reachable computer is active" do
77
      assert Target.computer_lifecycle(machine(), true) == "active"
78
    end
79
80
    test "an active but unreachable computer is cold, never failed" do
81
      assert Target.computer_lifecycle(machine(), false) == "cold"
82
    end
83
84
    test "a revoked computer is destroyed even while a socket is still up" do
85
      revoked = %Machine{machine() | status: "revoked", revoked_at: DateTime.utc_now()}
86
87
      assert Target.computer_lifecycle(revoked, true) == "destroyed"
88
    end
89
90
    test "a revocation stamp alone is enough to read as destroyed" do
91
      stamped = %Machine{machine() | revoked_at: DateTime.utc_now()}
92
93
      assert Target.computer_lifecycle(stamped, true) == "destroyed"
94
    end
95
  end
96
97
  describe "custody" do
98
    test "is derived from the target kind and nothing else" do
99
      assert Target.custody("box") == "openagents_managed"
100
      assert Target.custody("computer") == "customer_premises"
101
    end
102
103
    test "covers every kind, and every value is admitted" do
104
      for kind <- Target.kinds() do
105
        assert Target.custody(kind) in Target.custodies()
106
      end
107
    end
108
109
    test "does not change with the lifecycle or the caller's authority" do
110
      for lifecycle <- Target.lifecycles(), authorized? <- [true, false] do
111
        assert Target.seam("box", lifecycle, authorized?)["custody"] == "openagents_managed"
112
        assert Target.seam("computer", lifecycle, authorized?)["custody"] == "customer_premises"
113
      end
114
    end
115
  end
116
117
  describe "runtime class" do
118
    test "a paired computer is the connected class the capacity catalog admits" do
119
      assert Target.runtime_class("computer") == "connected"
120
      assert OpenAgents.Capacity.Catalog.get("connected")["isolation"] == "customer_controlled"
121
    end
122
123
    test "a Box claims no class until its egress posture is established" do
124
      assert Target.runtime_class("box") == nil
125
    end
126
  end
127
128
  describe "capabilities" do
129
    test "an active target the caller can reach may be started" do
130
      assert Target.seam("box", "active", true)["capabilities"] == ["start"]
131
      assert Target.seam("computer", "active", true)["capabilities"] == ["start"]
132
    end
133
134
    test "no other lifecycle offers any capability" do
135
      for kind <- Target.kinds(), lifecycle <- Target.lifecycles(), lifecycle != "active" do
136
        assert Target.seam(kind, lifecycle, true)["capabilities"] == [],
137
               "#{kind} in #{lifecycle} advertised a capability"
138
      end
139
    end
140
141
    test "an unauthorized caller is offered nothing, whatever the lifecycle" do
142
      for kind <- Target.kinds(), lifecycle <- Target.lifecycles() do
143
        seam = Target.seam(kind, lifecycle, false)
144
145
        assert seam["capabilities"] == [], "#{kind} in #{lifecycle} leaked a capability"
146
      end
147
    end
148
149
    test "authority is refused by name, so it is not confused with a cold target" do
150
      assert Target.seam("computer", "active", false)["unavailable_reason"] == "not_authorized"
151
      assert Target.seam("computer", "cold", true)["unavailable_reason"] == "cold"
152
    end
153
154
    test "a reachable, authorized target gives no reason, because there is none" do
155
      assert Target.seam("box", "active", true)["unavailable_reason"] == nil
156
    end
157
158
    test "the reason for refusing is always the lifecycle itself" do
159
      for kind <- Target.kinds(), lifecycle <- Target.lifecycles(), lifecycle != "active" do
160
        assert Target.seam(kind, lifecycle, true)["unavailable_reason"] == lifecycle
161
      end
162
    end
163
  end
164
165
  describe "delegation capabilities" do
166
    test "a live delegation can be cancelled" do
167
      for lifecycle <- ~w(queued starting active) do
168
        assert Target.delegation_seam(lifecycle)["capabilities"] == ["cancel"]
169
      end
170
    end
171
172
    test "a finished delegation cannot" do
173
      for lifecycle <- ~w(succeeded failed cancelled) do
174
        assert Target.delegation_seam(lifecycle)["capabilities"] == []
175
      end
176
    end
177
178
    test "every delegation lifecycle is covered" do
179
      for lifecycle <- Target.delegation_lifecycles() do
180
        assert is_list(Target.delegation_seam(lifecycle)["capabilities"])
181
      end
182
    end
183
184
    test "every terminal work job status yields an uncancellable delegation" do
185
      for status <- ~w(completed failed cancelled interrupted budget_exhausted) do
186
        seam = status |> Target.job_lifecycle() |> Target.delegation_seam()
187
188
        assert seam["capabilities"] == [], "#{status} still offered cancel"
189
      end
190
    end
191
  end
192
193
  describe "the seam document" do
194
    test "carries the same fields for every kind and lifecycle" do
195
      expected = ~w(capabilities custody lifecycle runtime_class unavailable_reason)
196
197
      for kind <- Target.kinds(), lifecycle <- Target.lifecycles() do
198
        seam = Target.seam(kind, lifecycle, true)
199
200
        assert seam |> Map.keys() |> Enum.sort() == expected
201
        assert seam["lifecycle"] == lifecycle
202
      end
203
    end
204
  end
205
206
  defp machine do
207
    %Machine{
208
      id: Ecto.UUID.generate(),
209
      name: "workshop",
210
      status: "active",
211
      tier: "probe",
212
      revoked_at: nil
213
    }
214
  end
215
end

This page updates live while a promote is in flight · changelog