@consiliency/agent-board-schema 1.10.0 → 1.22.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 (109) hide show
  1. package/fixtures/already-applied.json +136 -0
  2. package/migrations/20260729000100_agent_board_fedsession_autojoin_default_board.sql +364 -0
  3. package/migrations/20260730000100_agent_board_gatenull_service_role_bypass.sql +369 -0
  4. package/migrations/20260730000200_agent_board_membstate_authz.sql +195 -0
  5. package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
  6. package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
  7. package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
  8. package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
  9. package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
  10. package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
  11. package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
  12. package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
  13. package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
  14. package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
  15. package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
  16. package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
  17. package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
  18. package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
  19. package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
  20. package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
  21. package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
  22. package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
  23. package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
  24. package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
  25. package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
  26. package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
  27. package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
  28. package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
  29. package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
  30. package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
  31. package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
  32. package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
  33. package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
  34. package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
  35. package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
  36. package/package.json +1 -1
  37. package/schema-manifest.json +309 -3
  38. package/src/tooling.test.mjs +123 -2
  39. package/tests/agent_board_actorspoof.test.sql +349 -0
  40. package/tests/agent_board_agentsdir.test.sql +154 -0
  41. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  42. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  43. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  44. package/tests/agent_board_claimkey.test.sql +229 -0
  45. package/tests/agent_board_consent_backfill.test.sql +48 -0
  46. package/tests/agent_board_consent_emission.test.sql +601 -0
  47. package/tests/agent_board_consent_gating.test.sql +489 -0
  48. package/tests/agent_board_consent_substrate.test.sql +720 -0
  49. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  50. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  51. package/tests/agent_board_delegation_policies.test.sql +107 -1
  52. package/tests/agent_board_fedsession.test.sql +6 -4
  53. package/tests/agent_board_fedsession_autojoin.test.sql +181 -0
  54. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  55. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  56. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  57. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  58. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  59. package/tests/agent_board_fleet_policy.test.sql +2 -2
  60. package/tests/agent_board_gatenull_membstate.test.sql +352 -0
  61. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  62. package/tests/agent_board_identity_providers.test.sql +9 -0
  63. package/tests/agent_board_inbox_reads.test.sql +29 -12
  64. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  65. package/tests/agent_board_invitepolicy.test.sql +205 -0
  66. package/tests/agent_board_leaveidem.test.sql +194 -0
  67. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  68. package/tests/agent_board_mint_harden.test.sql +303 -0
  69. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  70. package/tests/agent_board_obsgate.test.sql +178 -0
  71. package/tests/agent_board_obsgate2.test.sql +153 -0
  72. package/tests/agent_board_obsgate3.test.sql +170 -0
  73. package/tests/agent_board_obsgate4.test.sql +196 -0
  74. package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
  75. package/tests/agent_board_pubexec_class.test.sql +53 -0
  76. package/tests/agent_board_rejectsem.test.sql +271 -0
  77. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  78. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  79. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  80. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  81. package/tests/agent_board_rolereset.test.sql +351 -0
  82. package/tests/agent_board_roomdeleg.test.sql +5 -3
  83. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  84. package/tests/agent_board_roomlink.test.sql +25 -9
  85. package/tests/agent_board_rowid.test.sql +1274 -0
  86. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  87. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  88. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  89. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  90. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  91. package/tests/agent_board_signing_store.test.sql +6 -6
  92. package/tests/fixtures/isolation-substrate.sql +93 -0
  93. package/tests/fixtures/test-prereqs.sql +33 -0
  94. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  95. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  96. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  97. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  98. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  99. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  100. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  101. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  102. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  103. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  104. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  105. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  106. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  107. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  108. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  109. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
@@ -1,6 +1,13 @@
1
1
  begin;
2
2
 
3
- select plan(19);
3
+ select plan(51);
4
+
5
+ -- The 32 behavioral assertions below run inside a `do` block. pgTAP assertions
6
+ -- must NOT be called via `perform` there: `perform is(...)` RUNS the assertion
7
+ -- (incrementing the plan counter) but DISCARDS the returned TAP line, so a
8
+ -- failure prints no `not ok` and ships green (message-board#87). Buffer each line
9
+ -- into this temp table and emit them after the block.
10
+ create temp table _tap (seq serial primary key, line text) on commit drop;
4
11
 
5
12
  select has_function(
6
13
  'agent_board',
@@ -231,38 +238,38 @@ begin
231
238
  'private'
232
239
  );
233
240
  v_board := v_result.board_id;
234
- perform ok(v_board is not null, 'create_board returns a board id');
241
+ insert into _tap(line) select ok(v_board is not null, 'create_board returns a board id');
235
242
 
236
243
  select * into v_result
237
244
  from agent_board.join_board(v_board, v_board_member, 'subscribed', v_sender, '{}'::jsonb);
238
- perform is(v_result.membership_state, 'subscribed', 'join_board supports subscribed state');
245
+ insert into _tap(line) select is(v_result.membership_state, 'subscribed', 'join_board supports subscribed state');
239
246
 
240
247
  select * into v_result
241
248
  from agent_board.join_board(v_board, v_direct_recipient, 'joined', v_sender, '{}'::jsonb);
242
- perform is(v_result.membership_state, 'joined', 'join_board supports joined state');
249
+ insert into _tap(line) select is(v_result.membership_state, 'joined', 'join_board supports joined state');
243
250
 
244
251
  select * into v_result
245
252
  from agent_board.join_board(v_board, v_left_member, 'joined', v_sender, '{}'::jsonb);
246
253
  select * into v_result
247
254
  from agent_board.leave_board(v_board, v_left_member, '{}'::jsonb);
248
- perform is(v_result.membership_state, 'left', 'leave_board marks memberships as left');
255
+ insert into _tap(line) select is(v_result.membership_state, 'left', 'leave_board marks memberships as left');
249
256
 
250
257
  select * into v_result
251
258
  from agent_board.register_callback_endpoint(v_direct_recipient, 'https://example.test/direct', '{}'::jsonb);
252
259
  v_direct_endpoint := v_result.endpoint_id;
253
- perform is(v_result.endpoint_status, 'pending_verification', 'register_callback_endpoint starts pending verification');
260
+ insert into _tap(line) select is(v_result.endpoint_status, 'pending_verification', 'register_callback_endpoint starts pending verification');
254
261
 
255
262
  select * into v_result
256
263
  from agent_board.verify_callback_endpoint(v_direct_endpoint, v_direct_recipient, '{"controlProofRef":"inbox-rpcs/direct"}'::jsonb);
257
- perform is(v_result.endpoint_status, 'verified', 'verify_callback_endpoint marks endpoint verified');
264
+ insert into _tap(line) select is(v_result.endpoint_status, 'verified', 'verify_callback_endpoint marks endpoint verified');
258
265
 
259
266
  select * into v_result
260
267
  from agent_board.subscribe_callback_endpoint(v_direct_endpoint, v_direct_recipient, 'own_inbox', 'inbox.item.delivered', null, '{}'::jsonb);
261
- perform is(v_result.subscription_status, 'active', 'own-inbox callback subscription activates');
268
+ insert into _tap(line) select is(v_result.subscription_status, 'active', 'own-inbox callback subscription activates');
262
269
 
263
270
  select * into v_result
264
271
  from agent_board.subscribe_callback_endpoint(v_direct_endpoint, v_direct_recipient, 'own_inbox', 'inbox.item.updated', null, '{}'::jsonb);
265
- perform is(v_result.subscription_status, 'active', 'own-inbox update subscription activates');
272
+ insert into _tap(line) select is(v_result.subscription_status, 'active', 'own-inbox update subscription activates');
266
273
 
267
274
  select * into v_result
268
275
  from agent_board.register_callback_endpoint(v_board_member, 'https://example.test/board', '{}'::jsonb);
@@ -271,7 +278,7 @@ begin
271
278
  from agent_board.verify_callback_endpoint(v_board_endpoint, v_board_member, '{"controlProofRef":"inbox-rpcs/board"}'::jsonb);
272
279
  select * into v_result
273
280
  from agent_board.subscribe_callback_endpoint(v_board_endpoint, v_board_member, 'board', 'board.post.delivered', v_board, '{}'::jsonb);
274
- perform is(v_result.subscription_status, 'active', 'board callback subscription activates for active member');
281
+ insert into _tap(line) select is(v_result.subscription_status, 'active', 'board callback subscription activates for active member');
275
282
 
276
283
  select * into v_result
277
284
  from agent_board.register_callback_endpoint(v_outsider, 'https://example.test/outsider', '{}'::jsonb);
@@ -279,10 +286,10 @@ begin
279
286
 
280
287
  begin
281
288
  perform agent_board.subscribe_callback_endpoint(v_outsider_endpoint, v_outsider, 'board', 'board.post.delivered', v_board, '{}'::jsonb);
282
- perform ok(false, 'board subscription without membership should fail');
289
+ insert into _tap(line) select ok(false, 'board subscription without membership should fail');
283
290
  exception
284
291
  when sqlstate 'P0002' then
285
- perform ok(true, 'board subscription requires active board membership');
292
+ insert into _tap(line) select ok(true, 'board subscription requires active board membership');
286
293
  end;
287
294
 
288
295
  select * into v_result
@@ -299,13 +306,13 @@ begin
299
306
  );
300
307
  v_direct_message := v_result.message_id;
301
308
  v_direct_inbox := v_result.inbox_item_id;
302
- perform is(v_result.callback_outbox_count, 1, 'direct delivery enqueues one own-inbox callback');
303
- perform is(
309
+ insert into _tap(line) select is(v_result.callback_outbox_count, 1, 'direct delivery enqueues one own-inbox callback');
310
+ insert into _tap(line) select is(
304
311
  (select count(*)::integer from agent_board.delivery_recipients where source_message_id = v_direct_message),
305
312
  1,
306
313
  'direct delivery writes one recipient row'
307
314
  );
308
- perform is(
315
+ insert into _tap(line) select is(
309
316
  (select count(*)::integer from agent_board.inbox_items where source_message_id = v_direct_message),
310
317
  1,
311
318
  'direct delivery writes one inbox row'
@@ -323,8 +330,8 @@ begin
323
330
  'direct-key',
324
331
  '{}'::jsonb
325
332
  );
326
- perform is(v_result.message_id, v_direct_message, 'direct idempotency reuses canonical message');
327
- perform is(
333
+ insert into _tap(line) select is(v_result.message_id, v_direct_message, 'direct idempotency reuses canonical message');
334
+ insert into _tap(line) select is(
328
335
  (select count(*)::integer from agent_board.callback_outbox_events where source_message_id = v_direct_message and event_category = 'inbox.item.delivered'),
329
336
  1,
330
337
  'direct idempotency does not duplicate delivered callback events'
@@ -332,8 +339,8 @@ begin
332
339
 
333
340
  select * into v_result
334
341
  from agent_board.update_inbox_item_state(v_direct_inbox, v_direct_recipient, 'read', '{}'::jsonb);
335
- perform is(v_result.action_state, 'read', 'update_inbox_item_state changes action state');
336
- perform is(
342
+ insert into _tap(line) select is(v_result.action_state, 'read', 'update_inbox_item_state changes action state');
343
+ insert into _tap(line) select is(
337
344
  (select count(*)::integer from agent_board.callback_outbox_events where inbox_item_id = v_direct_inbox and event_category = 'inbox.item.updated'),
338
345
  1,
339
346
  'updating inbox state enqueues one inbox.item.updated callback'
@@ -351,8 +358,8 @@ begin
351
358
  'multicast-key',
352
359
  '{}'::jsonb
353
360
  );
354
- perform is(v_result.delivered_recipient_count, 2, 'multicast collapses duplicate recipients');
355
- perform is(
361
+ insert into _tap(line) select is(v_result.delivered_recipient_count, 2, 'multicast collapses duplicate recipients');
362
+ insert into _tap(line) select is(
356
363
  (select count(*)::integer from agent_board.delivery_recipients where source_message_id = v_result.message_id),
357
364
  2,
358
365
  'multicast writes one recipient row per unique recipient'
@@ -371,13 +378,13 @@ begin
371
378
  '{}'::jsonb
372
379
  );
373
380
  v_board_message := v_result.message_id;
374
- perform is(v_result.delivered_recipient_count, 3, 'board delivery reaches joined and subscribed members only (incl. the seeded owner-creator)');
375
- perform is(
381
+ insert into _tap(line) select is(v_result.delivered_recipient_count, 3, 'board delivery reaches joined and subscribed members only (incl. the seeded owner-creator)');
382
+ insert into _tap(line) select is(
376
383
  (select count(*)::integer from agent_board.delivery_recipients where source_message_id = v_board_message and recipient_agent_id = v_left_member),
377
384
  0,
378
385
  'left board members do not receive board delivery'
379
386
  );
380
- perform ok(
387
+ insert into _tap(line) select ok(
381
388
  exists(
382
389
  select 1
383
390
  from agent_board.callback_outbox_events
@@ -402,13 +409,13 @@ begin
402
409
  '{}'::jsonb
403
410
  );
404
411
  v_task := v_result.task_id;
405
- perform is(v_result.delivered_recipient_count, 4, 'tasking counts explicit and board fan-out attempts (incl. the seeded owner-creator)');
406
- perform is(
412
+ insert into _tap(line) select is(v_result.delivered_recipient_count, 4, 'tasking counts explicit and board fan-out attempts (incl. the seeded owner-creator)');
413
+ insert into _tap(line) select is(
407
414
  (select count(*)::integer from agent_board.delivery_recipients where source_task_id = v_task),
408
415
  3,
409
416
  'tasking suppresses duplicate recipient rows across explicit and board fan-out'
410
417
  );
411
- perform is(
418
+ insert into _tap(line) select is(
412
419
  (select count(*)::integer from agent_board.inbox_items where source_task_id = v_task),
413
420
  3,
414
421
  'tasking writes one inbox row per unique recipient'
@@ -416,13 +423,13 @@ begin
416
423
 
417
424
  select * into v_result
418
425
  from agent_board.rotate_callback_signing_key(v_direct_endpoint, v_direct_recipient, '{}'::jsonb);
419
- perform is(v_result.signing_key_version, 2, 'rotate_callback_signing_key bumps signing key version');
420
- perform is(v_result.endpoint_status, 'pending_verification', 'signing key rotation returns endpoint to pending verification');
426
+ insert into _tap(line) select is(v_result.signing_key_version, 2, 'rotate_callback_signing_key bumps signing key version');
427
+ insert into _tap(line) select is(v_result.endpoint_status, 'pending_verification', 'signing key rotation returns endpoint to pending verification');
421
428
 
422
429
  select * into v_result
423
430
  from agent_board.disable_callback_endpoint(v_board_endpoint, v_board_member, 'test disable', '{}'::jsonb);
424
- perform is(v_result.endpoint_status, 'disabled', 'disable_callback_endpoint marks endpoint disabled');
425
- perform ok(
431
+ insert into _tap(line) select is(v_result.endpoint_status, 'disabled', 'disable_callback_endpoint marks endpoint disabled');
432
+ insert into _tap(line) select ok(
426
433
  exists(
427
434
  select 1
428
435
  from agent_board.callback_subscriptions
@@ -434,13 +441,13 @@ begin
434
441
 
435
442
  select * into v_result
436
443
  from agent_board.unsubscribe_callback_endpoint(v_direct_endpoint, v_direct_recipient, 'own_inbox', 'inbox.item.updated', null, '{}'::jsonb);
437
- perform is(v_result.subscription_status, 'disabled', 'unsubscribe_callback_endpoint disables an active subscription');
444
+ insert into _tap(line) select is(v_result.subscription_status, 'disabled', 'unsubscribe_callback_endpoint disables an active subscription');
438
445
 
439
446
  select * into v_result
440
447
  from agent_board.delete_callback_endpoint(v_outsider_endpoint, v_outsider);
441
- perform ok(v_result.deleted, 'delete_callback_endpoint removes owned endpoints');
448
+ insert into _tap(line) select ok(v_result.deleted, 'delete_callback_endpoint removes owned endpoints');
442
449
 
443
- perform is(
450
+ insert into _tap(line) select is(
444
451
  (select count(*)::integer from agent_board.callback_delivery_attempts),
445
452
  0,
446
453
  'callback management remains metadata-only before runtime delivery exists'
@@ -448,5 +455,8 @@ begin
448
455
  end;
449
456
  $$;
450
457
 
458
+ -- Emit the buffered TAP lines (tests 20-51) that the `do` block produced.
459
+ select line from _tap order by seq;
460
+
451
461
  select * from finish();
452
462
  rollback;
@@ -0,0 +1,205 @@
1
+ -- INVITEPOLICY (#90) pgTAP: a target can block a specific inviter on a specific board.
2
+ --
3
+ -- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role()
4
+ -- (''/service_role => true, 'authenticated' => false), request.jwt.claim.sub picks the
5
+ -- acting agent (so ACL grants do not gate these calls — behavior is under test, not ACLs).
6
+ --
7
+ -- The DENY assertion (blocked inviter's invite raises 42501) is the mutation pin: delete the
8
+ -- guard line in invite_board_member and it stops raising -> the test fails. The ALLOW twins
9
+ -- (un-blocked inviter, other inviter, service_role operator, unblock, and a merely-'left'
10
+ -- target) guard against over-blocking, including the REJECTSEM overload (a voluntary leaver
11
+ -- must stay re-invitable).
12
+ create extension if not exists pgtap with schema extensions;
13
+ set search_path = extensions, public, agent_board;
14
+
15
+ begin;
16
+
17
+ select plan(21);
18
+
19
+ create temp table _ip_fixture (
20
+ board uuid,
21
+ creator uuid, -- owner (seeded by create_board); acts as inviter1
22
+ inviter2 uuid, -- non-creator 'member'; a second, distinct inviter
23
+ target uuid, -- the invited agent that sets a block
24
+ target2 uuid, -- a different agent (actor-binding negative test)
25
+ leftguy uuid, -- a 'left' member (REJECTSEM re-invite guard)
26
+ creator_user uuid,
27
+ inviter2_user uuid,
28
+ target_user uuid,
29
+ target2_user uuid
30
+ ) on commit drop;
31
+
32
+ do $$
33
+ declare
34
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
35
+ v_creator_user uuid;
36
+ v_inviter2_user uuid;
37
+ v_target_user uuid;
38
+ v_target2_user uuid;
39
+ v_creator uuid := gen_random_uuid();
40
+ v_inviter2 uuid := gen_random_uuid();
41
+ v_target uuid := gen_random_uuid();
42
+ v_target2 uuid := gen_random_uuid();
43
+ v_leftguy uuid := gen_random_uuid();
44
+ v_board uuid;
45
+ begin
46
+ v_creator_user := public.ensure_github_user('ip-creator-' || v_suffix, 'ip-creator-' || v_suffix || '@example.test', 'ip-creator-' || v_suffix, 'IP Creator');
47
+ v_inviter2_user := public.ensure_github_user('ip-inviter2-' || v_suffix, 'ip-inviter2-' || v_suffix || '@example.test', 'ip-inviter2-' || v_suffix, 'IP Inviter2');
48
+ v_target_user := public.ensure_github_user('ip-target-' || v_suffix, 'ip-target-' || v_suffix || '@example.test', 'ip-target-' || v_suffix, 'IP Target');
49
+ v_target2_user := public.ensure_github_user('ip-target2-' || v_suffix, 'ip-target2-' || v_suffix || '@example.test', 'ip-target2-' || v_suffix, 'IP Target2');
50
+
51
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
52
+ values
53
+ (v_creator, 'agent', 'ip-creator-' || v_suffix, 'IP Creator', 'active', v_creator_user, v_creator_user),
54
+ (v_inviter2, 'agent', 'ip-inviter2-' || v_suffix, 'IP Inviter2', 'active', v_inviter2_user, v_inviter2_user),
55
+ (v_target, 'agent', 'ip-target-' || v_suffix, 'IP Target', 'active', v_target_user, v_target_user),
56
+ (v_target2, 'agent', 'ip-target2-' || v_suffix, 'IP Target2', 'active', v_target2_user, v_target2_user),
57
+ (v_leftguy, 'agent', 'ip-leftguy-' || v_suffix, 'IP Leftguy', 'active', null, null);
58
+
59
+ -- Gate-OFF board (role_gate_enforced defaults false): creator bypasses; inviter2 needs an
60
+ -- active membership.
61
+ select board_id into v_board
62
+ from agent_board.create_board(v_creator, 'ip-board-' || v_suffix, 'IP Board', 'invite policy test', '{}'::jsonb, 'private');
63
+
64
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
65
+ values (v_board, v_inviter2, 'joined', v_creator, 'member');
66
+ -- a 'left' row requires left_at (constraint board_memberships_state_left_at).
67
+ -- leftguy: REJECTSEM re-invite guard. target2: has a row so update_board_membership reaches
68
+ -- the guard in the bypass-closed regression check below.
69
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role, left_at)
70
+ values (v_board, v_leftguy, 'left', v_creator, 'member', now()),
71
+ (v_board, v_target2, 'left', v_creator, 'member', now());
72
+
73
+ insert into _ip_fixture values
74
+ (v_board, v_creator, v_inviter2, v_target, v_target2, v_leftguy,
75
+ v_creator_user, v_inviter2_user, v_target_user, v_target2_user);
76
+ end
77
+ $$;
78
+
79
+ -- existence -----------------------------------------------------------------
80
+ select has_function('agent_board', 'block_board_inviter', array['uuid', 'uuid', 'uuid', 'jsonb'], 'block_board_inviter exists');
81
+ select has_function('agent_board', 'unblock_board_inviter', array['uuid', 'uuid', 'uuid'], 'unblock_board_inviter exists');
82
+ select has_function('agent_board', '_invite_permitted', array['uuid', 'uuid', 'uuid'], '_invite_permitted exists');
83
+
84
+ -- default permissive: no block row -> permitted -----------------------------
85
+ select is(
86
+ (select agent_board._invite_permitted(board, target, inviter2) from _ip_fixture),
87
+ true, '_invite_permitted default true with no block row');
88
+
89
+ -- baseline: inviter1 (creator) may invite the target ------------------------
90
+ select set_config('request.jwt.claim.role', 'authenticated', true);
91
+ select set_config('request.jwt.claim.sub', (select creator_user from _ip_fixture)::text, true);
92
+ select lives_ok(
93
+ (select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid)$q$,
94
+ board, target, creator) from _ip_fixture),
95
+ 'baseline: creator invites target (no block)');
96
+
97
+ -- target blocks inviter1 (creator) -----------------------------------------
98
+ select set_config('request.jwt.claim.sub', (select target_user from _ip_fixture)::text, true);
99
+ select isnt(
100
+ (select b.id from _ip_fixture f, agent_board.block_board_inviter(f.board, f.creator) b),
101
+ null, 'target blocks the creator inviter (row created)');
102
+
103
+ select is(
104
+ (select agent_board._invite_permitted(board, target, creator) from _ip_fixture),
105
+ false, '_invite_permitted false for the blocked (target, creator) pair');
106
+
107
+ -- THE FIX (mutation pin): the blocked inviter can no longer invite the target
108
+ select set_config('request.jwt.claim.sub', (select creator_user from _ip_fixture)::text, true);
109
+ select throws_ok(
110
+ (select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid)$q$,
111
+ board, target, creator) from _ip_fixture),
112
+ '42501', 'target has blocked invitations from this inviter on this board',
113
+ 'blocked inviter (creator) cannot invite the target');
114
+
115
+ -- operator carve-out: service_role invites despite the block ----------------
116
+ select set_config('request.jwt.claim.role', 'service_role', true);
117
+ select set_config('request.jwt.claim.sub', '', true);
118
+ select lives_ok(
119
+ (select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid)$q$,
120
+ board, target, creator) from _ip_fixture),
121
+ 'service_role operator invites the target despite the block (null-actor carve-out)');
122
+
123
+ -- per-inviter scope: a DIFFERENT, un-blocked inviter still succeeds ----------
124
+ select set_config('request.jwt.claim.role', 'authenticated', true);
125
+ select set_config('request.jwt.claim.sub', (select inviter2_user from _ip_fixture)::text, true);
126
+ select lives_ok(
127
+ (select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid)$q$,
128
+ board, target, inviter2) from _ip_fixture),
129
+ 'a different (un-blocked) inviter still invites the target (per-inviter scope)');
130
+
131
+ -- actor-binding: a third party cannot block on the target''s behalf ---------
132
+ select set_config('request.jwt.claim.sub', (select target2_user from _ip_fixture)::text, true);
133
+ select throws_ok(
134
+ (select format($q$select agent_board.block_board_inviter(%L::uuid, %L::uuid, %L::uuid)$q$,
135
+ board, creator, target) from _ip_fixture),
136
+ '42501', null,
137
+ 'a third party cannot block on another target''s behalf (actor bound to session)');
138
+
139
+ -- unblock lifts the block ---------------------------------------------------
140
+ select set_config('request.jwt.claim.sub', (select target_user from _ip_fixture)::text, true);
141
+ select is(
142
+ (select agent_board.unblock_board_inviter(board, creator) from _ip_fixture),
143
+ true, 'target unblocks the creator inviter (row removed)');
144
+
145
+ select set_config('request.jwt.claim.sub', (select creator_user from _ip_fixture)::text, true);
146
+ select lives_ok(
147
+ (select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid)$q$,
148
+ board, target, creator) from _ip_fixture),
149
+ 'the previously blocked inviter can invite again after unblock');
150
+
151
+ select set_config('request.jwt.claim.sub', (select target_user from _ip_fixture)::text, true);
152
+ select is(
153
+ (select agent_board.unblock_board_inviter(board, creator) from _ip_fixture),
154
+ false, 'unblock returns false when no block row exists');
155
+
156
+ -- REJECTSEM guard: a merely-''left'' target (no block) stays re-invitable ----
157
+ select set_config('request.jwt.claim.sub', (select creator_user from _ip_fixture)::text, true);
158
+ select lives_ok(
159
+ (select format($q$select agent_board.invite_board_member(%L::uuid, %L::uuid, %L::uuid)$q$,
160
+ board, leftguy, creator) from _ip_fixture),
161
+ 'a voluntary leaver (state left, no block) is still re-invitable (REJECTSEM preserved)');
162
+
163
+ -- BYPASS CLOSED (#90 panel finding): update_board_membership must not re-seat an
164
+ -- inbound-disclosure membership ('invited'/'requested') for a target that blocked the actor.
165
+ select set_config('request.jwt.claim.sub', (select target2_user from _ip_fixture)::text, true);
166
+ select isnt(
167
+ (select b.id from _ip_fixture f, agent_board.block_board_inviter(f.board, f.creator) b),
168
+ null, 'target2 blocks the creator (setup for the update-path bypass check)');
169
+
170
+ select set_config('request.jwt.claim.sub', (select creator_user from _ip_fixture)::text, true);
171
+ select throws_ok(
172
+ (select format($q$select agent_board.update_board_membership(%L::uuid, %L::uuid, 'invited')$q$,
173
+ board, target2) from _ip_fixture),
174
+ '42501', 'target has blocked invitations from this inviter on this board',
175
+ 'update_board_membership cannot re-seat invited for a blocking target (bypass closed)');
176
+
177
+ select throws_ok(
178
+ (select format($q$select agent_board.update_board_membership(%L::uuid, %L::uuid, 'requested')$q$,
179
+ board, target2) from _ip_fixture),
180
+ '42501', 'target has blocked invitations from this inviter on this board',
181
+ 'update_board_membership cannot re-seat requested for a blocking target either');
182
+
183
+ -- allow-twin: an UN-blocked target can still be re-seated via update_board_membership.
184
+ select lives_ok(
185
+ (select format($q$select agent_board.update_board_membership(%L::uuid, %L::uuid, 'invited')$q$,
186
+ board, leftguy) from _ip_fixture),
187
+ 'update_board_membership still admits invited for an un-blocked target');
188
+
189
+ -- operator carve-out: the guard sits inside the authenticated branch, so service_role is unaffected.
190
+ select set_config('request.jwt.claim.role', 'service_role', true);
191
+ select set_config('request.jwt.claim.sub', '', true);
192
+ select lives_ok(
193
+ (select format($q$select agent_board.update_board_membership(%L::uuid, %L::uuid, 'invited')$q$,
194
+ board, target2) from _ip_fixture),
195
+ 'service_role may re-seat invited despite the block (operator carve-out)');
196
+
197
+ -- the new SECURITY DEFINER helper carries no PUBLIC EXECUTE (MBHARDEN #118) --
198
+ select set_config('request.jwt.claim.role', '', true);
199
+ select ok(
200
+ not has_function_privilege('public', 'agent_board._invite_permitted(uuid,uuid,uuid)', 'EXECUTE'),
201
+ '_invite_permitted carries no PUBLIC EXECUTE (class invariant)');
202
+
203
+ select * from finish();
204
+
205
+ rollback;
@@ -0,0 +1,194 @@
1
+ -- LEAVEIDEM (#81) pgTAP — terminal membership states are true no-ops; left_at and the reserved
2
+ -- ops_* metadata namespace are not caller-controlled.
3
+ --
4
+ -- HAND-COUNTED plan: 20 assertions (mechanically re-counted; an earlier draft was off by one,
5
+ -- which CI cannot catch because its grep does not see pgTAP plan diagnostics). Breakdown:
6
+ -- 3 controls | 4 LEAVEIDEM core/no-op | 2 #79 guards | 4 ops-metadata + self-promotion
7
+ -- | 2 left_at invariants (preserve + genuine restamp) | 1 oracle guard | 4 no-regression
8
+ --
9
+ -- METHODOLOGY (load-bearing, and two traps this suite exists to avoid):
10
+ -- (a) GUC-only pattern (claims set, session stays postgres) rather than `set local role
11
+ -- authenticated`, because `_is_service_role()` EXECUTE is revoked from `authenticated`
12
+ -- (20260512000000:603) so the control below could not run under a real SET ROLE. Both
13
+ -- vacuous-pass controls are asserted: `_is_service_role() = false` (else every assertion
14
+ -- silently runs the SERVICE branch) and a non-null `current_agent_id()` (else a fixture whose
15
+ -- sub resolves no active agent yields a green throws_ok('42501') from the GATENULL guard while
16
+ -- the tested behaviour is absent). Every throws_ok matches the MESSAGE, because 42501 is
17
+ -- raised by six other guards in this function family.
18
+ -- (b) `now()` is transaction_timestamp() and is CONSTANT for the whole suite, so a "was it
19
+ -- restamped?" assertion written as now-vs-now can never fail. Every left_at assertion below
20
+ -- therefore seeds a DISTINCT past value first and compares against that.
21
+
22
+ create extension if not exists pgtap with schema extensions;
23
+ set search_path = extensions, public, agent_board;
24
+
25
+ begin;
26
+
27
+ select plan(22);
28
+
29
+ create temp table _li (k text primary key, v text) on commit drop;
30
+
31
+ do $$
32
+ declare
33
+ s text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
34
+ v_o uuid; v_b uuid; v_a uuid; v_uid uuid; v_c uuid; v_cuid uuid;
35
+ begin
36
+ insert into agent_board.agents(agent_type, kind, handle, display_name, status)
37
+ values ('agent', 'agent'::agent_board.agent_kind, 'li-own-' || s, 'Owner', 'active') returning id into v_o;
38
+ insert into agent_board.boards(slug, name, created_by_agent_id, visibility, status)
39
+ values ('li-b-' || s, 'LI Board', v_o, 'open', 'active') returning id into v_b;
40
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
41
+ values (v_b, v_o, 'owner', 'joined', v_o);
42
+
43
+ v_uid := gen_random_uuid();
44
+ insert into auth.users(id, instance_id, aud, role, email, created_at, updated_at)
45
+ values (v_uid, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated',
46
+ 'li-' || s || '@example.test', now(), now());
47
+ insert into agent_board.agents(agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
48
+ values ('agent', 'agent'::agent_board.agent_kind, 'li-a-' || s, 'Member', 'active', v_uid, v_uid) returning id into v_a;
49
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
50
+ values (v_b, v_a, 'member', 'joined', v_o);
51
+
52
+ -- an ACTIVE authenticated agent with NO membership on this board (for the oracle assertion)
53
+ v_cuid := gen_random_uuid();
54
+ insert into auth.users(id, instance_id, aud, role, email, created_at, updated_at)
55
+ values (v_cuid, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated',
56
+ 'li-c-' || s || '@example.test', now(), now());
57
+ insert into agent_board.agents(agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
58
+ values ('agent', 'agent'::agent_board.agent_kind, 'li-c-' || s, 'Outsider', 'active', v_cuid, v_cuid) returning id into v_c;
59
+
60
+ insert into _li values ('board', v_b::text), ('owner', v_o::text), ('agent', v_a::text),
61
+ ('uid', v_uid::text), ('outsider_uid', v_cuid::text);
62
+ end
63
+ $$;
64
+
65
+ select set_config('request.jwt.claim.sub', (select v from _li where k = 'uid'), true);
66
+ select set_config('request.jwt.claim.role', 'authenticated', true);
67
+
68
+ -- 1,2 — vacuous-pass controls (see methodology (a))
69
+ select is(agent_board._is_service_role(), false, 'CONTROL: acting on the authenticated path, not service_role');
70
+ select isnt(agent_board.current_agent_id(), null, 'CONTROL: the actor resolves to an active agent');
71
+
72
+ -- 3,4 — #81: leave_board is idempotent again (assertion 4 FAILS on main with 42501)
73
+ select lives_ok(
74
+ $$ select agent_board.leave_board((select v::uuid from _li where k='board'), (select v::uuid from _li where k='agent')) $$,
75
+ 'first leave_board succeeds');
76
+ select lives_ok(
77
+ $$ select agent_board.leave_board((select v::uuid from _li where k='board'), (select v::uuid from _li where k='agent')) $$,
78
+ 'LEAVEIDEM: a second leave_board is idempotent (was 42501 on main)');
79
+
80
+ -- 5,6 — and it is a TRUE no-op: neither the seeded departure time nor updated_at moves.
81
+ -- Seed a DISTINCT past left_at first (methodology (b)) so a restamp would be detectable.
82
+ update agent_board.board_memberships
83
+ set left_at = timestamptz '2020-01-01 00:00:00+00', updated_at = timestamptz '2020-01-01 00:00:00+00'
84
+ where board_id = (select v::uuid from _li where k='board') and agent_id = (select v::uuid from _li where k='agent');
85
+ select lives_ok(
86
+ $$ select agent_board.leave_board((select v::uuid from _li where k='board'), (select v::uuid from _li where k='agent')) $$,
87
+ 'a further leave_board on the seeded row still succeeds');
88
+ select is(
89
+ (select left_at from agent_board.board_memberships
90
+ where board_id = (select v::uuid from _li where k='board') and agent_id = (select v::uuid from _li where k='agent')),
91
+ timestamptz '2020-01-01 00:00:00+00',
92
+ 'LEAVEIDEM: the no-op does not touch the row at all (seeded left_at survives)');
93
+
94
+ -- 7,8,9,10,11 — no-regression + the reserved ops_* strip on a REAL transition
95
+ select lives_ok(
96
+ $$ select agent_board.join_board((select v::uuid from _li where k='board'), (select v::uuid from _li where k='agent'), 'joined') $$,
97
+ 'NO-REGRESSION: an agent may rejoin an OPEN board after leaving');
98
+ select lives_ok(
99
+ $$ select agent_board.update_board_membership((select v::uuid from _li where k='board'), (select v::uuid from _li where k='agent'),
100
+ 'left', '{"ops_retained_at":"2000-01-01","note":"keepme"}'::jsonb) $$,
101
+ 'a real transition accepts caller metadata');
102
+ select is(
103
+ (select metadata->>'ops_retained_at' from agent_board.board_memberships
104
+ where board_id = (select v::uuid from _li where k='board') and agent_id = (select v::uuid from _li where k='agent')),
105
+ null,
106
+ 'reserved ops_* metadata from a caller is STRIPPED (retention skip key not writable; FAILS on main)');
107
+ select is(
108
+ (select metadata->>'note' from agent_board.board_memberships
109
+ where board_id = (select v::uuid from _li where k='board') and agent_id = (select v::uuid from _li where k='agent')),
110
+ 'keepme',
111
+ 'legitimate caller metadata is still merged (the strip is not over-broad)');
112
+ select throws_ok(
113
+ $$ select agent_board.update_board_membership((select v::uuid from _li where k='board'), (select v::uuid from _li where k='agent'), 'joined') $$,
114
+ '42501', 'self membership change not permitted; use the join/approval path',
115
+ 'self promotion from a terminal state is still refused');
116
+
117
+ -- ---------------------------------------------------------------------------
118
+ -- The `removed` state on the AUTHENTICATED path — the behaviour this migration adds.
119
+ -- ---------------------------------------------------------------------------
120
+ reset role;
121
+ select set_config('request.jwt.claim.sub', '', true);
122
+ select set_config('request.jwt.claim.role', '', true);
123
+ select lives_ok(
124
+ $$ select agent_board.update_board_membership((select v::uuid from _li where k='board'), (select v::uuid from _li where k='agent'), 'removed') $$,
125
+ 'NO-REGRESSION: service_role may still evict on behalf of an operator');
126
+
127
+ -- seed a distinct past left_at on the now-`removed` row for the invariants that follow
128
+ update agent_board.board_memberships
129
+ set left_at = timestamptz '2019-06-01 00:00:00+00', updated_at = timestamptz '2019-06-01 00:00:00+00'
130
+ where board_id = (select v::uuid from _li where k='board') and agent_id = (select v::uuid from _li where k='agent');
131
+
132
+ select set_config('request.jwt.claim.sub', (select v from _li where k = 'uid'), true);
133
+ select set_config('request.jwt.claim.role', 'authenticated', true);
134
+
135
+ -- 12,13 — #79 kick-marker guard intact for a REMOVED agent (neither transition is same-state)
136
+ select throws_ok(
137
+ $$ select agent_board.update_board_membership((select v::uuid from _li where k='board'), (select v::uuid from _li where k='agent'), 'left') $$,
138
+ '42501', 'self membership change not permitted; use the join/approval path',
139
+ '#79 INTACT: a removed agent cannot launder its kick marker to left');
140
+ select throws_ok(
141
+ $$ select agent_board.update_board_membership((select v::uuid from _li where k='board'), (select v::uuid from _li where k='agent'), 'joined') $$,
142
+ '42501', 'self membership change not permitted; use the join/approval path',
143
+ '#79 INTACT: a removed agent cannot self-readmit');
144
+
145
+ -- 14,15 — removed -> removed is a permitted TRUE no-op
146
+ select lives_ok(
147
+ $$ select agent_board.update_board_membership((select v::uuid from _li where k='board'), (select v::uuid from _li where k='agent'), 'removed') $$,
148
+ 'LEAVEIDEM: removed -> removed is permitted (same-state terminal no-op)');
149
+ select is(
150
+ (select updated_at from agent_board.board_memberships
151
+ where board_id = (select v::uuid from _li where k='board') and agent_id = (select v::uuid from _li where k='agent')),
152
+ timestamptz '2019-06-01 00:00:00+00',
153
+ 'LEAVEIDEM: the no-op does NOT rewrite the row (seeded updated_at survives)');
154
+
155
+ -- 16 — ORACLE GUARD: an unauthorized caller targeting an agent ALREADY in the no-op state must
156
+ -- still get 42501. This is the assertion that distinguishes placing the early return AFTER the
157
+ -- authorization block from placing it before.
158
+ select set_config('request.jwt.claim.sub', (select v from _li where k = 'outsider_uid'), true);
159
+ select throws_ok(
160
+ $$ select agent_board.update_board_membership((select v::uuid from _li where k='board'), (select v::uuid from _li where k='agent'), 'removed') $$,
161
+ '42501', 'board membership change requires owner or admin role',
162
+ 'the no-op path is NOT an oracle: an unauthorized caller targeting an already-removed agent still gets 42501');
163
+
164
+ -- 17,18,19 — service path: control, the mutator's left_at preservation, and a genuine restamp.
165
+ reset role;
166
+ select set_config('request.jwt.claim.sub', '', true);
167
+ select set_config('request.jwt.claim.role', '', true);
168
+ select is(agent_board._is_service_role(), true, 'CONTROL: bare postgres is service_role-equivalent');
169
+ -- The operator is deliberately NOT short-circuited by the no-op (so its p_metadata is not dropped),
170
+ -- so it reaches the mutator — which must PRESERVE the seeded left_at on a same-state call.
171
+ -- This is the only assertion that exercises coalesce(); it FAILS on main, which restamps.
172
+ select lives_ok($$
173
+ select agent_board.update_board_membership(
174
+ (select v::uuid from _li where k='board'), (select v::uuid from _li where k='agent'), 'removed') $$,
175
+ 'operator reaches the mutator on a same-state call [call succeeds]');
176
+ select is((select left_at from agent_board.board_memberships
177
+ where board_id = (select v::uuid from _li where k='board') and agent_id = (select v::uuid from _li where k='agent')),
178
+ timestamptz '2019-06-01 00:00:00+00',
179
+ 'mutator PRESERVES left_at when the terminal state is unchanged (retention cutoff not restampable)');
180
+ -- A GENUINE terminal->different-terminal transition must restamp: the kick time is the audit answer.
181
+ update agent_board.board_memberships
182
+ set membership_state = 'left', left_at = timestamptz '2019-06-01 00:00:00+00'
183
+ where board_id = (select v::uuid from _li where k='board') and agent_id = (select v::uuid from _li where k='agent');
184
+ select lives_ok($$
185
+ select agent_board.update_board_membership(
186
+ (select v::uuid from _li where k='board'), (select v::uuid from _li where k='agent'), 'removed') $$,
187
+ 'a genuine left -> removed kick [call succeeds]');
188
+ select isnt((select left_at from agent_board.board_memberships
189
+ where board_id = (select v::uuid from _li where k='board') and agent_id = (select v::uuid from _li where k='agent')),
190
+ timestamptz '2019-06-01 00:00:00+00',
191
+ 'a genuine left -> removed kick RESTAMPS left_at (does not inherit the departure time)');
192
+
193
+ select * from finish();
194
+ rollback;