@consiliency/agent-board-schema 1.63.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 (49) hide show
  1. package/README.md +6 -0
  2. package/fixtures/already-applied.json +4 -0
  3. package/migrations/20261014000200_agent_board_last_owner.sql +239 -0
  4. package/package.json +1 -1
  5. package/schema-manifest.json +12 -3
  6. package/src/tooling.test.mjs +6 -2
  7. package/tests/agent_board_atomic_fleet_onboarding.test.sql +3 -1
  8. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +7 -0
  9. package/tests/agent_board_fk_lock_edges.test.sql +3 -2
  10. package/tests/agent_board_grant_target_role.test.sql +4 -4
  11. package/tests/agent_board_hosted_delegates.test.sql +5 -3
  12. package/tests/agent_board_invite_no_downgrade.test.sql +5 -3
  13. package/tests/agent_board_ledger_authority.test.sql +7 -0
  14. package/tests/agent_board_role_guard.test.sql +319 -0
  15. package/tests/agent_board_rolereset.test.sql +17 -27
  16. package/tests/agent_board_roomdeleg.test.sql +8 -0
  17. package/tests/agent_board_roomown.test.sql +3 -1
  18. package/tests/agent_board_rowid.test.sql +4 -1
  19. package/tests/catalog/function_execute_privileges.json +9 -0
  20. package/tests/catalog/p_metadata_parameters.json +4 -0
  21. package/tests/isolation/expected/last_owner_departure.out +562 -0
  22. package/tests/isolation/specs/atomic_fleet_authority_races.spec +8 -3
  23. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +0 -3
  24. package/tests/isolation/specs/atomic_fleet_board_archive.spec +0 -3
  25. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +0 -3
  26. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +0 -3
  27. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +0 -3
  28. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +0 -3
  29. package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +0 -3
  30. package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +0 -2
  31. package/tests/isolation/specs/claimpolicy_board_scope.spec +0 -2
  32. package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +0 -2
  33. package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +0 -2
  34. package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +0 -2
  35. package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +0 -2
  36. package/tests/isolation/specs/consent_approval_lock.spec +0 -1
  37. package/tests/isolation/specs/consent_emit_actual_state.spec +0 -1
  38. package/tests/isolation/specs/grant_target_role_race.spec +0 -1
  39. package/tests/isolation/specs/hosted_delegate_add_tombstone_race.spec +0 -1
  40. package/tests/isolation/specs/hosted_delegate_tombstone_revoke_race.spec +0 -1
  41. package/tests/isolation/specs/hosted_delegate_withdraw_evict_race.spec +0 -1
  42. package/tests/isolation/specs/last_owner_departure.spec +137 -0
  43. package/tests/isolation/specs/ledger_authority_revoke.spec +0 -3
  44. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +0 -3
  45. package/tests/isolation/specs/outbox_wake_ordering.spec +0 -1
  46. package/tests/isolation/specs/session_identity_links.spec +3 -1
  47. package/tests/isolation/specs/session_identity_tombstone_onboarding.spec +0 -1
  48. package/tests/isolation/specs/signed_grant_onboarding_deadlock.spec +0 -1
  49. package/tests/isolation/specs/signer_lifecycle_revoke.spec +0 -1
@@ -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
@@ -284,17 +288,16 @@ select ok(
284
288
  limit 1),
285
289
  'grant_board_role carries the active-state predicate exactly THREE times (#674: step-6 target, step-6 grantor join, UPDATE -- the TOCTOU half)');
286
290
 
287
- -- 16. STRUCTURAL: the clamp lives in the mutator, not in a trigger. A BEFORE UPDATE
288
- -- trigger on board_memberships would also fire for the four raw-insert seeders and
289
- -- 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.
290
293
  select is(
291
- (select count(*)::int from pg_trigger t
294
+ (select string_agg(t.tgname || ':' || t.tgtype::text, ',' order by t.tgname) from pg_trigger t
292
295
  join pg_class c on c.oid = t.tgrelid
293
296
  join pg_namespace n on n.oid = c.relnamespace
294
297
  where n.nspname = 'agent_board' and c.relname = 'board_memberships'
295
298
  and not t.tgisinternal),
296
- 0,
297
- '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');
298
301
 
299
302
  -- 17. STRUCTURAL: the mutator's superseding comment no longer claims it never writes role.
300
303
  select ok(
@@ -331,35 +334,22 @@ select is(
331
334
  'member',
332
335
  'owner self-leave -> self-rejoin on an OPEN board returns at ''member'', not ''owner'' (#79 repro B closed)');
333
336
 
334
- -- 21. SOLE-OWNER LOCKOUT: grant_board_role cannot recover it. _grant_within_ceiling is
335
- -- checked UNCONDITIONALLY -- no service_role bypass -- so an operator must nominate a
336
- -- grantor that already holds rank, and in a sole-owner lockout none exists.
337
- -- NOTE (CR correction): this does NOT mean "no RPC recovery" exists, as an earlier draft
338
- -- of this suite and the CHANGELOG both claimed. bootstrap_board_owner_memberships IS a
339
- -- service_role RPC and raw-inserts joined/owner for a creator with no ACTIVE membership,
340
- -- 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.
341
338
  select set_config('request.jwt.claim.role', '', true);
342
339
  select set_config('request.jwt.claim.sub', '', true);
343
- select agent_board.leave_board(lockout_board, lockout_owner) from _rr_fixture;
344
- select agent_board.join_board(lockout_board, lockout_owner, 'joined') from _rr_fixture;
345
340
  select throws_ok(
346
- (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),
347
- '42501',
348
- 'grant exceeds grantor role or board authority ceiling',
349
- 'sole-owner lockout has NO grant_board_role recovery even as service_role (ceiling has no service bypass)');
350
-
351
- -- 22. The REAL recovery path, exercised as an RPC rather than asserted by hand. An earlier
352
- -- draft did `update ... set role='owner'` and then read it back, which is tautological -- it
353
- -- proved SQL works, not that a recovery path exists. Leave again so the creator has no ACTIVE
354
- -- membership, then call bootstrap_board_owner_memberships(); it raw-inserts joined/owner.
355
- select agent_board.leave_board(lockout_board, lockout_owner) from _rr_fixture;
356
- 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.
357
347
  select is(
358
348
  (select role from agent_board.board_memberships bm
359
349
  join _rr_fixture f on bm.board_id = f.lockout_board and bm.agent_id = f.lockout_owner
360
350
  where bm.membership_state in ('joined','subscribed')),
361
351
  'owner',
362
- 'RPC recovery EXISTS: leave-again + bootstrap_board_owner_memberships restores an active owner');
352
+ 'ROLEGUARD refused departure preserves the active owner');
363
353
 
364
354
  select set_config('request.jwt.claim.role', '', true);
365
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
@@ -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
@@ -660,7 +663,7 @@ select is(
660
663
  on bm.board_id = f.left_board and bm.agent_id = f.left_creator
661
664
  where bm.membership_state in ('joined', 'subscribed')),
662
665
  'member',
663
- 'left recovery: ROLERESET rejoin clamps the sole creator to member');
666
+ 'left recovery: ROLERESET rejoin clamps the departing creator to member');
664
667
  select lives_ok(
665
668
  (select format(
666
669
  $q$select agent_board.update_board_membership(%L::uuid, %L::uuid, 'left')$q$,
@@ -109,6 +109,15 @@
109
109
  "authenticated": true
110
110
  }
111
111
  },
112
+ {
113
+ "name": "_board_memberships_last_owner_guard",
114
+ "signature": "_board_memberships_last_owner_guard()",
115
+ "executable_by": {
116
+ "anon": false,
117
+ "service_role": false,
118
+ "authenticated": false
119
+ }
120
+ },
112
121
  {
113
122
  "name": "_build_fetch_descriptor",
114
123
  "signature": "_build_fetch_descriptor(p_surface text, p_target_id uuid, p_view text, p_reason text, p_filters jsonb)",
@@ -49,6 +49,10 @@
49
49
  "signature": "_board_membership_events_provenance_guard()",
50
50
  "declares_p_metadata": false
51
51
  },
52
+ {
53
+ "signature": "_board_memberships_last_owner_guard()",
54
+ "declares_p_metadata": false
55
+ },
52
56
  {
53
57
  "signature": "_build_fetch_descriptor(p_surface text, p_target_id uuid, p_view text, p_reason text, p_filters jsonb)",
54
58
  "declares_p_metadata": false