@consiliency/agent-board-schema 1.50.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.
@@ -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