@consiliency/agent-board-schema 1.12.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 (104) hide show
  1. package/fixtures/already-applied.json +124 -0
  2. package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
  3. package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
  4. package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
  5. package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
  6. package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
  7. package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
  8. package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
  9. package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
  10. package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
  11. package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
  12. package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
  13. package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
  14. package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
  15. package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
  16. package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
  17. package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
  18. package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
  19. package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
  20. package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
  21. package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
  22. package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
  23. package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
  24. package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
  25. package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
  26. package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
  27. package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
  28. package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
  29. package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
  30. package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
  31. package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
  32. package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
  33. package/package.json +1 -1
  34. package/schema-manifest.json +282 -3
  35. package/src/tooling.test.mjs +113 -2
  36. package/tests/agent_board_actorspoof.test.sql +349 -0
  37. package/tests/agent_board_agentsdir.test.sql +154 -0
  38. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  39. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  40. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  41. package/tests/agent_board_claimkey.test.sql +229 -0
  42. package/tests/agent_board_consent_backfill.test.sql +48 -0
  43. package/tests/agent_board_consent_emission.test.sql +601 -0
  44. package/tests/agent_board_consent_gating.test.sql +489 -0
  45. package/tests/agent_board_consent_substrate.test.sql +720 -0
  46. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  47. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  48. package/tests/agent_board_delegation_policies.test.sql +107 -1
  49. package/tests/agent_board_fedsession.test.sql +6 -4
  50. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  51. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  52. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  53. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  54. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  55. package/tests/agent_board_fleet_policy.test.sql +2 -2
  56. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  57. package/tests/agent_board_identity_providers.test.sql +9 -0
  58. package/tests/agent_board_inbox_reads.test.sql +29 -12
  59. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  60. package/tests/agent_board_invitepolicy.test.sql +205 -0
  61. package/tests/agent_board_leaveidem.test.sql +194 -0
  62. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  63. package/tests/agent_board_mint_harden.test.sql +303 -0
  64. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  65. package/tests/agent_board_obsgate.test.sql +178 -0
  66. package/tests/agent_board_obsgate2.test.sql +153 -0
  67. package/tests/agent_board_obsgate3.test.sql +170 -0
  68. package/tests/agent_board_obsgate4.test.sql +196 -0
  69. package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
  70. package/tests/agent_board_pubexec_class.test.sql +53 -0
  71. package/tests/agent_board_rejectsem.test.sql +271 -0
  72. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  73. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  74. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  75. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  76. package/tests/agent_board_rolereset.test.sql +351 -0
  77. package/tests/agent_board_roomdeleg.test.sql +5 -3
  78. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  79. package/tests/agent_board_roomlink.test.sql +25 -9
  80. package/tests/agent_board_rowid.test.sql +1274 -0
  81. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  82. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  83. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  84. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  85. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  86. package/tests/agent_board_signing_store.test.sql +6 -6
  87. package/tests/fixtures/isolation-substrate.sql +93 -0
  88. package/tests/fixtures/test-prereqs.sql +33 -0
  89. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  90. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  91. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  92. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  93. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  94. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  95. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  96. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  97. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  98. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  99. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  100. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  101. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  102. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  103. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  104. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
@@ -0,0 +1,1201 @@
1
+ -- MBROWID — deterministic board-membership row identity (total order + R-LINK-7 bootstrap).
2
+ -- Closes the remaining third of message-board#79 (bootstrap resurrection of a removed
3
+ -- creator: "2 rows, 1 active") and freezes every latest-row membership reader on a total
4
+ -- order so equal-created_at duplicates cannot be read differently by different defenses.
5
+ --
6
+ -- THEME: board_memberships has no single authoritative row per (board_id, agent_id). The
7
+ -- active-membership unique index is PARTIAL —
8
+ -- idx_agent_board_board_memberships_active_unique on (board_id, agent_id) WHERE
9
+ -- membership_state in ('joined','subscribed') (20260505000000:34-36) — so a raw INSERT
10
+ -- onto a NON-ACTIVE row creates a SECOND row instead of conflicting. Latest-row readers
11
+ -- previously used `order by created_at desc limit 1`; created_at defaults to
12
+ -- transaction_timestamp() and is equal for same-txn writes, so the tie broke
13
+ -- non-deterministically. Two defenses reading the same pair could disagree.
14
+ --
15
+ -- WHAT THIS MIGRATION DOES
16
+ -- 1. Total-order every live latest-row reader: `order by created_at desc, id desc`
17
+ -- (per-agent reads: `agent_id, created_at desc, id desc`). Catalog-derived inventory
18
+ -- (8 functions / 10 sites + v_board_directory):
19
+ -- _active_board_membership_id (20260506000000 last-definer)
20
+ -- _upsert_board_membership_state (20260802000100 last-definer) — 1 site
21
+ -- join_board (20260730000100 last-definer)
22
+ -- resolve_board_request (20260730000100 last-definer)
23
+ -- update_board_membership (20260801000100 last-definer) — 2 sites
24
+ -- list_board_pending_memberships (20260727000100 last-definer) — 2 sites
25
+ -- create_tasking_delivery (20260718000200 last-definer; tasks idempotency
26
+ -- site counted by COUNT-EQUALITY structural proof)
27
+ -- post_board_message (20260718000200 last-definer; messages idempotency
28
+ -- site counted by COUNT-EQUALITY structural proof)
29
+ -- v_board_directory (DROP+CREATE last definer 20260515000000:397-444)
30
+ -- Bodies are copied forward from the LIVE catalog (pg_get_functiondef /
31
+ -- pg_get_viewdef), not from older migration files. create or replace preserves
32
+ -- grants; the view is drop + create so SELECT is re-granted.
33
+ --
34
+ -- 2. bootstrap_board_owner_memberships R-LINK-7 guard (20260723000100 last-definer):
35
+ -- re-seed only when there is NO active row AND NO removed row. NEVER suppress on
36
+ -- 'left' alone — that is the sole-owner-lockout recovery trigger pinned by assertion
37
+ -- 23 of tests/agent_board_rolereset.test.sql. requested-only and invited-only
38
+ -- creators ARE still re-seeded; a removed+left pair is suppressed.
39
+ --
40
+ -- 3a. Duplicate-aware ACTIVE-row selection at EVERY acting read site (not just the
41
+ -- mutator). A pure `order by created_at desc, id desc` can select a higher-id
42
+ -- REMOVED (or REQUESTED) row while an ACTIVE row still exists. Every reader that
43
+ -- then ACTS on the resulting state must prefer active first, then total-order:
44
+ -- order by case when membership_state in ('joined','subscribed') then 0 else 1 end,
45
+ -- created_at desc, id desc
46
+ -- Sites that now prefer active (when one exists), then total-order:
47
+ -- _upsert_board_membership_state — FOR UPDATE pre-read (avoids 23505)
48
+ -- join_board — joinability / idempotent-join state read
49
+ -- update_board_membership — both sites (authz pre-read + LEAVEIDEM return)
50
+ -- list_board_pending_memberships — distinct-on-per-agent selection
51
+ -- v_board_directory — current_membership lateral
52
+ -- Currently-removed standing (mutator + join_board self-join guard) uses the
53
+ -- R-LINK-7 SET predicate (exists removed AND not exists active), not a single row.
54
+ --
55
+ -- 3b. join_board joinability agrees with mutator + grant_board_role on removed+left
56
+ -- histories: the self-join guard uses the R-LINK-7 set predicate for "removed
57
+ -- standing", not just the total-ordered latest row's membership_state. Otherwise a
58
+ -- higher-UUID left row would admit open-board self-join while standing is removed.
59
+ -- Combined with 3a, a low-id ACTIVE + high-id REMOVED pair is treated as active
60
+ -- for idempotent join on private / invite_only boards (the active member is not
61
+ -- locked out of their own board).
62
+ --
63
+ -- 3c. Bare total-order retained (deliberately) where the reader is NOT acting on
64
+ -- "current standing" but on newest row regardless of state, or where active is
65
+ -- already filtered by the WHERE clause:
66
+ -- resolve_board_request — pending-only gate: must see the newest
67
+ -- requested/invited marker, not prefer a
68
+ -- coexisting active (approval path is for
69
+ -- non-active pending rows; active standing
70
+ -- is already joinable via join_board)
71
+ -- _active_board_membership_id — WHERE already restricts to joined|subscribed
72
+ -- create_tasking_delivery / post_board_message — task/message idempotency keys,
73
+ -- not membership standing
74
+ --
75
+ -- FOUR RAW INSERT WRITERS — per-writer ruling (IF-0-MBROLERESET-1(c) / EC-MBROWID-4):
76
+ -- 1. create_board owner seed (20260723000100) — KEEP RAW.
77
+ -- Runs only on a genuine board insert (if v_did_insert); no prior membership can
78
+ -- exist. on conflict (active) do update covers the slug-collision active case.
79
+ -- 2. bootstrap_board_owner_memberships (this migration) — KEEP RAW; FIX THE GUARD
80
+ -- to the R-LINK-7 shape above. The only raw writer that manufactured the harmful
81
+ -- "2 rows, 1 active" resurrection.
82
+ -- 3. _redeem_invite_for_agent (20260724000100) — KEEP RAW.
83
+ -- R-LINK-7 guard already refuses removed; on conflict (active) rank-max; left gets
84
+ -- the normal left+joined pair, never a second active row.
85
+ -- 4. _fedsession_autojoin_default_board (20260729000100) — KEEP RAW (benign).
86
+ -- not exists (any row) guard + exception when unique_violation then null.
87
+ -- None of the four route through _upsert_board_membership_state: the mutator clamps
88
+ -- role to 'member' on non-active→active re-entry (ROLERESET), which would erase
89
+ -- writer 1's owner seed and writer 3's admin preset.
90
+ --
91
+ -- WHY A TRIGGER IS FORBIDDEN: a BEFORE INSERT/UPDATE trigger on board_memberships
92
+ -- would also fire for the intentional non-default seeders (create_board owner,
93
+ -- bootstrap owner, invite preset_role, fedsession member) and erase their intentional
94
+ -- roles. The clamp and total-order live in the mutator / readers only.
95
+ --
96
+ -- Non-goals: no drop/widen of the partial unique index; no delete/merge/renumber of
97
+ -- existing rows (delivery_recipients, callback_subscriptions, board_invite_redemptions
98
+ -- pin membership row identity via FK); schema_version stays 8.
99
+ --
100
+ -- Additive, forward-only. Last-definer rule: only this file is live for the recreated
101
+ -- objects after apply.
102
+
103
+ -- 1. _active_board_membership_id ---------------------------------------------------------
104
+ CREATE OR REPLACE FUNCTION agent_board._active_board_membership_id(p_board_id uuid, p_agent_id uuid)
105
+ RETURNS uuid
106
+ LANGUAGE sql
107
+ STABLE
108
+ AS $$
109
+ select bm.id
110
+ from agent_board.board_memberships bm
111
+ where bm.board_id = p_board_id
112
+ and bm.agent_id = p_agent_id
113
+ and bm.membership_state in ('joined', 'subscribed')
114
+ order by bm.created_at desc, bm.id desc
115
+ limit 1
116
+ $$;
117
+ -- 2. _upsert_board_membership_state (total order + active-prefer + R-LINK-7 set) ---------
118
+ CREATE OR REPLACE FUNCTION agent_board._upsert_board_membership_state(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_created_by_agent_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
119
+ RETURNS agent_board.board_memberships
120
+ LANGUAGE plpgsql
121
+ SECURITY DEFINER
122
+ SET search_path TO 'agent_board', 'public'
123
+ AS $$
124
+ declare
125
+ v_membership agent_board.board_memberships%rowtype;
126
+ v_metadata jsonb;
127
+ -- EC-MBROWID-3: currently-removed standing (R-LINK-7 set form), not a single row.
128
+ v_currently_removed boolean;
129
+ begin
130
+ if p_membership_state not in ('requested', 'invited', 'joined', 'subscribed', 'left', 'removed') then
131
+ raise exception 'invalid board membership state' using errcode = '22023';
132
+ end if;
133
+
134
+ -- Metadata must be a JSON OBJECT. Previously a scalar/array was stored verbatim on insert and
135
+ -- concatenated on update; the reserved-key strip below iterates keys, so a non-object would raise
136
+ -- an opaque internal error. Fail explicitly instead. Reachable from the CLI `--metadata`, which
137
+ -- parses arbitrary JSON — this is a deliberate, declared behavior change for ALL mutator callers.
138
+ if jsonb_typeof(coalesce(p_metadata, '{}'::jsonb)) <> 'object' then
139
+ raise exception 'board membership metadata must be a JSON object' using errcode = '22023';
140
+ end if;
141
+
142
+ -- Reserved namespace: `ops_*` keys are operational state (the retention sweep's skip marker
143
+ -- `ops_retained_at`, read for board_memberships at 20260513000001:122) and must never be writable
144
+ -- by a caller whose own row they govern. Strip them from caller input; operational writers set
145
+ -- the column directly.
146
+ select coalesce(jsonb_object_agg(e.key, e.value), '{}'::jsonb)
147
+ into v_metadata
148
+ from jsonb_each(coalesce(p_metadata, '{}'::jsonb)) as e(key, value)
149
+ where e.key not like 'ops\_%';
150
+
151
+ -- EC-MBROWID-3 / R-LINK-7: currently-removed standing is the SET predicate
152
+ -- (exists removed AND no active), not a single total-ordered row. A higher-id
153
+ -- removed row can coexist with an active row; inferring "removed" from the
154
+ -- total-order winner alone would then try to activate the removed row and
155
+ -- collide with idx_agent_board_board_memberships_active_unique (23505).
156
+ -- Capture standing before the row lock so the predicate is independent of
157
+ -- which physical row the active-preferring SELECT below returns.
158
+ v_currently_removed :=
159
+ exists (
160
+ select 1 from agent_board.board_memberships bm
161
+ where bm.board_id = p_board_id
162
+ and bm.agent_id = p_agent_id
163
+ and bm.membership_state = 'removed'
164
+ ) and not exists (
165
+ select 1 from agent_board.board_memberships bm
166
+ where bm.board_id = p_board_id
167
+ and bm.agent_id = p_agent_id
168
+ and bm.membership_state in ('joined', 'subscribed')
169
+ );
170
+
171
+ -- Duplicate-aware ACTIVE-row selection (3a): prefer an active row when one
172
+ -- exists, then total-order by created_at desc, id desc. A pure total-order
173
+ -- can select a higher-id REMOVED row while an ACTIVE row still exists.
174
+ -- When v_currently_removed, no active row exists and the total-order pick is
175
+ -- non-active (re-entry clamp applies below).
176
+ select *
177
+ into v_membership
178
+ from agent_board.board_memberships bm
179
+ where bm.board_id = p_board_id
180
+ and bm.agent_id = p_agent_id
181
+ order by
182
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
183
+ bm.created_at desc,
184
+ bm.id desc
185
+ limit 1
186
+ for update;
187
+
188
+ if v_membership.id is null then
189
+ -- INSERT branch intentionally omits `role` so a no-prior-row entry takes the table default
190
+ -- (20260717000100:13-15). Do not write role here — an explicit write is wrong.
191
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, left_at, metadata)
192
+ values (
193
+ p_board_id,
194
+ p_agent_id,
195
+ p_membership_state,
196
+ p_created_by_agent_id,
197
+ case when p_membership_state in ('left', 'removed') then now() else null end,
198
+ v_metadata
199
+ )
200
+ returning * into v_membership;
201
+ else
202
+ update agent_board.board_memberships bm
203
+ set membership_state = p_membership_state,
204
+ created_by_agent_id = coalesce(p_created_by_agent_id, bm.created_by_agent_id),
205
+ -- PRESERVE the first departure time. Restamping it would let the departing agent (or a
206
+ -- re-kicking owner) push its own row past the retention cutoff (20260513000001:121).
207
+ left_at = case
208
+ when p_membership_state in ('left', 'removed') then
209
+ case
210
+ -- SAME terminal state re-asserted: preserve the FIRST departure time so a
211
+ -- repeat call cannot push the retention cutoff forward.
212
+ when v_membership.membership_state = p_membership_state
213
+ then coalesce(v_membership.left_at, now())
214
+ -- GENUINE transition (notably left -> removed, an owner barring
215
+ -- invite-link re-entry for an already-departed agent) stamps NOW. R-LINK-7
216
+ -- deliberately does not block voluntary 'left' (20260724000100:321-322),
217
+ -- so this is the real kick moment; inheriting the earlier departure would
218
+ -- both lose the audit answer and make the marker row retention-eligible
219
+ -- prematurely.
220
+ else now()
221
+ end
222
+ else null
223
+ end,
224
+ -- ROLERESET (#79): clamp role to the table default on non-active -> active re-entry.
225
+ -- Hardcoded 'member' (column default at 20260717000100:13-15); do not read
226
+ -- information_schema at runtime. Active -> active preserves bm.role (including owner).
227
+ -- Unconditional — no service_role exemption.
228
+ -- MBROWID: origin is non-active when the selected row is non-active OR when
229
+ -- currently-removed standing holds (R-LINK-7 set form). With active-preferring
230
+ -- selection those agree; the set form is load-bearing against equal-created_at
231
+ -- removed+left duplicates where a single-row read would flip with id order.
232
+ role = case
233
+ when p_membership_state in ('joined', 'subscribed')
234
+ and (
235
+ v_membership.membership_state not in ('joined', 'subscribed')
236
+ or v_currently_removed
237
+ )
238
+ then 'member'
239
+ else bm.role
240
+ end,
241
+ metadata = coalesce(bm.metadata, '{}'::jsonb) || v_metadata,
242
+ updated_at = now()
243
+ where bm.id = v_membership.id
244
+ returning * into v_membership;
245
+ end if;
246
+
247
+ return v_membership;
248
+ end;
249
+ $$;
250
+ -- 3. bootstrap_board_owner_memberships (R-LINK-7 guard) ---------------------------------
251
+ CREATE OR REPLACE FUNCTION agent_board.bootstrap_board_owner_memberships()
252
+ RETURNS TABLE(board_id uuid, owner_agent_id uuid, membership_id uuid)
253
+ LANGUAGE plpgsql
254
+ SECURITY DEFINER
255
+ SET search_path TO 'agent_board', 'public'
256
+ AS $$
257
+ #variable_conflict use_column
258
+ declare
259
+ v_row record;
260
+ v_membership_id uuid;
261
+ begin
262
+ -- service_role-only: current_agent_id() is null for service_role, non-null for any
263
+ -- authenticated principal (guarded here in addition to the EXECUTE revoke below).
264
+ if not agent_board._is_service_role() then
265
+ raise exception 'bootstrap_board_owner_memberships is service_role-only' using errcode = '42501';
266
+ end if;
267
+
268
+ -- R-LINK-7 guard shape (structural proof requires no-active AND no-removed
269
+ -- contiguously): re-seed only when the creator has no active membership AND is
270
+ -- not currently removed. NEVER suppress on 'left' alone — that is the
271
+ -- sole-owner-lockout recovery trigger (agent_board_rolereset.test.sql assertion
272
+ -- 23). requested/invited-only creators still re-seed; a removed+left pair is
273
+ -- suppressed.
274
+ for v_row in
275
+ select b.id as board_id, b.created_by_agent_id as owner_agent_id
276
+ from agent_board.boards b
277
+ where not exists (
278
+ select 1
279
+ from agent_board.board_memberships bm
280
+ where bm.board_id = b.id
281
+ and bm.agent_id = b.created_by_agent_id
282
+ and bm.membership_state in ('joined', 'subscribed')
283
+ )
284
+ and not exists (
285
+ select 1
286
+ from agent_board.board_memberships bm
287
+ where bm.board_id = b.id
288
+ and bm.agent_id = b.created_by_agent_id
289
+ and bm.membership_state = 'removed'
290
+ )
291
+ loop
292
+ insert into agent_board.board_memberships as bm(board_id, agent_id, membership_state, role, created_by_agent_id)
293
+ values (v_row.board_id, v_row.owner_agent_id, 'joined', 'owner', v_row.owner_agent_id)
294
+ on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
295
+ do update set role = 'owner', updated_at = now()
296
+ returning bm.id into v_membership_id;
297
+
298
+ board_id := v_row.board_id;
299
+ owner_agent_id := v_row.owner_agent_id;
300
+ membership_id := v_membership_id;
301
+ return next;
302
+ end loop;
303
+ end;
304
+ $$;
305
+ -- 4. join_board (active-prefer + total order + R-LINK-7 joinability) --------------------
306
+ CREATE OR REPLACE FUNCTION agent_board.join_board(p_board_id uuid, p_agent_id uuid, p_membership_state text DEFAULT 'joined'::text, p_created_by_agent_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
307
+ RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, created_at timestamp with time zone, updated_at timestamp with time zone)
308
+ LANGUAGE plpgsql
309
+ SECURITY DEFINER
310
+ SET search_path TO 'agent_board', 'public'
311
+ AS $$
312
+ declare
313
+ v_board agent_board.boards%rowtype;
314
+ v_existing_state text;
315
+ v_membership agent_board.board_memberships%rowtype;
316
+ v_actor uuid := agent_board.current_agent_id();
317
+ begin
318
+ -- GATENULL (#58): only service_role may proceed with an unresolved actor.
319
+ if v_actor is null and not agent_board._is_service_role() then
320
+ raise exception 'current agent principal not resolved' using errcode = '42501';
321
+ end if;
322
+
323
+ if p_membership_state not in ('joined', 'subscribed') then
324
+ raise exception 'join_board only accepts joined or subscribed' using errcode = '22023';
325
+ end if;
326
+ if not exists (select 1 from agent_board.agents where id = p_agent_id) then
327
+ raise exception 'agent not found' using errcode = 'P0002';
328
+ end if;
329
+ 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
330
+ raise exception 'creator agent not found' using errcode = 'P0002';
331
+ end if;
332
+
333
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
334
+ if v_board.id is null then
335
+ raise exception 'board not found' using errcode = 'P0002';
336
+ end if;
337
+
338
+ -- MBROWID (3a): prefer ACTIVE standing for joinability / idempotent-join. A bare
339
+ -- total-order can surface a high-id REMOVED row while a low-id JOINED row exists,
340
+ -- locking an active member out of their own private / invite_only board (P0002).
341
+ select bm.membership_state
342
+ into v_existing_state
343
+ from agent_board.board_memberships bm
344
+ where bm.board_id = p_board_id
345
+ and bm.agent_id = p_agent_id
346
+ order by
347
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
348
+ bm.created_at desc,
349
+ bm.id desc
350
+ limit 1;
351
+
352
+ if v_actor is not null and v_actor <> p_agent_id and v_actor <> v_board.created_by_agent_id then
353
+ raise exception 'board membership actor mismatch' using errcode = '42501';
354
+ end if;
355
+
356
+ -- NULLFIX (CR): v_existing_state is NULL when the caller has NO membership row, and the original
357
+ -- guard then evaluated to NULL — `not (false or false or NULL)` is NULL, not TRUE — so `if NULL
358
+ -- then` never fired and ANY resolvable actor self-joined ANY board, private ones included. That
359
+ -- (not the null-actor bypass) was the true root cause of the live "joined a private invite_only
360
+ -- board with no invite" finding, and it is reachable by an ACTIVE agent, which the GATENULL guard
361
+ -- above cannot touch. coalesce() makes the absent-row case FALSE so the raise fires.
362
+ --
363
+ -- MBROWID (3b): a 'removed' agent is excluded from self-joining even an OPEN board via the
364
+ -- R-LINK-7 SET predicate (exists removed AND no active), not a single latest-row state.
365
+ -- Total-ordering the latest-row read alone is insufficient: a removed+left pair with equal
366
+ -- created_at can surface 'left' as the total-order winner while standing is still removed
367
+ -- (mutator + grant_board_role already use set-based rules). The three defenses must agree.
368
+ if v_actor is not null
369
+ and v_actor = p_agent_id
370
+ and v_board.created_by_agent_id <> v_actor
371
+ and not (
372
+ not (
373
+ exists (
374
+ select 1 from agent_board.board_memberships bm
375
+ where bm.board_id = p_board_id
376
+ and bm.agent_id = p_agent_id
377
+ and bm.membership_state = 'removed'
378
+ ) and not exists (
379
+ select 1 from agent_board.board_memberships bm
380
+ where bm.board_id = p_board_id
381
+ and bm.agent_id = p_agent_id
382
+ and bm.membership_state in ('joined', 'subscribed')
383
+ )
384
+ )
385
+ and (
386
+ v_board.visibility = 'open'
387
+ or (v_board.visibility = 'invite_only' and coalesce(v_existing_state, '') = 'invited')
388
+ or coalesce(v_existing_state, '') in ('joined', 'subscribed')
389
+ )
390
+ ) then
391
+ raise exception 'board is not joinable by this agent' using errcode = 'P0002';
392
+ end if;
393
+
394
+ v_membership := agent_board._upsert_board_membership_state(
395
+ p_board_id,
396
+ p_agent_id,
397
+ p_membership_state,
398
+ coalesce(p_created_by_agent_id, p_agent_id),
399
+ p_metadata
400
+ );
401
+
402
+ return query
403
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
404
+ end;
405
+ $$;
406
+ -- 5. resolve_board_request --------------------------------------------------------------
407
+ CREATE OR REPLACE FUNCTION agent_board.resolve_board_request(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_created_by_agent_id uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
408
+ RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamp with time zone, updated_at timestamp with time zone)
409
+ LANGUAGE plpgsql
410
+ SECURITY DEFINER
411
+ SET search_path TO 'agent_board', 'public'
412
+ AS $$
413
+ declare
414
+ v_board agent_board.boards%rowtype;
415
+ v_existing_state text;
416
+ v_membership agent_board.board_memberships%rowtype;
417
+ v_actor uuid := agent_board.current_agent_id();
418
+ begin
419
+ -- GATENULL (#58): only service_role may proceed with an unresolved actor.
420
+ if v_actor is null and not agent_board._is_service_role() then
421
+ raise exception 'current agent principal not resolved' using errcode = '42501';
422
+ end if;
423
+
424
+ if p_membership_state not in ('joined', 'subscribed', 'removed') then
425
+ raise exception 'resolve_board_request only accepts joined, subscribed, or removed' using errcode = '22023';
426
+ end if;
427
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
428
+ if v_board.id is null then
429
+ raise exception 'board not found' using errcode = 'P0002';
430
+ end if;
431
+ if v_actor is not null then
432
+ if v_board.role_gate_enforced then
433
+ -- Gate ON: NO creator exemption. Every actor (including a demoted creator)
434
+ -- must hold an active owner/admin membership.
435
+ if not exists (
436
+ select 1
437
+ from agent_board.board_memberships bm
438
+ where bm.board_id = p_board_id
439
+ and bm.agent_id = v_actor
440
+ and bm.membership_state in ('joined', 'subscribed')
441
+ and bm.role in ('owner', 'admin')
442
+ ) then
443
+ raise exception 'board request resolution requires owner or admin role' using errcode = '42501';
444
+ end if;
445
+ elsif v_actor <> v_board.created_by_agent_id then
446
+ -- Gate OFF: creator bypasses; any other actor needs an active membership.
447
+ if not exists (
448
+ select 1
449
+ from agent_board.board_memberships bm
450
+ where bm.board_id = p_board_id
451
+ and bm.agent_id = v_actor
452
+ and bm.membership_state in ('joined', 'subscribed')
453
+ ) then
454
+ raise exception 'board request resolution requires active membership' using errcode = '42501';
455
+ end if;
456
+ end if;
457
+ end if;
458
+
459
+ select bm.membership_state
460
+ into v_existing_state
461
+ from agent_board.board_memberships bm
462
+ where bm.board_id = p_board_id
463
+ and bm.agent_id = p_agent_id
464
+ order by bm.created_at desc, bm.id desc
465
+ limit 1;
466
+
467
+ -- NULLFIX (CR): same NULL-falsy hazard — with NO membership row v_existing_state is NULL and
468
+ -- `NULL not in (...)` is NULL, so the raise never fired and a caller could "resolve" an agent
469
+ -- that had never requested or been invited STRAIGHT to joined, bypassing the whole request path
470
+ -- (reachable by any active member on a gate-OFF board, incl. via `rooms approve --agent-id`).
471
+ if coalesce(v_existing_state, '') not in ('requested', 'invited') then
472
+ raise exception 'pending board membership not found' using errcode = 'P0002';
473
+ end if;
474
+
475
+ v_membership := agent_board._upsert_board_membership_state(
476
+ p_board_id,
477
+ p_agent_id,
478
+ p_membership_state,
479
+ p_created_by_agent_id,
480
+ p_metadata
481
+ );
482
+
483
+ return query
484
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
485
+ end;
486
+ $$;
487
+ -- 6. update_board_membership (both sites: active-prefer + total order) ------------------
488
+ CREATE OR REPLACE FUNCTION agent_board.update_board_membership(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_metadata jsonb DEFAULT '{}'::jsonb)
489
+ RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamp with time zone, updated_at timestamp with time zone)
490
+ LANGUAGE plpgsql
491
+ SECURITY DEFINER
492
+ SET search_path TO 'agent_board', 'public'
493
+ AS $$
494
+ declare
495
+ v_membership agent_board.board_memberships%rowtype;
496
+ v_board agent_board.boards%rowtype;
497
+ v_actor uuid := agent_board.current_agent_id();
498
+ v_is_service boolean := agent_board._is_service_role();
499
+ v_existing_state text;
500
+ v_target_role text;
501
+ v_actor_role text;
502
+ begin
503
+ -- (1) Unresolved principal fails closed (GATENULL #58 posture). Raised before any lookup so this
504
+ -- cannot be used as a membership-existence oracle.
505
+ if v_actor is null and not v_is_service then
506
+ raise exception 'current agent principal not resolved' using errcode = '42501';
507
+ end if;
508
+
509
+ -- (2) AUTHORITY BEFORE DISCLOSURE. For a caller acting on ANOTHER agent's row the owner/admin
510
+ -- check runs BEFORE the membership lookup, so an unauthorized caller cannot distinguish
511
+ -- P0002 'board membership not found' from 42501 and thus cannot probe whether an arbitrary
512
+ -- (board_id, agent_id) pair has a membership row. (The self branch legitimately learns about its
513
+ -- own membership, and the service path is the operator.)
514
+ if not v_is_service and v_actor <> p_agent_id then
515
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
516
+ if v_board.id is null then
517
+ raise exception 'board not found' using errcode = 'P0002';
518
+ end if;
519
+ select bm.role
520
+ into v_actor_role
521
+ from agent_board.board_memberships bm
522
+ where bm.board_id = p_board_id
523
+ and bm.agent_id = v_actor
524
+ and bm.membership_state in ('joined', 'subscribed')
525
+ limit 1;
526
+ if v_actor_role is null or v_actor_role not in ('owner', 'admin') then
527
+ raise exception 'board membership change requires owner or admin role'
528
+ using errcode = '42501';
529
+ end if;
530
+ end if;
531
+
532
+ -- (3) Pre-existing precondition, preserved. Also captures the target's CURRENT role for the
533
+ -- rank check below.
534
+ -- MBROWID (3a): prefer ACTIVE standing. Bare total-order can surface a high-id REMOVED
535
+ -- row while a low-id JOINED row exists, so self-service would reject a legitimate
536
+ -- joined->joined toggle with 42501 'self membership change not permitted'.
537
+ select bm.membership_state, bm.role
538
+ into v_existing_state, v_target_role
539
+ from agent_board.board_memberships bm
540
+ where bm.board_id = p_board_id
541
+ and bm.agent_id = p_agent_id
542
+ order by
543
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
544
+ bm.created_at desc,
545
+ bm.id desc
546
+ limit 1;
547
+ if v_existing_state is null then
548
+ raise exception 'board membership not found' using errcode = 'P0002';
549
+ end if;
550
+
551
+ -- (4) MEMBSTATE authorization. Skipped ONLY for a genuine service_role/operator caller; every
552
+ -- authenticated caller (whose actor is guaranteed non-null by step 1) is gated.
553
+ if not v_is_service then
554
+ if v_actor = p_agent_id then
555
+ -- SELF-SERVICE. Deliberately NO board lookup on this branch: the board row is only needed
556
+ -- for the authority checks below, and requiring an ACTIVE board here would strand an agent
557
+ -- holding a live membership on an ARCHIVED board with no way to leave it (leave_board
558
+ -- delegates straight into this RPC, 20260512000002:154-158).
559
+ --
560
+ -- Leaving is permitted only FROM a live/pending state, and joined<->subscribed may be
561
+ -- toggled. Promotion INTO the board is NOT permitted — that belongs to the approval path.
562
+ -- The 'removed' exclusion is load-bearing: allowing removed -> left would let a kicked agent
563
+ -- erase the 'removed' marker (memberships are updated IN PLACE, so the row IS the record),
564
+ -- which is exactly the marker _redeem_invite_for_agent's R-LINK-7 defense reads
565
+ -- (20260724000100:323-335) — i.e. it would launder a kick into invite-link re-entry.
566
+ --
567
+ -- LEAVEIDEM (#81): a same-state TERMINAL call is additionally permitted. It is a no-op
568
+ -- (returned unchanged at step 5), so it neither erases nor refreshes the marker — it simply
569
+ -- restores idempotence to leave_board without widening the transition set.
570
+ if not (
571
+ (p_membership_state = 'left'
572
+ and v_existing_state in ('joined', 'subscribed', 'invited', 'requested'))
573
+ or (p_membership_state in ('joined', 'subscribed')
574
+ and v_existing_state in ('joined', 'subscribed'))
575
+ or (p_membership_state = v_existing_state
576
+ and p_membership_state in ('left', 'removed'))
577
+ ) then
578
+ raise exception 'self membership change not permitted; use the join/approval path'
579
+ using errcode = '42501';
580
+ end if;
581
+ else
582
+ -- ACTING ON ANOTHER AGENT'S MEMBERSHIP. The board-active check and the ACTIVE owner/admin
583
+ -- requirement already ran in step (2) (authority before disclosure). This RPC is the
584
+ -- schema's only eviction path — no sibling can move a JOINED member to removed
585
+ -- (resolve_board_request only resolves requested/invited, 20260725000100:212) — so it is
586
+ -- gated more strictly than its siblings, with NO creator exemption: a bare
587
+ -- created_by_agent_id is deliberately NOT sufficient. ROOMOWN seeds the creator's owner
588
+ -- membership at create_board time (20260723000100), so a legitimate creator holds one, while
589
+ -- a DEMOTED or REMOVED creator must not retain eviction power (the R-DELEG-1 lesson).
590
+ --
591
+ -- STRICT RANK SUPERIORITY (owner=4 > admin=3 > member=2 > observer=1). Without it an admin
592
+ -- could evict the OWNER, or admins could evict each other.
593
+ if agent_board._role_rank(v_actor_role)
594
+ <= agent_board._role_rank(coalesce(v_target_role, '')) then
595
+ raise exception 'board membership change requires a higher board role than the target'
596
+ using errcode = '42501';
597
+ end if;
598
+ end if;
599
+ end if;
600
+
601
+ -- (5) LEAVEIDEM (#81): a same-state TERMINAL call is a TRUE no-op — return the row untouched
602
+ -- rather than re-entering the mutator, which would restamp left_at and re-merge metadata.
603
+ -- Placed AFTER the authorization block on purpose: returning early before step (2)/(4) would
604
+ -- hand an UNAUTHORIZED caller a successful no-op instead of 42501, turning this RPC into a
605
+ -- membership-state oracle for arbitrary (board, agent) pairs. Authority is still required; the
606
+ -- entitled caller simply gets an unchanged row.
607
+ -- Scoped to the AUTHENTICATED path on purpose: a service_role/operator caller re-asserting a
608
+ -- terminal state may legitimately be attaching an annotation via p_metadata (CLI --metadata), and
609
+ -- silently dropping it would be a lossy success. The operator falls through to the mutator, which
610
+ -- now preserves left_at for an UNCHANGED terminal state anyway — so the operator keeps the
611
+ -- metadata merge without reacquiring the restamp defect.
612
+ if not v_is_service
613
+ and p_membership_state = v_existing_state
614
+ and p_membership_state in ('left', 'removed') then
615
+ -- MBROWID (3a): same active-preferring total-order as the authz pre-read above so
616
+ -- the no-op return row agrees with the state used for authorization.
617
+ return query
618
+ select bm.id, bm.board_id, bm.agent_id, bm.membership_state, bm.left_at, bm.updated_at
619
+ from agent_board.board_memberships bm
620
+ where bm.board_id = p_board_id
621
+ and bm.agent_id = p_agent_id
622
+ order by
623
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
624
+ bm.created_at desc,
625
+ bm.id desc
626
+ limit 1;
627
+ return;
628
+ end if;
629
+
630
+ select * into v_membership
631
+ from agent_board._upsert_board_membership_state(
632
+ p_board_id,
633
+ p_agent_id,
634
+ p_membership_state,
635
+ null,
636
+ p_metadata
637
+ );
638
+
639
+ return query
640
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
641
+ end;
642
+ $$;
643
+ -- 7. list_board_pending_memberships (both sites: active-prefer + total order) -----------
644
+ CREATE OR REPLACE FUNCTION agent_board.list_board_pending_memberships(p_board_id uuid)
645
+ RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, handle text, display_name text, agent_type text, membership_state text, role text, created_by_agent_id uuid, requested_at timestamp with time zone, updated_at timestamp with time zone)
646
+ LANGUAGE plpgsql
647
+ STABLE SECURITY DEFINER
648
+ SET search_path TO 'agent_board', 'public'
649
+ AS $$
650
+ declare
651
+ v_actor uuid := agent_board.current_agent_id();
652
+ begin
653
+ -- FLAT owner/admin gate, ALWAYS. Fail closed on a null actor (suspended/unmapped
654
+ -- principal, or service_role) — mirrors set_board_role_gate, NOT the write-RPC
655
+ -- `if v_actor is not null` bypass. Authz precedes disclosure.
656
+ if v_actor is null
657
+ or not exists (
658
+ select 1
659
+ from agent_board.board_memberships bm
660
+ where bm.board_id = p_board_id
661
+ and bm.agent_id = v_actor
662
+ and bm.role in ('owner', 'admin')
663
+ and bm.membership_state in ('joined', 'subscribed')
664
+ ) then
665
+ raise exception 'only a board owner or admin may list pending memberships' using errcode = '42501';
666
+ end if;
667
+
668
+ -- Current standing per agent, scoped to this board, filtered to the pending set.
669
+ -- MBROWID (3a): prefer ACTIVE over bare total-order so a stale high-id REQUESTED
670
+ -- (or REMOVED) row cannot surface an already-active agent as pending. The dedup
671
+ -- subquery is ALSO scoped to p_board_id so no other board's rows can leak in.
672
+ -- Outer ORDER BY remains bare total-order (presentation only; no state acted on).
673
+ return query
674
+ select
675
+ latest.id,
676
+ latest.board_id,
677
+ latest.agent_id,
678
+ a.handle,
679
+ a.display_name,
680
+ a.agent_type,
681
+ latest.membership_state,
682
+ latest.role,
683
+ latest.created_by_agent_id,
684
+ latest.created_at as requested_at,
685
+ latest.updated_at
686
+ from (
687
+ select distinct on (bm.agent_id) bm.*
688
+ from agent_board.board_memberships bm
689
+ where bm.board_id = p_board_id
690
+ order by
691
+ bm.agent_id,
692
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
693
+ bm.created_at desc,
694
+ bm.id desc
695
+ ) latest
696
+ join agent_board.agents a on a.id = latest.agent_id
697
+ where latest.membership_state in ('requested', 'invited')
698
+ order by latest.created_at desc, latest.id desc;
699
+ end;
700
+ $$;
701
+ -- 8. create_tasking_delivery (idempotency total-order for COUNT-EQUALITY) ---------------
702
+ CREATE OR REPLACE FUNCTION agent_board.create_tasking_delivery(p_thread_id uuid, p_created_by_agent_id uuid, p_title text, p_details text DEFAULT NULL::text, p_recipient_agent_ids uuid[] DEFAULT NULL::uuid[], p_board_id uuid DEFAULT NULL::uuid, p_priority text DEFAULT 'medium'::text, p_orchestration_mode text DEFAULT 'distributed'::text, p_idempotency_key text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb)
703
+ RETURNS TABLE(task_id uuid, created_event_id uuid, delivered_recipient_count integer, inbox_item_count integer, callback_outbox_count integer, created_at timestamp with time zone, updated_at timestamp with time zone)
704
+ LANGUAGE plpgsql
705
+ SECURITY DEFINER
706
+ SET search_path TO 'agent_board', 'public'
707
+ AS $$
708
+ declare
709
+ v_task agent_board.tasks%rowtype;
710
+ v_created_event_id uuid;
711
+ v_created_task record;
712
+ v_recipient_ids uuid[];
713
+ v_recipient_id uuid;
714
+ v_membership record;
715
+ v_recipient agent_board.delivery_recipients%rowtype;
716
+ v_inbox agent_board.inbox_items%rowtype;
717
+ v_delivered_count integer := 0;
718
+ v_inbox_count integer := 0;
719
+ v_callback_count integer := 0;
720
+ v_metadata jsonb;
721
+ v_preview text;
722
+ v_fetch_descriptor jsonb;
723
+ v_idempotency_key text := nullif(trim(p_idempotency_key), '');
724
+ v_actor_agent_id uuid;
725
+ begin
726
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
727
+
728
+ if nullif(trim(p_title), '') is null then
729
+ raise exception 'task title is required' using errcode = '22023';
730
+ end if;
731
+ if p_priority not in ('low', 'medium', 'high', 'urgent') then
732
+ raise exception 'invalid priority' using errcode = '22023';
733
+ end if;
734
+ if p_orchestration_mode not in ('distributed', 'hierarchical', 'hybrid') then
735
+ raise exception 'invalid orchestration mode' using errcode = '22023';
736
+ end if;
737
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
738
+ raise exception 'thread not found' using errcode = 'P0002';
739
+ end if;
740
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
741
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
742
+ end if;
743
+ if (p_recipient_agent_ids is null or cardinality(p_recipient_agent_ids) = 0) and p_board_id is null then
744
+ raise exception 'tasking delivery requires explicit recipients or a board target' using errcode = '22023';
745
+ end if;
746
+ if p_recipient_agent_ids is not null and array_position(p_recipient_agent_ids, null) is not null then
747
+ raise exception 'recipient_agent_ids must not contain nulls' using errcode = '22023';
748
+ end if;
749
+ if p_board_id is not null then
750
+ if not exists (select 1 from agent_board.boards where id = p_board_id) then
751
+ raise exception 'board not found' using errcode = 'P0002';
752
+ end if;
753
+ if not agent_board._current_agent_has_board_access(p_board_id) then
754
+ raise exception 'board not accessible to current agent' using errcode = '42501';
755
+ end if;
756
+ if not agent_board._current_agent_can_send_to_board(p_board_id) then
757
+ raise exception 'board send requires a non-observer board role' using errcode = '42501';
758
+ end if;
759
+ end if;
760
+
761
+ select array_agg(distinct recipient_id)
762
+ into v_recipient_ids
763
+ from unnest(coalesce(p_recipient_agent_ids, '{}'::uuid[])) as recipient_id;
764
+
765
+ if v_recipient_ids is not null and (
766
+ select count(*)
767
+ from agent_board.agents
768
+ where id = any(v_recipient_ids)
769
+ ) <> cardinality(v_recipient_ids) then
770
+ raise exception 'recipient agent not found' using errcode = 'P0002';
771
+ end if;
772
+
773
+ if agent_board._is_service_role() then
774
+ perform agent_board._record_send_authz_telemetry(
775
+ 'service_role_bypass',
776
+ coalesce(nullif(p_metadata -> 'signed_write' ->> 'origin', ''), 'unknown'),
777
+ v_actor_agent_id,
778
+ p_board_id,
779
+ 'create_tasking_delivery',
780
+ agent_board._resolve_send_fleet_scope(v_actor_agent_id),
781
+ null,
782
+ jsonb_build_object('redacted', true)
783
+ );
784
+ end if;
785
+
786
+ perform agent_board._lock_idempotency_key(
787
+ 'create_tasking_delivery:' || p_thread_id::text || ':' || v_actor_agent_id::text,
788
+ v_idempotency_key
789
+ );
790
+
791
+ if v_idempotency_key is not null then
792
+ select *
793
+ into v_task
794
+ from agent_board.tasks
795
+ where thread_id = p_thread_id
796
+ and created_by_agent_id = v_actor_agent_id
797
+ and metadata ->> 'rpcName' = 'create_tasking_delivery'
798
+ and metadata ->> 'idempotencyKey' = v_idempotency_key
799
+ order by created_at desc, id desc
800
+ limit 1;
801
+ end if;
802
+
803
+ if v_task.id is null then
804
+ v_metadata := coalesce(p_metadata, '{}'::jsonb)
805
+ || jsonb_build_object(
806
+ 'rpcName', 'create_tasking_delivery',
807
+ 'priority', p_priority
808
+ );
809
+ if p_board_id is not null then
810
+ v_metadata := v_metadata || jsonb_build_object('boardId', p_board_id::text);
811
+ end if;
812
+ if v_idempotency_key is not null then
813
+ v_metadata := v_metadata || jsonb_build_object('idempotencyKey', v_idempotency_key);
814
+ end if;
815
+
816
+ select *
817
+ into v_created_task
818
+ from agent_board.create_task(
819
+ p_thread_id,
820
+ v_actor_agent_id,
821
+ trim(p_title),
822
+ p_details,
823
+ null,
824
+ null,
825
+ p_priority,
826
+ p_orchestration_mode,
827
+ v_metadata
828
+ );
829
+
830
+ v_created_event_id := v_created_task.created_event_id;
831
+
832
+ select *
833
+ into v_task
834
+ from agent_board.tasks
835
+ where id = v_created_task.task_id;
836
+ else
837
+ select te.id
838
+ into v_created_event_id
839
+ from agent_board.task_events te
840
+ where te.task_id = v_task.id
841
+ and te.event_sequence = 1;
842
+ end if;
843
+
844
+ v_preview := agent_board._trim_inbox_text(coalesce(p_details, p_title), 280);
845
+ v_fetch_descriptor := agent_board._build_fetch_descriptor(
846
+ 'task',
847
+ v_task.id,
848
+ 'agent_board.v_task_tree',
849
+ 'tasking delivery',
850
+ jsonb_build_object('threadId', p_thread_id::text)
851
+ );
852
+
853
+ if v_recipient_ids is not null then
854
+ foreach v_recipient_id in array v_recipient_ids loop
855
+ v_recipient := agent_board._upsert_delivery_recipient(
856
+ 'tasking',
857
+ v_recipient_id,
858
+ 'task',
859
+ null,
860
+ v_task.id,
861
+ null,
862
+ null,
863
+ null,
864
+ jsonb_build_object('priority', p_priority)
865
+ );
866
+
867
+ v_inbox := agent_board._upsert_inbox_item(
868
+ v_recipient.id,
869
+ v_recipient_id,
870
+ null,
871
+ v_recipient.delivery_mode,
872
+ 'task',
873
+ null,
874
+ v_task.id,
875
+ null,
876
+ trim(p_title),
877
+ v_preview,
878
+ p_priority,
879
+ true,
880
+ false,
881
+ v_fetch_descriptor,
882
+ jsonb_build_object('threadId', p_thread_id::text)
883
+ );
884
+
885
+ v_delivered_count := v_delivered_count + 1;
886
+ v_inbox_count := v_inbox_count + 1;
887
+ v_callback_count := v_callback_count + agent_board._queue_inbox_callbacks(v_inbox.id, 'inbox.item.delivered');
888
+ end loop;
889
+ end if;
890
+
891
+ if p_board_id is not null then
892
+ for v_membership in
893
+ select bm.id, bm.agent_id
894
+ from agent_board.board_memberships bm
895
+ where bm.board_id = p_board_id
896
+ and bm.membership_state in ('joined', 'subscribed')
897
+ order by bm.created_at
898
+ loop
899
+ v_recipient := agent_board._upsert_delivery_recipient(
900
+ 'tasking',
901
+ v_membership.agent_id,
902
+ 'task',
903
+ null,
904
+ v_task.id,
905
+ null,
906
+ p_board_id,
907
+ v_membership.id,
908
+ jsonb_build_object('priority', p_priority)
909
+ );
910
+
911
+ v_inbox := agent_board._upsert_inbox_item(
912
+ v_recipient.id,
913
+ v_membership.agent_id,
914
+ v_recipient.board_id,
915
+ v_recipient.delivery_mode,
916
+ 'task',
917
+ null,
918
+ v_task.id,
919
+ null,
920
+ trim(p_title),
921
+ v_preview,
922
+ p_priority,
923
+ true,
924
+ v_recipient.board_id is not null,
925
+ v_fetch_descriptor,
926
+ jsonb_build_object('threadId', p_thread_id::text, 'boardId', p_board_id::text)
927
+ );
928
+
929
+ v_delivered_count := v_delivered_count + 1;
930
+ v_inbox_count := v_inbox_count + 1;
931
+ v_callback_count := v_callback_count + agent_board._queue_inbox_callbacks(v_inbox.id, 'inbox.item.delivered');
932
+ end loop;
933
+ end if;
934
+
935
+ return query
936
+ select v_task.id, v_created_event_id, v_delivered_count, v_inbox_count, v_callback_count, v_task.created_at, v_task.updated_at;
937
+ end;
938
+ $$;
939
+ -- 9. post_board_message (idempotency total-order for COUNT-EQUALITY) --------------------
940
+ CREATE OR REPLACE FUNCTION agent_board.post_board_message(p_thread_id uuid, p_author_agent_id uuid, p_board_id uuid, p_subject text, p_body text, p_message_type text DEFAULT 'comment'::text, p_priority text DEFAULT 'medium'::text, p_idempotency_key text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb)
941
+ RETURNS TABLE(message_id uuid, board_id uuid, delivered_recipient_count integer, callback_outbox_count integer, created_at timestamp with time zone)
942
+ LANGUAGE plpgsql
943
+ SECURITY DEFINER
944
+ SET search_path TO 'agent_board', 'public'
945
+ AS $$
946
+ declare
947
+ v_message agent_board.messages%rowtype;
948
+ v_created_message record;
949
+ v_membership record;
950
+ v_recipient agent_board.delivery_recipients%rowtype;
951
+ v_inbox agent_board.inbox_items%rowtype;
952
+ v_delivered_count integer := 0;
953
+ v_callback_count integer := 0;
954
+ v_metadata jsonb;
955
+ v_preview text;
956
+ v_fetch_descriptor jsonb;
957
+ v_idempotency_key text := nullif(trim(p_idempotency_key), '');
958
+ v_actor_agent_id uuid;
959
+ begin
960
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_author_agent_id, 'p_author_agent_id');
961
+
962
+ if nullif(trim(p_subject), '') is null or nullif(trim(p_body), '') is null then
963
+ raise exception 'subject and body are required' using errcode = '22023';
964
+ end if;
965
+ if p_priority not in ('low', 'medium', 'high', 'urgent') then
966
+ raise exception 'invalid priority' using errcode = '22023';
967
+ end if;
968
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
969
+ raise exception 'thread not found' using errcode = 'P0002';
970
+ end if;
971
+ if not exists (select 1 from agent_board.boards where id = p_board_id) then
972
+ raise exception 'board not found' using errcode = 'P0002';
973
+ end if;
974
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
975
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
976
+ end if;
977
+ if not agent_board._current_agent_has_board_access(p_board_id) then
978
+ raise exception 'board not accessible to current agent' using errcode = '42501';
979
+ end if;
980
+ if not agent_board._current_agent_can_send_to_board(p_board_id) then
981
+ raise exception 'board send requires a non-observer board role' using errcode = '42501';
982
+ end if;
983
+
984
+ if agent_board._is_service_role() then
985
+ perform agent_board._record_send_authz_telemetry(
986
+ 'service_role_bypass',
987
+ coalesce(nullif(p_metadata -> 'signed_write' ->> 'origin', ''), 'unknown'),
988
+ v_actor_agent_id,
989
+ p_board_id,
990
+ 'post_board_message',
991
+ agent_board._resolve_send_fleet_scope(v_actor_agent_id),
992
+ null,
993
+ jsonb_build_object('redacted', true)
994
+ );
995
+ end if;
996
+
997
+ perform agent_board._lock_idempotency_key(
998
+ 'post_board_message:' || p_thread_id::text || ':' || v_actor_agent_id::text || ':' || p_board_id::text,
999
+ v_idempotency_key
1000
+ );
1001
+
1002
+ if v_idempotency_key is not null then
1003
+ select *
1004
+ into v_message
1005
+ from agent_board.messages
1006
+ where thread_id = p_thread_id
1007
+ and author_agent_id = v_actor_agent_id
1008
+ and metadata ->> 'rpcName' = 'post_board_message'
1009
+ and metadata ->> 'idempotencyKey' = v_idempotency_key
1010
+ order by created_at desc, id desc
1011
+ limit 1;
1012
+ end if;
1013
+
1014
+ if v_message.id is null then
1015
+ v_metadata := coalesce(p_metadata, '{}'::jsonb)
1016
+ || jsonb_build_object(
1017
+ 'rpcName', 'post_board_message',
1018
+ 'subject', trim(p_subject),
1019
+ 'priority', p_priority,
1020
+ 'boardId', p_board_id::text
1021
+ );
1022
+ if v_idempotency_key is not null then
1023
+ v_metadata := v_metadata || jsonb_build_object('idempotencyKey', v_idempotency_key);
1024
+ end if;
1025
+
1026
+ select *
1027
+ into v_created_message
1028
+ from agent_board.post_message(
1029
+ p_thread_id,
1030
+ v_actor_agent_id,
1031
+ p_body,
1032
+ p_message_type,
1033
+ null,
1034
+ v_metadata
1035
+ );
1036
+
1037
+ select *
1038
+ into v_message
1039
+ from agent_board.messages
1040
+ where id = v_created_message.message_id;
1041
+ end if;
1042
+
1043
+ v_preview := agent_board._trim_inbox_text(p_body, 280);
1044
+ v_fetch_descriptor := agent_board._build_fetch_descriptor(
1045
+ 'board',
1046
+ p_board_id,
1047
+ 'agent_board.v_board_feed',
1048
+ 'board delivery',
1049
+ jsonb_build_object('messageId', v_message.id::text)
1050
+ );
1051
+
1052
+ for v_membership in
1053
+ select bm.id, bm.agent_id
1054
+ from agent_board.board_memberships bm
1055
+ where bm.board_id = p_board_id
1056
+ and bm.membership_state in ('joined', 'subscribed')
1057
+ order by bm.created_at
1058
+ loop
1059
+ v_recipient := agent_board._upsert_delivery_recipient(
1060
+ 'board',
1061
+ v_membership.agent_id,
1062
+ 'message',
1063
+ v_message.id,
1064
+ null,
1065
+ null,
1066
+ p_board_id,
1067
+ v_membership.id,
1068
+ jsonb_build_object('priority', p_priority)
1069
+ );
1070
+
1071
+ v_inbox := agent_board._upsert_inbox_item(
1072
+ v_recipient.id,
1073
+ v_membership.agent_id,
1074
+ p_board_id,
1075
+ v_recipient.delivery_mode,
1076
+ 'message',
1077
+ v_message.id,
1078
+ null,
1079
+ null,
1080
+ trim(p_subject),
1081
+ v_preview,
1082
+ p_priority,
1083
+ true,
1084
+ true,
1085
+ v_fetch_descriptor,
1086
+ jsonb_build_object('threadId', p_thread_id::text, 'boardId', p_board_id::text)
1087
+ );
1088
+
1089
+ v_delivered_count := v_delivered_count + 1;
1090
+ v_callback_count := v_callback_count + agent_board._queue_inbox_callbacks(v_inbox.id, 'inbox.item.delivered');
1091
+ end loop;
1092
+
1093
+ v_callback_count := v_callback_count + agent_board._queue_board_post_callbacks(
1094
+ p_board_id,
1095
+ v_message.id,
1096
+ trim(p_subject),
1097
+ v_preview,
1098
+ p_priority,
1099
+ v_fetch_descriptor
1100
+ );
1101
+
1102
+ return query
1103
+ select v_message.id, p_board_id, v_delivered_count, v_callback_count, v_message.created_at;
1104
+ end;
1105
+ $$;
1106
+ -- 10. v_board_directory: DROP + CREATE (last definer used drop view, not create or replace)
1107
+ -- MBROWID (3a): current_membership lateral prefers ACTIVE standing, then total-order.
1108
+ -- Bare total-order can report 'removed' for an active member (high-id removed + low-id
1109
+ -- joined) and disagree with _active_board_membership_id.
1110
+ drop view if exists agent_board.v_board_directory;
1111
+
1112
+ create view agent_board.v_board_directory
1113
+ with (security_invoker = true)
1114
+ as
1115
+ SELECT b.id AS board_id,
1116
+ b.slug AS board_slug,
1117
+ b.name AS board_name,
1118
+ b.description AS board_description,
1119
+ b.status AS board_status,
1120
+ b.visibility AS board_visibility,
1121
+ b.created_by_agent_id,
1122
+ creator.handle AS created_by_handle,
1123
+ COALESCE(members.active_member_count, 0::bigint) AS active_member_count,
1124
+ current_membership.membership_state AS current_membership_state,
1125
+ COALESCE(board_activity.latest_board_activity_at, b.updated_at) AS latest_board_activity_at,
1126
+ b.created_at,
1127
+ b.updated_at
1128
+ FROM agent_board.boards b
1129
+ JOIN agent_board.agents creator ON creator.id = b.created_by_agent_id
1130
+ LEFT JOIN LATERAL ( SELECT count(*) AS active_member_count
1131
+ FROM agent_board.board_memberships bm
1132
+ WHERE bm.board_id = b.id AND (bm.membership_state = ANY (ARRAY['joined'::text, 'subscribed'::text]))) members ON true
1133
+ LEFT JOIN LATERAL ( SELECT bm.membership_state
1134
+ FROM agent_board.board_memberships bm
1135
+ WHERE bm.board_id = b.id AND bm.agent_id = agent_board.current_agent_id()
1136
+ ORDER BY
1137
+ CASE WHEN bm.membership_state = ANY (ARRAY['joined'::text, 'subscribed'::text]) THEN 0 ELSE 1 END,
1138
+ bm.created_at DESC,
1139
+ bm.id DESC
1140
+ LIMIT 1) current_membership ON true
1141
+ LEFT JOIN LATERAL ( SELECT max(ii.created_at) AS latest_board_activity_at
1142
+ FROM agent_board.inbox_items ii
1143
+ WHERE ii.board_id = b.id AND ii.visible_in_board_feed = true) board_activity ON true
1144
+ WHERE b.status = 'active'::text AND b.visibility <> 'directory_hidden'::text AND (b.visibility = 'open'::text OR b.created_by_agent_id = agent_board.current_agent_id() OR current_membership.membership_state IS NOT NULL);
1145
+
1146
+ grant select on agent_board.v_board_directory to authenticated, service_role;
1147
+
1148
+
1149
+ -- Re-issue comments on recreated mutator / bootstrap / list / update so next authors
1150
+ -- do not inherit a stale ROLERESET-era comment that pretends MBROWID is still deferred.
1151
+ comment on function agent_board._upsert_board_membership_state(uuid, uuid, text, uuid, jsonb) is
1152
+ 'UNAUTHORIZED PRIMITIVE — accepts any membership state; every caller MUST gate. '
1153
+ 'MBROWID: pre-read is duplicate-aware (prefer active, then created_at desc, id desc) and '
1154
+ 'currently-removed standing uses the R-LINK-7 set predicate (exists removed AND no active). '
1155
+ 'ROLERESET (#79): on the UPDATE path only, clamps role to the table default (''member'', '
1156
+ '20260717000100:13-15) when transitioning from a non-active origin into joined|subscribed; '
1157
+ 'INSERT omits role and takes the table default. Active->active preserves role. LEAVEIDEM '
1158
+ '(#81): preserves the FIRST left_at across repeated terminal transitions (the retention sweep '
1159
+ 'keys on left_at, 20260513000001:121) and strips the reserved `ops_*` metadata namespace from '
1160
+ 'caller input (the sweep skips rows carrying ops_retained_at). Not a chokepoint — raw-INSERT '
1161
+ 'seeders (create_board owner, bootstrap, invite redeem, fedsession autojoin) bypass this '
1162
+ 'function by design.';
1163
+
1164
+ comment on function agent_board.bootstrap_board_owner_memberships() is
1165
+ 'service_role-only repair sweep: seeds a joined owner membership for boards whose creator '
1166
+ 'has no active membership AND is not currently removed (R-LINK-7). Voluntary left remains '
1167
+ 'the sole-owner-lockout recovery trigger. Takes no caller/role param.';
1168
+
1169
+ comment on function agent_board.update_board_membership(uuid, uuid, text, jsonb) is
1170
+ 'Updates a board membership state. MEMBSTATE (#75): service_role retains full '
1171
+ 'modify-on-behalf-of; an authenticated caller with an unresolved actor fails closed (42501); '
1172
+ 'a caller acting on its OWN membership may only leave or toggle joined<->subscribed; a caller '
1173
+ 'acting on ANOTHER agent''s membership must hold an active owner/admin membership AND strictly '
1174
+ 'higher board rank than the target. LEAVEIDEM (#81): a same-state terminal call is a TRUE no-op '
1175
+ 'returned unchanged AFTER authorization. MBROWID: both latest-row reads prefer an active row '
1176
+ 'when one exists, then created_at desc, id desc.';
1177
+
1178
+ comment on function agent_board.list_board_pending_memberships(uuid) is
1179
+ 'Owner/admin-gated reader for a board''s pending (requested + invited) memberships, '
1180
+ 'current-standing-per-agent preferring active then total order '
1181
+ '(agent_id, active-first, created_at desc, id desc). FLAT owner/admin gate ALWAYS, '
1182
+ 'fail-closed on a null actor (else 42501). Board-scoped. service_role hits 42501 '
1183
+ '(null current_agent_id()); operators read the table directly.';
1184
+
1185
+ -- Privileges: create or replace preserves EXECUTE; re-state bootstrap and view grants.
1186
+ revoke all on function agent_board.bootstrap_board_owner_memberships() from public, anon, authenticated;
1187
+ grant execute on function agent_board.bootstrap_board_owner_memberships() to service_role;
1188
+
1189
+ revoke all on function agent_board._upsert_board_membership_state(uuid, uuid, text, uuid, jsonb)
1190
+ from public, anon, authenticated;
1191
+ grant execute on function agent_board._upsert_board_membership_state(uuid, uuid, text, uuid, jsonb)
1192
+ to service_role;
1193
+
1194
+ grant execute on function agent_board.join_board(uuid, uuid, text, uuid, jsonb)
1195
+ to authenticated, service_role;
1196
+ grant execute on function agent_board.resolve_board_request(uuid, uuid, text, uuid, jsonb)
1197
+ to authenticated, service_role;
1198
+ grant execute on function agent_board.update_board_membership(uuid, uuid, text, jsonb)
1199
+ to authenticated, service_role;
1200
+ grant execute on function agent_board.list_board_pending_memberships(uuid)
1201
+ to authenticated, service_role;