@consiliency/agent-board-schema 1.10.0 → 1.22.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 (109) hide show
  1. package/fixtures/already-applied.json +136 -0
  2. package/migrations/20260729000100_agent_board_fedsession_autojoin_default_board.sql +364 -0
  3. package/migrations/20260730000100_agent_board_gatenull_service_role_bypass.sql +369 -0
  4. package/migrations/20260730000200_agent_board_membstate_authz.sql +195 -0
  5. package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
  6. package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
  7. package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
  8. package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
  9. package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
  10. package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
  11. package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
  12. package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
  13. package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
  14. package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
  15. package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
  16. package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
  17. package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
  18. package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
  19. package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
  20. package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
  21. package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
  22. package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
  23. package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
  24. package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
  25. package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
  26. package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
  27. package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
  28. package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
  29. package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
  30. package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
  31. package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
  32. package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
  33. package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
  34. package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
  35. package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
  36. package/package.json +1 -1
  37. package/schema-manifest.json +309 -3
  38. package/src/tooling.test.mjs +123 -2
  39. package/tests/agent_board_actorspoof.test.sql +349 -0
  40. package/tests/agent_board_agentsdir.test.sql +154 -0
  41. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  42. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  43. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  44. package/tests/agent_board_claimkey.test.sql +229 -0
  45. package/tests/agent_board_consent_backfill.test.sql +48 -0
  46. package/tests/agent_board_consent_emission.test.sql +601 -0
  47. package/tests/agent_board_consent_gating.test.sql +489 -0
  48. package/tests/agent_board_consent_substrate.test.sql +720 -0
  49. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  50. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  51. package/tests/agent_board_delegation_policies.test.sql +107 -1
  52. package/tests/agent_board_fedsession.test.sql +6 -4
  53. package/tests/agent_board_fedsession_autojoin.test.sql +181 -0
  54. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  55. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  56. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  57. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  58. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  59. package/tests/agent_board_fleet_policy.test.sql +2 -2
  60. package/tests/agent_board_gatenull_membstate.test.sql +352 -0
  61. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  62. package/tests/agent_board_identity_providers.test.sql +9 -0
  63. package/tests/agent_board_inbox_reads.test.sql +29 -12
  64. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  65. package/tests/agent_board_invitepolicy.test.sql +205 -0
  66. package/tests/agent_board_leaveidem.test.sql +194 -0
  67. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  68. package/tests/agent_board_mint_harden.test.sql +303 -0
  69. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  70. package/tests/agent_board_obsgate.test.sql +178 -0
  71. package/tests/agent_board_obsgate2.test.sql +153 -0
  72. package/tests/agent_board_obsgate3.test.sql +170 -0
  73. package/tests/agent_board_obsgate4.test.sql +196 -0
  74. package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
  75. package/tests/agent_board_pubexec_class.test.sql +53 -0
  76. package/tests/agent_board_rejectsem.test.sql +271 -0
  77. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  78. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  79. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  80. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  81. package/tests/agent_board_rolereset.test.sql +351 -0
  82. package/tests/agent_board_roomdeleg.test.sql +5 -3
  83. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  84. package/tests/agent_board_roomlink.test.sql +25 -9
  85. package/tests/agent_board_rowid.test.sql +1274 -0
  86. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  87. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  88. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  89. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  90. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  91. package/tests/agent_board_signing_store.test.sql +6 -6
  92. package/tests/fixtures/isolation-substrate.sql +93 -0
  93. package/tests/fixtures/test-prereqs.sql +33 -0
  94. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  95. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  96. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  97. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  98. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  99. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  100. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  101. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  102. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  103. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  104. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  105. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  106. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  107. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  108. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  109. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
@@ -0,0 +1,369 @@
1
+ -- GATENULL — distinguish the service_role bypass from an unresolvable authenticated principal.
2
+ -- Tracks message-board#58.
3
+ --
4
+ -- THE DEFECT. Board-mutating RPCs use `if v_actor is not null then <authz> end if` as the
5
+ -- service_role bypass, where `v_actor := agent_board.current_agent_id()`. current_agent_id()
6
+ -- resolves only an ACTIVE agent, so it returns NULL for TWO very different callers:
7
+ -- (1) a genuine service_role / operator call — the bypass is intended; and
8
+ -- (2) an AUTHENTICATED principal with no active agent — a suspended/revoked agent holding a
9
+ -- still-valid JWT, a signed-in human with no agents row at all, or an active agent whose
10
+ -- JWT `agent_credential_id` claim names a revoked/expired credential.
11
+ -- Class (2) therefore takes the service_role bypass and skips authorization entirely.
12
+ --
13
+ -- SEVERITY IS NOT SESSION-BOUNDED (corrected during panel review). LOCALAUTH (20260726000100)
14
+ -- exists so browser humans sign in with THIS project's own Supabase Auth; such a user, BEFORE
15
+ -- self_enroll_human, is role=authenticated with no agents row — i.e. class (2) — while holding a
16
+ -- GoTrue session with a REFRESH token, renewable indefinitely. A suspended human is refused
17
+ -- reactivation (20260726000100:63-81) and so remains in class (2) permanently. Confirmed against
18
+ -- the live board in a rolled-back transaction: a signed-in principal with NO agent row joined a
19
+ -- private `invite_only` board with no invite, via the join_board visibility guard below.
20
+ --
21
+ -- THE FIX (deliberately MINIMAL — panel rejected a broader rewrite). Each function gains ONE
22
+ -- guard, placed FIRST, and every existing `if v_actor is not null then ...` block is left
23
+ -- BYTE-IDENTICAL:
24
+ --
25
+ -- if v_actor is null and not agent_board._is_service_role() then
26
+ -- raise exception 'current agent principal not resolved' using errcode = '42501';
27
+ -- end if;
28
+ --
29
+ -- After it, a non-service caller is guaranteed to have a non-null actor, so the untouched
30
+ -- `if v_actor is not null then <authz>` block now runs for EVERY authenticated caller. Behavior
31
+ -- is unchanged for: service_role with a null actor (create-on-behalf-of preserved), service_role
32
+ -- WITH a non-null actor (the checks keep running exactly as today — a broader
33
+ -- `if not _is_service_role()` wrapper would have WRONGLY skipped them, and would have turned
34
+ -- tests/agent_board_board_role.test.sql red), and any authenticated caller with an active agent.
35
+ -- Exactly one class changes: class (2) now fails closed.
36
+ --
37
+ -- GUARD PLACEMENT is deliberate: FIRST, before any table lookup. Raising at the original guard
38
+ -- site would leave `P0002 'board not found'` reachable ahead of the 42501, giving an
39
+ -- unauthorized principal a board/agent existence oracle. Only class (2) sees the reordering, and
40
+ -- that class currently has full write access — so nothing legitimate regresses.
41
+ --
42
+ -- MESSAGE reuses the canonical 'current agent principal not resolved' / 42501 of
43
+ -- _resolved_actor_agent_id (20260512000000:35-38) rather than minting a fifth phrasing.
44
+ --
45
+ -- SCOPE (audited at HEAD by last-definer; three panel seats independently confirmed no fifth
46
+ -- function carries this pattern): join_board (TWO sites), request_join_board, invite_board_member,
47
+ -- resolve_board_request. The issue named only the latter two. Bodies are reproduced from their
48
+ -- HEAD definers — invite/resolve from ROOMDELEG 20260725000100 (NOT the superseded 20260717000100
49
+ -- copies, which would silently revert the R-DELEG-1 creator-bypass fix), join/request from
50
+ -- 20260515000000. Signatures and RETURNS TABLE lists are reproduced VERBATIM: a changed arg list
51
+ -- would create an OVERLOAD instead of replacing, a changed return list hard-errors.
52
+ --
53
+ -- RELATED, NOT FIXED HERE: update_board_membership has NO authorization at all (a strictly
54
+ -- stronger primitive for the same attacker) — hardened in the sibling migration 20260730000200
55
+ -- (MEMBSTATE, #75). Caller-supplied actor override in resolve_recipients /
56
+ -- expand_recipients_dry_run is a different family — #76 (ACTORSPOOF).
57
+ --
58
+ -- Additive, forward-only. Body-only change with unchanged signatures, so NO PostgREST reload is
59
+ -- required. Writes no agent_board.schema_version (stays 8).
60
+
61
+ -- 1. join_board — TWO guarded sites (actor-mismatch, and the visibility/joinability gate). ------
62
+ create or replace function agent_board.join_board(
63
+ p_board_id uuid,
64
+ p_agent_id uuid,
65
+ p_membership_state text default 'joined',
66
+ p_created_by_agent_id uuid default null,
67
+ p_metadata jsonb default '{}'::jsonb
68
+ )
69
+ returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, created_at timestamptz, updated_at timestamptz)
70
+ language plpgsql
71
+ security definer
72
+ set search_path = agent_board, public
73
+ as $$
74
+ declare
75
+ v_board agent_board.boards%rowtype;
76
+ v_existing_state text;
77
+ v_membership agent_board.board_memberships%rowtype;
78
+ v_actor uuid := agent_board.current_agent_id();
79
+ begin
80
+ -- GATENULL (#58): only service_role may proceed with an unresolved actor.
81
+ if v_actor is null and not agent_board._is_service_role() then
82
+ raise exception 'current agent principal not resolved' using errcode = '42501';
83
+ end if;
84
+
85
+ if p_membership_state not in ('joined', 'subscribed') then
86
+ raise exception 'join_board only accepts joined or subscribed' using errcode = '22023';
87
+ end if;
88
+ if not exists (select 1 from agent_board.agents where id = p_agent_id) then
89
+ raise exception 'agent not found' using errcode = 'P0002';
90
+ end if;
91
+ if p_created_by_agent_id is not null and not exists (select 1 from agent_board.agents where id = p_created_by_agent_id) then
92
+ raise exception 'creator agent not found' using errcode = 'P0002';
93
+ end if;
94
+
95
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
96
+ if v_board.id is null then
97
+ raise exception 'board not found' using errcode = 'P0002';
98
+ end if;
99
+
100
+ select bm.membership_state
101
+ into v_existing_state
102
+ from agent_board.board_memberships bm
103
+ where bm.board_id = p_board_id
104
+ and bm.agent_id = p_agent_id
105
+ order by bm.created_at desc
106
+ limit 1;
107
+
108
+ if v_actor is not null and v_actor <> p_agent_id and v_actor <> v_board.created_by_agent_id then
109
+ raise exception 'board membership actor mismatch' using errcode = '42501';
110
+ end if;
111
+
112
+ -- NULLFIX (CR): v_existing_state is NULL when the caller has NO membership row, and the original
113
+ -- guard then evaluated to NULL — `not (false or false or NULL)` is NULL, not TRUE — so `if NULL
114
+ -- then` never fired and ANY resolvable actor self-joined ANY board, private ones included. That
115
+ -- (not the null-actor bypass) was the true root cause of the live "joined a private invite_only
116
+ -- board with no invite" finding, and it is reachable by an ACTIVE agent, which the GATENULL guard
117
+ -- above cannot touch. coalesce() makes the absent-row case FALSE so the raise fires.
118
+ -- A 'removed' agent is also excluded from self-joining even an OPEN board: re-entry after a kick
119
+ -- must go through invite/approval (the R-LINK-7 principle, 20260724000100:315-335), and
120
+ -- _upsert_board_membership_state does not reset `role`, so a re-joining removed owner/admin would
121
+ -- otherwise return at its former rank.
122
+ if v_actor is not null
123
+ and v_actor = p_agent_id
124
+ and v_board.created_by_agent_id <> v_actor
125
+ and not (
126
+ coalesce(v_existing_state, '') <> 'removed'
127
+ and (
128
+ v_board.visibility = 'open'
129
+ or (v_board.visibility = 'invite_only' and coalesce(v_existing_state, '') = 'invited')
130
+ or coalesce(v_existing_state, '') in ('joined', 'subscribed')
131
+ )
132
+ ) then
133
+ raise exception 'board is not joinable by this agent' using errcode = 'P0002';
134
+ end if;
135
+
136
+ v_membership := agent_board._upsert_board_membership_state(
137
+ p_board_id,
138
+ p_agent_id,
139
+ p_membership_state,
140
+ coalesce(p_created_by_agent_id, p_agent_id),
141
+ p_metadata
142
+ );
143
+
144
+ return query
145
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
146
+ end;
147
+ $$;
148
+
149
+ -- 2. request_join_board ------------------------------------------------------------------------
150
+ create or replace function agent_board.request_join_board(
151
+ p_board_id uuid,
152
+ p_agent_id uuid,
153
+ p_metadata jsonb default '{}'::jsonb
154
+ )
155
+ returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, created_at timestamptz, updated_at timestamptz)
156
+ language plpgsql
157
+ security definer
158
+ set search_path = agent_board, public
159
+ as $$
160
+ declare
161
+ v_board agent_board.boards%rowtype;
162
+ v_membership agent_board.board_memberships%rowtype;
163
+ v_actor uuid := agent_board.current_agent_id();
164
+ begin
165
+ -- GATENULL (#58): only service_role may proceed with an unresolved actor.
166
+ if v_actor is null and not agent_board._is_service_role() then
167
+ raise exception 'current agent principal not resolved' using errcode = '42501';
168
+ end if;
169
+
170
+ select * into v_board
171
+ from agent_board.boards
172
+ where id = p_board_id
173
+ and status = 'active'
174
+ and visibility in ('open', 'invite_only');
175
+
176
+ if v_board.id is null then
177
+ raise exception 'board not found' using errcode = 'P0002';
178
+ end if;
179
+ if v_actor is not null and v_actor <> p_agent_id then
180
+ raise exception 'board request actor mismatch' using errcode = '42501';
181
+ end if;
182
+ if not exists (select 1 from agent_board.agents where id = p_agent_id) then
183
+ raise exception 'agent not found' using errcode = 'P0002';
184
+ end if;
185
+
186
+ v_membership := agent_board._upsert_board_membership_state(
187
+ p_board_id,
188
+ p_agent_id,
189
+ 'requested',
190
+ p_agent_id,
191
+ p_metadata
192
+ );
193
+
194
+ return query
195
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
196
+ end;
197
+ $$;
198
+
199
+ -- 3. invite_board_member (body from ROOMDELEG 20260725000100 — R-DELEG-1 preserved) -------------
200
+ create or replace function agent_board.invite_board_member(
201
+ p_board_id uuid,
202
+ p_agent_id uuid,
203
+ p_created_by_agent_id uuid,
204
+ p_metadata jsonb default '{}'::jsonb
205
+ )
206
+ returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, created_at timestamptz, updated_at timestamptz)
207
+ language plpgsql
208
+ security definer
209
+ set search_path = agent_board, public
210
+ as $$
211
+ declare
212
+ v_board agent_board.boards%rowtype;
213
+ v_membership agent_board.board_memberships%rowtype;
214
+ v_actor uuid := agent_board.current_agent_id();
215
+ begin
216
+ -- GATENULL (#58): only service_role may proceed with an unresolved actor.
217
+ if v_actor is null and not agent_board._is_service_role() then
218
+ raise exception 'current agent principal not resolved' using errcode = '42501';
219
+ end if;
220
+
221
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
222
+ if v_board.id is null then
223
+ raise exception 'board not found' using errcode = 'P0002';
224
+ end if;
225
+ if v_actor is not null then
226
+ if v_board.role_gate_enforced then
227
+ -- Gate ON: NO creator exemption. Every actor (including a demoted creator)
228
+ -- must hold an active owner/admin membership.
229
+ if not exists (
230
+ select 1
231
+ from agent_board.board_memberships bm
232
+ where bm.board_id = p_board_id
233
+ and bm.agent_id = v_actor
234
+ and bm.membership_state in ('joined', 'subscribed')
235
+ and bm.role in ('owner', 'admin')
236
+ ) then
237
+ raise exception 'board invite requires owner or admin role' using errcode = '42501';
238
+ end if;
239
+ elsif v_actor <> v_board.created_by_agent_id then
240
+ -- Gate OFF: creator bypasses; any other actor needs an active membership.
241
+ if not exists (
242
+ select 1
243
+ from agent_board.board_memberships bm
244
+ where bm.board_id = p_board_id
245
+ and bm.agent_id = v_actor
246
+ and bm.membership_state in ('joined', 'subscribed')
247
+ ) then
248
+ raise exception 'board invite requires active membership' using errcode = '42501';
249
+ end if;
250
+ end if;
251
+ end if;
252
+ if not exists (select 1 from agent_board.agents where id = p_agent_id) then
253
+ raise exception 'agent not found' using errcode = 'P0002';
254
+ end if;
255
+ if not exists (select 1 from agent_board.agents where id = p_created_by_agent_id) then
256
+ raise exception 'creator agent not found' using errcode = 'P0002';
257
+ end if;
258
+
259
+ v_membership := agent_board._upsert_board_membership_state(
260
+ p_board_id,
261
+ p_agent_id,
262
+ 'invited',
263
+ p_created_by_agent_id,
264
+ p_metadata
265
+ );
266
+
267
+ return query
268
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
269
+ end;
270
+ $$;
271
+
272
+ -- 4. resolve_board_request (body from ROOMDELEG 20260725000100 — R-DELEG-1 preserved) -----------
273
+ create or replace function agent_board.resolve_board_request(
274
+ p_board_id uuid,
275
+ p_agent_id uuid,
276
+ p_membership_state text,
277
+ p_created_by_agent_id uuid,
278
+ p_metadata jsonb default '{}'::jsonb
279
+ )
280
+ returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamptz, updated_at timestamptz)
281
+ language plpgsql
282
+ security definer
283
+ set search_path = agent_board, public
284
+ as $$
285
+ declare
286
+ v_board agent_board.boards%rowtype;
287
+ v_existing_state text;
288
+ v_membership agent_board.board_memberships%rowtype;
289
+ v_actor uuid := agent_board.current_agent_id();
290
+ begin
291
+ -- GATENULL (#58): only service_role may proceed with an unresolved actor.
292
+ if v_actor is null and not agent_board._is_service_role() then
293
+ raise exception 'current agent principal not resolved' using errcode = '42501';
294
+ end if;
295
+
296
+ if p_membership_state not in ('joined', 'subscribed', 'removed') then
297
+ raise exception 'resolve_board_request only accepts joined, subscribed, or removed' using errcode = '22023';
298
+ end if;
299
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
300
+ if v_board.id is null then
301
+ raise exception 'board not found' using errcode = 'P0002';
302
+ end if;
303
+ if v_actor is not null then
304
+ if v_board.role_gate_enforced then
305
+ -- Gate ON: NO creator exemption. Every actor (including a demoted creator)
306
+ -- must hold an active owner/admin membership.
307
+ if not exists (
308
+ select 1
309
+ from agent_board.board_memberships bm
310
+ where bm.board_id = p_board_id
311
+ and bm.agent_id = v_actor
312
+ and bm.membership_state in ('joined', 'subscribed')
313
+ and bm.role in ('owner', 'admin')
314
+ ) then
315
+ raise exception 'board request resolution requires owner or admin role' using errcode = '42501';
316
+ end if;
317
+ elsif v_actor <> v_board.created_by_agent_id then
318
+ -- Gate OFF: creator bypasses; any other actor needs an active membership.
319
+ if not exists (
320
+ select 1
321
+ from agent_board.board_memberships bm
322
+ where bm.board_id = p_board_id
323
+ and bm.agent_id = v_actor
324
+ and bm.membership_state in ('joined', 'subscribed')
325
+ ) then
326
+ raise exception 'board request resolution requires active membership' using errcode = '42501';
327
+ end if;
328
+ end if;
329
+ end if;
330
+
331
+ select bm.membership_state
332
+ into v_existing_state
333
+ from agent_board.board_memberships bm
334
+ where bm.board_id = p_board_id
335
+ and bm.agent_id = p_agent_id
336
+ order by bm.created_at desc
337
+ limit 1;
338
+
339
+ -- NULLFIX (CR): same NULL-falsy hazard — with NO membership row v_existing_state is NULL and
340
+ -- `NULL not in (...)` is NULL, so the raise never fired and a caller could "resolve" an agent
341
+ -- that had never requested or been invited STRAIGHT to joined, bypassing the whole request path
342
+ -- (reachable by any active member on a gate-OFF board, incl. via `rooms approve --agent-id`).
343
+ if coalesce(v_existing_state, '') not in ('requested', 'invited') then
344
+ raise exception 'pending board membership not found' using errcode = 'P0002';
345
+ end if;
346
+
347
+ v_membership := agent_board._upsert_board_membership_state(
348
+ p_board_id,
349
+ p_agent_id,
350
+ p_membership_state,
351
+ p_created_by_agent_id,
352
+ p_metadata
353
+ );
354
+
355
+ return query
356
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
357
+ end;
358
+ $$;
359
+
360
+ -- Re-assert grants (create or replace preserves them, but be explicit so a fresh replay is
361
+ -- order-independent) — house convention, 20260725000100:229-232.
362
+ revoke all on function agent_board.join_board(uuid, uuid, text, uuid, jsonb) from public, anon;
363
+ revoke all on function agent_board.request_join_board(uuid, uuid, jsonb) from public, anon;
364
+ revoke all on function agent_board.invite_board_member(uuid, uuid, uuid, jsonb) from public, anon;
365
+ revoke all on function agent_board.resolve_board_request(uuid, uuid, text, uuid, jsonb) from public, anon;
366
+ grant execute on function agent_board.join_board(uuid, uuid, text, uuid, jsonb) to authenticated, service_role;
367
+ grant execute on function agent_board.request_join_board(uuid, uuid, jsonb) to authenticated, service_role;
368
+ grant execute on function agent_board.invite_board_member(uuid, uuid, uuid, jsonb) to authenticated, service_role;
369
+ grant execute on function agent_board.resolve_board_request(uuid, uuid, text, uuid, jsonb) to authenticated, service_role;
@@ -0,0 +1,195 @@
1
+ -- MEMBSTATE — give update_board_membership an authorization model. Tracks message-board#75.
2
+ --
3
+ -- THE DEFECT. agent_board.update_board_membership (HEAD: 20260515000000_agent_board_boardopt.sql
4
+ -- :203-238) is SECURITY DEFINER, granted to `authenticated` (:486), and performs NO authorization
5
+ -- of any kind — its only precondition is that a (board_id, agent_id) membership row EXISTS. It
6
+ -- then hands that row to _upsert_board_membership_state, which accepts any of
7
+ -- requested|invited|joined|subscribed|left|removed.
8
+ --
9
+ -- This is NOT the GATENULL (#58) null-actor pattern and is strictly worse: it needs no bypass at
10
+ -- all. An ordinary ACTIVE authenticated principal (current_agent_id() resolves, _is_service_role()
11
+ -- false) can rewrite ANY membership on ANY board. All four plan-panel seats independently flagged
12
+ -- it; both chains were confirmed against the live board in rolled-back transactions:
13
+ --
14
+ -- 1. EVICTION / TAKEOVER — update_board_membership(board, <owner>, 'removed') by an agent with
15
+ -- NO membership on that board succeeded; the owner's state became 'removed', stripping the
16
+ -- `joined` standing that the owner-loop RLS and every owner-gated RPC key on.
17
+ -- 2. SELF-APPROVAL INTO A PRIVATE BOARD — request_join_board(invite_only board, self) (a
18
+ -- legitimate call producing 'requested') followed by update_board_membership(board, self,
19
+ -- 'joined') succeeded, and the board feed then read successfully. This bypasses the entire
20
+ -- invite/approval path (invite_board_member / resolve_board_request / invite links) that the
21
+ -- authority model exists to enforce, and defeats the ROOMDELEG role gate for the join path.
22
+ --
23
+ -- It also re-opens, on a direct path, the kicked-agent re-entry hazard that _redeem_invite_for_agent
24
+ -- deliberately defends on the invite path (R-LINK-7, 20260724000100:315-335).
25
+ --
26
+ -- THE MODEL. A purpose-built gate (the panel explicitly rejected reusing GATENULL's transform here
27
+ -- — that only distinguishes the null actor and would leave every ACTIVE-caller chain above open):
28
+ --
29
+ -- * service_role / operator -> unchanged full power (create/modify-on-behalf-of).
30
+ -- * authenticated, unresolved actor -> 42501, same posture and message as GATENULL (#58).
31
+ -- * authenticated acting on ITS OWN row-> only SELF-SERVICE transitions:
32
+ -- joined <-> subscribed, and joined|subscribed|invited|requested -> left. NOT from
33
+ -- 'removed': erasing a 'removed' marker would launder a kick into invite-link re-entry
34
+ -- (R-LINK-7, 20260724000100:323-335), since memberships are updated IN PLACE.
35
+ -- Notably NOT requested|invited|left|removed -> joined|subscribed: promotion into a board is
36
+ -- the approval path's job (join_board / resolve_board_request / redeem_invite_link), which
37
+ -- enforces visibility, invite state, gates and ceilings. This is what closes chain 2 and the
38
+ -- kicked-agent re-entry.
39
+ -- * authenticated acting on ANOTHER agent's row -> requires an ACTIVE owner/admin membership on
40
+ -- that board AND strict rank superiority over the target (owner=4 > admin=3 > member=2 >
41
+ -- observer=1). There is deliberately NO creator exemption here (unlike invite_board_member /
42
+ -- resolve_board_request): this RPC is the schema's ONLY eviction path — no sibling can move a
43
+ -- JOINED member to removed — so a demoted or removed original creator must not retain eviction
44
+ -- power, and an admin must not be able to evict the owner. This closes chain 1.
45
+ --
46
+ -- ORDERING / DISCLOSURE. The unresolved-actor 42501 is raised FIRST, and for a caller acting on
47
+ -- ANOTHER agent's row the board + owner/admin authority check runs BEFORE the membership lookup —
48
+ -- so an unauthorized caller cannot distinguish P0002 'board membership not found' from 42501 and
49
+ -- cannot probe whether an arbitrary (board_id, agent_id) pair has a membership row. The self
50
+ -- branch takes NO board lookup at all, so leave_board keeps working on an ARCHIVED board
51
+ -- (20260512000002:154-158 delegates here); P0002 'board membership not found' is otherwise
52
+ -- preserved for every caller that reaches it.
53
+ --
54
+ -- COMPATIBILITY. No signature or RETURNS TABLE change (a changed arg list would create an OVERLOAD
55
+ -- rather than replace; a changed return list hard-errors), so no client/mcp/cli surface change and
56
+ -- no PostgREST reload. `leave_board` (20260512000002) remains the ergonomic self-`left` path and is
57
+ -- untouched; the self-service transitions above deliberately keep update_board_membership able to
58
+ -- express it too, so no existing legitimate caller loses a capability.
59
+ --
60
+ -- Additive, forward-only. Writes no agent_board.schema_version (stays 8).
61
+
62
+ create or replace function agent_board.update_board_membership(
63
+ p_board_id uuid,
64
+ p_agent_id uuid,
65
+ p_membership_state text,
66
+ p_metadata jsonb default '{}'::jsonb
67
+ )
68
+ returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamptz, updated_at timestamptz)
69
+ language plpgsql
70
+ security definer
71
+ set search_path = agent_board, public
72
+ as $$
73
+ declare
74
+ v_membership agent_board.board_memberships%rowtype;
75
+ v_board agent_board.boards%rowtype;
76
+ v_actor uuid := agent_board.current_agent_id();
77
+ v_is_service boolean := agent_board._is_service_role();
78
+ v_existing_state text;
79
+ v_target_role text;
80
+ v_actor_role text;
81
+ begin
82
+ -- (1) Unresolved principal fails closed (GATENULL #58 posture). Raised before any lookup so this
83
+ -- cannot be used as a membership-existence oracle.
84
+ if v_actor is null and not v_is_service then
85
+ raise exception 'current agent principal not resolved' using errcode = '42501';
86
+ end if;
87
+
88
+ -- (2) AUTHORITY BEFORE DISCLOSURE. For a caller acting on ANOTHER agent's row the owner/admin
89
+ -- check runs BEFORE the membership lookup, so an unauthorized caller cannot distinguish
90
+ -- P0002 'board membership not found' from 42501 and thus cannot probe whether an arbitrary
91
+ -- (board_id, agent_id) pair has a membership row. (The self branch legitimately learns about its
92
+ -- own membership, and the service path is the operator.)
93
+ if not v_is_service and v_actor <> p_agent_id then
94
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
95
+ if v_board.id is null then
96
+ raise exception 'board not found' using errcode = 'P0002';
97
+ end if;
98
+ select bm.role
99
+ into v_actor_role
100
+ from agent_board.board_memberships bm
101
+ where bm.board_id = p_board_id
102
+ and bm.agent_id = v_actor
103
+ and bm.membership_state in ('joined', 'subscribed')
104
+ limit 1;
105
+ if v_actor_role is null or v_actor_role not in ('owner', 'admin') then
106
+ raise exception 'board membership change requires owner or admin role'
107
+ using errcode = '42501';
108
+ end if;
109
+ end if;
110
+
111
+ -- (3) Pre-existing precondition, preserved. Also captures the target's CURRENT role for the
112
+ -- rank check below.
113
+ select bm.membership_state, bm.role
114
+ into v_existing_state, v_target_role
115
+ from agent_board.board_memberships bm
116
+ where bm.board_id = p_board_id
117
+ and bm.agent_id = p_agent_id
118
+ order by bm.created_at desc
119
+ limit 1;
120
+ if v_existing_state is null then
121
+ raise exception 'board membership not found' using errcode = 'P0002';
122
+ end if;
123
+
124
+ -- (4) MEMBSTATE authorization. Skipped ONLY for a genuine service_role/operator caller; every
125
+ -- authenticated caller (whose actor is guaranteed non-null by step 1) is gated.
126
+ if not v_is_service then
127
+ if v_actor = p_agent_id then
128
+ -- SELF-SERVICE. Deliberately NO board lookup on this branch: the board row is only needed
129
+ -- for the authority checks below, and requiring an ACTIVE board here would strand an agent
130
+ -- holding a live membership on an ARCHIVED board with no way to leave it (leave_board
131
+ -- delegates straight into this RPC, 20260512000002:154-158).
132
+ --
133
+ -- Leaving is permitted only FROM a live/pending state, and joined<->subscribed may be
134
+ -- toggled. Promotion INTO the board is NOT permitted — that belongs to the approval path.
135
+ -- The 'removed' exclusion is load-bearing: allowing removed -> left would let a kicked agent
136
+ -- erase the 'removed' marker (memberships are updated IN PLACE, so the row IS the record),
137
+ -- which is exactly the marker _redeem_invite_for_agent's R-LINK-7 defense reads
138
+ -- (20260724000100:323-335) — i.e. it would launder a kick into invite-link re-entry.
139
+ if not (
140
+ (p_membership_state = 'left'
141
+ and v_existing_state in ('joined', 'subscribed', 'invited', 'requested'))
142
+ or (p_membership_state in ('joined', 'subscribed')
143
+ and v_existing_state in ('joined', 'subscribed'))
144
+ ) then
145
+ raise exception 'self membership change not permitted; use the join/approval path'
146
+ using errcode = '42501';
147
+ end if;
148
+ else
149
+ -- ACTING ON ANOTHER AGENT'S MEMBERSHIP. The board-active check and the ACTIVE owner/admin
150
+ -- requirement already ran in step (2) (authority before disclosure). This RPC is the
151
+ -- schema's only eviction path — no sibling can move a JOINED member to removed
152
+ -- (resolve_board_request only resolves requested/invited, 20260725000100:212) — so it is
153
+ -- gated more strictly than its siblings, with NO creator exemption: a bare
154
+ -- created_by_agent_id is deliberately NOT sufficient. ROOMOWN seeds the creator's owner
155
+ -- membership at create_board time (20260723000100), so a legitimate creator holds one, while
156
+ -- a DEMOTED or REMOVED creator must not retain eviction power (the R-DELEG-1 lesson).
157
+ --
158
+ -- STRICT RANK SUPERIORITY (owner=4 > admin=3 > member=2 > observer=1). Without it an admin
159
+ -- could evict the OWNER, or admins could evict each other.
160
+ if agent_board._role_rank(v_actor_role)
161
+ <= agent_board._role_rank(coalesce(v_target_role, '')) then
162
+ raise exception 'board membership change requires a higher board role than the target'
163
+ using errcode = '42501';
164
+ end if;
165
+ end if;
166
+ end if;
167
+
168
+ select * into v_membership
169
+ from agent_board._upsert_board_membership_state(
170
+ p_board_id,
171
+ p_agent_id,
172
+ p_membership_state,
173
+ null,
174
+ p_metadata
175
+ );
176
+
177
+ return query
178
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
179
+ end;
180
+ $$;
181
+
182
+ comment on function agent_board.update_board_membership(uuid, uuid, text, jsonb) is
183
+ 'Updates a board membership state. MEMBSTATE (#75): service_role retains full '
184
+ 'modify-on-behalf-of; an authenticated caller with an unresolved actor fails closed (42501); a '
185
+ 'caller acting on its OWN membership may only leave or toggle joined<->subscribed (promotion '
186
+ 'into a board belongs to the join/approval path, so a requested/invited/left/removed principal '
187
+ 'cannot self-approve or re-enter); a caller acting on ANOTHER agent''s membership must hold an '
188
+ 'active owner/admin membership AND strictly higher board rank than the target (no creator '
189
+ 'exemption: this is the only eviction path, so a demoted creator keeps no power and an admin '
190
+ 'cannot evict the owner).';
191
+
192
+ -- Re-assert grants (create or replace preserves them, but be explicit so a fresh replay is
193
+ -- order-independent) — house convention, 20260725000100:229-232.
194
+ revoke all on function agent_board.update_board_membership(uuid, uuid, text, jsonb) from public, anon;
195
+ grant execute on function agent_board.update_board_membership(uuid, uuid, text, jsonb) to authenticated, service_role;
@@ -0,0 +1,88 @@
1
+ -- AGENTSDIR — stop exposing identity-linkage columns of agent_board.agents to `authenticated`.
2
+ -- Tracks message-board#80.
3
+ --
4
+ -- THE DEFECT. The SELECT policy on agent_board.agents is
5
+ -- agent_board_authenticated_select_agents USING (agent_board.current_agent_id() is not null)
6
+ -- (20260512000003_agent_board_auth_rls.sql:48-54) — effectively TRUE for any principal that
7
+ -- resolves to an active agent. Combined with a TABLE-LEVEL `grant select` to `authenticated` and
8
+ -- PostgREST exposing the agent_board schema, ANY authenticated JWT reads EVERY agent row and
9
+ -- EVERY column. Measured live as role `authenticated` with RLS enforced: 6 of 6 agents visible,
10
+ -- 5 exposing auth_user_id.
11
+ --
12
+ -- Post-FEDSESSION every Portal-federated human has an agents row, so this is cross-tenant: one
13
+ -- signed-in human enumerates every other human and agent on the deployment.
14
+ --
15
+ -- WHY COLUMN SCOPE, NOT ROW SCOPE. The 4-seat plan panel unanimously rejected row-scoping, and
16
+ -- each objection was reproduced live before this migration was written:
17
+ -- * `agents` IS the product's participant directory by contract — specs/schema.md:179 ("the
18
+ -- canonical participant directory for both humans and automation"), and specs/read-models.md
19
+ -- designates v_agent_dashboard the sanctioned substitute precisely so clients need not read
20
+ -- this table. Handle/display_name are the feature, not the leak.
21
+ -- * A co-member row predicate is DEAD CODE: board_memberships' own SELECT policy is self-scoped
22
+ -- (20260515000000:465-472) and RLS applies inside a policy's USING expression, so a join to
23
+ -- another agent's membership row can never match. Verified: a board owner was invisible to
24
+ -- their own co-member.
25
+ -- * ~18 views INNER JOIN agents, so an invisible row DELETES the row rather than hiding a name.
26
+ -- Verified: after an author left a board, v_board_feed went 1 -> 0 — historical messages
27
+ -- vanished for everyone still on the board. Same class would drop stranger-created `open`
28
+ -- boards from discovery (v_board_directory) and escalations from the supervision feed
29
+ -- (v_open_escalations).
30
+ -- * Row scope kills invite-by-handle: resolve_agent_handle is SECURITY INVOKER and bounded by
31
+ -- this very policy (20260727000100:119), so you could only invite people you ALREADY share a
32
+ -- board with — which is what an invite creates.
33
+ -- * Board co-membership is not a trust boundary anyway: a creator can force-join any agent
34
+ -- without consent (message-board#82, verified live), so it is manufacturable in two calls.
35
+ -- What actually leaks is IDENTITY LINKAGE, which is a column property:
36
+ -- auth_user_id / owner_user_id — raw auth.users UUIDs, cross-linkable to Portal identity
37
+ -- parent_agent_id / root_agent_id — the delegation lineage graph
38
+ -- metadata — the field LOCALAUTH deliberately keeps plaintext email OUT of precisely because
39
+ -- this table is broadly readable (20260726000100:15-22)
40
+ -- No view granted to `authenticated` projects ANY of those columns, and no client/CLI/MCP package
41
+ -- reads this table directly (they go through views and RPCs) — so scoping columns removes the
42
+ -- exposure at ~zero product risk.
43
+ --
44
+ -- MECHANISM NOTE (this is why the migration is shaped this way). A column-level
45
+ -- `revoke select (col, ...)` is a NO-OP while a table-level `grant select` exists — the
46
+ -- table-level grant covers every column, including ones added later. Verified live: the
47
+ -- "revoke columns" form left auth_user_id readable. The table-level grant must be revoked FIRST
48
+ -- and only the permitted columns re-granted.
49
+ --
50
+ -- MAINTENANCE OBLIGATION: because the grant is now an explicit column list, a future
51
+ -- `alter table agent_board.agents add column ...` is NOT readable by `authenticated` until it is
52
+ -- added here. That is the correct fail-closed default; a new column must be a deliberate decision.
53
+ --
54
+ -- NOT CHANGED: the row policy itself (unchanged — see above), the service_role ALL policy
55
+ -- (`using (true)`), and every SECURITY DEFINER RPC (they run as the table owner, RLS- and
56
+ -- grant-exempt, so list_board_pending_memberships et al. keep returning handles/display names).
57
+ -- `anon` holds no grants on this table and none are added.
58
+ --
59
+ -- BREAKING (fail-loud, intended): `select *` on agent_board.agents as `authenticated` now raises
60
+ -- 42501 rather than returning narrowed rows. No in-repo consumer does this; a non-SDK consumer
61
+ -- (e.g. the Portal) doing `select=*` will break visibly rather than silently leaking.
62
+ --
63
+ -- Additive, forward-only. Grants/RLS only — no function, table or column changes. Writes no
64
+ -- agent_board.schema_version (stays 8). No PostgREST reload required (no signature change), but a
65
+ -- reload is harmless.
66
+
67
+ revoke select on agent_board.agents from authenticated;
68
+
69
+ grant select (
70
+ id,
71
+ handle,
72
+ display_name,
73
+ kind,
74
+ agent_type,
75
+ status,
76
+ created_at,
77
+ updated_at
78
+ ) on agent_board.agents to authenticated;
79
+
80
+ comment on table agent_board.agents is
81
+ 'Canonical participant directory for humans and automation. READ POSTURE (AGENTSDIR, #80): '
82
+ '`authenticated` may select ONLY the directory columns (id, handle, display_name, kind, '
83
+ 'agent_type, status, created_at, updated_at). The identity-linkage columns — auth_user_id, '
84
+ 'owner_user_id, parent_agent_id, root_agent_id — and metadata are NOT granted to '
85
+ '`authenticated`, because raw auth.users UUIDs and the delegation lineage are cross-tenant '
86
+ 'identity linkage and metadata is an acknowledged PII-hazard field. service_role and '
87
+ 'SECURITY DEFINER RPCs are unaffected. A NEWLY ADDED COLUMN IS NOT READABLE by `authenticated` '
88
+ 'until explicitly granted here — fail closed by design.';