@consiliency/agent-board-schema 1.63.0 → 1.68.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 (63) hide show
  1. package/README.md +25 -0
  2. package/fixtures/already-applied.json +20 -0
  3. package/migrations/20261014000200_agent_board_last_owner.sql +239 -0
  4. package/migrations/20261015000100_agent_board_session_provenance.sql +367 -0
  5. package/migrations/20261015000200_agent_board_request_preserve_removed.sql +87 -0
  6. package/migrations/20261015000300_agent_board_rls_correlation.sql +132 -0
  7. package/migrations/20261015000400_agent_board_resolution_authority.sql +146 -0
  8. package/package.json +1 -1
  9. package/schema-manifest.json +48 -3
  10. package/src/deployment-drift-main.test.mjs +156 -0
  11. package/src/deployment-drift-script.test.mjs +14 -1
  12. package/src/policy-comment.test.mjs +17 -0
  13. package/src/tooling.test.mjs +10 -2
  14. package/tests/agent_board_atomic_fleet_onboarding.test.sql +3 -1
  15. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +7 -0
  16. package/tests/agent_board_consent_gating.test.sql +4 -4
  17. package/tests/agent_board_fk_lock_edges.test.sql +3 -2
  18. package/tests/agent_board_gatenull_membstate.test.sql +7 -4
  19. package/tests/agent_board_grant_target_role.test.sql +4 -4
  20. package/tests/agent_board_hosted_delegates.test.sql +5 -3
  21. package/tests/agent_board_invite_no_downgrade.test.sql +5 -3
  22. package/tests/agent_board_ledger_authority.test.sql +7 -0
  23. package/tests/agent_board_policy_correlation.test.sql +189 -0
  24. package/tests/agent_board_request_removed.test.sql +96 -0
  25. package/tests/agent_board_resolution_authority.test.sql +190 -0
  26. package/tests/agent_board_role_guard.test.sql +319 -0
  27. package/tests/agent_board_rolereset.test.sql +17 -27
  28. package/tests/agent_board_roomdeleg.test.sql +8 -0
  29. package/tests/agent_board_roomown.test.sql +3 -1
  30. package/tests/agent_board_rowid.test.sql +4 -1
  31. package/tests/agent_board_session_provenance.test.sql +106 -0
  32. package/tests/catalog/function_execute_privileges.json +11 -2
  33. package/tests/catalog/p_metadata_parameters.json +4 -0
  34. package/tests/isolation/expected/consent_approval_lock.out +1 -1
  35. package/tests/isolation/expected/last_owner_departure.out +562 -0
  36. package/tests/isolation/specs/atomic_fleet_authority_races.spec +8 -3
  37. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +0 -3
  38. package/tests/isolation/specs/atomic_fleet_board_archive.spec +0 -3
  39. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +0 -3
  40. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +0 -3
  41. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +0 -3
  42. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +0 -3
  43. package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +0 -3
  44. package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +0 -2
  45. package/tests/isolation/specs/claimpolicy_board_scope.spec +0 -2
  46. package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +0 -2
  47. package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +0 -2
  48. package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +0 -2
  49. package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +0 -2
  50. package/tests/isolation/specs/consent_approval_lock.spec +13 -8
  51. package/tests/isolation/specs/consent_emit_actual_state.spec +0 -1
  52. package/tests/isolation/specs/grant_target_role_race.spec +0 -1
  53. package/tests/isolation/specs/hosted_delegate_add_tombstone_race.spec +0 -1
  54. package/tests/isolation/specs/hosted_delegate_tombstone_revoke_race.spec +0 -1
  55. package/tests/isolation/specs/hosted_delegate_withdraw_evict_race.spec +0 -1
  56. package/tests/isolation/specs/last_owner_departure.spec +137 -0
  57. package/tests/isolation/specs/ledger_authority_revoke.spec +0 -3
  58. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +0 -3
  59. package/tests/isolation/specs/outbox_wake_ordering.spec +0 -1
  60. package/tests/isolation/specs/session_identity_links.spec +3 -1
  61. package/tests/isolation/specs/session_identity_tombstone_onboarding.spec +0 -1
  62. package/tests/isolation/specs/signed_grant_onboarding_deadlock.spec +0 -1
  63. package/tests/isolation/specs/signer_lifecycle_revoke.spec +0 -1
@@ -0,0 +1,17 @@
1
+ import { expect, it } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+
4
+ const comment = (file) => {
5
+ const sql = readFileSync(new URL('../migrations/' + file, import.meta.url), 'utf8');
6
+ const statement = sql.match(/^comment on function agent_board\._current_agent_can_access_thread\(uuid\) is\s*((?:'(?:''|[^'])*'\s*)+);/im);
7
+ if (!statement) throw new Error('thread-access comment missing in ' + file);
8
+ return [...statement[1].matchAll(/'((?:''|[^'])*)'/g)].map((part) => part[1].replaceAll("''", "'")).join('');
9
+ };
10
+
11
+ it('RLS-comment-ruling: authenticated grants preserve the complete ratified thread-access comment', () => {
12
+ const ruling = comment('20260914000100_agent_board_claim_subject_gate.sql');
13
+ expect(ruling).toContain('message-board#291');
14
+ expect(ruling).toContain('REVERSING A RULING');
15
+ expect(comment('20261015000300_agent_board_rls_correlation.sql')).toBe(
16
+ ruling + ' Callable by authenticated for its RLS policies and by service_role.');
17
+ });
@@ -24,6 +24,9 @@ const historyExact = () => ({ applied: ordered.map((m) => ({ id: m.id, sha256: m
24
24
  const union = () => ordered.filter((m) => m.origin !== 'message-board-forward');
25
25
 
26
26
  describe('manifest integrity', () => {
27
+ it('pins the exact owner-authorized A3/A10.3 test amendment for Consiliency/message-board#675', () => {
28
+ expect(sha256(readFileSync(new URL('../tests/agent_board_grant_target_role.test.sql', import.meta.url)))).toBe('5c0a857d8c138f368235fa345a18c76fafa773cfe8eb57185b232d466d222f93');
29
+ });
27
30
  it('aggregate digest matches recomputation over ordered per-file digests', () => {
28
31
  const agg = sha256(Buffer.from(ordered.map((e) => `${e.order} ${e.id} ${e.sha256}`).join('\n')));
29
32
  expect(agg).toBe(manifest.aggregateSha256);
@@ -38,9 +41,9 @@ describe('manifest integrity', () => {
38
41
  expect(manifest.schemaVersion).toBe(8);
39
42
  expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
40
43
  });
41
- it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE + one BOARDCLAIMPOLICY + one HEARTBEATPRESERVE + one LIVENESSDENOM + one OWNERLESSRENEWTTL + one OWNERLESSQUOTACENSUS + one FLEETDOMAINNODOWNGRADE + one OWNERLESSQUOTACONVERGE + one ADVISORYREVERTRESET + one CLAIMPOLICYRACE + one CLAIMPOLICYLOCKGRANT + one SESSIONCHALLENGESUPERSEDE + one ROOMCONTRACT + one CALLBACKCONSUMER + one ENROLLTOKENSINGLEUSE + one ROOMCONTRACT-ACTIVEFIRST + one ONBOARD + one HOSTED + one HOSTED-FOLLOWUPS + one OWNPOST + one SIDSCHEMA + one SIDOWNER + one GRANTTARGETROLE (#674) forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 161)', () => {
44
+ it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE + one BOARDCLAIMPOLICY + one HEARTBEATPRESERVE + one LIVENESSDENOM + one OWNERLESSRENEWTTL + one OWNERLESSQUOTACENSUS + one FLEETDOMAINNODOWNGRADE + one OWNERLESSQUOTACONVERGE + one ADVISORYREVERTRESET + one CLAIMPOLICYRACE + one CLAIMPOLICYLOCKGRANT + one SESSIONCHALLENGESUPERSEDE + one ROOMCONTRACT + one CALLBACKCONSUMER + one ENROLLTOKENSINGLEUSE + one ROOMCONTRACT-ACTIVEFIRST + one ONBOARD + one HOSTED + one HOSTED-FOLLOWUPS + one OWNPOST + one SIDSCHEMA + one SIDOWNER + one GRANTTARGETROLE (#674) + one LASTOWNER (Consiliency/message-board#675) + one SESSIONPROVENANCE (Consiliency/message-board#665) + one REQUESTREMOVED (Consiliency/message-board#680) + one RLSCORRELATION (Consiliency/message-board#589) + one RESOLUTIONAUTHORITY (Consiliency/message-board#668) forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 166)', () => {
42
45
  expect(manifest.migrationCount).toBe(manifest.migrations.length);
43
- expect(manifest.migrationCount).toBe(161);
46
+ expect(manifest.migrationCount).toBe(166);
44
47
  // MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
45
48
  // schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
46
49
  // enrollment-only additive surface (identity_providers registry, enrollment audit,
@@ -360,6 +363,11 @@ describe('manifest integrity', () => {
360
363
  '20261012000100_agent_board_sidowner.sql',
361
364
  // #674: grant_board_role checks the target's current role (C-A + C-B).
362
365
  '20261014000100_agent_board_grant_target_role.sql',
366
+ '20261014000200_agent_board_last_owner.sql',
367
+ '20261015000100_agent_board_session_provenance.sql',
368
+ '20261015000200_agent_board_request_preserve_removed.sql',
369
+ '20261015000300_agent_board_rls_correlation.sql',
370
+ '20261015000400_agent_board_resolution_authority.sql',
363
371
  ]) {
364
372
  expect(names.has(f)).toBe(true);
365
373
  }
@@ -700,6 +700,7 @@ select is( -- pgtap-allow-raise-checked: catalog read only; the RPC names below
700
700
  -- than naming the table itself. Internal — it reads rows for an arbitrary agent id with no
701
701
  -- authorization of its own, so service_role only, like every other helper here.
702
702
  'agent_board._actor_fleet_revoked(uuid) [definer] => service_role',
703
+ 'agent_board._board_memberships_last_owner_guard() [definer] => (none)',
703
704
  'agent_board._lock_board_authority_ceiling(uuid) [invoker] => service_role',
704
705
  'agent_board._redeem_fleet_invite_for_agent(text,uuid) [definer] => service_role',
705
706
  'agent_board._redeem_invite_for_agent(text,uuid) [definer] => service_role',
@@ -736,7 +737,8 @@ select is( -- pgtap-allow-raise-checked: catalog read only; the RPC names below
736
737
  'agent_board.signed_grant_board_role(jsonb,jsonb) [definer] => service_role',
737
738
  -- SIDSCHEMA integrity repair: the verified Portal withdrawal now takes board mutexes
738
739
  -- before the instance/family rows. Its service-only transport is unchanged.
739
- 'agent_board.tombstone_delegate_instance(text,text,text,text) [definer] => service_role'
740
+ 'agent_board.tombstone_delegate_instance(text,text,text,text) [definer] => service_role',
741
+ 'agent_board.update_board_membership(uuid,uuid,text,jsonb) [definer] => authenticated,service_role'
740
742
  ]::text[],
741
743
  'every function on this migration set''s surface is declared with its definer flag and exact EXECUTE grantees'
742
744
  );
@@ -837,6 +837,13 @@ select is(
837
837
  '0/0', 'generic-invite rejection leaves uses and redemption counts unchanged'
838
838
  );
839
839
 
840
+ -- Preserve a second owner while exercising the invite creator's lost membership.
841
+ insert into agent_board.agents(id, agent_type, handle, display_name, status)
842
+ values ('16210000-0000-0000-0000-000000000002', 'agent', 'atomic-runtime-coowner', 'Atomic Runtime Coowner', 'active');
843
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, role)
844
+ select board_id, '16210000-0000-0000-0000-000000000002', 'joined', 'owner' from _atomic_cfg;
845
+ select agent_board._lock_board_authority_ceiling(board_id) from _atomic_cfg;
846
+
840
847
  update agent_board.board_memberships
841
848
  set membership_state='removed', left_at=now(), updated_at=now()
842
849
  where board_id=(select board_id from _atomic_cfg)
@@ -202,7 +202,7 @@ select is(pg_temp.state('c2000000-0000-4000-8000-000000000001','c1000000-0000-40
202
202
  'joined', 'EC-2 join_board: re-entry after leaving still works');
203
203
 
204
204
  -- ===========================================================================
205
- -- EC-3 OPERATOR -- the carve-out is preserved on every route
205
+ -- EC-3 OPERATOR -- direct placement remains; resolution requires owner/admin identity
206
206
  -- ===========================================================================
207
207
  select pg_temp.service();
208
208
  select lives_ok($$
@@ -215,13 +215,13 @@ select is(
215
215
  (select provenance from agent_board.board_membership_events
216
216
  where agent_id = 'c1000000-0000-4000-8000-000000000006' order by seq desc limit 1),
217
217
  'operator-designated', 'EC-3: ...recorded as an operator placement, never as consent');
218
- select lives_ok($$
218
+ select throws_ok($$
219
219
  select agent_board.resolve_board_request(
220
220
  'c2000000-0000-4000-8000-000000000001'::uuid,'c1000000-0000-4000-8000-000000000003'::uuid,
221
221
  'joined'::text, null::uuid, '{}'::jsonb) $$,
222
- 'EC-3: service_role may resolve a pending row the gate refuses to an owner [call succeeds]');
222
+ '42501', null, 'EC-3: unresolved service_role cannot resolve pending membership');
223
223
  select is(pg_temp.state('c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000003'),
224
- 'joined', 'EC-3: service_role may resolve a pending row the gate refuses to an owner');
224
+ 'requested', 'EC-3: refused service_role resolution preserves the pending row');
225
225
 
226
226
  -- ===========================================================================
227
227
  -- EC-6 / EC-7 -- consent is EXERCISABLE (#108), and the widening admits nobody else
@@ -297,7 +297,8 @@ select is(
297
297
  'grant_board_role: agents(share) then memberships(update) [advisory]',
298
298
  'invite_board_member: memberships(update) then agents(key share) [unserialised]',
299
299
  'join_board: memberships(update) then agents(key share) [unserialised]',
300
- 'leave_board: memberships(update) then agents(key share) [unserialised]',
300
+ -- ROLEGUARD: the departure RPC serializes on the board key before its sink.
301
+ 'leave_board: memberships(update) then agents(key share) [advisory]',
301
302
  -- SIDSCHEMA role writers use the existing consent sink and board mutex.
302
303
  -- Their installed lock order is asserted, not exempted from the class.
303
304
  'link_session_alias: agents(update) then memberships(update) [advisory]',
@@ -319,7 +320,7 @@ select is(
319
320
  -- SIDSCHEMA integrity repair: board mutexes precede owner/instance and family row locks,
320
321
  -- matching add_delegate_to_board's board-before-instance order. No withdrawal exemption.
321
322
  'tombstone_delegate_instance: agents(update) then memberships(update) [advisory]',
322
- 'update_board_membership: memberships(update) then agents(key share) [unserialised]'
323
+ 'update_board_membership: memberships(update) then agents(key share) [advisory]'
323
324
  ]::text[],
324
325
  'FKL-9: the agents+board_memberships lock class, FK edges included (section 9''s text scan sees 3 of these)');
325
326
 
@@ -23,7 +23,7 @@ set search_path = extensions, public, agent_board;
23
23
 
24
24
  begin;
25
25
 
26
- select plan(27);
26
+ select plan(28);
27
27
 
28
28
  -- ---------------------------------------------------------------------------
29
29
  -- Phase 0: exactly one pg_proc row per name (a changed arg list would OVERLOAD, not replace)
@@ -340,13 +340,16 @@ select set_config('request.jwt.claim.role', 'authenticated', true);
340
340
  select throws_ok(
341
341
  $$ select agent_board.resolve_board_request((select board from _f), (select nopend from _f3), 'joined', (select owner_agent from _f)) $$,
342
342
  'P0002', null, 'NULLFIX: cannot resolve an agent with no pending request straight to joined');
343
- -- 27 — resolving a GENUINE request still works (service path).
343
+ -- Consiliency/message-board#668: resolution requires a resolved owner/admin identity.
344
344
  reset role;
345
345
  select set_config('request.jwt.claim.sub', '', true);
346
346
  select set_config('request.jwt.claim.role', '', true);
347
- select lives_ok(
347
+ select throws_ok(
348
348
  $$ select agent_board.resolve_board_request((select board from _f), (select victim from _f), 'joined', (select owner_agent from _f)) $$,
349
- 'NO-REGRESSION: a genuine invited/requested membership still resolves');
349
+ '42501', null, 'unresolved service_role cannot resolve a pending membership');
350
+ select is((select membership_state from agent_board.board_memberships
351
+ where board_id=(select board from _f) and agent_id=(select victim from _f)),
352
+ 'invited', 'service-role refusal preserves the pending membership');
350
353
 
351
354
  select * from finish();
352
355
  rollback;
@@ -611,9 +611,9 @@ select results_eq(format('select label, membership_state, role from pg_temp.g674
611
611
  $$values ('G', 'joined', 'owner')$$,
612
612
  'A3.pre');
613
613
  select pg_temp.g674_as('A3', 'G');
614
- select lives_ok(format('select * from agent_board.grant_board_role(%L::uuid, %L::uuid, %L, null)', pg_temp.g674_b('A3'), pg_temp.g674_a('A3', 'G'), 'admin'), 'A3.call');
614
+ select throws_ok(format('select * from agent_board.grant_board_role(%L::uuid, %L::uuid, %L, null)', pg_temp.g674_b('A3'), pg_temp.g674_a('A3', 'G'), 'admin'), '23514', 'last_active_board_owner: cannot remove the last active owner', 'A3.call');
615
615
  select pg_temp.g674_service();
616
- select is(pg_temp.g674_post('A3', 'G'), 'G[joined/admin/written] owners=0', 'A3.post');
616
+ select is(pg_temp.g674_post('A3', 'G'), 'G[joined/owner/pinned] owners=1', 'A3.post');
617
617
 
618
618
  -- A4: admin -> member, admin: ceiling 3<=3, then 3>2
619
619
  insert into _g674_case values ('A4', pg_temp.g674_board('{"O": "owner", "G": "admin", "T": "member", "_creator": "O"}'::jsonb));
@@ -711,9 +711,9 @@ select results_eq(format('select label, membership_state, role from pg_temp.g674
711
711
  $$values ('G', 'joined', 'owner')$$,
712
712
  'A10.3.pre');
713
713
  select pg_temp.g674_as('A10.3', 'G');
714
- select lives_ok(format('select * from agent_board.grant_board_role(%L::uuid, %L::uuid, %L, null)', pg_temp.g674_b('A10.3'), pg_temp.g674_a('A10.3', 'G'), 'admin'), 'A10.3.call');
714
+ select throws_ok(format('select * from agent_board.grant_board_role(%L::uuid, %L::uuid, %L, null)', pg_temp.g674_b('A10.3'), pg_temp.g674_a('A10.3', 'G'), 'admin'), '23514', 'last_active_board_owner: cannot remove the last active owner', 'A10.3.call');
715
715
  select pg_temp.g674_service();
716
- select is(pg_temp.g674_post('A10.3', 'G'), 'G[joined/admin/written] owners=0', 'A10.3.post');
716
+ select is(pg_temp.g674_post('A10.3', 'G'), 'G[joined/owner/pinned] owners=1', 'A10.3.post');
717
717
 
718
718
  -- A10.4: A4 with the role gate on
719
719
  insert into _g674_case values ('A10.4', pg_temp.g674_board('{"O": "owner", "G": "admin", "T": "member", "_creator": "O", "_gate": true}'::jsonb));
@@ -165,16 +165,17 @@ begin
165
165
  end loop;
166
166
  end $$;
167
167
 
168
- -- Keyed agents: K (board owner/admin everywhere), K2 (member), Z1 (only on BZ).
168
+ -- Keyed agents: K (board owner/admin everywhere), K2 (member), Z1 (only on BZ),
169
+ -- and a coowner of BW so its principal-departure cascade leaves an active owner.
169
170
  insert into auth.users(id, instance_id, aud, role, email)
170
171
  select ('5e000000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid,
171
172
  '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'hosted-k' || n || '@t.test'
172
- from generate_series(1, 3) n;
173
+ from generate_series(1, 4) n;
173
174
  insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id)
174
175
  select ('5e100000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid, 'agent', 'agent',
175
176
  'hosted-keyed-' || n, 'Hosted Keyed ' || n, 'active',
176
177
  ('5e000000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid
177
- from generate_series(1, 3) n;
178
+ from generate_series(1, 4) n;
178
179
  select pg_temp.put('K', '5e100000-0000-4000-8000-000000000001'),
179
180
  pg_temp.put('K.auth', '5e000000-0000-4000-8000-000000000001'),
180
181
  pg_temp.put('K2', '5e100000-0000-4000-8000-000000000002'),
@@ -200,6 +201,7 @@ values (pg_temp.u('BX'), pg_temp.u('K'), 'owner', 'joined', pg_temp.u('K')),
200
201
  (pg_temp.u('BZ'), pg_temp.u('K'), 'owner', 'joined', pg_temp.u('K')),
201
202
  (pg_temp.u('BZ'), pg_temp.u('Z1'), 'member', 'joined', pg_temp.u('K')),
202
203
  (pg_temp.u('BW'), pg_temp.u('OA'), 'owner', 'joined', pg_temp.u('OA')),
204
+ (pg_temp.u('BW'), '5e100000-0000-4000-8000-000000000004', 'owner', 'joined', pg_temp.u('OA')),
203
205
  (pg_temp.u('BW'), pg_temp.u('K'), 'member', 'joined', pg_temp.u('OA'));
204
206
 
205
207
  -- ===========================================================================
@@ -513,14 +513,16 @@ select is(
513
513
  -- A new view over this table is then a loud event that forces its author to decide whether it
514
514
  -- is writable, which is the decision that would otherwise be made silently.
515
515
  select is(
516
- (select coalesce(array_agg(t.tgname::text order by t.tgname::text), array[]::text[])
516
+ (select coalesce(array_agg(t.tgname::text || ':' || t.tgtype::text || ':' || p.proname::text
517
+ order by t.tgname::text), array[]::text[])
517
518
  from pg_trigger t
518
519
  join pg_class c on c.oid = t.tgrelid
519
520
  join pg_namespace n on n.oid = c.relnamespace
521
+ join pg_proc p on p.oid = t.tgfoid
520
522
  where n.nspname = 'agent_board' and c.relname = 'board_memberships'
521
523
  and not t.tgisinternal),
522
- array[]::text[],
523
- '#317: no user trigger on board_memberships — a trigger assigning NEW.membership_state would bypass every source predicate above');
524
+ array['trg_board_memberships_last_owner_guard:27:_board_memberships_last_owner_guard']::text[],
525
+ '#317: only the exact UPDATE/DELETE last-owner guard is installed; new state-rewriting triggers name themselves (preserved-state controls are in agent_board_role_guard)');
524
526
 
525
527
  select is(
526
528
  (select coalesce(array_agg(r.rulename::text order by r.rulename::text), array[]::text[])
@@ -652,6 +652,13 @@ select is(
652
652
 
653
653
  select pg_temp.service();
654
654
 
655
+ -- Preserve a coowner while testing the returning owner's role clamp.
656
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status)
657
+ values ('27610000-0000-4000-8000-000000000007','agent','agent','k276-coowner','K276 Coowner','active');
658
+ insert into agent_board.board_memberships(board_id,agent_id,role,membership_state,created_by_agent_id)
659
+ values ('27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000007','owner','joined','27610000-0000-4000-8000-000000000003');
660
+ select agent_board._lock_board_authority_ceiling('27620000-0000-4000-8000-000000000002');
661
+
655
662
  select lives_ok(
656
663
  $$create temp table _cl as select agent_board._upsert_board_membership_state(
657
664
  '27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000003',
@@ -0,0 +1,189 @@
1
+ create extension if not exists pgtap with schema extensions;
2
+ set search_path = extensions, public, agent_board;
3
+ begin;
4
+ select plan(41);
5
+ create function pg_temp.a(n integer) returns uuid language sql immutable as $$
6
+ select ('58900000-0000-4000-8000-'||lpad(n::text,12,'0'))::uuid
7
+ $$;
8
+ create function pg_temp.claim(n integer) returns void language plpgsql as $$
9
+ begin perform set_config('request.jwt.claims',json_build_object('role','authenticated','sub',pg_temp.a(n))::text,true); end $$;
10
+ insert into auth.users(id,instance_id,aud,role,email)
11
+ select pg_temp.a(n),'00000000-0000-0000-0000-000000000000','authenticated','authenticated','rls589-'||n||'@test.invalid' from generate_series(1,9)n;
12
+ insert into agent_board.agents(id,agent_type,handle,display_name,status,auth_user_id)
13
+ select pg_temp.a(n),'agent','rls589-'||n,'RLS '||n,'active',pg_temp.a(n) from generate_series(1,9)n;
14
+ insert into agent_board.boards(id,slug,name,created_by_agent_id,visibility) values(pg_temp.a(200),'rls589','RLS',pg_temp.a(1),'private');
15
+ insert into agent_board.board_memberships(id,board_id,agent_id,membership_state,role,created_by_agent_id)
16
+ values(pg_temp.a(201),pg_temp.a(200),pg_temp.a(1),'joined','owner',pg_temp.a(1)),
17
+ (pg_temp.a(202),pg_temp.a(200),pg_temp.a(3),'joined','member',pg_temp.a(3));
18
+ insert into agent_board.threads(id,created_by_agent_id,subject)
19
+ select pg_temp.a(n),pg_temp.a(1),'RLS '||n from generate_series(100,106)n;
20
+ insert into agent_board.messages(id,thread_id,author_agent_id,message_sequence,body)
21
+ values(pg_temp.a(300),pg_temp.a(100),pg_temp.a(2),1,'Board-visible message'),
22
+ (pg_temp.a(301),pg_temp.a(103),pg_temp.a(1),1,'Unrelated message');
23
+ insert into agent_board.tasks(id,thread_id,created_by_agent_id,assigned_agent_id,title,parent_task_id,root_task_id)
24
+ values(pg_temp.a(400),pg_temp.a(101),pg_temp.a(1),pg_temp.a(5),'Assigned task',null,null),
25
+ (pg_temp.a(401),pg_temp.a(102),pg_temp.a(6),pg_temp.a(6),'Parent',null,null),
26
+ (pg_temp.a(402),pg_temp.a(102),pg_temp.a(7),pg_temp.a(7),'Child',pg_temp.a(401),pg_temp.a(401)),
27
+ (pg_temp.a(403),pg_temp.a(103),pg_temp.a(1),pg_temp.a(1),'Unrelated parent',null,null),
28
+ (pg_temp.a(404),pg_temp.a(103),pg_temp.a(1),pg_temp.a(1),'Unrelated child',pg_temp.a(403),pg_temp.a(403)),
29
+ (pg_temp.a(405),pg_temp.a(105),pg_temp.a(1),pg_temp.a(1),'Board-delivered task',null,null);
30
+ insert into agent_board.delegations(id,parent_task_id,child_task_id,delegated_by_agent_id,delegated_to_agent_id)
31
+ values(pg_temp.a(500),pg_temp.a(401),pg_temp.a(402),pg_temp.a(8),pg_temp.a(9)),
32
+ (pg_temp.a(501),pg_temp.a(403),pg_temp.a(404),pg_temp.a(1),pg_temp.a(1));
33
+ insert into agent_board.delivery_recipients(delivery_mode,recipient_agent_id,source_kind,source_message_id,source_task_id,board_id,board_membership_id)
34
+ values('board',pg_temp.a(1),'message',pg_temp.a(300),null,pg_temp.a(200),pg_temp.a(201)),
35
+ ('direct',pg_temp.a(9),'message',pg_temp.a(300),null,null,null),
36
+ ('tasking',pg_temp.a(1),'task',null,pg_temp.a(405),pg_temp.a(200),pg_temp.a(201));
37
+ insert into agent_board.board_authority_ceilings(board_id) values(pg_temp.a(200));
38
+ insert into agent_board.thread_memberships(id,thread_id,agent_id,granted_by_agent_id)
39
+ values(pg_temp.a(600),pg_temp.a(100),pg_temp.a(2),pg_temp.a(1));
40
+ insert into agent_board.read_receipts(thread_id,agent_id) values(pg_temp.a(104),pg_temp.a(3));
41
+ create temp table captured(label text,id uuid);
42
+ grant select,insert on captured to authenticated;
43
+
44
+ -- Compare all policies, deriving FK/key column pairs from the constraint catalog.
45
+ create temp table policy_sites as
46
+ select p.oid,p.polname,c.relname,p.polroles,
47
+ agent_board_test.strip_sql(coalesce(pg_get_expr(p.polqual,p.polrelid),'')||' '||coalesce(pg_get_expr(p.polwithcheck,p.polrelid),'')) as expr
48
+ from pg_policy p join pg_class c on c.oid=p.polrelid join pg_namespace n on n.oid=c.relnamespace where n.nspname='agent_board';
49
+ with pairs as (
50
+ select distinct a.attname::text as fk,b.attname::text as key
51
+ from pg_constraint c join pg_class r on r.oid=c.conrelid join pg_namespace n on n.oid=r.relnamespace
52
+ cross join lateral unnest(c.conkey,c.confkey) k(src,dst)
53
+ join pg_attribute a on a.attrelid=c.conrelid and a.attnum=k.src
54
+ join pg_attribute b on b.attrelid=c.confrelid and b.attnum=k.dst
55
+ where c.contype='f' and n.nspname='agent_board'
56
+ ), refs as (
57
+ select s.relname||'.'||s.polname||': '||m[1]||'.'||m[2]||' = '||m[1]||'.'||m[3] as site,trim(both '"' from m[2]) as lhs,trim(both '"' from m[3]) as rhs
58
+ from policy_sites s cross join lateral regexp_matches(s.expr,agent_board_test.re('('||agent_board_test.ident_any_frag()||')\s*\.\s*('||agent_board_test.ident_any_frag()||')\s*=\s*\1\s*\.\s*('||agent_board_test.ident_any_frag()||')'),'g')m
59
+ )
60
+ select is((select coalesce(string_agg(distinct site,', ' order by site),'') from refs where lhs=rhs or exists(select 1 from pairs where (lhs=fk and rhs=key) or (rhs=fk and lhs=key))),'','RLS-1 no policy self-comparison or same-alias foreign-key/key comparison; offenders named');
61
+ create temp table policy_functions as
62
+ select distinct s.relname,s.polname,f.oid,f.oid::regprocedure::text as signature,f.prosecdef
63
+ from policy_sites s join pg_depend d on d.classid='pg_policy'::regclass and d.objid=s.oid and d.refclassid='pg_proc'::regclass
64
+ join pg_proc f on f.oid=d.refobjid join pg_namespace n on n.oid=f.pronamespace
65
+ where n.nspname='agent_board' and exists(select 1 from unnest(s.polroles) r(role_id)
66
+ where case when role_id=0 then true else pg_has_role('authenticated',role_id,'USAGE') end);
67
+ select is((select coalesce(string_agg(relname||'.'||polname||': '||signature,', ' order by relname,polname,signature),'') from policy_functions where not has_function_privilege('authenticated',oid,'EXECUTE')),'','RLS-2 every authenticated policy function is executable; offenders named');
68
+ select cmp_ok((select count(*) from policy_sites),'>',0::bigint,'RLS-3 catalog policy class is nonempty');
69
+ select is((select coalesce(string_agg(distinct signature,', ' order by signature),'') from policy_functions where prosecdef and has_function_privilege('anon',oid,'EXECUTE')),'','RLS-4 authenticated policy definers are not anonymous-callable');
70
+
71
+ select pg_temp.claim(2);
72
+ set local role authenticated;
73
+ select lives_ok($$insert into captured select 'message-author',id from agent_board.threads where id in (pg_temp.a(100),pg_temp.a(103))$$,'RLS-5 message-author SELECT executes');
74
+ reset role;
75
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='message-author'),array[pg_temp.a(100)]::uuid[],'RLS-6 message-author visible rows are scoped');
76
+
77
+ select pg_temp.claim(3);
78
+ set local role authenticated;
79
+ select lives_ok($$insert into captured select 'board-member',id from agent_board.threads where id in (pg_temp.a(100),pg_temp.a(103))$$,'RLS-7 board-member SELECT executes');
80
+ reset role;
81
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='board-member'),array[pg_temp.a(100)]::uuid[],'RLS-8 board-member visible rows are scoped');
82
+
83
+ select pg_temp.claim(5);
84
+ set local role authenticated;
85
+ select lives_ok($$insert into captured select 'task-assignee',id from agent_board.threads where id in (pg_temp.a(101),pg_temp.a(103))$$,'RLS-9 task-assignee SELECT executes');
86
+ reset role;
87
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='task-assignee'),array[pg_temp.a(101)]::uuid[],'RLS-10 task-assignee visible rows are scoped');
88
+
89
+ select pg_temp.claim(6);
90
+ set local role authenticated;
91
+ select lives_ok($$insert into captured select 'parent-task-owner',id from agent_board.delegations where id in (pg_temp.a(500),pg_temp.a(501))$$,'RLS-11 parent-task-owner SELECT executes');
92
+ reset role;
93
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='parent-task-owner'),array[pg_temp.a(500)]::uuid[],'RLS-12 parent-task-owner visible rows are scoped');
94
+
95
+ select pg_temp.claim(7);
96
+ set local role authenticated;
97
+ select lives_ok($$insert into captured select 'child-task-owner',id from agent_board.delegations where id in (pg_temp.a(500),pg_temp.a(501))$$,'RLS-13 child-task-owner SELECT executes');
98
+ reset role;
99
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='child-task-owner'),array[pg_temp.a(500)]::uuid[],'RLS-14 child-task-owner visible rows are scoped');
100
+
101
+ select pg_temp.claim(8);
102
+ set local role authenticated;
103
+ select lives_ok($$insert into captured select 'delegator',id from agent_board.delegations where id in (pg_temp.a(500),pg_temp.a(501))$$,'RLS-15 delegator SELECT executes');
104
+ reset role;
105
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='delegator'),array[pg_temp.a(500)]::uuid[],'RLS-16 delegator visible rows are scoped');
106
+
107
+ select pg_temp.claim(9);
108
+ set local role authenticated;
109
+ select lives_ok($$insert into captured select 'delegate-recipient',id from agent_board.delegations where id in (pg_temp.a(500),pg_temp.a(501))$$,'RLS-17 delegate-recipient SELECT executes');
110
+ reset role;
111
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='delegate-recipient'),array[pg_temp.a(500)]::uuid[],'RLS-18 delegate-recipient visible rows are scoped');
112
+
113
+ select pg_temp.claim(1);
114
+ set local role authenticated;
115
+ select lives_ok($$insert into captured select 'thread-creator',id from agent_board.threads where id=pg_temp.a(106)$$,'RLS-19 thread-creator SELECT executes');
116
+ reset role;
117
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='thread-creator'),array[pg_temp.a(106)]::uuid[],'RLS-20 thread-creator visible rows are scoped');
118
+
119
+ select pg_temp.claim(4);
120
+ set local role authenticated;
121
+ select lives_ok($$insert into captured select 'outsider-threads',id from agent_board.threads where id between pg_temp.a(100) and pg_temp.a(105)$$,'RLS-21 outsider-threads SELECT executes');
122
+ reset role;
123
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='outsider-threads'),array[]::uuid[],'RLS-22 outsider-threads visible rows are scoped');
124
+
125
+ select pg_temp.claim(4);
126
+ set local role authenticated;
127
+ select lives_ok($$insert into captured select 'outsider-delegations',id from agent_board.delegations where id in (pg_temp.a(500),pg_temp.a(501))$$,'RLS-23 outsider-delegations SELECT executes');
128
+ reset role;
129
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='outsider-delegations'),array[]::uuid[],'RLS-24 outsider-delegations visible rows are scoped');
130
+
131
+ select pg_temp.claim(3);
132
+ set local role authenticated;
133
+ select lives_ok($$insert into captured select 'member-ceiling',board_id from agent_board.board_authority_ceilings where board_id=pg_temp.a(200)$$,'RLS-25 member-ceiling SELECT executes');
134
+ reset role;
135
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='member-ceiling'),array[pg_temp.a(200)]::uuid[],'RLS-26 member-ceiling visible rows are scoped');
136
+
137
+ select pg_temp.claim(4);
138
+ set local role authenticated;
139
+ select lives_ok($$insert into captured select 'outsider-ceiling',board_id from agent_board.board_authority_ceilings where board_id=pg_temp.a(200)$$,'RLS-27 outsider-ceiling SELECT executes');
140
+ reset role;
141
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='outsider-ceiling'),array[]::uuid[],'RLS-28 outsider-ceiling visible rows are scoped');
142
+
143
+ select pg_temp.claim(3);
144
+ set local role authenticated;
145
+ select lives_ok($$insert into captured select 'member-thread-acl',id from agent_board.thread_memberships where id=pg_temp.a(600)$$,'RLS-29 member-thread-acl SELECT executes');
146
+ reset role;
147
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='member-thread-acl'),array[pg_temp.a(600)]::uuid[],'RLS-30 member-thread-acl visible rows are scoped');
148
+
149
+ select pg_temp.claim(4);
150
+ set local role authenticated;
151
+ select lives_ok($$insert into captured select 'outsider-thread-acl',id from agent_board.thread_memberships where id=pg_temp.a(600)$$,'RLS-31 outsider-thread-acl SELECT executes');
152
+ reset role;
153
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='outsider-thread-acl'),array[]::uuid[],'RLS-32 outsider-thread-acl visible rows are scoped');
154
+
155
+ select pg_temp.claim(3);
156
+ set local role authenticated;
157
+ select lives_ok($$insert into captured select 'receipt-reader',id from agent_board.threads where id=pg_temp.a(104)$$,'RLS-33 receipt-reader SELECT executes');
158
+ reset role;
159
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='receipt-reader'),array[pg_temp.a(104)]::uuid[],'RLS-34 receipt-reader visible rows are scoped');
160
+
161
+ select pg_temp.claim(3);
162
+ set local role authenticated;
163
+ select lives_ok($$insert into captured select 'member-unrelated-delegation',id from agent_board.delegations where id in (pg_temp.a(500),pg_temp.a(501))$$,'RLS-35 member-unrelated-delegation SELECT executes');
164
+ reset role;
165
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='member-unrelated-delegation'),array[]::uuid[],'RLS-36 member-unrelated-delegation visible rows are scoped');
166
+
167
+ select pg_temp.claim(3);
168
+ set local role authenticated;
169
+ select lives_ok($$insert into captured select 'board-task-delivery',id from agent_board.threads where id in (pg_temp.a(105),pg_temp.a(103))$$,'RLS-37 board-task-delivery SELECT executes');
170
+ reset role;
171
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='board-task-delivery'),array[pg_temp.a(105)]::uuid[],'RLS-38 board-task-delivery visible rows are scoped');
172
+
173
+ select pg_temp.claim(9);
174
+ set local role authenticated;
175
+ select lives_ok($$insert into captured select 'direct-message-recipient',id from agent_board.threads where id in (pg_temp.a(100),pg_temp.a(103))$$,'RLS-39 direct-message-recipient SELECT executes');
176
+ reset role;
177
+ select is((select coalesce(array_agg(id order by id),array[]::uuid[]) from captured where label='direct-message-recipient'),array[pg_temp.a(100)]::uuid[],'RLS-40 direct-message-recipient visible rows are scoped');
178
+
179
+
180
+ -- Catalog provenance is part of the read contract: grants must not erase the ruling.
181
+ select ok(coalesce(
182
+ obj_description('agent_board._current_agent_can_access_thread(uuid)'::regprocedure, 'pg_proc') like '%message-board#291%'
183
+ and obj_description('agent_board._current_agent_can_access_thread(uuid)'::regprocedure, 'pg_proc') like '%REVERSING A RULING%'
184
+ and obj_description('agent_board._current_agent_can_access_thread(uuid)'::regprocedure, 'pg_proc') like '%restricted%is UNREACHABLE%'
185
+ and obj_description('agent_board._current_agent_can_access_thread(uuid)'::regprocedure, 'pg_proc') like '%Callable by authenticated for its RLS policies and by service_role.%',
186
+ false), 'RLS-41 thread access comment preserves the ratified claims ruling and dormant ACL');
187
+
188
+ select * from finish();
189
+ rollback;
@@ -0,0 +1,96 @@
1
+ create extension if not exists pgtap with schema extensions;
2
+ set search_path = extensions, public, agent_board;
3
+ begin;
4
+ select plan(33);
5
+ create function pg_temp.a(n integer) returns uuid language sql immutable as $$
6
+ select ('68000000-0000-4000-8000-' || lpad(n::text,12,'0'))::uuid
7
+ $$;
8
+ create function pg_temp.claim(n integer) returns void language plpgsql as $$
9
+ begin
10
+ perform set_config('request.jwt.claims', json_build_object('role','authenticated','sub',pg_temp.a(n))::text,true);
11
+ end $$;
12
+ insert into auth.users(id, instance_id, aud, role, email)
13
+ select pg_temp.a(n),'00000000-0000-0000-0000-000000000000','authenticated','authenticated','removed680-'||n||'@test.invalid'
14
+ from generate_series(1,9) n;
15
+ insert into agent_board.agents(id,agent_type,handle,display_name,status,auth_user_id)
16
+ select pg_temp.a(n),'agent','removed680-'||n,'Removed '||n,'active',pg_temp.a(n)
17
+ from generate_series(1,9) n;
18
+ insert into agent_board.boards(id,slug,name,created_by_agent_id,visibility)
19
+ values (pg_temp.a(100),'removed680-open','Open',pg_temp.a(1),'open'),
20
+ (pg_temp.a(101),'removed680-link','Link',pg_temp.a(1),'invite_only'),
21
+ (pg_temp.a(102),'removed680-private','Private',pg_temp.a(1),'private');
22
+ insert into agent_board.board_memberships(board_id,agent_id,membership_state,role,created_by_agent_id)
23
+ select pg_temp.a(n),pg_temp.a(1),'joined','owner',pg_temp.a(1) from generate_series(100,102) n;
24
+ insert into agent_board.board_memberships(board_id,agent_id,membership_state,role,created_by_agent_id,left_at,metadata)
25
+ values (pg_temp.a(100),pg_temp.a(2),'removed','member',pg_temp.a(1),now(),'{"eviction":true}'),
26
+ (pg_temp.a(101),pg_temp.a(2),'removed','member',pg_temp.a(1),now(),'{"eviction":true}'),
27
+ (pg_temp.a(100),pg_temp.a(3),'removed','member',pg_temp.a(1),now(),'{"eviction":true}'),
28
+ (pg_temp.a(100),pg_temp.a(3),'left','member',pg_temp.a(1),now(),'{"left":true}'),
29
+ (pg_temp.a(100),pg_temp.a(5),'left','member',pg_temp.a(5),now(),'{}'),
30
+ (pg_temp.a(100),pg_temp.a(6),'requested','member',pg_temp.a(6),null,'{}'),
31
+ (pg_temp.a(100),pg_temp.a(7),'removed','member',pg_temp.a(1),now(),'{"eviction":true}'),
32
+ (pg_temp.a(100),pg_temp.a(7),'joined','member',pg_temp.a(7),null,'{"active":true}'),
33
+ (pg_temp.a(100),pg_temp.a(8),'subscribed','member',pg_temp.a(8),null,'{"active":true}');
34
+ update agent_board.board_memberships set created_at=now()-interval '1 day'
35
+ where board_id=pg_temp.a(100) and agent_id=pg_temp.a(3) and membership_state='removed';
36
+ select pg_temp.claim(1);
37
+ create temp table live_link as select * from agent_board.create_invite_link(pg_temp.a(101),'member','joined',null,null,pg_temp.a(1));
38
+
39
+ create temp table request_sites as
40
+ select p.oid,p.proname::text as name,agent_board_test.stripped_body(p.oid) as body,
41
+ agent_board_test.stripped_body(p.oid,true) as literals
42
+ from pg_proc p join pg_namespace n on n.oid=p.pronamespace
43
+ where n.nspname='agent_board' and has_function_privilege('authenticated',p.oid,'EXECUTE')
44
+ and agent_board_test.stripped_body(p.oid,true) ~ agent_board_test.re(
45
+ agent_board_test.call_frag('_upsert_board_membership_state') || '[^,;]*,[^,;]*,\s*''requested''\s*,');
46
+ select is((select string_agg(name,',' order by name) from request_sites),'request_join_board','REM-1 literal-request sink callers enumerated from catalog');
47
+ select is((select coalesce(string_agg(name,','),'') from request_sites where body !~ agent_board_test.row_lock_re('board_memberships')),'','REM-2 every request writer locks membership rows; offenders named');
48
+ select is((select coalesce(string_agg(name,','),'') from request_sites
49
+ where regexp_instr(body,agent_board_test.row_lock_re('board_memberships'))=0
50
+ or regexp_instr(body,agent_board_test.row_lock_re('board_memberships')) >= regexp_instr(body,agent_board_test.call_re('_upsert_board_membership_state'))
51
+ or regexp_instr(body,agent_board_test.row_lock_re('board_memberships')) >= regexp_instr(body,agent_board_test.re(agent_board_test.ci_re('if') || '\s+' || agent_board_test.ci_re('exists') || '\s*\('))
52
+ or literals !~ 'board join request refused for removed standing'),
53
+ '','REM-3 every request writer checks removed standing before its sink');
54
+ select is((select coalesce(string_agg(name,','),'') from request_sites where has_function_privilege('anon',oid,'EXECUTE') or not has_function_privilege('service_role',oid,'EXECUTE') or obj_description(oid,'pg_proc') is null),'','REM-4 request writer ACL and comment');
55
+
56
+ select pg_temp.claim(2);
57
+ select throws_ok($$select agent_board.request_join_board(pg_temp.a(100),pg_temp.a(2),'{"overwrite":true}')$$,'42501',null,'REM-5 removed open-board request refused');
58
+ select is((select membership_state from agent_board.board_memberships where board_id=pg_temp.a(100) and agent_id=pg_temp.a(2)),'removed','REM-6 eviction persists after request');
59
+ select is((select count(*)::integer from agent_board.board_membership_events where board_id=pg_temp.a(100) and agent_id=pg_temp.a(2)),0,'REM-7 refused request emits no membership event');
60
+ select throws_ok($$select agent_board.join_board(pg_temp.a(100),pg_temp.a(2))$$,'P0002',null,'REM-8 request cannot unlock open self-join');
61
+ select is((select membership_state from agent_board.board_memberships where board_id=pg_temp.a(100) and agent_id=pg_temp.a(2)),'removed','REM-9 self-join leaves eviction intact');
62
+ select throws_ok($$select agent_board.request_join_board(pg_temp.a(101),pg_temp.a(2))$$,'42501',null,'REM-10 removed invite-only request refused');
63
+ select is((select membership_state from agent_board.board_memberships where board_id=pg_temp.a(101) and agent_id=pg_temp.a(2)),'removed','REM-11 invite-only eviction persists');
64
+ select throws_ok($$select agent_board.redeem_invite_link((select code from live_link))$$,'42501',null,'REM-12 request cannot unlock live-link redemption');
65
+ select is((select string_agg(membership_state,',' order by membership_state) from agent_board.board_memberships where board_id=pg_temp.a(101) and agent_id=pg_temp.a(2)),'removed','REM-13 live link leaves eviction intact');
66
+ select is((select uses_count from agent_board.board_invite_tokens where id=(select invite_id from live_link)),0,'REM-14 refused redemption consumes no use');
67
+
68
+ select pg_temp.claim(3);
69
+ select throws_ok($$select agent_board.request_join_board(pg_temp.a(100),pg_temp.a(3),'{"overwrite":true}')$$,'42501',null,'REM-15 removed plus left standing refused');
70
+ select is((select string_agg(membership_state,',' order by membership_state) from agent_board.board_memberships where board_id=pg_temp.a(100) and agent_id=pg_temp.a(3)),'left,removed','REM-16 whole row set preserved');
71
+ select is((select metadata from agent_board.board_memberships where board_id=pg_temp.a(100) and agent_id=pg_temp.a(3) and membership_state='removed'),'{"eviction":true}'::jsonb,'REM-17 denied request preserves eviction metadata');
72
+ select pg_temp.claim(4);
73
+ select lives_ok($$select agent_board.request_join_board(pg_temp.a(100),pg_temp.a(4))$$,'REM-18 first request succeeds');
74
+ select is((select membership_state from agent_board.board_memberships where board_id=pg_temp.a(100) and agent_id=pg_temp.a(4)),'requested','REM-19 first request stored');
75
+ select pg_temp.claim(5);
76
+ select lives_ok($$select agent_board.request_join_board(pg_temp.a(100),pg_temp.a(5))$$,'REM-20 voluntary leaver can request');
77
+ select is((select membership_state from agent_board.board_memberships where board_id=pg_temp.a(100) and agent_id=pg_temp.a(5)),'requested','REM-21 voluntary request stored');
78
+ select pg_temp.claim(6);
79
+ select lives_ok($$select agent_board.request_join_board(pg_temp.a(100),pg_temp.a(6))$$,'REM-22 repeated pending request succeeds');
80
+ select is((select count(*)::integer from agent_board.board_memberships where board_id=pg_temp.a(100) and agent_id=pg_temp.a(6) and membership_state='requested'),1,'REM-23 repeat preserves one pending row');
81
+ select pg_temp.claim(7);
82
+ select lives_ok($$select agent_board.request_join_board(pg_temp.a(100),pg_temp.a(7),'{"overwrite":true}')$$,'REM-24 active plus removed remains a no-op');
83
+ select is((select metadata from agent_board.board_memberships where board_id=pg_temp.a(100) and agent_id=pg_temp.a(7) and membership_state='joined'),'{"active":true}'::jsonb,'REM-25 active row remains unchanged');
84
+ select pg_temp.claim(8);
85
+ select lives_ok($$select agent_board.request_join_board(pg_temp.a(100),pg_temp.a(8))$$,'REM-26 subscribed request remains a no-op');
86
+ select is((select membership_state from agent_board.board_memberships where board_id=pg_temp.a(100) and agent_id=pg_temp.a(8)),'subscribed','REM-27 subscription preserved');
87
+ select set_config('request.jwt.claims','{"role":"service_role"}',true);
88
+ select throws_ok($$select agent_board.request_join_board(pg_temp.a(100),pg_temp.a(2))$$,'42501',null,'REM-28 service request cannot erase removal');
89
+ select is((select membership_state from agent_board.board_memberships where board_id=pg_temp.a(100) and agent_id=pg_temp.a(2)),'removed','REM-29 service refusal preserves eviction');
90
+ select pg_temp.claim(9);
91
+ select throws_ok($$select agent_board.request_join_board(pg_temp.a(102),pg_temp.a(9))$$,'P0002',null,'REM-30 private board request still hidden');
92
+ select is((select count(*)::integer from agent_board.board_memberships where board_id=pg_temp.a(102) and agent_id=pg_temp.a(9)),0,'REM-31 hidden request writes nothing');
93
+ select throws_ok($$select agent_board.request_join_board(pg_temp.a(100),pg_temp.a(4))$$,'42501',null,'REM-32 request remains self-only');
94
+ select is((select membership_state from agent_board.board_memberships where board_id=pg_temp.a(100) and agent_id=pg_temp.a(4)),'requested','REM-33 actor mismatch preserves target');
95
+ select * from finish();
96
+ rollback;