@consiliency/agent-board-schema 1.24.0 → 1.27.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 +12 -0
- package/migrations/20260906000100_agent_board_enroll_resumption.sql +749 -0
- package/migrations/20260907000100_agent_board_signed_authority_provenance.sql +243 -0
- package/migrations/20260908000100_agent_board_signer_lifecycle_revalidation.sql +451 -0
- package/package.json +1 -1
- package/schema-manifest.json +30 -3
- package/src/tooling.test.mjs +3 -3
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +129 -112
- package/tests/agent_board_claimkey.test.sql +25 -3
- package/tests/agent_board_consent_emission.test.sql +17 -13
- package/tests/agent_board_consent_gating.test.sql +55 -11
- package/tests/agent_board_delegation_policies.test.sql +3 -1
- package/tests/agent_board_enroll_resumption.test.sql +412 -0
- package/tests/agent_board_federated_lock_order_class.test.sql +1 -3
- package/tests/agent_board_fedsession_provider_absence_gate.test.sql +9 -6
- package/tests/agent_board_fleet_machine_reactivation.test.sql +56 -5
- package/tests/agent_board_mint_harden.test.sql +39 -4
- package/tests/agent_board_outbox_wake_ordering.test.sql +27 -12
- package/tests/agent_board_rejectsem.test.sql +1 -1
- package/tests/agent_board_rolereset.test.sql +23 -2
- package/tests/agent_board_rowid.test.sql +24 -13
- package/tests/agent_board_scoped_send_regrant.test.sql +8 -4
- package/tests/agent_board_signed_authority_provenance.test.sql +477 -0
- package/tests/agent_board_signer_lifecycle.test.sql +709 -0
- package/tests/agent_board_source_strip_helper.test.sql +285 -0
- package/tests/agent_board_wake_recovery.test.sql +58 -40
- package/tests/catalog/p_metadata_parameters.json +693 -0
- package/tests/fixtures/test-prereqs.sql +313 -0
- package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +3 -3
- package/tests/isolation/expected/signed_grant_onboarding_deadlock.out +220 -0
- package/tests/isolation/expected/signer_lifecycle_revoke.out +307 -0
- package/tests/isolation/specs/signed_grant_onboarding_deadlock.spec +159 -0
- package/tests/isolation/specs/signer_lifecycle_revoke.spec +176 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
-- #269 pgTAP: signer-lifecycle revalidation for the two signed AUTHORITY wrappers.
|
|
2
|
+
--
|
|
3
|
+
-- The claim under test: the gateway decision must say the signature VERIFIED, and at MUTATION
|
|
4
|
+
-- time — not at the earlier moment Portal verified it — the decision's signer must still be an
|
|
5
|
+
-- active agent, and the signing key the decision names must still exist, belong to THAT agent,
|
|
6
|
+
-- be active and unexpired.
|
|
7
|
+
--
|
|
8
|
+
-- The verification half (section 4b) was added after the lifecycle half shipped, because
|
|
9
|
+
-- nothing in the chain asked for the VERDICT: a decision naming a live signer and a live key
|
|
10
|
+
-- but carrying verification_status='failed' completed the authority mutation and recorded
|
|
11
|
+
-- 'failed' in the evidence row beside it. The accepted set is exactly {'verified'}; 'observed'
|
|
12
|
+
-- and 'warn' label UNSIGNED writes accepted under those enforcement modes
|
|
13
|
+
-- (specs/agent-provenance-signing.md:258-259), not weaker verifications.
|
|
14
|
+
--
|
|
15
|
+
-- What this file can and cannot reach. A pgTAP file runs in ONE transaction, so it can prove
|
|
16
|
+
-- the PREDICATE (a suspended signer is refused) but structurally cannot produce the
|
|
17
|
+
-- interleaving the issue is about (a revocation committing between two calls) or the row
|
|
18
|
+
-- locks that serialize it. Those live in tests/isolation/specs/signer_lifecycle_revoke.spec,
|
|
19
|
+
-- driven by isolationtester across committed sessions.
|
|
20
|
+
--
|
|
21
|
+
-- Methodology, inherited from agent_board_signed_authority_provenance.test.sql:
|
|
22
|
+
-- * RPC probes are driven by the JWT claim blob only; SET LOCAL ROLE is never used, since
|
|
23
|
+
-- it would revoke _is_service_role() and mask the gateway branch behind a permission
|
|
24
|
+
-- error on the function itself.
|
|
25
|
+
-- * Allow-side probes are lives_ok(...) then a SEPARATE state check. A bare
|
|
26
|
+
-- `select ... from <raising rpc>(...)` ABORTS the transaction instead of failing it,
|
|
27
|
+
-- silently dropping every later assertion (#113, CLAUDE.md rule 3).
|
|
28
|
+
-- * Every refusal probe is followed by a state check, because a gate that raises AND writes
|
|
29
|
+
-- is the failure mode a throws_ok alone cannot see.
|
|
30
|
+
-- * The class assertions at the end are catalog-derived (pg_proc + stripped_body), not a
|
|
31
|
+
-- list written here, so a NEW signed authority wrapper that forgets the revalidation
|
|
32
|
+
-- fails and NAMES ITSELF (CLAUDE.md rule 1). The five exempt wrappers are asserted
|
|
33
|
+
-- POSITIVELY — by the non-authority write each of them actually performs — never by the
|
|
34
|
+
-- absence of a match.
|
|
35
|
+
|
|
36
|
+
create extension if not exists pgtap with schema extensions;
|
|
37
|
+
set search_path = extensions, public, agent_board;
|
|
38
|
+
|
|
39
|
+
begin;
|
|
40
|
+
|
|
41
|
+
select plan(48);
|
|
42
|
+
|
|
43
|
+
create function pg_temp.claims(p_sub text) returns void language plpgsql as $$
|
|
44
|
+
begin
|
|
45
|
+
perform set_config('request.jwt.claims', json_build_object('role','authenticated','sub',p_sub)::text, true);
|
|
46
|
+
end $$;
|
|
47
|
+
create function pg_temp.service() returns void language plpgsql as $$
|
|
48
|
+
begin
|
|
49
|
+
perform set_config('request.jwt.claims', '', true);
|
|
50
|
+
end $$;
|
|
51
|
+
|
|
52
|
+
-- Fixture -------------------------------------------------------------------
|
|
53
|
+
-- agent 1 = board owner and the signer the gateway verifies
|
|
54
|
+
-- agent 2 = grantee, an active member
|
|
55
|
+
-- agent 3 = another member, holder of a perfectly live key that is NOT agent 1's
|
|
56
|
+
|
|
57
|
+
insert into auth.users(id, instance_id, aud, role, email)
|
|
58
|
+
select ('26900000-0000-0000-0000-' || lpad(n::text,12,'0'))::uuid,
|
|
59
|
+
'00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated',
|
|
60
|
+
'k269u'||n||'@t.test'
|
|
61
|
+
from generate_series(1,3) n;
|
|
62
|
+
|
|
63
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id)
|
|
64
|
+
select ('26910000-0000-4000-8000-' || lpad(n::text,12,'0'))::uuid,
|
|
65
|
+
'agent','agent','k269-agent-'||n,'K269 Agent '||n,'active',
|
|
66
|
+
('26900000-0000-0000-0000-' || lpad(n::text,12,'0'))::uuid
|
|
67
|
+
from generate_series(1,3) n;
|
|
68
|
+
|
|
69
|
+
insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
|
|
70
|
+
values ('26920000-0000-4000-8000-000000000001','k269-board','K269 Board',
|
|
71
|
+
'26910000-0000-4000-8000-000000000001'::uuid, 'private','active');
|
|
72
|
+
|
|
73
|
+
insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
|
|
74
|
+
values
|
|
75
|
+
('26920000-0000-4000-8000-000000000001','26910000-0000-4000-8000-000000000001','owner','joined','26910000-0000-4000-8000-000000000001'),
|
|
76
|
+
('26920000-0000-4000-8000-000000000001','26910000-0000-4000-8000-000000000002','member','joined','26910000-0000-4000-8000-000000000001'),
|
|
77
|
+
('26920000-0000-4000-8000-000000000001','26910000-0000-4000-8000-000000000003','member','joined','26910000-0000-4000-8000-000000000001');
|
|
78
|
+
|
|
79
|
+
-- KEY A: agent 1's live key (the happy path).
|
|
80
|
+
-- KEY B: agent 3's live key — the "valid, but somebody else's" case.
|
|
81
|
+
-- KEY C: agent 1's key, still status='active' but past its expires_at. Nothing sweeps
|
|
82
|
+
-- expires_at into status outside issue_signing_key_challenge, so this state is real.
|
|
83
|
+
-- KEY D: agent 1's revoked key.
|
|
84
|
+
insert into agent_board.agent_signing_keys(
|
|
85
|
+
id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
|
|
86
|
+
status, proof_state, expires_at, revoked_at)
|
|
87
|
+
values
|
|
88
|
+
('26930000-0000-4000-8000-000000000001','26910000-0000-4000-8000-000000000001',
|
|
89
|
+
'primary', 1, public.test_pubkey('k269-a'), 'raw_ed25519_base64url',
|
|
90
|
+
public.test_key_fingerprint('k269-a'), 'active', 'proven', null, null),
|
|
91
|
+
('26930000-0000-4000-8000-000000000002','26910000-0000-4000-8000-000000000003',
|
|
92
|
+
'primary', 1, public.test_pubkey('k269-b'), 'raw_ed25519_base64url',
|
|
93
|
+
public.test_key_fingerprint('k269-b'), 'active', 'proven', null, null),
|
|
94
|
+
('26930000-0000-4000-8000-000000000003','26910000-0000-4000-8000-000000000001',
|
|
95
|
+
'stale', 2, public.test_pubkey('k269-c'), 'raw_ed25519_base64url',
|
|
96
|
+
public.test_key_fingerprint('k269-c'), 'active', 'proven', now() - interval '1 hour', null),
|
|
97
|
+
('26930000-0000-4000-8000-000000000004','26910000-0000-4000-8000-000000000001',
|
|
98
|
+
'dead', 3, public.test_pubkey('k269-d'), 'raw_ed25519_base64url',
|
|
99
|
+
public.test_key_fingerprint('k269-d'), 'revoked', 'revoked', null, now());
|
|
100
|
+
|
|
101
|
+
-- The gateway decision. `agent_id` is the VERIFIED signer; the key triple is what Portal
|
|
102
|
+
-- resolved and what _record_signed_write_evidence copies into signature_evidence verbatim.
|
|
103
|
+
create function pg_temp.gw(p_actor text, p_key text, p_key_id text, p_key_version integer)
|
|
104
|
+
returns jsonb language sql immutable as $$
|
|
105
|
+
select jsonb_strip_nulls(jsonb_build_object(
|
|
106
|
+
'agent_id', p_actor,
|
|
107
|
+
'signing_key_id', p_key,
|
|
108
|
+
'key_id', p_key_id,
|
|
109
|
+
'key_version', p_key_version,
|
|
110
|
+
'algorithm', 'Ed25519',
|
|
111
|
+
'verification_status', 'verified',
|
|
112
|
+
'payload_digest', 'sha256:' || repeat('a', 64),
|
|
113
|
+
'signature_digest', 'sha256:' || repeat('b', 64),
|
|
114
|
+
'canonicalization_version', 'agent-provenance-jcs.v1',
|
|
115
|
+
'enforcement_mode', 'require_for_agent_writes',
|
|
116
|
+
'redacted', true));
|
|
117
|
+
$$;
|
|
118
|
+
|
|
119
|
+
-- Agent 1 signing with key A: the shape every allow-side probe uses.
|
|
120
|
+
create function pg_temp.gwa() returns jsonb language sql immutable as $$
|
|
121
|
+
select pg_temp.gw('26910000-0000-4000-8000-000000000001',
|
|
122
|
+
'26930000-0000-4000-8000-000000000001', 'primary', 1);
|
|
123
|
+
$$;
|
|
124
|
+
|
|
125
|
+
create function pg_temp.grant_payload(p_role text) returns jsonb language sql immutable as $$
|
|
126
|
+
select jsonb_build_object(
|
|
127
|
+
'p_board_id', '26920000-0000-4000-8000-000000000001',
|
|
128
|
+
'p_agent_id', '26910000-0000-4000-8000-000000000002',
|
|
129
|
+
'p_grantee_role', p_role);
|
|
130
|
+
$$;
|
|
131
|
+
|
|
132
|
+
-- ===========================================================================
|
|
133
|
+
-- 1. The revalidation chokepoint exists, and it is not reachable by a caller.
|
|
134
|
+
-- A SECURITY DEFINER function grants EXECUTE to PUBLIC by default, and this one
|
|
135
|
+
-- takes row locks on an arbitrary agent id with no authorization of its own.
|
|
136
|
+
-- ===========================================================================
|
|
137
|
+
|
|
138
|
+
select has_function('agent_board', '_assert_signed_authority_signer_live', array['uuid','jsonb'],
|
|
139
|
+
'the signer-lifecycle revalidation helper exists');
|
|
140
|
+
|
|
141
|
+
select is(
|
|
142
|
+
(select string_agg(distinct coalesce(pg_get_userbyid(nullif(a.grantee,0)),'PUBLIC'), ','
|
|
143
|
+
order by coalesce(pg_get_userbyid(nullif(a.grantee,0)),'PUBLIC'))
|
|
144
|
+
from pg_proc p, aclexplode(p.proacl) a
|
|
145
|
+
where p.oid = 'agent_board._assert_signed_authority_signer_live(uuid,jsonb)'::regprocedure
|
|
146
|
+
and a.privilege_type = 'EXECUTE'),
|
|
147
|
+
'postgres,service_role',
|
|
148
|
+
'the revalidation helper executes for service_role only (never PUBLIC/anon/authenticated)');
|
|
149
|
+
|
|
150
|
+
select isnt(
|
|
151
|
+
(select obj_description('agent_board._assert_signed_authority_signer_live(uuid,jsonb)'::regprocedure, 'pg_proc')),
|
|
152
|
+
null,
|
|
153
|
+
'the revalidation helper carries its COMMENT');
|
|
154
|
+
|
|
155
|
+
-- ===========================================================================
|
|
156
|
+
-- 2. The allow side is untouched: a live signer with a live key still writes.
|
|
157
|
+
-- ===========================================================================
|
|
158
|
+
|
|
159
|
+
select pg_temp.service();
|
|
160
|
+
|
|
161
|
+
select lives_ok(
|
|
162
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
163
|
+
pg_temp.grant_payload('admin'), pg_temp.gwa())$$,
|
|
164
|
+
'a signed grant by an ACTIVE owner with a LIVE key still succeeds');
|
|
165
|
+
|
|
166
|
+
select is(
|
|
167
|
+
(select role from agent_board.board_memberships
|
|
168
|
+
where board_id = '26920000-0000-4000-8000-000000000001'
|
|
169
|
+
and agent_id = '26910000-0000-4000-8000-000000000002'),
|
|
170
|
+
'admin',
|
|
171
|
+
'and it actually raised the grantee role');
|
|
172
|
+
|
|
173
|
+
select is(
|
|
174
|
+
(select count(*)::int from agent_board.signature_evidence where operation = 'grant_board_role'),
|
|
175
|
+
1,
|
|
176
|
+
'and it recorded exactly one signature_evidence row');
|
|
177
|
+
|
|
178
|
+
select lives_ok(
|
|
179
|
+
$$select * from agent_board.signed_set_board_authority_ceiling(
|
|
180
|
+
jsonb_build_object(
|
|
181
|
+
'p_board_id', '26920000-0000-4000-8000-000000000001',
|
|
182
|
+
'p_max_grantable_role', 'owner'),
|
|
183
|
+
pg_temp.gwa())$$,
|
|
184
|
+
'a signed ceiling change by an ACTIVE owner with a LIVE key still succeeds');
|
|
185
|
+
|
|
186
|
+
select is(
|
|
187
|
+
(select max_grantable_role from agent_board.board_authority_ceilings
|
|
188
|
+
where board_id = '26920000-0000-4000-8000-000000000001'),
|
|
189
|
+
'owner',
|
|
190
|
+
'and it actually wrote the ceiling');
|
|
191
|
+
|
|
192
|
+
-- ===========================================================================
|
|
193
|
+
-- 3. Agent lifecycle. Each of the three non-active states is refused, and the
|
|
194
|
+
-- refusal leaves no write behind.
|
|
195
|
+
-- ===========================================================================
|
|
196
|
+
|
|
197
|
+
update agent_board.agents set status = 'suspended'
|
|
198
|
+
where id = '26910000-0000-4000-8000-000000000001';
|
|
199
|
+
|
|
200
|
+
select throws_like(
|
|
201
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
202
|
+
pg_temp.grant_payload('member'), pg_temp.gwa())$$,
|
|
203
|
+
'%signer_not_active%',
|
|
204
|
+
'a SUSPENDED signer cannot complete a signed grant');
|
|
205
|
+
|
|
206
|
+
select is(
|
|
207
|
+
(select role from agent_board.board_memberships
|
|
208
|
+
where board_id = '26920000-0000-4000-8000-000000000001'
|
|
209
|
+
and agent_id = '26910000-0000-4000-8000-000000000002'),
|
|
210
|
+
'admin',
|
|
211
|
+
'and the refused grant did not raise the grantee');
|
|
212
|
+
|
|
213
|
+
select throws_like(
|
|
214
|
+
$$select * from agent_board.signed_set_board_authority_ceiling(
|
|
215
|
+
jsonb_build_object(
|
|
216
|
+
'p_board_id', '26920000-0000-4000-8000-000000000001',
|
|
217
|
+
'p_max_grantable_role', 'member'),
|
|
218
|
+
pg_temp.gwa())$$,
|
|
219
|
+
'%signer_not_active%',
|
|
220
|
+
'a SUSPENDED signer cannot complete a signed ceiling change either');
|
|
221
|
+
|
|
222
|
+
select is(
|
|
223
|
+
(select max_grantable_role from agent_board.board_authority_ceilings
|
|
224
|
+
where board_id = '26920000-0000-4000-8000-000000000001'),
|
|
225
|
+
'owner',
|
|
226
|
+
'and the refused ceiling change left the committed ceiling alone');
|
|
227
|
+
|
|
228
|
+
update agent_board.agents set status = 'revoked'
|
|
229
|
+
where id = '26910000-0000-4000-8000-000000000001';
|
|
230
|
+
|
|
231
|
+
select throws_like(
|
|
232
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
233
|
+
pg_temp.grant_payload('member'), pg_temp.gwa())$$,
|
|
234
|
+
'%signer_not_active%',
|
|
235
|
+
'a REVOKED signer cannot complete a signed grant');
|
|
236
|
+
|
|
237
|
+
update agent_board.agents set status = 'inactive'
|
|
238
|
+
where id = '26910000-0000-4000-8000-000000000001';
|
|
239
|
+
|
|
240
|
+
select throws_like(
|
|
241
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
242
|
+
pg_temp.grant_payload('member'), pg_temp.gwa())$$,
|
|
243
|
+
'%signer_not_active%',
|
|
244
|
+
'an INACTIVE signer cannot complete a signed grant');
|
|
245
|
+
|
|
246
|
+
update agent_board.agents set status = 'active'
|
|
247
|
+
where id = '26910000-0000-4000-8000-000000000001';
|
|
248
|
+
|
|
249
|
+
-- ===========================================================================
|
|
250
|
+
-- 4. Key lifecycle. The signer is active throughout this section, so every
|
|
251
|
+
-- refusal below is attributable to the KEY and nothing else.
|
|
252
|
+
-- ===========================================================================
|
|
253
|
+
|
|
254
|
+
select throws_like(
|
|
255
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
256
|
+
pg_temp.grant_payload('member'),
|
|
257
|
+
pg_temp.gw('26910000-0000-4000-8000-000000000001',
|
|
258
|
+
'26930000-0000-4000-8000-000000000004', 'dead', 3))$$,
|
|
259
|
+
'%signer_key_not_live%',
|
|
260
|
+
'a REVOKED signing key cannot complete a signed grant');
|
|
261
|
+
|
|
262
|
+
select throws_like(
|
|
263
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
264
|
+
pg_temp.grant_payload('member'),
|
|
265
|
+
pg_temp.gw('26910000-0000-4000-8000-000000000001',
|
|
266
|
+
'26930000-0000-4000-8000-000000000003', 'stale', 2))$$,
|
|
267
|
+
'%signer_key_expired%',
|
|
268
|
+
'an EXPIRED signing key cannot complete a signed grant');
|
|
269
|
+
|
|
270
|
+
-- The ownership half. Key B is active, proven and unexpired -- it is simply agent 3's. The
|
|
271
|
+
-- signer here is the real owner, so the inner grant SUCCEEDS and only the ownership check
|
|
272
|
+
-- can be what refuses this.
|
|
273
|
+
select throws_like(
|
|
274
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
275
|
+
pg_temp.grant_payload('member'),
|
|
276
|
+
pg_temp.gw('26910000-0000-4000-8000-000000000001',
|
|
277
|
+
'26930000-0000-4000-8000-000000000002', 'primary', 1))$$,
|
|
278
|
+
'%signer_key_not_owned%',
|
|
279
|
+
'a LIVE key belonging to another agent cannot authorize this signer');
|
|
280
|
+
|
|
281
|
+
select throws_like(
|
|
282
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
283
|
+
pg_temp.grant_payload('member'),
|
|
284
|
+
jsonb_build_object('agent_id', '26910000-0000-4000-8000-000000000001',
|
|
285
|
+
'verification_status', 'verified'))$$,
|
|
286
|
+
'%signer_key_absent: signed authority write requires a gateway-verified signing key%',
|
|
287
|
+
'a decision that names no signing key cannot complete a signed authority write');
|
|
288
|
+
|
|
289
|
+
select throws_like(
|
|
290
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
291
|
+
pg_temp.grant_payload('member'),
|
|
292
|
+
pg_temp.gw('26910000-0000-4000-8000-000000000001',
|
|
293
|
+
'26930000-0000-4000-8000-0000000000ff', 'primary', 1))$$,
|
|
294
|
+
'%signer_key_absent: signed authority write names a signing key that does not exist%',
|
|
295
|
+
'a decision naming a signing key that does not exist is refused');
|
|
296
|
+
|
|
297
|
+
-- Evidence integrity: _record_signed_write_evidence copies key_id/key_version from the
|
|
298
|
+
-- decision verbatim, so a decision whose text identifiers disagree with the row just
|
|
299
|
+
-- revalidated would mint evidence naming a key that was never checked.
|
|
300
|
+
select throws_like(
|
|
301
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
302
|
+
pg_temp.grant_payload('member'),
|
|
303
|
+
pg_temp.gw('26910000-0000-4000-8000-000000000001',
|
|
304
|
+
'26930000-0000-4000-8000-000000000001', 'rotated', 1))$$,
|
|
305
|
+
'%signer_key_mismatch: gateway decision key_id does not match%',
|
|
306
|
+
'a decision whose key_id disagrees with the revalidated key is refused');
|
|
307
|
+
|
|
308
|
+
select throws_like(
|
|
309
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
310
|
+
pg_temp.grant_payload('member'),
|
|
311
|
+
pg_temp.gw('26910000-0000-4000-8000-000000000001',
|
|
312
|
+
'26930000-0000-4000-8000-000000000001', 'primary', 7))$$,
|
|
313
|
+
'%signer_key_mismatch: gateway decision key_version does not match%',
|
|
314
|
+
'a decision whose key_version disagrees with the revalidated key is refused');
|
|
315
|
+
|
|
316
|
+
-- ===========================================================================
|
|
317
|
+
-- 4b. The gateway's VERDICT. Every probe above varies a LIFECYCLE fact; none of them asks
|
|
318
|
+
-- whether the signature actually verified. A decision naming a live signer and a live
|
|
319
|
+
-- key but carrying verification_status='failed' satisfied the whole chain and COMPLETED
|
|
320
|
+
-- the grant, minting an evidence row that recorded 'failed' beside a mutation that had
|
|
321
|
+
-- succeeded (_record_signed_write_evidence admits 'failed' by design,
|
|
322
|
+
-- 20260710000000:88). Each probe below is pg_temp.gwa() -- the decision the allow-side
|
|
323
|
+
-- probes use -- with ONLY verification_status altered, so the refusal is attributable to
|
|
324
|
+
-- the status and to nothing else.
|
|
325
|
+
-- ===========================================================================
|
|
326
|
+
|
|
327
|
+
select throws_like(
|
|
328
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
329
|
+
pg_temp.grant_payload('member'),
|
|
330
|
+
pg_temp.gwa() || jsonb_build_object('verification_status', 'failed'))$$,
|
|
331
|
+
'%signer_not_verified%',
|
|
332
|
+
'a FAILED verification cannot complete a signed grant, live signer and live key or not');
|
|
333
|
+
|
|
334
|
+
select throws_like(
|
|
335
|
+
$$select * from agent_board.signed_set_board_authority_ceiling(
|
|
336
|
+
jsonb_build_object(
|
|
337
|
+
'p_board_id', '26920000-0000-4000-8000-000000000001',
|
|
338
|
+
'p_max_grantable_role', 'member'),
|
|
339
|
+
pg_temp.gwa() || jsonb_build_object('verification_status', 'failed'))$$,
|
|
340
|
+
'%signer_not_verified%',
|
|
341
|
+
'a FAILED verification cannot complete a signed ceiling change either');
|
|
342
|
+
|
|
343
|
+
-- 'observed' and 'warn' are NOT weaker verifications: specs/agent-provenance-signing.md:258-259
|
|
344
|
+
-- defines both as the label an UNSIGNED write receives under enforcement_mode 'observe' and
|
|
345
|
+
-- 'warn'. Admitting either would re-open the same hole under another name, so the accepted set
|
|
346
|
+
-- has exactly one member. These two probes are what pin exact-match over a set: widening the
|
|
347
|
+
-- predicate to `in ('verified','warn')` reds the 'warn' one alone.
|
|
348
|
+
select throws_like(
|
|
349
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
350
|
+
pg_temp.grant_payload('member'),
|
|
351
|
+
pg_temp.gwa() || jsonb_build_object('verification_status', 'warn'))$$,
|
|
352
|
+
'%signer_not_verified%',
|
|
353
|
+
'WARN -- an unsigned write accepted under enforcement_mode=warn -- cannot complete a signed grant');
|
|
354
|
+
|
|
355
|
+
select throws_like(
|
|
356
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
357
|
+
pg_temp.grant_payload('member'),
|
|
358
|
+
pg_temp.gwa() || jsonb_build_object('verification_status', 'observed'))$$,
|
|
359
|
+
'%signer_not_verified%',
|
|
360
|
+
'OBSERVED -- an unsigned write accepted under enforcement_mode=observe -- cannot complete a signed grant');
|
|
361
|
+
|
|
362
|
+
select throws_like(
|
|
363
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
364
|
+
pg_temp.grant_payload('member'),
|
|
365
|
+
pg_temp.gwa() || jsonb_build_object('verification_status', 'unsigned'))$$,
|
|
366
|
+
'%signer_not_verified%',
|
|
367
|
+
'an UNSIGNED decision cannot complete a signed grant');
|
|
368
|
+
|
|
369
|
+
-- Fail CLOSED on absent and on empty: `is distinct from` puts both in the same branch as a
|
|
370
|
+
-- wrong value, rather than letting a missing key read as "nothing to object to".
|
|
371
|
+
select throws_like(
|
|
372
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
373
|
+
pg_temp.grant_payload('member'),
|
|
374
|
+
pg_temp.gwa() - 'verification_status')$$,
|
|
375
|
+
'%signer_not_verified: signed authority write requires a verified gateway decision, got (absent)%',
|
|
376
|
+
'a decision carrying NO verification_status at all is refused, not defaulted');
|
|
377
|
+
|
|
378
|
+
select throws_like(
|
|
379
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
380
|
+
pg_temp.grant_payload('member'),
|
|
381
|
+
pg_temp.gwa() || jsonb_build_object('verification_status', ''))$$,
|
|
382
|
+
'%signer_not_verified: signed authority write requires a verified gateway decision, got (absent)%',
|
|
383
|
+
'an EMPTY verification_status is refused too');
|
|
384
|
+
|
|
385
|
+
-- The client-side SigningVerificationStatus vocabulary (contract.ts:268) is a DIFFERENT
|
|
386
|
+
-- vocabulary -- 'signed' / 'valid_signature' / 'unsigned_compatibility' / 'invalid_signature'
|
|
387
|
+
-- -- and none of its values may satisfy a gate written against the evidence vocabulary. An
|
|
388
|
+
-- exact match gets this for free; a "not in (bad values)" formulation would have admitted it.
|
|
389
|
+
select throws_like(
|
|
390
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
391
|
+
pg_temp.grant_payload('member'),
|
|
392
|
+
pg_temp.gwa() || jsonb_build_object('verification_status', 'valid_signature'))$$,
|
|
393
|
+
'%signer_not_verified%',
|
|
394
|
+
'a value from the CLIENT-side signing vocabulary does not satisfy the evidence-vocabulary gate');
|
|
395
|
+
|
|
396
|
+
-- The refusal must happen BEFORE the row locks, so it cannot be defeated by lifecycle state:
|
|
397
|
+
-- an unverified decision has no standing regardless of whose key it names. With the signer
|
|
398
|
+
-- suspended, a lifecycle-first ordering would report signer_not_active instead.
|
|
399
|
+
update agent_board.agents set status = 'suspended'
|
|
400
|
+
where id = '26910000-0000-4000-8000-000000000001';
|
|
401
|
+
|
|
402
|
+
select throws_like(
|
|
403
|
+
$$select * from agent_board.signed_grant_board_role(
|
|
404
|
+
pg_temp.grant_payload('member'),
|
|
405
|
+
pg_temp.gwa() || jsonb_build_object('verification_status', 'failed'))$$,
|
|
406
|
+
'%signer_not_verified%',
|
|
407
|
+
'the verification verdict is checked BEFORE the lifecycle locks, not after');
|
|
408
|
+
|
|
409
|
+
update agent_board.agents set status = 'active'
|
|
410
|
+
where id = '26910000-0000-4000-8000-000000000001';
|
|
411
|
+
|
|
412
|
+
select is(
|
|
413
|
+
(select count(*)::int from agent_board.signature_evidence where operation = 'grant_board_role'),
|
|
414
|
+
1,
|
|
415
|
+
'none of the twenty refusals above minted a second grant evidence row');
|
|
416
|
+
|
|
417
|
+
select is(
|
|
418
|
+
(select role from agent_board.board_memberships
|
|
419
|
+
where board_id = '26920000-0000-4000-8000-000000000001'
|
|
420
|
+
and agent_id = '26910000-0000-4000-8000-000000000002'),
|
|
421
|
+
'admin',
|
|
422
|
+
'and none of them changed the grantee role');
|
|
423
|
+
|
|
424
|
+
-- ===========================================================================
|
|
425
|
+
-- 5. The two CHECKs the predicate RELIES ON, asserted positively.
|
|
426
|
+
--
|
|
427
|
+
-- _assert_signed_authority_signer_live does NOT re-test proof_state='proven' or
|
|
428
|
+
-- revoked_at is null: both are consequences of status='active' under these table
|
|
429
|
+
-- constraints, and a branch no mutation can falsify is an unpinnable construct
|
|
430
|
+
-- (CLAUDE.md rule 2). The reliance is therefore proven HERE. If either CHECK is ever
|
|
431
|
+
-- dropped, this fails rather than the predicate silently losing a guarantee.
|
|
432
|
+
-- ===========================================================================
|
|
433
|
+
|
|
434
|
+
select throws_like(
|
|
435
|
+
$$update agent_board.agent_signing_keys
|
|
436
|
+
set proof_state = 'unproven'
|
|
437
|
+
where id = '26930000-0000-4000-8000-000000000001'$$,
|
|
438
|
+
'%violates check constraint "agent_signing_keys_active_requires_proof"%',
|
|
439
|
+
'RELIED ON: an active key cannot be unproven (agent_signing_keys_active_requires_proof)');
|
|
440
|
+
|
|
441
|
+
select throws_like(
|
|
442
|
+
$$update agent_board.agent_signing_keys
|
|
443
|
+
set revoked_at = now()
|
|
444
|
+
where id = '26930000-0000-4000-8000-000000000001'$$,
|
|
445
|
+
'%violates check constraint "agent_signing_keys_revoked_shape"%',
|
|
446
|
+
'RELIED ON: an active key cannot carry revoked_at (agent_signing_keys_revoked_shape)');
|
|
447
|
+
|
|
448
|
+
-- ===========================================================================
|
|
449
|
+
-- 6. Class assertions (catalog-derived, CLAUDE.md rule 1).
|
|
450
|
+
--
|
|
451
|
+
-- The class is not "the two wrappers #269 happened to edit". It is: every signed_*
|
|
452
|
+
-- wrapper whose inner write is an AUTHORITY write. Both sides are derived from pg_proc
|
|
453
|
+
-- and compared, so a new authority wrapper that omits the revalidation appears in one
|
|
454
|
+
-- set and not the other and the symmetric difference NAMES it. stripped_body (not raw
|
|
455
|
+
-- prosrc) is used throughout: a comment or a string literal mentioning a marker must not
|
|
456
|
+
-- be able to satisfy any of these (#196/#199).
|
|
457
|
+
-- ===========================================================================
|
|
458
|
+
|
|
459
|
+
select is(
|
|
460
|
+
(select string_agg(p.proname, ',' order by p.proname)
|
|
461
|
+
from pg_proc p
|
|
462
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
463
|
+
where n.nspname = 'agent_board'
|
|
464
|
+
and p.proname like 'signed\_%'
|
|
465
|
+
and agent_board_test.stripped_body(p.oid)
|
|
466
|
+
~ '\m(grant_board_role|set_board_authority_ceiling|set_board_role_gate)\s*\('),
|
|
467
|
+
'signed_grant_board_role,signed_set_board_authority_ceiling',
|
|
468
|
+
'the signed wrappers that perform an AUTHORITY write are exactly these two');
|
|
469
|
+
|
|
470
|
+
select is(
|
|
471
|
+
(select string_agg(p.proname, ',' order by p.proname)
|
|
472
|
+
from pg_proc p
|
|
473
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
474
|
+
where n.nspname = 'agent_board'
|
|
475
|
+
and p.proname like 'signed\_%'
|
|
476
|
+
and agent_board_test.stripped_body(p.oid)
|
|
477
|
+
~ '\m_assert_signed_authority_signer_live\s*\('),
|
|
478
|
+
'signed_grant_board_role,signed_set_board_authority_ceiling',
|
|
479
|
+
'the signed wrappers that revalidate the signer are exactly these two');
|
|
480
|
+
|
|
481
|
+
-- The gate proper: the two sets must not merely each be right today, they must be EQUAL,
|
|
482
|
+
-- and any member of the difference is named in the failure.
|
|
483
|
+
select is(
|
|
484
|
+
(select string_agg(x.proname || ':' || x.why, '; ' order by x.proname)
|
|
485
|
+
from (
|
|
486
|
+
select p.proname,
|
|
487
|
+
case when agent_board_test.stripped_body(p.oid)
|
|
488
|
+
~ '\m(grant_board_role|set_board_authority_ceiling|set_board_role_gate)\s*\('
|
|
489
|
+
then 'authority write with NO signer revalidation'
|
|
490
|
+
else 'revalidates a signer but performs no authority write'
|
|
491
|
+
end as why
|
|
492
|
+
from pg_proc p
|
|
493
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
494
|
+
where n.nspname = 'agent_board'
|
|
495
|
+
and p.proname like 'signed\_%'
|
|
496
|
+
and (agent_board_test.stripped_body(p.oid)
|
|
497
|
+
~ '\m(grant_board_role|set_board_authority_ceiling|set_board_role_gate)\s*\(')
|
|
498
|
+
<> (agent_board_test.stripped_body(p.oid)
|
|
499
|
+
~ '\m_assert_signed_authority_signer_live\s*\(')
|
|
500
|
+
) x),
|
|
501
|
+
null,
|
|
502
|
+
'no signed wrapper is on one side of the authority/revalidation pairing without the other');
|
|
503
|
+
|
|
504
|
+
-- The five exemptions, asserted POSITIVELY: each names the non-authority write it actually
|
|
505
|
+
-- performs. An exemption list justified by the ABSENCE of a match is the soft spot this
|
|
506
|
+
-- repo has been bitten by, so none is used here.
|
|
507
|
+
select is(
|
|
508
|
+
(select string_agg(p.proname, ',' order by p.proname)
|
|
509
|
+
from pg_proc p
|
|
510
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
511
|
+
where n.nspname = 'agent_board'
|
|
512
|
+
and p.proname like 'signed\_%'
|
|
513
|
+
and agent_board_test.stripped_body(p.oid)
|
|
514
|
+
~ '\m(send_direct_message|send_multicast_message|post_board_message|append_task_event|create_tasking_delivery)\s*\('),
|
|
515
|
+
'signed_append_task_event,signed_create_tasking_delivery,signed_post_board_message,'
|
|
516
|
+
|| 'signed_send_direct_message,signed_send_multicast_message',
|
|
517
|
+
'the five exempt wrappers each perform a message/task write, which is why they are exempt');
|
|
518
|
+
|
|
519
|
+
-- Blast radius, stated as an assertion rather than as prose: the shared actor chokepoint is
|
|
520
|
+
-- still reached by all SEVEN wrappers and #269 added nothing to it.
|
|
521
|
+
select is(
|
|
522
|
+
(select string_agg(p.proname, ',' order by p.proname)
|
|
523
|
+
from pg_proc p
|
|
524
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
525
|
+
where n.nspname = 'agent_board'
|
|
526
|
+
and p.proname like 'signed\_%'
|
|
527
|
+
and agent_board_test.stripped_body(p.oid) ~ '\m_signed_write_actor_id\s*\('),
|
|
528
|
+
'signed_append_task_event,signed_create_tasking_delivery,signed_grant_board_role,'
|
|
529
|
+
|| 'signed_post_board_message,signed_send_direct_message,signed_send_multicast_message,'
|
|
530
|
+
|| 'signed_set_board_authority_ceiling',
|
|
531
|
+
'all seven signed wrappers still resolve their actor through the shared _signed_write_actor_id');
|
|
532
|
+
|
|
533
|
+
select ok(
|
|
534
|
+
agent_board_test.stripped_body('agent_board._signed_write_actor_id(jsonb,jsonb,text)'::regprocedure)
|
|
535
|
+
!~ '\m(agents|agent_signing_keys)\M',
|
|
536
|
+
'the shared actor chokepoint reads no lifecycle table: the five message wrappers are unchanged');
|
|
537
|
+
|
|
538
|
+
-- ===========================================================================
|
|
539
|
+
-- 7. Position of the call, asserted from the catalog. The revalidation must sit AFTER the
|
|
540
|
+
-- inner authority call: hoisting it inverts the ceiling wrapper's lock order against
|
|
541
|
+
-- onboard_federated_fleet_agent on (advisory, agents), and it rewrites two existing #247
|
|
542
|
+
-- assertions that expect the inner gate's error first. Prose in the migration is not a
|
|
543
|
+
-- guard; this is.
|
|
544
|
+
-- ===========================================================================
|
|
545
|
+
|
|
546
|
+
-- Each claim below guards BOTH markers with `> 0`. A bare `strpos(A) < strpos(B)` returns
|
|
547
|
+
-- `0 < N` = TRUE when the LEFT marker is absent, so it passes on a body it no longer matches
|
|
548
|
+
-- -- the fail-open shape documented at length in agent_board_atomic_fleet_onboarding.test.sql's
|
|
549
|
+
-- header, which shipped green once with the ordering it named genuinely inverted (#198).
|
|
550
|
+
select ok(
|
|
551
|
+
strpos(agent_board_test.stripped_body('agent_board.signed_grant_board_role(jsonb,jsonb)'::regprocedure),
|
|
552
|
+
'agent_board.grant_board_role(') > 0
|
|
553
|
+
and strpos(agent_board_test.stripped_body('agent_board.signed_grant_board_role(jsonb,jsonb)'::regprocedure),
|
|
554
|
+
'agent_board._assert_signed_authority_signer_live(') > 0
|
|
555
|
+
and strpos(agent_board_test.stripped_body('agent_board.signed_grant_board_role(jsonb,jsonb)'::regprocedure),
|
|
556
|
+
'agent_board.grant_board_role(')
|
|
557
|
+
< strpos(agent_board_test.stripped_body('agent_board.signed_grant_board_role(jsonb,jsonb)'::regprocedure),
|
|
558
|
+
'agent_board._assert_signed_authority_signer_live('),
|
|
559
|
+
'signed_grant_board_role revalidates AFTER the inner grant, not before');
|
|
560
|
+
|
|
561
|
+
select ok(
|
|
562
|
+
strpos(agent_board_test.stripped_body('agent_board.signed_set_board_authority_ceiling(jsonb,jsonb)'::regprocedure),
|
|
563
|
+
'agent_board.set_board_authority_ceiling(') > 0
|
|
564
|
+
and strpos(agent_board_test.stripped_body('agent_board.signed_set_board_authority_ceiling(jsonb,jsonb)'::regprocedure),
|
|
565
|
+
'agent_board._assert_signed_authority_signer_live(') > 0
|
|
566
|
+
and strpos(agent_board_test.stripped_body('agent_board.signed_set_board_authority_ceiling(jsonb,jsonb)'::regprocedure),
|
|
567
|
+
'agent_board.set_board_authority_ceiling(')
|
|
568
|
+
< strpos(agent_board_test.stripped_body('agent_board.signed_set_board_authority_ceiling(jsonb,jsonb)'::regprocedure),
|
|
569
|
+
'agent_board._assert_signed_authority_signer_live('),
|
|
570
|
+
'signed_set_board_authority_ceiling revalidates AFTER the inner upsert, not before');
|
|
571
|
+
|
|
572
|
+
-- And the revalidation must precede the evidence row, so a refused write never even attempts
|
|
573
|
+
-- to mint attestation.
|
|
574
|
+
select ok(
|
|
575
|
+
strpos(agent_board_test.stripped_body('agent_board.signed_grant_board_role(jsonb,jsonb)'::regprocedure),
|
|
576
|
+
'agent_board._assert_signed_authority_signer_live(') > 0
|
|
577
|
+
and strpos(agent_board_test.stripped_body('agent_board.signed_grant_board_role(jsonb,jsonb)'::regprocedure),
|
|
578
|
+
'agent_board._record_signed_write_evidence(') > 0
|
|
579
|
+
and strpos(agent_board_test.stripped_body('agent_board.signed_grant_board_role(jsonb,jsonb)'::regprocedure),
|
|
580
|
+
'agent_board._assert_signed_authority_signer_live(')
|
|
581
|
+
< strpos(agent_board_test.stripped_body('agent_board.signed_grant_board_role(jsonb,jsonb)'::regprocedure),
|
|
582
|
+
'agent_board._record_signed_write_evidence('),
|
|
583
|
+
'and it revalidates BEFORE recording evidence');
|
|
584
|
+
|
|
585
|
+
-- ===========================================================================
|
|
586
|
+
-- 8. Lock order INSIDE the helper: agents BEFORE agent_signing_keys.
|
|
587
|
+
--
|
|
588
|
+
-- Section 7 pins where the helper is CALLED. Nothing pinned the order of the two share
|
|
589
|
+
-- locks it takes, and that order is the load-bearing ABBA-safety claim: _revoke_agent_principal
|
|
590
|
+
-- (20260901000100:1292) takes `agents ... for update` and THEN loops the keys, and
|
|
591
|
+
-- revoke_fleet_subject's signing_key branch does the same. Taking the key first here
|
|
592
|
+
-- inverts against both.
|
|
593
|
+
--
|
|
594
|
+
-- NO RUNTIME COUNTERPARTY DISCRIMINATES THIS. The mutation that motivated these three --
|
|
595
|
+
-- inserting `perform 1 from agent_board.agent_signing_keys k where k.id = v_signing_key_id
|
|
596
|
+
-- for share;` ABOVE the agents block, leaving the check/error sequence intact -- leaves the
|
|
597
|
+
-- pgTAP suite green (it is order-insensitive) AND every isolation permutation green,
|
|
598
|
+
-- because each adversary takes its locks in ONE statement, so the intra-statement order
|
|
599
|
+
-- never reaches a golden. These assertions are therefore the SOLE PIN, which is exactly why
|
|
600
|
+
-- they must not be fail-open.
|
|
601
|
+
--
|
|
602
|
+
-- Shape: two PRESENCE claims carrying the lock MODE (so a downgrade to a weaker mode, or a
|
|
603
|
+
-- lock deleted outright, reds by name), then one POSITIONAL claim guarded on both markers.
|
|
604
|
+
-- Presence is asserted with `~` rather than strpos so the marker can span the newlines the
|
|
605
|
+
-- body actually contains without any local text rewriting -- rolling a private strip is
|
|
606
|
+
-- what scripts/lint-pgtap-source-assertions.mjs rule 2 prohibits. All three read
|
|
607
|
+
-- stripped_body, so a comment or literal naming a marker cannot satisfy them.
|
|
608
|
+
-- ===========================================================================
|
|
609
|
+
|
|
610
|
+
select ok(
|
|
611
|
+
agent_board_test.stripped_body('agent_board._assert_signed_authority_signer_live(uuid,jsonb)'::regprocedure)
|
|
612
|
+
~ 'from agent_board\.agents a\s+where a\.id = p_actor_agent_id\s+for share',
|
|
613
|
+
'the revalidation share-locks the signer''s agents row (FOR SHARE, not a weaker mode)');
|
|
614
|
+
|
|
615
|
+
select ok(
|
|
616
|
+
agent_board_test.stripped_body('agent_board._assert_signed_authority_signer_live(uuid,jsonb)'::regprocedure)
|
|
617
|
+
~ 'from agent_board\.agent_signing_keys k\s+where k\.id = v_signing_key_id\s+for share',
|
|
618
|
+
'and it share-locks the signing key row named by the decision (FOR SHARE)');
|
|
619
|
+
|
|
620
|
+
-- The board advisory lock must be taken BEFORE the inner grant, which is what makes the
|
|
621
|
+
-- wrapper acyclic against onboard_federated_fleet_agent. Without it the wrapper's order is
|
|
622
|
+
-- board_memberships(grantee) -> agents(signer) against onboarding's agents -> board_memberships:
|
|
623
|
+
-- a reproduced `deadlock detected` between two honest callers. The RUNTIME counterparty is
|
|
624
|
+
-- tests/isolation/specs/signed_grant_onboarding_deadlock.spec, which discriminates the removal
|
|
625
|
+
-- as a golden diff; this assertion is the static half.
|
|
626
|
+
select ok(
|
|
627
|
+
strpos(agent_board_test.stripped_body('agent_board.signed_grant_board_role(jsonb,jsonb)'::regprocedure),
|
|
628
|
+
'agent_board._lock_board_authority_ceiling(') > 0
|
|
629
|
+
and strpos(agent_board_test.stripped_body('agent_board.signed_grant_board_role(jsonb,jsonb)'::regprocedure),
|
|
630
|
+
'agent_board.grant_board_role(') > 0
|
|
631
|
+
and strpos(agent_board_test.stripped_body('agent_board.signed_grant_board_role(jsonb,jsonb)'::regprocedure),
|
|
632
|
+
'agent_board._lock_board_authority_ceiling(')
|
|
633
|
+
< strpos(agent_board_test.stripped_body('agent_board.signed_grant_board_role(jsonb,jsonb)'::regprocedure),
|
|
634
|
+
'agent_board.grant_board_role('),
|
|
635
|
+
'the grant wrapper takes the board advisory lock BEFORE the inner grant [CONFIRMATORY: signed_grant_onboarding_deadlock.spec discriminates its removal]');
|
|
636
|
+
|
|
637
|
+
select ok(
|
|
638
|
+
strpos(agent_board_test.stripped_body('agent_board._assert_signed_authority_signer_live(uuid,jsonb)'::regprocedure),
|
|
639
|
+
'from agent_board.agents a') > 0
|
|
640
|
+
and strpos(agent_board_test.stripped_body('agent_board._assert_signed_authority_signer_live(uuid,jsonb)'::regprocedure),
|
|
641
|
+
'from agent_board.agent_signing_keys k') > 0
|
|
642
|
+
and strpos(agent_board_test.stripped_body('agent_board._assert_signed_authority_signer_live(uuid,jsonb)'::regprocedure),
|
|
643
|
+
'from agent_board.agents a')
|
|
644
|
+
< strpos(agent_board_test.stripped_body('agent_board._assert_signed_authority_signer_live(uuid,jsonb)'::regprocedure),
|
|
645
|
+
'from agent_board.agent_signing_keys k'),
|
|
646
|
+
'and it takes them agents-BEFORE-keys, matching _revoke_agent_principal [SOLE PIN: no isolation permutation discriminates this ordering]');
|
|
647
|
+
|
|
648
|
+
-- ===========================================================================
|
|
649
|
+
-- 9. CLASS (catalog-derived, CLAUDE.md rule 1): the agents/board_memberships cycle class.
|
|
650
|
+
--
|
|
651
|
+
-- F4 was an ABBA between signed_grant_board_role and onboard_federated_fleet_agent on
|
|
652
|
+
-- (board_memberships, agents). The fix is only as good as the claim that those are the ONLY
|
|
653
|
+
-- two functions that lock both — and that claim must come from pg_catalog, not from this
|
|
654
|
+
-- file. Any function that row-locks both relations can close the same cycle, so every member
|
|
655
|
+
-- must take the board advisory lock that serialises them.
|
|
656
|
+
--
|
|
657
|
+
-- THE CALL GRAPH IS CLOSED OVER FIRST, because a body-text scan cannot see a lock taken in a
|
|
658
|
+
-- CALLEE and EVERY lock the wrapper takes is in one: board_memberships in grant_board_role's
|
|
659
|
+
-- UPDATE, agents and agent_signing_keys in _assert_signed_authority_signer_live. A direct
|
|
660
|
+
-- scan reports the wrapper as locking nothing and the class as a single member — which is
|
|
661
|
+
-- exactly how this defect stayed invisible to the section-7 assertions.
|
|
662
|
+
--
|
|
663
|
+
-- Edges are OVER-approximated (any bare `name(` occurrence counts as a call). Over-connecting
|
|
664
|
+
-- adds candidates to the expected array, which is visible and harmless; under-connecting
|
|
665
|
+
-- would silently drop a site, which is the direction that hides a defect.
|
|
666
|
+
-- ===========================================================================
|
|
667
|
+
|
|
668
|
+
select is(
|
|
669
|
+
(with recursive fns as (
|
|
670
|
+
select p.oid, p.proname, agent_board_test.stripped_body(p.oid) as b
|
|
671
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
672
|
+
where n.nspname = 'agent_board'
|
|
673
|
+
), edges as (
|
|
674
|
+
select f.proname as caller, g.proname as callee
|
|
675
|
+
from fns f join fns g on g.proname <> f.proname
|
|
676
|
+
where f.b ~ ('\m' || g.proname || '\s*\(')
|
|
677
|
+
), closure as (
|
|
678
|
+
select proname as root, proname as reached from fns
|
|
679
|
+
union
|
|
680
|
+
select c.root, e.callee from closure c join edges e on e.caller = c.reached
|
|
681
|
+
), direct as (
|
|
682
|
+
select proname,
|
|
683
|
+
(b ~ 'agent_board\.agents[^;]*for (update|share)') as locks_agents,
|
|
684
|
+
(b ~ 'agent_board\.board_memberships[^;]*for (update|share)'
|
|
685
|
+
or b ~ 'update agent_board\.board_memberships') as locks_memberships,
|
|
686
|
+
(b ~ '\magent_board\._lock_board_authority_ceiling\s*\(') as takes_advisory
|
|
687
|
+
from fns
|
|
688
|
+
), agg as (
|
|
689
|
+
select c.root,
|
|
690
|
+
bool_or(d.locks_agents) as locks_agents,
|
|
691
|
+
bool_or(d.locks_memberships) as locks_memberships,
|
|
692
|
+
bool_or(d.takes_advisory) as takes_advisory
|
|
693
|
+
from closure c join direct d on d.proname = c.reached
|
|
694
|
+
group by c.root
|
|
695
|
+
)
|
|
696
|
+
select array_agg(root || case when takes_advisory then ' => serialised by the board advisory lock'
|
|
697
|
+
else ' => NOT SERIALISED (can deadlock against onboarding)' end
|
|
698
|
+
order by root)
|
|
699
|
+
from agg
|
|
700
|
+
where locks_agents and locks_memberships),
|
|
701
|
+
array[
|
|
702
|
+
'onboard_federated_fleet_agent => serialised by the board advisory lock',
|
|
703
|
+
'signed_grant_board_role => serialised by the board advisory lock'
|
|
704
|
+
]::text[],
|
|
705
|
+
'every function that row-locks BOTH agents and board_memberships takes the board advisory lock (transitive; a new one names itself)');
|
|
706
|
+
|
|
707
|
+
select finish();
|
|
708
|
+
|
|
709
|
+
rollback;
|