@consiliency/agent-board-schema 1.28.0 → 1.38.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.
Files changed (43) hide show
  1. package/fixtures/already-applied.json +40 -0
  2. package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
  3. package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
  4. package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
  5. package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
  6. package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
  7. package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
  8. package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
  9. package/migrations/20260917000100_agent_board_invite_no_downgrade.sql +398 -0
  10. package/migrations/20260918000100_agent_board_signed_write_enforced_repoint.sql +139 -0
  11. package/migrations/20260919000100_agent_board_signed_write_model_key.sql +107 -0
  12. package/package.json +1 -1
  13. package/schema-manifest.json +93 -3
  14. package/src/deployment-drift-script.test.mjs +424 -0
  15. package/src/deployment-drift.test.mjs +301 -0
  16. package/src/tooling.test.mjs +83 -2
  17. package/tests/agent_board_atomic_fleet_onboarding.test.sql +144 -29
  18. package/tests/agent_board_bundle_endpoint_compat.test.sql +73 -18
  19. package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
  20. package/tests/agent_board_consent_substrate.test.sql +8 -1
  21. package/tests/agent_board_enroll_resumption.test.sql +7 -1
  22. package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
  23. package/tests/agent_board_fedsession.test.sql +17 -2
  24. package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
  25. package/tests/agent_board_fk_lock_edges.test.sql +422 -0
  26. package/tests/agent_board_invite_no_downgrade.test.sql +689 -0
  27. package/tests/agent_board_ledger_authority.test.sql +142 -71
  28. package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
  29. package/tests/agent_board_signed_write_enforced.test.sql +366 -0
  30. package/tests/agent_board_signer_lifecycle.test.sql +23 -6
  31. package/tests/agent_board_source_strip_helper.test.sql +256 -1
  32. package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
  33. package/tests/agent_board_write_enforcement_class.test.sql +1016 -0
  34. package/tests/catalog/p_metadata_parameters.json +12 -0
  35. package/tests/fixtures/isolation-substrate.sql +1 -1
  36. package/tests/fixtures/test-prereqs.sql +442 -11
  37. package/tests/isolation/expected/ledger_authority_revoke.out +219 -1
  38. package/tests/isolation/expected/task_thread_lock_order.out +203 -0
  39. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
  40. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
  41. package/tests/isolation/specs/ledger_authority_revoke.spec +115 -0
  42. package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
  43. package/tests/seeds/20260916000100_agent_board_federated_identity_injective_slug.sql.pre.sql +196 -0
@@ -0,0 +1,422 @@
1
+ -- Consiliency/message-board#281 pgTAP: the FK-IMPLIED lock edges that no body
2
+ -- scan can see, and the ABBA cycles they expose.
3
+ --
4
+ -- THE DEFECT THIS FILE EXISTS FOR. Every lock-order guard in this repo found
5
+ -- locks by scanning a function body for the literal text `for update` /
6
+ -- `for share`. A lock PostgreSQL takes on our behalf through a FOREIGN KEY never
7
+ -- appears in that text. So `agent_board_signer_lifecycle.test.sql` section 9,
8
+ -- whose comment claimed "any function that row-locks BOTH agents and
9
+ -- board_memberships takes the board advisory lock", was structurally unable to
10
+ -- deliver that closure: it saw 3 members where the FK-aware derivation sees 28.
11
+ -- One of the 25 it could not see -- `_upsert_board_membership_state`, the funnel
12
+ -- every membership writer goes through -- is one half of a live ABBA against
13
+ -- `onboard_federated_fleet_agent` (FKL-11).
14
+ --
15
+ -- WHAT IS ASSERTED HERE, AND WHAT IS NOT.
16
+ -- * The detector is agent_board_test.lock_edges(), defined in
17
+ -- tests/fixtures/test-prereqs.sql. Its header states exactly which parts are
18
+ -- catalog-derived (pg_constraint / pg_attribute / pg_class, by OID) and which
19
+ -- are regexes over agent_board_test.stripped_body(). Read it before reading a
20
+ -- green run here as a closure claim.
21
+ -- * FKL-9/10/11 are EXACT SETS of a state that is KNOWN-BAD and deliberately
22
+ -- NOT FIXED in this change (#281 splits the tooling from the ordering
23
+ -- decision on purpose -- adding an ordering change to a lock-order PR under
24
+ -- review is how the #269 F4 deadlock shipped). They are written as exact sets
25
+ -- rather than as a "no cycles" claim marked `# TODO`, because the suite runner
26
+ -- EXCLUDES `# TODO` lines from its failure grep: a TODO-marked "no cycles"
27
+ -- assertion would let a NEW cycle ship silently. An exact set makes a new
28
+ -- member fail and NAME ITSELF while the known ones stay pinned.
29
+ -- * FKL-12..16 are the mutation pins for the detector's six acquisition shapes.
30
+ -- They use PROBE functions created inside this transaction, so each shape is
31
+ -- exercised by a body written to exercise exactly it -- and the fk-insert
32
+ -- probe carries the contrast that IS this issue's deliverable: the old text
33
+ -- scan reports nothing, the new detector reports the edge.
34
+ --
35
+ -- ORDERING INSIDE THIS FILE IS LOAD-BEARING. The probes are agent_board functions
36
+ -- and would join the class and cycle sets, so every exact-set assertion runs
37
+ -- BEFORE any probe is created.
38
+ create extension if not exists pgtap with schema extensions;
39
+ set search_path = extensions, public, agent_board;
40
+
41
+ begin;
42
+
43
+ select plan(16);
44
+
45
+ -- ---------------------------------------------------------------------------
46
+ -- The derivation, materialized once. `first_path` is the position PATH of the
47
+ -- first acquisition (the caller's call-site offset prepended to the callee's own
48
+ -- offset, so ordering survives nesting); `strongest` is the strongest mode the
49
+ -- function takes on that relation anywhere, which over-reports conflicts rather
50
+ -- than under-reporting them.
51
+ -- ---------------------------------------------------------------------------
52
+ create temp table _le on commit drop as select * from agent_board_test.lock_edges();
53
+ create temp table _cl on commit drop as select * from agent_board_test.fn_call_closure();
54
+
55
+ create temp table _acq on commit drop as
56
+ select fn_oid, fn_name, fn_sig, rel_oid, rel_name,
57
+ min(path) as first_path,
58
+ (array_agg(lock_mode order by agent_board_test.row_lock_rank(lock_mode) desc))[1] as strongest
59
+ from _le
60
+ group by fn_oid, fn_name, fn_sig, rel_oid, rel_name;
61
+
62
+ -- Advisory serialisation: `_lock_board_authority_ceiling` is the mutual-exclusion
63
+ -- lever section 9 requires of its members. Two routes that both take it cannot
64
+ -- interleave, so their ordering disagreement is not a cycle.
65
+ create temp table _adv on commit drop as
66
+ select root_oid, bool_or(reached_name = '_lock_board_authority_ceiling') as adv
67
+ from _cl group by root_oid;
68
+
69
+ create temp table _abba on commit drop as
70
+ select fa.fn_name as f, ga.fn_name as g,
71
+ fa.rel_name as rel_a, fb.rel_name as rel_b,
72
+ fa.strongest as f_a, fb.strongest as f_b,
73
+ ga.strongest as g_a, gb.strongest as g_b,
74
+ (coalesce(af.adv, false) and coalesce(ag.adv, false)) as both_advisory
75
+ from _acq fa
76
+ join _acq fb on fb.fn_oid = fa.fn_oid and fb.rel_oid <> fa.rel_oid
77
+ and fa.first_path < fb.first_path
78
+ join _acq ga on ga.rel_oid = fa.rel_oid and ga.fn_oid <> fa.fn_oid
79
+ join _acq gb on gb.fn_oid = ga.fn_oid and gb.rel_oid = fb.rel_oid
80
+ and gb.first_path < ga.first_path
81
+ left join _adv af on af.root_oid = fa.fn_oid
82
+ left join _adv ag on ag.root_oid = ga.fn_oid
83
+ where agent_board_test.row_locks_conflict(fa.strongest, ga.strongest)
84
+ and agent_board_test.row_locks_conflict(fb.strongest, gb.strongest)
85
+ and fa.fn_oid < ga.fn_oid;
86
+
87
+ -- ===========================================================================
88
+ -- FKL-1: the FK edges the whole derivation rests on. If one is renamed or
89
+ -- dropped the class silently empties and every other assertion here passes
90
+ -- VACUOUSLY, so assert them POSITIVELY by name rather than by absence.
91
+ -- ===========================================================================
92
+ select is(
93
+ (select string_agg(c.conname, ', ' order by c.conname)
94
+ from pg_constraint c
95
+ where c.contype = 'f'
96
+ and ((c.confrelid = 'agent_board.agents'::regclass
97
+ and c.conrelid in ('agent_board.board_membership_events'::regclass,
98
+ 'agent_board.board_memberships'::regclass))
99
+ or (c.confrelid = 'agent_board.boards'::regclass
100
+ and c.conrelid = 'agent_board.board_memberships'::regclass))),
101
+ 'board_membership_events_agent_id_fkey, board_memberships_agent_id_fkey, '
102
+ || 'board_memberships_board_id_fkey, board_memberships_created_by_agent_id_fkey',
103
+ 'FKL-1: the invisible FK edges (membership_events->agents, memberships->agents/boards) all exist');
104
+
105
+ -- ===========================================================================
106
+ -- FKL-2: the row-lock conflict matrix. Without it the cycle enumeration is
107
+ -- worthless: over a schema this FK-dense, a mode-blind ABBA search reports every
108
+ -- pair of functions that INSERT into two tables in opposite order, and two
109
+ -- FOR KEY SHARE locks never conflict. The pair that matters for #281 is
110
+ -- (key share, update) -- an FK-implied lock against an explicit one.
111
+ -- ===========================================================================
112
+ select is(
113
+ (select string_agg(a || '/' || b || '=' || agent_board_test.row_locks_conflict(a, b)::text, ' ' order by a, b)
114
+ from unnest(array['key share','share','no key update','update']) a,
115
+ unnest(array['key share','share','no key update','update']) b),
116
+ 'key share/key share=false key share/no key update=false key share/share=false key share/update=true '
117
+ || 'no key update/key share=false no key update/no key update=true no key update/share=true no key update/update=true '
118
+ || 'share/key share=false share/no key update=true share/share=false share/update=true '
119
+ || 'update/key share=true update/no key update=true update/share=true update/update=true',
120
+ 'FKL-2: the row-lock conflict matrix is PostgreSQL''s, incl. KEY SHARE conflicting only with UPDATE');
121
+
122
+ -- ===========================================================================
123
+ -- FKL-3 / FKL-4: THE BLIND SPOT AND ITS CLOSURE, on the real function.
124
+ --
125
+ -- `_emit_membership_event` inserts into board_membership_events and therefore
126
+ -- row-locks the subject's `agents` row FOR KEY SHARE. It never NAMES agents.
127
+ -- FKL-3 asserts the old detector's blindness POSITIVELY (its two section-9
128
+ -- regexes, verbatim, both false); FKL-4 asserts the new detector's exact edge
129
+ -- set. The two together are the contrast this issue asked for.
130
+ -- ===========================================================================
131
+ select is(
132
+ (select (agent_board_test.stripped_body(p.oid) ~ 'agent_board\.agents[^;]*for (update|share)')::text
133
+ || '/' ||
134
+ (agent_board_test.stripped_body(p.oid) ~ '\magents\M')::text
135
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
136
+ where n.nspname = 'agent_board' and p.proname = '_emit_membership_event'),
137
+ 'false/false',
138
+ 'FKL-3: the section-9 text scan finds no agents lock in _emit_membership_event -- it never names agents');
139
+
140
+ select is(
141
+ (select string_agg(distinct e.rel_name || ' ' || e.lock_mode || ' via ' || e.via, ' | '
142
+ order by e.rel_name || ' ' || e.lock_mode || ' via ' || e.via)
143
+ from _le e where e.fn_name = '_emit_membership_event' and e.own),
144
+ 'agent_board.agents key share via fk-insert:board_membership_events_agent_id_fkey'
145
+ || ' | agent_board.boards key share via fk-insert:board_membership_events_board_id_fkey',
146
+ 'FKL-4: the FK detector reports the agents KEY SHARE the text scan cannot see, named by constraint');
147
+
148
+ -- ===========================================================================
149
+ -- FKL-5: POSITION PATHS THROUGH A CALLEE. `join_board` takes both locks inside
150
+ -- ONE call to `_upsert_board_membership_state`, so comparing bare call-site
151
+ -- offsets ties and the ordering that decides the cycle is invisible. The path
152
+ -- array must resolve it to memberships-BEFORE-agents.
153
+ -- ===========================================================================
154
+ select is(
155
+ (select case when m.first_path < a.first_path then 'memberships-first' else 'agents-first' end
156
+ || ' (' || m.strongest || '/' || a.strongest || ')'
157
+ from _acq m join _acq a on a.fn_oid = m.fn_oid
158
+ where m.fn_name = 'join_board'
159
+ and m.rel_name = 'agent_board.board_memberships'
160
+ and a.rel_name = 'agent_board.agents'),
161
+ 'memberships-first (update/key share)',
162
+ 'FKL-5: join_board''s order is resolved THROUGH the callee frame, not by a tied call-site offset');
163
+
164
+ -- ===========================================================================
165
+ -- FKL-6 / FKL-7 / FKL-8: the three ways this detector could under-report
166
+ -- SILENTLY. Each is a tripwire that is true today and fails loudly the day it
167
+ -- stops being, rather than a caveat in prose.
168
+ -- ===========================================================================
169
+ select is(
170
+ (select coalesce(string_agg(p.proname, ', ' order by p.proname), '')
171
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
172
+ where n.nspname = 'agent_board' and p.prosqlbody is not null),
173
+ '',
174
+ 'FKL-6: no agent_board function has a BEGIN ATOMIC body -- readable since message-board#293, but only as a DEPARSED tree this detector''s positional/path semantics have never been exercised against');
175
+
176
+ select is(
177
+ (select coalesce(string_agg(distinct e.fn_name, ', ' order by e.fn_name), '')
178
+ from _le e join pg_proc p on p.oid = e.fn_oid
179
+ where agent_board_test.stripped_body(p.oid) ~* '\mexecute\M'),
180
+ '',
181
+ 'FKL-7: no function with a lock edge uses dynamic SQL, which the position analysis cannot read');
182
+
183
+ select is(
184
+ (select coalesce(string_agg(distinct e.fn_name, ', ' order by e.fn_name), '')
185
+ from _le e
186
+ where e.fn_oid in (select t.tgfoid from pg_trigger t
187
+ join pg_class cl on cl.oid = t.tgrelid
188
+ join pg_namespace n on n.oid = cl.relnamespace
189
+ where not t.tgisinternal and n.nspname = 'agent_board')),
190
+ '',
191
+ 'FKL-8: no trigger function on an agent_board table takes a lock edge -- trigger locks are NOT folded in');
192
+
193
+ -- ===========================================================================
194
+ -- FKL-9: THE CLASS section 9 could not compute. Every function whose lock graph
195
+ -- contains BOTH agents and board_memberships, with the order it takes them and
196
+ -- whether the board advisory lock serialises it. Section 9's text scan sees
197
+ -- three of these; the FK-aware derivation sees 28.
198
+ --
199
+ -- This is an exact set of a KNOWN-BAD state. 25 of the 28 are `[unserialised]`
200
+ -- and disagree with each other about the order. #281 stops here deliberately:
201
+ -- the ordering fix is a separate decision. Do not "green" this by editing the
202
+ -- expectation -- a changed member means the lock graph changed.
203
+ -- ===========================================================================
204
+ select is(
205
+ (select array_agg(m order by m) from (
206
+ select distinct a.fn_name || ': ' ||
207
+ case when a.first_path < b.first_path
208
+ then 'agents(' || a.strongest || ') then memberships(' || b.strongest || ')'
209
+ else 'memberships(' || b.strongest || ') then agents(' || a.strongest || ')' end ||
210
+ case when coalesce(v.adv, false) then ' [advisory]' else ' [unserialised]' end as m
211
+ from _acq a
212
+ join _acq b on b.fn_oid = a.fn_oid
213
+ left join _adv v on v.root_oid = a.fn_oid
214
+ where a.rel_name = 'agent_board.agents'
215
+ and b.rel_name = 'agent_board.board_memberships') z),
216
+ array[
217
+ '_enroll_federated_identity_once: agents(key share) then memberships(key share) [unserialised]',
218
+ '_redeem_fleet_invite_for_agent: memberships(key share) then agents(key share) [unserialised]',
219
+ '_redeem_invite_for_agent: memberships(key share) then agents(key share) [unserialised]',
220
+ '_redeem_unbounded_invite_for_agent: memberships(key share) then agents(key share) [unserialised]',
221
+ '_upsert_board_membership_state: memberships(update) then agents(key share) [unserialised]',
222
+ '_upsert_delivery_recipient: memberships(key share) then agents(key share) [unserialised]',
223
+ 'create_tasking_delivery: agents(key share) then memberships(key share) [unserialised]',
224
+ 'enroll_federated_identity: agents(update) then memberships(key share) [unserialised]',
225
+ 'grant_board_role: agents(share) then memberships(update) [advisory]',
226
+ 'invite_board_member: memberships(update) then agents(key share) [unserialised]',
227
+ 'join_board: memberships(update) then agents(key share) [unserialised]',
228
+ 'leave_board: memberships(update) then agents(key share) [unserialised]',
229
+ 'onboard_federated_fleet_agent: agents(update) then memberships(update) [advisory]',
230
+ 'post_board_message: agents(key share) then memberships(key share) [unserialised]',
231
+ 'redeem_invite_link: memberships(key share) then agents(key share) [unserialised]',
232
+ 'request_join_board: memberships(update) then agents(key share) [unserialised]',
233
+ 'resolve_board_request: memberships(update) then agents(key share) [unserialised]',
234
+ 'send_direct_message: agents(key share) then memberships(key share) [unserialised]',
235
+ 'send_multicast_message: agents(key share) then memberships(key share) [unserialised]',
236
+ 'signed_create_tasking_delivery: agents(key share) then memberships(key share) [unserialised]',
237
+ 'signed_grant_board_role: agents(share) then memberships(update) [advisory]',
238
+ 'signed_post_board_message: agents(key share) then memberships(key share) [unserialised]',
239
+ 'signed_send_direct_message: agents(key share) then memberships(key share) [unserialised]',
240
+ 'signed_send_multicast_message: agents(key share) then memberships(key share) [unserialised]',
241
+ 'subscribe_callback_endpoint: memberships(key share) then agents(key share) [unserialised]',
242
+ 'unsubscribe_callback_endpoint: memberships(key share) then agents(key share) [unserialised]',
243
+ 'update_board_membership: memberships(update) then agents(key share) [unserialised]'
244
+ ]::text[],
245
+ 'FKL-9: the agents+board_memberships lock class, FK edges included (section 9''s text scan sees 3 of these)');
246
+
247
+ -- ===========================================================================
248
+ -- FKL-10: every RELATION PAIR that some pair of routes acquires in opposite
249
+ -- orders with CONFLICTING modes and no shared advisory lock. Pinned at the
250
+ -- relation-pair level rather than the function-pair level (146 of those today)
251
+ -- because a pair of tables entering this set is the reviewable event.
252
+ --
253
+ -- KNOWN-BAD and deliberately not fixed -- see the file header. ONE pair has been
254
+ -- removed since: #281 R1 fixed `agent_board.tasks <-> agent_board.threads` by
255
+ -- agreeing on threads-before-tasks in append_task_event and
256
+ -- _upsert_delivery_recipient (migration 20260913000100). That took the row count
257
+ -- from 186 to 146 -- exactly the 40 rows on that pair, with no other pair moving
258
+ -- by a single row -- and this array from 27 entries to 26. The ordering itself is
259
+ -- pinned as a class in agent_board_task_thread_lock_order.test.sql (TT-1), which
260
+ -- is what makes a REGRESSION fail there and name the function, rather than only
261
+ -- failing here as an opaque re-appearance of the pair.
262
+ -- ===========================================================================
263
+ select is(
264
+ (select array_agg(p order by p) from (
265
+ select distinct least(rel_a, rel_b) || ' <-> ' || greatest(rel_a, rel_b) as p
266
+ from _abba where not both_advisory) z),
267
+ array[
268
+ 'agent_board.agent_signing_keys <-> agent_board.agents',
269
+ 'agent_board.agent_signing_keys <-> agent_board.board_memberships',
270
+ 'agent_board.agent_signing_keys <-> agent_board.fleet_machine_agents',
271
+ 'agent_board.agent_signing_keys <-> agent_board.fleet_machines',
272
+ 'agent_board.agent_signing_keys <-> agent_board.fleet_trust_domains',
273
+ 'agent_board.agents <-> agent_board.board_invite_tokens',
274
+ 'agent_board.agents <-> agent_board.board_memberships',
275
+ 'agent_board.agents <-> agent_board.boards',
276
+ 'agent_board.agents <-> agent_board.delegation_grants',
277
+ 'agent_board.agents <-> agent_board.fleet_machine_agents',
278
+ 'agent_board.agents <-> agent_board.fleet_machines',
279
+ 'agent_board.agents <-> agent_board.fleet_trust_domains',
280
+ 'agent_board.agents <-> agent_board.identity_providers',
281
+ 'agent_board.agents <-> auth.users',
282
+ 'agent_board.board_invite_tokens <-> agent_board.boards',
283
+ 'agent_board.board_invite_tokens <-> agent_board.fleet_machines',
284
+ 'agent_board.board_invite_tokens <-> agent_board.fleet_trust_domains',
285
+ 'agent_board.board_memberships <-> agent_board.boards',
286
+ 'agent_board.boards <-> auth.users',
287
+ 'agent_board.callback_endpoints <-> agent_board.callback_outbox_events',
288
+ 'agent_board.fleet_harness_registrations <-> agent_board.fleet_machine_agents',
289
+ 'agent_board.fleet_machine_agents <-> agent_board.fleet_repo_scopes',
290
+ 'agent_board.fleet_machine_agents <-> agent_board.fleet_trust_domains',
291
+ 'agent_board.fleet_machines <-> auth.users',
292
+ 'agent_board.fleet_trust_domains <-> auth.users',
293
+ 'agent_board.identity_providers <-> auth.users'
294
+ ]::text[],
295
+ 'FKL-10: the relation pairs held in opposite orders with conflicting modes and no shared advisory lock');
296
+
297
+ -- ===========================================================================
298
+ -- FKL-11: THE CYCLE #281 WAS FILED FOR, asserted as a concrete claim rather
299
+ -- than as a member of the set above. `_upsert_board_membership_state` is the
300
+ -- funnel every membership writer goes through; its `agents` edge is FK-implied
301
+ -- and invisible to section 9. `onboard_federated_fleet_agent` takes the same two
302
+ -- relations in the opposite order, explicitly, with FOR UPDATE.
303
+ -- ===========================================================================
304
+ select is(
305
+ (select f || ' ' || rel_a || '(' || f_a || ')->' || rel_b || '(' || f_b || ') ABBA '
306
+ || g || ' ' || rel_b || '(' || g_b || ')->' || rel_a || '(' || g_a || ') advisory=' || both_advisory::text
307
+ from _abba
308
+ where f = '_upsert_board_membership_state' and g = 'onboard_federated_fleet_agent'
309
+ and least(rel_a, rel_b) = 'agent_board.agents'
310
+ and greatest(rel_a, rel_b) = 'agent_board.board_memberships'),
311
+ '_upsert_board_membership_state agent_board.board_memberships(update)->agent_board.agents(key share)'
312
+ || ' ABBA onboard_federated_fleet_agent agent_board.agents(update)->agent_board.board_memberships(update)'
313
+ || ' advisory=false',
314
+ 'FKL-11: the #281 ABBA is present and unserialised -- reported, NOT fixed here');
315
+
316
+ -- ===========================================================================
317
+ -- THE PROBES. From here on, agent_board gains four functions that exist only to
318
+ -- exercise one acquisition shape each. They are never CALLED -- the detector
319
+ -- reads bodies -- and the transaction is rolled back. Every exact-set assertion
320
+ -- above has already run.
321
+ -- ===========================================================================
322
+
323
+ -- Shape (iv): the acquisition that no body scan can see. Its ONLY contact with
324
+ -- agent_board.agents is the foreign key on the table it inserts into.
325
+ create function agent_board._probe281_fk_insert(p_board uuid, p_agent uuid)
326
+ returns void language plpgsql as $probe$
327
+ begin
328
+ insert into agent_board.board_membership_events (board_id, agent_id, event_kind, result_state)
329
+ values (p_board, p_agent, 'probe281', 'joined');
330
+ end;
331
+ $probe$;
332
+
333
+ -- Shapes (ii) + (v): the UPDATE row-locks board_memberships itself, and because
334
+ -- the SET re-points agent_id it also locks the NEW agents row. It must NOT be
335
+ -- credited with the board_id or created_by_agent_id parents -- those columns are
336
+ -- not in the SET, and crediting every FK of an updated table is what turns this
337
+ -- detector into a cycle-fabricator.
338
+ create function agent_board._probe281_fk_update(p_id uuid, p_agent uuid)
339
+ returns void language plpgsql as $probe$
340
+ begin
341
+ update agent_board.board_memberships set agent_id = p_agent where id = p_id;
342
+ end;
343
+ $probe$;
344
+
345
+ -- Shapes (iii) + (vi): DELETE row-locks the deleted rows, and locks the
346
+ -- REFERENCING child rows in a mode that follows confdeltype -- no action => KEY
347
+ -- SHARE (restrict takes the same branch), set null => the child row is updated,
348
+ -- cascade => the child row is deleted.
349
+ create function agent_board._probe281_fk_delete(p_id uuid, p_scope uuid)
350
+ returns void language plpgsql as $probe$
351
+ begin
352
+ delete from agent_board.board_memberships where id = p_id;
353
+ delete from agent_board.fleet_repo_scopes where id = p_scope;
354
+ end;
355
+ $probe$;
356
+
357
+ -- Shape (i): an explicit row lock, with its MODE captured rather than reduced to
358
+ -- a boolean -- a downgrade from FOR UPDATE to FOR SHARE changes which pairs
359
+ -- conflict, so a mode-less detector cannot enumerate cycles at all.
360
+ create function agent_board._probe281_textual(p_agent uuid)
361
+ returns void language plpgsql as $probe$
362
+ begin
363
+ perform 1 from agent_board.agents a where a.id = p_agent for share;
364
+ end;
365
+ $probe$;
366
+
367
+ -- The derivation, re-evaluated ONCE now that the probes exist. (Re-evaluating it
368
+ -- per assertion is correct but costs a full re-scan of every body each time.)
369
+ create temp table _lep on commit drop as select * from agent_board_test.lock_edges();
370
+
371
+ -- FKL-12/13: the CONTRAST, on a body built to isolate it. Same two claims as
372
+ -- FKL-3/FKL-4, but on a function whose every other line is inert, so a pass
373
+ -- cannot be borrowed from some other statement in a large real body.
374
+ select is(
375
+ (select (agent_board_test.stripped_body(p.oid) ~ 'agent_board\.agents[^;]*for (update|share)')::text
376
+ || '/' ||
377
+ (agent_board_test.stripped_body(p.oid) ~ 'agent_board\.board_memberships[^;]*for (update|share)')::text
378
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
379
+ where n.nspname = 'agent_board' and p.proname = '_probe281_fk_insert'),
380
+ 'false/false',
381
+ 'FKL-12: the section-9 text scan reports the fk-insert probe as locking NOTHING');
382
+
383
+ select is(
384
+ (select string_agg(distinct e.rel_name || ' ' || e.lock_mode || ' via ' || e.via, ' | '
385
+ order by e.rel_name || ' ' || e.lock_mode || ' via ' || e.via)
386
+ from _lep e where e.fn_name = '_probe281_fk_insert'),
387
+ 'agent_board.agents key share via fk-insert:board_membership_events_agent_id_fkey'
388
+ || ' | agent_board.boards key share via fk-insert:board_membership_events_board_id_fkey',
389
+ 'FKL-13: the FK detector reports both FK-implied KEY SHARE edges of that same probe');
390
+
391
+ select is(
392
+ (select string_agg(distinct e.rel_name || ' ' || e.lock_mode || ' via ' || e.via, ' | '
393
+ order by e.rel_name || ' ' || e.lock_mode || ' via ' || e.via)
394
+ from _lep e where e.fn_name = '_probe281_fk_update'),
395
+ 'agent_board.agents key share via fk-update:board_memberships_agent_id_fkey'
396
+ || ' | agent_board.board_memberships update via write-update',
397
+ 'FKL-14: an FK re-point locks the new parent and the updated table -- and ONLY the re-pointed FK');
398
+
399
+ select is(
400
+ (select string_agg(distinct e.rel_name || ' ' || e.lock_mode || ' via ' || e.via, ' | '
401
+ order by e.rel_name || ' ' || e.lock_mode || ' via ' || e.via)
402
+ from _lep e where e.fn_name = '_probe281_fk_delete'),
403
+ 'agent_board.board_invite_redemptions key share via fk-delete:board_invite_redemptions_membership_id_fkey'
404
+ || ' | agent_board.board_memberships update via write-delete'
405
+ || ' | agent_board.callback_subscriptions key share via fk-delete:callback_subscriptions_board_membership_fkey'
406
+ || ' | agent_board.delivery_recipients key share via fk-delete:delivery_recipients_board_membership_fkey'
407
+ || ' | agent_board.fleet_machine_agents no key update via fk-delete:fleet_machine_agents_repo_scope_id_fkey'
408
+ || ' | agent_board.fleet_repo_scopes update via write-delete'
409
+ || ' | agent_board.fleet_revocation_events update via fk-delete:fleet_revocation_events_repo_scope_id_fkey'
410
+ || ' | agent_board.fleet_trust_sets update via fk-delete:fleet_trust_sets_repo_scope_id_fkey',
411
+ 'FKL-15: DELETE locks the deleted rows and the referencing children, in the mode confdeltype implies');
412
+
413
+ select is(
414
+ (select string_agg(distinct e.rel_name || ' ' || e.lock_mode || ' via ' || e.via, ' | '
415
+ order by e.rel_name || ' ' || e.lock_mode || ' via ' || e.via)
416
+ from _lep e where e.fn_name = '_probe281_textual'),
417
+ 'agent_board.agents share via textual',
418
+ 'FKL-16: an explicit row lock is reported with its MODE, not as a boolean');
419
+
420
+ select * from finish();
421
+
422
+ rollback;