@consiliency/agent-board-schema 1.4.0 → 1.5.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.
@@ -284,6 +284,14 @@
284
284
  {
285
285
  "id": "20260719001300_agent_board_enroll_rate_events_reaper_rls",
286
286
  "sha256": "b2f58eadc13cb227991c4d978c5460878dc3cbada43c58285b55154b89a28459"
287
+ },
288
+ {
289
+ "id": "20260723000100_agent_board_roomown_owner_seed",
290
+ "sha256": "ecfaf262832b3d499102ec3a2ee1d48e043b33282287d0e987555e868c07b804"
291
+ },
292
+ {
293
+ "id": "20260723000200_agent_board_roomown_grant_role_live",
294
+ "sha256": "40c9f585af16211fe9ab1d35efca6787e49b31afc7c85f208da22b9566a65575"
287
295
  }
288
296
  ]
289
297
  }
@@ -0,0 +1,167 @@
1
+ -- ROOMOWN Lane A: room creators actually OWN the room.
2
+ --
3
+ -- Before this migration create_board wrote ONLY the boards row and never seeded a
4
+ -- board_memberships owner row, so the creator held no membership and could not act
5
+ -- as owner (grant_board_role / set_board_authority_ceiling both resolve the grantor
6
+ -- rank from a joined/subscribed membership). This migration:
7
+ --
8
+ -- 1. create or replace create_board so that, on a GENUINE INSERT, it also seeds a
9
+ -- joined `owner` board_memberships row bound to the board's ACTUAL
10
+ -- created_by_agent_id. The pre-existing slug-collision UPDATE path is now
11
+ -- OWNER-ONLY: a non-owner colliding on an existing slug raises 42501 and mutates
12
+ -- nothing (closing a confidentiality bypass where any authenticated agent could
13
+ -- overwrite another board's name/visibility/metadata). The effective actor is
14
+ -- resolved through _resolved_actor_agent_id, so an authenticated caller can only
15
+ -- create/own as itself while service_role retains create-on-behalf-of.
16
+ --
17
+ -- 2. bootstrap_board_owner_memberships(): a service_role-only repair sweep that
18
+ -- seeds the owner membership for pre-existing owner-less boards' created_by only.
19
+ --
20
+ -- Additive; no schema_version bump (net-new owner-seed + repair, no previously-live
21
+ -- authenticated contract re-activated here).
22
+
23
+ -- 1. create_board: seed owner on insert; owner-only slug-collision update ----------
24
+ create or replace function agent_board.create_board(
25
+ p_created_by_agent_id uuid,
26
+ p_slug text,
27
+ p_name text,
28
+ p_description text default null,
29
+ p_metadata jsonb default '{}'::jsonb,
30
+ p_visibility text default 'private'
31
+ )
32
+ returns table(board_id uuid, slug text, name text, status text, visibility text, created_at timestamptz, updated_at timestamptz)
33
+ language plpgsql
34
+ security definer
35
+ set search_path = agent_board, public
36
+ as $$
37
+ #variable_conflict use_column
38
+ declare
39
+ v_actor uuid;
40
+ v_existing agent_board.boards%rowtype;
41
+ v_board agent_board.boards%rowtype;
42
+ begin
43
+ if nullif(trim(p_slug), '') is null or nullif(trim(p_name), '') is null then
44
+ raise exception 'board slug and name are required' using errcode = '22023';
45
+ end if;
46
+ if coalesce(p_visibility, 'private') not in ('private', 'invite_only', 'open', 'directory_hidden') then
47
+ raise exception 'invalid board visibility' using errcode = '22023';
48
+ end if;
49
+
50
+ -- Effective actor: service_role -> supplied arg (create-on-behalf-of);
51
+ -- authenticated -> current_agent_id() and rejects arg <> current (42501).
52
+ v_actor := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
53
+
54
+ if not exists (select 1 from agent_board.agents where id = v_actor) then
55
+ raise exception 'agent not found' using errcode = 'P0002';
56
+ end if;
57
+
58
+ select * into v_existing
59
+ from agent_board.boards b
60
+ where b.slug = trim(p_slug);
61
+
62
+ if found then
63
+ -- Slug-collision path is OWNER-ONLY, keyed on CURRENT authority: the actor must
64
+ -- hold an ACTIVE owner-role membership on the existing board — NOT merely be its
65
+ -- original `created_by_agent_id`. A demoted or departed original creator no longer
66
+ -- owns the board and must NOT be able to re-open/rename/flip-visibility it.
67
+ if not exists (
68
+ select 1 from agent_board.board_memberships m
69
+ where m.board_id = v_existing.id
70
+ and m.agent_id = v_actor
71
+ and m.role = 'owner'
72
+ and m.membership_state in ('joined', 'subscribed')
73
+ ) then
74
+ raise exception 'board slug already in use' using errcode = '42501';
75
+ end if;
76
+
77
+ update agent_board.boards as b
78
+ set name = trim(p_name),
79
+ description = nullif(trim(p_description), ''),
80
+ status = 'active',
81
+ visibility = coalesce(p_visibility, 'private'),
82
+ metadata = coalesce(b.metadata, '{}'::jsonb) || coalesce(p_metadata, '{}'::jsonb),
83
+ updated_at = now()
84
+ where b.id = v_existing.id
85
+ returning * into v_board;
86
+ -- No owner re-seed: the owner was seeded at this board's original creation, and
87
+ -- re-promoting on every create_board would resurrect a deliberately demoted owner.
88
+ else
89
+ insert into agent_board.boards as b(slug, name, description, created_by_agent_id, visibility, metadata)
90
+ values (
91
+ trim(p_slug),
92
+ trim(p_name),
93
+ nullif(trim(p_description), ''),
94
+ v_actor,
95
+ coalesce(p_visibility, 'private'),
96
+ coalesce(p_metadata, '{}'::jsonb)
97
+ )
98
+ returning * into v_board;
99
+
100
+ -- Seed the creator's owner membership, bound to the board's ACTUAL
101
+ -- created_by_agent_id (never the raw param). Idempotent against the active
102
+ -- partial-unique index so at most one active owner row exists.
103
+ insert into agent_board.board_memberships as bm(board_id, agent_id, membership_state, role, created_by_agent_id)
104
+ values (v_board.id, v_board.created_by_agent_id, 'joined', 'owner', v_board.created_by_agent_id)
105
+ on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
106
+ do update set role = 'owner', updated_at = now();
107
+ end if;
108
+
109
+ return query
110
+ select v_board.id, v_board.slug, v_board.name, v_board.status, v_board.visibility, v_board.created_at, v_board.updated_at;
111
+ end;
112
+ $$;
113
+
114
+ comment on function agent_board.create_board(uuid, text, text, text, jsonb, text) is
115
+ 'Creates a board and, on genuine insert, seeds the creator''s joined owner membership (bound to the board''s created_by_agent_id). The slug-collision update path is owner-only (non-owner raises 42501, mutates nothing). Effective actor resolved via _resolved_actor_agent_id.';
116
+
117
+ revoke all on function agent_board.create_board(uuid, text, text, text, jsonb, text) from public, anon;
118
+ grant execute on function agent_board.create_board(uuid, text, text, text, jsonb, text) to authenticated, service_role;
119
+
120
+ -- 2. bootstrap_board_owner_memberships(): service_role-only owner-seed repair -------
121
+ create or replace function agent_board.bootstrap_board_owner_memberships()
122
+ returns table(board_id uuid, owner_agent_id uuid, membership_id uuid)
123
+ language plpgsql
124
+ security definer
125
+ set search_path = agent_board, public
126
+ as $$
127
+ #variable_conflict use_column
128
+ declare
129
+ v_row record;
130
+ v_membership_id uuid;
131
+ begin
132
+ -- service_role-only: current_agent_id() is null for service_role, non-null for any
133
+ -- authenticated principal (guarded here in addition to the EXECUTE revoke below).
134
+ if not agent_board._is_service_role() then
135
+ raise exception 'bootstrap_board_owner_memberships is service_role-only' using errcode = '42501';
136
+ end if;
137
+
138
+ for v_row in
139
+ select b.id as board_id, b.created_by_agent_id as owner_agent_id
140
+ from agent_board.boards b
141
+ where not exists (
142
+ select 1
143
+ from agent_board.board_memberships bm
144
+ where bm.board_id = b.id
145
+ and bm.agent_id = b.created_by_agent_id
146
+ and bm.membership_state in ('joined', 'subscribed')
147
+ )
148
+ loop
149
+ insert into agent_board.board_memberships as bm(board_id, agent_id, membership_state, role, created_by_agent_id)
150
+ values (v_row.board_id, v_row.owner_agent_id, 'joined', 'owner', v_row.owner_agent_id)
151
+ on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
152
+ do update set role = 'owner', updated_at = now()
153
+ returning bm.id into v_membership_id;
154
+
155
+ board_id := v_row.board_id;
156
+ owner_agent_id := v_row.owner_agent_id;
157
+ membership_id := v_membership_id;
158
+ return next;
159
+ end loop;
160
+ end;
161
+ $$;
162
+
163
+ comment on function agent_board.bootstrap_board_owner_memberships() is
164
+ 'service_role-only repair sweep: seeds a joined owner membership for pre-existing owner-less boards, bound to each board''s created_by_agent_id. Takes no caller/role param.';
165
+
166
+ revoke all on function agent_board.bootstrap_board_owner_memberships() from public, anon, authenticated;
167
+ grant execute on function agent_board.bootstrap_board_owner_memberships() to service_role;
@@ -0,0 +1,72 @@
1
+ -- ROOMOWN Lane B: activate the dormant grant_board_role chokepoint for authenticated
2
+ -- board owners/admins, with a privilege-escalation-safe grantor binding.
3
+ --
4
+ -- 20260718000400 shipped grant_board_role dormant (revoked from authenticated,
5
+ -- granted service_role) and TRUSTED p_grantor_agent_id. Now that create_board seeds
6
+ -- the creator's owner membership (ROOMOWN Lane A), the owner can drive grants from
7
+ -- their own authenticated session. Activating it safely requires binding the grantor
8
+ -- to the RESOLVED actor, NOT coalesce(current_agent_id(), p_grantor_agent_id):
9
+ -- current_agent_id() returns null for ANY authenticated caller whose agent is
10
+ -- suspended/credential-rotating/unmapped (20260710000000:15 filters status='active'),
11
+ -- and such a caller still carries a valid `authenticated` token. A coalesce would then
12
+ -- honor an attacker-supplied p_grantor_agent_id (owner IDs are non-secret) and let them
13
+ -- self-escalate. _resolved_actor_agent_id fails closed: service_role -> supplied arg;
14
+ -- authenticated -> current_agent_id() and raises 42501 if null or if arg <> current.
15
+ --
16
+ -- Additive; no schema_version bump. grant_board_role was never authenticated, so no
17
+ -- old client keys on it — this is a net-new capability, not a re-activation.
18
+
19
+ create or replace function agent_board.grant_board_role(
20
+ p_board_id uuid,
21
+ p_agent_id uuid,
22
+ p_grantee_role text,
23
+ p_grantor_agent_id uuid default null
24
+ )
25
+ returns table(board_id uuid, agent_id uuid, role text, updated_at timestamptz)
26
+ language plpgsql
27
+ security definer
28
+ set search_path = agent_board, public
29
+ as $$
30
+ declare
31
+ v_grantor uuid;
32
+ v_updated agent_board.board_memberships%rowtype;
33
+ begin
34
+ if p_grantee_role not in ('owner', 'admin', 'member', 'observer') then
35
+ raise exception 'invalid grantee role' using errcode = '22023';
36
+ end if;
37
+
38
+ -- Grantor is the RESOLVED actor. service_role -> supplied arg; authenticated ->
39
+ -- current_agent_id() and raises 42501 if null (suspended/unmapped) or if the
40
+ -- supplied arg <> current_agent_id(). This closes the single-principal escalation.
41
+ v_grantor := agent_board._resolved_actor_agent_id(p_grantor_agent_id, 'p_grantor_agent_id');
42
+
43
+ if not exists (
44
+ select 1 from agent_board.board_memberships bm
45
+ where bm.board_id = p_board_id and bm.agent_id = p_agent_id
46
+ ) then
47
+ raise exception 'grantee has no membership on this board' using errcode = 'P0002';
48
+ end if;
49
+
50
+ -- Ceiling consumption: grantee-bounded against min(grantor role, board ceiling).
51
+ if not agent_board._grant_within_ceiling(v_grantor, p_board_id, p_grantee_role) then
52
+ raise exception 'grant exceeds grantor role or board authority ceiling' using errcode = '42501';
53
+ end if;
54
+
55
+ update agent_board.board_memberships bm
56
+ set role = p_grantee_role,
57
+ updated_at = now()
58
+ where bm.board_id = p_board_id
59
+ and bm.agent_id = p_agent_id
60
+ returning * into v_updated;
61
+
62
+ return query
63
+ select v_updated.board_id, v_updated.agent_id, v_updated.role, v_updated.updated_at;
64
+ end;
65
+ $$;
66
+
67
+ comment on function agent_board.grant_board_role(uuid, uuid, text, uuid) is
68
+ 'Live board-role grant chokepoint. Grantor is the resolved actor (_resolved_actor_agent_id: service_role uses the supplied arg, authenticated uses current_agent_id() and fails closed on null/mismatch). Requires grantee membership (P0002), then enforces _grant_within_ceiling(grantor, board, grantee_role) before setting board_memberships.role.';
69
+
70
+ -- Live: authenticated owners/admins may grant (within ceiling); service_role retained.
71
+ revoke all on function agent_board.grant_board_role(uuid, uuid, text, uuid) from public, anon;
72
+ grant execute on function agent_board.grant_board_role(uuid, uuid, text, uuid) to authenticated, service_role;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-schema",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
5
5
  "repository": "https://github.com/Consiliency/message-board.git",
6
6
  "type": "module",
@@ -2,7 +2,7 @@
2
2
  "schema": "agent_board_schema_manifest.v1",
3
3
  "interface": "IF-0-MBPLANE-BUNDLE-1",
4
4
  "package": "@consiliency/agent-board-schema",
5
- "packageVersion": "1.4.0",
5
+ "packageVersion": "1.5.0",
6
6
  "schemaVersion": 8,
7
7
  "minClientVersion": "1.1.0",
8
8
  "source": {
@@ -46,8 +46,8 @@
46
46
  "usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
47
47
  }
48
48
  ],
49
- "migrationCount": 71,
50
- "aggregateSha256": "794661727d22a39e2d42ddfc5d6456fb93165cef68af11fb361385c29dbdead9",
49
+ "migrationCount": 73,
50
+ "aggregateSha256": "a43737edf382d8aee59069d6a9aec9e39fce17b31f092a3d7fcd96303b6f70e9",
51
51
  "migrations": [
52
52
  {
53
53
  "order": 0,
@@ -687,6 +687,24 @@
687
687
  "sourceRef": "message-board",
688
688
  "bytes": 3112,
689
689
  "sha256": "b2f58eadc13cb227991c4d978c5460878dc3cbada43c58285b55154b89a28459"
690
+ },
691
+ {
692
+ "order": 71,
693
+ "id": "20260723000100_agent_board_roomown_owner_seed",
694
+ "filename": "20260723000100_agent_board_roomown_owner_seed.sql",
695
+ "origin": "message-board-forward",
696
+ "sourceRef": "message-board",
697
+ "bytes": 7871,
698
+ "sha256": "ecfaf262832b3d499102ec3a2ee1d48e043b33282287d0e987555e868c07b804"
699
+ },
700
+ {
701
+ "order": 72,
702
+ "id": "20260723000200_agent_board_roomown_grant_role_live",
703
+ "filename": "20260723000200_agent_board_roomown_grant_role_live.sql",
704
+ "origin": "message-board-forward",
705
+ "sourceRef": "message-board",
706
+ "bytes": 3716,
707
+ "sha256": "40c9f585af16211fe9ab1d35efca6787e49b31afc7c85f208da22b9566a65575"
690
708
  }
691
709
  ],
692
710
  "excludedBridge": {
@@ -37,9 +37,9 @@ describe('manifest integrity', () => {
37
37
  expect(manifest.schemaVersion).toBe(8);
38
38
  expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
39
39
  });
40
- it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 forward migrations (migrationCount = prior 67 + 4 = 71)', () => {
40
+ it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN forward migrations (migrationCount = prior 71 + 2 = 73)', () => {
41
41
  expect(manifest.migrationCount).toBe(manifest.migrations.length);
42
- expect(manifest.migrationCount).toBe(71);
42
+ expect(manifest.migrationCount).toBe(73);
43
43
  // MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
44
44
  // schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
45
45
  // enrollment-only additive surface (identity_providers registry, enrollment audit,
@@ -74,6 +74,10 @@ describe('manifest integrity', () => {
74
74
  '20260719000300_agent_board_enroll_rate_cap.sql',
75
75
  '20260719001200_agent_board_enroll_federated_identity_raw_fingerprint.sql',
76
76
  '20260719001300_agent_board_enroll_rate_events_reaper_rls.sql',
77
+ // ROOMOWN: room ownership + self-service authority (create_board owner-seed +
78
+ // bootstrap; grant_board_role activated for authenticated). schema stays 8.
79
+ '20260723000100_agent_board_roomown_owner_seed.sql',
80
+ '20260723000200_agent_board_roomown_grant_role_live.sql',
77
81
  ]) {
78
82
  expect(names.has(f)).toBe(true);
79
83
  }
@@ -47,11 +47,11 @@ begin
47
47
  select board_id into v_board
48
48
  from agent_board.create_board(v_creator, 'br-board-' || v_suffix, 'BR Board', 'board role test', '{}'::jsonb, 'private');
49
49
 
50
- -- Creator + other memberships inserted with the column DEFAULT (member) to
51
- -- exercise the one-time backfill; member/admin get explicit roles for gate tests.
50
+ -- The creator's owner membership is now seeded by create_board (ROOMOWN). The
51
+ -- `other` membership is inserted with the column DEFAULT (member) to exercise the
52
+ -- one-time backfill; member/admin get explicit roles for gate tests.
52
53
  insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id)
53
54
  values
54
- (v_board, v_creator, 'joined', v_creator),
55
55
  (v_board, v_other, 'joined', v_creator);
56
56
  insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
57
57
  values
@@ -37,9 +37,10 @@ begin
37
37
  select board_id into v_board
38
38
  from agent_board.create_board(v_owner, 'dg-board-' || v_suffix, 'DG Board', 'delegation test', '{}'::jsonb, 'private');
39
39
 
40
+ -- The owner's owner membership is now seeded by create_board (ROOMOWN); only the
41
+ -- member membership needs an explicit insert.
40
42
  insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
41
43
  values
42
- (v_board, v_owner, 'joined', v_owner, 'owner'),
43
44
  (v_board, v_member, 'joined', v_owner, 'member');
44
45
 
45
46
  -- Policy with only root_agent_id set -> defaults apply (child_ttl_ceiling 8h).
@@ -72,6 +72,10 @@ select is(
72
72
  select pg_get_functiondef(p.oid) as def, p.proname
73
73
  from pg_proc p join pg_namespace n on n.oid = p.pronamespace
74
74
  where n.nspname = 'agent_board'
75
+ -- pg_get_functiondef() RAISES on aggregate ('a') / window ('w') procs
76
+ -- ("X is an aggregate function"); restrict to callable functions +
77
+ -- procedures (the RPC surface this invariant is about).
78
+ and p.prokind in ('f', 'p')
75
79
  and p.proname <> 'enroll_federated_identity'
76
80
  ) d
77
81
  where lower(d.def) like '%enrollment_federation_audit%'),
@@ -81,6 +85,7 @@ select is(
81
85
  select pg_get_functiondef(p.oid) as def
82
86
  from pg_proc p join pg_namespace n on n.oid = p.pronamespace
83
87
  where n.nspname = 'agent_board'
88
+ and p.prokind in ('f', 'p') -- pg_get_functiondef raises on aggregate/window procs
84
89
  and p.proname in (
85
90
  'mint_fleet_agent_session', 'post_board_message', 'send_direct_message',
86
91
  'send_multicast_message', 'create_tasking_delivery', 'revoke_agent')
@@ -371,7 +371,7 @@ begin
371
371
  '{}'::jsonb
372
372
  );
373
373
  v_board_message := v_result.message_id;
374
- perform is(v_result.delivered_recipient_count, 2, 'board delivery reaches joined and subscribed members only');
374
+ perform is(v_result.delivered_recipient_count, 3, 'board delivery reaches joined and subscribed members only (incl. the seeded owner-creator)');
375
375
  perform is(
376
376
  (select count(*)::integer from agent_board.delivery_recipients where source_message_id = v_board_message and recipient_agent_id = v_left_member),
377
377
  0,
@@ -402,15 +402,15 @@ begin
402
402
  '{}'::jsonb
403
403
  );
404
404
  v_task := v_result.task_id;
405
- perform is(v_result.delivered_recipient_count, 3, 'tasking counts explicit and board fan-out attempts');
405
+ perform is(v_result.delivered_recipient_count, 4, 'tasking counts explicit and board fan-out attempts (incl. the seeded owner-creator)');
406
406
  perform is(
407
407
  (select count(*)::integer from agent_board.delivery_recipients where source_task_id = v_task),
408
- 2,
408
+ 3,
409
409
  'tasking suppresses duplicate recipient rows across explicit and board fan-out'
410
410
  );
411
411
  perform is(
412
412
  (select count(*)::integer from agent_board.inbox_items where source_task_id = v_task),
413
- 2,
413
+ 3,
414
414
  'tasking writes one inbox row per unique recipient'
415
415
  );
416
416
 
@@ -28,6 +28,7 @@ do $$
28
28
  declare
29
29
  s text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
30
30
  v_agent uuid; v_auth uuid;
31
+ v_creator uuid := gen_random_uuid();
31
32
  v_key uuid := gen_random_uuid();
32
33
  v_ch uuid := gen_random_uuid();
33
34
  v_board uuid; v_thread uuid := gen_random_uuid();
@@ -57,11 +58,14 @@ begin
57
58
  insert into agent_board.agent_signing_key_challenges(id, signing_key_id, agent_id, challenge_nonce, challenge_bytes, proof_state, expires_at, consumed_at)
58
59
  values (v_ch, v_key, v_agent, v_ch::text, 'agent-signing-key-proof.v1:'||v_ch||':n', 'pending', now() + interval '10 minutes', null);
59
60
 
60
- -- A board + membership + thread so the enrolled agent can send.
61
+ -- A board (created by a dedicated creator; owner seeded by create_board) +
62
+ -- membership + thread so the enrolled agent sends as a genuine NON-creator member.
63
+ insert into agent_board.agents(id, agent_type, handle, display_name, status)
64
+ values (v_creator, 'agent', 'pofx-creator-'||s, 'POFX Creator', 'active');
61
65
  select board_id into v_board
62
- from agent_board.create_board(v_agent, 'pofx-board-'||s, 'POFX Board', 'portal-offline', '{}'::jsonb, 'private');
66
+ from agent_board.create_board(v_creator, 'pofx-board-'||s, 'POFX Board', 'portal-offline', '{}'::jsonb, 'private');
63
67
  insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
64
- values (v_board, v_agent, 'joined', v_agent, 'member');
68
+ values (v_board, v_agent, 'joined', v_creator, 'member');
65
69
  insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
66
70
  values (v_thread, 'coordination', v_agent, 'POFX thread');
67
71
 
@@ -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