@consiliency/agent-board-schema 1.3.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.
Files changed (66) hide show
  1. package/bin/agent-board-schema.js +0 -0
  2. package/fixtures/already-applied.json +112 -0
  3. package/migrations/20260717000100_agent_board_board_role.sql +180 -0
  4. package/migrations/20260717000200_agent_board_thread_acl.sql +149 -0
  5. package/migrations/20260717000300_agent_board_agent_lineage.sql +54 -0
  6. package/migrations/20260717000400_agent_board_delegation_policies.sql +175 -0
  7. package/migrations/20260718000100_agent_board_send_authz_config.sql +172 -0
  8. package/migrations/20260718000200_agent_board_send_role_gate.sql +804 -0
  9. package/migrations/20260718000300_agent_board_fleet_policy_default_deny.sql +192 -0
  10. package/migrations/20260718000400_agent_board_grant_ceiling_wiring.sql +58 -0
  11. package/migrations/20260718000500_agent_board_fleet_agent_session_class.sql +24 -0
  12. package/migrations/20260718000600_agent_board_mint_fleet_agent_session.sql +172 -0
  13. package/migrations/20260718000700_agent_board_nonfleet_session_reaper.sql +35 -0
  14. package/migrations/20260718000800_agent_board_regrant_scoped_send.sql +24 -0
  15. package/migrations/20260718000900_agent_board_agents_status_revoked.sql +24 -0
  16. package/migrations/20260718001000_agent_board_delegation_enforcement.sql +296 -0
  17. package/migrations/20260718001100_agent_board_revoke_agent_subtree.sql +238 -0
  18. package/migrations/20260718001200_agent_board_resigning_refresh.sql +129 -0
  19. package/migrations/20260718001300_agent_board_identity_providers.sql +70 -0
  20. package/migrations/20260718001400_agent_board_enrollment_federation_audit.sql +50 -0
  21. package/migrations/20260718001500_agent_board_enroll_federated_identity.sql +220 -0
  22. package/migrations/20260719000100_agent_board_signing_challenge_pending_cap.sql +124 -0
  23. package/migrations/20260719000200_agent_board_signing_challenge_reaper.sql +34 -0
  24. package/migrations/20260719000300_agent_board_enroll_rate_cap.sql +72 -0
  25. package/migrations/20260719001000_agent_board_identity_providers_portal_contract.sql +38 -0
  26. package/migrations/20260719001100_agent_board_enroll_federated_identity_pop.sql +267 -0
  27. package/migrations/20260719001200_agent_board_enroll_federated_identity_raw_fingerprint.sql +296 -0
  28. package/migrations/20260719001300_agent_board_enroll_rate_events_reaper_rls.sql +62 -0
  29. package/migrations/20260723000100_agent_board_roomown_owner_seed.sql +167 -0
  30. package/migrations/20260723000200_agent_board_roomown_grant_role_live.sql +72 -0
  31. package/package.json +9 -9
  32. package/schema-manifest.json +256 -4
  33. package/src/tooling.test.mjs +46 -1
  34. package/tests/agent_board_agent_lineage.test.sql +73 -0
  35. package/tests/agent_board_agents_status_revoked.test.sql +60 -0
  36. package/tests/agent_board_auth_rpcs.test.sql +17 -16
  37. package/tests/agent_board_board_role.test.sql +129 -0
  38. package/tests/agent_board_bundle_endpoint_compat.test.sql +18 -6
  39. package/tests/agent_board_canary_signed_fleet_enforcement.test.sql +8 -8
  40. package/tests/agent_board_delegation_enforcement.test.sql +175 -0
  41. package/tests/agent_board_delegation_policies.test.sql +120 -0
  42. package/tests/agent_board_enroll_federated_identity.test.sql +217 -0
  43. package/tests/agent_board_enroll_pop_binding.test.sql +131 -0
  44. package/tests/agent_board_enroll_rate_cap.test.sql +53 -0
  45. package/tests/agent_board_enrollment_audit_content.test.sql +139 -0
  46. package/tests/agent_board_fleet_agent_session_class.test.sql +64 -0
  47. package/tests/agent_board_fleet_policy_default_deny.test.sql +149 -0
  48. package/tests/agent_board_fleet_registry.test.sql +2 -2
  49. package/tests/agent_board_identity_providers.test.sql +104 -0
  50. package/tests/agent_board_inbox_rpcs.test.sql +10 -10
  51. package/tests/agent_board_mint_fleet_agent_session.test.sql +290 -0
  52. package/tests/agent_board_nonfleet_session_reaper.test.sql +61 -0
  53. package/tests/agent_board_portal_offline_invariant.test.sql +134 -0
  54. package/tests/agent_board_resigning_refresh.test.sql +73 -0
  55. package/tests/agent_board_revoke_agent_subtree.test.sql +188 -0
  56. package/tests/agent_board_roomown.test.sql +233 -0
  57. package/tests/agent_board_schema_version.test.sql +2 -2
  58. package/tests/agent_board_schema_version_rpc.test.sql +1 -1
  59. package/tests/agent_board_scoped_send_regrant.test.sql +142 -0
  60. package/tests/agent_board_send_authz_config.test.sql +84 -0
  61. package/tests/agent_board_send_authz_matrix.test.sql +162 -0
  62. package/tests/agent_board_send_role_gate.test.sql +181 -0
  63. package/tests/agent_board_signing_challenge_pending_cap.test.sql +87 -0
  64. package/tests/agent_board_signing_challenge_reaper.test.sql +74 -0
  65. package/tests/agent_board_thread_acl.test.sql +129 -0
  66. package/tests/agent_board_v2_invariants.test.sql +5 -5
@@ -0,0 +1,188 @@
1
+ -- MBDELEG SL-1 pgTAP (IF-0-MBDELEG-3): DOWN-walking refresh-checked subtree cascade.
2
+ --
3
+ -- Proves the two linchpins and the survival contract:
4
+ -- (a) REFRESH-BOUNDARY KILL: after a live child fleet_agent_session exists,
5
+ -- revoke_agent_subtree(root) flips ONLY key+status+grant (NOT board membership),
6
+ -- and a fresh mint_fleet_agent_session for that child RAISES within one <=15-min
7
+ -- TTL (the re-mint fails; not old-token expiry).
8
+ -- (b) INERT-LEDGER: a bare fleet_revocation_events row inserted directly (no key/status
9
+ -- flip) does NOT stop mint_fleet_agent_session — the refresh path reads key/status,
10
+ -- not the audit ledger.
11
+ -- + revoke_agent_subtree kills the root + grant-derived descendants (keys + status +
12
+ -- grants + audit rows), while a lineage-independent independent_auth principal (NULL
13
+ -- parent, own active key+status, same owner) SURVIVES and still mints; and
14
+ -- revoke_human_subtree(owner) is TOTAL — it also kills the independent principal.
15
+ --
16
+ -- Runs as postgres (service_role-equivalent).
17
+ create extension if not exists pgtap with schema extensions;
18
+ set search_path = extensions, public, agent_board;
19
+
20
+ begin;
21
+
22
+ select plan(19);
23
+
24
+ create temp table _rev_fx (
25
+ owner_id uuid, admin uuid, r uuid, r_key uuid,
26
+ c uuid, c_key uuid, i uuid, i_key uuid, z uuid, z_key uuid, bd uuid,
27
+ ch_c1 uuid, ch_c2 uuid, ch_i1 uuid, ch_i2 uuid, ch_i3 uuid, ch_z uuid
28
+ ) on commit drop;
29
+
30
+ do $$
31
+ declare
32
+ s text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
33
+ u_owner uuid; u_admin uuid; u_r uuid; u_i uuid; u_z uuid;
34
+ v_admin uuid := gen_random_uuid(); v_r uuid := gen_random_uuid();
35
+ v_i uuid := gen_random_uuid(); v_z uuid := gen_random_uuid();
36
+ v_bd uuid := gen_random_uuid();
37
+ v_rkey uuid := gen_random_uuid(); v_ikey uuid := gen_random_uuid(); v_zkey uuid := gen_random_uuid();
38
+ v_c uuid; v_ckey uuid;
39
+ ch_c1 uuid := gen_random_uuid(); ch_c2 uuid := gen_random_uuid();
40
+ ch_i1 uuid := gen_random_uuid(); ch_i2 uuid := gen_random_uuid(); ch_i3 uuid := gen_random_uuid();
41
+ ch_z uuid := gen_random_uuid();
42
+ begin
43
+ u_owner := public.ensure_github_user('rev-own-'||s, 'rev-own-'||s||'@example.test', 'rev-own-'||s, 'OWN');
44
+ u_admin := public.ensure_github_user('rev-adm-'||s, 'rev-adm-'||s||'@example.test', 'rev-adm-'||s, 'ADM');
45
+ u_r := public.ensure_github_user('rev-r-'||s, 'rev-r-'||s||'@example.test', 'rev-r-'||s, 'R');
46
+ u_i := public.ensure_github_user('rev-i-'||s, 'rev-i-'||s||'@example.test', 'rev-i-'||s, 'I');
47
+ u_z := public.ensure_github_user('rev-z-'||s, 'rev-z-'||s||'@example.test', 'rev-z-'||s, 'Z');
48
+
49
+ -- Admin (revoker; distinct owner so it is not swept by revoke_human_subtree(owner)).
50
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id)
51
+ values (v_admin, 'agent', 'rev-adm-'||s, 'ADM', 'active', u_admin);
52
+
53
+ -- Root R (owner = the human). Its own active key.
54
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
55
+ values (v_r, 'agent', 'rev-r-'||s, 'R', 'active', u_r, u_owner);
56
+ insert into agent_board.delegation_policies(root_agent_id, mode, max_active_children, allowed_scopes, child_ttl_ceiling, require_child_signing_key)
57
+ values (v_r, 'root_minted', 5, jsonb_build_array('scope:a'), interval '8 hours', true);
58
+ insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
59
+ values (v_rkey, v_r, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(v_rkey::text,'-',''),43,'A'), 'active', 'proven');
60
+
61
+ -- Delegated child C under R (gets an ephemeral revocable key + a grant).
62
+ select child_agent_id, signing_key_id into v_c, v_ckey
63
+ from agent_board.mint_delegated_child(v_r, 'rev-c-'||s, jsonb_build_array('scope:a'),
64
+ rtrim(translate(encode(extensions.gen_random_bytes(32), 'base64'), '+/', '-_'), '='));
65
+
66
+ -- Give C a joined board membership; the cascade must NOT touch it.
67
+ insert into agent_board.boards(id, slug, name, created_by_agent_id)
68
+ values (v_bd, 'rev-bd-'||s, 'Rev Board', v_admin);
69
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, role)
70
+ values (v_bd, v_c, 'joined', 'member');
71
+
72
+ -- Give C a live legacy agent_credentials row; the cascade must revoke it (#3).
73
+ insert into agent_board.agent_credentials(agent_id, refresh_token_hash, hash_version, credential_label, expires_at)
74
+ values (v_c, extensions.digest('rev-c-cred-'||s, 'sha256'), 1, 'legacy', now() + interval '1 day');
75
+
76
+ -- Independent principal I: lineage-independent (NULL parent/root), own active key,
77
+ -- same human owner. Survives a root revoke; killed only by self or human revoke.
78
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
79
+ values (v_i, 'agent', 'rev-i-'||s, 'I', 'active', u_i, u_owner);
80
+ insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
81
+ values (v_ikey, v_i, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(v_ikey::text,'-',''),43,'A'), 'active', 'proven');
82
+
83
+ -- Z: independent bystander (distinct owner) used only for the inert-ledger proof.
84
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
85
+ values (v_z, 'agent', 'rev-z-'||s, 'Z', 'active', u_z, u_z);
86
+ insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
87
+ values (v_zkey, v_z, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(v_zkey::text,'-',''),43,'A'), 'active', 'proven');
88
+
89
+ -- Challenges (pending, unexpired), bound to (key, agent).
90
+ insert into agent_board.agent_signing_key_challenges(id, signing_key_id, agent_id, challenge_nonce, challenge_bytes, proof_state, expires_at)
91
+ values
92
+ (ch_c1, v_ckey, v_c, ch_c1::text, 'agent-signing-key-proof.v1:'||ch_c1||':n', 'pending', now() + interval '10 minutes'),
93
+ (ch_c2, v_ckey, v_c, ch_c2::text, 'agent-signing-key-proof.v1:'||ch_c2||':n', 'pending', now() + interval '10 minutes'),
94
+ (ch_i1, v_ikey, v_i, ch_i1::text, 'agent-signing-key-proof.v1:'||ch_i1||':n', 'pending', now() + interval '10 minutes'),
95
+ (ch_i2, v_ikey, v_i, ch_i2::text, 'agent-signing-key-proof.v1:'||ch_i2||':n', 'pending', now() + interval '10 minutes'),
96
+ (ch_i3, v_ikey, v_i, ch_i3::text, 'agent-signing-key-proof.v1:'||ch_i3||':n', 'pending', now() + interval '10 minutes'),
97
+ (ch_z, v_zkey, v_z, ch_z::text, 'agent-signing-key-proof.v1:'||ch_z||':n', 'pending', now() + interval '10 minutes');
98
+
99
+ insert into _rev_fx values (
100
+ u_owner, v_admin, v_r, v_rkey, v_c, v_ckey, v_i, v_ikey, v_z, v_zkey, v_bd,
101
+ ch_c1, ch_c2, ch_i1, ch_i2, ch_i3, ch_z
102
+ );
103
+ end
104
+ $$;
105
+
106
+ -- Function existence.
107
+ select has_function('agent_board', 'revoke_agent_subtree', array['uuid','uuid','text'], 'revoke_agent_subtree exists');
108
+ select has_function('agent_board', 'revoke_human_subtree', array['uuid','uuid','text'], 'revoke_human_subtree exists');
109
+
110
+ -- Phase 1: live sessions mint pre-revoke.
111
+ select lives_ok(
112
+ (select format($q$select agent_board.mint_fleet_agent_session(%1$L::uuid, %1$L::text, %2$L::uuid, %3$L::uuid)$q$, ch_c1, c_key, c) from _rev_fx),
113
+ 'child C mints a live fleet_agent_session while its lineage is active');
114
+ select lives_ok(
115
+ (select format($q$select agent_board.mint_fleet_agent_session(%1$L::uuid, %1$L::text, %2$L::uuid, %3$L::uuid)$q$, ch_i1, i_key, i) from _rev_fx),
116
+ 'independent principal I mints a live session pre-revoke');
117
+
118
+ -- INERT-LEDGER: a bare audit row alone does NOT stop Z's mint.
119
+ insert into agent_board.fleet_revocation_events(subject_kind, subject_id, agent_id, revoked_by_agent_id, revocation_reason)
120
+ select 'agent', z, z, admin, 'bare-ledger-inert-test' from _rev_fx;
121
+ select lives_ok(
122
+ (select format($q$select agent_board.mint_fleet_agent_session(%1$L::uuid, %1$L::text, %2$L::uuid, %3$L::uuid)$q$, ch_z, z_key, z) from _rev_fx),
123
+ 'inert-ledger: a bare fleet_revocation_events row does NOT stop mint (refresh reads key/status, not the ledger)');
124
+
125
+ -- Phase 2: DOWN-walking cascade from the root R.
126
+ create temp table _rev_counts (revoked_agent_count int, revoked_key_count int) on commit drop;
127
+ insert into _rev_counts
128
+ select * from agent_board.revoke_agent_subtree((select r from _rev_fx), (select admin from _rev_fx), 'root revocation');
129
+
130
+ select is((select revoked_agent_count from _rev_counts), 2, 'revoke_agent_subtree revoked 2 agents (root R + grant-derived child C)');
131
+ select is((select revoked_key_count from _rev_counts), 2, 'revoke_agent_subtree revoked 2 signing keys (R primary + C ephemeral)');
132
+
133
+ select is(
134
+ (select k.status from agent_board.agent_signing_keys k where k.id = (select c_key from _rev_fx)),
135
+ 'revoked', 'child C signing key flipped to revoked');
136
+ select is(
137
+ (select a.status from agent_board.agents a where a.id = (select c from _rev_fx)),
138
+ 'revoked', 'child C agents.status flipped to revoked');
139
+ select ok(
140
+ (select g.revoked_at is not null from agent_board.delegation_grants g where g.child_agent_id = (select c from _rev_fx)),
141
+ 'child C delegation grant marked revoked');
142
+
143
+ -- #3 fix: the cascade also revokes the descendant's legacy agent_credentials.
144
+ select is(
145
+ (select count(*)::int from agent_board.agent_credentials ac
146
+ where ac.agent_id = (select c from _rev_fx) and ac.revoked_at is null),
147
+ 0, 'cascade revoked child C''s agent_credentials (no live legacy credential remains)');
148
+
149
+ -- NOT board membership: C's joined membership is untouched by the cascade.
150
+ select is(
151
+ (select bm.membership_state from agent_board.board_memberships bm
152
+ where bm.agent_id = (select c from _rev_fx) and bm.board_id = (select bd from _rev_fx)),
153
+ 'joined', 'cascade did NOT touch board membership (still joined)');
154
+
155
+ -- Audit rows appended for C (agent + signing_key).
156
+ select ok(
157
+ (select count(*) filter (where subject_kind = 'agent') >= 1
158
+ and count(*) filter (where subject_kind = 'signing_key') >= 1
159
+ from agent_board.fleet_revocation_events e where e.agent_id = (select c from _rev_fx)),
160
+ 'cascade appended audit fleet_revocation_events rows for C (agent + signing_key)');
161
+
162
+ -- REFRESH-BOUNDARY KILL [linchpin]: a fresh re-mint for C now RAISES (not old-token expiry).
163
+ select throws_ok(
164
+ (select format($q$select agent_board.mint_fleet_agent_session(%1$L::uuid, %1$L::text, %2$L::uuid, %3$L::uuid)$q$, ch_c2, c_key, c) from _rev_fx),
165
+ '42501', null, 'refresh-boundary kill: a fresh re-mint for C AFTER revoke_agent_subtree raises');
166
+
167
+ -- independent_auth SURVIVAL: I is untouched by revoke_agent_subtree(root) and still mints.
168
+ select lives_ok(
169
+ (select format($q$select agent_board.mint_fleet_agent_session(%1$L::uuid, %1$L::text, %2$L::uuid, %3$L::uuid)$q$, ch_i2, i_key, i) from _rev_fx),
170
+ 'independent principal I survives revoke_agent_subtree(root) and still mints');
171
+ select is(
172
+ (select k.status from agent_board.agent_signing_keys k where k.id = (select i_key from _rev_fx)),
173
+ 'active', 'independent principal I signing key remains active after root revoke');
174
+
175
+ -- Phase 3: TOTAL human revocation kills I as well.
176
+ select lives_ok(
177
+ (select format($q$select agent_board.revoke_human_subtree(%L::uuid, %L::uuid, 'human revocation')$q$, owner_id, admin) from _rev_fx),
178
+ 'revoke_human_subtree runs for the human owner');
179
+ select throws_ok(
180
+ (select format($q$select agent_board.mint_fleet_agent_session(%1$L::uuid, %1$L::text, %2$L::uuid, %3$L::uuid)$q$, ch_i3, i_key, i) from _rev_fx),
181
+ '42501', null, 'after revoke_human_subtree, independent principal I can no longer mint (total human revocation)');
182
+ select is(
183
+ (select a.status from agent_board.agents a where a.id = (select i from _rev_fx)),
184
+ 'revoked', 'revoke_human_subtree flipped the independent principal I to revoked');
185
+
186
+ select finish();
187
+
188
+ rollback;
@@ -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;
@@ -10,8 +10,8 @@ SELECT is(
10
10
 
11
11
  SELECT is(
12
12
  (SELECT version FROM agent_board.schema_version),
13
- 7,
14
- 'schema_version.version is 7'
13
+ 8,
14
+ 'schema_version.version is 8'
15
15
  );
16
16
 
17
17
  SELECT throws_ok(
@@ -31,7 +31,7 @@ set local role authenticated;
31
31
 
32
32
  select is(
33
33
  (select version from agent_board.get_schema_version()),
34
- 7,
34
+ 8,
35
35
  'authenticated agents receive the metadata-only schema version'
36
36
  );
37
37
 
@@ -0,0 +1,142 @@
1
+ -- MBSESSION SL-1 pgTAP: the 8 core send RPCs are re-granted to authenticated, the
2
+ -- authenticated path derives the actor from current_agent_id() (rejecting a mismatched
3
+ -- p_author_agent_id), real membership is evaluated with _is_service_role() false, a
4
+ -- membership-revoked-mid-session send fails, and no send RPC references Portal
5
+ -- issuer/JWKS (a write succeeds with no Portal dependency present).
6
+ --
7
+ -- Runs as postgres; request.jwt.claim.role toggles _is_service_role()
8
+ -- ('authenticated' => false) and request.jwt.claim.sub picks the acting agent.
9
+ create extension if not exists pgtap with schema extensions;
10
+ set search_path = extensions, public, agent_board;
11
+
12
+ begin;
13
+
14
+ select plan(15);
15
+
16
+ create temp table _regrant_fixture (
17
+ board uuid,
18
+ member_agent uuid,
19
+ other_agent uuid,
20
+ recipient_agent uuid,
21
+ member_user uuid,
22
+ thread_member uuid,
23
+ thread_dm uuid
24
+ ) on commit drop;
25
+
26
+ do $$
27
+ declare
28
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
29
+ v_member_user uuid;
30
+ v_other_user uuid;
31
+ v_creator uuid := gen_random_uuid();
32
+ v_member uuid := gen_random_uuid();
33
+ v_other uuid := gen_random_uuid();
34
+ v_recipient uuid := gen_random_uuid();
35
+ v_board uuid;
36
+ v_thread_member uuid := gen_random_uuid();
37
+ v_thread_dm uuid := gen_random_uuid();
38
+ begin
39
+ v_member_user := public.ensure_github_user('rg-member-' || v_suffix, 'rg-member-' || v_suffix || '@example.test', 'rg-member-' || v_suffix, 'RG Member');
40
+ v_other_user := public.ensure_github_user('rg-other-' || v_suffix, 'rg-other-' || v_suffix || '@example.test', 'rg-other-' || v_suffix, 'RG Other');
41
+
42
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
43
+ values
44
+ (v_member, 'agent', 'rg-member-' || v_suffix, 'RG Member', 'active', v_member_user, v_member_user),
45
+ (v_other, 'agent', 'rg-other-' || v_suffix, 'RG Other', 'active', v_other_user, v_other_user),
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);
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'.
51
+ select board_id into v_board
52
+ from agent_board.create_board(v_creator, 'rg-board-' || v_suffix, 'RG Board', 'regrant test', '{}'::jsonb, 'private');
53
+
54
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
55
+ values (v_board, v_member, 'joined', v_creator, 'member');
56
+
57
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
58
+ values
59
+ (v_thread_member, 'coordination', v_member, 'RG member thread'),
60
+ (v_thread_dm, 'coordination', v_member, 'RG dm thread');
61
+
62
+ insert into _regrant_fixture values
63
+ (v_board, v_member, v_other, v_recipient, v_member_user, v_thread_member, v_thread_dm);
64
+ end
65
+ $$;
66
+
67
+ -- 1. The 8 core send-RPC overloads are re-granted execute to authenticated. --------
68
+ select function_privs_are(
69
+ 'agent_board', 'send_direct_message', array['uuid','uuid','uuid','text','text','text','text','text','jsonb'],
70
+ 'authenticated', array['EXECUTE'], 'send_direct_message(uuid recipient) re-granted to authenticated');
71
+ select function_privs_are(
72
+ 'agent_board', 'send_direct_message', array['uuid','uuid','jsonb','text','text','text','text','text','text','jsonb'],
73
+ 'authenticated', array['EXECUTE'], 'send_direct_message(jsonb descriptor) re-granted to authenticated');
74
+ select function_privs_are(
75
+ 'agent_board', 'send_multicast_message', array['uuid','uuid','uuid[]','text','text','text','text','text','jsonb'],
76
+ 'authenticated', array['EXECUTE'], 'send_multicast_message(uuid[] recipients) re-granted to authenticated');
77
+ select function_privs_are(
78
+ 'agent_board', 'send_multicast_message', array['uuid','uuid','jsonb','text','text','text','text','text','text','jsonb'],
79
+ 'authenticated', array['EXECUTE'], 'send_multicast_message(jsonb descriptors) re-granted to authenticated');
80
+ select function_privs_are(
81
+ 'agent_board', 'post_board_message', array['uuid','uuid','uuid','text','text','text','text','text','jsonb'],
82
+ 'authenticated', array['EXECUTE'], 'post_board_message(uuid board) re-granted to authenticated');
83
+ select function_privs_are(
84
+ 'agent_board', 'post_board_message', array['uuid','uuid','text','text','text','text','text','text','text','jsonb'],
85
+ 'authenticated', array['EXECUTE'], 'post_board_message(text board_slug) re-granted to authenticated');
86
+ select function_privs_are(
87
+ 'agent_board', 'create_tasking_delivery', array['uuid','uuid','text','text','uuid[]','uuid','text','text','text','jsonb'],
88
+ 'authenticated', array['EXECUTE'], 'create_tasking_delivery(uuid[] recipients) re-granted to authenticated');
89
+ select function_privs_are(
90
+ 'agent_board', 'create_tasking_delivery', array['uuid','uuid','text','text','jsonb','text','text','text','text','text','jsonb'],
91
+ 'authenticated', array['EXECUTE'], 'create_tasking_delivery(jsonb descriptors) re-granted to authenticated');
92
+
93
+ -- 2. Authenticated path: _is_service_role() is false, real membership evaluated. ----
94
+ select set_config('request.jwt.claim.role', 'authenticated', true);
95
+ select set_config('request.jwt.claim.sub', (select member_user from _regrant_fixture)::text, true);
96
+ select is(agent_board._is_service_role(), false, 'authenticated path: _is_service_role() is false');
97
+
98
+ select lives_ok(
99
+ (select format($q$select agent_board.post_board_message(%L::uuid, %L::uuid, %L::uuid, 'sub', 'body')$q$,
100
+ thread_member, member_agent, board) from _regrant_fixture),
101
+ 'a non-observer board member send succeeds with real membership evaluated (Portal-offline write, no Portal dependency present)');
102
+
103
+ -- 3. A caller-supplied actor that mismatches current_agent_id() is rejected. --------
104
+ select throws_ok(
105
+ (select format($q$select agent_board.send_direct_message(%L::uuid, %L::uuid, %L::uuid, 'sub', 'body')$q$,
106
+ thread_dm, other_agent, recipient_agent) from _regrant_fixture),
107
+ '42501', null,
108
+ 'send with p_author_agent_id <> current_agent_id() is rejected by _resolved_actor_agent_id');
109
+
110
+ -- 4. A board-less direct send by the member also succeeds (no Portal dependency). ---
111
+ select lives_ok(
112
+ (select format($q$select agent_board.send_direct_message(%L::uuid, %L::uuid, %L::uuid, 'sub', 'body')$q$,
113
+ thread_dm, member_agent, recipient_agent) from _regrant_fixture),
114
+ 'a board-less direct send succeeds on the authenticated path (Portal-offline behavioral)');
115
+
116
+ -- 5. Membership revoked mid-session: the same member board send now fails. ----------
117
+ update agent_board.board_memberships
118
+ set membership_state = 'left', left_at = now()
119
+ where board_id = (select board from _regrant_fixture)
120
+ and agent_id = (select member_agent from _regrant_fixture);
121
+ select throws_ok(
122
+ (select format($q$select agent_board.post_board_message(%L::uuid, %L::uuid, %L::uuid, 'sub', 'body')$q$,
123
+ thread_member, member_agent, board) from _regrant_fixture),
124
+ '42501', null,
125
+ 'a membership-revoked-mid-session board send is rejected by the role gate');
126
+
127
+ select set_config('request.jwt.claim.role', '', true);
128
+ select set_config('request.jwt.claim.sub', '', true);
129
+
130
+ -- 6. Portal-offline static: no send RPC body references a Portal issuer / JWKS. -----
131
+ select ok(
132
+ position('portal' in lower(pg_get_functiondef(
133
+ 'agent_board.post_board_message(uuid,uuid,uuid,text,text,text,text,text,jsonb)'::regprocedure))) = 0,
134
+ 'post_board_message references no Portal issuer/URL');
135
+ select ok(
136
+ position('jwks' in lower(pg_get_functiondef(
137
+ 'agent_board.post_board_message(uuid,uuid,uuid,text,text,text,text,text,jsonb)'::regprocedure))) = 0,
138
+ 'post_board_message references no JWKS');
139
+
140
+ select finish();
141
+
142
+ rollback;