@consiliency/agent-board-schema 1.4.0 → 1.7.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.
@@ -0,0 +1,233 @@
1
+ -- ROOMOWN pgTAP: room ownership (create_board owner-seed + owner-only slug-collision
2
+ -- + bootstrap repair) and self-service authority (grant_board_role activated for
3
+ -- authenticated owners/admins with a privilege-escalation-safe grantor binding).
4
+ --
5
+ -- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role()
6
+ -- (''/service_role => true, 'authenticated' => false) and request.jwt.claim.sub picks
7
+ -- the acting agent. Fixture style mirrors agent_board_board_role.test.sql.
8
+ create extension if not exists pgtap with schema extensions;
9
+ set search_path = extensions, public, agent_board;
10
+
11
+ begin;
12
+
13
+ select plan(21);
14
+
15
+ create temp table _roomown_fixture (
16
+ board uuid,
17
+ board_slug text,
18
+ board2 uuid,
19
+ board2_owner uuid,
20
+ alice uuid,
21
+ bob uuid,
22
+ member_agent uuid,
23
+ grantee uuid,
24
+ nomember uuid,
25
+ suspended uuid,
26
+ alice_user uuid,
27
+ bob_user uuid,
28
+ member_user uuid,
29
+ suspended_user uuid
30
+ ) on commit drop;
31
+
32
+ do $$
33
+ declare
34
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
35
+ v_alice_user uuid;
36
+ v_bob_user uuid;
37
+ v_member_user uuid;
38
+ v_suspended_user uuid;
39
+ v_alice uuid := gen_random_uuid();
40
+ v_bob uuid := gen_random_uuid();
41
+ v_member uuid := gen_random_uuid();
42
+ v_grantee uuid := gen_random_uuid();
43
+ v_nomember uuid := gen_random_uuid();
44
+ v_suspended uuid := gen_random_uuid();
45
+ v_b2owner uuid := gen_random_uuid();
46
+ v_board uuid;
47
+ v_slug text := 'roomown-board-' || v_suffix;
48
+ v_board2 uuid := gen_random_uuid();
49
+ begin
50
+ v_alice_user := public.ensure_github_user('ro-alice-' || v_suffix, 'ro-alice-' || v_suffix || '@example.test', 'ro-alice-' || v_suffix, 'RO Alice');
51
+ v_bob_user := public.ensure_github_user('ro-bob-' || v_suffix, 'ro-bob-' || v_suffix || '@example.test', 'ro-bob-' || v_suffix, 'RO Bob');
52
+ v_member_user := public.ensure_github_user('ro-member-' || v_suffix, 'ro-member-' || v_suffix || '@example.test', 'ro-member-' || v_suffix, 'RO Member');
53
+ v_suspended_user := public.ensure_github_user('ro-susp-' || v_suffix, 'ro-susp-' || v_suffix || '@example.test', 'ro-susp-' || v_suffix, 'RO Suspended');
54
+
55
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
56
+ values
57
+ (v_alice, 'agent', 'ro-alice-' || v_suffix, 'RO Alice', 'active', v_alice_user, v_alice_user),
58
+ (v_bob, 'agent', 'ro-bob-' || v_suffix, 'RO Bob', 'active', v_bob_user, v_bob_user),
59
+ (v_member, 'agent', 'ro-member-' || v_suffix, 'RO Member', 'active', v_member_user, v_member_user),
60
+ -- Suspended agent WITH a valid auth user: current_agent_id() filters status='active'
61
+ -- so it resolves to null while auth.uid() still maps (the A1 escalation vector).
62
+ (v_suspended, 'agent', 'ro-susp-' || v_suffix, 'RO Suspended', 'suspended', v_suspended_user, v_suspended_user),
63
+ (v_grantee, 'agent', 'ro-grantee-' || v_suffix, 'RO Grantee', 'active', null, null),
64
+ (v_nomember, 'agent', 'ro-nomember-' || v_suffix, 'RO NoMember', 'active', null, null),
65
+ (v_b2owner, 'agent', 'ro-b2owner-' || v_suffix, 'RO B2 Owner', 'active', null, null);
66
+
67
+ -- Alice creates board B (service_role null-actor here -> supplied arg is honored):
68
+ -- create_board seeds Alice's joined owner membership.
69
+ select board_id into v_board
70
+ from agent_board.create_board(v_alice, v_slug, 'ROOMOWN Board', 'roomown test', '{}'::jsonb, 'private');
71
+
72
+ -- Genuine NON-creator members: a member (rank member) + a grant target (observer).
73
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
74
+ values
75
+ (v_board, v_member, 'joined', v_alice, 'member'),
76
+ (v_board, v_grantee, 'joined', v_alice, 'observer');
77
+
78
+ -- Board B2: an owner-LESS board (direct insert, no membership) for the bootstrap sweep.
79
+ insert into agent_board.boards(id, slug, name, description, created_by_agent_id, visibility)
80
+ values (v_board2, 'roomown-board2-' || v_suffix, 'ROOMOWN Board2', 'owner-less', v_b2owner, 'private');
81
+
82
+ insert into _roomown_fixture values
83
+ (v_board, v_slug, v_board2, v_b2owner, v_alice, v_bob, v_member, v_grantee, v_nomember, v_suspended,
84
+ v_alice_user, v_bob_user, v_member_user, v_suspended_user);
85
+ end
86
+ $$;
87
+
88
+ -- 1. create_board seeds the creator's joined owner membership --------------------
89
+ select is(
90
+ (select count(*)::int from agent_board.board_memberships bm
91
+ join _roomown_fixture f on bm.board_id = f.board and bm.agent_id = f.alice
92
+ where bm.membership_state = 'joined' and bm.role = 'owner'),
93
+ 1, 'create_board seeds exactly one joined owner membership for the creator');
94
+
95
+ -- A5.1 create_board actor-guard: authenticated Bob cannot create as Alice ---------
96
+ select set_config('request.jwt.claim.role', 'authenticated', true);
97
+ select set_config('request.jwt.claim.sub', (select bob_user from _roomown_fixture)::text, true);
98
+ select throws_ok(
99
+ (select format($q$select agent_board.create_board(%L::uuid, %L, 'Bob As Alice', null, '{}'::jsonb, 'private')$q$,
100
+ alice, 'roomown-bobasalice-' || replace(board::text, '-', '')) from _roomown_fixture),
101
+ '42501', 'p_created_by_agent_id must match current_agent_id()',
102
+ 'actor-guard: authenticated Bob cannot create_board as Alice');
103
+
104
+ -- A5.2 slug-collision guard: non-owner Bob colliding on Alice's slug -> 42501 -----
105
+ select throws_ok(
106
+ (select format($q$select agent_board.create_board(%L::uuid, %L, 'HIJACK', null, '{}'::jsonb, 'open')$q$,
107
+ bob, board_slug) from _roomown_fixture),
108
+ '42501', 'board slug already in use',
109
+ 'slug-collision: non-owner colliding on an existing slug is rejected');
110
+ select is(
111
+ (select name from agent_board.boards b join _roomown_fixture f on b.id = f.board),
112
+ 'ROOMOWN Board', 'slug-collision: the existing board name is unchanged (no hijack mutation)');
113
+ select is(
114
+ (select count(*)::int from agent_board.board_memberships bm
115
+ join _roomown_fixture f on bm.board_id = f.board and bm.agent_id = f.bob),
116
+ 0, 'slug-collision: the non-owner gains no membership on the existing board');
117
+
118
+ -- 2. owner-seed idempotent: re-create by the owner does not add an owner row -------
119
+ select set_config('request.jwt.claim.sub', (select alice_user from _roomown_fixture)::text, true);
120
+ select lives_ok(
121
+ (select format($q$select agent_board.create_board(%L::uuid, %L, 'ROOMOWN Board', 'roomown test', '{}'::jsonb, 'private')$q$,
122
+ alice, board_slug) from _roomown_fixture),
123
+ 'owner re-create on the same slug is admitted (owner-only update path)');
124
+ select is(
125
+ (select count(*)::int from agent_board.board_memberships bm
126
+ join _roomown_fixture f on bm.board_id = f.board and bm.agent_id = f.alice
127
+ where bm.membership_state in ('joined', 'subscribed')),
128
+ 1, 'owner-seed is idempotent: re-create does not duplicate the owner membership');
129
+
130
+ -- 3. bootstrap repairs an owner-less board's created_by membership ----------------
131
+ select set_config('request.jwt.claim.role', '', true);
132
+ select set_config('request.jwt.claim.sub', '', true);
133
+ select lives_ok(
134
+ $$select agent_board.bootstrap_board_owner_memberships()$$,
135
+ 'bootstrap_board_owner_memberships runs for service_role');
136
+ select is(
137
+ (select count(*)::int from agent_board.board_memberships bm
138
+ join _roomown_fixture f on bm.board_id = f.board2 and bm.agent_id = f.board2_owner
139
+ where bm.membership_state = 'joined' and bm.role = 'owner'),
140
+ 1, 'bootstrap seeds a joined owner membership for the owner-less board''s created_by');
141
+
142
+ -- 4. bootstrap is service_role-only ----------------------------------------------
143
+ select set_config('request.jwt.claim.role', 'authenticated', true);
144
+ select set_config('request.jwt.claim.sub', (select alice_user from _roomown_fixture)::text, true);
145
+ select throws_ok(
146
+ $$select agent_board.bootstrap_board_owner_memberships()$$,
147
+ '42501', 'bootstrap_board_owner_memberships is service_role-only',
148
+ 'bootstrap rejects an authenticated (mapped) caller');
149
+
150
+ -- 5. owner grants admin (within ceiling, no ceiling row = owner) ------------------
151
+ select set_config('request.jwt.claim.sub', (select alice_user from _roomown_fixture)::text, true);
152
+ select lives_ok(
153
+ (select format($q$select agent_board.grant_board_role(%L::uuid, %L::uuid, 'admin')$q$, board, grantee) from _roomown_fixture),
154
+ 'owner grants admin (grantor bound to current_agent_id, no explicit arg)');
155
+ select is(
156
+ (select role from agent_board.board_memberships bm join _roomown_fixture f on bm.board_id = f.board and bm.agent_id = f.grantee),
157
+ 'admin', 'the grantee role is upserted to admin');
158
+
159
+ -- 6. a member cannot grant above its own rank ------------------------------------
160
+ select set_config('request.jwt.claim.sub', (select member_user from _roomown_fixture)::text, true);
161
+ select throws_ok(
162
+ (select format($q$select agent_board.grant_board_role(%L::uuid, %L::uuid, 'admin')$q$, board, grantee) from _roomown_fixture),
163
+ '42501', 'grant exceeds grantor role or board authority ceiling',
164
+ 'a member grantor cannot grant admin (grantee-bounded ceiling)');
165
+
166
+ -- 7. grantor binding: a member passing p_grantor=<owner> is still rejected (A1) ---
167
+ select throws_ok(
168
+ (select format($q$select agent_board.grant_board_role(%L::uuid, %L::uuid, 'admin', %L::uuid)$q$, board, grantee, alice) from _roomown_fixture),
169
+ '42501', 'p_grantor_agent_id must match current_agent_id()',
170
+ 'grantor binding: an authenticated member cannot impersonate the owner via p_grantor_agent_id');
171
+
172
+ -- 8. board ceiling caps an owner grant -------------------------------------------
173
+ insert into agent_board.board_authority_ceilings(board_id, max_grantable_role)
174
+ select board, 'member' from _roomown_fixture;
175
+ select set_config('request.jwt.claim.sub', (select alice_user from _roomown_fixture)::text, true);
176
+ select throws_ok(
177
+ (select format($q$select agent_board.grant_board_role(%L::uuid, %L::uuid, 'admin')$q$, board, grantee) from _roomown_fixture),
178
+ '42501', 'grant exceeds grantor role or board authority ceiling',
179
+ 'the board ceiling caps an over-ceiling grant even for an owner grantor');
180
+
181
+ -- A5.3 grantee-membership required -> P0002 --------------------------------------
182
+ select throws_ok(
183
+ (select format($q$select agent_board.grant_board_role(%L::uuid, %L::uuid, 'member')$q$, board, nomember) from _roomown_fixture),
184
+ 'P0002', 'grantee has no membership on this board',
185
+ 'grant to an agent with no membership raises P0002');
186
+
187
+ -- A5.4 service_role regression: explicit p_grantor honored on the null actor ------
188
+ select set_config('request.jwt.claim.role', '', true);
189
+ select set_config('request.jwt.claim.sub', '', true);
190
+ select lives_ok(
191
+ (select format($q$select agent_board.grant_board_role(%L::uuid, %L::uuid, 'member', %L::uuid)$q$, board, grantee, alice) from _roomown_fixture),
192
+ 'service_role: an explicit p_grantor_agent_id is honored (null actor via _is_service_role)');
193
+
194
+ -- A5.5 null-actor escalation BLOCKED (proves A1) ---------------------------------
195
+ -- An authenticated sub mapped to a SUSPENDED agent (current_agent_id() null) passing
196
+ -- p_grantor=<owner> must be rejected at actor resolution, NOT self-escalated.
197
+ select set_config('request.jwt.claim.role', 'authenticated', true);
198
+ select set_config('request.jwt.claim.sub', (select suspended_user from _roomown_fixture)::text, true);
199
+ select throws_ok(
200
+ (select format($q$select agent_board.grant_board_role(%L::uuid, %L::uuid, 'owner', %L::uuid)$q$, board, grantee, alice) from _roomown_fixture),
201
+ '42501', 'current agent principal not resolved',
202
+ 'null-actor escalation is blocked: a suspended principal cannot grant via a supplied owner id');
203
+
204
+ select set_config('request.jwt.claim.role', '', true);
205
+ select set_config('request.jwt.claim.sub', '', true);
206
+
207
+ -- 9. grant_board_role EXECUTE is granted to authenticated (live) -----------------
208
+ select function_privs_are(
209
+ 'agent_board', 'grant_board_role', array['uuid', 'uuid', 'text', 'uuid'],
210
+ 'authenticated', array['EXECUTE'], 'grant_board_role execute is granted to authenticated (self-service authority live)');
211
+
212
+ -- A5.6 demoted creator cannot mutate via slug-collision (current-owner authz, NOT created_by) --
213
+ -- Panel finding: the slug-collision UPDATE path must key on CURRENT owner membership, not the
214
+ -- immutable created_by. Alice (still owner after the grant tests) self-demotes to member, then
215
+ -- her re-create on the same slug must be rejected and mutate nothing.
216
+ select set_config('request.jwt.claim.role', 'authenticated', true);
217
+ select set_config('request.jwt.claim.sub', (select alice_user from _roomown_fixture)::text, true);
218
+ -- self-demote to member (member <= min(owner, ceiling)); bare statement, not an assertion
219
+ select agent_board.grant_board_role(board, alice, 'member') from _roomown_fixture;
220
+ select throws_ok(
221
+ (select format($q$select agent_board.create_board(%L::uuid, %L, 'REOPEN', null, '{}'::jsonb, 'open')$q$,
222
+ alice, board_slug) from _roomown_fixture),
223
+ '42501', 'board slug already in use',
224
+ 'demoted creator (no current owner membership) cannot mutate the board via slug-collision');
225
+ select is(
226
+ (select visibility from agent_board.boards b join _roomown_fixture f on b.id = f.board),
227
+ 'private', 'demoted-creator slug-collision leaves visibility unchanged (no private->open flip)');
228
+ select set_config('request.jwt.claim.role', '', true);
229
+ select set_config('request.jwt.claim.sub', '', true);
230
+
231
+ select finish();
232
+
233
+ rollback;
@@ -28,6 +28,7 @@ declare
28
28
  v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
29
29
  v_member_user uuid;
30
30
  v_other_user uuid;
31
+ v_creator uuid := gen_random_uuid();
31
32
  v_member uuid := gen_random_uuid();
32
33
  v_other uuid := gen_random_uuid();
33
34
  v_recipient uuid := gen_random_uuid();
@@ -42,13 +43,16 @@ begin
42
43
  values
43
44
  (v_member, 'agent', 'rg-member-' || v_suffix, 'RG Member', 'active', v_member_user, v_member_user),
44
45
  (v_other, 'agent', 'rg-other-' || v_suffix, 'RG Other', 'active', v_other_user, v_other_user),
45
- (v_recipient, 'agent', 'rg-recipient-' || v_suffix, 'RG Recipient', 'active', null, null);
46
+ (v_recipient, 'agent', 'rg-recipient-' || v_suffix, 'RG Recipient', 'active', null, null),
47
+ (v_creator, 'agent', 'rg-creator-' || v_suffix, 'RG Creator', 'active', null, null);
46
48
 
49
+ -- Dedicated creator (owner seeded by create_board); v_member is a genuine
50
+ -- NON-creator member so the member send path is exercised as role 'member'.
47
51
  select board_id into v_board
48
- from agent_board.create_board(v_member, 'rg-board-' || v_suffix, 'RG Board', 'regrant test', '{}'::jsonb, 'private');
52
+ from agent_board.create_board(v_creator, 'rg-board-' || v_suffix, 'RG Board', 'regrant test', '{}'::jsonb, 'private');
49
53
 
50
54
  insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
51
- values (v_board, v_member, 'joined', v_member, 'member');
55
+ values (v_board, v_member, 'joined', v_creator, 'member');
52
56
 
53
57
  insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
54
58
  values
@@ -26,6 +26,7 @@ declare
26
26
  v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
27
27
  v_sb_member_user uuid;
28
28
  v_sb_observer_user uuid;
29
+ v_sb_creator uuid := gen_random_uuid();
29
30
  v_sb_member uuid := gen_random_uuid();
30
31
  v_sb_observer uuid := gen_random_uuid();
31
32
  v_sb_board uuid;
@@ -45,6 +46,7 @@ begin
45
46
  (v_sb_observer, 'agent', 'mtx-observer-' || v_suffix, 'MTX Observer', 'active', v_sb_observer_user, v_sb_observer_user);
46
47
  insert into agent_board.agents(id, agent_type, handle, display_name)
47
48
  values
49
+ (v_sb_creator, 'agent', 'mtx-sbcreator-' || v_suffix, 'MTX Send Board Creator'),
48
50
  (v_nf_sender, 'agent', 'mtx-nf-' || v_suffix, 'MTX NonFleet'),
49
51
  (v_nf_allow, 'agent', 'mtx-nfa-' || v_suffix, 'MTX NonFleet Allow'),
50
52
  (v_g_owner, 'agent', 'mtx-gowner-' || v_suffix, 'MTX Grant Owner'),
@@ -53,12 +55,13 @@ begin
53
55
  (v_g2_owner, 'agent', 'mtx-g2owner-' || v_suffix, 'MTX Grant2 Owner'),
54
56
  (v_g2_grantee, 'agent', 'mtx-g2grantee-' || v_suffix, 'MTX Grant2 Grantee');
55
57
 
56
- -- Send board with member/observer roles + accessible threads.
57
- select board_id into v_sb_board from agent_board.create_board(v_sb_member, 'mtx-sb-' || v_suffix, 'MTX Send Board', 'matrix', '{}'::jsonb, 'private');
58
+ -- Send board created by a dedicated creator (owner is seeded by create_board);
59
+ -- member/observer are genuine NON-creator members so their roles drive the gate.
60
+ select board_id into v_sb_board from agent_board.create_board(v_sb_creator, 'mtx-sb-' || v_suffix, 'MTX Send Board', 'matrix', '{}'::jsonb, 'private');
58
61
  insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
59
62
  values
60
- (v_sb_board, v_sb_member, 'joined', v_sb_member, 'member'),
61
- (v_sb_board, v_sb_observer, 'joined', v_sb_member, 'observer');
63
+ (v_sb_board, v_sb_member, 'joined', v_sb_creator, 'member'),
64
+ (v_sb_board, v_sb_observer, 'joined', v_sb_creator, 'observer');
62
65
  insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
63
66
  values
64
67
  (v_thread_member, 'coordination', v_sb_member, 'MTX member thread'),
@@ -66,17 +69,17 @@ begin
66
69
 
67
70
  -- Grant board B1: no ceiling row (ceiling defaults to owner).
68
71
  select board_id into v_b1 from agent_board.create_board(v_g_owner, 'mtx-b1-' || v_suffix, 'MTX Grant B1', 'ceiling', '{}'::jsonb, 'private');
72
+ -- v_g_owner's owner membership is seeded by create_board (ROOMOWN).
69
73
  insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
70
74
  values
71
- (v_b1, v_g_owner, 'joined', v_g_owner, 'owner'),
72
75
  (v_b1, v_g_admin, 'joined', v_g_owner, 'admin'),
73
76
  (v_b1, v_g_grantee, 'joined', v_g_owner, 'observer');
74
77
 
75
78
  -- Grant board B2: ceiling lowered to member.
76
79
  select board_id into v_b2 from agent_board.create_board(v_g2_owner, 'mtx-b2-' || v_suffix, 'MTX Grant B2', 'ceiling', '{}'::jsonb, 'private');
80
+ -- v_g2_owner's owner membership is seeded by create_board (ROOMOWN).
77
81
  insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
78
82
  values
79
- (v_b2, v_g2_owner, 'joined', v_g2_owner, 'owner'),
80
83
  (v_b2, v_g2_grantee, 'joined', v_g2_owner, 'observer');
81
84
  insert into agent_board.board_authority_ceilings(board_id, max_grantable_role) values (v_b2, 'member');
82
85
 
@@ -149,10 +152,10 @@ select throws_ok(
149
152
  '42501', 'grant exceeds grantor role or board authority ceiling',
150
153
  'matrix: an over-ceiling grant is rejected even for an owner grantor');
151
154
 
152
- -- E. Dormancy: grant_board_role is revoked from authenticated -----------------
155
+ -- E. Live: grant_board_role is now granted EXECUTE to authenticated (ROOMOWN) ---
153
156
  select function_privs_are(
154
157
  'agent_board', 'grant_board_role', array['uuid', 'uuid', 'text', 'uuid'],
155
- 'authenticated', array[]::text[], 'matrix: grant_board_role execute is revoked from authenticated');
158
+ 'authenticated', array['EXECUTE'], 'matrix: grant_board_role execute is granted to authenticated (self-service authority live)');
156
159
 
157
160
  select finish();
158
161
 
@@ -32,6 +32,7 @@ declare
32
32
  v_member_user uuid;
33
33
  v_observer_user uuid;
34
34
  v_nonmember_user uuid;
35
+ v_creator uuid := gen_random_uuid();
35
36
  v_member uuid := gen_random_uuid();
36
37
  v_observer uuid := gen_random_uuid();
37
38
  v_nonmember uuid := gen_random_uuid();
@@ -50,16 +51,19 @@ begin
50
51
  (v_member, 'agent', 'srg-member-' || v_suffix, 'SRG Member', 'active', v_member_user, v_member_user),
51
52
  (v_observer, 'agent', 'srg-observer-' || v_suffix, 'SRG Observer', 'active', v_observer_user, v_observer_user),
52
53
  (v_nonmember, 'agent', 'srg-nonmember-' || v_suffix, 'SRG Nonmember', 'active', v_nonmember_user, v_nonmember_user),
53
- (v_recipient, 'agent', 'srg-recipient-' || v_suffix, 'SRG Recipient', 'active', null, null);
54
+ (v_recipient, 'agent', 'srg-recipient-' || v_suffix, 'SRG Recipient', 'active', null, null),
55
+ (v_creator, 'agent', 'srg-creator-' || v_suffix, 'SRG Creator', 'active', null, null);
54
56
 
57
+ -- Dedicated creator (owner seeded by create_board); member/observer are genuine
58
+ -- NON-creator members so their roles drive the send gate.
55
59
  select board_id into v_board
56
- from agent_board.create_board(v_member, 'srg-board-' || v_suffix, 'SRG Board', 'send role gate test', '{}'::jsonb, 'private');
60
+ from agent_board.create_board(v_creator, 'srg-board-' || v_suffix, 'SRG Board', 'send role gate test', '{}'::jsonb, 'private');
57
61
 
58
62
  -- member: role member; observer: role observer. Both active.
59
63
  insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
60
64
  values
61
- (v_board, v_member, 'joined', v_member, 'member'),
62
- (v_board, v_observer, 'joined', v_member, 'observer');
65
+ (v_board, v_member, 'joined', v_creator, 'member'),
66
+ (v_board, v_observer, 'joined', v_creator, 'observer');
63
67
 
64
68
  -- Threads each sending actor can access (inherit mode; creator has access).
65
69
  insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
@@ -323,8 +323,8 @@ select *
323
323
 
324
324
  select is(
325
325
  (select delivered_recipient_count from v2_board_delivery),
326
- 2,
327
- 'post_board_message only reaches active joined or subscribed board members'
326
+ 3,
327
+ 'post_board_message only reaches active joined or subscribed board members (incl. the seeded owner-creator)'
328
328
  );
329
329
 
330
330
  select is(
@@ -369,13 +369,13 @@ select *
369
369
 
370
370
  select is(
371
371
  (select delivered_recipient_count from v2_tasking_delivery),
372
- 3,
373
- 'tasking counts explicit and board fan-out attempts before duplicate collapse'
372
+ 4,
373
+ 'tasking counts explicit and board fan-out attempts before duplicate collapse (incl. the seeded owner-creator)'
374
374
  );
375
375
 
376
376
  select is(
377
377
  (select count(*)::integer from agent_board.delivery_recipients where source_task_id = (select task_id from v2_tasking_delivery)),
378
- 2,
378
+ 3,
379
379
  'tasking suppresses duplicate durable recipient rows across explicit and board fan-out'
380
380
  );
381
381