Publish when a computer was revoked, not only that it was

16f2155ccfa8 · AtlantisPleb · · parent 3aa4eecb76a6

Publish when a computer was revoked, not only that it was

`machines.revoked_at` is written by both revocation paths — `revoke_machine/2`
and the pairing-expiry branch — and was read by nothing.
`ComputerProjection.project/1` omitted it, and that projection is the only
shape a computer leaves this system in: the computers API, the delegation
target list, and the account export all render it.

So the owner's own subject-access export could say a computer was revoked and
never when. Every other credential in this release publishes both — `api_tokens`
through `ApiTokenController`, `inference_grants` and `agent_tokens` through
`AgentController`, `forge_assignment_credentials` through the assignment
surface. `machines` was the one that held the stamp and dropped it on the way
out.

This is a missing reader rather than dead storage. `updated_at` happens to
carry the same instant today, because `authenticate_token/1` refuses a revoked
computer and nothing touches the row afterwards, but that is a coincidence of
the current write set and not something anything states. The explicit stamp
exists to be read.

The projection still withholds what IDENTITY-008 keeps out — the token, its
digest, and the raw probe document — and a test asserts that alongside the new
key so widening the projection later has to argue with it. The `.v1` tool
schema in `OpenAgents.Tools.ComputerList` is left alone: it is a published wire
contract, and CANON-002 holds that half separately.

Mutations: removing the key from the projection fails four of the five
assertions, printing `left: nil` against the stored stamp; making
`revoke_machine/2` stop stamping fails two with a `DateTime.to_iso8601/3`
clause error on `nil`.

Refs #184.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTmy4SEXrHXouw5sZbs3f4
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.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified INVARIANTS.md
  • modified lib/openagents/computer_projection.ex
  • added test/openagents/computer_projection_test.exs

Diff

3 files changed, +108 -3

INVARIANTS.md modified +6 -2

@@ -438,7 +438,11 @@ Computer grant. The local computer controller remains authoritative over

438 438
declared roots, presence, probe-reported ACP agents, prompt bounds, and
439 439
execution; the API cannot widen a tier, add a root, or request an unadvertised
440 440
capability. Computer projections never expose a computer token, token digest, or
441
raw probe document.
441
raw probe document. They do expose `revoked_at`: `machines.revoked_at` was
442
written by both revocation paths and read by nothing, so the computers API and
443
the account export could say a computer was revoked and never when, alone among
444
this release's credentials. It is the owner's own record of their own computer
445
and is published with `status`.
442 446
443 447
Amended 2026-08-24 (issue #183): revoking a computer ends the inference
444 448
authority it holds, and cannot be raced.

@@ -4629,7 +4633,7 @@ contract; the invariant prose above defines the assertion, not the filename.

4629 4633
| IDENTITY-005 | `test/openagents_web/controllers/box_controller_test.exs` |
4630 4634
| IDENTITY-006 | `test/openagents/forge/assignment_test.exs` |
4631 4635
| IDENTITY-007 | `test/openagents/agents_test.exs` |
4632
| IDENTITY-008 | `test/openagents_web/controllers/computer_control_api_test.exs`, `test/openagents/inference/computer_revocation_test.exs` |
4636
| IDENTITY-008 | `test/openagents_web/controllers/computer_control_api_test.exs`, `test/openagents/inference/computer_revocation_test.exs`, `test/openagents/computer_projection_test.exs` |
4633 4637
| IDENTITY-009 | `test/openagents_web/controllers/delegations_controller_test.exs` |
4634 4638
| IDENTITY-010 | `test/openagents/forge/assignment_test.exs`, `test/openagents/forge/assignment_credential_reach_test.exs` |
4635 4639
| IDENTITY-011 | `test/openagents/machines/pairing_expiry_test.exs` |
lib/openagents/computer_projection.ex modified +14 -1

@@ -1,5 +1,17 @@

1 1
defmodule OpenAgents.ComputerProjection do
2
  @moduledoc "Safe projection of a paired computer for API and product surfaces."
2
  @moduledoc """
3
  Safe projection of a paired computer for API and product surfaces.
4
5
  Everything here is the owner's own record of their own computer. What stays
6
  out is what IDENTITY-008 keeps out: the computer token, its digest, and the
7
  raw probe document.
8
9
  `revoked_at` is included because `status` alone answers half the question.
10
  Every other credential in this release publishes both — `api_tokens`,
11
  `inference_grants`, `agent_tokens`, `forge_assignment_credentials` — and this
12
  was the one terminal stamp the database held and nothing read, so an account
13
  export could tell you a computer was revoked and never when.
14
  """
3 15
4 16
  alias OpenAgents.Computer
5 17
  alias OpenAgents.Machines.Machine

@@ -11,6 +23,7 @@ defmodule OpenAgents.ComputerProjection do

11 23
      "name" => machine.name,
12 24
      "tier" => machine.tier,
13 25
      "status" => machine.status,
26
      "revoked_at" => iso8601(machine.revoked_at),
14 27
      "scoped_forge_credentials_enabled" => machine.scoped_forge_credentials_enabled,
15 28
      "online" => machine.status == "active" and Computer.online?(machine.id),
16 29
      "platform" => machine.platform,
test/openagents/computer_projection_test.exs added +88

@@ -0,0 +1,88 @@

1
defmodule OpenAgents.ComputerProjectionTest do
2
  @moduledoc """
3
  `machines.revoked_at` was written by both revocation paths and read by
4
  nothing: `ComputerProjection.project/1` omitted it, so the account export and
5
  the computers API reported that a computer was revoked without saying when.
6
  """
7
8
  use OpenAgents.DataCase, async: true
9
10
  alias OpenAgents.ComputerProjection
11
  alias OpenAgents.Machines
12
  alias OpenAgents.Machines.Machine
13
  alias OpenAgents.Repo
14
15
  defp user(key) do
16
    {:ok, user} =
17
      OpenAgents.Accounts.upsert_github_user(%{
18
        github_id: :erlang.phash2({__MODULE__, key}),
19
        github_login: "projection-#{key}",
20
        github_avatar_url: "https://avatars.githubusercontent.com/u/1?v=4"
21
      })
22
23
    user
24
  end
25
26
  defp computer(owner, key) do
27
    {:ok, %{code: code}} = Machines.start_pairing(%{"name" => "box-#{key}", "tier" => "probe"})
28
    {:ok, machine} = Machines.approve_pairing(owner, code)
29
    machine
30
  end
31
32
  test "an active computer reports no revocation" do
33
    projection = ComputerProjection.project(computer(user("active"), "active"))
34
35
    assert projection["status"] == "active"
36
    assert Map.fetch!(projection, "revoked_at") == nil
37
  end
38
39
  test "a revoked computer reports when it was revoked" do
40
    owner = user("revoked")
41
    machine = computer(owner, "revoked")
42
43
    {:ok, revoked} = Machines.revoke_machine(owner, machine.id)
44
    projection = ComputerProjection.project(revoked)
45
46
    assert projection["status"] == "revoked"
47
    assert projection["revoked_at"] == DateTime.to_iso8601(revoked.revoked_at)
48
  end
49
50
  test "the stamp the account export carries is the stamp the row holds" do
51
    owner = user("export")
52
    machine = computer(owner, "export")
53
    {:ok, _revoked} = Machines.revoke_machine(owner, machine.id)
54
55
    # Projected from a fresh read, the way every caller reaches it.
56
    [projected] = Enum.map(Machines.list_machines(owner.id), &ComputerProjection.project/1)
57
    stored = Repo.get!(Machine, machine.id)
58
59
    assert projected["revoked_at"] == DateTime.to_iso8601(stored.revoked_at)
60
  end
61
62
  test "a computer revoked by an elapsed pairing reports its stamp too" do
63
    owner = user("swept")
64
    machine = computer(owner, "swept")
65
66
    OpenAgents.Machines.Pairing
67
    |> Repo.get_by!(machine_id: machine.id)
68
    |> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), -3_600, :second))
69
    |> Repo.update!()
70
71
    assert Machines.expire_elapsed_pairings() == 1
72
73
    projection = ComputerProjection.project(Repo.get!(Machine, machine.id))
74
    assert projection["status"] == "revoked"
75
    assert is_binary(projection["revoked_at"])
76
  end
77
78
  test "the projection still withholds the token, its digest, and the raw probe" do
79
    machine = computer(user("secrets"), "secrets")
80
    {:ok, machine} = Machines.store_probe(machine, %{"acp_agents" => [], "secret" => "x"})
81
82
    projection = ComputerProjection.project(machine)
83
84
    refute Map.has_key?(projection, "token_digest")
85
    refute Map.has_key?(projection, "token_expires_at")
86
    refute Map.has_key?(projection, "last_probe")
87
  end
88
end

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