Close a pairing window on its own clock instead of on a poll

3aa4eecb76a6 · AtlantisPleb · · parent f6d98d2b54ab

Close a pairing window on its own clock instead of on a poll

`machine_pairings.expires_at` recorded a deadline that nothing read, which is
why `machine_pairings_expires_at_index` had no reader. The index was not the
defect; the missing reader was.

`claim_pairing/2` expired an elapsed pairing, and it was the only thing that
ever did, so the window closed only when the CLI knocked on it. A pairing the
CLI stopped polling — killed, disconnected, or simply abandoned after the owner
approved it — stayed `approved` forever. A run against the test database, not a
grep, shows what that leaves behind a day later: `status=approved`, 77 bytes of
`token_ciphertext` that `TokenVault.open/1` returns a live `smct_` token from,
and the computer the approval created still `active`.

`OpenAgents.Machines.TokenVault` carries one version and one AAD on the
strength of a bound it does not hold — "a sealed token lives on a
`machine_pairings` row for at most `@pairing_lifetime_seconds`" — and CANON-002
repeats it as settled. `test/openagents/machines_test.exs` appeared to cover it
and does not: it asserts `expires_at - inserted_at <= 600`, which is the
deadline being written, not the ciphertext being gone.

`expire_elapsed_pairings/0` is the reader, and `OpenAgents.Machines.
PairingExpiry` runs it on the interval and shape `Forge.AssignmentExpiry`
already uses. It applies `claim_pairing/2`'s own expiry rather than a second,
weaker one: `expire_locked_pairing/1` unchanged, under each row's `FOR UPDATE`.
So the status moves to `expired`, `token_ciphertext` is nulled, and any
computer the pairing created is revoked with the inference grants it holds —
IDENTITY-008 inherited intact, because it is the same call inside the same
lock. A test asserts the swept row and a late-claimed row are byte-identical.
The revocation is now announced on the computer's topic; that path never had to
before, because nothing ran it without a bearer.

Two mutations of the sweep's predicate came back green: selecting `claimed`
rows too, and dropping `expires_at` entirely. `expire_elapsed_pairing/1`
re-reads each row under its lock and refuses anything terminal or still fresh,
so a widened selection changes no row and no return value — it only stops using
the index and scans the table every sixty seconds, invisibly, which is exactly
the defect being closed. `elapsed_pairing_query/1` is public for that reason and
proved on its own terms: which rows it names, and `EXPLAIN` of that same query
naming `machine_pairings_expires_at_index` with sequential scans priced out.
Both mutations are red now; the second prints the `Seq Scan` it falls back to.

IDENTITY-011 states the window, and CANON-002 now points at what enforces it.

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/application.ex
  • modified lib/openagents/machines.ex
  • added lib/openagents/machines/pairing_expiry.ex
  • modified lib/openagents/machines/token_vault.ex
  • added test/openagents/machines/pairing_expiry_test.exs

Diff

6 files changed, +407 -8

INVARIANTS.md modified +51 -2

@@ -65,8 +65,9 @@ instead, held by the contracts that own each surface.

65 65
A retained name owes a population, and the population must be established from
66 66
something that cannot lie rather than from a reading of the call sites. The
67 67
retired AAD `sarah.machine_token.v1` had none — nothing seals a version-1 blob
68
and a sealed pairing token cannot outlive its bounded window — so the token
69
vault carries one version and one AAD. The audit actor kind `machine` looked
68
and a sealed pairing token cannot outlive its bounded window, which
69
IDENTITY-011 now enforces rather than assumes — so the token vault carries one
70
version and one AAD. The audit actor kind `machine` looked
70 71
the same and is not: a paired computer authenticating to the Git plane pushes
71 72
under `{:machine, id}`, from a variable that no source scan for a literal
72 73
finds. It stays, and the proof asserts that every principal kind

@@ -494,6 +495,53 @@ Evidence: `OpenAgentsWeb.Plugs.AssignmentControlAuth`,

494 495
`test/openagents_web/controllers/computer_control_api_test.exs`, and
495 496
`test/openagents/inference/computer_revocation_test.exs`.
496 497
498
### IDENTITY-011 — A pairing window closes on its own clock
499
500
Status: Current
501
502
A computer pairing is a bounded window: the CLI registers it, the account owner
503
approves it in the browser, and the CLI claims the computer token exactly once.
504
`OpenAgents.Machines` seals that token at rest for the claim window alone, and
505
`OpenAgents.Machines.TokenVault` carries one version and one AAD on the
506
strength of that bound — CANON-002 states it as settled.
507
508
Nothing enforced it. `claim_pairing/2` expired an elapsed pairing, and that was
509
the only thing that ever did, so the window closed only when someone knocked on
510
it. A pairing the CLI stopped polling — killed, disconnected, or abandoned
511
after approval — stayed `approved` indefinitely: `token_ciphertext` held a
512
sealed, openable `smct_` token long past the window, and the computer the
513
approval created stayed `active`, counting against the owner's capacity and
514
issuing operator-approval receipts for a computer that never connected.
515
`machine_pairings.expires_at` recorded the deadline and nothing read it, which
516
is why `machine_pairings_expires_at_index` had no reader.
517
518
`OpenAgents.Machines.expire_elapsed_pairings/0` is that reader, and
519
`OpenAgents.Machines.PairingExpiry` runs it on the same interval and shape as
520
`OpenAgents.Forge.AssignmentExpiry`. It applies `claim_pairing/2`'s own expiry
521
rather than a second, weaker one: each row is re-read under `FOR UPDATE`, the
522
status moves to `expired`, `token_ciphertext` is nulled, and any computer the
523
pairing created is revoked together with the inference grants it holds. That
524
last step inherits IDENTITY-008 unchanged, because it is the same call inside
525
the same lock. A sweep and a late claim racing the same row therefore have one
526
winner, and the loser finds a status it no longer acts on. The revocation is
527
announced on the computer's topic, which this path had never done because until
528
now nothing ran it without a bearer.
529
530
The selection is proved separately from the outcomes, and the split is the
531
point. `expire_elapsed_pairing/1` refuses anything terminal or still fresh on
532
its own re-read, so widening the outer predicate changes no row and no return
533
value — every outcome test stays green while the sweep silently stops using the
534
index and starts scanning the table each minute, which is the defect this entry
535
closes. `OpenAgents.Machines.elapsed_pairing_query/1` is therefore public and
536
asserted twice: on exactly which rows it names, and on `EXPLAIN` of that same
537
query naming `machine_pairings_expires_at_index` with sequential scans priced
538
out.
539
540
Evidence: `OpenAgents.Machines.expire_elapsed_pairings/0`,
541
`OpenAgents.Machines.elapsed_pairing_query/1`,
542
`OpenAgents.Machines.PairingExpiry`, `OpenAgents.Machines.TokenVault`, and
543
`test/openagents/machines/pairing_expiry_test.exs`.
544
497 545
### IDENTITY-009 — Unified delegation preserves substrate authority
498 546
499 547
Status: Current

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

4584 4632
| IDENTITY-008 | `test/openagents_web/controllers/computer_control_api_test.exs`, `test/openagents/inference/computer_revocation_test.exs` |
4585 4633
| IDENTITY-009 | `test/openagents_web/controllers/delegations_controller_test.exs` |
4586 4634
| IDENTITY-010 | `test/openagents/forge/assignment_test.exs`, `test/openagents/forge/assignment_credential_reach_test.exs` |
4635
| IDENTITY-011 | `test/openagents/machines/pairing_expiry_test.exs` |
4587 4636
| CAPACITY-002 | `test/openagents/box_fanout_test.exs` |
4588 4637
| CAPACITY-003 | `test/openagents/box_reconciler_test.exs` |
4589 4638
| WORK-002 | `test/openagents/box_runs_test.exs` |
lib/openagents/application.ex modified +2 -1

@@ -60,7 +60,8 @@ defmodule OpenAgents.Application do

60 60
        OpenAgents.BoxRunRecovery,
61 61
        OpenAgents.Box.Reconciler,
62 62
        OpenAgents.Forge.AssignmentExpiry,
63
        OpenAgents.Forge.AssignmentCredentialVault
63
        OpenAgents.Forge.AssignmentCredentialVault,
64
        OpenAgents.Machines.PairingExpiry
64 65
      ] ++ analytics_children() ++ [OpenAgentsWeb.Endpoint]
65 66
66 67
    # See https://elixir.hexdocs.pm/Supervisor.html
lib/openagents/machines.ex modified +82 -4

@@ -343,6 +343,72 @@ defmodule OpenAgents.Machines do

343 343
    end
344 344
  end
345 345
346
  @doc """
347
  Expire every pairing whose window has closed, without waiting for a poll.
348
349
  `claim_pairing/2` already expires an elapsed pairing, and that was the only
350
  thing that ever did. A pairing the CLI stops polling — killed, disconnected,
351
  or simply abandoned after the owner approved it — therefore stayed `approved`
352
  forever, holding the sealed computer token `TokenVault` promises lives only
353
  for the claim window, and leaving the computer it created `active` with a
354
  token nobody ever received.
355
356
  This is the same transition without a bearer. It reads
357
  `machine_pairings.expires_at`, which is what
358
  `machine_pairings_expires_at_index` exists for and what nothing read, then
359
  applies `claim_pairing/2`'s own expiry to each row under that row's lock: the
360
  status moves to `expired`, `token_ciphertext` is nulled, and any computer the
361
  pairing created is revoked along with the inference grants it holds
362
  (IDENTITY-008, IDENTITY-011).
363
  """
364
  @spec expire_elapsed_pairings() :: non_neg_integer()
365
  def expire_elapsed_pairings do
366
    DateTime.utc_now()
367
    |> elapsed_pairing_ids()
368
    |> Enum.count(&(expire_elapsed_pairing(&1) == :ok))
369
  end
370
371
  @doc """
372
  The rows `expire_elapsed_pairings/0` will act on, as of `now`.
373
374
  This is a separate function because it is the only thing in the release that
375
  reads `machine_pairings.expires_at`, and therefore the only reader
376
  `machine_pairings_expires_at_index` has. `expire_elapsed_pairing/1` re-reads
377
  each row under its own lock and refuses anything terminal or still fresh, so
378
  a widened predicate here changes no outcome and no result — it only makes the
379
  sweep read rows it will not touch, and stops using the index, invisibly. The
380
  selection is proved on its own for that reason.
381
  """
382
  @spec elapsed_pairing_ids(DateTime.t()) :: [Ecto.UUID.t()]
383
  def elapsed_pairing_ids(%DateTime{} = now), do: now |> elapsed_pairing_query() |> Repo.all()
384
385
  @doc "The selection above, unexecuted, so a proof can read its plan."
386
  @spec elapsed_pairing_query(DateTime.t()) :: Ecto.Query.t()
387
  def elapsed_pairing_query(%DateTime{} = now) do
388
    Pairing
389
    |> where([p], p.status in ["pending", "approved"] and p.expires_at <= ^now)
390
    |> select([p], p.id)
391
  end
392
393
  # One transaction per row. The re-read under `FOR UPDATE` is what makes the
394
  # sweep safe next to a claim arriving at the same moment: whichever takes the
395
  # lock first moves the row, and the other finds a status it no longer acts on.
396
  defp expire_elapsed_pairing(pairing_id) do
397
    Repo.transaction(fn ->
398
      case Repo.one(from(p in Pairing, where: p.id == ^pairing_id, lock: "FOR UPDATE")) do
399
        %Pairing{status: status} = pairing when status in ["pending", "approved"] ->
400
          if expired?(pairing), do: expire_locked_pairing(pairing), else: :fresh
401
402
        _consumed ->
403
          :consumed
404
      end
405
    end)
406
    |> case do
407
      {:ok, :ok} -> :ok
408
      _unchanged -> :unchanged
409
    end
410
  end
411
346 412
  defp verify_capacity(user_id) do
347 413
    now = DateTime.utc_now()
348 414

@@ -414,15 +480,27 @@ defmodule OpenAgents.Machines do

414 480
    |> Repo.update!()
415 481
416 482
    if pairing.machine_id do
417
      from(machine in Machine,
418
        where: machine.id == ^pairing.machine_id and machine.status == "active"
419
      )
420
      |> Repo.update_all(set: [status: "revoked", revoked_at: now, updated_at: now])
483
      {revoked, _returned} =
484
        from(machine in Machine,
485
          where: machine.id == ^pairing.machine_id and machine.status == "active"
486
        )
487
        |> Repo.update_all(set: [status: "revoked", revoked_at: now, updated_at: now])
421 488
422 489
      # The other revocation path, and it owes the same thing: this runs inside
423 490
      # the claim transaction, after the update has taken the computer row's
424 491
      # lock, so an interleaved mint cannot slip a live grant past it.
425 492
      _ = OpenAgents.Inference.revoke_active_for_machine(pairing.machine_id)
493
494
      # `revoke_machine/2` announces its revocation so the computer's channel
495
      # closes and an open /computers stops showing the card as live. This path
496
      # revokes too, and until the sweep existed nothing ever ran it headlessly.
497
      if revoked > 0 do
498
        Phoenix.PubSub.broadcast(
499
          OpenAgents.PubSub,
500
          "machine:#{pairing.machine_id}",
501
          {:machine_revoked, pairing.machine_id}
502
        )
503
      end
426 504
    end
427 505
428 506
    :ok
lib/openagents/machines/pairing_expiry.ex added +34

@@ -0,0 +1,34 @@

1
defmodule OpenAgents.Machines.PairingExpiry do
2
  @moduledoc """
3
  Periodically expires pairings whose windows have closed.
4
5
  Without it the only thing that expired a pairing was the CLI polling for its
6
  token, so an abandoned pairing kept a sealed computer token and a live
7
  computer indefinitely. See `INVARIANTS.md`, IDENTITY-011.
8
  """
9
10
  use GenServer
11
12
  @interval_ms 60_000
13
14
  @spec start_link(keyword()) :: GenServer.on_start()
15
  def start_link(options \\ []) do
16
    GenServer.start_link(__MODULE__, options, name: __MODULE__)
17
  end
18
19
  @impl true
20
  def init(options) do
21
    interval_ms = Keyword.get(options, :interval_ms, @interval_ms)
22
    schedule(interval_ms)
23
    {:ok, interval_ms}
24
  end
25
26
  @impl true
27
  def handle_info(:expire, interval_ms) do
28
    _ = OpenAgents.Machines.expire_elapsed_pairings()
29
    schedule(interval_ms)
30
    {:noreply, interval_ms}
31
  end
32
33
  defp schedule(interval_ms), do: Process.send_after(self(), :expire, interval_ms)
34
end
lib/openagents/machines/token_vault.ex modified +4 -1

@@ -8,7 +8,10 @@ defmodule OpenAgents.Machines.TokenVault do

8 8
  `OpenAgents.Machines` `@pairing_lifetime_seconds`, and both terminal
9 9
  transitions — claim and expiry — null `token_ciphertext` on the way out. A
10 10
  compatibility branch with an empty population is a name and a claim that
11
  nothing tests. See `INVARIANTS.md`, CANON-002.
11
  nothing tests. That bound is enforced by
12
  `OpenAgents.Machines.expire_elapsed_pairings/0`; before it existed, expiry
13
  ran only when the CLI polled, so an abandoned pairing held its sealed token
14
  indefinitely. See `INVARIANTS.md`, CANON-002 and IDENTITY-011.
12 15
13 16
  The `openagents.machine_token.v2` AAD keeps its `machine` spelling for the
14 17
  opposite reason: it is bound into ciphertext this release did not write.
test/openagents/machines/pairing_expiry_test.exs added +234

@@ -0,0 +1,234 @@

1
defmodule OpenAgents.Machines.PairingExpiryTest do
2
  @moduledoc """
3
  IDENTITY-011: a pairing window closes on its own clock.
4
5
  Before the sweep, `claim_pairing/2` was the only thing that expired a
6
  pairing, so every property here failed for the same reason: nobody polled.
7
  """
8
9
  use OpenAgents.DataCase, async: true
10
11
  alias OpenAgents.Inference
12
  alias OpenAgents.Machines
13
  alias OpenAgents.Machines.{Machine, Pairing, TokenVault}
14
  alias OpenAgents.Repo
15
16
  defp user(key) do
17
    {:ok, user} =
18
      OpenAgents.Accounts.upsert_github_user(%{
19
        github_id: :erlang.phash2({__MODULE__, key}),
20
        github_login: "pairing-expiry-#{key}",
21
        github_avatar_url: "https://avatars.githubusercontent.com/u/1?v=4"
22
      })
23
24
    user
25
  end
26
27
  defp start_pairing do
28
    {:ok, started} = Machines.start_pairing(%{"name" => "box", "tier" => "probe"})
29
    started
30
  end
31
32
  defp close_window(pairing_id) do
33
    Pairing
34
    |> Repo.get!(pairing_id)
35
    |> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), -3_600, :second))
36
    |> Repo.update!()
37
  end
38
39
  test "an approved pairing nobody claims loses its sealed token" do
40
    %{pairing: pairing, code: code} = start_pairing()
41
    {:ok, _machine} = Machines.approve_pairing(user("sealed"), code)
42
43
    assert is_binary(Repo.get!(Pairing, pairing.id).token_ciphertext),
44
           "the fixture must actually hold a sealed token"
45
46
    close_window(pairing.id)
47
48
    assert Machines.expire_elapsed_pairings() == 1
49
50
    swept = Repo.get!(Pairing, pairing.id)
51
    assert swept.status == "expired"
52
    assert is_nil(swept.token_ciphertext)
53
  end
54
55
  test "the computer an unclaimed pairing created is revoked" do
56
    %{pairing: pairing, code: code} = start_pairing()
57
    {:ok, machine} = Machines.approve_pairing(user("computer"), code)
58
    close_window(pairing.id)
59
60
    assert Machines.expire_elapsed_pairings() == 1
61
62
    revoked = Repo.get!(Machine, machine.id)
63
    assert revoked.status == "revoked"
64
    assert revoked.revoked_at
65
  end
66
67
  test "the revoked computer's inference grants close with it" do
68
    %{pairing: pairing, code: code} = start_pairing()
69
    owner = user("grants")
70
    {:ok, machine} = Machines.approve_pairing(owner, code)
71
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(owner)
72
73
    {:ok, grant, _token} =
74
      Inference.mint(%{
75
        owner_visitor_id: conversation.visitor_id,
76
        conversation_id: conversation.id,
77
        machine_id: machine.id
78
      })
79
80
    assert grant.status == "active"
81
82
    close_window(pairing.id)
83
    assert Machines.expire_elapsed_pairings() == 1
84
85
    assert Repo.reload!(grant).status == "revoked"
86
  end
87
88
  test "the sweep announces the revocation so an open channel closes" do
89
    %{pairing: pairing, code: code} = start_pairing()
90
    {:ok, machine} = Machines.approve_pairing(user("broadcast"), code)
91
    :ok = Phoenix.PubSub.subscribe(OpenAgents.PubSub, "machine:#{machine.id}")
92
93
    close_window(pairing.id)
94
    assert Machines.expire_elapsed_pairings() == 1
95
96
    machine_id = machine.id
97
    assert_receive {:machine_revoked, ^machine_id}
98
  end
99
100
  test "a pending pairing nobody approves expires too" do
101
    %{pairing: pairing} = start_pairing()
102
    close_window(pairing.id)
103
104
    assert Machines.expire_elapsed_pairings() == 1
105
    assert Repo.get!(Pairing, pairing.id).status == "expired"
106
  end
107
108
  test "a pairing still inside its window is left alone" do
109
    %{pairing: pairing, code: code} = start_pairing()
110
    {:ok, machine} = Machines.approve_pairing(user("fresh"), code)
111
112
    assert Machines.expire_elapsed_pairings() == 0
113
114
    assert Repo.get!(Pairing, pairing.id).status == "approved"
115
    assert Repo.get!(Machine, machine.id).status == "active"
116
  end
117
118
  test "a claimed pairing is never swept, and its computer keeps working" do
119
    %{pairing: pairing, code: code, poll_secret: poll_secret} = start_pairing()
120
    {:ok, machine} = Machines.approve_pairing(user("claimed"), code)
121
    {:ok, %{token: token}} = Machines.claim_pairing(pairing.id, poll_secret)
122
123
    close_window(pairing.id)
124
    assert Machines.expire_elapsed_pairings() == 0
125
126
    assert Repo.get!(Pairing, pairing.id).status == "claimed"
127
    assert {:ok, %Machine{id: id}} = Machines.authenticate_token(token)
128
    assert id == machine.id
129
  end
130
131
  test "the sweep is idempotent" do
132
    %{pairing: pairing, code: code} = start_pairing()
133
    {:ok, _machine} = Machines.approve_pairing(user("idempotent"), code)
134
    close_window(pairing.id)
135
136
    assert Machines.expire_elapsed_pairings() == 1
137
    assert Machines.expire_elapsed_pairings() == 0
138
  end
139
140
  # The claim path and the sweep must reach the same state, or the sweep would
141
  # be a second, weaker expiry rather than the same one on a clock.
142
  test "the sweep leaves the row where a late claim would have left it" do
143
    %{pairing: swept, code: swept_code} = start_pairing()
144
    {:ok, _} = Machines.approve_pairing(user("parity-a"), swept_code)
145
    close_window(swept.id)
146
    assert Machines.expire_elapsed_pairings() == 1
147
148
    %{pairing: claimed, code: claimed_code, poll_secret: secret} = start_pairing()
149
    {:ok, _} = Machines.approve_pairing(user("parity-b"), claimed_code)
150
    close_window(claimed.id)
151
    assert {:error, :pairing_expired} = Machines.claim_pairing(claimed.id, secret)
152
153
    fields = fn id ->
154
      row = Repo.get!(Pairing, id)
155
      {row.status, row.token_ciphertext, Repo.get!(Machine, row.machine_id).status}
156
    end
157
158
    assert fields.(swept.id) == fields.(claimed.id)
159
  end
160
161
  # The sweep's selection is the only reader `machine_pairings_expires_at_index`
162
  # has, and it is invisible to every outcome above: `expire_elapsed_pairing/1`
163
  # re-reads each row under `FOR UPDATE` and refuses anything terminal or still
164
  # fresh, so widening the predicate leaves all of those tests green while the
165
  # index quietly stops being used. It is proved directly for that reason.
166
  describe "the sweep's selection" do
167
    test "names elapsed pending and approved pairings and nothing else" do
168
      %{pairing: elapsed_pending} = start_pairing()
169
      close_window(elapsed_pending.id)
170
171
      %{pairing: elapsed_approved, code: approved_code} = start_pairing()
172
      {:ok, _} = Machines.approve_pairing(user("select-approved"), approved_code)
173
      close_window(elapsed_approved.id)
174
175
      %{pairing: elapsed_claimed, code: claimed_code, poll_secret: secret} = start_pairing()
176
      {:ok, _} = Machines.approve_pairing(user("select-claimed"), claimed_code)
177
      {:ok, _} = Machines.claim_pairing(elapsed_claimed.id, secret)
178
      close_window(elapsed_claimed.id)
179
180
      %{pairing: fresh} = start_pairing()
181
182
      selected = Machines.elapsed_pairing_ids(DateTime.utc_now())
183
184
      assert Enum.sort(selected) == Enum.sort([elapsed_pending.id, elapsed_approved.id])
185
      refute elapsed_claimed.id in selected
186
      refute fresh.id in selected
187
    end
188
189
    test "the predicate is served by machine_pairings_expires_at_index" do
190
      # A cold table is small enough that a sequential scan is the cheaper plan,
191
      # so the planner is asked which index it would use, not whether it bothers.
192
      # `SET LOCAL` so the choice dies with the sandbox's transaction and cannot
193
      # follow this connection back into the pool.
194
      Repo.query!("SET LOCAL enable_seqscan = off")
195
196
      # The production query itself, not a copy of it, so a predicate that stops
197
      # naming `expires_at` fails here as well as above.
198
      {sql, params} =
199
        Ecto.Adapters.SQL.to_sql(
200
          :all,
201
          Repo,
202
          Machines.elapsed_pairing_query(DateTime.utc_now())
203
        )
204
205
      plan =
206
        Repo.query!("EXPLAIN " <> sql, params).rows
207
        |> Enum.map_join("\n", &hd/1)
208
209
      assert plan =~ "machine_pairings_expires_at_index", plan
210
    end
211
  end
212
213
  # TokenVault carries exactly one AAD on the strength of this bound, and
214
  # CANON-002 states it as settled. It was not: nothing enforced it.
215
  test "no sealed ciphertext survives a closed window" do
216
    for key <- ["survivor-a", "survivor-b"] do
217
      %{pairing: pairing, code: code} = start_pairing()
218
      {:ok, _} = Machines.approve_pairing(user(key), code)
219
      close_window(pairing.id)
220
    end
221
222
    _swept = Machines.expire_elapsed_pairings()
223
224
    stale =
225
      Repo.all(
226
        from p in Pairing,
227
          where: not is_nil(p.token_ciphertext) and p.expires_at <= ^DateTime.utc_now(),
228
          select: p.token_ciphertext
229
      )
230
231
    assert stale == [],
232
           "sealed tokens outlived their window: #{inspect(Enum.map(stale, &TokenVault.open/1))}"
233
  end
234
end

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