Reach the database constraints the ledger credits

308aa1b14f1d · AtlantisPleb · · parent 8928c4af50eb

Reach the database constraints the ledger credits

A constraint nothing tests is a claim, not a control. These were all
load-bearing and none was reachable by any test: the effects
lease-pair and status-shape checks, the compensation policy CHECK and
its append-only triggers, the settlement uniqueness and
partial-uniqueness indexes, and the voice one-active partial index.

Each now has a test in the shape grant_fence_test.exs already used —
a raw insert that violates the constraint, asserting PostgreSQL
refuses and naming the constraint that refused it. Raw SQL on purpose:
it goes around Ecto, so the test proves the database holds the line
rather than proving a changeset does.

Where a credited constraint did not exist, the ledger gave way rather
than the code: SETTLEMENT-001 called its tables "seven append-only
schemas" and no append-only trigger existed on any settlement table,
so the phrase is struck and the evidence names what is really there.

Also closes the work-launch gap the audit named: a crash between the
job insert and the launch now proves the effect row survives, which
the previous test stayed green without.

Built by a Devin child through the openagents coder's delegate tool;
26 new constraint tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <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 349 · 2026-08-25T06:35:03.596133Z

Changed files

  • modified INVARIANTS.md
  • added test/openagents/compensation_fence_test.exs
  • added test/openagents/effects/database_constraints_test.exs
  • modified test/openagents/effects/work_launch_test.exs
  • added test/openagents/settlement/constraints_test.exs
  • added test/openagents/voice/voice_003_partial_unique_index_test.exs
  • added test/support/effects_crashing_horde.ex

Diff

7 files changed, +1069 -2

INVARIANTS.md modified +2 -2

@@ -1567,8 +1567,8 @@ claimant or buyer reference, an operator identity, an approval reference, or a

1567 1567
gateway reference. The claimant can export the full receipt, including their own
1568 1568
destination, without a hosted wallet.
1569 1569
1570
Evidence: `OpenAgents.Settlement`, `OpenAgents.Settlement.PaymentGateway`, its
1571
seven append-only schemas with their uniqueness and partial-uniqueness
1570
Evidence: `OpenAgents.Settlement`, `OpenAgents.Settlement.PaymentGateway`, the
1571
settlement schemas and tables, with their uniqueness and partial-uniqueness
1572 1572
constraints, and the pricing, claim, verification, duplicate, stale-commit,
1573 1573
approval, budget, retry, reconciliation, expiry, dispute, refund, privacy, and
1574 1574
receipt-export cases in `test/openagents/settlement_test.exs`.
test/openagents/compensation_fence_test.exs added +368

@@ -0,0 +1,368 @@

1
defmodule OpenAgents.CompensationFenceTest do
2
  @moduledoc """
3
  COMPENSATION-001 and append-only invariants, at the database. Every claim
4
  bypasses the Ecto changeset and writes raw SQL, because the property is that
5
  PostgreSQL refuses the row or mutation — not that the application declines
6
  to build it.
7
  """
8
9
  use OpenAgents.DataCase, async: false
10
11
  import OpenAgentsWeb.ConnCase, only: [github_user: 1]
12
13
  alias OpenAgents.Conversations
14
  alias OpenAgents.Repo
15
16
  describe "compensation policies can never grant payout authority" do
17
    test "a policy receipt with payout_authority true is refused" do
18
      assert {:error, %Postgrex.Error{} = error} =
19
               Repo.query(
20
                 """
21
                 INSERT INTO compensation_policy_receipts
22
                   (id, policy_id, version, policy_digest, rules, actor_id, auth_method, approval_receipt_ref, inserted_at)
23
                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
24
                 """,
25
                 [
26
                   uuid(Ecto.UUID.generate()),
27
                   "sarah.compensation.accounting.v1",
28
                   1,
29
                   hex(),
30
                   %{"payout_authority" => true},
31
                   "operator:test",
32
                   "test_session",
33
                   "approval:policy:payout:#{System.unique_integer([:positive])}",
34
                   now()
35
                 ]
36
               )
37
38
      assert error.postgres.constraint == "compensation_policy_no_payout"
39
    end
40
  end
41
42
  describe "compensation accounting tables are append-only" do
43
    setup do
44
      user = github_user("compensation-fence-#{System.unique_integer([:positive])}")
45
      {:ok, conversation} = Conversations.ensure_conversation(user)
46
      {:ok, %{turn: turn}} = Conversations.create_turn(conversation, "Use a module.")
47
48
      tool_step = setup_tool_step!(turn)
49
      policy = insert_policy!()
50
      allocation = insert_module_allocation!(policy)
51
      outcome_decision = insert_outcome_decision!(tool_step)
52
      event = insert_event!(tool_step, policy, outcome_decision)
53
      share = insert_share!(event)
54
      adjustment = insert_adjustment!(policy, event)
55
      statement = insert_statement!(policy)
56
57
      %{
58
        rows: %{
59
          "compensation_policy_receipts" => policy,
60
          "compensation_module_allocations" => allocation,
61
          "compensation_outcome_decisions" => outcome_decision,
62
          "compensation_events" => event,
63
          "compensation_shares" => share,
64
          "compensation_adjustments" => adjustment,
65
          "compensation_statements" => statement
66
        }
67
      }
68
    end
69
70
    test "compensation_policy_receipts rejects update and delete", %{rows: rows} do
71
      assert_rejects_mutation("compensation_policy_receipts", rows)
72
    end
73
74
    test "compensation_module_allocations rejects update and delete", %{rows: rows} do
75
      assert_rejects_mutation("compensation_module_allocations", rows)
76
    end
77
78
    test "compensation_outcome_decisions rejects update and delete", %{rows: rows} do
79
      assert_rejects_mutation("compensation_outcome_decisions", rows)
80
    end
81
82
    test "compensation_events rejects update and delete", %{rows: rows} do
83
      assert_rejects_mutation("compensation_events", rows)
84
    end
85
86
    test "compensation_shares rejects update and delete", %{rows: rows} do
87
      assert_rejects_mutation("compensation_shares", rows)
88
    end
89
90
    test "compensation_adjustments rejects update and delete", %{rows: rows} do
91
      assert_rejects_mutation("compensation_adjustments", rows)
92
    end
93
94
    test "compensation_statements rejects update and delete", %{rows: rows} do
95
      assert_rejects_mutation("compensation_statements", rows)
96
    end
97
  end
98
99
  defp assert_rejects_mutation(table, rows) do
100
    id = Map.fetch!(rows, table)
101
102
    assert {:error, %Postgrex.Error{} = update_error} =
103
             Repo.query("UPDATE #{table} SET id = id WHERE id = $1", [id])
104
105
    assert update_error.postgres.message =~ "#{table} is append-only"
106
107
    assert {:error, %Postgrex.Error{} = delete_error} =
108
             Repo.query("DELETE FROM #{table} WHERE id = $1", [id])
109
110
    assert delete_error.postgres.message =~ "#{table} is append-only"
111
  end
112
113
  defp setup_tool_step!(turn) do
114
    now = now()
115
    turn_id = uuid(turn.id)
116
    receipt_id = insert_turn_receipt!(turn_id, now)
117
    insert_tool_step!(turn_id, receipt_id, now)
118
  end
119
120
  defp insert_turn_receipt!(turn_id, now) do
121
    insert!(
122
      """
123
      INSERT INTO turn_receipts
124
        (id, turn_id, model_id, persona_id, persona_digest, role_id, role_digest,
125
         instruction_digest, input_digest, input_message_count, input_bytes,
126
         provider_started_at, inserted_at, updated_at)
127
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
128
      RETURNING id
129
      """,
130
      [
131
        uuid(Ecto.UUID.generate()),
132
        turn_id,
133
        "test-model",
134
        "test-persona",
135
        hex(),
136
        "test-role",
137
        hex(),
138
        hex(),
139
        hex(),
140
        0,
141
        0,
142
        now,
143
        now,
144
        now
145
      ]
146
    )
147
  end
148
149
  defp insert_tool_step!(turn_id, turn_receipt_id, now) do
150
    insert!(
151
      """
152
      INSERT INTO turn_tool_steps
153
        (id, turn_id, turn_receipt_id, sequence, provider_call_id, provider_item_id,
154
         provider_response_id, tool_name, tool_version, module_id, side_effect_class,
155
         invocation_key, catalog_digest, raw_arguments, argument_digest, requested_at,
156
         inserted_at, updated_at)
157
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
158
      RETURNING id
159
      """,
160
      [
161
        uuid(Ecto.UUID.generate()),
162
        turn_id,
163
        turn_receipt_id,
164
        1,
165
        "call-#{System.unique_integer([:positive])}",
166
        "item-#{System.unique_integer([:positive])}",
167
        "response-#{System.unique_integer([:positive])}",
168
        "host",
169
        1,
170
        "sarah.host",
171
        "read_only",
172
        hex(),
173
        hex(),
174
        "{}",
175
        hex(),
176
        now,
177
        now,
178
        now
179
      ]
180
    )
181
  end
182
183
  defp insert_policy! do
184
    insert!(
185
      """
186
      INSERT INTO compensation_policy_receipts
187
        (id, policy_id, version, policy_digest, rules, actor_id, auth_method, approval_receipt_ref, inserted_at)
188
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
189
      RETURNING id
190
      """,
191
      [
192
        uuid(Ecto.UUID.generate()),
193
        "sarah.compensation.accounting.v1",
194
        1,
195
        hex(),
196
        %{"payout_authority" => false},
197
        "operator:test",
198
        "test_session",
199
        "approval:policy:valid:#{System.unique_integer([:positive])}",
200
        now()
201
      ]
202
    )
203
  end
204
205
  defp insert_module_allocation!(policy_id) do
206
    insert!(
207
      """
208
      INSERT INTO compensation_module_allocations
209
        (id, policy_receipt_id, module_id, module_version, artifact_digest, contribution_ref,
210
         allocation_ppm, lineage_digest, actor_id, approval_receipt_ref, inserted_at)
211
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
212
      RETURNING id
213
      """,
214
      [
215
        uuid(Ecto.UUID.generate()),
216
        policy_id,
217
        "sarah.host",
218
        1,
219
        hex(),
220
        "OpenAgentsInc/openagents.com",
221
        1_000_000,
222
        hex(),
223
        "operator:test",
224
        "approval:allocation:#{System.unique_integer([:positive])}",
225
        now()
226
      ]
227
    )
228
  end
229
230
  defp insert_outcome_decision!(tool_step_id) do
231
    insert!(
232
      """
233
      INSERT INTO compensation_outcome_decisions
234
        (id, tool_step_id, invocation_key, outcome_receipt_ref, outcome_digest, decision,
235
         reason_code, actor_id, auth_method, decision_receipt_ref, inserted_at)
236
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
237
      RETURNING id
238
      """,
239
      [
240
        uuid(Ecto.UUID.generate()),
241
        tool_step_id,
242
        hex(),
243
        "outcome:decision:#{System.unique_integer([:positive])}",
244
        hex(),
245
        "accepted",
246
        "verified_outcome",
247
        "outcome-reviewer:test",
248
        "test_session",
249
        "approval:decision:#{System.unique_integer([:positive])}",
250
        now()
251
      ]
252
    )
253
  end
254
255
  defp insert_event!(tool_step_id, policy_id, outcome_decision_id) do
256
    insert!(
257
      """
258
      INSERT INTO compensation_events
259
        (id, tool_step_id, policy_receipt_id, outcome_decision_id, module_id, module_version,
260
         artifact_digest, invocation_key, outcome_receipt_ref, technical_units, eligible_units,
261
         classification, reason_code, event_digest, inserted_at)
262
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
263
      RETURNING id
264
      """,
265
      [
266
        uuid(Ecto.UUID.generate()),
267
        tool_step_id,
268
        policy_id,
269
        outcome_decision_id,
270
        "sarah.host",
271
        1,
272
        hex(),
273
        hex(),
274
        "outcome:event:#{System.unique_integer([:positive])}",
275
        0,
276
        0,
277
        "ineligible",
278
        "outcome_rejected",
279
        hex(),
280
        now()
281
      ]
282
    )
283
  end
284
285
  defp insert_share!(event_id) do
286
    insert!(
287
      """
288
      INSERT INTO compensation_shares
289
        (id, event_id, contribution_ref, allocation_ppm, allocated_units, share_digest, inserted_at)
290
      VALUES ($1, $2, $3, $4, $5, $6, $7)
291
      RETURNING id
292
      """,
293
      [
294
        uuid(Ecto.UUID.generate()),
295
        event_id,
296
        "OpenAgentsInc/openagents.com",
297
        1_000_000,
298
        0,
299
        hex(),
300
        now()
301
      ]
302
    )
303
  end
304
305
  defp insert_adjustment!(policy_id, event_id) do
306
    insert!(
307
      """
308
      INSERT INTO compensation_adjustments
309
        (id, event_id, policy_receipt_id, contribution_ref, kind, delta_units, reason_code,
310
         actor_id, auth_method, adjustment_receipt_ref, adjustment_digest, inserted_at)
311
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
312
      RETURNING id
313
      """,
314
      [
315
        uuid(Ecto.UUID.generate()),
316
        event_id,
317
        policy_id,
318
        "OpenAgentsInc/openagents.com",
319
        "refund",
320
        -10,
321
        "customer_refund",
322
        "operator:test",
323
        "test_session",
324
        "approval:adjustment:#{System.unique_integer([:positive])}",
325
        hex(),
326
        now()
327
      ]
328
    )
329
  end
330
331
  defp insert_statement!(policy_id) do
332
    now = now()
333
334
    insert!(
335
      """
336
      INSERT INTO compensation_statements
337
        (id, policy_receipt_id, contribution_ref, cutoff_at, gross_units, adjustment_units,
338
         net_units, event_count, state, statement_digest, actor_id, statement_receipt_ref, inserted_at)
339
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
340
      RETURNING id
341
      """,
342
      [
343
        uuid(Ecto.UUID.generate()),
344
        policy_id,
345
        "OpenAgentsInc/openagents.com",
346
        now,
347
        100,
348
        -50,
349
        50,
350
        1,
351
        "reconciled",
352
        hex(),
353
        "operator:test",
354
        "approval:statement:#{System.unique_integer([:positive])}",
355
        now
356
      ]
357
    )
358
  end
359
360
  defp insert!(sql, params) do
361
    %{rows: [[id]]} = Repo.query!(sql, params)
362
    id
363
  end
364
365
  defp uuid(value), do: Ecto.UUID.dump!(value)
366
  defp now, do: DateTime.utc_now()
367
  defp hex, do: String.duplicate("0", 64)
368
end
test/openagents/effects/database_constraints_test.exs added +115

@@ -0,0 +1,115 @@

1
defmodule OpenAgents.Effects.DatabaseConstraintsTest do
2
  @moduledoc """
3
  EFFECT-001, at the database: the CHECK constraints on `effects` reject
4
  malformed rows even when the insert bypasses the application changeset.
5
  """
6
7
  use OpenAgents.DataCase, async: false
8
9
  alias OpenAgents.Repo
10
11
  @insert_effects """
12
  INSERT INTO effects
13
    (id, kind, payload, payload_digest, source_kind, source_id, idempotency_key,
14
     status, attempts, maximum_attempts, available_at,
15
     lease_owner, lease_expires_at, claimed_at, completed_at, last_error,
16
     inserted_at, updated_at)
17
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
18
          $12, $13, $14, $15, $16, $17, $18)
19
  """
20
21
  setup do
22
    base = %{
23
      id: uuid(),
24
      kind: "test.kind",
25
      payload: %{},
26
      payload_digest: "sha256:" <> String.duplicate("0", 64),
27
      source_kind: "test_source",
28
      source_id: uuid_string(),
29
      idempotency_key: uuid_string(),
30
      status: "pending",
31
      attempts: 0,
32
      maximum_attempts: 5,
33
      available_at: now(),
34
      lease_owner: nil,
35
      lease_expires_at: nil,
36
      claimed_at: nil,
37
      completed_at: nil,
38
      last_error: nil,
39
      inserted_at: now(),
40
      updated_at: now()
41
    }
42
43
    {:ok, base: base}
44
  end
45
46
  describe "effects_lease_pair_check" do
47
    test "a lease owner without an expiry is refused", %{base: base} do
48
      assert {:error, %Postgrex.Error{} = error} =
49
               Repo.query(@insert_effects, values(%{base | lease_owner: "worker-1"}))
50
51
      assert error.postgres.constraint == "effects_lease_pair_check"
52
    end
53
54
    test "an expiry without an owner is refused", %{base: base} do
55
      assert {:error, %Postgrex.Error{} = error} =
56
               Repo.query(@insert_effects, values(%{base | lease_expires_at: now()}))
57
58
      assert error.postgres.constraint == "effects_lease_pair_check"
59
    end
60
  end
61
62
  describe "effects_status_shape_check" do
63
    test "a claimed effect without a lease and claimed_at is refused", %{base: base} do
64
      assert {:error, %Postgrex.Error{} = error} =
65
               Repo.query(@insert_effects, values(%{base | status: "claimed"}))
66
67
      assert error.postgres.constraint == "effects_status_shape_check"
68
    end
69
70
    test "a done effect with an owner is refused", %{base: base} do
71
      completed_at = now()
72
73
      assert {:error, %Postgrex.Error{} = error} =
74
               Repo.query(
75
                 @insert_effects,
76
                 values(%{
77
                   base
78
                   | status: "done",
79
                     lease_owner: "worker-1",
80
                     lease_expires_at: completed_at,
81
                     completed_at: completed_at
82
                 })
83
               )
84
85
      assert error.postgres.constraint == "effects_status_shape_check"
86
    end
87
  end
88
89
  defp values(attrs) do
90
    [
91
      attrs.id,
92
      attrs.kind,
93
      attrs.payload,
94
      attrs.payload_digest,
95
      attrs.source_kind,
96
      attrs.source_id,
97
      attrs.idempotency_key,
98
      attrs.status,
99
      attrs.attempts,
100
      attrs.maximum_attempts,
101
      attrs.available_at,
102
      attrs.lease_owner,
103
      attrs.lease_expires_at,
104
      attrs.claimed_at,
105
      attrs.completed_at,
106
      attrs.last_error,
107
      attrs.inserted_at,
108
      attrs.updated_at
109
    ]
110
  end
111
112
  defp uuid, do: Ecto.UUID.dump!(Ecto.UUID.generate())
113
  defp uuid_string, do: Ecto.UUID.generate()
114
  defp now, do: DateTime.utc_now()
115
end
test/openagents/effects/work_launch_test.exs modified +102

@@ -138,6 +138,48 @@ defmodule OpenAgents.Effects.WorkLaunchTest do

138 138
    end
139 139
  end
140 140
141
  describe "crash boundary" do
142
    test "the launch effect survives a failed inline placement" do
143
      remove_horde_supervisor()
144
      on_exit(fn -> restore_horde_supervisor() end)
145
146
      _fake =
147
        start_supervised!({OpenAgents.Effects.WorkLaunchTest.CrashingHorde, []})
148
149
      recording_launch_handler()
150
151
      {:ok, conversation} =
152
        Conversations.ensure_conversation("effect-launch-failed-placement")
153
154
      owner = Conversations.get_conversation_owner!(conversation)
155
156
      assert {:error, :worker_start_failed} =
157
               Work.start_job(%{
158
                 conversation_id: conversation.id,
159
                 owner_visitor_id: owner.id,
160
                 surface: "text",
161
                 goal: "a job whose worker could not be placed"
162
               })
163
164
      assert [job] = Work.recent_jobs(conversation, 1)
165
      assert job.status == "queued"
166
      job_id = job.id
167
168
      assert [effect] = Effects.for_source("work_job", job_id)
169
      assert effect.kind == "work.launch_worker"
170
      assert effect.payload == %{"job_id" => job_id, "worker" => "job"}
171
      assert effect.source_kind == "work_job"
172
      assert effect.source_id == job_id
173
      assert effect.status == "pending"
174
175
      assert %{claimed: 1, completed: 1} =
176
               Worker.run_once(identity: "worker-outbox")
177
178
      assert_received {:launch_requested, OpenAgents.Work.JobServer, ^job_id}
179
      assert Effects.get(effect.id).status == "done"
180
    end
181
  end
182
141 183
  # The ordinary path, run to a terminal job so no worker outlives the test.
142 184
  defp start_job(browser_key) do
143 185
    {:ok, conversation} = Conversations.ensure_conversation(browser_key)

@@ -208,4 +250,64 @@ defmodule OpenAgents.Effects.WorkLaunchTest do

208 250
      Application.delete_env(:openagents, :effects_launch_observer)
209 251
    end)
210 252
  end
253
254
  defp remove_horde_supervisor do
255
    remove_horde_supervisor(5)
256
  end
257
258
  defp remove_horde_supervisor(0) do
259
    :ok
260
  end
261
262
  defp remove_horde_supervisor(retries) do
263
    case Supervisor.terminate_child(OpenAgents.RuntimeSupervisor, OpenAgents.HordeSupervisor) do
264
      :ok ->
265
        case Supervisor.delete_child(OpenAgents.RuntimeSupervisor, OpenAgents.HordeSupervisor) do
266
          :ok ->
267
            :ok
268
269
          {:error, :running} ->
270
            remove_horde_supervisor(retries - 1)
271
272
          _ ->
273
            :ok
274
        end
275
276
      {:error, :not_found} ->
277
        :ok
278
    end
279
  end
280
281
  defp restore_horde_supervisor do
282
    _ = Supervisor.terminate_child(OpenAgents.RuntimeSupervisor, OpenAgents.HordeSupervisor)
283
    _ = Supervisor.delete_child(OpenAgents.RuntimeSupervisor, OpenAgents.HordeSupervisor)
284
285
    horde_spec = {
286
      Horde.DynamicSupervisor,
287
      name: OpenAgents.HordeSupervisor,
288
      strategy: :one_for_one,
289
      members: :auto,
290
      process_redistribution: :passive,
291
      delta_crdt_options: [sync_interval: 150]
292
    }
293
294
    case Supervisor.restart_child(OpenAgents.RuntimeSupervisor, OpenAgents.HordeSupervisor) do
295
      {:ok, _pid} ->
296
        :ok
297
298
      {:ok, _pid, _info} ->
299
        :ok
300
301
      {:error, :not_found} ->
302
        case Supervisor.start_child(OpenAgents.RuntimeSupervisor, horde_spec) do
303
          {:ok, _pid} -> :ok
304
          {:ok, _pid, _info} -> :ok
305
          {:error, :already_started} -> :ok
306
          _ -> :ok
307
        end
308
309
      _ ->
310
        :ok
311
    end
312
  end
211 313
end
test/openagents/settlement/constraints_test.exs added +310

@@ -0,0 +1,310 @@

1
defmodule OpenAgents.Settlement.ConstraintsTest do
2
  @moduledoc """
3
  SETTLEMENT-001, at the database. Every claim here bypasses the Ecto changeset
4
  and writes raw SQL, because the property is that PostgreSQL refuses the row —
5
  not that the application declines to build it.
6
  """
7
8
  use OpenAgents.DataCase, async: false
9
10
  import OpenAgents.IssuesFixtures
11
12
  alias OpenAgents.Repo
13
14
  @insert_policy """
15
  INSERT INTO settlement_treasury_policies
16
    (id, policy_id, version, policy_digest, rules, actor_id, auth_method,
17
     approval_receipt_ref, inserted_at)
18
  VALUES ($1, $2, 1, $3, $4, $5, $6, $7, $8)
19
  """
20
21
  @insert_spec """
22
  INSERT INTO settlement_bounty_specs
23
    (id, treasury_policy_id, issue_id, revision, buyer_ref, amount_sats,
24
     acceptance_criteria, verification_policy, destination_kind, expires_at,
25
     spec_fingerprint, actor_id, auth_method, approval_receipt_ref, inserted_at)
26
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
27
  """
28
29
  @insert_claim """
30
  INSERT INTO settlement_claims
31
    (id, bounty_spec_id, spec_fingerprint, claimant_ref, work_job_ref,
32
     destination_kind, destination, destination_digest, state, claim_digest,
33
     expires_at, inserted_at, updated_at)
34
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12)
35
  """
36
37
  @insert_verification """
38
  INSERT INTO settlement_verifications
39
    (id, claim_id, spec_fingerprint, commit_sha, work_job_ref, verifier_ref,
40
     verifier_policy_digest, evidence_digest, outcome, reason_code, auth_method,
41
     decision_receipt_ref, inserted_at)
42
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
43
  """
44
45
  @insert_intent """
46
  INSERT INTO settlement_payment_intents
47
    (id, claim_id, verification_id, idempotency_key, amount_sats, commit_sha,
48
     destination_digest, spec_fingerprint, state, attempts, intent_digest,
49
     actor_id, auth_method, approval_receipt_ref, inserted_at, updated_at)
50
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15)
51
  """
52
53
  @insert_receipt """
54
  INSERT INTO settlement_payment_receipts
55
    (id, payment_intent_id, claim_id, amount_sats, fee_sats, payment_hash,
56
     preimage_digest, gateway_ref, paid_at, receipt_digest, inserted_at)
57
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
58
  """
59
60
  setup do
61
    repository = repository_fixture()
62
    issue = issue_fixture(repository, %{title: "Settlement constraint test"})
63
    now = DateTime.utc_now()
64
    expires_at = DateTime.add(now, 900, :second)
65
66
    policy = insert_policy!(now)
67
    spec = insert_spec!(issue.id, policy.id, 1, now, expires_at)
68
    claim = insert_claim!(spec, now, expires_at)
69
    verification = insert_verification!(claim, now)
70
71
    %{
72
      issue: issue,
73
      policy: policy,
74
      spec: spec,
75
      claim: claim,
76
      verification: verification,
77
      now: now,
78
      expires_at: expires_at
79
    }
80
  end
81
82
  describe "payment receipt uniqueness" do
83
    test "a payment hash cannot be reused for another receipt", context do
84
      intent_1_id = Ecto.UUID.generate()
85
      payment_hash = hex64()
86
87
      assert {:ok, %{num_rows: 1}} =
88
               insert_intent(intent_1_id, context, "intent-#{unique()}", "paid")
89
90
      assert {:ok, %{num_rows: 1}} =
91
               insert_receipt(
92
                 Ecto.UUID.generate(),
93
                 intent_1_id,
94
                 context.claim.id,
95
                 payment_hash,
96
                 context.now
97
               )
98
99
      second =
100
        insert_issue_chain!(
101
          context.issue.id,
102
          context.policy.id,
103
          2,
104
          context.now,
105
          context.expires_at
106
        )
107
        |> Map.put(:now, context.now)
108
109
      intent_2_id = Ecto.UUID.generate()
110
111
      assert {:ok, %{num_rows: 1}} =
112
               insert_intent(intent_2_id, second, "intent-#{unique()}", "paid")
113
114
      assert {:error, %Postgrex.Error{} = error} =
115
               insert_receipt(
116
                 Ecto.UUID.generate(),
117
                 intent_2_id,
118
                 second.claim.id,
119
                 payment_hash,
120
                 context.now
121
               )
122
123
      assert error.postgres.constraint ==
124
               "settlement_payment_receipts_payment_hash_index"
125
    end
126
127
    test "a second receipt for one payment intent is refused", context do
128
      intent_id = Ecto.UUID.generate()
129
      first_hash = hex64()
130
      second_hash = hex64()
131
132
      assert {:ok, %{num_rows: 1}} =
133
               insert_intent(intent_id, context, "intent-#{unique()}", "paid")
134
135
      assert {:ok, %{num_rows: 1}} =
136
               insert_receipt(
137
                 Ecto.UUID.generate(),
138
                 intent_id,
139
                 context.claim.id,
140
                 first_hash,
141
                 context.now
142
               )
143
144
      assert {:error, %Postgrex.Error{} = error} =
145
               insert_receipt(
146
                 Ecto.UUID.generate(),
147
                 intent_id,
148
                 context.claim.id,
149
                 second_hash,
150
                 context.now
151
               )
152
153
      assert error.postgres.constraint ==
154
               "settlement_payment_receipts_payment_intent_id_index"
155
    end
156
  end
157
158
  describe "payment intent partial uniqueness" do
159
    test "two paid intents for one claim are refused", context do
160
      first_id = Ecto.UUID.generate()
161
162
      assert {:ok, %{num_rows: 1}} =
163
               insert_intent(first_id, context, "intent-#{unique()}", "paid")
164
165
      second_id = Ecto.UUID.generate()
166
167
      assert {:error, %Postgrex.Error{} = error} =
168
               insert_intent(second_id, context, "intent-#{unique()}", "paid")
169
170
      assert error.postgres.constraint == "settlement_payment_intent_single_paid"
171
    end
172
  end
173
174
  defp insert_policy!(now) do
175
    id = Ecto.UUID.generate()
176
177
    Repo.query!(@insert_policy, [
178
      uuid(id),
179
      "policy:#{unique()}",
180
      hex64(),
181
      %{"max_payment_sats" => 100_000, "daily_budget_sats" => 1_000_000},
182
      "actor:operator",
183
      "session",
184
      "approval:#{unique()}",
185
      now
186
    ])
187
188
    %{id: id}
189
  end
190
191
  defp insert_spec!(issue_id, policy_id, revision, now, expires_at) do
192
    id = Ecto.UUID.generate()
193
    fingerprint = hex64()
194
195
    Repo.query!(@insert_spec, [
196
      uuid(id),
197
      uuid(policy_id),
198
      issue_id,
199
      revision,
200
      "buyer:openagents",
201
      2_500,
202
      ["The constraint holds."],
203
      %{"name" => "forge.precommit.v1", "requires" => ["mix precommit"]},
204
      "bolt12_offer",
205
      expires_at,
206
      fingerprint,
207
      "actor:operator",
208
      "session",
209
      "approval:#{unique()}",
210
      now
211
    ])
212
213
    %{id: id, spec_fingerprint: fingerprint}
214
  end
215
216
  defp insert_claim!(spec, now, expires_at) do
217
    id = Ecto.UUID.generate()
218
    destination_digest = hex64()
219
    fingerprint = spec.spec_fingerprint
220
221
    Repo.query!(@insert_claim, [
222
      uuid(id),
223
      uuid(spec.id),
224
      fingerprint,
225
      "agent:claimant-#{unique()}",
226
      "work-job:#{unique()}",
227
      "bolt12_offer",
228
      "lno1#{String.duplicate("q", 40)}",
229
      destination_digest,
230
      "verified",
231
      hex64(),
232
      expires_at,
233
      now
234
    ])
235
236
    %{id: id, spec_fingerprint: fingerprint, destination_digest: destination_digest}
237
  end
238
239
  defp insert_verification!(claim, now) do
240
    id = Ecto.UUID.generate()
241
    commit_sha = hex40()
242
243
    Repo.query!(@insert_verification, [
244
      uuid(id),
245
      uuid(claim.id),
246
      claim.spec_fingerprint,
247
      commit_sha,
248
      "work-job:#{unique()}",
249
      "verifier:forge-precommit",
250
      hex64(),
251
      hex64(),
252
      "accepted",
253
      "criteria_met",
254
      "session",
255
      "decision:#{unique()}",
256
      now
257
    ])
258
259
    %{id: id, commit_sha: commit_sha}
260
  end
261
262
  defp insert_issue_chain!(issue_id, policy_id, revision, now, expires_at) do
263
    spec = insert_spec!(issue_id, policy_id, revision, now, expires_at)
264
    claim = insert_claim!(spec, now, expires_at)
265
    verification = insert_verification!(claim, now)
266
267
    %{spec: spec, claim: claim, verification: verification}
268
  end
269
270
  defp insert_intent(intent_id, context, idempotency_key, state) do
271
    Repo.query(@insert_intent, [
272
      uuid(intent_id),
273
      uuid(context.claim.id),
274
      uuid(context.verification.id),
275
      idempotency_key,
276
      2_500,
277
      context.verification.commit_sha,
278
      context.claim.destination_digest,
279
      context.spec.spec_fingerprint,
280
      state,
281
      0,
282
      hex64(),
283
      "actor:operator",
284
      "session",
285
      "approval:#{unique()}",
286
      context.now
287
    ])
288
  end
289
290
  defp insert_receipt(receipt_id, payment_intent_id, claim_id, payment_hash, now) do
291
    Repo.query(@insert_receipt, [
292
      uuid(receipt_id),
293
      uuid(payment_intent_id),
294
      uuid(claim_id),
295
      2_500,
296
      3,
297
      payment_hash,
298
      hex64(),
299
      "gateway:#{unique()}",
300
      now,
301
      hex64(),
302
      now
303
    ])
304
  end
305
306
  defp hex40, do: Base.encode16(:crypto.strong_rand_bytes(20), case: :lower)
307
  defp hex64, do: Base.encode16(:crypto.strong_rand_bytes(32), case: :lower)
308
  defp unique, do: System.unique_integer([:positive, :monotonic])
309
  defp uuid(value), do: Ecto.UUID.dump!(value)
310
end
test/openagents/voice/voice_003_partial_unique_index_test.exs added +134

@@ -0,0 +1,134 @@

1
defmodule OpenAgents.Voice.Voice003PartialUniqueIndexTest do
2
  @moduledoc """
3
  VOICE-003, at the database. The partial unique index allows only one active
4
  voice session per conversation. These claims bypass the Ecto changeset and
5
  write raw SQL, because the property is that PostgreSQL refuses the row.
6
  """
7
8
  use OpenAgents.DataCase, async: false
9
10
  alias OpenAgents.Conversations
11
  alias OpenAgents.Repo
12
13
  @initial_control_id "00000000-0000-0000-0000-000000000001"
14
15
  @insert """
16
  INSERT INTO voice_sessions
17
    (id, conversation_id, generation, status, architecture, provider_id,
18
     model_id, voice_artifact_id, provider_session_id, persona_id,
19
     persona_digest, role_id, role_digest, instruction_digest,
20
     tool_catalog_digest, event_sequence, usage, started_at, connected_at,
21
     ended_at, termination_reason, failure_code, release_control_id,
22
     inserted_at, updated_at)
23
  VALUES
24
    ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
25
     $11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
26
     $21, $22, $23, $24, $25)
27
  """
28
29
  setup do
30
    {:ok, conversation} =
31
      Conversations.ensure_conversation("voice-003-browser")
32
33
    %{conversation: conversation}
34
  end
35
36
  describe "one active session per conversation" do
37
    test "a second active session is refused by the partial unique index",
38
         %{conversation: conversation} do
39
      conversation_id = uuid(conversation.id)
40
      now = DateTime.utc_now()
41
42
      first_id = uuid(Ecto.UUID.generate())
43
44
      assert {:ok, %{num_rows: 1}} =
45
               insert_voice_session(
46
                 first_id,
47
                 conversation_id,
48
                 1,
49
                 "connecting",
50
                 now
51
               )
52
53
      second_id = uuid(Ecto.UUID.generate())
54
55
      assert {:error, %Postgrex.Error{} = error} =
56
               insert_voice_session(
57
                 second_id,
58
                 conversation_id,
59
                 2,
60
                 "listening",
61
                 now
62
               )
63
64
      assert error.postgres.constraint ==
65
               "voice_sessions_one_active_per_conversation_index"
66
    end
67
68
    test "a terminal session for the same conversation is admitted",
69
         %{conversation: conversation} do
70
      conversation_id = uuid(conversation.id)
71
      now = DateTime.utc_now()
72
73
      assert {:ok, %{num_rows: 1}} =
74
               insert_voice_session(
75
                 uuid(Ecto.UUID.generate()),
76
                 conversation_id,
77
                 1,
78
                 "connecting",
79
                 now
80
               )
81
82
      assert {:ok, %{num_rows: 1}} =
83
               insert_voice_session(
84
                 uuid(Ecto.UUID.generate()),
85
                 conversation_id,
86
                 2,
87
                 "ended",
88
                 now,
89
                 now,
90
                 "test"
91
               )
92
    end
93
  end
94
95
  defp insert_voice_session(
96
         id,
97
         conversation_id,
98
         generation,
99
         status,
100
         started_at,
101
         ended_at \\ nil,
102
         termination_reason \\ nil
103
       ) do
104
    Repo.query(@insert, [
105
      id,
106
      conversation_id,
107
      generation,
108
      status,
109
      "openai.realtime",
110
      "openai",
111
      "openai.gpt-realtime-2.1.2026-08-16",
112
      "sarah.voice.openai.marin.v1",
113
      nil,
114
      "sarah.persona.voice.v1",
115
      String.duplicate("0", 64),
116
      "sarah.role.voice.v1",
117
      String.duplicate("1", 64),
118
      String.duplicate("2", 64),
119
      String.duplicate("3", 64),
120
      0,
121
      %{},
122
      started_at,
123
      nil,
124
      ended_at,
125
      termination_reason,
126
      nil,
127
      uuid(@initial_control_id),
128
      started_at,
129
      started_at
130
    ])
131
  end
132
133
  defp uuid(value), do: Ecto.UUID.dump!(value)
134
end
test/support/effects_crashing_horde.ex added +38

@@ -0,0 +1,38 @@

1
defmodule OpenAgents.Effects.WorkLaunchTest.CrashingHorde do
2
  @moduledoc """
3
  A stand-in for `OpenAgents.HordeSupervisor` that refuses to start children.
4
5
  During `OpenAgents.Work.start_job/1`, the job row and the launch effect are
6
  committed in one transaction, and then the worker is asked for inline. This
7
  module lets the transaction commit and the broadcast run, then reports a
8
  placement failure, so the effect is left pending exactly as a real crash in
9
  that gap would.
10
  """
11
12
  use GenServer
13
14
  @spec start_link(keyword()) :: GenServer.on_start()
15
  def start_link(_options) do
16
    GenServer.start_link(__MODULE__, [], name: OpenAgents.HordeSupervisor)
17
  end
18
19
  @impl true
20
  def init(_init_arg) do
21
    {:ok, nil}
22
  end
23
24
  @impl true
25
  def handle_call({:start_child, _child_spec}, _from, state) do
26
    {:reply, {:error, :worker_start_failed}, state}
27
  end
28
29
  @impl true
30
  def handle_call(_request, _from, state) do
31
    {:reply, {:error, :unknown_call}, state}
32
  end
33
34
  @impl true
35
  def handle_info(_message, state) do
36
    {:noreply, state}
37
  end
38
end

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