@consiliency/agent-board-schema 1.48.0 → 1.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/fixtures/already-applied.json +16 -0
  2. package/migrations/20260929000100_agent_board_advisory_revert_cap_reset.sql +371 -0
  3. package/migrations/20260930000100_agent_board_claim_policy_race.sql +922 -0
  4. package/migrations/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql +158 -0
  5. package/migrations/20261002000100_agent_board_session_challenge_supersede.sql +383 -0
  6. package/package.json +1 -1
  7. package/schema-manifest.json +39 -3
  8. package/src/tooling.test.mjs +33 -2
  9. package/tests/agent_board_board_claim_policy.test.sql +261 -1
  10. package/tests/agent_board_claim_subject_gate.test.sql +11 -2
  11. package/tests/agent_board_claimkey.test.sql +17 -2
  12. package/tests/agent_board_claimpolicy_race.test.sql +1374 -0
  13. package/tests/agent_board_fk_lock_edges.test.sql +20 -0
  14. package/tests/agent_board_ownerless_quota_census.test.sql +6 -0
  15. package/tests/agent_board_ownerless_renew_quota.test.sql +37 -8
  16. package/tests/agent_board_signing_challenge_pending_cap.test.sql +391 -36
  17. package/tests/catalog/p_metadata_parameters.json +16 -0
  18. package/tests/fixtures/test-prereqs.sql +186 -0
  19. package/tests/isolation/expected/challenge_supersede_lock_order.out +213 -0
  20. package/tests/isolation/expected/claimpolicy_acquire_revert_race.out +66 -0
  21. package/tests/isolation/expected/claimpolicy_board_scope.out +73 -0
  22. package/tests/isolation/expected/claimpolicy_claimrow_lock_order.out +80 -0
  23. package/tests/isolation/expected/claimpolicy_isolation_refusal.out +114 -0
  24. package/tests/isolation/expected/claimpolicy_renew_revert_race.out +66 -0
  25. package/tests/isolation/expected/claimpolicy_writer_keyshare.out +49 -0
  26. package/tests/isolation/specs/challenge_supersede_lock_order.spec +183 -0
  27. package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +118 -0
  28. package/tests/isolation/specs/claimpolicy_board_scope.spec +115 -0
  29. package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +200 -0
  30. package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +162 -0
  31. package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +150 -0
  32. package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +107 -0
  33. package/tests/seeds/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql.pre.sql +99 -0
@@ -259,12 +259,32 @@ select is(
259
259
  -- pinned as a class in agent_board_task_thread_lock_order.test.sql (TT-1), which
260
260
  -- is what makes a REGRESSION fail there and name the function, rather than only
261
261
  -- failing here as an opaque re-appearance of the pair.
262
+ --
263
+ -- ONE PAIR HAS BEEN ADDED, and it is added with its own positive pin rather than as a
264
+ -- widened exemption. F1's supersede-at-the-cap makes issue_signing_key_challenge write
265
+ -- an agent_signing_key_challenges row while holding the agent_signing_keys row, which
266
+ -- mint_fleet_agent_session takes in the opposite order -- so this detector named
267
+ -- `agent_signing_key_challenges <-> agent_signing_keys` the first time that migration
268
+ -- was written. The cycle is closed at RUNTIME, by `for update skip locked` on the
269
+ -- eviction's row selection: a transaction that never waits on a challenge row cannot
270
+ -- close a cycle. This detector is a static regex scan and cannot see a SKIP LOCKED, so
271
+ -- it reports the pair and always will.
272
+ --
273
+ -- The clause is therefore pinned TWICE, POSITIVELY, so that deleting it fails a NAMED
274
+ -- assertion rather than merely re-adding a member to a list that already contains it
275
+ -- (which would be silent):
276
+ -- * agent_board_signing_challenge_pending_cap.test.sql asserts the DEPLOYED body
277
+ -- carries it, over stripped_body() so a comment cannot satisfy the match;
278
+ -- * tests/isolation/specs/challenge_supersede_lock_order.spec proves the absence of
279
+ -- the wait across two real sessions -- removing the clause turns its golden from a
280
+ -- clean completion into `<waiting ...>` and then a deadlock.
262
281
  -- ===========================================================================
263
282
  select is(
264
283
  (select array_agg(p order by p) from (
265
284
  select distinct least(rel_a, rel_b) || ' <-> ' || greatest(rel_a, rel_b) as p
266
285
  from _abba where not both_advisory) z),
267
286
  array[
287
+ 'agent_board.agent_signing_key_challenges <-> agent_board.agent_signing_keys',
268
288
  'agent_board.agent_signing_keys <-> agent_board.agents',
269
289
  'agent_board.agent_signing_keys <-> agent_board.board_memberships',
270
290
  'agent_board.agent_signing_keys <-> agent_board.fleet_machine_agents',
@@ -386,6 +386,12 @@ select is(
386
386
  -- membership to exercise it would be testing who may set the policy (BCP's subject) rather than
387
387
  -- what the reader does with it. updated_at is set as the RPC sets it, so the row is not a shape
388
388
  -- the RPC could never produce.
389
+ --
390
+ -- SINCE #400 THE TWO PATHS DIVERGE IN ONE RESPECT, recorded here rather than left as a silent
391
+ -- premise: the RPC ALSO re-applies the ownerless TTL cap to this board's active ownerless claims on
392
+ -- an advisory -> inherit revert, and a direct UPDATE does not. It changes nothing this assertion
393
+ -- reads — a clamped 30-day lease is still active and still counted — but "the row is not a shape
394
+ -- the RPC could never produce" is now a statement about the BOARD row only.
389
395
  update agent_board.boards
390
396
  set claim_policy = 'inherit', updated_at = now()
391
397
  where id = (select adv_board from _oq);
@@ -7,8 +7,16 @@
7
7
  -- SCOPED, AND THE SCOPE IS ASSERTED HERE RATHER THAN PROMISED IN PROSE. That convergence holds
8
8
  -- against an agent whose boards do not opt out. It does NOT hold against one whose board is flipped
9
9
  -- to `claim_policy = 'advisory'` and back, which an owner may do at will and which an agent that
10
- -- created its own board may do to itself. ORQ-11/12 perform that flip-flop and pin both halves: the
11
- -- horizon survives the revert, and the census cannot see that it was taken.
10
+ -- created its own board may do to itself. ORQ-11/12 perform that flip-flop and pin what survives it.
11
+ --
12
+ -- #400 CHANGED WHAT SURVIVES, AND ORQ-11'S CONTRACT WITH IT. When this file shipped, the escape was
13
+ -- UNBOUNDED: the horizon taken during the advisory window survived the revert intact, so one flip
14
+ -- bought as much time as the agent asked for. Reverting to 'inherit' now re-applies the ownerless
15
+ -- TTL cap to that board's active ownerless claims, so a cycle banks at most
16
+ -- `_claimgate_ownerless_ttl_cap()` — what any claim on any inherit board may hold. ORQ-11 asserts
17
+ -- the CLAMP where it used to assert the survival. The escape is BOUNDED, not closed: an owner
18
+ -- flipping before each expiry still keeps an over-quota agent over quota, and ORQ-12 is unchanged
19
+ -- because the census still cannot see that any of it happened.
12
20
  --
13
21
  -- ORQ-1 IS THE ASSERTION THIS FILE EXISTS FOR, and it is written as the attack #388 describes: an
14
22
  -- agent that is already over the ceiling asks for +400 days, which before this migration it got,
@@ -431,6 +439,21 @@ select is(
431
439
  -- monotone clamp guarantees convergence against an agent whose boards do not opt out; it guarantees
432
440
  -- NOTHING against one whose board owner cooperates, and the flip-flop can be repeated forever.
433
441
  --
442
+ -- WHAT #400 TOOK AWAY IS THE HORIZON, NOT THE FLIP. The revert now re-applies the ownerless TTL cap
443
+ -- (BCP-11 owns the mechanics), so ORQ-11b's +400 days does not survive ORQ-11c. ORQ-11 asserts that
444
+ -- clamp. Its contract genuinely CHANGED — it used to assert `now() + 400 days` and now asserts
445
+ -- `now() + _claimgate_ownerless_ttl_cap()` — and the change is legitimate because the BEHAVIOUR
446
+ -- changed underneath it, not because a red test needed to go green: the migration is what makes the
447
+ -- old value impossible, and this assertion is the one #399 deliberately left in place so that
448
+ -- closing the escape could not be done in prose.
449
+ --
450
+ -- IT IS STILL AN ESCAPE, AND THIS ASSERTION STILL DOCUMENTS ONE. Each cycle banks up to the cap, so
451
+ -- an owner flipping every 29 days keeps an over-quota agent over quota indefinitely; convergence is
452
+ -- still defeatable by a board owner, just no longer by a single flip. To hold MORE than the cap the
453
+ -- board must STAY advisory — and while it does, those claims are counted by the census's
454
+ -- claims_on_advisory_boards, which is the column an operator reads. Above the cap the escape is
455
+ -- ineffective or visible; below it, ORQ-12 still applies.
456
+ --
434
457
  -- AND THE AGENT CAN BE ITS OWN OWNER, which is why the fixture builds this board through
435
458
  -- create_board rather than inserting it. create_board seeds the creator's owner membership and
436
459
  -- set_board_claim_policy is gated on exactly that, so no accomplice is required — an agent that
@@ -459,11 +482,12 @@ select lives_ok(
459
482
 
460
483
  select is(
461
484
  (select lease_expires_at from agent_board.claims where lease_token = (select tok from _tok where name = 'over-escape')),
462
- now() + interval '400 days',
463
- 'ORQ-11 THE ESCAPE HATCH: reverting to inherit does NOT take the horizon back — the +400 day lease survives the revert, so an owner who flips advisory/inherit before each expiry keeps an over-quota agent over quota forever. Convergence is guaranteed only against an agent whose boards do not opt out');
485
+ now() + agent_board._claimgate_ownerless_ttl_cap(),
486
+ 'ORQ-11 THE ESCAPE HATCH, BOUNDED (#400): reverting to inherit TAKES THE HORIZON BACK to the ownerless TTL cap — the +400 day lease does not survive the revert, so a flip-flop banks at most what any claim on any inherit board may hold. Still an escape (the owner may flip again before that expiry), no longer an unbounded one');
464
487
 
465
- -- The second half, kept HERE rather than in the census file. The escape and its invisibility are
466
- -- one defect: the census resolves board policy at QUERY time (OQ-29's subject), so once the board
488
+ -- The second half, kept HERE rather than in the census file, and UNCHANGED by #400 — which is the
489
+ -- point of keeping it. Bounding the horizon did nothing about the visibility: the escape and its
490
+ -- invisibility are one defect: the census resolves board policy at QUERY time (OQ-29's subject), so once the board
467
491
  -- is back to 'inherit' this claim contributes ZERO to claims_on_advisory_boards while the overage
468
492
  -- it produced is still counted. An operator reading the census sees an agent over quota with no
469
493
  -- advisory share — the exact shape that says "this one is a squatter, act on it" — and the column
@@ -499,14 +523,19 @@ select is(
499
523
  -- text asserts "acquire_claim/renew_claim are the only SQL writers" — a recorded fact, which rule 5
500
524
  -- says is a claim about the moment it was written. Derived here instead, so a third writer that
501
525
  -- needs this guard fails a test and NAMES ITSELF rather than relying on someone remembering.
526
+ --
527
+ -- IT DID, one release later. #400's cap reset is exactly the third writer this assertion was built
528
+ -- to catch, and it turned this test red before anyone wrote it down. The expected set is widened
529
+ -- deliberately: the reset writes leases, only ever SHORTENING them to the ownerless TTL cap, and the
530
+ -- mechanics are pinned in agent_board_board_claim_policy.test.sql (BCP-11..20).
502
531
  -- ---------------------------------------------------------------------------
503
532
  select is(
504
533
  (select string_agg(p.proname, ', ' order by p.proname)
505
534
  from pg_proc p join pg_namespace n on n.oid = p.pronamespace
506
535
  where n.nspname = 'agent_board'
507
536
  and agent_board_test.stripped_body(p.oid, true) ~ '(lease_expires_at\s*=[^=]|insert into agent_board\.claims\s*\([^)]*lease_expires_at)'),
508
- 'acquire_claim, renew_claim',
509
- 'ORQ-14 CLASS: acquire_claim and renew_claim are the ONLY functions that write a claim lease — a new one names itself here instead of quietly inheriting an unconverged renewal path');
537
+ 'acquire_claim, renew_claim, set_board_claim_policy',
538
+ 'ORQ-14 CLASS: acquire_claim, renew_claim and (since #400) set_board_claim_policy''s advisory-revert cap reset are the ONLY functions that write a claim lease — a new one names itself here instead of quietly inheriting an unconverged renewal path');
510
539
 
511
540
  -- ---------------------------------------------------------------------------
512
541
  -- ORQ-15/14: CLASS, one definition of the count. Three hand-copies of a predicate across two
@@ -1,23 +1,71 @@
1
- -- MBHARDEN SL-1 pgTAP: issue_signing_key_challenge enforces a per-key cap of
2
- -- N=16 concurrently-pending challenges; <=16 issue as before, the 17th is
3
- -- rejected, and expired/consumed challenges do not count toward the cap.
1
+ -- issue_signing_key_challenge bounds a key to N=16 concurrently-pending challenges,
2
+ -- and AT the bound it supersedes the oldest pending challenge instead of refusing.
3
+ --
4
+ -- WHY THIS FILE CHANGED SHAPE (F1, code-review-2026-09-01). It used to assert that the
5
+ -- 17th pending challenge was REJECTED. That rejection was the defect: /session step
6
+ -- "challenge" is verify_jwt=false and needs only a directory-visible handle and key id,
7
+ -- so sixteen requests denied a key every further challenge, and the client stopped its
8
+ -- refresh loop on the resulting 403. The bound is unchanged and still asserted; what is
9
+ -- asserted now is that arrival is never refused.
10
+ --
11
+ -- It also stopped standing on `select pass(...)`. Three of this file's four assertions
12
+ -- were unconditional passes placed after a DO block that raised on failure — an ok line
13
+ -- that says nothing about what it ran beside, and the exact construct review finding F51
14
+ -- names. Every assertion below reads state back out of the table.
15
+ --
16
+ -- WHY EVERY `DO` BLOCK HERE TRAPS INSTEAD OF RAISING, which is the part that is easy to
17
+ -- undo. Rewriting the passes into real assertions is only half the job, and the half that
18
+ -- is visible. The other half was found by MUTATING: reverting the supersede so the RPC
19
+ -- raises `challenge_pending_cap` again made this file report NOTHING. A bare call to a
20
+ -- raising RPC inside a `DO` block aborts the whole transaction, so pgTAP printed `1..18`,
21
+ -- then `ok 1`, `ok 2`, `ok 3`, then twenty lines of `current transaction is aborted` — and
22
+ -- ZERO `not ok`. Three of eighteen assertions ran and nothing said so. That is AGENTS.md
23
+ -- rule 3 exactly, and it is worse than it sounds: the shipped code path never raises, so
24
+ -- this only appears when someone mutates, and a `grep not ok` over the failing run comes
25
+ -- back clean.
26
+ --
27
+ -- So every block that calls the RPC wraps it in `begin … exception when others then …`
28
+ -- and records the OUTCOME (an id, or `sqlerrm`) into the `pcap_state` temp table, which a
29
+ -- named assertion then checks. A raise becomes a value, not an abort. With the trap in
30
+ -- place the same mutation emits 20 of 20 with a named `not ok`, the first being #4.
31
+ --
32
+ -- "Every block" is now literally true, and was not when first written: the SEED loop below
33
+ -- called the RPC sixteen times bare. It happens not to raise under either mutation this
34
+ -- file is cited for (sixteen issues are UNDER the cap), so nothing caught it — which is
35
+ -- the point. A block that cannot raise TODAY is one cap constant away from being the block
36
+ -- that silently stops the file, and "every" in a comment is a claim, not a description.
37
+ --
38
+ -- Two smaller traps in the same family, both of which abort identically and both of which
39
+ -- cost a debugging round here:
40
+ -- * `v_c := null` on a plpgsql `record` raises `record "v_c" is not assigned yet` — the
41
+ -- exception HANDLER was aborting the transaction. Use a scalar (`v_id uuid`).
42
+ -- * Writing `pcap_state` while still under `set local role service_role` raises
43
+ -- `permission denied for table pcap_state`: the temp table belongs to the session
44
+ -- user. Every temp-table write below therefore happens after `reset role`.
45
+ --
46
+ -- If you add a probe here that can raise, trap it. An untrapped one does not fail loudly;
47
+ -- it silently stops the file.
4
48
  create extension if not exists pgtap with schema extensions;
5
49
  set search_path = extensions, public, agent_board;
6
50
 
7
51
  begin;
8
52
 
9
- select plan(4);
53
+ select plan(20);
10
54
 
11
55
  select has_function(
12
56
  'agent_board', 'issue_signing_key_challenge', array['uuid', 'text', 'interval'],
13
57
  'issue_signing_key_challenge exists');
58
+ select has_function(
59
+ 'agent_board', 'mint_fleet_agent_session', array['uuid', 'text', 'uuid', 'uuid', 'integer'],
60
+ 'mint_fleet_agent_session exists');
14
61
 
15
- -- Block 1: 16 pending challenges are allowed; the 17th is rejected by the cap.
62
+ -- Seed one agent + key, then fill the key's sixteen pending slots.
16
63
  do $$
17
64
  declare
18
65
  v_agent uuid := '00000000-0000-0000-0000-0000000000c1';
19
66
  v_i int;
20
67
  v_c record;
68
+ v_ids uuid[] := '{}';
21
69
  begin
22
70
  insert into agent_board.agents(id, agent_type, handle, display_name)
23
71
  values (v_agent, 'agent', 'pcap-agent', 'PCAP Agent');
@@ -27,60 +75,367 @@ begin
27
75
  v_agent, 'primary', 1, public.test_pubkey('pcap-primary'), 'raw_ed25519_base64url',
28
76
  public.test_key_fingerprint('pcap-primary'));
29
77
 
78
+ -- Issue as service_role, then RESET before back-dating: service_role holds SELECT
79
+ -- only on agent_signing_key_challenges, so the UPDATE must run as the owner.
80
+ -- created_at is set explicitly rather than relying on clock order, because every
81
+ -- row in one statement-less loop can share a now().
30
82
  set local role service_role;
31
-
32
83
  for v_i in 1..16 loop
33
- select * into v_c
34
- from agent_board.issue_signing_key_challenge(v_agent, 'primary', interval '5 minutes');
35
- if v_c.challenge_id is null then
36
- raise exception 'pending challenge % was not issued below the cap', v_i;
37
- end if;
84
+ -- Trapped like every other call site here. These sixteen are below the cap and do not
85
+ -- raise today; if a change makes one raise, this leaves v_ids short and assertion 3
86
+ -- fails BY NAME instead of the raise aborting the transaction and emitting nothing.
87
+ begin
88
+ select * into v_c
89
+ from agent_board.issue_signing_key_challenge(v_agent, 'primary', interval '5 minutes');
90
+ v_ids := array_append(v_ids, v_c.challenge_id);
91
+ exception when others then
92
+ exit;
93
+ end;
38
94
  end loop;
95
+ reset role;
96
+
97
+ for v_i in 1..coalesce(array_length(v_ids, 1), 0) loop
98
+ update agent_board.agent_signing_key_challenges
99
+ set created_at = now() - make_interval(secs => (17 - v_i))
100
+ where id = v_ids[v_i];
101
+ end loop;
102
+ end
103
+ $$;
104
+ reset role;
105
+
106
+ -- 1. The bound is reached, and every one of the sixteen is genuinely pending.
107
+ select is(
108
+ (select count(*)::int from agent_board.agent_signing_key_challenges
109
+ where agent_id = '00000000-0000-0000-0000-0000000000c1'
110
+ and proof_state = 'pending' and consumed_at is null and expires_at > now()),
111
+ 16,
112
+ 'sixteen challenges issue below the cap and are all pending');
113
+
114
+ -- Remember which row is the oldest before the 17th arrives.
115
+ create temporary table pcap_state as
116
+ select (select id from agent_board.agent_signing_key_challenges
117
+ where agent_id = '00000000-0000-0000-0000-0000000000c1'
118
+ and proof_state = 'pending' and consumed_at is null
119
+ order by created_at, id limit 1) as oldest_id,
120
+ null::uuid as seventeenth_id,
121
+ null::uuid as next_oldest_id,
122
+ null::uuid as victim_id,
123
+ null::text as lapse_note;
124
+
125
+ -- 2. The 17th request is SERVED, not refused. This is the assertion that inverts.
126
+ do $$
127
+ declare
128
+ v_agent uuid := '00000000-0000-0000-0000-0000000000c1';
129
+ v_id uuid;
130
+ begin
131
+ set local role service_role;
132
+ begin
133
+ select c.challenge_id into v_id
134
+ from agent_board.issue_signing_key_challenge(v_agent, 'primary', interval '5 minutes') c;
135
+ exception when others then
136
+ -- Trapped, never re-raised: a bare call to a raising RPC aborts the whole
137
+ -- transaction, and pgTAP then emits NO `not ok` -- only a short assertion count
138
+ -- (AGENTS.md rule 3). Leaving the id null makes the assertion below fail BY NAME.
139
+ v_id := null;
140
+ end;
141
+ reset role;
142
+ update pcap_state set seventeenth_id = v_id;
143
+ end
144
+ $$;
145
+ reset role;
146
+
147
+ select isnt(
148
+ (select seventeenth_id from pcap_state), null,
149
+ 'the 17th request at the cap is served, not refused (challenge_pending_cap is not raised)');
150
+
151
+ -- 3. The resource bound the cap exists for is still held.
152
+ select is(
153
+ (select count(*)::int from agent_board.agent_signing_key_challenges
154
+ where agent_id = '00000000-0000-0000-0000-0000000000c1'
155
+ and proof_state = 'pending' and consumed_at is null and expires_at > now()),
156
+ 16,
157
+ 'the per-key pending bound is still 16 after the 17th issues');
158
+
159
+ -- 4-7. The evicted row is the OLDEST one, closed out in the shape the
160
+ -- consumed-shape CHECK requires, and it names what superseded it.
161
+ select is(
162
+ (select proof_state from agent_board.agent_signing_key_challenges
163
+ where id = (select oldest_id from pcap_state)),
164
+ 'expired',
165
+ 'the oldest pending challenge is closed out as expired');
166
+ select ok(
167
+ (select consumed_at is not null from agent_board.agent_signing_key_challenges
168
+ where id = (select oldest_id from pcap_state)),
169
+ 'the superseded row carries consumed_at (agent_signing_key_challenges_consumed_shape)');
170
+ -- `ok(a = b)`, NOT `is(a, b)`. pgTAP's `is` is IS NOT DISTINCT FROM, so under the
171
+ -- revert-supersede mutation — where nothing is evicted and nothing is issued, leaving
172
+ -- BOTH sides null — `is(null, null)` PASSES. This assertion was cited in the PR as one of
173
+ -- the nine that mutation fails, and it was in fact vacuous there: a probe that scores
174
+ -- 0-vs-0 and reads as agreement. `=` yields NULL when either side is null, and pgTAP
175
+ -- fails a NULL result, so the null case now reads `not ok`.
176
+ select ok(
177
+ (select (metadata->>'superseded_by')::uuid from agent_board.agent_signing_key_challenges
178
+ where id = (select oldest_id from pcap_state))
179
+ = (select seventeenth_id from pcap_state),
180
+ 'the superseded row names the challenge that superseded it');
181
+ select is(
182
+ (select count(*)::int from agent_board.agent_signing_key_challenges
183
+ where agent_id = '00000000-0000-0000-0000-0000000000c1'
184
+ and metadata ? 'superseded_by'),
185
+ 1,
186
+ 'exactly one row was evicted -- the oldest, and only the oldest');
39
187
 
188
+ -- 8. A second request at the cap takes the NEXT-oldest, not the same row twice and
189
+ -- not an arbitrary one.
190
+ do $$
191
+ declare
192
+ v_next uuid;
193
+ v_agent uuid := '00000000-0000-0000-0000-0000000000c1';
194
+ begin
195
+ select id into v_next from agent_board.agent_signing_key_challenges
196
+ where agent_id = v_agent and proof_state = 'pending' and consumed_at is null
197
+ order by created_at, id limit 1;
198
+ update pcap_state set next_oldest_id = v_next;
199
+ set local role service_role;
40
200
  begin
41
- perform *
42
- from agent_board.issue_signing_key_challenge(v_agent, 'primary', interval '5 minutes');
43
- raise exception 'the 17th pending challenge was NOT rejected by the cap';
44
- exception
45
- when others then
46
- if sqlerrm !~ 'challenge_pending_cap' then
47
- raise;
48
- end if;
201
+ perform * from agent_board.issue_signing_key_challenge(v_agent, 'primary', interval '5 minutes');
202
+ exception when others then
203
+ null;
49
204
  end;
205
+ reset role;
50
206
  end
51
207
  $$;
52
208
  reset role;
53
209
 
54
- select pass('16 pending challenges issue below the cap');
55
- select pass('the 17th concurrently-pending challenge is rejected (challenge_pending_cap)');
210
+ select is(
211
+ (select proof_state from agent_board.agent_signing_key_challenges
212
+ where id = (select next_oldest_id from pcap_state)),
213
+ 'expired',
214
+ 'a second request at the cap evicts the NEXT-oldest row, not the same one twice');
215
+ select is(
216
+ (select count(*)::int from agent_board.agent_signing_key_challenges
217
+ where agent_id = '00000000-0000-0000-0000-0000000000c1'
218
+ and metadata ? 'superseded_by'),
219
+ 2,
220
+ 'a second request at the cap evicts one row, not several');
221
+
222
+ -- 9-10. An eviction is reported as an eviction, NOT as a replay. A client may retry
223
+ -- challenge_superseded; it must never learn to retry nonce_replay, so the two must not
224
+ -- share a message.
225
+ select throws_matching(
226
+ $q$ select agent_board.mint_fleet_agent_session(
227
+ (select oldest_id from pcap_state),
228
+ (select challenge_nonce from agent_board.agent_signing_key_challenges
229
+ where id = (select oldest_id from pcap_state)),
230
+ (select id from agent_board.agent_signing_keys
231
+ where agent_id = '00000000-0000-0000-0000-0000000000c1'),
232
+ '00000000-0000-0000-0000-0000000000c1'::uuid,
233
+ 900) $q$,
234
+ 'challenge_superseded',
235
+ 'minting a superseded challenge raises challenge_superseded');
236
+
237
+ -- A genuinely CONSUMED challenge still raises nonce_replay: the new branch narrowed
238
+ -- nothing. Consume one of the still-pending rows by hand and mint it.
239
+ update agent_board.agent_signing_key_challenges
240
+ set proof_state = 'consumed', consumed_at = now()
241
+ where id = (select id from agent_board.agent_signing_key_challenges
242
+ where agent_id = '00000000-0000-0000-0000-0000000000c1'
243
+ and proof_state = 'pending' and consumed_at is null
244
+ order by created_at desc, id limit 1);
245
+
246
+ select throws_matching(
247
+ $q$ select agent_board.mint_fleet_agent_session(
248
+ (select id from agent_board.agent_signing_key_challenges
249
+ where agent_id = '00000000-0000-0000-0000-0000000000c1'
250
+ and proof_state = 'consumed' and not (metadata ? 'superseded_by')
251
+ order by consumed_at desc limit 1),
252
+ (select challenge_nonce from agent_board.agent_signing_key_challenges
253
+ where agent_id = '00000000-0000-0000-0000-0000000000c1'
254
+ and proof_state = 'consumed' and not (metadata ? 'superseded_by')
255
+ order by consumed_at desc limit 1),
256
+ (select id from agent_board.agent_signing_keys
257
+ where agent_id = '00000000-0000-0000-0000-0000000000c1'),
258
+ '00000000-0000-0000-0000-0000000000c1'::uuid,
259
+ 900) $q$,
260
+ 'nonce_replay',
261
+ 'a genuinely consumed challenge still raises nonce_replay, not challenge_superseded');
56
262
 
57
- -- Expire one pending challenge (as owner), then a new challenge succeeds: expired
58
- -- rows do not count toward the cap.
263
+ -- 11-12. An EXPIRED-by-lapse pending row frees a slot without being evicted: expiry
264
+ -- and eviction remain different things, and expiry still costs no one their challenge.
59
265
  update agent_board.agent_signing_key_challenges
60
266
  set expires_at = now() - interval '1 minute'
61
- where id = (
62
- select id from agent_board.agent_signing_key_challenges
63
- where agent_id = '00000000-0000-0000-0000-0000000000c1'
64
- and proof_state = 'pending'
65
- order by created_at
66
- limit 1);
267
+ where id = (select id from agent_board.agent_signing_key_challenges
268
+ where agent_id = '00000000-0000-0000-0000-0000000000c1'
269
+ and proof_state = 'pending' and consumed_at is null
270
+ order by created_at desc, id limit 1);
67
271
 
68
272
  do $$
69
273
  declare
70
274
  v_agent uuid := '00000000-0000-0000-0000-0000000000c1';
71
- v_c record;
275
+ v_note text;
276
+ begin
277
+ set local role service_role;
278
+ begin
279
+ perform * from agent_board.issue_signing_key_challenge(v_agent, 'primary', interval '5 minutes');
280
+ v_note := 'issued';
281
+ exception when others then
282
+ v_note := sqlerrm;
283
+ end;
284
+ -- The pcap_state write happens as the OWNER: service_role has no privilege on a
285
+ -- temp table created by the session user, and a permission error here would abort
286
+ -- the transaction exactly as an untrapped raise would.
287
+ reset role;
288
+ update pcap_state set lapse_note = v_note;
289
+ end
290
+ $$;
291
+ reset role;
292
+
293
+ select is(
294
+ (select lapse_note from pcap_state), 'issued',
295
+ 'a lapsed pending row frees a slot, so the next request issues rather than raising');
296
+
297
+ select is(
298
+ (select count(*)::int from agent_board.agent_signing_key_challenges
299
+ where agent_id = '00000000-0000-0000-0000-0000000000c1'
300
+ and metadata ? 'superseded_by'),
301
+ 2,
302
+ 'a lapsed pending row frees a slot with NO eviction (still 2 superseded)');
303
+ select is(
304
+ (select count(*)::int from agent_board.agent_signing_key_challenges
305
+ where agent_id = '00000000-0000-0000-0000-0000000000c1'
306
+ and proof_state = 'pending' and consumed_at is null and expires_at > now()),
307
+ 15,
308
+ 'the pending set never exceeds the bound across evictions and lapses');
309
+
310
+ -- 13. The grant surface is unchanged by the create-or-replace.
311
+ select is(
312
+ (select array_agg(g order by g)
313
+ from unnest(array['anon', 'authenticated', 'service_role']) g
314
+ where has_function_privilege(
315
+ g, 'agent_board.issue_signing_key_challenge(uuid,text,interval)', 'execute')),
316
+ array['authenticated', 'service_role'],
317
+ 'issue_signing_key_challenge stays authenticated + service_role, never anon');
318
+
319
+ -- 14. THE DEADLOCK-FREEDOM CLAUSE, pinned positively.
320
+ --
321
+ -- The eviction writes agent_signing_key_challenges while this function holds the
322
+ -- agent_signing_keys row; mint_fleet_agent_session takes that pair in the opposite
323
+ -- order (challenge row FOR UPDATE at 20260718000600:73-76, key row FOR UPDATE at
324
+ -- :95-100). `for update skip locked` is what keeps that from being a live ABBA:
325
+ -- an issuer that never waits on a challenge row cannot close the cycle.
326
+ --
327
+ -- agent_board_fk_lock_edges.test.sql FKL-10 reports the PAIR (its detector is a
328
+ -- static scan and cannot see a SKIP LOCKED, so the pair is listed there as known),
329
+ -- which means removing this clause would fail NOTHING there. This is the named
330
+ -- assertion that removing it fails. Read over stripped_body(), which removes
331
+ -- comments and literals in one pass, so the prose above cannot satisfy the match --
332
+ -- a comment satisfying a body scan is how a prior class assertion in this repo went
333
+ -- green over an inverted lock.
334
+ --
335
+ -- A source scan has a floor, and this one is bounded rather than trusted: the
336
+ -- BEHAVIOUR (an issuer skipping a mint-held row instead of waiting) is proved across
337
+ -- two real sessions by tests/isolation/specs/challenge_supersede_lock_order.spec.
338
+ -- Neither check covers the other -- a scan cannot observe a wait, and the golden
339
+ -- cannot tell you which function the clause is missing from.
340
+ select matches(
341
+ (select agent_board_test.stripped_body(
342
+ 'agent_board.issue_signing_key_challenge(uuid,text,interval)'::regprocedure)),
343
+ 'for\s+update\s+skip\s+locked',
344
+ 'the eviction selects its victim FOR UPDATE SKIP LOCKED (no wait => no ABBA with mint_fleet_agent_session)');
345
+
346
+ -- 19-20. THE RESIDUAL, PINNED RATHER THAN LEFT IN PROSE.
347
+ --
348
+ -- What this change closes is the HOLDABLE lockout: filling a key's sixteen slots and
349
+ -- walking away no longer denies anything, because arrival is never refused. What it does
350
+ -- NOT close is eviction under a SUSTAINED flood. A challenge issued now is the NEWEST, so
351
+ -- it survives fifteen further issues and is evicted by the sixteenth -- and there is no
352
+ -- per-key aggregate bound on how fast those sixteen arrive, because a per-key aggregate
353
+ -- bound is exactly the holdable lever this change removed. Per source it is bounded by the
354
+ -- `session:` rate cap; across N sources the sixteen land in 1/N of that time.
355
+ --
356
+ -- So a minter whose issue->mint gap exceeds the flood's rotation period loses its
357
+ -- challenge, for as long as the flood runs. That is DoS-class -- it costs the attacker
358
+ -- continuous traffic and stores nothing -- and it is a real residual, recorded here as an
359
+ -- assertion instead of a sentence because a sentence is not re-checked (rule 5).
360
+ --
361
+ -- NOT A DUPLICATE OF #12. That one mints a row which was ALREADY the oldest of sixteen and
362
+ -- was evicted by ONE further issue. This one starts from a challenge that is the NEWEST of
363
+ -- its key and shows the whole queue rotating past it -- the property under discussion is
364
+ -- that a LIVE minter can be rotated out, which #12's setup cannot exhibit.
365
+ --
366
+ -- The count is load-bearing and was measured, not assumed: with fifteen further issues
367
+ -- both assertions below FAIL (the victim survives). Sixteen is the number.
368
+ do $$
369
+ declare
370
+ v_agent uuid := '00000000-0000-0000-0000-0000000000c2';
371
+ v_victim uuid;
372
+ v_i int;
72
373
  begin
374
+ insert into agent_board.agents(id, agent_type, handle, display_name)
375
+ values (v_agent, 'agent', 'pcap-victim', 'PCAP Victim');
376
+ -- active + proven deliberately: mint checks the key AFTER the superseded branch, so an
377
+ -- inactive key would let assertion 20 pass on `key_revoked` instead of on the property.
378
+ insert into agent_board.agent_signing_keys(
379
+ agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
380
+ values (
381
+ v_agent, 'primary', 1, public.test_pubkey('pcap-victim'), 'raw_ed25519_base64url',
382
+ public.test_key_fingerprint('pcap-victim'), 'active', 'proven');
383
+
73
384
  set local role service_role;
74
- select * into v_c
75
- from agent_board.issue_signing_key_challenge(v_agent, 'primary', interval '5 minutes');
76
- if v_c.challenge_id is null then
77
- raise exception 'a new challenge was rejected even though an expired row freed a slot';
78
- end if;
385
+ -- The victim obtains a challenge, exactly as a legitimate agent would.
386
+ begin
387
+ select c.challenge_id into v_victim
388
+ from agent_board.issue_signing_key_challenge(v_agent, 'primary', interval '5 minutes') c;
389
+ exception when others then
390
+ v_victim := null; -- trapped: an untrapped raise kills the file (see the header)
391
+ end;
392
+ reset role;
393
+
394
+ -- Back-date the victim so "oldest" is unambiguous. `now()` is TRANSACTION start time, so
395
+ -- every row this pgTAP transaction inserts shares one created_at and the eviction's
396
+ -- `order by created_at, id` degenerates to uuid order -- which made the first version of
397
+ -- this block evict a random row and fail for a reason that had nothing to do with the
398
+ -- property. In production each issue is its own transaction and now() advances, so this
399
+ -- back-date restores the real ordering rather than inventing one. Owner-only UPDATE,
400
+ -- hence the reset above.
401
+ update agent_board.agent_signing_key_challenges
402
+ set created_at = now() - interval '1 hour'
403
+ where id = v_victim;
404
+
405
+ set local role service_role;
406
+ -- Sixteen further issues for the SAME key. The 16th finds the set full and evicts the
407
+ -- oldest, which by then is the victim's.
408
+ for v_i in 1..16 loop
409
+ begin
410
+ perform * from agent_board.issue_signing_key_challenge(v_agent, 'primary', interval '5 minutes');
411
+ exception when others then
412
+ null;
413
+ end;
414
+ end loop;
415
+ reset role;
416
+
417
+ update pcap_state set victim_id = v_victim;
79
418
  end
80
419
  $$;
81
420
  reset role;
82
421
 
83
- select pass('an expired pending challenge frees a slot under the cap');
422
+ select is(
423
+ (select proof_state from agent_board.agent_signing_key_challenges
424
+ where id = (select victim_id from pcap_state)),
425
+ 'expired',
426
+ 'RESIDUAL: a live challenge is rotated out by sixteen later issues for the same key');
427
+
428
+ select throws_matching(
429
+ $q$ select agent_board.mint_fleet_agent_session(
430
+ (select victim_id from pcap_state),
431
+ (select challenge_nonce from agent_board.agent_signing_key_challenges
432
+ where id = (select victim_id from pcap_state)),
433
+ (select id from agent_board.agent_signing_keys
434
+ where agent_id = '00000000-0000-0000-0000-0000000000c2'),
435
+ '00000000-0000-0000-0000-0000000000c2'::uuid,
436
+ 900) $q$,
437
+ 'challenge_superseded',
438
+ 'RESIDUAL: that victim''s mint then fails -- a sustained flood evicts a slow minter (DoS-class, retryable, not a stored lockout)');
84
439
 
85
440
  select finish();
86
441
 
@@ -73,10 +73,22 @@
73
73
  "signature": "_claimgate_board_claim_policy(p_thread_id uuid)",
74
74
  "declares_p_metadata": false
75
75
  },
76
+ {
77
+ "signature": "_claimgate_board_claim_policy_of(p_board_id uuid)",
78
+ "declares_p_metadata": false
79
+ },
80
+ {
81
+ "signature": "_claimgate_board_of_thread(p_thread_id uuid)",
82
+ "declares_p_metadata": false
83
+ },
76
84
  {
77
85
  "signature": "_claimgate_kind_enforced(p_resource_kind text)",
78
86
  "declares_p_metadata": false
79
87
  },
88
+ {
89
+ "signature": "_claimgate_lock_board_policy(p_board_id uuid)",
90
+ "declares_p_metadata": false
91
+ },
80
92
  {
81
93
  "signature": "_claimgate_max_lease_horizon()",
82
94
  "declares_p_metadata": false
@@ -85,6 +97,10 @@
85
97
  "signature": "_claimgate_ownerless_guards_enabled()",
86
98
  "declares_p_metadata": false
87
99
  },
100
+ {
101
+ "signature": "_claimgate_ownerless_kinds()",
102
+ "declares_p_metadata": false
103
+ },
88
104
  {
89
105
  "signature": "_claimgate_ownerless_quota()",
90
106
  "declares_p_metadata": false