@consiliency/agent-board-schema 1.25.0 → 1.28.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 +8 -0
- package/migrations/20260908000100_agent_board_signer_lifecycle_revalidation.sql +451 -0
- package/migrations/20260909000100_agent_board_ledger_revoked_authority.sql +1036 -0
- package/package.json +1 -1
- package/schema-manifest.json +21 -3
- package/src/tooling.test.mjs +2 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +23 -2
- package/tests/agent_board_ledger_authority.test.sql +1010 -0
- package/tests/agent_board_signed_authority_provenance.test.sql +24 -1
- package/tests/agent_board_signer_lifecycle.test.sql +715 -0
- package/tests/catalog/p_metadata_parameters.json +12 -0
- package/tests/isolation/expected/ledger_authority_revoke.out +483 -0
- 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/ledger_authority_revoke.spec +290 -0
- package/tests/isolation/specs/signed_grant_onboarding_deadlock.spec +159 -0
- package/tests/isolation/specs/signer_lifecycle_revoke.spec +186 -0
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
-- #276 pgTAP: a fleet-revoked agent loses BOARD AUTHORITY, not only send.
|
|
2
|
+
--
|
|
3
|
+
-- The claim under test: an agent carrying an agent-subject `fleet_revocation_events` row cannot
|
|
4
|
+
-- complete a board-role grant, a board authority-ceiling change, or a role-gate flip — on the
|
|
5
|
+
-- unsigned `authenticated` route or the signed gateway route — while an agent that was never in
|
|
6
|
+
-- a fleet is completely unaffected.
|
|
7
|
+
--
|
|
8
|
+
-- THE DIVERGENCE, asserted rather than described. `revoke_fleet_subject('agent', X)` leaves
|
|
9
|
+
-- `agents.status = 'active'` and X's signing keys live; only the ledger row and the bindings
|
|
10
|
+
-- change. Section 4 asserts that precondition POSITIVELY before probing the refusal, so a
|
|
11
|
+
-- future change that (say) also flipped `agents.status` would make these tests pass for the
|
|
12
|
+
-- WRONG reason and section 4 is what would catch it.
|
|
13
|
+
--
|
|
14
|
+
-- WHAT THIS FILE CAN AND CANNOT REACH. A pgTAP file runs in ONE transaction, so it proves the
|
|
15
|
+
-- PREDICATE (a ledger-revoked grantor is refused) but structurally cannot produce the
|
|
16
|
+
-- interleaving (`revoke_fleet_subject` committing between an authority write's ledger read and
|
|
17
|
+
-- its commit) or the `agents` FOR SHARE lock that serializes it. That lives in
|
|
18
|
+
-- tests/isolation/specs/ledger_authority_revoke.spec, driven by isolationtester across
|
|
19
|
+
-- committed sessions.
|
|
20
|
+
--
|
|
21
|
+
-- Methodology, inherited from agent_board_signer_lifecycle.test.sql:
|
|
22
|
+
-- * RPC probes are driven by the JWT claim blob only; SET LOCAL ROLE is never used, since it
|
|
23
|
+
-- would revoke _is_service_role() and mask the branch behind a permission error.
|
|
24
|
+
-- * Allow-side probes are lives_ok(...) then a SEPARATE state check. A bare
|
|
25
|
+
-- `select ... from <raising rpc>(...)` ABORTS the transaction instead of failing it,
|
|
26
|
+
-- silently dropping every later assertion (#113, CLAUDE.md rule 3).
|
|
27
|
+
-- * Every refusal probe is followed by a state check, because a gate that raises AND writes
|
|
28
|
+
-- is the failure mode throws_like alone cannot see.
|
|
29
|
+
-- * The class assertions in section 9 are catalog-derived (pg_proc + stripped_body), not a
|
|
30
|
+
-- list written here, so a NEW authority write that forgets the gate fails and NAMES ITSELF
|
|
31
|
+
-- (CLAUDE.md rule 1). The one exemption is asserted POSITIVELY — by three properties it
|
|
32
|
+
-- actually has — never by the absence of a match.
|
|
33
|
+
|
|
34
|
+
create extension if not exists pgtap with schema extensions;
|
|
35
|
+
set search_path = extensions, public, agent_board;
|
|
36
|
+
|
|
37
|
+
begin;
|
|
38
|
+
|
|
39
|
+
select plan(88);
|
|
40
|
+
|
|
41
|
+
create function pg_temp.claims(p_sub text) returns void language plpgsql as $$
|
|
42
|
+
begin
|
|
43
|
+
perform set_config('request.jwt.claims', json_build_object('role','authenticated','sub',p_sub)::text, true);
|
|
44
|
+
end $$;
|
|
45
|
+
create function pg_temp.service() returns void language plpgsql as $$
|
|
46
|
+
begin
|
|
47
|
+
perform set_config('request.jwt.claims', '', true);
|
|
48
|
+
end $$;
|
|
49
|
+
|
|
50
|
+
-- Fixture -------------------------------------------------------------------
|
|
51
|
+
-- agent 1 = FLEET agent, owner of board 1, bound to machines A and B. The #276 subject.
|
|
52
|
+
-- agent 2 = grantee, an active member of every board here.
|
|
53
|
+
-- agent 3 = NON-FLEET agent, owner of board 2. It has NO fleet_machine_agents row at all —
|
|
54
|
+
-- the exemption category judgement 2 is about.
|
|
55
|
+
-- agent 4 = FLEET agent, owner of board 3, bound to machines A and B. Used for the
|
|
56
|
+
-- multi-binding case: its binding on machine A is revoked, B stays active.
|
|
57
|
+
-- agent 5 = operator principal that performs the revocations.
|
|
58
|
+
|
|
59
|
+
insert into auth.users(id, instance_id, aud, role, email)
|
|
60
|
+
select ('27600000-0000-0000-0000-' || lpad(n::text,12,'0'))::uuid,
|
|
61
|
+
'00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated',
|
|
62
|
+
'k276u'||n||'@t.test'
|
|
63
|
+
from generate_series(1,6) n;
|
|
64
|
+
|
|
65
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id)
|
|
66
|
+
select ('27610000-0000-4000-8000-' || lpad(n::text,12,'0'))::uuid,
|
|
67
|
+
'agent','agent','k276-agent-'||n,'K276 Agent '||n,'active',
|
|
68
|
+
('27600000-0000-0000-0000-' || lpad(n::text,12,'0'))::uuid
|
|
69
|
+
from generate_series(1,6) n;
|
|
70
|
+
|
|
71
|
+
insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
|
|
72
|
+
values
|
|
73
|
+
('27620000-0000-4000-8000-000000000001','k276-board-1','K276 Board 1','27610000-0000-4000-8000-000000000001','private','active'),
|
|
74
|
+
('27620000-0000-4000-8000-000000000002','k276-board-2','K276 Board 2','27610000-0000-4000-8000-000000000003','private','active'),
|
|
75
|
+
('27620000-0000-4000-8000-000000000003','k276-board-3','K276 Board 3','27610000-0000-4000-8000-000000000004','private','active'),
|
|
76
|
+
-- board 4 exists solely for the invite ALLOW side: its owner (agent 6) is never revoked
|
|
77
|
+
-- and never role-clamped, so a failure there is the gate and not fixture coupling.
|
|
78
|
+
('27620000-0000-4000-8000-000000000004','k276-board-4','K276 Board 4','27610000-0000-4000-8000-000000000006','private','active');
|
|
79
|
+
|
|
80
|
+
insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
|
|
81
|
+
values
|
|
82
|
+
('27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000001','owner','joined','27610000-0000-4000-8000-000000000001'),
|
|
83
|
+
('27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002','member','joined','27610000-0000-4000-8000-000000000001'),
|
|
84
|
+
('27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000003','owner','joined','27610000-0000-4000-8000-000000000003'),
|
|
85
|
+
('27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000002','member','joined','27610000-0000-4000-8000-000000000003'),
|
|
86
|
+
('27620000-0000-4000-8000-000000000003','27610000-0000-4000-8000-000000000004','owner','joined','27610000-0000-4000-8000-000000000004'),
|
|
87
|
+
('27620000-0000-4000-8000-000000000003','27610000-0000-4000-8000-000000000002','member','joined','27610000-0000-4000-8000-000000000004'),
|
|
88
|
+
('27620000-0000-4000-8000-000000000004','27610000-0000-4000-8000-000000000006','owner','joined','27610000-0000-4000-8000-000000000006');
|
|
89
|
+
|
|
90
|
+
-- Agent 1's live signing key: the signed route needs a decision naming a live, owned key so
|
|
91
|
+
-- that a refusal can only be the #276 gate and never #269's revalidation.
|
|
92
|
+
insert into agent_board.agent_signing_keys(
|
|
93
|
+
id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
|
|
94
|
+
status, proof_state, expires_at, revoked_at)
|
|
95
|
+
values
|
|
96
|
+
('27630000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000001',
|
|
97
|
+
'primary', 1, public.test_pubkey('k276-a'), 'raw_ed25519_base64url',
|
|
98
|
+
public.test_key_fingerprint('k276-a'), 'active', 'proven', null, null);
|
|
99
|
+
|
|
100
|
+
-- Fleet substrate: one trust domain, two ACTIVE machines, and bindings for agents 1 and 4 on
|
|
101
|
+
-- BOTH machines. Two machines is what makes judgement 1 testable at all.
|
|
102
|
+
insert into agent_board.fleet_trust_domains(id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
|
|
103
|
+
values ('27640000-0000-4000-8000-000000000001','k276-domain','K276 Domain','tailnet',
|
|
104
|
+
'27610000-0000-4000-8000-000000000005','active', now());
|
|
105
|
+
|
|
106
|
+
insert into agent_board.fleet_machines(id, trust_domain_id, owner_agent_id, machine_label, machine_fingerprint, lifecycle_state, enrolled_at)
|
|
107
|
+
values
|
|
108
|
+
('27650000-0000-4000-8000-00000000000a','27640000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000005','k276-machine-a','fp-k276-a','active', now()),
|
|
109
|
+
('27650000-0000-4000-8000-00000000000b','27640000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000005','k276-machine-b','fp-k276-b','active', now());
|
|
110
|
+
|
|
111
|
+
insert into agent_board.fleet_machine_agents(id, machine_id, agent_id, agent_role, lifecycle_state)
|
|
112
|
+
values
|
|
113
|
+
('27660000-0000-4000-8000-00000000001a','27650000-0000-4000-8000-00000000000a','27610000-0000-4000-8000-000000000001','phase_worker','active'),
|
|
114
|
+
('27660000-0000-4000-8000-00000000001b','27650000-0000-4000-8000-00000000000b','27610000-0000-4000-8000-000000000001','phase_worker','active'),
|
|
115
|
+
('27660000-0000-4000-8000-00000000004a','27650000-0000-4000-8000-00000000000a','27610000-0000-4000-8000-000000000004','phase_worker','active'),
|
|
116
|
+
('27660000-0000-4000-8000-00000000004b','27650000-0000-4000-8000-00000000000b','27610000-0000-4000-8000-000000000004','phase_worker','active');
|
|
117
|
+
|
|
118
|
+
create function pg_temp.gwa() returns jsonb language sql immutable as $$
|
|
119
|
+
select jsonb_build_object(
|
|
120
|
+
'agent_id', '27610000-0000-4000-8000-000000000001',
|
|
121
|
+
'signing_key_id', '27630000-0000-4000-8000-000000000001',
|
|
122
|
+
'key_id', 'primary',
|
|
123
|
+
'key_version', 1,
|
|
124
|
+
'algorithm', 'Ed25519',
|
|
125
|
+
'verification_status', 'verified',
|
|
126
|
+
'payload_digest', 'sha256:' || repeat('a', 64),
|
|
127
|
+
'signature_digest', 'sha256:' || repeat('b', 64),
|
|
128
|
+
'canonicalization_version', 'agent-provenance-jcs.v1',
|
|
129
|
+
'enforcement_mode', 'require_for_agent_writes',
|
|
130
|
+
'redacted', true);
|
|
131
|
+
$$;
|
|
132
|
+
|
|
133
|
+
create function pg_temp.grant_payload(p_role text) returns jsonb language sql immutable as $$
|
|
134
|
+
select jsonb_build_object(
|
|
135
|
+
'p_board_id', '27620000-0000-4000-8000-000000000001',
|
|
136
|
+
'p_agent_id', '27610000-0000-4000-8000-000000000002',
|
|
137
|
+
'p_grantee_role', p_role);
|
|
138
|
+
$$;
|
|
139
|
+
|
|
140
|
+
create function pg_temp.role_on(p_board uuid, p_agent uuid) returns text language sql stable as $$
|
|
141
|
+
select role from agent_board.board_memberships where board_id = p_board and agent_id = p_agent;
|
|
142
|
+
$$;
|
|
143
|
+
|
|
144
|
+
-- ===========================================================================
|
|
145
|
+
-- 1. The gate helper exists, is unreachable by a caller, and is documented.
|
|
146
|
+
-- A SECURITY DEFINER function grants EXECUTE to PUBLIC by default and this one locks and
|
|
147
|
+
-- reads rows for an arbitrary agent id with no authorization of its own (#118).
|
|
148
|
+
-- ===========================================================================
|
|
149
|
+
|
|
150
|
+
select has_function('agent_board', '_assert_actor_not_fleet_revoked', array['uuid','text'],
|
|
151
|
+
'the fleet-revocation gate helper exists');
|
|
152
|
+
|
|
153
|
+
select is(
|
|
154
|
+
(select string_agg(distinct coalesce(pg_get_userbyid(nullif(a.grantee,0)),'PUBLIC'), ','
|
|
155
|
+
order by coalesce(pg_get_userbyid(nullif(a.grantee,0)),'PUBLIC'))
|
|
156
|
+
from pg_proc p, aclexplode(p.proacl) a
|
|
157
|
+
where p.oid = 'agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure
|
|
158
|
+
and a.privilege_type = 'EXECUTE'),
|
|
159
|
+
'postgres,service_role',
|
|
160
|
+
'the gate helper executes for service_role only (never PUBLIC/anon/authenticated)');
|
|
161
|
+
|
|
162
|
+
select isnt(
|
|
163
|
+
(select obj_description('agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure, 'pg_proc')),
|
|
164
|
+
null,
|
|
165
|
+
'the gate helper carries its COMMENT');
|
|
166
|
+
|
|
167
|
+
-- The null branch is unreachable from all three chokepoints (each resolves or gates its actor
|
|
168
|
+
-- first), so it is pinned by calling the helper DIRECTLY rather than left as a branch no
|
|
169
|
+
-- mutation could falsify (CLAUDE.md rule 2).
|
|
170
|
+
select pg_temp.service();
|
|
171
|
+
|
|
172
|
+
select throws_like(
|
|
173
|
+
$$select agent_board._assert_actor_not_fleet_revoked(null::uuid, 'probe')$$,
|
|
174
|
+
'%fleet_revoked_unresolved%',
|
|
175
|
+
'the gate fails closed on a null actor');
|
|
176
|
+
|
|
177
|
+
-- ===========================================================================
|
|
178
|
+
-- 2. ALLOW SIDE, unsigned and signed: a fleet-bound but NOT-revoked owner is untouched.
|
|
179
|
+
-- This is the blast-radius assertion — the gate must cost an unrevoked fleet agent nothing.
|
|
180
|
+
-- ===========================================================================
|
|
181
|
+
|
|
182
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000001');
|
|
183
|
+
|
|
184
|
+
select lives_ok(
|
|
185
|
+
$$create temp table _c1 as
|
|
186
|
+
select * from agent_board.grant_board_role(
|
|
187
|
+
'27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002','admin')$$,
|
|
188
|
+
'an ACTIVE, fleet-BOUND, unrevoked owner still completes an unsigned grant');
|
|
189
|
+
|
|
190
|
+
select is(
|
|
191
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002'),
|
|
192
|
+
'admin',
|
|
193
|
+
'and it actually raised the grantee role');
|
|
194
|
+
|
|
195
|
+
select pg_temp.service();
|
|
196
|
+
|
|
197
|
+
select lives_ok(
|
|
198
|
+
$$create temp table _sg as
|
|
199
|
+
select * from agent_board.signed_grant_board_role(pg_temp.grant_payload('member'), pg_temp.gwa())$$,
|
|
200
|
+
'and the SIGNED route by the same unrevoked owner still completes');
|
|
201
|
+
|
|
202
|
+
select is(
|
|
203
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002'),
|
|
204
|
+
'member',
|
|
205
|
+
'and the signed grant actually wrote the grantee role');
|
|
206
|
+
|
|
207
|
+
-- ===========================================================================
|
|
208
|
+
-- 3. JUDGEMENT 2, asserted POSITIVELY: a principal with NO fleet binding is unaffected.
|
|
209
|
+
-- A predicate phrased as "has an active binding" would refuse this grant. Never asserted by
|
|
210
|
+
-- the absence of a match — agent 3 completes a real grant and the role is read back.
|
|
211
|
+
-- ===========================================================================
|
|
212
|
+
|
|
213
|
+
select is(
|
|
214
|
+
(select count(*)::int from agent_board.fleet_machine_agents
|
|
215
|
+
where agent_id = '27610000-0000-4000-8000-000000000003'),
|
|
216
|
+
0,
|
|
217
|
+
'agent 3 has NO fleet binding at all — the exemption category, established before it is used');
|
|
218
|
+
|
|
219
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000003');
|
|
220
|
+
|
|
221
|
+
select lives_ok(
|
|
222
|
+
$$create temp table _c2 as
|
|
223
|
+
select * from agent_board.grant_board_role(
|
|
224
|
+
'27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000002','admin')$$,
|
|
225
|
+
'a NON-FLEET owner still completes a grant [judgement 2: no capability regression]');
|
|
226
|
+
|
|
227
|
+
select is(
|
|
228
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000002'),
|
|
229
|
+
'admin',
|
|
230
|
+
'and the non-fleet grant actually wrote the role');
|
|
231
|
+
|
|
232
|
+
select lives_ok(
|
|
233
|
+
$$create temp table _c3 as
|
|
234
|
+
select * from agent_board.set_board_role_gate('27620000-0000-4000-8000-000000000002', true)$$,
|
|
235
|
+
'a NON-FLEET owner still flips the role gate');
|
|
236
|
+
|
|
237
|
+
select lives_ok(
|
|
238
|
+
$$create temp table _c4 as
|
|
239
|
+
select * from agent_board.set_board_authority_ceiling('27620000-0000-4000-8000-000000000002','owner')$$,
|
|
240
|
+
'a NON-FLEET owner still sets the board authority ceiling');
|
|
241
|
+
|
|
242
|
+
select is(
|
|
243
|
+
(select max_grantable_role from agent_board.board_authority_ceilings
|
|
244
|
+
where board_id = '27620000-0000-4000-8000-000000000002'),
|
|
245
|
+
'owner',
|
|
246
|
+
'and the non-fleet ceiling write landed');
|
|
247
|
+
|
|
248
|
+
-- ===========================================================================
|
|
249
|
+
-- 4. THE PRECONDITION, asserted before the refusal is probed. revoke_fleet_subject('agent')
|
|
250
|
+
-- changes the ledger and the bindings and NOTHING else — the agent stays active and its
|
|
251
|
+
-- signing key stays live. Without this, every refusal below could be passing because some
|
|
252
|
+
-- OTHER predicate started failing, which is the "succeeds/fails for the wrong reason"
|
|
253
|
+
-- trap this repo has been bitten by.
|
|
254
|
+
-- ===========================================================================
|
|
255
|
+
|
|
256
|
+
select pg_temp.service();
|
|
257
|
+
|
|
258
|
+
select lives_ok(
|
|
259
|
+
$$create temp table _rv as
|
|
260
|
+
select * from agent_board.revoke_fleet_subject(
|
|
261
|
+
'agent','27610000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000005',
|
|
262
|
+
'k276 operator revocation')$$,
|
|
263
|
+
'revoke_fleet_subject(agent, 1) succeeds');
|
|
264
|
+
|
|
265
|
+
select is(
|
|
266
|
+
(select count(*)::int from agent_board.fleet_revocation_events
|
|
267
|
+
where subject_kind = 'agent' and subject_id = '27610000-0000-4000-8000-000000000001'),
|
|
268
|
+
1,
|
|
269
|
+
'it wrote exactly one agent-subject ledger row');
|
|
270
|
+
|
|
271
|
+
select is(
|
|
272
|
+
(select status from agent_board.agents where id = '27610000-0000-4000-8000-000000000001'),
|
|
273
|
+
'active',
|
|
274
|
+
'PRECONDITION: the revoked agent is STILL active — the ledger row is the only authority signal');
|
|
275
|
+
|
|
276
|
+
select is(
|
|
277
|
+
(select status from agent_board.agent_signing_keys where id = '27630000-0000-4000-8000-000000000001'),
|
|
278
|
+
'active',
|
|
279
|
+
'PRECONDITION: its signing key is STILL live, so #269 revalidation cannot be what refuses below');
|
|
280
|
+
|
|
281
|
+
select is(
|
|
282
|
+
(select role from agent_board.board_memberships
|
|
283
|
+
where board_id = '27620000-0000-4000-8000-000000000001'
|
|
284
|
+
and agent_id = '27610000-0000-4000-8000-000000000001'),
|
|
285
|
+
'owner',
|
|
286
|
+
'PRECONDITION: it is STILL a board owner, so the owner gate cannot be what refuses below');
|
|
287
|
+
|
|
288
|
+
-- ===========================================================================
|
|
289
|
+
-- 5. THE REFUSAL: unsigned route. This is the wider door — `authenticated`, no gateway.
|
|
290
|
+
-- ===========================================================================
|
|
291
|
+
|
|
292
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000001');
|
|
293
|
+
|
|
294
|
+
select throws_like(
|
|
295
|
+
$$select * from agent_board.grant_board_role(
|
|
296
|
+
'27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002','owner')$$,
|
|
297
|
+
'%fleet_revoked:%grant_board_role%',
|
|
298
|
+
'a ledger-revoked grantor cannot complete an UNSIGNED grant');
|
|
299
|
+
|
|
300
|
+
select is(
|
|
301
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002'),
|
|
302
|
+
'member',
|
|
303
|
+
'and the refused unsigned grant wrote nothing');
|
|
304
|
+
|
|
305
|
+
-- ===========================================================================
|
|
306
|
+
-- 6. THE REFUSAL: signed route. Inherited by construction — the check lives INSIDE
|
|
307
|
+
-- grant_board_role, and signed_grant_board_role is its only other referrer in the database.
|
|
308
|
+
-- Asserted anyway, because "by construction" is a claim until something fails on it.
|
|
309
|
+
-- ===========================================================================
|
|
310
|
+
|
|
311
|
+
select pg_temp.service();
|
|
312
|
+
|
|
313
|
+
select throws_like(
|
|
314
|
+
$$select * from agent_board.signed_grant_board_role(pg_temp.grant_payload('owner'), pg_temp.gwa())$$,
|
|
315
|
+
'%fleet_revoked:%grant_board_role%',
|
|
316
|
+
'a ledger-revoked signer cannot complete a SIGNED grant either [Portal #362 divergence test]');
|
|
317
|
+
|
|
318
|
+
select is(
|
|
319
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002'),
|
|
320
|
+
'member',
|
|
321
|
+
'and the refused signed grant wrote nothing');
|
|
322
|
+
|
|
323
|
+
-- IDENTITY, not a count. `count(*) = 1` would still read 1 if the ALLOW-side write stopped
|
|
324
|
+
-- recording and the REFUSAL path started recording instead — the two errors cancel, and the
|
|
325
|
+
-- assertion cannot tell the difference. Pinning the surviving row's id to the one the
|
|
326
|
+
-- section-2 allow-side call returned makes both halves observable: a second row breaks the
|
|
327
|
+
-- count half, a substituted row breaks the identity half.
|
|
328
|
+
select is(
|
|
329
|
+
(select string_agg(se.id::text, ',' order by se.id) from agent_board.signature_evidence se
|
|
330
|
+
where se.operation = 'grant_board_role'),
|
|
331
|
+
(select sg.signature_evidence_id::text from _sg sg),
|
|
332
|
+
'and it recorded no NEW evidence row — the only grant_board_role evidence in the database is '
|
|
333
|
+
'still exactly the row section 2''s allow-side signed write returned');
|
|
334
|
+
|
|
335
|
+
select throws_like(
|
|
336
|
+
$$select * from agent_board.signed_set_board_authority_ceiling(
|
|
337
|
+
jsonb_build_object('p_board_id','27620000-0000-4000-8000-000000000001',
|
|
338
|
+
'p_max_grantable_role','observer'),
|
|
339
|
+
pg_temp.gwa())$$,
|
|
340
|
+
'%fleet_revoked:%set_board_authority_ceiling%',
|
|
341
|
+
'and the SIGNED ceiling wrapper is refused for the same principal');
|
|
342
|
+
|
|
343
|
+
-- A service_role gateway that SUPPLIES the revoked principal as the grantor argument is
|
|
344
|
+
-- refused too: the gate binds the principal the write is attributed to, not the connection role.
|
|
345
|
+
select throws_like(
|
|
346
|
+
$$select * from agent_board.grant_board_role(
|
|
347
|
+
'27620000-0000-4000-8000-000000000001','27610000-0000-4000-8000-000000000002','owner',
|
|
348
|
+
'27610000-0000-4000-8000-000000000001')$$,
|
|
349
|
+
'%fleet_revoked:%',
|
|
350
|
+
'service_role supplying the revoked principal as p_grantor_agent_id is refused too');
|
|
351
|
+
|
|
352
|
+
-- Back to the authenticated owner: the signed probes above ran as service_role, and the two
|
|
353
|
+
-- setters gate on current_agent_id(), which service_role does not have.
|
|
354
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000001');
|
|
355
|
+
|
|
356
|
+
-- The two SETTERS are probed last in this section deliberately. Each refusal probe leaves a
|
|
357
|
+
-- write behind when the gate is removed, and a ceiling written by an earlier probe would then
|
|
358
|
+
-- make the signed grant below fail on _grant_within_ceiling instead of on the gate -- masking
|
|
359
|
+
-- the evidence-row assertion from every mutation. Ordering is load-bearing, not cosmetic.
|
|
360
|
+
select throws_like(
|
|
361
|
+
$$select * from agent_board.set_board_authority_ceiling(
|
|
362
|
+
'27620000-0000-4000-8000-000000000001','observer')$$,
|
|
363
|
+
'%fleet_revoked:%set_board_authority_ceiling%',
|
|
364
|
+
'a ledger-revoked owner cannot change the board authority ceiling');
|
|
365
|
+
|
|
366
|
+
select is(
|
|
367
|
+
(select count(*)::int from agent_board.board_authority_ceilings
|
|
368
|
+
where board_id = '27620000-0000-4000-8000-000000000001'),
|
|
369
|
+
0,
|
|
370
|
+
'and the refused ceiling change wrote no ceiling row (the raise rolled the upsert back)');
|
|
371
|
+
|
|
372
|
+
select throws_like(
|
|
373
|
+
$$select * from agent_board.set_board_role_gate('27620000-0000-4000-8000-000000000001', true)$$,
|
|
374
|
+
'%fleet_revoked:%set_board_role_gate%',
|
|
375
|
+
'a ledger-revoked owner cannot flip the board role gate');
|
|
376
|
+
|
|
377
|
+
select is(
|
|
378
|
+
(select role_gate_enforced from agent_board.boards where id = '27620000-0000-4000-8000-000000000001'),
|
|
379
|
+
false,
|
|
380
|
+
'and the refused gate flip left the boards row alone (the raise rolled the UPDATE back)');
|
|
381
|
+
|
|
382
|
+
-- ===========================================================================
|
|
383
|
+
-- 7. JUDGEMENT 1 — the multi-binding case, and the agreement with the send path.
|
|
384
|
+
--
|
|
385
|
+
-- Agent 4 is bound to machines A and B. Its binding on A is revoked permanently; B stays
|
|
386
|
+
-- active. That is a PER-MACHINE fact. evaluate_fleet_sender_policy answers it by candidate
|
|
387
|
+
-- selection (it finds B and proceeds), so authority must too. Only the AGENT-SUBJECT ledger
|
|
388
|
+
-- row is agent-wide, and then both must refuse. Both halves are asserted on BOTH paths, so
|
|
389
|
+
-- the two can never be right separately and wrong together.
|
|
390
|
+
-- ===========================================================================
|
|
391
|
+
|
|
392
|
+
select pg_temp.service();
|
|
393
|
+
|
|
394
|
+
select lives_ok(
|
|
395
|
+
$$create temp table _rb as
|
|
396
|
+
select * from agent_board.revoke_fleet_machine_agent_binding(
|
|
397
|
+
'27660000-0000-4000-8000-00000000004a','27610000-0000-4000-8000-000000000005',
|
|
398
|
+
'k276 per-machine binding revocation')$$,
|
|
399
|
+
'agent 4''s binding on machine A is revoked; its binding on machine B stays active');
|
|
400
|
+
|
|
401
|
+
select is(
|
|
402
|
+
(select lifecycle_state from agent_board.fleet_machine_agents
|
|
403
|
+
where id = '27660000-0000-4000-8000-00000000004b'),
|
|
404
|
+
'active',
|
|
405
|
+
'PRECONDITION: the machine-B binding really is still active (the multi-binding shape exists)');
|
|
406
|
+
|
|
407
|
+
select is(
|
|
408
|
+
(select denial_class from agent_board.evaluate_fleet_sender_policy(
|
|
409
|
+
'27610000-0000-4000-8000-000000000004','27610000-0000-4000-8000-000000000002',
|
|
410
|
+
'27650000-0000-4000-8000-00000000000b')),
|
|
411
|
+
'recipient_policy_denied',
|
|
412
|
+
'SEND PATH: one revoked binding does not make agent 4 machine_revoked — it gets past the '
|
|
413
|
+
'revocation check on its surviving binding');
|
|
414
|
+
|
|
415
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000004');
|
|
416
|
+
|
|
417
|
+
select lives_ok(
|
|
418
|
+
$$create temp table _c5 as
|
|
419
|
+
select * from agent_board.grant_board_role(
|
|
420
|
+
'27620000-0000-4000-8000-000000000003','27610000-0000-4000-8000-000000000002','admin')$$,
|
|
421
|
+
'AUTHORITY PATH agrees: one revoked binding does not remove agent 4''s ability to grant');
|
|
422
|
+
|
|
423
|
+
select is(
|
|
424
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000003','27610000-0000-4000-8000-000000000002'),
|
|
425
|
+
'admin',
|
|
426
|
+
'and that grant really landed');
|
|
427
|
+
|
|
428
|
+
-- Now the AGENT-WIDE act, on the same agent, with a binding still nominally in play.
|
|
429
|
+
select pg_temp.service();
|
|
430
|
+
|
|
431
|
+
-- Mint an admin link for agent 4 while it is STILL unrevoked. Section 10 redeems it: this is
|
|
432
|
+
-- the outstanding-link case, the one the mint gate structurally cannot reach and the reason
|
|
433
|
+
-- both halves of the invite route are gated.
|
|
434
|
+
select lives_ok(
|
|
435
|
+
$$create temp table _pre as select * from agent_board.create_invite_link(
|
|
436
|
+
'27620000-0000-4000-8000-000000000003','admin','joined',5, now() + interval '2 days',
|
|
437
|
+
'27610000-0000-4000-8000-000000000004')$$,
|
|
438
|
+
'agent 4 mints an admin invite link BEFORE it is revoked');
|
|
439
|
+
|
|
440
|
+
select lives_ok(
|
|
441
|
+
$$create temp table _rv4 as
|
|
442
|
+
select * from agent_board.revoke_fleet_subject(
|
|
443
|
+
'agent','27610000-0000-4000-8000-000000000004','27610000-0000-4000-8000-000000000005',
|
|
444
|
+
'k276 agent-wide revocation')$$,
|
|
445
|
+
'agent 4 is now revoked as an agent-SUBJECT');
|
|
446
|
+
|
|
447
|
+
select is(
|
|
448
|
+
(select denial_class from agent_board.evaluate_fleet_sender_policy(
|
|
449
|
+
'27610000-0000-4000-8000-000000000004','27610000-0000-4000-8000-000000000002',
|
|
450
|
+
'27650000-0000-4000-8000-00000000000b')),
|
|
451
|
+
'machine_revoked',
|
|
452
|
+
'SEND PATH: the agent-subject ledger row denies agent 4 on the machine it was still bound to');
|
|
453
|
+
|
|
454
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000004');
|
|
455
|
+
|
|
456
|
+
select throws_like(
|
|
457
|
+
$$select * from agent_board.grant_board_role(
|
|
458
|
+
'27620000-0000-4000-8000-000000000003','27610000-0000-4000-8000-000000000002','owner')$$,
|
|
459
|
+
'%fleet_revoked:%',
|
|
460
|
+
'AUTHORITY PATH agrees: the agent-subject row denies agent 4 too [judgement 1: one answer]');
|
|
461
|
+
|
|
462
|
+
select is(
|
|
463
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000003','27610000-0000-4000-8000-000000000002'),
|
|
464
|
+
'admin',
|
|
465
|
+
'and the refused grant left agent 2 where the earlier grant put it');
|
|
466
|
+
|
|
467
|
+
-- ===========================================================================
|
|
468
|
+
-- 8. NO SUPERSESSION CLAUSE, mirrored from the send path. reactivate_fleet_machine stamps a
|
|
469
|
+
-- supersession point that the sender policy honours for MACHINE-subject events only; it
|
|
470
|
+
-- never un-revokes a sender agent. A future `effective_at` comparison added to the gate
|
|
471
|
+
-- would let a reactivated machine silently restore a revoked agent's authority, so the
|
|
472
|
+
-- absence is pinned as a behaviour, not left to the comment.
|
|
473
|
+
-- ===========================================================================
|
|
474
|
+
|
|
475
|
+
select pg_temp.service();
|
|
476
|
+
|
|
477
|
+
select lives_ok(
|
|
478
|
+
$$create temp table _rm as
|
|
479
|
+
select * from agent_board.revoke_fleet_subject(
|
|
480
|
+
'machine','27650000-0000-4000-8000-00000000000b','27610000-0000-4000-8000-000000000005',
|
|
481
|
+
'k276 machine revocation')$$,
|
|
482
|
+
'machine B is revoked, so it has a machine-subject ledger row that CAN be superseded');
|
|
483
|
+
|
|
484
|
+
select lives_ok(
|
|
485
|
+
$$create temp table _ra as
|
|
486
|
+
select * from agent_board.reactivate_fleet_machine(
|
|
487
|
+
'27650000-0000-4000-8000-00000000000b','27610000-0000-4000-8000-000000000005','k276 reactivation')$$,
|
|
488
|
+
'and it is reactivated, stamping the supersession point AFTER agent 4''s agent-subject revocation');
|
|
489
|
+
|
|
490
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000004');
|
|
491
|
+
|
|
492
|
+
select throws_like(
|
|
493
|
+
$$select * from agent_board.grant_board_role(
|
|
494
|
+
'27620000-0000-4000-8000-000000000003','27610000-0000-4000-8000-000000000002','owner')$$,
|
|
495
|
+
'%fleet_revoked:%',
|
|
496
|
+
'a machine reactivation does NOT restore a ledger-revoked agent''s authority (no supersession)');
|
|
497
|
+
|
|
498
|
+
-- ===========================================================================
|
|
499
|
+
-- 9. CLASS ASSERTIONS (catalog-derived, CLAUDE.md rule 1).
|
|
500
|
+
--
|
|
501
|
+
-- The class is not "the three functions #276 happened to edit". It is: every agent_board
|
|
502
|
+
-- function that WRITES a board-authority relation, derived from pg_proc by scanning stripped
|
|
503
|
+
-- bodies (never raw prosrc — #196/#199, a comment or a literal naming a marker must not be
|
|
504
|
+
-- able to satisfy any of these). Both sides are derived and compared, so a new authority
|
|
505
|
+
-- write that omits the gate appears in one set and not the other and the difference NAMES it.
|
|
506
|
+
--
|
|
507
|
+
-- THE MEMBERSHIPS ARM MATCHES ANY INSERT, and the reason is a defect this assertion had.
|
|
508
|
+
-- It first matched `update … set … role =` only. Review found `create_board` — which is
|
|
509
|
+
-- EXECUTE-able by `authenticated` — seeding `role = 'owner'` with an INSERT, and its
|
|
510
|
+
-- `on conflict … do update set role = 'owner'` is an UPDATE of bm.role that the UPDATE-shaped
|
|
511
|
+
-- regex STILL could not match, because the table name precedes `do update`. The description
|
|
512
|
+
-- said "WRITE" while the regex checked one syntax: a comment claiming more than the assertion
|
|
513
|
+
-- does, which is this repo's recorded failure shape.
|
|
514
|
+
--
|
|
515
|
+
-- Shape-blind costs nothing and is strictly stronger. Measured: the any-insert form names the
|
|
516
|
+
-- SAME five membership writers a role-token form does. It subsumes on-conflict by
|
|
517
|
+
-- construction (that clause exists only inside an insert) and it also catches a positional
|
|
518
|
+
-- insert naming no columns, which any column-matching form would miss.
|
|
519
|
+
-- ===========================================================================
|
|
520
|
+
|
|
521
|
+
select is(
|
|
522
|
+
(select string_agg(p.proname, ',' order by p.proname)
|
|
523
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
524
|
+
where n.nspname = 'agent_board'
|
|
525
|
+
and (agent_board_test.stripped_body(p.oid) ~* 'insert\s+into\s+agent_board\.board_memberships\M'
|
|
526
|
+
or agent_board_test.stripped_body(p.oid) ~* 'update\s+agent_board\.board_memberships\M[^;]*\mset\M[^;]*\mrole\s*='
|
|
527
|
+
or agent_board_test.stripped_body(p.oid) ~* '(insert\s+into|update)\s+agent_board\.board_authority_ceilings\M'
|
|
528
|
+
or agent_board_test.stripped_body(p.oid) ~* 'update\s+agent_board\.boards\M[^;]*\mset\M[^;]*\mrole_gate_enforced\s*='
|
|
529
|
+
or agent_board_test.stripped_body(p.oid) ~* 'insert\s+into\s+agent_board\.board_invite_tokens\M')),
|
|
530
|
+
'_fedsession_autojoin_default_board,_redeem_unbounded_invite_for_agent,'
|
|
531
|
+
|| '_upsert_board_membership_state,bootstrap_board_owner_memberships,create_board,'
|
|
532
|
+
|| 'create_invite_link,grant_board_role,set_board_authority_ceiling,set_board_role_gate',
|
|
533
|
+
'the functions that WRITE a board-authority relation — in ANY shape, including a mintable '
|
|
534
|
+
'invite preset_role — are exactly these nine');
|
|
535
|
+
|
|
536
|
+
-- EITHER helper counts as consulting the ledger: the invite redeem path calls the UNLOCKED
|
|
537
|
+
-- predicate (see the migration header), so a side that matched only the share-locking assert
|
|
538
|
+
-- would report the gated redeem path as ungated. The two helpers are excluded because they ARE
|
|
539
|
+
-- the gate — _assert_actor_not_fleet_revoked calls _actor_fleet_revoked, so it matches its own
|
|
540
|
+
-- regex.
|
|
541
|
+
select is(
|
|
542
|
+
(select string_agg(p.proname, ',' order by p.proname)
|
|
543
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
544
|
+
where n.nspname = 'agent_board'
|
|
545
|
+
and p.proname not in ('_assert_actor_not_fleet_revoked', '_actor_fleet_revoked')
|
|
546
|
+
and agent_board_test.stripped_body(p.oid) ~ '\m(_assert_actor_not_fleet_revoked|_actor_fleet_revoked)\s*\('),
|
|
547
|
+
'_redeem_unbounded_invite_for_agent,create_invite_link,grant_board_role,'
|
|
548
|
+
|| 'set_board_authority_ceiling,set_board_role_gate',
|
|
549
|
+
'the functions that consult the fleet revocation ledger are exactly these five');
|
|
550
|
+
|
|
551
|
+
-- The gate proper: the two sets must differ by EXACTLY the four asserted exemptions, and any
|
|
552
|
+
-- other member of the difference is NAMED in the failure. Each excluded name is licensed by a
|
|
553
|
+
-- positive disposition below, never by the absence of a match.
|
|
554
|
+
select is(
|
|
555
|
+
(select string_agg(x.proname || ':' || x.why, '; ' order by x.proname)
|
|
556
|
+
from (
|
|
557
|
+
select p.proname,
|
|
558
|
+
case when agent_board_test.stripped_body(p.oid) ~ '\m(_assert_actor_not_fleet_revoked|_actor_fleet_revoked)\s*\('
|
|
559
|
+
then 'consults the ledger but writes no authority relation'
|
|
560
|
+
else 'writes an authority relation with NO ledger gate'
|
|
561
|
+
end as why
|
|
562
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
563
|
+
where n.nspname = 'agent_board'
|
|
564
|
+
and p.proname not in (
|
|
565
|
+
-- the four asserted exemptions...
|
|
566
|
+
'_upsert_board_membership_state', 'create_board',
|
|
567
|
+
'_fedsession_autojoin_default_board', 'bootstrap_board_owner_memberships',
|
|
568
|
+
-- ...and the two helpers, which ARE the gate rather than users of it
|
|
569
|
+
'_assert_actor_not_fleet_revoked', '_actor_fleet_revoked')
|
|
570
|
+
and ((agent_board_test.stripped_body(p.oid) ~* 'insert\s+into\s+agent_board\.board_memberships\M'
|
|
571
|
+
or agent_board_test.stripped_body(p.oid) ~* 'update\s+agent_board\.board_memberships\M[^;]*\mset\M[^;]*\mrole\s*='
|
|
572
|
+
or agent_board_test.stripped_body(p.oid) ~* '(insert\s+into|update)\s+agent_board\.board_authority_ceilings\M'
|
|
573
|
+
or agent_board_test.stripped_body(p.oid) ~* 'update\s+agent_board\.boards\M[^;]*\mset\M[^;]*\mrole_gate_enforced\s*='
|
|
574
|
+
or agent_board_test.stripped_body(p.oid) ~* 'insert\s+into\s+agent_board\.board_invite_tokens\M')
|
|
575
|
+
<> (agent_board_test.stripped_body(p.oid) ~ '\m(_assert_actor_not_fleet_revoked|_actor_fleet_revoked)\s*\('))
|
|
576
|
+
) x),
|
|
577
|
+
null,
|
|
578
|
+
'no function writes an authority relation without the ledger gate, or vice versa');
|
|
579
|
+
|
|
580
|
+
-- ---------------------------------------------------------------------------
|
|
581
|
+
-- EXEMPTION A — _upsert_board_membership_state (the unauthorized membership primitive).
|
|
582
|
+
--
|
|
583
|
+
-- The signature is asserted as an EXACT STRING, not as a `role` word match. `_` is a word
|
|
584
|
+
-- CHARACTER in POSIX regex, so there is no word boundary between `p_` and `role`:
|
|
585
|
+
-- 'p_role text' ~ '\mrole\M' -> false
|
|
586
|
+
-- 'p_grantee_role text' ~ '\mrole\M' -> false
|
|
587
|
+
-- and every parameter in this codebase is `p_*`. The word-match form this assertion first used
|
|
588
|
+
-- would therefore have stayed GREEN while somebody added `p_role` and threaded it into the role
|
|
589
|
+
-- write — destroying the very claim the assertion exists to make.
|
|
590
|
+
--
|
|
591
|
+
-- string_agg over ALL oids of the name, not a single-row lookup: `create or replace` cannot add
|
|
592
|
+
-- a parameter, so that mutation can only land as a new OVERLOAD, and a scalar subquery over two
|
|
593
|
+
-- oids ABORTS the file instead of failing one assertion (CLAUDE.md rule 3).
|
|
594
|
+
-- ---------------------------------------------------------------------------
|
|
595
|
+
select is(
|
|
596
|
+
(select string_agg(pg_get_function_identity_arguments(p.oid), ' | ' order by p.oid)
|
|
597
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
598
|
+
where n.nspname = 'agent_board' and p.proname = '_upsert_board_membership_state'),
|
|
599
|
+
'p_board_id uuid, p_agent_id uuid, p_membership_state text, p_created_by_agent_id uuid, '
|
|
600
|
+
|| 'p_metadata jsonb, p_membership_id uuid, p_event_kind text',
|
|
601
|
+
'EXEMPTION A1: _upsert_board_membership_state takes EXACTLY these parameters — no role among '
|
|
602
|
+
'them, and no overload carrying one');
|
|
603
|
+
|
|
604
|
+
select is(
|
|
605
|
+
(select string_agg(distinct coalesce(pg_get_userbyid(nullif(a.grantee,0)),'PUBLIC'), ','
|
|
606
|
+
order by coalesce(pg_get_userbyid(nullif(a.grantee,0)),'PUBLIC'))
|
|
607
|
+
from pg_proc p, aclexplode(p.proacl) a
|
|
608
|
+
where p.oid = (select p2.oid from pg_proc p2 join pg_namespace n2 on n2.oid = p2.pronamespace
|
|
609
|
+
where n2.nspname = 'agent_board' and p2.proname = '_upsert_board_membership_state')
|
|
610
|
+
and a.privilege_type = 'EXECUTE'),
|
|
611
|
+
'postgres,service_role',
|
|
612
|
+
'EXEMPTION A2: and it is not EXECUTE-able by authenticated, anon or PUBLIC');
|
|
613
|
+
|
|
614
|
+
select pg_temp.service();
|
|
615
|
+
|
|
616
|
+
select lives_ok(
|
|
617
|
+
$$create temp table _cl as select agent_board._upsert_board_membership_state(
|
|
618
|
+
'27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000003',
|
|
619
|
+
'left','27610000-0000-4000-8000-000000000003','{}'::jsonb)$$,
|
|
620
|
+
'EXEMPTION A3 (setup): the owner of board 2 is moved to a terminal state');
|
|
621
|
+
|
|
622
|
+
select lives_ok(
|
|
623
|
+
$$create temp table _cl2 as select agent_board._upsert_board_membership_state(
|
|
624
|
+
'27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000003',
|
|
625
|
+
'joined','27610000-0000-4000-8000-000000000003','{}'::jsonb)$$,
|
|
626
|
+
'EXEMPTION A3 (act): and re-entered as active');
|
|
627
|
+
|
|
628
|
+
select is(
|
|
629
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000003'),
|
|
630
|
+
'member',
|
|
631
|
+
'EXEMPTION A3: its only role write is the ROLERESET clamp to the table default — it DEMOTES '
|
|
632
|
+
'an owner and can never confer authority, which is why it needs no ledger gate');
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
-- EXEMPTION A4 — the claim A1 could not make on its own.
|
|
636
|
+
--
|
|
637
|
+
-- A1 pins the HELPER's signature, and A3 demonstrates the ROLERESET clamp. Neither establishes
|
|
638
|
+
-- the broader sentence the exemption actually rests on: "no caller can hand this helper a role".
|
|
639
|
+
-- A1 covers the helper; A3 covers one code path. The gap is every OTHER function that reaches
|
|
640
|
+
-- the primitive — a caller that gained a role parameter and threaded it through would leave
|
|
641
|
+
-- both green while making the exemption false.
|
|
642
|
+
--
|
|
643
|
+
-- So the whole caller set is swept, in the EXACT-signature form rather than a word match (`_`
|
|
644
|
+
-- is a word character in POSIX regex, so `\mrole\M` does not match `p_role`, and every
|
|
645
|
+
-- parameter here is `p_*` — the same evasion G2 closed). A new caller, a removed caller, or an
|
|
646
|
+
-- existing caller gaining ANY parameter all change this string, and the failure prints the set.
|
|
647
|
+
--
|
|
648
|
+
-- Swept and clean at the time of writing: not one of the five declares a role-bearing
|
|
649
|
+
-- parameter. `invite_board_member` and `update_board_membership` admit at the membership floor
|
|
650
|
+
-- or move membership STATE; a `member` holds no authority to grant anything onward. That is why
|
|
651
|
+
-- they are outside the ratified criterion and are recorded as a bounded residual in the
|
|
652
|
+
-- migration header rather than gated here.
|
|
653
|
+
select is(
|
|
654
|
+
(select string_agg(p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')', ' | '
|
|
655
|
+
order by p.proname)
|
|
656
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
657
|
+
where n.nspname = 'agent_board'
|
|
658
|
+
and p.proname <> '_upsert_board_membership_state'
|
|
659
|
+
and agent_board_test.stripped_body(p.oid) ~ '\m_upsert_board_membership_state\s*\('),
|
|
660
|
+
'invite_board_member(p_board_id uuid, p_agent_id uuid, p_created_by_agent_id uuid, p_metadata jsonb)'
|
|
661
|
+
|| ' | join_board(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_created_by_agent_id uuid, p_metadata jsonb)'
|
|
662
|
+
|| ' | request_join_board(p_board_id uuid, p_agent_id uuid, p_metadata jsonb)'
|
|
663
|
+
|| ' | resolve_board_request(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_created_by_agent_id uuid, p_metadata jsonb)'
|
|
664
|
+
|| ' | update_board_membership(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_metadata jsonb)',
|
|
665
|
+
'EXEMPTION A4: EVERY caller of _upsert_board_membership_state takes EXACTLY these parameters '
|
|
666
|
+
'— not one declares a role, so "no caller can hand it one" is checked rather than argued');
|
|
667
|
+
|
|
668
|
+
-- ---------------------------------------------------------------------------
|
|
669
|
+
-- EXEMPTION B — create_board, the ONE authenticated-callable member of the class.
|
|
670
|
+
-- ---------------------------------------------------------------------------
|
|
671
|
+
select is(
|
|
672
|
+
(select string_agg(pg_get_function_identity_arguments(p.oid), ' | ' order by p.oid)
|
|
673
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
674
|
+
where n.nspname = 'agent_board' and p.proname = 'create_board'),
|
|
675
|
+
'p_created_by_agent_id uuid, p_slug text, p_name text, p_description text, p_metadata jsonb, '
|
|
676
|
+
|| 'p_visibility text',
|
|
677
|
+
'EXEMPTION B1: create_board takes EXACTLY these parameters — no role among them, and no '
|
|
678
|
+
'overload carrying one, so the owner seat it writes is a literal and not a caller choice');
|
|
679
|
+
|
|
680
|
+
select ok(
|
|
681
|
+
agent_board_test.stripped_body('agent_board.create_board(uuid,text,text,text,jsonb,text)'::regprocedure, true)
|
|
682
|
+
like '%values (v_board.id, v_board.created_by_agent_id, ''joined'', ''owner'', v_board.created_by_agent_id)%',
|
|
683
|
+
'EXEMPTION B2: the seat it writes is the literal owner for the board OWN created_by_agent_id '
|
|
684
|
+
'— the resolved actor, seeding itself on a board it just inserted');
|
|
685
|
+
|
|
686
|
+
select ok(
|
|
687
|
+
agent_board_test.stripped_body('agent_board.create_board(uuid,text,text,text,jsonb,text)'::regprocedure, true)
|
|
688
|
+
like '%board slug already in use%'
|
|
689
|
+
and strpos(agent_board_test.stripped_body('agent_board.create_board(uuid,text,text,text,jsonb,text)'::regprocedure, true),
|
|
690
|
+
'update agent_board.boards as b')
|
|
691
|
+
< strpos(agent_board_test.stripped_body('agent_board.create_board(uuid,text,text,text,jsonb,text)'::regprocedure, true),
|
|
692
|
+
'insert into agent_board.board_memberships'),
|
|
693
|
+
'EXEMPTION B3: the slug-COLLISION branch refuses a non-owner and updates the boards row '
|
|
694
|
+
'BEFORE the else-branch membership insert — the seat is on the fresh-board path only, so it '
|
|
695
|
+
'cannot elevate a membership on a pre-existing board');
|
|
696
|
+
|
|
697
|
+
-- ...and the residual, asserted as BEHAVIOUR so #280 closing flips a test rather than a comment.
|
|
698
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000001');
|
|
699
|
+
|
|
700
|
+
select lives_ok(
|
|
701
|
+
$$create temp table _cb as select * from agent_board.create_board(
|
|
702
|
+
'27610000-0000-4000-8000-000000000001','k276-residual-board','K276 Residual Board')$$,
|
|
703
|
+
'RESIDUAL (#280): a ledger-revoked agent CAN still create a board and be seeded its owner');
|
|
704
|
+
|
|
705
|
+
select is(
|
|
706
|
+
(select bm.role from agent_board.board_memberships bm
|
|
707
|
+
join agent_board.boards b on b.id = bm.board_id
|
|
708
|
+
where b.slug = 'k276-residual-board'
|
|
709
|
+
and bm.agent_id = '27610000-0000-4000-8000-000000000001'),
|
|
710
|
+
'owner',
|
|
711
|
+
'RESIDUAL (#280): and the owner seat really landed — this is the manufacture-new-authority '
|
|
712
|
+
'gap #276 does NOT close, measured rather than described');
|
|
713
|
+
|
|
714
|
+
select throws_like(
|
|
715
|
+
$$select * from agent_board.grant_board_role(
|
|
716
|
+
(select id from agent_board.boards where slug = 'k276-residual-board'),
|
|
717
|
+
'27610000-0000-4000-8000-000000000002','admin')$$,
|
|
718
|
+
'%fleet_revoked:%',
|
|
719
|
+
'but the gate still holds on the board it just created: it cannot GRANT there either, even '
|
|
720
|
+
'as that board owner with no ceiling row');
|
|
721
|
+
|
|
722
|
+
-- ---------------------------------------------------------------------------
|
|
723
|
+
-- EXEMPTIONS C and D — the two remaining service_role-only seeders. (What used to be
|
|
724
|
+
-- exemption C, _redeem_unbounded_invite_for_agent, is now GATED — see section 10.)
|
|
725
|
+
-- ---------------------------------------------------------------------------
|
|
726
|
+
select is(
|
|
727
|
+
(select string_agg(p.proname || '=' ||
|
|
728
|
+
has_function_privilege('authenticated', p.oid, 'EXECUTE')::text, ',' order by p.proname)
|
|
729
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
730
|
+
where n.nspname = 'agent_board'
|
|
731
|
+
and p.proname in ('_fedsession_autojoin_default_board','bootstrap_board_owner_memberships')),
|
|
732
|
+
'_fedsession_autojoin_default_board=false,bootstrap_board_owner_memberships=false',
|
|
733
|
+
'EXEMPTIONS C/D: neither remaining seeder is EXECUTE-able by authenticated');
|
|
734
|
+
|
|
735
|
+
select ok(
|
|
736
|
+
agent_board_test.stripped_body('agent_board._fedsession_autojoin_default_board(agent_board.identity_providers,uuid)'::regprocedure, true)
|
|
737
|
+
like '%p_provider.default_board_id, p_agent_id, ''member'', ''joined''%',
|
|
738
|
+
'EXEMPTION C: the fedsession autojoin seeds the literal member — the BASE role, which confers '
|
|
739
|
+
'no authority at all');
|
|
740
|
+
|
|
741
|
+
select is(
|
|
742
|
+
(select string_agg(pg_get_function_identity_arguments(p.oid), ' | ' order by p.oid)
|
|
743
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
744
|
+
where n.nspname = 'agent_board' and p.proname = 'bootstrap_board_owner_memberships'),
|
|
745
|
+
'',
|
|
746
|
+
'EXEMPTION D: bootstrap_board_owner_memberships takes NO arguments — it backfills the board '
|
|
747
|
+
'own recorded owner_agent_id and can name no other principal');
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
-- ===========================================================================
|
|
751
|
+
-- 10. G3 — THE INVITE ROUTE. An admin-preset link is a deferred grant of admin, so a
|
|
752
|
+
-- ledger-revoked agent must not be able to mint one, and an outstanding one must stop
|
|
753
|
+
-- conferring. Both halves are driven, and the discriminating pair against the adjacent
|
|
754
|
+
-- R-LINK-2 check is asserted explicitly: the creator here is ACTIVE (so R-LINK-2 passes)
|
|
755
|
+
-- and only the ledger row refuses.
|
|
756
|
+
--
|
|
757
|
+
-- Agent 3 is the NON-FLEET owner of board 2 and is used for the allow side; agent 1 is the
|
|
758
|
+
-- ledger-revoked owner of board 1. Agent 5 redeems.
|
|
759
|
+
-- ===========================================================================
|
|
760
|
+
|
|
761
|
+
-- ALLOW SIDE FIRST: an unrevoked owner still mints and the link still redeems, so the gate
|
|
762
|
+
-- cannot be passing below merely because invites are broken.
|
|
763
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000006');
|
|
764
|
+
|
|
765
|
+
select lives_ok(
|
|
766
|
+
$$create temp table _mk as select * from agent_board.create_invite_link(
|
|
767
|
+
'27620000-0000-4000-8000-000000000004','admin','joined',5,
|
|
768
|
+
now() + interval '2 days')$$,
|
|
769
|
+
'ALLOW: a NON-REVOKED owner still mints an admin invite link');
|
|
770
|
+
|
|
771
|
+
select is(
|
|
772
|
+
(select preset_role from _mk),
|
|
773
|
+
'admin',
|
|
774
|
+
'and the minted link really carries the admin preset');
|
|
775
|
+
|
|
776
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000005');
|
|
777
|
+
|
|
778
|
+
select lives_ok(
|
|
779
|
+
$$create temp table _rd as select * from agent_board.redeem_invite_link((select code from _mk))$$,
|
|
780
|
+
'ALLOW: and it redeems, conferring admin on the redeemer');
|
|
781
|
+
|
|
782
|
+
select is(
|
|
783
|
+
pg_temp.role_on('27620000-0000-4000-8000-000000000004','27610000-0000-4000-8000-000000000005'),
|
|
784
|
+
'admin',
|
|
785
|
+
'and the admin seat really landed — this is the capability #276 must not regress');
|
|
786
|
+
|
|
787
|
+
-- MINT, REFUSED. Agent 1 is ledger-revoked (section 4) and still an ACTIVE owner of board 1.
|
|
788
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000001');
|
|
789
|
+
|
|
790
|
+
select throws_like(
|
|
791
|
+
$$select * from agent_board.create_invite_link(
|
|
792
|
+
'27620000-0000-4000-8000-000000000001','admin','joined',5, now() + interval '2 days')$$,
|
|
793
|
+
'%fleet_revoked:%create_invite_link%',
|
|
794
|
+
'G3 MINT: a ledger-revoked owner cannot mint an admin invite link');
|
|
795
|
+
|
|
796
|
+
select is(
|
|
797
|
+
(select count(*)::int from agent_board.board_invite_tokens
|
|
798
|
+
where board_id = '27620000-0000-4000-8000-000000000001'),
|
|
799
|
+
0,
|
|
800
|
+
'and the refused mint left NO token behind — the raise discarded it, so no code escaped');
|
|
801
|
+
|
|
802
|
+
select throws_like(
|
|
803
|
+
$$select * from agent_board.create_invite_link(
|
|
804
|
+
'27620000-0000-4000-8000-000000000001','member','joined',5, now() + interval '2 days')$$,
|
|
805
|
+
'%fleet_revoked:%create_invite_link%',
|
|
806
|
+
'and a member-preset link is refused too: the gate is on the principal, not on the preset');
|
|
807
|
+
|
|
808
|
+
-- REDEEM, REFUSED — via a link minted BEFORE the creator was revoked, which is the case the
|
|
809
|
+
-- mint gate cannot reach and the reason both halves exist.
|
|
810
|
+
-- The link was minted in section 7 while agent 4 was still unrevoked; agent 4 has since been
|
|
811
|
+
-- ledger-revoked there. Assert the discriminating precondition: R-LINK-2
|
|
812
|
+
-- passes for it, so a refusal below can only be the #276 clause.
|
|
813
|
+
select is(
|
|
814
|
+
(select status from agent_board.agents where id = '27610000-0000-4000-8000-000000000004'),
|
|
815
|
+
'active',
|
|
816
|
+
'PRECONDITION: the link creator is STILL active, so R-LINK-2 passes and only the ledger '
|
|
817
|
+
'clause can refuse the redemption');
|
|
818
|
+
|
|
819
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000005');
|
|
820
|
+
|
|
821
|
+
select throws_like(
|
|
822
|
+
$$select * from agent_board.redeem_invite_link((select code from _pre))$$,
|
|
823
|
+
'invite is not usable',
|
|
824
|
+
'G3 REDEEM: an outstanding link from a ledger-revoked creator stops conferring — and raises '
|
|
825
|
+
'the UNIFORM message, not a fleet_revoked prefix, so a code-bearer learns nothing about the '
|
|
826
|
+
'creator lifecycle');
|
|
827
|
+
|
|
828
|
+
select is(
|
|
829
|
+
(select count(*)::int from agent_board.board_memberships
|
|
830
|
+
where board_id = '27620000-0000-4000-8000-000000000003'
|
|
831
|
+
and agent_id = '27610000-0000-4000-8000-000000000005'),
|
|
832
|
+
0,
|
|
833
|
+
'and the refused redemption wrote NO membership');
|
|
834
|
+
|
|
835
|
+
select is(
|
|
836
|
+
(select uses_count from agent_board.board_invite_tokens where code = (select code from _pre)),
|
|
837
|
+
0,
|
|
838
|
+
'and consumed no use');
|
|
839
|
+
|
|
840
|
+
select is(
|
|
841
|
+
(select count(*)::int from agent_board.board_invite_redemptions r
|
|
842
|
+
join agent_board.board_invite_tokens t on t.id = r.invite_id
|
|
843
|
+
where t.code = (select code from _pre)),
|
|
844
|
+
0,
|
|
845
|
+
'and left no redemption ledger row');
|
|
846
|
+
|
|
847
|
+
-- The REDEEMER's own revocation is NOT a bar: receiving a membership is not conferring one.
|
|
848
|
+
select pg_temp.service();
|
|
849
|
+
|
|
850
|
+
select lives_ok(
|
|
851
|
+
$$create temp table _mk2 as select * from agent_board.create_invite_link(
|
|
852
|
+
'27620000-0000-4000-8000-000000000004','member','joined',5, now() + interval '2 days',
|
|
853
|
+
'27610000-0000-4000-8000-000000000006')$$,
|
|
854
|
+
'a NON-revoked creator mints a second link on board 4');
|
|
855
|
+
|
|
856
|
+
select pg_temp.claims('27600000-0000-0000-0000-000000000001');
|
|
857
|
+
|
|
858
|
+
select lives_ok(
|
|
859
|
+
$$create temp table _rd2 as select * from agent_board.redeem_invite_link((select code from _mk2))$$,
|
|
860
|
+
'a ledger-revoked REDEEMER may still redeem: receiving authority is not conferring it, and '
|
|
861
|
+
'the gate is keyed on the creator');
|
|
862
|
+
|
|
863
|
+
select is(
|
|
864
|
+
(select membership_state from agent_board.board_memberships
|
|
865
|
+
where board_id = '27620000-0000-4000-8000-000000000004'
|
|
866
|
+
and agent_id = '27610000-0000-4000-8000-000000000001'),
|
|
867
|
+
'joined',
|
|
868
|
+
'and that redemption really placed the revoked redeemer');
|
|
869
|
+
|
|
870
|
+
-- The residual companion: the board a revoked agent CAN still create is not a way back in.
|
|
871
|
+
select throws_like(
|
|
872
|
+
$$select * from agent_board.create_invite_link(
|
|
873
|
+
(select id from agent_board.boards where slug = 'k276-residual-board'),
|
|
874
|
+
'admin','joined',5, now() + interval '2 days')$$,
|
|
875
|
+
'%fleet_revoked:%',
|
|
876
|
+
'RESIDUAL (#280) is contained: on the board it just created, a revoked agent still cannot '
|
|
877
|
+
'mint a link either — so the residual confers nothing on anyone else');
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
-- The RESIDUAL BOUND rests on a lock that is already in this function: the token FOR UPDATE is
|
|
881
|
+
-- its FIRST statement, before the ledger read, so two redemptions of the SAME token cannot both
|
|
882
|
+
-- sit between their ledger read and their commit. Delete that ordering and the disclosure in
|
|
883
|
+
-- the migration header ("at most one per outstanding token") stops being true, which is why it
|
|
884
|
+
-- is pinned here rather than asserted in prose. Both markers guarded with `> 0` (#198).
|
|
885
|
+
-- Two claims, not one, and the first version of this assertion made only the second. It
|
|
886
|
+
-- compared the POSITION of the token table reference against the ledger read — a proxy for
|
|
887
|
+
-- "locks the token first" that says nothing about whether the read is a lock at all.
|
|
888
|
+
-- Mutation-tested: demoting `for update` to a plain read left it GREEN while destroying the
|
|
889
|
+
-- per-token bound it exists to defend. It now requires the token select to BE a lock, requires
|
|
890
|
+
-- that lock to be the function's only one (so `for update` is an unambiguous position marker),
|
|
891
|
+
-- and only then compares positions. Both markers guarded with `> 0` (#198).
|
|
892
|
+
select ok(
|
|
893
|
+
agent_board_test.stripped_body('agent_board._redeem_unbounded_invite_for_agent(text,uuid)'::regprocedure)
|
|
894
|
+
~ 'from agent_board\.board_invite_tokens t[^;]*\mfor update\M'
|
|
895
|
+
and (select count(*) from regexp_matches(
|
|
896
|
+
agent_board_test.stripped_body('agent_board._redeem_unbounded_invite_for_agent(text,uuid)'::regprocedure),
|
|
897
|
+
'for update', 'g')) = 1
|
|
898
|
+
and strpos(agent_board_test.stripped_body('agent_board._redeem_unbounded_invite_for_agent(text,uuid)'::regprocedure),
|
|
899
|
+
'for update') > 0
|
|
900
|
+
and strpos(agent_board_test.stripped_body('agent_board._redeem_unbounded_invite_for_agent(text,uuid)'::regprocedure),
|
|
901
|
+
'agent_board._actor_fleet_revoked(') > 0
|
|
902
|
+
and strpos(agent_board_test.stripped_body('agent_board._redeem_unbounded_invite_for_agent(text,uuid)'::regprocedure),
|
|
903
|
+
'for update')
|
|
904
|
+
< strpos(agent_board_test.stripped_body('agent_board._redeem_unbounded_invite_for_agent(text,uuid)'::regprocedure),
|
|
905
|
+
'agent_board._actor_fleet_revoked('),
|
|
906
|
+
'the redeem path LOCKS the token (FOR UPDATE, its only row lock) before it reads the ledger — '
|
|
907
|
+
'which is what bounds the accepted window to one redemption PER OUTSTANDING TOKEN rather '
|
|
908
|
+
'than per creator');
|
|
909
|
+
|
|
910
|
+
-- FLEET ONBOARDING INHERITS THE REDEEM GATE, through _redeem_fleet_invite_for_agent. This is a
|
|
911
|
+
-- real behaviour change on a live path: an onboarding invite minted by a since-revoked owner
|
|
912
|
+
-- stops admitting. Asserted from the call graph rather than left for an operator to discover.
|
|
913
|
+
select ok(
|
|
914
|
+
agent_board_test.stripped_body('agent_board._redeem_fleet_invite_for_agent(text,uuid)'::regprocedure)
|
|
915
|
+
~ '\m_redeem_unbounded_invite_for_agent\s*\(',
|
|
916
|
+
'the FLEET redemption core delegates to the gated unbounded core, so fleet onboarding '
|
|
917
|
+
'inherits the creator-revocation gate by construction');
|
|
918
|
+
|
|
919
|
+
select is(
|
|
920
|
+
(select string_agg(p.proname, ',' order by p.proname)
|
|
921
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
922
|
+
where n.nspname = 'agent_board'
|
|
923
|
+
and agent_board_test.stripped_body(p.oid) ~ '\m_redeem_unbounded_invite_for_agent\s*\('),
|
|
924
|
+
'_redeem_fleet_invite_for_agent,_redeem_invite_for_agent',
|
|
925
|
+
'and those are the only two callers of it, so no redemption route bypasses the gate');
|
|
926
|
+
|
|
927
|
+
-- The gate's serialization rests on a claim about OTHER functions: every writer of an
|
|
928
|
+
-- agent-subject ledger row takes the agents row lock BEFORE the write, so the FOR SHARE in the
|
|
929
|
+
-- helper conflicts with it. Pinned from the catalog, so a future ledger writer that skips the
|
|
930
|
+
-- lock fails here and names itself instead of silently reopening the TOCTOU.
|
|
931
|
+
select is(
|
|
932
|
+
(select string_agg(p.proname, ',' order by p.proname)
|
|
933
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
934
|
+
where n.nspname = 'agent_board'
|
|
935
|
+
and agent_board_test.stripped_body(p.oid) ~* '(insert\s+into|update)\s+agent_board\.fleet_revocation_events\M'),
|
|
936
|
+
'_revoke_agent_principal,revoke_fleet_subject',
|
|
937
|
+
'the writers of the fleet revocation ledger are exactly these two');
|
|
938
|
+
|
|
939
|
+
-- Both markers guarded with `> 0`: a bare `strpos(A) < strpos(B)` is `0 < N` = TRUE when the
|
|
940
|
+
-- LEFT marker is absent, so it passes on a body it no longer matches (#198).
|
|
941
|
+
select ok(
|
|
942
|
+
strpos(agent_board_test.stripped_body('agent_board.revoke_fleet_subject(text,uuid,uuid,text,jsonb)'::regprocedure),
|
|
943
|
+
'agent_board.agents a where a.id = p_subject_id for update') > 0
|
|
944
|
+
and strpos(agent_board_test.stripped_body('agent_board.revoke_fleet_subject(text,uuid,uuid,text,jsonb)'::regprocedure),
|
|
945
|
+
'insert into agent_board.fleet_revocation_events') > 0
|
|
946
|
+
and strpos(agent_board_test.stripped_body('agent_board.revoke_fleet_subject(text,uuid,uuid,text,jsonb)'::regprocedure),
|
|
947
|
+
'agent_board.agents a where a.id = p_subject_id for update')
|
|
948
|
+
< strpos(agent_board_test.stripped_body('agent_board.revoke_fleet_subject(text,uuid,uuid,text,jsonb)'::regprocedure),
|
|
949
|
+
'insert into agent_board.fleet_revocation_events'),
|
|
950
|
+
'revoke_fleet_subject locks the agents row BEFORE writing the ledger — what the gate''s FOR SHARE conflicts with');
|
|
951
|
+
|
|
952
|
+
select ok(
|
|
953
|
+
strpos(agent_board_test.stripped_body('agent_board._revoke_agent_principal(uuid,uuid,text)'::regprocedure),
|
|
954
|
+
'agent_board.agents a where a.id = p_agent_id for update') > 0
|
|
955
|
+
and strpos(agent_board_test.stripped_body('agent_board._revoke_agent_principal(uuid,uuid,text)'::regprocedure),
|
|
956
|
+
'insert into agent_board.fleet_revocation_events') > 0
|
|
957
|
+
and strpos(agent_board_test.stripped_body('agent_board._revoke_agent_principal(uuid,uuid,text)'::regprocedure),
|
|
958
|
+
'agent_board.agents a where a.id = p_agent_id for update')
|
|
959
|
+
< strpos(agent_board_test.stripped_body('agent_board._revoke_agent_principal(uuid,uuid,text)'::regprocedure),
|
|
960
|
+
'insert into agent_board.fleet_revocation_events'),
|
|
961
|
+
'_revoke_agent_principal locks the agents row BEFORE writing the ledger too');
|
|
962
|
+
|
|
963
|
+
-- The lock the gate itself takes, and its MODE. FOR KEY SHARE would not conflict with the FOR
|
|
964
|
+
-- NO KEY UPDATE a plain status change takes; the isolation spec is what proves the conflict,
|
|
965
|
+
-- this is what stops the mode being quietly weakened.
|
|
966
|
+
select ok(
|
|
967
|
+
agent_board_test.stripped_body('agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure)
|
|
968
|
+
~ 'agent_board\.agents a where a\.id = p_actor_agent_id for share',
|
|
969
|
+
'the gate takes FOR SHARE on the actor''s agents row (not KEY SHARE, not a bare read)');
|
|
970
|
+
|
|
971
|
+
select ok(
|
|
972
|
+
strpos(agent_board_test.stripped_body('agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure),
|
|
973
|
+
'for share') > 0
|
|
974
|
+
and strpos(agent_board_test.stripped_body('agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure),
|
|
975
|
+
'agent_board._actor_fleet_revoked(') > 0
|
|
976
|
+
and strpos(agent_board_test.stripped_body('agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure),
|
|
977
|
+
'for share')
|
|
978
|
+
< strpos(agent_board_test.stripped_body('agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure),
|
|
979
|
+
'agent_board._actor_fleet_revoked('),
|
|
980
|
+
'and it takes that lock BEFORE reading the ledger, which is the whole point of taking it');
|
|
981
|
+
|
|
982
|
+
-- The split, asserted from the catalog. The predicate lives in ONE function so the locked and
|
|
983
|
+
-- unlocked callers cannot drift into asking two different questions, and the UNLOCKED one must
|
|
984
|
+
-- stay unlocked — an `agents` lock reappearing in it is the deadlock the header enumerates.
|
|
985
|
+
select ok(
|
|
986
|
+
agent_board_test.stripped_body('agent_board._actor_fleet_revoked(uuid)'::regprocedure)
|
|
987
|
+
~ 'agent_board\.fleet_revocation_events'
|
|
988
|
+
and agent_board_test.stripped_body('agent_board._actor_fleet_revoked(uuid)'::regprocedure)
|
|
989
|
+
!~ '\mfor\s+(share|update|key\s+share|no\s+key\s+update)\M',
|
|
990
|
+
'the unlocked predicate reads the ledger and takes NO row lock — what makes it safe to call '
|
|
991
|
+
'from the invite redeem path, which is reached holding a boards lock');
|
|
992
|
+
|
|
993
|
+
select is(
|
|
994
|
+
(select string_agg(distinct coalesce(pg_get_userbyid(nullif(a.grantee,0)),'PUBLIC'), ','
|
|
995
|
+
order by coalesce(pg_get_userbyid(nullif(a.grantee,0)),'PUBLIC'))
|
|
996
|
+
from pg_proc p, aclexplode(p.proacl) a
|
|
997
|
+
where p.oid = 'agent_board._actor_fleet_revoked(uuid)'::regprocedure
|
|
998
|
+
and a.privilege_type = 'EXECUTE'),
|
|
999
|
+
'postgres,service_role',
|
|
1000
|
+
'and the unlocked predicate is service_role-only, like the assert it was split out of');
|
|
1001
|
+
|
|
1002
|
+
-- The gate must not have acquired a supersession clause. `effective_at` appearing in the body
|
|
1003
|
+
-- would mean a machine reactivation could restore a revoked agent's authority.
|
|
1004
|
+
select ok(
|
|
1005
|
+
agent_board_test.stripped_body('agent_board._assert_actor_not_fleet_revoked(uuid,text)'::regprocedure)
|
|
1006
|
+
!~ '\m(effective_at|reactivated_at)\M',
|
|
1007
|
+
'the gate has no supersession clause, matching the send path''s agent-subject predicate');
|
|
1008
|
+
|
|
1009
|
+
select finish();
|
|
1010
|
+
rollback;
|