Record why a request cannot reach the computer tables' constraints

7a58c2ffeaff · AtlantisPleb · · parent 3db40dc5a039

Record why a request cannot reach the computer tables' constraints

Five constraints on `machines` and `machine_pairings` have no changeset
mapping, so a violation raises `Postgrex.Error` instead of returning an invalid
changeset. That is a defect where a user-supplied value can reach the
constraint. None of these five can be, so each is recorded rather than mapped:
mapping claims a population, and an empty population is a claim nothing tests.

`status` is not in either `cast/3` list, and every writer passes a literal, so
neither status check is reachable. `token_digest` and `code_digest` take 256
random bits and a random code. `token_expires_at` is not cast either.

The issue reported `machines_token_expiry_after_creation` as reachable through
a non-positive `:machine_token_ttl_seconds`, which would make every pairing
approval raise. That is stale. `OpenAgents.RuntimeConfig` admits the setting
only in `300..2_592_000` and `install!/0` raises before the endpoint starts, so
the node cannot boot into the state that reaches the constraint. The proof
asserts the refusal at `0`, `-1`, and `-86_400`, and the admission at the `300`
floor.

`machine_pairings_code_digest_index` is the weakest of the five and still does
not qualify. The code space is `30^8` and rows are never deleted, so a
collision is a real event at a scale this release will not see; it costs the
caller one `500` and a retry that succeeds, against a value the caller never
supplied and cannot correct. Wiring a retry would be machinery for an event no
test can produce.

Because each reason is a property of the code rather than a fact about it, the
record is an assertion. The proof pushes the whole hostile parameter set —
every guarded column, through both changesets, including the unauthenticated
pairing entry point — and fails if any guarded field becomes castable. It then
reaches all five constraints directly with raw SQL and requires each to refuse,
so the record also fails if a constraint is dropped or weakened.

Mutations: casting `:status` in `Machine.create_changeset` fails "the computer
changeset casts none of them" with `status became castable`; the same in
`Pairing.create_changeset` fails two; widening the admitted TTL range to
`0..2_592_000` fails the boot-admission test; dropping `machines_status_check`
from the database fails both its load-bearing test with `Expected exception
Postgrex.Error but nothing was raised` and CANON-002's ledger.

IDENTITY-012 states the boundary.

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
  • added test/openagents/machines/constraint_reach_test.exs

Diff

2 files changed, +245 -0

INVARIANTS.md modified +46

@@ -499,6 +499,51 @@ Evidence: `OpenAgentsWeb.Plugs.AssignmentControlAuth`,

499 499
`test/openagents_web/controllers/computer_control_api_test.exs`, and
500 500
`test/openagents/inference/computer_revocation_test.exs`.
501 501
502
### IDENTITY-012 — The computer tables' constraints are unreachable from a request
503
504
Status: Current
505
506
Five constraints on `machines` and `machine_pairings` have no
507
`check_constraint/2` or `unique_constraint/2` beside them, so a violation
508
arrives as a `Postgrex.Error` and not an invalid changeset. That is a defect
509
where a user-supplied value can reach the constraint: a form should have
510
refused the input, and instead the request answers `500`. None of these five
511
can be reached that way, so each is recorded rather than mapped. Mapping a
512
constraint to a changeset error claims a population, and an empty population is
513
a claim nothing tests.
514
515
| Constraint | Why a request cannot reach it |
516
| --- | --- |
517
| `machines_status_check` | `status` is not cast; every writer passes a literal |
518
| `machine_pairings_status_check` | the same |
519
| `machines_token_expiry_after_creation` | `token_expires_at` is not cast, and its TTL is admitted at boot |
520
| `machines_token_digest_index` | 256 bits from `:crypto.strong_rand_bytes/1` |
521
| `machine_pairings_code_digest_index` | a code from the same generator |
522
523
The last row is the weakest of the five and still holds: the code space is
524
`30^8`, rows are never deleted, so a collision is a real event at a scale this
525
release will not see, and it costs the caller one `500` and a retry that
526
succeeds. It is recorded, not mapped, because the caller supplied nothing to
527
correct.
528
529
`machines_token_expiry_after_creation` was reported as reachable through a
530
non-positive `:machine_token_ttl_seconds`, which would have made every pairing
531
approval raise. It is not: `OpenAgents.RuntimeConfig` admits that setting only
532
in `300..2_592_000` and `install!/0` raises before the endpoint starts, so the
533
node cannot boot into the state that would reach the constraint.
534
535
The reason each is unreachable is a property of the code and not a fact about
536
it, so it is asserted rather than described. The proof feeds the whole hostile
537
parameter set — every guarded column, on both write paths, including the
538
unauthenticated one — through both changesets and fails if any guarded field
539
becomes castable. It then reaches each constraint directly with raw SQL and
540
requires it to refuse, so a constraint that is dropped or weakened fails here
541
as well as in CANON-002's ledger.
542
543
Evidence: `OpenAgents.Machines.Machine`, `OpenAgents.Machines.Pairing`,
544
`OpenAgents.RuntimeConfig`, and
545
`test/openagents/machines/constraint_reach_test.exs`.
546
502 547
### IDENTITY-011 — A pairing window closes on its own clock
503 548
504 549
Status: Current

@@ -4637,6 +4682,7 @@ contract; the invariant prose above defines the assertion, not the filename.

4637 4682
| IDENTITY-009 | `test/openagents_web/controllers/delegations_controller_test.exs` |
4638 4683
| IDENTITY-010 | `test/openagents/forge/assignment_test.exs`, `test/openagents/forge/assignment_credential_reach_test.exs` |
4639 4684
| IDENTITY-011 | `test/openagents/machines/pairing_expiry_test.exs` |
4685
| IDENTITY-012 | `test/openagents/machines/constraint_reach_test.exs` |
4640 4686
| CAPACITY-002 | `test/openagents/box_fanout_test.exs` |
4641 4687
| CAPACITY-003 | `test/openagents/box_reconciler_test.exs` |
4642 4688
| WORK-002 | `test/openagents/box_runs_test.exs` |
test/openagents/machines/constraint_reach_test.exs added +199

@@ -0,0 +1,199 @@

1
defmodule OpenAgents.Machines.ConstraintReachTest do
2
  @moduledoc """
3
  Issue #184 lists five PostgreSQL constraints on the computer tables with no
4
  `check_constraint/2` or `unique_constraint/2` beside them, so a violation
5
  arrives as a `Postgrex.Error` rather than an invalid changeset.
6
7
  That is a defect when a user-supplied value can reach the constraint: a form
8
  should have refused the input, and instead the request 500s. None of these
9
  five can be reached that way, so each is recorded here rather than mapped —
10
  and recorded as an assertion, because the reason it cannot be reached is a
11
  property of the code that can change.
12
13
  | Constraint | Guarded by |
14
  | --- | --- |
15
  | `machines_status_check` | `status` is not cast; writers pass literals |
16
  | `machine_pairings_status_check` | same |
17
  | `machines_token_expiry_after_creation` | `token_expires_at` is not cast, and its TTL is admitted at boot |
18
  | `machines_token_digest_index` | 256 random bits, not user input |
19
  | `machine_pairings_code_digest_index` | a random code, not user input |
20
21
  The constraints stay. They are the last defense against a future writer, and
22
  the tests below show each one still refusing a bad row when reached directly.
23
  Mapping them would claim a population that cannot occur.
24
  """
25
26
  use OpenAgents.DataCase, async: true
27
28
  alias OpenAgents.Machines
29
  alias OpenAgents.Machines.{Machine, Pairing}
30
  alias OpenAgents.Repo
31
  alias OpenAgents.RuntimeConfig
32
33
  # Everything an attacker controls on the two unauthenticated or
34
  # owner-facing write paths, plus every field the constraints guard.
35
  @hostile %{
36
    "name" => "box",
37
    "tier" => "probe",
38
    "status" => "compromised",
39
    "token_digest" => "chosen",
40
    "token_expires_at" => ~U[1970-01-01 00:00:00.000000Z],
41
    "revoked_at" => ~U[1970-01-01 00:00:00.000000Z],
42
    "code_digest" => "chosen",
43
    "poll_secret_digest" => "chosen",
44
    "expires_at" => ~U[1970-01-01 00:00:00.000000Z],
45
    "user_id" => Ecto.UUID.generate(),
46
    "machine_id" => Ecto.UUID.generate()
47
  }
48
49
  @guarded ~w(status token_digest token_expires_at code_digest expires_at)a
50
51
  describe "no user-supplied value reaches the guarded columns" do
52
    test "the computer changeset casts none of them" do
53
      changes = Machine.create_changeset(%Machine{}, @hostile).changes
54
55
      for field <- @guarded do
56
        refute Map.has_key?(changes, field),
57
               "#{field} became castable; it now reaches a constraint with no changeset mapping"
58
      end
59
    end
60
61
    test "the pairing changeset casts none of them" do
62
      changes = Pairing.create_changeset(%Pairing{}, @hostile).changes
63
64
      for field <- @guarded do
65
        refute Map.has_key?(changes, field),
66
               "#{field} became castable; it now reaches a constraint with no changeset mapping"
67
      end
68
    end
69
70
    test "a hostile pairing request still produces an ordinary pending row" do
71
      # The unauthenticated entry point, carrying the whole hostile map.
72
      assert {:ok, %{pairing: pairing}} = Machines.start_pairing(@hostile)
73
74
      assert pairing.status == "pending"
75
      assert pairing.code_digest != "chosen"
76
      assert DateTime.compare(pairing.expires_at, DateTime.utc_now()) == :gt
77
    end
78
  end
79
80
  describe "machines_token_expiry_after_creation is guarded before boot, not at insert" do
81
    test "the admitted TTL range cannot produce a row the constraint refuses" do
82
      settings = Application.get_all_env(:openagents)
83
84
      for ttl <- [0, -1, -86_400] do
85
        assert {:error, _reason} =
86
                 RuntimeConfig.validate(Keyword.put(settings, :machine_token_ttl_seconds, ttl)),
87
               "a TTL of #{ttl} would make every pairing approval raise, and boot admitted it"
88
      end
89
90
      assert {:ok, _config} =
91
               RuntimeConfig.validate(Keyword.put(settings, :machine_token_ttl_seconds, 300))
92
    end
93
  end
94
95
  describe "the constraints are still load-bearing" do
96
    test "machines_status_check refuses a status reached around the changeset" do
97
      owner = owner()
98
99
      assert_raise Postgrex.Error, ~r/machines_status_check/, fn ->
100
        Repo.query!(
101
          """
102
          INSERT INTO machines
103
            (id, user_id, name, tier, roots, token_digest, token_expires_at, status,
104
             scoped_forge_credentials_enabled, inserted_at, updated_at)
105
          VALUES (gen_random_uuid(), $1, 'x', 'probe', '{}', 'd', NOW() + INTERVAL '1 day',
106
                  'compromised', false, NOW(), NOW())
107
          """,
108
          [Ecto.UUID.dump!(owner.id)]
109
        )
110
      end
111
    end
112
113
    test "machines_token_expiry_after_creation refuses an expiry before creation" do
114
      owner = owner()
115
116
      assert_raise Postgrex.Error, ~r/machines_token_expiry_after_creation/, fn ->
117
        Repo.query!(
118
          """
119
          INSERT INTO machines
120
            (id, user_id, name, tier, roots, token_digest, token_expires_at, status,
121
             scoped_forge_credentials_enabled, inserted_at, updated_at)
122
          VALUES (gen_random_uuid(), $1, 'x', 'probe', '{}', 'd', NOW() - INTERVAL '1 day',
123
                  'active', false, NOW(), NOW())
124
          """,
125
          [Ecto.UUID.dump!(owner.id)]
126
        )
127
      end
128
    end
129
130
    test "machine_pairings_status_check refuses a status reached around the changeset" do
131
      assert_raise Postgrex.Error, ~r/machine_pairings_status_check/, fn ->
132
        Repo.query!(
133
          """
134
          INSERT INTO machine_pairings
135
            (id, code_digest, poll_secret_digest, name, tier, roots, status, expires_at,
136
             inserted_at, updated_at)
137
          VALUES (gen_random_uuid(), 'a', 'b', 'x', 'probe', '{}', 'compromised',
138
                  NOW() + INTERVAL '1 hour', NOW(), NOW())
139
          """,
140
          []
141
        )
142
      end
143
    end
144
145
    test "machine_pairings_code_digest_index refuses a duplicate code" do
146
      %{pairing: first} = start_pairing()
147
      digest = Repo.get!(Pairing, first.id).code_digest
148
149
      assert_raise Postgrex.Error, ~r/machine_pairings_code_digest_index/, fn ->
150
        Repo.query!(
151
          """
152
          INSERT INTO machine_pairings
153
            (id, code_digest, poll_secret_digest, name, tier, roots, status, expires_at,
154
             inserted_at, updated_at)
155
          VALUES (gen_random_uuid(), $1, 'b', 'x', 'probe', '{}', 'pending',
156
                  NOW() + INTERVAL '1 hour', NOW(), NOW())
157
          """,
158
          [digest]
159
        )
160
      end
161
    end
162
163
    test "machines_token_digest_index refuses a duplicate computer token" do
164
      owner = owner()
165
      %{code: code} = start_pairing()
166
      {:ok, machine} = Machines.approve_pairing(owner, code)
167
      digest = Repo.get!(Machine, machine.id).token_digest
168
169
      assert_raise Postgrex.Error, ~r/machines_token_digest_index/, fn ->
170
        Repo.query!(
171
          """
172
          INSERT INTO machines
173
            (id, user_id, name, tier, roots, token_digest, token_expires_at, status,
174
             scoped_forge_credentials_enabled, inserted_at, updated_at)
175
          VALUES (gen_random_uuid(), $1, 'x', 'probe', '{}', $2, NOW() + INTERVAL '1 day',
176
                  'active', false, NOW(), NOW())
177
          """,
178
          [Ecto.UUID.dump!(owner.id), digest]
179
        )
180
      end
181
    end
182
  end
183
184
  defp owner do
185
    {:ok, user} =
186
      OpenAgents.Accounts.upsert_github_user(%{
187
        github_id: :erlang.phash2({__MODULE__, System.unique_integer()}),
188
        github_login: "reach-#{System.unique_integer([:positive])}",
189
        github_avatar_url: "https://avatars.githubusercontent.com/u/1?v=4"
190
      })
191
192
    user
193
  end
194
195
  defp start_pairing do
196
    {:ok, started} = Machines.start_pairing(%{"name" => "box", "tier" => "probe"})
197
    started
198
  end
199
end

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