@consiliency/agent-board-schema 1.60.0 → 1.64.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 (100) hide show
  1. package/README.md +86 -0
  2. package/fixtures/already-applied.json +16 -0
  3. package/migrations/20261011000100_agent_board_session_identities.sql +1964 -0
  4. package/migrations/20261012000100_agent_board_sidowner.sql +209 -0
  5. package/migrations/20261014000100_agent_board_grant_target_role.sql +218 -0
  6. package/migrations/20261014000200_agent_board_last_owner.sql +239 -0
  7. package/package.json +1 -1
  8. package/schema-manifest.json +39 -3
  9. package/src/tooling.test.mjs +10 -2
  10. package/tests/agent_board_atomic_fleet_onboarding.test.sql +15 -4
  11. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +7 -0
  12. package/tests/agent_board_consent_emission.test.sql +30 -11
  13. package/tests/agent_board_consent_gating.test.sql +16 -2
  14. package/tests/agent_board_delegate_role_class.test.sql +1 -1
  15. package/tests/agent_board_delegation_cap_ceiling.test.sql +26 -15
  16. package/tests/agent_board_delegation_enforcement.test.sql +1 -1
  17. package/tests/agent_board_enroll_resumption.test.sql +22 -4
  18. package/tests/agent_board_enroll_token_single_use.test.sql +18 -3
  19. package/tests/agent_board_federated_lock_order_class.test.sql +2 -2
  20. package/tests/agent_board_fk_lock_edges.test.sql +77 -16
  21. package/tests/agent_board_grant_target_role.test.sql +1118 -0
  22. package/tests/agent_board_hosted_delegates.test.sql +5 -3
  23. package/tests/agent_board_invite_no_downgrade.test.sql +15 -11
  24. package/tests/agent_board_ledger_authority.test.sql +77 -12
  25. package/tests/agent_board_membership_current_row_class.test.sql +9 -1
  26. package/tests/agent_board_mint_fleet_agent_session.test.sql +2 -2
  27. package/tests/agent_board_mint_harden.test.sql +38 -27
  28. package/tests/agent_board_role_guard.test.sql +319 -0
  29. package/tests/agent_board_rolereset.test.sql +25 -29
  30. package/tests/agent_board_roomdeleg.test.sql +8 -0
  31. package/tests/agent_board_roomdeleg_lane_b.test.sql +6 -6
  32. package/tests/agent_board_roomown.test.sql +3 -1
  33. package/tests/agent_board_rowid.test.sql +7 -2
  34. package/tests/agent_board_session_identity_approval_revocation.test.sql +334 -0
  35. package/tests/agent_board_session_identity_backfill.test.sql +82 -0
  36. package/tests/agent_board_session_identity_backfill_refusal.test.sql +14 -0
  37. package/tests/agent_board_session_identity_behavior_classes.test.sql +176 -0
  38. package/tests/agent_board_session_identity_class.test.sql +118 -0
  39. package/tests/agent_board_session_identity_fingerprint.test.sql +271 -0
  40. package/tests/agent_board_session_identity_labels_limits.test.sql +205 -0
  41. package/tests/agent_board_session_identity_lease.test.sql +235 -0
  42. package/tests/agent_board_session_identity_lifetime.test.sql +230 -0
  43. package/tests/agent_board_session_identity_limits.test.sql +208 -0
  44. package/tests/agent_board_session_identity_lineage.test.sql +64 -0
  45. package/tests/agent_board_session_identity_link.test.sql +201 -0
  46. package/tests/agent_board_session_identity_mint.test.sql +233 -0
  47. package/tests/agent_board_session_identity_panel_r1.test.sql +194 -0
  48. package/tests/agent_board_session_identity_panel_r2.test.sql +174 -0
  49. package/tests/agent_board_session_identity_protected_actions.test.sql +156 -0
  50. package/tests/agent_board_session_identity_revoke_closure.test.sql +131 -0
  51. package/tests/agent_board_sidowner_leases.test.sql +107 -0
  52. package/tests/agent_board_sidowner_owner_required.test.sql +97 -0
  53. package/tests/agent_board_sidowner_read_class.test.sql +171 -0
  54. package/tests/agent_board_sidowner_reads.test.sql +136 -0
  55. package/tests/agent_board_signer_lifecycle.test.sql +26 -4
  56. package/tests/agent_board_signing_challenge_pending_cap.test.sql +1 -1
  57. package/tests/agent_board_write_enforcement_class.test.sql +74 -15
  58. package/tests/catalog/function_execute_privileges.json +227 -2
  59. package/tests/catalog/p_metadata_parameters.json +102 -2
  60. package/tests/isolation/expected/grant_target_role_race.out +348 -0
  61. package/tests/isolation/expected/last_owner_departure.out +562 -0
  62. package/tests/isolation/expected/session_identity_limits.out +165 -0
  63. package/tests/isolation/expected/session_identity_links.out +235 -0
  64. package/tests/isolation/expected/session_identity_reclaim.out +253 -0
  65. package/tests/isolation/expected/session_identity_renew_revoke.out +115 -0
  66. package/tests/isolation/expected/session_identity_renew_revoke_key_gate.out +138 -0
  67. package/tests/isolation/expected/session_identity_tombstone_onboarding.out +247 -0
  68. package/tests/isolation/specs/atomic_fleet_authority_races.spec +8 -3
  69. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +0 -3
  70. package/tests/isolation/specs/atomic_fleet_board_archive.spec +0 -3
  71. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +0 -3
  72. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +0 -3
  73. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +0 -3
  74. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +0 -3
  75. package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +0 -3
  76. package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +0 -2
  77. package/tests/isolation/specs/claimpolicy_board_scope.spec +0 -2
  78. package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +0 -2
  79. package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +0 -2
  80. package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +0 -2
  81. package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +0 -2
  82. package/tests/isolation/specs/consent_approval_lock.spec +0 -1
  83. package/tests/isolation/specs/consent_emit_actual_state.spec +0 -1
  84. package/tests/isolation/specs/grant_target_role_race.spec +206 -0
  85. package/tests/isolation/specs/hosted_delegate_add_tombstone_race.spec +0 -1
  86. package/tests/isolation/specs/hosted_delegate_tombstone_revoke_race.spec +0 -1
  87. package/tests/isolation/specs/hosted_delegate_withdraw_evict_race.spec +0 -1
  88. package/tests/isolation/specs/last_owner_departure.spec +137 -0
  89. package/tests/isolation/specs/ledger_authority_revoke.spec +0 -3
  90. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +0 -3
  91. package/tests/isolation/specs/outbox_wake_ordering.spec +0 -1
  92. package/tests/isolation/specs/session_identity_limits.spec +81 -0
  93. package/tests/isolation/specs/session_identity_links.spec +82 -0
  94. package/tests/isolation/specs/session_identity_reclaim.spec +125 -0
  95. package/tests/isolation/specs/session_identity_renew_revoke.spec +84 -0
  96. package/tests/isolation/specs/session_identity_renew_revoke_key_gate.spec +94 -0
  97. package/tests/isolation/specs/session_identity_tombstone_onboarding.spec +107 -0
  98. package/tests/isolation/specs/signed_grant_onboarding_deadlock.spec +0 -1
  99. package/tests/isolation/specs/signer_lifecycle_revoke.spec +0 -1
  100. package/tests/seeds/20261011000100_agent_board_session_identities.sql.pre.sql +47 -0
@@ -0,0 +1,319 @@
1
+ -- Consiliency/message-board#675: actual authenticated RPCs, table paths,
2
+ -- catalog-derived writer classes, and paired allow cases. Mutations are recorded
3
+ -- in plans/evidence/roleguard; every run must emit its full plan.
4
+ create extension if not exists pgtap with schema extensions;
5
+ set search_path = extensions, public, agent_board;
6
+ begin;
7
+ select plan(76);
8
+ grant usage on schema extensions to authenticated, service_role;
9
+ -- On unexpected success, force the probe's subtransaction to roll back. The
10
+ -- wrong SQLSTATE still fails throws_ok, while later fixtures and plan counts survive.
11
+ create function pg_temp.refusal_probe(p_sql text) returns void language plpgsql as $$
12
+ begin
13
+ execute p_sql;
14
+ raise exception 'refusal probe unexpectedly succeeded' using errcode='PZ001';
15
+ end $$;
16
+ create function pg_temp.a(n int) returns uuid language sql immutable as $$
17
+ select ('67410000-0000-4000-8000-' || lpad(n::text,12,'0'))::uuid $$;
18
+ create function pg_temp.b(n int) returns uuid language sql immutable as $$
19
+ select ('67520000-0000-4000-8000-' || lpad(n::text,12,'0'))::uuid $$;
20
+ create function pg_temp.actor(n int) returns void language sql as $$
21
+ select set_config('request.jwt.claims',jsonb_build_object('role','authenticated',
22
+ 'sub','67400000-0000-0000-0000-' || lpad(n::text,12,'0'))::text,true)::text::void $$;
23
+ insert into auth.users(id,instance_id,aud,role,email)
24
+ select ('67400000-0000-0000-0000-' || lpad(n::text,12,'0'))::uuid,
25
+ '00000000-0000-0000-0000-000000000000','authenticated','authenticated','roleguard-'||n||'@test.invalid'
26
+ from generate_series(1,6) n;
27
+ insert into agent_board.agents(id,agent_type,kind,handle,display_name,status,auth_user_id)
28
+ select pg_temp.a(n),'agent','agent','roleguard-'||n,'Roleguard '||n,'active',
29
+ ('67400000-0000-0000-0000-' || lpad(n::text,12,'0'))::uuid from generate_series(1,6) n;
30
+ insert into agent_board.boards(id,slug,name,created_by_agent_id,visibility)
31
+ select pg_temp.b(n),'roleguard-'||n,'Roleguard '||n,pg_temp.a(1),'private' from generate_series(1,5) n;
32
+ insert into agent_board.board_memberships(board_id,agent_id,role,membership_state,created_by_agent_id)
33
+ select pg_temp.b(b),pg_temp.a(a),
34
+ case when a in (1,2) then 'owner' when a in (3,4) then 'admin' when a=5 then 'member' else 'observer' end,
35
+ case when b=4 and a=1 then 'subscribed' else 'joined' end,pg_temp.a(1)
36
+ from generate_series(1,5) b cross join generate_series(1,6) a
37
+ where (b in (1,5) or a<>2) and (b<>3 or a<>1);
38
+ -- Pending and terminal owners do not count as active owners.
39
+ insert into agent_board.board_memberships(board_id,agent_id,role,membership_state,created_by_agent_id)
40
+ values (pg_temp.b(2),pg_temp.a(2),'owner','invited',pg_temp.a(1));
41
+
42
+
43
+ select pg_temp.actor(1);
44
+ set local role authenticated;
45
+
46
+ select lives_ok($$select * from agent_board.grant_board_role(pg_temp.b(1),pg_temp.a(1),'admin')$$, 'LO-self-demote two owners allowed');
47
+
48
+ reset role;
49
+ select set_config('request.jwt.claims', '', true);
50
+
51
+ select is((select role from agent_board.board_memberships where board_id=pg_temp.b(1) and agent_id=pg_temp.a(1) and membership_state in ('joined','subscribed')), 'admin', 'LO-self-demote state');
52
+
53
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(1) and role='owner' and membership_state in ('joined','subscribed')), 1, 'LO-self-demote keeps one owner');
54
+
55
+ update agent_board.board_memberships set role='owner' where board_id=pg_temp.b(1) and agent_id=pg_temp.a(1);
56
+
57
+ select pg_temp.actor(1);
58
+ set local role authenticated;
59
+
60
+ select throws_ok($$select pg_temp.refusal_probe($probe$select * from agent_board.grant_board_role(pg_temp.b(2),pg_temp.a(1),'member')$probe$)$$,'23514','last_active_board_owner: cannot remove the last active owner','LO-last self-demote refused');
61
+
62
+ reset role;
63
+ select set_config('request.jwt.claims', '', true);
64
+
65
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(2) and role='owner' and membership_state in ('joined','subscribed')), 1, 'LO-last self-demote unchanged');
66
+
67
+ select pg_temp.actor(1);
68
+ set local role authenticated;
69
+
70
+ select throws_ok($$select pg_temp.refusal_probe($probe$select * from agent_board.leave_board(pg_temp.b(2),pg_temp.a(1))$probe$)$$,'23514','last_active_board_owner: cannot remove the last active owner','LO-last leave refused');
71
+
72
+ reset role;
73
+ select set_config('request.jwt.claims', '', true);
74
+
75
+ select is((select membership_state from agent_board.board_memberships where board_id=pg_temp.b(2) and agent_id=pg_temp.a(1)), 'joined', 'LO-last leave unchanged');
76
+
77
+ select pg_temp.actor(1);
78
+ set local role authenticated;
79
+
80
+ select lives_ok($$select * from agent_board.leave_board(pg_temp.b(1),pg_temp.a(1))$$, 'LO-two owners leave allowed');
81
+
82
+ reset role;
83
+ select set_config('request.jwt.claims', '', true);
84
+
85
+ select is((select membership_state from agent_board.board_memberships where board_id=pg_temp.b(1) and agent_id=pg_temp.a(1)), 'left', 'LO-two owners leave state');
86
+
87
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(1) and role='owner' and membership_state in ('joined','subscribed')), 1, 'LO-two owners leave keeps one');
88
+
89
+ set local role service_role;
90
+ select set_config('request.jwt.claims','{"role":"service_role"}',true);
91
+
92
+ select throws_ok($$select pg_temp.refusal_probe($probe$select * from agent_board.update_board_membership(pg_temp.b(2),pg_temp.a(1),'removed')$probe$)$$,'23514','last_active_board_owner: cannot remove the last active owner','LO-last removal service refused');
93
+
94
+ reset role;
95
+ select set_config('request.jwt.claims', '', true);
96
+
97
+ select is((select membership_state from agent_board.board_memberships where board_id=pg_temp.b(2) and agent_id=pg_temp.a(1)), 'joined', 'LO-last removal unchanged');
98
+
99
+ update agent_board.board_memberships set role='owner',membership_state='joined',left_at=null where board_id=pg_temp.b(1) and agent_id=pg_temp.a(1);
100
+
101
+ set local role service_role;
102
+ select set_config('request.jwt.claims','{"role":"service_role"}',true);
103
+
104
+ select lives_ok($$select * from agent_board.update_board_membership(pg_temp.b(1),pg_temp.a(1),'removed')$$, 'LO-two owners removal allowed');
105
+
106
+ reset role;
107
+ select set_config('request.jwt.claims', '', true);
108
+
109
+ select is((select membership_state from agent_board.board_memberships where board_id=pg_temp.b(1) and agent_id=pg_temp.a(1)), 'removed', 'LO-two owners removal state');
110
+
111
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(1) and role='owner' and membership_state in ('joined','subscribed')), 1, 'LO-two owners removal keeps one');
112
+
113
+ select throws_ok($$select pg_temp.refusal_probe($probe$delete from agent_board.board_memberships where board_id=pg_temp.b(5) and agent_id=pg_temp.a(1)$probe$)$$,'55000','last_active_board_owner: acquire board authority lock before owner transition','LO-raw owner transition requires prior board lock');
114
+
115
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(5) and role='owner' and membership_state in ('joined','subscribed')), 2, 'LO-unlocked raw transition unchanged');
116
+
117
+ select agent_board._lock_board_authority_ceiling(pg_temp.b(5));
118
+
119
+ select throws_ok($$select pg_temp.refusal_probe($probe$delete from agent_board.board_memberships where board_id=pg_temp.b(5) and role='owner'$probe$)$$,'23514','last_active_board_owner: cannot remove the last active owner','LO-bulk owners delete refused');
120
+
121
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(5) and role='owner' and membership_state in ('joined','subscribed')), 2, 'LO-bulk owners delete atomic rollback');
122
+
123
+ select lives_ok($$delete from agent_board.board_memberships where board_id=pg_temp.b(5) and agent_id=pg_temp.a(1)$$, 'LO-two owners raw delete allowed');
124
+
125
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(5) and role='owner' and membership_state in ('joined','subscribed')), 1, 'LO-two owners raw delete state');
126
+
127
+ select agent_board._lock_board_authority_ceiling(pg_temp.b(2));
128
+
129
+ select throws_ok($$select pg_temp.refusal_probe($probe$delete from agent_board.board_memberships where board_id=pg_temp.b(2) and agent_id=pg_temp.a(1)$probe$)$$,'23514','last_active_board_owner: cannot remove the last active owner','LO-raw-delete refused');
130
+
131
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(2) and role='owner' and membership_state in ('joined','subscribed')), 1, 'LO-raw-delete unchanged');
132
+
133
+ select throws_ok($$select pg_temp.refusal_probe($probe$update agent_board.board_memberships set role='member' where board_id=pg_temp.b(2) and agent_id=pg_temp.a(1)$probe$)$$,'23514','last_active_board_owner: cannot remove the last active owner','LO-raw-role refused');
134
+
135
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(2) and role='owner' and membership_state in ('joined','subscribed')), 1, 'LO-raw-role unchanged');
136
+
137
+ select throws_ok($$select pg_temp.refusal_probe($probe$update agent_board.board_memberships set board_id=pg_temp.b(3) where board_id=pg_temp.b(2) and agent_id=pg_temp.a(1)$probe$)$$,'23514','last_active_board_owner: cannot remove the last active owner','LO-raw-move refused');
138
+
139
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(2) and role='owner' and membership_state in ('joined','subscribed')), 1, 'LO-raw-move unchanged');
140
+
141
+ select throws_ok($$select pg_temp.refusal_probe($probe$select * from agent_board.update_board_membership(pg_temp.b(4),pg_temp.a(1),'left')$probe$)$$,'23514','last_active_board_owner: cannot remove the last active owner','LO-subscribed-leave refused');
142
+
143
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(4) and role='owner' and membership_state in ('joined','subscribed')), 1, 'LO-subscribed-leave unchanged');
144
+
145
+ select throws_ok($$select pg_temp.refusal_probe($probe$delete from agent_board.agents where id=pg_temp.a(2)$probe$)$$,'23514','last_active_board_owner: cannot remove the last active owner','LO-cascade-agent refused');
146
+
147
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(1) and role='owner' and membership_state in ('joined','subscribed')), 1, 'LO-cascade-agent unchanged');
148
+
149
+ select lives_ok($$delete from agent_board.board_memberships where board_id=pg_temp.b(2) and agent_id=pg_temp.a(2)$$, 'LO-pending owner delete allowed');
150
+
151
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(2) and agent_id=pg_temp.a(2)),0,'LO-pending owner delete state');
152
+
153
+ select lives_ok($$update agent_board.board_memberships set metadata='{"unrelated":true}' where board_id=pg_temp.b(2) and agent_id=pg_temp.a(1)$$, 'LO-owner metadata allowed');
154
+
155
+ select is((select metadata->>'unrelated' from agent_board.board_memberships where board_id=pg_temp.b(2) and agent_id=pg_temp.a(1)),'true','LO-owner metadata state');
156
+
157
+ select lives_ok($$update agent_board.board_memberships set metadata='{"unrelated":true}' where board_id=pg_temp.b(3)$$, 'LO-ownerless unrelated update allowed');
158
+
159
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(3) and metadata->>'unrelated'='true'),4,'LO-ownerless unrelated state');
160
+
161
+ select pg_temp.actor(5);
162
+ set local role authenticated;
163
+
164
+ select lives_ok($$select * from agent_board.update_board_membership(pg_temp.b(3),pg_temp.a(5),'left')$$, 'LO-ownerless member leave allowed');
165
+
166
+ reset role;
167
+ select set_config('request.jwt.claims', '', true);
168
+
169
+ select is((select membership_state from agent_board.board_memberships where board_id=pg_temp.b(3) and agent_id=pg_temp.a(5)), 'left', 'LO-ownerless member leave state');
170
+
171
+ select pg_temp.actor(3);
172
+ set local role authenticated;
173
+
174
+ select throws_ok($$select pg_temp.refusal_probe($probe$select * from agent_board.grant_board_role(pg_temp.b(3),pg_temp.a(3),'owner')$probe$)$$, '42501', 'grant exceeds grantor role or board authority ceiling', 'LO-auth recovery refused');
175
+
176
+ select ok(not has_function_privilege('authenticated','agent_board.bootstrap_board_owner_memberships()','EXECUTE'),'LO-bootstrap recovery service-only');
177
+
178
+ reset role;
179
+ select set_config('request.jwt.claims', '', true);
180
+
181
+ set local role service_role;
182
+ select set_config('request.jwt.claims','{"role":"service_role"}',true);
183
+
184
+ select lives_ok($$select * from agent_board.bootstrap_board_owner_memberships()$$, 'LO-service recovery allowed');
185
+
186
+ reset role;
187
+ select set_config('request.jwt.claims', '', true);
188
+
189
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(3) and role='owner' and membership_state in ('joined','subscribed')), 1, 'LO-service recovery state');
190
+
191
+ select lives_ok($$delete from agent_board.boards where id=pg_temp.b(4)$$, 'LO-board deletion allowed');
192
+
193
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(4)),0,'LO-board deletion cascades');
194
+
195
+
196
+ -- Refusal-only behaviour: the trigger must return the proposed row verbatim.
197
+ select is((select jsonb_build_object('role',role,'state',membership_state,'agent',agent_id,'board',board_id,'metadata',metadata)
198
+ from agent_board.board_memberships where board_id=pg_temp.b(2) and agent_id=pg_temp.a(1)),
199
+ jsonb_build_object('role','owner','state','joined','agent',pg_temp.a(1),'board',pg_temp.b(2),'metadata','{"unrelated":true}'::jsonb),
200
+ 'LO-refusal-only preserves proposed owner row');
201
+
202
+ insert into agent_board.agents(id,agent_type,handle,display_name,status)
203
+ values (pg_temp.a(7),'agent','last-owner-replacement','Replacement','active');
204
+ select throws_ok($$select pg_temp.refusal_probe($probe$update agent_board.board_memberships set agent_id=pg_temp.a(2) where board_id=pg_temp.b(2) and agent_id=pg_temp.a(1)$probe$)$$,
205
+ '23514','last_active_board_owner: cannot remove the last active owner','LO-last owner reassignment refused');
206
+ select is((select agent_id from agent_board.board_memberships where board_id=pg_temp.b(2) and role='owner' and membership_state='joined'),pg_temp.a(1),'LO-last owner reassignment unchanged');
207
+ insert into agent_board.board_memberships(board_id,agent_id,role,membership_state,created_by_agent_id)
208
+ values (pg_temp.b(2),pg_temp.a(7),'owner','joined',pg_temp.a(1));
209
+ select lives_ok($$update agent_board.board_memberships set agent_id=pg_temp.a(2) where board_id=pg_temp.b(2) and agent_id=pg_temp.a(1)$$,'LO-two owners reassignment allowed');
210
+ select is((select count(*)::int from agent_board.board_memberships where board_id=pg_temp.b(2) and agent_id in (pg_temp.a(2),pg_temp.a(7)) and role='owner' and membership_state='joined'),2,'LO-two owners reassignment state');
211
+
212
+ -- Real SID holder: the parent is narrowed, and alias linking would demote the
213
+ -- child's sole owner membership. Refusal must roll back alias and authority.
214
+ create temp table _lo_sid(root_id uuid,owner_id uuid,board_id uuid,child_id uuid,key_id uuid,child_auth uuid,epoch bigint);
215
+ create function pg_temp.lo_sid_setup() returns void language plpgsql as $fixture$
216
+ declare v_root uuid:=gen_random_uuid(); v_board uuid:=gen_random_uuid();
217
+ v_owner_auth uuid; v_root_auth uuid; v_owner uuid; v_child record; v_challenge record; v_epoch bigint;
218
+ begin
219
+ v_owner_auth:=public.ensure_github_user('lo-owner-'||v_root,'lo-owner-'||v_root||'@test.invalid','lo-owner-'||v_root,'Last owner human');
220
+ v_root_auth:=public.ensure_github_user('lo-root-'||v_root,'lo-root-'||v_root||'@test.invalid','lo-root-'||v_root,'Last owner root');
221
+ insert into agent_board.agents(agent_type,handle,display_name,status,auth_user_id)
222
+ values ('human','lo-human-'||v_root,'Last owner human','active',v_owner_auth) returning id into v_owner;
223
+ insert into agent_board.agents(id,agent_type,handle,display_name,status,auth_user_id,owner_user_id)
224
+ values (v_root,'agent','lo-root-'||v_root,'Last owner root','active',v_root_auth,v_owner_auth);
225
+ insert into agent_board.session_identities(agent_id,root_agent_id,depth,approved_risky_digest,current_risky_digest)
226
+ values (v_root,v_root,0,'approved','approved');
227
+ insert into agent_board.boards(id,slug,name,created_by_agent_id) values (v_board,'lo-link-'||v_board,'Last owner link',v_root);
228
+ insert into agent_board.board_memberships(board_id,agent_id,role,membership_state) values (v_board,v_root,'owner','joined'),(v_board,v_owner,'owner','joined');
229
+ perform agent_board.upsert_delegation_policy(v_root,'root_minted','["read","write"]',200,interval '7 days',true);
230
+ select * into v_child from agent_board.mint_delegated_child(v_root,'lo-child-'||v_root,'["read","write"]',public.test_pubkey('lo-child-'||v_root),interval '8 hours',false,'claude','lo-child',null,
231
+ jsonb_build_array(jsonb_build_object('board_id',v_board,'role','owner')),'{"resolution":"resolved","risky_digest":"approved"}');
232
+ select * into v_challenge from agent_board.issue_signing_key_challenge(v_child.child_agent_id,'delegated-ephemeral');
233
+ select identity_lease_epoch into v_epoch from agent_board.mint_fleet_agent_session(v_challenge.challenge_id,split_part(v_challenge.challenge,':',3),v_child.signing_key_id,v_child.child_agent_id,900,null,'lo-child');
234
+ perform agent_board._lock_board_authority_ceiling(v_board);
235
+ update agent_board.board_memberships set role='admin' where board_id=v_board and agent_id in (v_root,v_owner);
236
+ insert into _lo_sid select v_root,v_owner,v_board,v_child.child_agent_id,v_child.signing_key_id,a.auth_user_id,v_epoch from agent_board.agents a where a.id=v_child.child_agent_id;
237
+ end $fixture$;
238
+ select set_config('request.jwt.claims','{"role":"service_role"}',true);
239
+ select set_config('request.jwt.claim.role','service_role',true);
240
+ select lives_ok($$select pg_temp.lo_sid_setup()$$,'LO-link fixture allowed');
241
+ select is((select count(*)::int from agent_board.board_memberships where board_id=(select board_id from _lo_sid) and role='owner' and membership_state in ('joined','subscribed')),1,'LO-link fixture has sole child owner');
242
+ grant select on _lo_sid to service_role;
243
+ select set_config('request.jwt.claim.sub',(select child_auth::text from _lo_sid),true);
244
+ select set_config('request.jwt.claim.identity_lease_epoch',(select epoch::text from _lo_sid),true);
245
+ set local role service_role;
246
+ select throws_ok($$select pg_temp.refusal_probe($probe$select agent_board.link_session_alias((select child_id from _lo_sid),(select epoch::text from _lo_sid),'lo-last-owner-refused')$probe$)$$,
247
+ '23514','last_active_board_owner: cannot remove the last active owner','LO-link sole-owner narrowing refused');
248
+ reset role;
249
+ select is((select role from agent_board.board_memberships where board_id=(select board_id from _lo_sid) and agent_id=(select child_id from _lo_sid)),'owner','LO-link refused membership unchanged');
250
+ select is((select count(*)::int from agent_board.session_id_aliases where root_agent_id=(select root_id from _lo_sid) and session_id='lo-last-owner-refused'),0,'LO-link refusal rolls back alias');
251
+ update agent_board.board_memberships set role='owner' where board_id=(select board_id from _lo_sid) and agent_id=(select owner_id from _lo_sid);
252
+ set local role service_role;
253
+ select lives_ok($$select agent_board.link_session_alias((select child_id from _lo_sid),(select epoch::text from _lo_sid),'lo-two-owners-allowed')$$,'LO-link narrowing with coowner allowed');
254
+ reset role;
255
+ select is((select role from agent_board.board_memberships where board_id=(select board_id from _lo_sid) and agent_id=(select child_id from _lo_sid)),'admin','LO-link allowed narrows child role');
256
+ select is((select count(*)::int from agent_board.board_memberships where board_id=(select board_id from _lo_sid) and role='owner' and membership_state in ('joined','subscribed')),1,'LO-link allowed retains coowner');
257
+ select set_config('request.jwt.claims','',true);
258
+ select set_config('request.jwt.claim.role','',true);
259
+ select set_config('request.jwt.claim.sub','',true);
260
+ select set_config('request.jwt.claim.identity_lease_epoch','',true);
261
+
262
+ select alike(obj_description('agent_board._lock_board_authority_ceiling(uuid)'::regprocedure,'pg_proc'),'%per-board departure mutex%','CLASS-board lock COMMENT documents departure mutex');
263
+
264
+ create temp view _roleguard_writers as
265
+ select p.oid,case when n.nspname='agent_board' then p.proname::text
266
+ else n.nspname||'.'||p.proname end as name,
267
+ agent_board_test.stripped_body(p.oid,true) as b,
268
+ agent_board_test.stripped_body(p.oid,true) ~ agent_board_test.col_write_re('board_memberships','role') as role_writer,
269
+ agent_board_test.stripped_body(p.oid,true) ~ agent_board_test.col_write_re('board_memberships','membership_state') as state_writer
270
+ from pg_proc p join pg_namespace n on n.oid=p.pronamespace
271
+ where n.nspname not in ('pg_catalog','information_schema','agent_board_test')
272
+ and n.nspname !~ '^pg_' and p.prokind in ('f','p')
273
+ and p.prolang <> (select oid from pg_language where lanname='internal')
274
+ and agent_board_test.stripped_body(p.oid,true) ~ agent_board_test.write_re('board_memberships');
275
+
276
+ select is((select string_agg(name,',' order by name) from _roleguard_writers where role_writer),'_fedsession_autojoin_default_board,_redeem_unbounded_invite_for_agent,_upsert_board_membership_state,bootstrap_board_owner_memberships,create_board,grant_board_role,link_session_alias,mint_delegated_child,readmit_session_identity','CLASS-role writers: a new role writer fails and names itself');
277
+
278
+ select is((select string_agg(name,',' order by name) from _roleguard_writers where state_writer),'_fedsession_autojoin_default_board,_redeem_unbounded_invite_for_agent,_upsert_board_membership_state,bootstrap_board_owner_memberships,create_board','CLASS-state writers: a new state writer fails and names itself');
279
+
280
+ select is((select string_agg(name,',' order by name) from _roleguard_writers),'_fedsession_autojoin_default_board,_redeem_unbounded_invite_for_agent,_upsert_board_membership_state,bootstrap_board_owner_memberships,create_board,grant_board_role,link_session_alias,mint_delegated_child,readmit_session_identity,set_board_member_label','CLASS-all table writers: new deletes and other writers name themselves');
281
+
282
+ -- TRUNCATE has no row trigger; application roles must never acquire that door.
283
+ select is((select coalesce(string_agg(r,',' order by r),'')
284
+ from unnest(array['anon','authenticated','service_role','agent_board_delegate']) r
285
+ where has_table_privilege(r,'agent_board.board_memberships','TRUNCATE')),
286
+ '', 'CLASS-no application TRUNCATE bypass of row invariant');
287
+
288
+ select ok((select count(*)=1 and bool_and(tgtype=27 and tgenabled='O' and tgqual is null and tgfoid=to_regprocedure('agent_board._board_memberships_last_owner_guard()')) from pg_trigger where tgrelid='agent_board.board_memberships'::regclass and tgname='trg_board_memberships_last_owner_guard'),'CLASS-table invariant: unconditional enabled BEFORE ROW UPDATE/DELETE trigger covers every writer');
289
+
290
+ select is((select count(*)::int from pg_proc p,aclexplode(coalesce(p.proacl,acldefault('f',p.proowner))) a where p.oid=to_regprocedure('agent_board._board_memberships_last_owner_guard()') and a.privilege_type='EXECUTE' and a.grantee<>p.proowner),0,'CLASS-trigger ACL: no API role or PUBLIC can execute internal trigger');
291
+
292
+ select isnt(obj_description(to_regprocedure('agent_board._board_memberships_last_owner_guard()'),'pg_proc'),null,'CLASS-trigger COMMENT exists');
293
+
294
+ select is((select md5(prosrc) from pg_proc where oid=to_regprocedure('agent_board.grant_board_role(uuid,uuid,text,uuid)')),'de7d1079623e95dcc376e1881492a44b','CLASS-main grant body unchanged'); -- pgtap-allow-raise-checked: non-raising catalog identity lookup; no RPC is executed.
295
+
296
+ select ok(strpos(agent_board_test.stripped_body('agent_board.update_board_membership(uuid,uuid,text,jsonb)'::regprocedure),'agent_board._lock_board_authority_ceiling(')>0 and strpos(agent_board_test.stripped_body('agent_board.update_board_membership(uuid,uuid,text,jsonb)'::regprocedure),'agent_board._lock_board_authority_ceiling(')<strpos(agent_board_test.stripped_body('agent_board.update_board_membership(uuid,uuid,text,jsonb)'::regprocedure),'for update'),'CLASS-departure advisory before membership row lock');
297
+
298
+ select ok((select b like '%select p_provider.default_board_id, p_agent_id, ''member'', ''joined''%' from _roleguard_writers where name='_fedsession_autojoin_default_board'),'CLASS-positive _fedsession_autojoin_default_board writer behavior');
299
+
300
+ select ok((select b like '%values (v_row.board_id, v_row.owner_agent_id, ''joined'', ''owner'', v_row.owner_agent_id)%' from _roleguard_writers where name='bootstrap_board_owner_memberships'),'CLASS-positive bootstrap_board_owner_memberships writer behavior');
301
+
302
+ select ok((select b like '%values (v_board.id, v_board.created_by_agent_id, ''joined'', ''owner'', v_board.created_by_agent_id)%' from _roleguard_writers where name='create_board'),'CLASS-positive create_board writer behavior');
303
+
304
+ select ok((select b like '%when agent_board._role_rank(excluded.role) > agent_board._role_rank(agent_board.board_memberships.role)%' from _roleguard_writers where name='_redeem_unbounded_invite_for_agent'),'CLASS-positive _redeem_unbounded_invite_for_agent writer behavior');
305
+
306
+ select ok((select b like '%v_membership.membership_state not in (''joined'', ''subscribed'')
307
+ or v_currently_removed
308
+ )
309
+ then ''member''
310
+ else bm.role%' from _roleguard_writers where name='_upsert_board_membership_state'),'CLASS-positive _upsert_board_membership_state writer behavior');
311
+
312
+ select ok((select b like '%update agent_board.board_memberships set role = v_role where id = v_membership.id%' from _roleguard_writers where name='mint_delegated_child'),'CLASS-positive mint_delegated_child writer behavior');
313
+
314
+ select ok((select b like '%update agent_board.board_memberships set role = v_role where id = v_membership.id%' from _roleguard_writers where name='readmit_session_identity'),'CLASS-positive readmit_session_identity writer behavior');
315
+
316
+ select ok((select b like '%update agent_board.board_memberships set role = v_role where id = v_membership.id%' from _roleguard_writers where name='link_session_alias'),'CLASS-positive link_session_alias writer behavior');
317
+
318
+ select finish();
319
+ rollback;
@@ -76,6 +76,10 @@ begin
76
76
  select board_id into v_open
77
77
  from agent_board.create_board(v_owner, 'rr-open-' || v_suffix, 'RR Open', 'rolereset open', '{}'::jsonb, 'open');
78
78
 
79
+ -- A second owner preserves the role-reset test without violating last-owner integrity.
80
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, role, created_by_agent_id)
81
+ values (v_open, v_admin, 'joined', 'owner', v_owner);
82
+
79
83
  -- Board PRIVATE: hosts the per-origin clamp matrix, driven service_role so the
80
84
  -- transition itself is never the thing under test.
81
85
  select board_id into v_priv
@@ -265,6 +269,12 @@ select is(
265
269
  -- behavioural assertion above, because the UPDATE is never reached with a non-active row
266
270
  -- unless an eviction lands between the pre-check and the UPDATE (the TOCTOU window).
267
271
  -- Both review seats flagged this independently; assert the predicate structurally.
272
+ --
273
+ -- #674 rewrote this from `>= 2` to `= 3` ON PURPOSE (plan-674 D5, Gemini G001). The step-6
274
+ -- pre-check is now ONE locked statement that also LEFT JOINs the grantor's active row, so the
275
+ -- body carries the predicate exactly three times: the target in step 6, the grantor join in
276
+ -- step 6, and the UPDATE. `>= 2` would stay green if either step-6 site were deleted, and a
277
+ -- later fourth reader would hide behind it; `= 3` turns red on either, forcing re-review.
268
278
  select ok(
269
279
  (select (
270
280
  select count(*) from regexp_matches(
@@ -272,23 +282,22 @@ select ok(
272
282
  -- the column qualified or bare (`bm.membership_state` is the UPDATE's spelling)
273
283
  agent_board_test.re(agent_board_test.col_ref_frag('membership_state')
274
284
  || '\s+' || agent_board_test.ci_re('in') || '\s*\(\s*''joined''\s*,\s*''subscribed''\s*\)'),
275
- 'g')) >= 2
285
+ 'g')) = 3
276
286
  from pg_proc p join pg_namespace n on n.oid = p.pronamespace
277
287
  where n.nspname = 'agent_board' and p.proname = 'grant_board_role'
278
288
  limit 1),
279
- 'grant_board_role carries the active-state predicate TWICE (pre-check + UPDATE, the TOCTOU half)');
289
+ 'grant_board_role carries the active-state predicate exactly THREE times (#674: step-6 target, step-6 grantor join, UPDATE -- the TOCTOU half)');
280
290
 
281
- -- 16. STRUCTURAL: the clamp lives in the mutator, not in a trigger. A BEFORE UPDATE
282
- -- trigger on board_memberships would also fire for the four raw-insert seeders and
283
- -- erase create_board's owner seed and ROOMLINK's admin preset.
291
+ -- 16. The only user trigger is the UPDATE/DELETE refusal guard. It cannot fire
292
+ -- on INSERT seeders, where a role-reset trigger would erase owner/admin presets.
284
293
  select is(
285
- (select count(*)::int from pg_trigger t
294
+ (select string_agg(t.tgname || ':' || t.tgtype::text, ',' order by t.tgname) from pg_trigger t
286
295
  join pg_class c on c.oid = t.tgrelid
287
296
  join pg_namespace n on n.oid = c.relnamespace
288
297
  where n.nspname = 'agent_board' and c.relname = 'board_memberships'
289
298
  and not t.tgisinternal),
290
- 0,
291
- 'no user trigger on board_memberships (the clamp is mutator-sited, never a trigger)');
299
+ 'trg_board_memberships_last_owner_guard:27',
300
+ 'only the last-owner UPDATE/DELETE trigger exists; the role clamp remains mutator-sited');
292
301
 
293
302
  -- 17. STRUCTURAL: the mutator's superseding comment no longer claims it never writes role.
294
303
  select ok(
@@ -325,35 +334,22 @@ select is(
325
334
  'member',
326
335
  'owner self-leave -> self-rejoin on an OPEN board returns at ''member'', not ''owner'' (#79 repro B closed)');
327
336
 
328
- -- 21. SOLE-OWNER LOCKOUT: grant_board_role cannot recover it. _grant_within_ceiling is
329
- -- checked UNCONDITIONALLY -- no service_role bypass -- so an operator must nominate a
330
- -- grantor that already holds rank, and in a sole-owner lockout none exists.
331
- -- NOTE (CR correction): this does NOT mean "no RPC recovery" exists, as an earlier draft
332
- -- of this suite and the CHANGELOG both claimed. bootstrap_board_owner_memberships IS a
333
- -- service_role RPC and raw-inserts joined/owner for a creator with no ACTIVE membership,
334
- -- so leave-again + bootstrap is a genuine RPC recovery path. Assertion 22 proves it.
337
+ -- 21. A sole owner cannot now create the former lockout state.
335
338
  select set_config('request.jwt.claim.role', '', true);
336
339
  select set_config('request.jwt.claim.sub', '', true);
337
- select agent_board.leave_board(lockout_board, lockout_owner) from _rr_fixture;
338
- select agent_board.join_board(lockout_board, lockout_owner, 'joined') from _rr_fixture;
339
340
  select throws_ok(
340
- (select format($q$select agent_board.grant_board_role(%L::uuid, %L::uuid, 'owner', %L::uuid)$q$, lockout_board, lockout_owner, lockout_owner) from _rr_fixture),
341
- '42501',
342
- 'grant exceeds grantor role or board authority ceiling',
343
- 'sole-owner lockout has NO grant_board_role recovery even as service_role (ceiling has no service bypass)');
344
-
345
- -- 22. The REAL recovery path, exercised as an RPC rather than asserted by hand. An earlier
346
- -- draft did `update ... set role='owner'` and then read it back, which is tautological -- it
347
- -- proved SQL works, not that a recovery path exists. Leave again so the creator has no ACTIVE
348
- -- membership, then call bootstrap_board_owner_memberships(); it raw-inserts joined/owner.
349
- select agent_board.leave_board(lockout_board, lockout_owner) from _rr_fixture;
350
- select agent_board.bootstrap_board_owner_memberships();
341
+ (select format($q$select agent_board.leave_board(%L::uuid, %L::uuid)$q$, lockout_board, lockout_owner) from _rr_fixture),
342
+ '23514',
343
+ 'last_active_board_owner: cannot remove the last active owner',
344
+ 'ROLEGUARD sole-owner departure is refused even to service_role');
345
+
346
+ -- 22. The refused departure preserves the owner; legacy recovery is tested in role_guard.
351
347
  select is(
352
348
  (select role from agent_board.board_memberships bm
353
349
  join _rr_fixture f on bm.board_id = f.lockout_board and bm.agent_id = f.lockout_owner
354
350
  where bm.membership_state in ('joined','subscribed')),
355
351
  'owner',
356
- 'RPC recovery EXISTS: leave-again + bootstrap_board_owner_memberships restores an active owner');
352
+ 'ROLEGUARD refused departure preserves the active owner');
357
353
 
358
354
  select set_config('request.jwt.claim.role', '', true);
359
355
  select set_config('request.jwt.claim.sub', '', true);
@@ -36,6 +36,7 @@ declare
36
36
  v_requester uuid := gen_random_uuid();
37
37
  v_nomember uuid := gen_random_uuid();
38
38
  v_suspended uuid := gen_random_uuid();
39
+ v_coowner uuid := gen_random_uuid();
39
40
  v_board uuid;
40
41
  v_slug text := 'roomdeleg-board-' || v_suffix;
41
42
  begin
@@ -54,6 +55,9 @@ begin
54
55
  (v_requester, 'agent', 'rd-requester-' || v_suffix, 'RD Requester', 'active', null, null),
55
56
  (v_nomember, 'agent', 'rd-nomember-' || v_suffix, 'RD NoMember', 'active', null, null);
56
57
 
58
+ insert into agent_board.agents(id, agent_type, handle, display_name, status)
59
+ values (v_coowner, 'agent', 'rd-coowner-' || v_suffix, 'RD Coowner', 'active');
60
+
57
61
  -- Alice creates board (service_role null-actor here -> supplied arg honored):
58
62
  -- create_board seeds Alice's joined owner membership (ROOMOWN).
59
63
  select board_id into v_board
@@ -66,6 +70,9 @@ begin
66
70
  (v_board, v_bob, 'joined', v_alice, 'member'),
67
71
  (v_board, v_requester, 'requested', v_alice, 'member');
68
72
 
73
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
74
+ values (v_board, v_coowner, 'joined', v_alice, 'owner');
75
+
69
76
  insert into _roomdeleg_fixture values
70
77
  (v_board, v_alice, v_bob, v_target, v_requester, v_nomember, v_suspended,
71
78
  v_alice_user, v_bob_user, v_suspended_user);
@@ -127,6 +134,7 @@ select lives_ok(
127
134
 
128
135
  -- Demote the creator to member (bare statement, not an assertion). created_by is
129
136
  -- immutable, so this models a creator who has lost owner authority.
137
+ select agent_board._lock_board_authority_ceiling(board) from _roomdeleg_fixture;
130
138
  update agent_board.board_memberships bm
131
139
  set role = 'member'
132
140
  from _roomdeleg_fixture f
@@ -65,7 +65,7 @@ select set_config('request.jwt.claim.sub', (select root_user from _lb_fx)::text,
65
65
  -- M3: the self-service write stamps scope_attestation='self_declared' (advisory).
66
66
  -- M1: require_child_signing_key is PINNED true even though false was requested.
67
67
  -- M2a: max_active_children null is CLAMPED to the non-null default (5).
68
- -- M6a: child_ttl_ceiling 12h is accepted and stored; max_depth pinned 1.
68
+ -- M6a: child_ttl_ceiling 12h is accepted and stored; SIDSCHEMA allows depth two.
69
69
  select lives_ok(
70
70
  format($$ select agent_board.upsert_delegation_policy(%L::uuid, 'root_minted', jsonb_build_array('scope:a'), null, interval '12 hours', false) $$,
71
71
  (select root from _lb_fx)),
@@ -78,14 +78,14 @@ select is((select require_child_signing_key from agent_board.delegation_policies
78
78
  select is((select max_active_children from agent_board.delegation_policies where root_agent_id = (select root from _lb_fx) and revoked_at is null order by created_at desc limit 1),
79
79
  5, 'M2a: null max_active_children clamped to non-null default 5');
80
80
  select is((select max_depth from agent_board.delegation_policies where root_agent_id = (select root from _lb_fx) and revoked_at is null order by created_at desc limit 1),
81
- 1, 'max_depth pinned 1');
81
+ 2, 'SIDSCHEMA max_depth supports depth two');
82
82
  select is((select child_ttl_ceiling from agent_board.delegation_policies where root_agent_id = (select root from _lb_fx) and revoked_at is null order by created_at desc limit 1),
83
83
  interval '12 hours', 'M6: within-cap ttl (12h) stored');
84
84
 
85
85
  -- M2b/M2c: out-of-range child cap rejected (both ends).
86
86
  select throws_ok(
87
- format($$ select agent_board.upsert_delegation_policy(%L::uuid, 'root_minted', jsonb_build_array('scope:a'), 26) $$, (select root from _lb_fx)),
88
- '22023', null, 'M2b: max_active_children 26 (> 25) rejected');
87
+ format($$ select agent_board.upsert_delegation_policy(%L::uuid, 'root_minted', jsonb_build_array('scope:a'), 201) $$, (select root from _lb_fx)),
88
+ '22023', null, 'M2b: max_active_children 201 (> 200) rejected');
89
89
  select throws_ok(
90
90
  format($$ select agent_board.upsert_delegation_policy(%L::uuid, 'root_minted', jsonb_build_array('scope:a'), 0) $$, (select root from _lb_fx)),
91
91
  '22023', null, 'M2c: max_active_children 0 (< 1) rejected');
@@ -131,10 +131,10 @@ select ok(not has_function_privilege('anon',
131
131
  'agent_board.upsert_delegation_policy(uuid, text, jsonb, integer, interval, boolean)', 'EXECUTE'),
132
132
  'M5: anon may NOT EXECUTE upsert_delegation_policy');
133
133
  select ok(not has_function_privilege('authenticated',
134
- 'agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean)', 'EXECUTE'),
134
+ 'agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean, text, text, text, jsonb, jsonb, uuid)', 'EXECUTE'),
135
135
  'M5: mint_delegated_child stays service_role-only — authenticated has NO EXECUTE (deferred to #138)');
136
136
  select ok(not has_function_privilege('anon',
137
- 'agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean)', 'EXECUTE'),
137
+ 'agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean, text, text, text, jsonb, jsonb, uuid)', 'EXECUTE'),
138
138
  'M5: anon may NOT EXECUTE mint_delegated_child');
139
139
 
140
140
  select set_config('request.jwt.claim.role', '', true);
@@ -73,7 +73,9 @@ begin
73
73
  insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
74
74
  values
75
75
  (v_board, v_member, 'joined', v_alice, 'member'),
76
- (v_board, v_grantee, 'joined', v_alice, 'observer');
76
+ (v_board, v_grantee, 'joined', v_alice, 'observer'),
77
+ -- Keep another owner for the creator self-demotion / slug-collision control.
78
+ (v_board, v_b2owner, 'joined', v_alice, 'owner');
77
79
 
78
80
  -- Board B2: an owner-LESS board (direct insert, no membership) for the bootstrap sweep.
79
81
  insert into agent_board.boards(id, slug, name, description, created_by_agent_id, visibility)
@@ -323,6 +323,9 @@ begin
323
323
  v_left_creator, 'rowid-bootstrap-left-' || s, 'ROWID Bootstrap Left',
324
324
  'leave-again recovery', '{}'::jsonb, 'open'
325
325
  );
326
+ -- ROLEGUARD: this creator may depart while another active owner remains.
327
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, role)
328
+ values (v_left_board, v_writer_owner, 'joined', 'owner');
326
329
 
327
330
  -- Writer 1: a genuine create_board insert.
328
331
  select board_id into v_writer_board
@@ -518,15 +521,17 @@ select is(
518
521
  '_upsert_board_membership_state',
519
522
  'create_tasking_delivery',
520
523
  'join_board',
524
+ 'link_session_alias',
521
525
  'list_board_members',
522
526
  'list_board_pending_memberships',
523
527
  'list_my_board_requests',
524
528
  'post_board_message',
529
+ 'readmit_session_identity',
525
530
  'request_join_board',
526
531
  'resolve_board_request',
527
532
  'update_board_membership'
528
533
  ]::text[],
529
- 'live catalog exposes nine membership readers plus two task/message idempotency readers');
534
+ 'live catalog exposes every membership total-order reader, including SID link/readmission, plus task/message readers');
530
535
 
531
536
  -- 3-10. COUNT-EQUALITY, never substring containment.
532
537
  select is(
@@ -658,7 +663,7 @@ select is(
658
663
  on bm.board_id = f.left_board and bm.agent_id = f.left_creator
659
664
  where bm.membership_state in ('joined', 'subscribed')),
660
665
  'member',
661
- 'left recovery: ROLERESET rejoin clamps the sole creator to member');
666
+ 'left recovery: ROLERESET rejoin clamps the departing creator to member');
662
667
  select lives_ok(
663
668
  (select format(
664
669
  $q$select agent_board.update_board_membership(%L::uuid, %L::uuid, 'left')$q$,