Drop the pairing owner column the computer already answers for

3db40dc5a039 · AtlantisPleb · · parent 16f2155ccfa8

Drop the pairing owner column the computer already answers for

`machine_pairings.user_id` was written by `do_approve_pairing/4` and read by
nothing in `lib/` or `test/`. Unlike the other two columns in issue #184, the
absence is not a missing reader: there is nothing for a reader to learn.

Approval writes `user_id` and `machine_id` into one changeset, nothing writes
either alone, and the account the first names is `machines.user_id` on the row
the second points at. A run over the table confirms both halves rather than
inferring them: the two are equal on every approved row, and `user_id` is null
exactly when `machine_id` is. Both foreign keys already cascade from the same
`users` row, so deletion behavior does not change either.

Looking past the grep first, because a grep is what missed `TokenProductivity`:
nothing preloads the association, no raw SQL names the table outside its
migration, and `AccountExport` carries computers through `ComputerProjection`
and does not carry pairings at all. `ControllerPairingController` serializes
four named fields and never the struct.

`OpenAgents.Vocabulary` loses `machine_pairings_user_id_fkey` in the same
change, so the ledger stays at 3 tables, 5 columns, 18 constraints, and 13
indexes and `VocabularyTest` keeps deriving it from `pg_catalog`. The
`down` migration restores the column and backfills it from `machines`, which is
where its only writer read it from.

Mutations, both directions of CANON-002's ledger: putting the constraint back
on the ledger with the database unchanged fails "every machine-named constraint
in the database is on the ledger"; recreating the column and foreign key in the
database with the ledger unchanged fails that same assertion and "the pairing
row no longer carries an owner column".

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 lib/openagents/machines.ex
  • modified lib/openagents/machines/pairing.ex
  • modified lib/openagents/vocabulary.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260824042729_drop_machine_pairing_user_id.exs
  • modified test/openagents/machines_test.exs

Diff

6 files changed, +80 -6

lib/openagents/machines.ex modified -1

@@ -139,7 +139,6 @@ defmodule OpenAgents.Machines do

139 139
        pairing
140 140
        |> Ecto.Changeset.change(
141 141
          status: "approved",
142
          user_id: user_id,
143 142
          machine_id: machine.id,
144 143
          token_ciphertext: sealed
145 144
        )
lib/openagents/machines/pairing.ex modified +8 -3

@@ -1,5 +1,12 @@

1 1
defmodule OpenAgents.Machines.Pairing do
2
  @moduledoc false
2
  @moduledoc """
3
  One pairing window.
4
5
  The owner is not stored here. `user_id` used to be, written beside
6
  `machine_id` in the same approval changeset and read by nothing; the account
7
  it named is `machines.user_id`, reachable through the computer the approval
8
  created. See issue #184.
9
  """
3 10
4 11
  use Ecto.Schema
5 12
  import Ecto.Changeset

@@ -17,7 +24,6 @@ defmodule OpenAgents.Machines.Pairing do

17 24
    field :agent_version, :string
18 25
    field :roots, {:array, :string}, default: []
19 26
    field :status, :string, default: "pending"
20
    belongs_to :user, OpenAgents.Accounts.User
21 27
    belongs_to :machine, OpenAgents.Machines.Machine
22 28
    field :token_ciphertext, :binary, redact: true
23 29
    field :expires_at, :utc_datetime_usec

@@ -35,7 +41,6 @@ defmodule OpenAgents.Machines.Pairing do

35 41
          agent_version: String.t() | nil,
36 42
          roots: [String.t()],
37 43
          status: String.t(),
38
          user_id: Ecto.UUID.t() | nil,
39 44
          machine_id: Ecto.UUID.t() | nil,
40 45
          token_ciphertext: binary() | nil,
41 46
          expires_at: DateTime.t(),
lib/openagents/vocabulary.ex modified -1

@@ -39,7 +39,6 @@ defmodule OpenAgents.Vocabulary do

39 39
    {"machine_pairings", "machine_pairings_pkey"},
40 40
    {"machine_pairings", "machine_pairings_status_check"},
41 41
    {"machine_pairings", "machine_pairings_tier_check"},
42
    {"machine_pairings", "machine_pairings_user_id_fkey"},
43 42
    {"machines", "machines_pkey"},
44 43
    {"machines", "machines_status_check"},
45 44
    {"machines", "machines_tier_check"},
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -287,7 +287,8 @@

287 287
    20260824032138,
288 288
    20260824032226,
289 289
    20260824035934,
290
    20260824040140
290
    20260824040140,
291
    20260824042729
291 292
  ],
292 293
  "required_tables": [
293 294
    "users",
priv/repo/migrations/20260824042729_drop_machine_pairing_user_id.exs added +31

@@ -0,0 +1,31 @@

1
defmodule OpenAgents.Repo.Migrations.DropMachinePairingUserId do
2
  use Ecto.Migration
3
4
  @moduledoc """
5
  `machine_pairings.user_id` was written by `do_approve_pairing/4` and read by
6
  nothing. It is also strictly derivable: approval sets `user_id` and
7
  `machine_id` in one changeset, nothing sets either alone, and
8
  `machines.user_id` is the same account. See issue #184 and CANON-002.
9
  """
10
11
  def up do
12
    alter table(:machine_pairings) do
13
      remove :user_id
14
    end
15
  end
16
17
  def down do
18
    alter table(:machine_pairings) do
19
      add :user_id, references(:users, type: :binary_id, on_delete: :delete_all)
20
    end
21
22
    # The owner was never lost: it is the computer's owner, which is where the
23
    # column's only writer read it from in the first place.
24
    execute("""
25
    UPDATE machine_pairings AS p
26
       SET user_id = m.user_id
27
      FROM machines AS m
28
     WHERE m.id = p.machine_id
29
    """)
30
  end
31
end
test/openagents/machines_test.exs modified +39

@@ -51,6 +51,45 @@ defmodule OpenAgents.MachinesTest do

51 51
    assert DateTime.compare(pairing.expires_at, pairing.inserted_at) == :gt
52 52
  end
53 53
54
  # `machine_pairings.user_id` was dropped in #184. It was written beside
55
  # `machine_id` in one changeset and read by nothing, and the account it named
56
  # is still reachable — through the computer the approval created, which is
57
  # where the writer read it from.
58
  test "a pairing's owner is reachable through the computer the approval created" do
59
    %{pairing: pairing, code: code} = start_pairing()
60
    owner = user("owner-reachable")
61
62
    assert {:ok, machine} = Machines.approve_pairing(owner, code)
63
64
    stored = Repo.get!(Pairing, pairing.id)
65
    assert stored.machine_id == machine.id
66
    assert Repo.get!(OpenAgents.Machines.Machine, stored.machine_id).user_id == owner.id
67
  end
68
69
  test "the pairing row no longer carries an owner column" do
70
    columns =
71
      Repo.query!(
72
        """
73
        SELECT column_name FROM information_schema.columns
74
        WHERE table_schema = 'public' AND table_name = 'machine_pairings'
75
        """,
76
        []
77
      ).rows
78
      |> Enum.map(&hd/1)
79
80
    refute "user_id" in columns
81
    assert "machine_id" in columns
82
83
    constraints =
84
      Repo.query!(
85
        "SELECT conname FROM pg_constraint WHERE conrelid = 'machine_pairings'::regclass",
86
        []
87
      ).rows
88
      |> Enum.map(&hd/1)
89
90
    refute "machine_pairings_user_id_fkey" in constraints
91
  end
92
54 93
  test "approve then claim hands the token over exactly once" do
55 94
    %{pairing: pairing, code: code, poll_secret: poll_secret} = start_pairing()
56 95
    owner = user("claim-once")

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