@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.
@@ -0,0 +1,290 @@
1
+ # Consiliency/message-board#276 — a board AUTHORITY write reads the fleet revocation ledger
2
+ # under a share lock on the actor's agents row, so a concurrent revoke_fleet_subject('agent')
3
+ # serializes against the write instead of racing it.
4
+ #
5
+ # WHY THIS CANNOT BE A pgTAP FILE. The predicate ("a ledger-revoked grantor is refused") is
6
+ # provable in one transaction and is proved in agent_board_ledger_authority.test.sql. The
7
+ # property THIS file exists for is not the predicate: it is that the read is LOCKED. An
8
+ # unlocked `exists (select 1 from fleet_revocation_events ...)` passes every pgTAP assertion in
9
+ # that file and still leaves the TOCTOU wide open — the authority write reads "not revoked", a
10
+ # revocation commits, and the grant lands anyway. A single rolled-back transaction cannot
11
+ # produce that interleaving. isolationtester prints `<waiting ...>` / `<... completed>` whenever
12
+ # a step blocks, so the lock is a byte in the golden rather than a claim in a comment.
13
+ #
14
+ # THE ADVERSARY IS THE SHIPPED FUNCTION, not a stand-in. `revoke_fleet_subject('agent', X)`
15
+ # (20260901000100:1062) is the exact call the issue is about: it locks the machines X is bound
16
+ # to, then the agents row FOR UPDATE, then the bindings, then writes the agent-subject ledger
17
+ # row. That `agents` FOR UPDATE is what the gate's FOR SHARE conflicts with, and it is the sole
18
+ # reason the two orders below produce different outcomes.
19
+ #
20
+ # BOTH ROUTES ARE DRIVEN, because the whole point of siting the check inside grant_board_role
21
+ # was that the wide `authenticated` door and the narrow signed one could not diverge:
22
+ # ugrant — grant_board_role called directly by the authenticated owner. No gateway.
23
+ # sgrant — signed_grant_board_role called by the service_role gateway, with a decision
24
+ # naming a live, owned, verified key so a refusal can only be #276's gate and
25
+ # never #269's signer revalidation.
26
+ #
27
+ # The check session also reads agents.status: it must stay 'active' throughout. If a future
28
+ # change made fleet revocation flip the status, these permutations would still pass — but for
29
+ # a different reason — and that column in the golden is what makes the substitution visible.
30
+ #
31
+ # MUTATION-VERIFIED: dropping `for share` from _assert_actor_not_fleet_revoked (leaving the
32
+ # ledger read unlocked) changes permutations 2 and 4 — the authority write no longer waits.
33
+ # Clean schema -> byte-identical across 10 runs.
34
+
35
+ setup
36
+ {
37
+ insert into auth.users (id) values
38
+ ('27700000-0000-0000-0000-0000000000a1'),
39
+ ('27700000-0000-0000-0000-0000000000a2'),
40
+ ('27700000-0000-0000-0000-0000000000a3');
41
+
42
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id)
43
+ values
44
+ ('27710000-0000-4000-8000-000000000001','agent','agent','k276iso-owner','K276 ISO Owner','active','27700000-0000-0000-0000-0000000000a1'),
45
+ ('27710000-0000-4000-8000-000000000002','agent','agent','k276iso-grantee','K276 ISO Grantee','active','27700000-0000-0000-0000-0000000000a2'),
46
+ ('27710000-0000-4000-8000-000000000003','agent','agent','k276iso-revoker','K276 ISO Revoker','active','27700000-0000-0000-0000-0000000000a3');
47
+
48
+ insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
49
+ values ('27720000-0000-4000-8000-000000000001','k276-iso-board','K276 ISO Board',
50
+ '27710000-0000-4000-8000-000000000001','private','active');
51
+
52
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
53
+ values
54
+ ('27720000-0000-4000-8000-000000000001','27710000-0000-4000-8000-000000000001','owner','joined','27710000-0000-4000-8000-000000000001'),
55
+ ('27720000-0000-4000-8000-000000000001','27710000-0000-4000-8000-000000000002','member','joined','27710000-0000-4000-8000-000000000001');
56
+
57
+ insert into agent_board.agent_signing_keys(
58
+ id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
59
+ values ('27730000-0000-4000-8000-000000000001','27710000-0000-4000-8000-000000000001',
60
+ 'primary', 1, public.test_pubkey('k276-iso'), 'raw_ed25519_base64url',
61
+ public.test_key_fingerprint('k276-iso'), 'active', 'proven');
62
+
63
+ -- A real fleet binding, so revoke_fleet_subject('agent') walks its full path (machine locks,
64
+ -- agents lock, binding revocation, ledger write) rather than a degenerate one.
65
+ insert into agent_board.fleet_trust_domains(id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
66
+ values ('27740000-0000-4000-8000-000000000001','k276-iso-domain','K276 ISO Domain','tailnet',
67
+ '27710000-0000-4000-8000-000000000003','active', now());
68
+ insert into agent_board.fleet_machines(id, trust_domain_id, owner_agent_id, machine_label, machine_fingerprint, lifecycle_state, enrolled_at)
69
+ values ('27750000-0000-4000-8000-000000000001','27740000-0000-4000-8000-000000000001',
70
+ '27710000-0000-4000-8000-000000000003','k276-iso-machine','fp-k276-iso','active', now());
71
+ insert into agent_board.fleet_machine_agents(id, machine_id, agent_id, agent_role, lifecycle_state)
72
+ values ('27760000-0000-4000-8000-000000000001','27750000-0000-4000-8000-000000000001',
73
+ '27710000-0000-4000-8000-000000000001','phase_worker','active');
74
+
75
+ -- An OUTSTANDING admin link, modelled as a pre-existing row because that is what it is: a
76
+ -- link minted before its creator was revoked, which the mint gate structurally cannot reach.
77
+ -- Seeded rather than minted through create_invite_link for two reasons, both mechanical: the
78
+ -- generated code is random (a golden must not contain one), and board_invite_tokens carries
79
+ -- a CREATOR-ONLY read policy (board_invite_tokens_creator_read), so a redeem session running
80
+ -- as `authenticated` cannot look the code up -- it holds the code out of band, as a real
81
+ -- redeemer does. An earlier revision of this spec did look it up and every redemption failed
82
+ -- 'invite is not usable' on a null code, which the uniform error made indistinguishable from
83
+ -- the gate firing. The code is a literal here for exactly that reason.
84
+ insert into agent_board.board_invite_tokens(
85
+ code, board_id, preset_role, preset_membership_state, max_uses, expires_at, created_by_agent_id)
86
+ values ('k276isolink', '27720000-0000-4000-8000-000000000001', 'admin', 'joined', 5,
87
+ now() + interval '2 days', '27710000-0000-4000-8000-000000000001');
88
+ }
89
+
90
+ teardown
91
+ {
92
+ delete from agent_board.signature_evidence
93
+ where agent_id in (select id from agent_board.agents where handle like 'k276iso-%');
94
+ delete from agent_board.board_invite_redemptions
95
+ where invite_id in (select id from agent_board.board_invite_tokens
96
+ where board_id = '27720000-0000-4000-8000-000000000001');
97
+ delete from agent_board.board_invite_tokens
98
+ where board_id = '27720000-0000-4000-8000-000000000001';
99
+ delete from agent_board.board_membership_events
100
+ where board_id = '27720000-0000-4000-8000-000000000001';
101
+ delete from agent_board.fleet_revocation_events
102
+ where agent_id in (select id from agent_board.agents where handle like 'k276iso-%')
103
+ or subject_id in (select id from agent_board.agents where handle like 'k276iso-%')
104
+ or revoked_by_agent_id in (select id from agent_board.agents where handle like 'k276iso-%');
105
+ delete from agent_board.fleet_machine_agents where id='27760000-0000-4000-8000-000000000001';
106
+ delete from agent_board.fleet_machines where id='27750000-0000-4000-8000-000000000001';
107
+ delete from agent_board.fleet_trust_domains where id='27740000-0000-4000-8000-000000000001';
108
+ delete from agent_board.agent_signing_keys
109
+ where agent_id in (select id from agent_board.agents where handle like 'k276iso-%');
110
+ delete from agent_board.board_authority_ceilings where board_id='27720000-0000-4000-8000-000000000001';
111
+ delete from agent_board.board_memberships where board_id='27720000-0000-4000-8000-000000000001';
112
+ delete from agent_board.boards where id='27720000-0000-4000-8000-000000000001';
113
+ delete from agent_board.agents where handle like 'k276iso-%';
114
+ delete from auth.users where id in (
115
+ '27700000-0000-0000-0000-0000000000a1',
116
+ '27700000-0000-0000-0000-0000000000a2',
117
+ '27700000-0000-0000-0000-0000000000a3');
118
+ }
119
+
120
+ # The WIDE door: the authenticated owner calling the chokepoint directly, no gateway involved.
121
+ session "ugrant"
122
+ step "ugrant_begin" {
123
+ BEGIN;
124
+ SET LOCAL ROLE authenticated;
125
+ SET LOCAL "request.jwt.claim.role" = 'authenticated';
126
+ SET LOCAL "request.jwt.claim.sub" = '27700000-0000-0000-0000-0000000000a1';
127
+ }
128
+ step "ugrant_grant" {
129
+ select g.role as granted_role
130
+ from agent_board.grant_board_role(
131
+ '27720000-0000-4000-8000-000000000001',
132
+ '27710000-0000-4000-8000-000000000002',
133
+ 'admin') g;
134
+ }
135
+ step "ugrant_commit" { COMMIT; }
136
+
137
+ # The NARROW door. Only the deterministic column is selected -- signature_evidence_id is a
138
+ # fresh uuid and a golden must not contain a random value.
139
+ session "sgrant"
140
+ step "sgrant_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
141
+ step "sgrant_grant" {
142
+ select w.role as granted_role
143
+ from agent_board.signed_grant_board_role(
144
+ jsonb_build_object(
145
+ 'p_board_id', '27720000-0000-4000-8000-000000000001',
146
+ 'p_agent_id', '27710000-0000-4000-8000-000000000002',
147
+ 'p_grantee_role', 'admin'),
148
+ jsonb_build_object(
149
+ 'agent_id', '27710000-0000-4000-8000-000000000001',
150
+ 'signing_key_id', '27730000-0000-4000-8000-000000000001',
151
+ 'key_id', 'primary',
152
+ 'key_version', 1,
153
+ 'algorithm', 'Ed25519',
154
+ 'verification_status', 'verified',
155
+ 'payload_digest', 'sha256:k276',
156
+ 'enforcement_mode', 'require_for_agent_writes')) w;
157
+ }
158
+ step "sgrant_commit" { COMMIT; }
159
+
160
+ session "revoke"
161
+ step "revoke_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
162
+ step "revoke_agent" {
163
+ select subject_kind
164
+ from agent_board.revoke_fleet_subject(
165
+ 'agent',
166
+ '27710000-0000-4000-8000-000000000001',
167
+ '27710000-0000-4000-8000-000000000003',
168
+ 'k276 iso fleet revocation');
169
+ }
170
+ step "revoke_commit" { COMMIT; }
171
+
172
+ # The invite MINT half (#276 G3). Locked like the three chokepoints: the actor IS the principal
173
+ # whose authority is being spent, since an admin-preset link is a deferred grant of admin.
174
+ session "mint"
175
+ step "mint_begin" {
176
+ BEGIN;
177
+ SET LOCAL ROLE authenticated;
178
+ SET LOCAL "request.jwt.claim.role" = 'authenticated';
179
+ SET LOCAL "request.jwt.claim.sub" = '27700000-0000-0000-0000-0000000000a1';
180
+ }
181
+ step "mint_link" {
182
+ select m.preset_role
183
+ from agent_board.create_invite_link(
184
+ '27720000-0000-4000-8000-000000000001', 'admin', 'joined', 5,
185
+ now() + interval '2 days') m;
186
+ }
187
+ step "mint_commit" { COMMIT; }
188
+
189
+ # The invite REDEEM half. DELIBERATELY UNLOCKED -- every way of row-locking the creator here
190
+ # closes a cycle (enroll_federated_identity reaches this function holding a boards lock; see the
191
+ # migration header). This session exists to record the ACCEPTED window as bytes rather than
192
+ # prose: with the revocation in flight and uncommitted, the redemption does NOT block and
193
+ # succeeds on its own snapshot. Permutation 6 then shows the very next redemption refused.
194
+ session "redeem"
195
+ step "redeem_begin" {
196
+ BEGIN;
197
+ SET LOCAL ROLE authenticated;
198
+ SET LOCAL "request.jwt.claim.role" = 'authenticated';
199
+ SET LOCAL "request.jwt.claim.sub" = '27700000-0000-0000-0000-0000000000a2';
200
+ }
201
+ step "redeem_link" {
202
+ select r.role as redeemed_role
203
+ from agent_board.redeem_invite_link('k276isolink') r;
204
+ }
205
+ step "redeem_commit" { COMMIT; }
206
+
207
+ # A SECOND redeemer of the SAME token. Exists to bound the accepted window: two redemptions of
208
+ # one token cannot both sit between their ledger read and their commit, because the token
209
+ # FOR UPDATE is the redeem path's first statement. Permutation 8 drives exactly that.
210
+ session "redeem2"
211
+ step "redeem2_begin" {
212
+ BEGIN;
213
+ SET LOCAL ROLE authenticated;
214
+ SET LOCAL "request.jwt.claim.role" = 'authenticated';
215
+ SET LOCAL "request.jwt.claim.sub" = '27700000-0000-0000-0000-0000000000a3';
216
+ }
217
+ step "redeem2_link" {
218
+ select r.role as redeemed_role
219
+ from agent_board.redeem_invite_link('k276isolink') r;
220
+ }
221
+ step "redeem2_commit" { COMMIT; }
222
+
223
+ session "check"
224
+ step "check" {
225
+ select
226
+ (select role from agent_board.board_memberships
227
+ where board_id = '27720000-0000-4000-8000-000000000001'
228
+ and agent_id = '27710000-0000-4000-8000-000000000002') as grantee_role,
229
+ (select status from agent_board.agents
230
+ where id = '27710000-0000-4000-8000-000000000001') as owner_status,
231
+ (select count(*) from agent_board.fleet_revocation_events
232
+ where subject_kind = 'agent'
233
+ and subject_id = '27710000-0000-4000-8000-000000000001') as agent_ledger_rows,
234
+ (select count(*) from agent_board.fleet_machine_agents
235
+ where agent_id = '27710000-0000-4000-8000-000000000001'
236
+ and lifecycle_state = 'active') as active_bindings,
237
+ (select count(*) from agent_board.board_invite_tokens
238
+ where board_id = '27720000-0000-4000-8000-000000000001'
239
+ and code <> 'k276isolink') as minted_tokens,
240
+ (select uses_count from agent_board.board_invite_tokens
241
+ where code = 'k276isolink') as outstanding_link_uses;
242
+ }
243
+
244
+ # 1. Write first (UNSIGNED). The revocation must WAIT on the share-locked agents row rather
245
+ # than committing underneath a grant that has already been authorized. The grant stands and
246
+ # the revocation lands after it -- a grant that beat the revocation, which is the same
247
+ # outcome as a grant one millisecond earlier.
248
+ permutation "ugrant_begin" "ugrant_grant" "revoke_begin" "revoke_agent" "ugrant_commit" "revoke_commit" "check"
249
+
250
+ # 2. THE DEFECT'S OWN INTERLEAVING, unsigned route. The revocation is in flight when the
251
+ # authority write runs; the write blocks on the agents row, then observes the committed
252
+ # revocation and fails closed. Before #276 this permutation COMPLETED the grant: the
253
+ # chokepoint read no ledger at all, so there was nothing to block on and nothing to see.
254
+ permutation "revoke_begin" "revoke_agent" "ugrant_begin" "ugrant_grant" "revoke_commit" "ugrant_commit" "check"
255
+
256
+ # 3. Write first, SIGNED route. Same property through the gateway wrapper, which reaches the
257
+ # gate only because the check lives inside the inner chokepoint.
258
+ permutation "sgrant_begin" "sgrant_grant" "revoke_begin" "revoke_agent" "sgrant_commit" "revoke_commit" "check"
259
+
260
+ # 4. The defect's interleaving through the SIGNED route. #269 already made this wrapper block
261
+ # on the agents row for a STATUS change; this permutation is what proves it also fails
262
+ # closed on a LEDGER revocation, which leaves agents.status untouched.
263
+ permutation "revoke_begin" "revoke_agent" "sgrant_begin" "sgrant_grant" "revoke_commit" "sgrant_commit" "check"
264
+
265
+ # 5. The MINT half, revoke-in-flight. The mint blocks on the creator's agents row, then observes
266
+ # the committed revocation and fails closed -- and leaves no token, so no code escapes. This
267
+ # is the permutation the FOR SHARE in _assert_actor_not_fleet_revoked exists for on this path.
268
+ permutation "revoke_begin" "revoke_agent" "mint_begin" "mint_link" "revoke_commit" "mint_commit" "check"
269
+
270
+ # 6. THE ACCEPTED WINDOW, recorded rather than described. The outstanding link exists, the
271
+ # revocation is IN FLIGHT and uncommitted, and the redemption does NOT block -- it reads its
272
+ # own snapshot and succeeds. That is the TOCTOU this migration deliberately accepts on the
273
+ # redeem path, because every row lock that would close it closes a deadlock cycle instead
274
+ # (see the header). The check row shows the use consumed; the pgTAP file shows the NEXT
275
+ # redemption, after the revocation commits, refused.
276
+ permutation "revoke_begin" "revoke_agent" "redeem_begin" "redeem_link" "redeem_commit" "revoke_commit" "check"
277
+
278
+ # 7. ...and the same interleaving with the revocation COMMITTED first: now the redemption is
279
+ # refused. The pair is what makes permutation 6 a bounded window rather than an open door.
280
+ permutation "revoke_begin" "revoke_agent" "revoke_commit" "redeem_begin" "redeem_link" "redeem_commit" "check"
281
+
282
+ # 8. THE RESIDUAL BOUND, driven rather than argued. Two redeemers of the SAME token with the
283
+ # revocation in flight: the first reads the ledger before the revocation commits and slips
284
+ # through; the second BLOCKS on the token row -- board_invite_tokens FOR UPDATE is the redeem
285
+ # path's first statement -- and by the time it proceeds the revocation has committed, so its
286
+ # own ledger read refuses it. That is why the disclosure says "at most one redemption per
287
+ # outstanding token" and not "one redemption": tokens serialise redemptions of themselves,
288
+ # but a creator holding K links has K independent windows. outstanding_link_uses = 1 in the
289
+ # check row is the byte that says exactly one slipped.
290
+ permutation "revoke_begin" "revoke_agent" "redeem_begin" "redeem_link" "redeem2_begin" "redeem2_link" "revoke_commit" "redeem_commit" "redeem2_commit" "check"
@@ -0,0 +1,159 @@
1
+ # Consiliency/message-board#269 (F4) — signed_grant_board_role must be ACYCLIC against
2
+ # onboard_federated_fleet_agent.
3
+ #
4
+ # THE DEFECT. The grant wrapper's lock order was
5
+ # board_memberships(grantee, via the inner grant's UPDATE) -> agents(signer, FOR SHARE)
6
+ # while onboard_federated_fleet_agent takes
7
+ # agents(20260901000110:349, FOR UPDATE) -> board_memberships(:397, FOR UPDATE)
8
+ # — the opposite order, with no advisory lock on the grant side to serialize them. That is an
9
+ # ABBA cycle between two HONEST callers: a board owner who created a fleet invite while
10
+ # concurrently granting the enrollee a role. Both operands are routine on one board (the
11
+ # collision needs enrolling agent == grantee and invite creator == signer). Reproduced before
12
+ # the fix as a live `ERROR: deadlock detected ... while locking tuple in relation
13
+ # "board_memberships"`, with the real wrapper on one side.
14
+ #
15
+ # THE FIX this spec pins: the wrapper takes the board-keyed _lock_board_authority_ceiling
16
+ # advisory lock FIRST. Both authority wrappers now take it before anything else, and onboarding
17
+ # takes it at :290 holding only board_invite_tokens and fleet_onboarding_invite_bounds — rows no
18
+ # authority wrapper ever touches — so the transactions are serialized per board rather than
19
+ # merely ordered. Mutual exclusion survives a new lock being added downstream; an ordering
20
+ # argument would have to be re-derived each time.
21
+ #
22
+ # THE "onboard" SESSION IS A LOCK-EQUIVALENT STAND-IN, and here is the equivalence, stated the
23
+ # way this suite requires rather than assumed.
24
+ # * It runs onboard_federated_fleet_agent's three cycle-relevant acquisitions, in its order
25
+ # and its modes: _lock_board_authority_ceiling(board) (:290), then `agents ... for update`
26
+ # (:349), then `board_memberships ... for update` (:397).
27
+ # * It omits that function's other locks — board_invite_tokens, fleet_onboarding_invite_bounds,
28
+ # boards, fleet_machines, fleet_trust_domains, the fingerprint-keyed advisory, auth.users,
29
+ # board_authority_ceilings, board_invite_redemptions, and the enrolled agent's own agents/
30
+ # agent_signing_keys rows. Every one of those is on a relation the grant wrapper never
31
+ # touches, so none can participate in a cycle with it; including them would change nothing
32
+ # observable and would require provisioning a full fleet invite, machine, trust domain and
33
+ # PoP-verified identity inside an isolation setup block.
34
+ # * A full onboarding replay was rejected for that reason alone, not because it would fail.
35
+ # The claim under test is a LOCK ORDER, and these are the locks.
36
+ # THE ADVISORY ACQUISITION IN THIS SESSION IS LOAD-BEARING, not decoration. Drop it and this
37
+ # spec deadlocks even against the FIXED wrapper — the stand-in would hold agents(signer) while
38
+ # taking no advisory lock, so the wrapper would sail through its own advisory acquisition and
39
+ # into the same cycle. A reader who "simplifies" this session by deleting the advisory step is
40
+ # not testing onboarding; they are testing a caller that does not exist, and they will read the
41
+ # resulting deadlock as a failed fix.
42
+ #
43
+ # WHY THE EXISTING signer_lifecycle_revoke.spec DOES NOT COVER THIS. Its three adversaries are
44
+ # revoke_agent_subtree, a bare suspend, and a key-only update. NONE of them takes a
45
+ # board_memberships lock, and board_memberships is one of the two operands in this cycle, so its
46
+ # green golden is silent here. That is why this is a separate spec rather than a permutation
47
+ # added there.
48
+
49
+ setup
50
+ {
51
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status)
52
+ values
53
+ ('f4940000-0000-4000-8000-000000000001','agent','agent','f4iso-signer','F4 ISO Signer/Creator','active'),
54
+ ('f4940000-0000-4000-8000-000000000002','agent','agent','f4iso-grantee','F4 ISO Grantee/Enrollee','active');
55
+
56
+ insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
57
+ values ('f4950000-0000-4000-8000-000000000001','f4-iso-board','F4 ISO Board',
58
+ 'f4940000-0000-4000-8000-000000000001','private','active');
59
+
60
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
61
+ values
62
+ ('f4950000-0000-4000-8000-000000000001','f4940000-0000-4000-8000-000000000001','owner','joined','f4940000-0000-4000-8000-000000000001'),
63
+ ('f4950000-0000-4000-8000-000000000001','f4940000-0000-4000-8000-000000000002','member','joined','f4940000-0000-4000-8000-000000000001');
64
+
65
+ insert into agent_board.agent_signing_keys(
66
+ id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
67
+ values ('f4960000-0000-4000-8000-000000000001','f4940000-0000-4000-8000-000000000001',
68
+ 'primary', 1, public.test_pubkey('f4-iso'), 'raw_ed25519_base64url',
69
+ public.test_key_fingerprint('f4-iso'), 'active', 'proven');
70
+ }
71
+
72
+ teardown
73
+ {
74
+ delete from agent_board.signature_evidence
75
+ where agent_id in (select id from agent_board.agents where handle like 'f4iso-%');
76
+ delete from agent_board.board_authority_ceilings where board_id='f4950000-0000-4000-8000-000000000001';
77
+ delete from agent_board.board_memberships where board_id='f4950000-0000-4000-8000-000000000001';
78
+ delete from agent_board.boards where id='f4950000-0000-4000-8000-000000000001';
79
+ delete from agent_board.agent_signing_keys
80
+ where agent_id in (select id from agent_board.agents where handle like 'f4iso-%');
81
+ delete from agent_board.agents where handle like 'f4iso-%';
82
+ }
83
+
84
+ # The real signed authority write, unmodified. Only the deterministic column is selected: the
85
+ # evidence id is a fresh uuid and must never reach a golden.
86
+ session "auth"
87
+ step "auth_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
88
+ step "auth_grant" {
89
+ select w.role as granted_role
90
+ from agent_board.signed_grant_board_role(
91
+ jsonb_build_object(
92
+ 'p_board_id', 'f4950000-0000-4000-8000-000000000001',
93
+ 'p_agent_id', 'f4940000-0000-4000-8000-000000000002',
94
+ 'p_grantee_role', 'admin'),
95
+ jsonb_build_object(
96
+ 'agent_id', 'f4940000-0000-4000-8000-000000000001',
97
+ 'signing_key_id', 'f4960000-0000-4000-8000-000000000001',
98
+ 'key_id', 'primary',
99
+ 'key_version', 1,
100
+ 'algorithm', 'Ed25519',
101
+ 'verification_status', 'verified',
102
+ 'payload_digest', 'sha256:f4iso',
103
+ 'enforcement_mode', 'require_for_agent_writes')) w;
104
+ }
105
+ step "auth_commit" { COMMIT; }
106
+
107
+ # onboard_federated_fleet_agent's cycle-relevant locks, in its order and modes. See the header
108
+ # for the equivalence argument; the advisory step is load-bearing.
109
+ session "onboard"
110
+ step "onboard_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
111
+ step "onboard_advisory" {
112
+ select agent_board._lock_board_authority_ceiling('f4950000-0000-4000-8000-000000000001') is null as advisory_taken;
113
+ }
114
+ step "onboard_agents" {
115
+ select count(*)::int as locked_agents from (
116
+ select 1 from agent_board.agents a
117
+ where a.id = 'f4940000-0000-4000-8000-000000000001' for update) s;
118
+ }
119
+ step "onboard_membership" {
120
+ select count(*)::int as locked_memberships from (
121
+ select 1 from agent_board.board_memberships bm
122
+ where bm.board_id = 'f4950000-0000-4000-8000-000000000001'
123
+ and bm.agent_id = 'f4940000-0000-4000-8000-000000000002'
124
+ order by bm.id for update) s;
125
+ }
126
+ step "onboard_commit" { COMMIT; }
127
+
128
+ session "check"
129
+ step "check" {
130
+ select
131
+ (select role from agent_board.board_memberships
132
+ where board_id = 'f4950000-0000-4000-8000-000000000001'
133
+ and agent_id = 'f4940000-0000-4000-8000-000000000002') as grantee_role,
134
+ (select count(*)::int from agent_board.signature_evidence
135
+ where operation = 'grant_board_role'
136
+ and agent_id = 'f4940000-0000-4000-8000-000000000001') as evidence_rows;
137
+ }
138
+
139
+ # 1. THE CYCLE, in the order that produced the deadlock. onboard takes the advisory lock and
140
+ # then the signer's agents row; the wrapper then runs. UNFIXED, the wrapper acquires the
141
+ # grantee membership row (its inner UPDATE) and blocks wanting agents FOR SHARE, onboard
142
+ # then wants that same membership row, and the server aborts one of them with `deadlock
143
+ # detected`. FIXED, the wrapper blocks at its FIRST statement — the advisory lock onboard
144
+ # already holds — so it holds nothing, onboard completes, and the grant proceeds after.
145
+ # The difference between those two worlds is the whole golden.
146
+ permutation "onboard_begin" "onboard_advisory" "onboard_agents" "auth_begin" "auth_grant" "onboard_membership" "onboard_commit" "auth_commit" "check"
147
+
148
+ # 2. The same operands with the wrapper first. It holds the advisory lock, so onboard waits at
149
+ # its OWN first step and never reaches the rows; once the wrapper commits, onboard proceeds.
150
+ # auth_commit is scheduled while onboard is still waiting on purpose — with it ordered after
151
+ # onboard's row steps the wait is unresolvable and isolationtester cancels the step, which
152
+ # records a `canceling statement due to user request` in the golden rather than the
153
+ # serialization this permutation exists to show.
154
+ permutation "auth_begin" "auth_grant" "onboard_begin" "onboard_advisory" "auth_commit" "onboard_agents" "onboard_membership" "onboard_commit" "check"
155
+
156
+ # 3. onboard holds the advisory lock alone, with no row locks yet. The wrapper must still block
157
+ # on it — this is what proves the acquisition is FIRST in the wrapper rather than merely
158
+ # present somewhere in it.
159
+ permutation "onboard_begin" "onboard_advisory" "auth_begin" "auth_grant" "onboard_agents" "onboard_membership" "onboard_commit" "auth_commit" "check"
@@ -0,0 +1,186 @@
1
+ # Consiliency/message-board#269 — a signed AUTHORITY write revalidates its signer inside
2
+ # its OWN transaction, and the lifecycle rows it reads are share-locked so a concurrent
3
+ # revoke/suspend serializes against the mutation instead of racing it.
4
+ #
5
+ # WHY THIS CANNOT BE A pgTAP FILE. The defect is a window BETWEEN two committed
6
+ # transactions: Portal verifies the signature and reads lifecycle in one PostgREST call,
7
+ # a revocation commits, and the wrapper runs in a second call. A pgTAP file is one
8
+ # rolled-back transaction and can only reach the predicate ("a suspended signer is
9
+ # refused"), never the interleaving or the blocking. Both are the point here, and both are
10
+ # observable in the trace: isolationtester prints `<waiting ...>` / `<... completed>`
11
+ # whenever a step blocks, so the lock is a byte in the golden rather than a claim.
12
+ #
13
+ # THE THREE ADVERSARIES, and why each is the real thing and not a stand-in.
14
+ # revoke_agent_subtree — the shipped cascade. Its primitive _revoke_agent_principal
15
+ # (20260901000100:1277, the NEWEST definition) locks the agents
16
+ # row FOR UPDATE and then the signing keys, which is why this
17
+ # helper takes agents first: matching the transaction the gate
18
+ # exists to serialize against is what keeps the order acyclic.
19
+ # suspend — a bare `update agent_board.agents set status='suspended'`. This
20
+ # is lock-identical to the platform-admin RPC revoke_agent
21
+ # (20260512000000:569), whose suspension is likewise a plain
22
+ # UPDATE of a non-key column taking FOR NO KEY UPDATE. It is used
23
+ # directly only to avoid provisioning a platform-admin JWT inside
24
+ # an isolation session; the lock interaction under test is the
25
+ # same byte for byte.
26
+ # keyrevoke — a key-only `update agent_board.agent_signing_keys set
27
+ # status='revoked'...`, which is the UPDATE statement
28
+ # revoke_fleet_subject's signing_key branch runs, minus the agent
29
+ # row lock that function takes first. Every shipped key-revoking
30
+ # path locks the agent first, so only a key-ONLY writer can pin
31
+ # the agent_signing_keys FOR SHARE at all -- and it must be a
32
+ # PLAIN update. issue_signing_key_challenge was tried here and
33
+ # rejected: it takes FOR UPDATE on the key, which also conflicts
34
+ # with the FOR KEY SHARE the evidence row's foreign key already
35
+ # takes, so it blocks whether or not this change exists and
36
+ # distinguishes nothing. A plain UPDATE takes FOR NO KEY UPDATE,
37
+ # which the FK's KEY SHARE does NOT block and FOR SHARE does --
38
+ # so this session, and only this session, makes the key lock and
39
+ # its MODE observable. The table's UPDATE privilege is the
40
+ # owner's alone (service_role has SELECT), so this models
41
+ # owner-level incident DML and any future key-only writer.
42
+ #
43
+ # GOLDEN CHANGE IN #276, recorded so it is not read as a #269 regression. Permutation 2's
44
+ # refusal line moved from `signer_not_active` to `fleet_revoked`. `revoke_agent_subtree`'s
45
+ # primitive produces BOTH conditions at once — it flips agents.status AND writes an
46
+ # agent-subject fleet_revocation_events row — and #276's gate, which lives inside
47
+ # grant_board_role, now runs before _assert_signed_authority_signer_live and wins the race to
48
+ # raise. The blocking behaviour this permutation exists for is unchanged (`<waiting ...>` /
49
+ # `<... completed>` are byte-identical), and `signer_not_active` is still exercised
50
+ # EXCLUSIVELY by permutations 3 and 4, whose `suspend` session touches only agents.status and
51
+ # writes no ledger row. Nothing here stopped being pinned.
52
+
53
+ setup
54
+ {
55
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status)
56
+ values
57
+ ('26940000-0000-4000-8000-000000000001','agent','agent','k269iso-signer','K269 ISO Signer','active'),
58
+ ('26940000-0000-4000-8000-000000000002','agent','agent','k269iso-grantee','K269 ISO Grantee','active'),
59
+ ('26940000-0000-4000-8000-000000000003','agent','agent','k269iso-revoker','K269 ISO Revoker','active');
60
+
61
+ insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
62
+ values ('26950000-0000-4000-8000-000000000001','k269-iso-board','K269 ISO Board',
63
+ '26940000-0000-4000-8000-000000000001','private','active');
64
+
65
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
66
+ values
67
+ ('26950000-0000-4000-8000-000000000001','26940000-0000-4000-8000-000000000001','owner','joined','26940000-0000-4000-8000-000000000001'),
68
+ ('26950000-0000-4000-8000-000000000001','26940000-0000-4000-8000-000000000002','member','joined','26940000-0000-4000-8000-000000000001');
69
+
70
+ insert into agent_board.agent_signing_keys(
71
+ id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
72
+ values ('26960000-0000-4000-8000-000000000001','26940000-0000-4000-8000-000000000001',
73
+ 'primary', 1, public.test_pubkey('k269-iso'), 'raw_ed25519_base64url',
74
+ public.test_key_fingerprint('k269-iso'), 'active', 'proven');
75
+ }
76
+
77
+ teardown
78
+ {
79
+ delete from agent_board.signature_evidence
80
+ where agent_id in (select id from agent_board.agents where handle like 'k269iso-%');
81
+ delete from agent_board.agent_signing_key_challenges
82
+ where agent_id in (select id from agent_board.agents where handle like 'k269iso-%');
83
+ delete from agent_board.fleet_revocation_events
84
+ where agent_id in (select id from agent_board.agents where handle like 'k269iso-%')
85
+ or revoked_by_agent_id in (select id from agent_board.agents where handle like 'k269iso-%');
86
+ delete from agent_board.board_authority_ceilings where board_id='26950000-0000-4000-8000-000000000001';
87
+ delete from agent_board.board_memberships where board_id='26950000-0000-4000-8000-000000000001';
88
+ delete from agent_board.boards where id='26950000-0000-4000-8000-000000000001';
89
+ delete from agent_board.agent_signing_keys
90
+ where agent_id in (select id from agent_board.agents where handle like 'k269iso-%');
91
+ delete from agent_board.agents where handle like 'k269iso-%';
92
+ }
93
+
94
+ # The signed authority write. The signature_evidence_id it returns is a fresh uuid, so only
95
+ # the deterministic column is selected — a golden must not contain a random value.
96
+ session "auth"
97
+ step "auth_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
98
+ step "auth_grant" {
99
+ select w.role as granted_role
100
+ from agent_board.signed_grant_board_role(
101
+ jsonb_build_object(
102
+ 'p_board_id', '26950000-0000-4000-8000-000000000001',
103
+ 'p_agent_id', '26940000-0000-4000-8000-000000000002',
104
+ 'p_grantee_role', 'admin'),
105
+ jsonb_build_object(
106
+ 'agent_id', '26940000-0000-4000-8000-000000000001',
107
+ 'signing_key_id', '26960000-0000-4000-8000-000000000001',
108
+ 'key_id', 'primary',
109
+ 'key_version', 1,
110
+ 'algorithm', 'Ed25519',
111
+ 'verification_status', 'verified',
112
+ 'payload_digest', 'sha256:k269',
113
+ 'enforcement_mode', 'require_for_agent_writes')) w;
114
+ }
115
+ step "auth_commit" { COMMIT; }
116
+
117
+ session "revoke"
118
+ step "revoke_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
119
+ step "revoke_signer" {
120
+ select revoked_agent_count, revoked_key_count
121
+ from agent_board.revoke_agent_subtree(
122
+ '26940000-0000-4000-8000-000000000001',
123
+ '26940000-0000-4000-8000-000000000003',
124
+ 'k269 iso revoke');
125
+ }
126
+ step "revoke_commit" { COMMIT; }
127
+
128
+ session "suspend"
129
+ step "suspend_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
130
+ step "suspend_signer" {
131
+ update agent_board.agents set status = 'suspended', updated_at = now()
132
+ where id = '26940000-0000-4000-8000-000000000001';
133
+ }
134
+ step "suspend_commit" { COMMIT; }
135
+
136
+ # Key-only writer: a PLAIN update of the signing key, touching no agents row.
137
+ session "keyrevoke"
138
+ step "keyrevoke_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
139
+ step "keyrevoke_key" {
140
+ update agent_board.agent_signing_keys
141
+ set status = 'revoked', proof_state = 'revoked', revoked_at = now(), updated_at = now()
142
+ where id = '26960000-0000-4000-8000-000000000001';
143
+ }
144
+ step "keyrevoke_commit" { COMMIT; }
145
+
146
+ session "check"
147
+ step "check" {
148
+ select
149
+ (select role from agent_board.board_memberships
150
+ where board_id = '26950000-0000-4000-8000-000000000001'
151
+ and agent_id = '26940000-0000-4000-8000-000000000002') as grantee_role,
152
+ (select status from agent_board.agents
153
+ where id = '26940000-0000-4000-8000-000000000001') as signer_status,
154
+ (select status from agent_board.agent_signing_keys
155
+ where id = '26960000-0000-4000-8000-000000000001') as key_status,
156
+ (select count(*) from agent_board.signature_evidence
157
+ where operation = 'grant_board_role') as evidence_rows;
158
+ }
159
+
160
+ # 1. Write first: the revocation must WAIT on the share-locked lifecycle rows rather than
161
+ # committing underneath a grant that has already been authorized. The grant stands and
162
+ # the revocation lands after it.
163
+ permutation "auth_begin" "auth_grant" "revoke_begin" "revoke_signer" "auth_commit" "revoke_commit" "check"
164
+
165
+ # 2. The defect's own interleaving, in the order that exposes it. The revocation is in
166
+ # flight when the wrapper runs; the wrapper blocks on the agents row, then observes the
167
+ # committed revocation and fails closed. Before #269 this permutation completed the
168
+ # grant: the wrapper read no lifecycle at all, so there was nothing to block on.
169
+ permutation "revoke_begin" "revoke_signer" "auth_begin" "auth_grant" "revoke_commit" "auth_commit" "check"
170
+
171
+ # 3. Suspension, which touches the agents row and NOT the key — so it isolates the agents
172
+ # FOR SHARE from the key FOR SHARE.
173
+ permutation "suspend_begin" "suspend_signer" "auth_begin" "auth_grant" "suspend_commit" "auth_commit" "check"
174
+
175
+ # 4. Suspension the other way round: the write holds the agents row and the suspension waits.
176
+ permutation "auth_begin" "auth_grant" "suspend_begin" "suspend_signer" "auth_commit" "suspend_commit" "check"
177
+
178
+ # 5. The key half, isolated. Nothing here touches the agents row, so the wait is produced by
179
+ # the agent_signing_keys FOR SHARE and by nothing else -- and by its MODE: FOR KEY SHARE
180
+ # would not conflict with this plain UPDATE's FOR NO KEY UPDATE.
181
+ permutation "auth_begin" "auth_grant" "keyrevoke_begin" "keyrevoke_key" "auth_commit" "keyrevoke_commit" "check"
182
+
183
+ # 6. And the key revocation's own race: revoked in flight, the authority write blocks on the
184
+ # key row, then observes the committed revocation and fails closed -- the key-side twin of
185
+ # permutation 2.
186
+ permutation "keyrevoke_begin" "keyrevoke_key" "auth_begin" "auth_grant" "keyrevoke_commit" "auth_commit" "check"