@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.
- package/fixtures/already-applied.json +16 -0
- package/migrations/20260929000100_agent_board_advisory_revert_cap_reset.sql +371 -0
- package/migrations/20260930000100_agent_board_claim_policy_race.sql +922 -0
- package/migrations/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql +158 -0
- package/migrations/20261002000100_agent_board_session_challenge_supersede.sql +383 -0
- package/package.json +1 -1
- package/schema-manifest.json +39 -3
- package/src/tooling.test.mjs +33 -2
- package/tests/agent_board_board_claim_policy.test.sql +261 -1
- package/tests/agent_board_claim_subject_gate.test.sql +11 -2
- package/tests/agent_board_claimkey.test.sql +17 -2
- package/tests/agent_board_claimpolicy_race.test.sql +1374 -0
- package/tests/agent_board_fk_lock_edges.test.sql +20 -0
- package/tests/agent_board_ownerless_quota_census.test.sql +6 -0
- package/tests/agent_board_ownerless_renew_quota.test.sql +37 -8
- package/tests/agent_board_signing_challenge_pending_cap.test.sql +391 -36
- package/tests/catalog/p_metadata_parameters.json +16 -0
- package/tests/fixtures/test-prereqs.sql +186 -0
- package/tests/isolation/expected/challenge_supersede_lock_order.out +213 -0
- package/tests/isolation/expected/claimpolicy_acquire_revert_race.out +66 -0
- package/tests/isolation/expected/claimpolicy_board_scope.out +73 -0
- package/tests/isolation/expected/claimpolicy_claimrow_lock_order.out +80 -0
- package/tests/isolation/expected/claimpolicy_isolation_refusal.out +114 -0
- package/tests/isolation/expected/claimpolicy_renew_revert_race.out +66 -0
- package/tests/isolation/expected/claimpolicy_writer_keyshare.out +49 -0
- package/tests/isolation/specs/challenge_supersede_lock_order.spec +183 -0
- package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +118 -0
- package/tests/isolation/specs/claimpolicy_board_scope.spec +115 -0
- package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +200 -0
- package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +162 -0
- package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +150 -0
- package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +107 -0
- 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
|
|
11
|
-
--
|
|
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() +
|
|
463
|
-
'ORQ-11 THE ESCAPE HATCH: reverting to inherit
|
|
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
|
|
466
|
-
--
|
|
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
|
|
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
|
-
--
|
|
2
|
-
--
|
|
3
|
-
--
|
|
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(
|
|
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
|
-
--
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
55
|
-
select
|
|
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
|
-
--
|
|
58
|
-
--
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|