@consiliency/agent-board-schema 1.59.0 → 1.63.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 (73) hide show
  1. package/README.md +80 -0
  2. package/fixtures/already-applied.json +16 -0
  3. package/migrations/20261010000100_agent_board_own_post_not_delivered.sql +637 -0
  4. package/migrations/20261011000100_agent_board_session_identities.sql +1964 -0
  5. package/migrations/20261012000100_agent_board_sidowner.sql +209 -0
  6. package/migrations/20261014000100_agent_board_grant_target_role.sql +218 -0
  7. package/package.json +1 -1
  8. package/schema-manifest.json +39 -3
  9. package/src/tooling.test.mjs +9 -2
  10. package/tests/agent_board_addressing.test.sql +3 -1
  11. package/tests/agent_board_atomic_fleet_onboarding.test.sql +13 -4
  12. package/tests/agent_board_consent_emission.test.sql +30 -11
  13. package/tests/agent_board_consent_gating.test.sql +16 -2
  14. package/tests/agent_board_delegate_role_class.test.sql +1 -1
  15. package/tests/agent_board_delegation_cap_ceiling.test.sql +26 -15
  16. package/tests/agent_board_delegation_enforcement.test.sql +1 -1
  17. package/tests/agent_board_enroll_resumption.test.sql +22 -4
  18. package/tests/agent_board_enroll_token_single_use.test.sql +18 -3
  19. package/tests/agent_board_federated_lock_order_class.test.sql +2 -2
  20. package/tests/agent_board_fk_lock_edges.test.sql +74 -14
  21. package/tests/agent_board_grant_target_role.test.sql +1118 -0
  22. package/tests/agent_board_inbox_rpcs.test.sql +5 -2
  23. package/tests/agent_board_invite_no_downgrade.test.sql +10 -8
  24. package/tests/agent_board_ledger_authority.test.sql +70 -12
  25. package/tests/agent_board_membership_current_row_class.test.sql +9 -1
  26. package/tests/agent_board_mint_fleet_agent_session.test.sql +2 -2
  27. package/tests/agent_board_mint_harden.test.sql +38 -27
  28. package/tests/agent_board_own_post_not_delivered.test.sql +490 -0
  29. package/tests/agent_board_rolereset.test.sql +8 -2
  30. package/tests/agent_board_roomdeleg_lane_b.test.sql +6 -6
  31. package/tests/agent_board_rowid.test.sql +3 -1
  32. package/tests/agent_board_session_identity_approval_revocation.test.sql +334 -0
  33. package/tests/agent_board_session_identity_backfill.test.sql +82 -0
  34. package/tests/agent_board_session_identity_backfill_refusal.test.sql +14 -0
  35. package/tests/agent_board_session_identity_behavior_classes.test.sql +176 -0
  36. package/tests/agent_board_session_identity_class.test.sql +118 -0
  37. package/tests/agent_board_session_identity_fingerprint.test.sql +271 -0
  38. package/tests/agent_board_session_identity_labels_limits.test.sql +205 -0
  39. package/tests/agent_board_session_identity_lease.test.sql +235 -0
  40. package/tests/agent_board_session_identity_lifetime.test.sql +230 -0
  41. package/tests/agent_board_session_identity_limits.test.sql +208 -0
  42. package/tests/agent_board_session_identity_lineage.test.sql +64 -0
  43. package/tests/agent_board_session_identity_link.test.sql +201 -0
  44. package/tests/agent_board_session_identity_mint.test.sql +233 -0
  45. package/tests/agent_board_session_identity_panel_r1.test.sql +194 -0
  46. package/tests/agent_board_session_identity_panel_r2.test.sql +174 -0
  47. package/tests/agent_board_session_identity_protected_actions.test.sql +156 -0
  48. package/tests/agent_board_session_identity_revoke_closure.test.sql +131 -0
  49. package/tests/agent_board_sidowner_leases.test.sql +107 -0
  50. package/tests/agent_board_sidowner_owner_required.test.sql +97 -0
  51. package/tests/agent_board_sidowner_read_class.test.sql +171 -0
  52. package/tests/agent_board_sidowner_reads.test.sql +136 -0
  53. package/tests/agent_board_signer_lifecycle.test.sql +26 -4
  54. package/tests/agent_board_signing_challenge_pending_cap.test.sql +1 -1
  55. package/tests/agent_board_v2_invariants.test.sql +4 -4
  56. package/tests/agent_board_write_enforcement_class.test.sql +74 -15
  57. package/tests/catalog/function_execute_privileges.json +218 -2
  58. package/tests/catalog/p_metadata_parameters.json +98 -2
  59. package/tests/isolation/expected/grant_target_role_race.out +348 -0
  60. package/tests/isolation/expected/session_identity_limits.out +165 -0
  61. package/tests/isolation/expected/session_identity_links.out +235 -0
  62. package/tests/isolation/expected/session_identity_reclaim.out +253 -0
  63. package/tests/isolation/expected/session_identity_renew_revoke.out +115 -0
  64. package/tests/isolation/expected/session_identity_renew_revoke_key_gate.out +138 -0
  65. package/tests/isolation/expected/session_identity_tombstone_onboarding.out +247 -0
  66. package/tests/isolation/specs/grant_target_role_race.spec +207 -0
  67. package/tests/isolation/specs/session_identity_limits.spec +81 -0
  68. package/tests/isolation/specs/session_identity_links.spec +80 -0
  69. package/tests/isolation/specs/session_identity_reclaim.spec +125 -0
  70. package/tests/isolation/specs/session_identity_renew_revoke.spec +84 -0
  71. package/tests/isolation/specs/session_identity_renew_revoke_key_gate.spec +94 -0
  72. package/tests/isolation/specs/session_identity_tombstone_onboarding.spec +108 -0
  73. package/tests/seeds/20261011000100_agent_board_session_identities.sql.pre.sql +47 -0
@@ -0,0 +1,637 @@
1
+ -- OWN POST NOT DELIVERED (Consiliency/message-board#608): an agent does not receive its own room
2
+ -- post, or its own task or multicast fanned out to a set it belongs to, as an inbox item. The owner's
3
+ -- rule (2026-10-03): an agent's own writes must not reach its inbox and must not wake it. Measured
4
+ -- before this migration: dev0-claude posted to mb-test-board, its own post appeared in its own
5
+ -- v_agent_wake_recovery, and its wake hook fired for it. schema_version stays 8.
6
+ --
7
+ -- THE CLASS, FROM THE CATALOG. agent_board.inbox_items has exactly one writer,
8
+ -- _upsert_inbox_item, and four callers: send_direct_message, send_multicast_message,
9
+ -- post_board_message and create_tasking_delivery (pg_proc). A recipient set reaches the actor
10
+ -- without the actor naming itself in two places, both fixed here:
11
+ -- * a BOARD expansion: post_board_message and create_tasking_delivery's board loop (the actor's
12
+ -- row is kept, feed-only, in both -- see below);
13
+ -- * a DESCRIPTOR expansion: group_alias and role_selector, resolved by
14
+ -- _addressing_agent_ids_or_raise for every descriptor overload and signed path (section 4).
15
+ -- Naming yourself -- a uuid list, or an agent_id / agent_handle / human_handle descriptor -- is an
16
+ -- explicit act and still delivers. agent_board_own_post_not_delivered.test.sql derives both sets
17
+ -- from pg_proc and asserts every member behaviourally, so a new site fails it by name.
18
+ --
19
+ -- WHY THE AUTHOR'S BOARD ROW IS KEPT, FEED-ONLY. v_board_feed is built from inbox_items rows with
20
+ -- visible_in_board_feed = true (any member's). Dropping the author's row would drop a post on a
21
+ -- board whose only member is its author from that board's feed. inbox_items_visibility_shape
22
+ -- already admits visible_in_inbox = false with visible_in_board_feed = true, and every inbox
23
+ -- surface -- v_agent_inbox, v_agent_wake_recovery (through v_agent_inbox), v_agent_inbox_counts,
24
+ -- v_agent_dashboard -- filters on visible_in_inbox, and v_fleet_delivery_posture reports such a row
25
+ -- as not actionable. v_board_directory's activity reads visible_in_board_feed, which is unchanged.
26
+ -- v_thread_feed reads neither inbox_items nor delivery_recipients. Read receipts (sent / delivered / read per
27
+ -- recipient, the owner's stated next goal) need RECIPIENT rows; the author's feed-only row is
28
+ -- distinguishable from them by visible_in_inbox and is not counted in delivered_recipient_count.
29
+ --
30
+ -- CALLBACKS. _queue_inbox_callbacks now queues nothing for a feed-only row, for every caller: an
31
+ -- own_inbox callback is about the inbox. The board-level board.post.delivered callbacks
32
+ -- (_queue_board_post_callbacks) are a board subscription, not an inbox delivery, and are unchanged.
33
+ --
34
+ -- NO BACKFILL. Author rows written before this migration keep visible_in_inbox = true. An author
35
+ -- that has acknowledged them is unaffected; a standing one is handled like any other item.
36
+ --
37
+ -- Sources, each at its LATEST definition, read from the catalog of a replay of this tree
38
+ -- (pg_get_functiondef):
39
+ -- * post_board_message -- 20260803000100
40
+ -- * create_tasking_delivery -- 20260803000100
41
+ -- * _queue_inbox_callbacks -- 20260506000000
42
+ -- * _addressing_agent_ids_or_raise -- 20261008000100
43
+ -- Each is redefined below VERBATIM plus the lines marked `#608`. Same signatures, so create or
44
+ -- replace keeps each function's ACL; none carries a COMMENT.
45
+
46
+ -- =============================================================================
47
+ -- 1. post_board_message: the author row is feed-only and not counted
48
+ -- =============================================================================
49
+ 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)
50
+ RETURNS TABLE(message_id uuid, board_id uuid, delivered_recipient_count integer, callback_outbox_count integer, created_at timestamp with time zone)
51
+ LANGUAGE plpgsql
52
+ SECURITY DEFINER
53
+ SET search_path TO 'agent_board', 'public'
54
+ AS $function$
55
+ declare
56
+ v_message agent_board.messages%rowtype;
57
+ v_created_message record;
58
+ v_membership record;
59
+ v_recipient agent_board.delivery_recipients%rowtype;
60
+ v_inbox agent_board.inbox_items%rowtype;
61
+ v_delivered_count integer := 0;
62
+ v_callback_count integer := 0;
63
+ v_metadata jsonb;
64
+ v_preview text;
65
+ v_fetch_descriptor jsonb;
66
+ v_idempotency_key text := nullif(trim(p_idempotency_key), '');
67
+ v_actor_agent_id uuid;
68
+ v_is_author boolean;
69
+ begin
70
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_author_agent_id, 'p_author_agent_id');
71
+
72
+ if nullif(trim(p_subject), '') is null or nullif(trim(p_body), '') is null then
73
+ raise exception 'subject and body are required' using errcode = '22023';
74
+ end if;
75
+ if p_priority not in ('low', 'medium', 'high', 'urgent') then
76
+ raise exception 'invalid priority' using errcode = '22023';
77
+ end if;
78
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
79
+ raise exception 'thread not found' using errcode = 'P0002';
80
+ end if;
81
+ if not exists (select 1 from agent_board.boards where id = p_board_id) then
82
+ raise exception 'board not found' using errcode = 'P0002';
83
+ end if;
84
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
85
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
86
+ end if;
87
+ if not agent_board._current_agent_has_board_access(p_board_id) then
88
+ raise exception 'board not accessible to current agent' using errcode = '42501';
89
+ end if;
90
+ if not agent_board._current_agent_can_send_to_board(p_board_id) then
91
+ raise exception 'board send requires a non-observer board role' using errcode = '42501';
92
+ end if;
93
+
94
+ if agent_board._is_service_role() then
95
+ perform agent_board._record_send_authz_telemetry(
96
+ 'service_role_bypass',
97
+ coalesce(nullif(p_metadata -> 'signed_write' ->> 'origin', ''), 'unknown'),
98
+ v_actor_agent_id,
99
+ p_board_id,
100
+ 'post_board_message',
101
+ agent_board._resolve_send_fleet_scope(v_actor_agent_id),
102
+ null,
103
+ jsonb_build_object('redacted', true)
104
+ );
105
+ end if;
106
+
107
+ perform agent_board._lock_idempotency_key(
108
+ 'post_board_message:' || p_thread_id::text || ':' || v_actor_agent_id::text || ':' || p_board_id::text,
109
+ v_idempotency_key
110
+ );
111
+
112
+ if v_idempotency_key is not null then
113
+ select *
114
+ into v_message
115
+ from agent_board.messages
116
+ where thread_id = p_thread_id
117
+ and author_agent_id = v_actor_agent_id
118
+ and metadata ->> 'rpcName' = 'post_board_message'
119
+ and metadata ->> 'idempotencyKey' = v_idempotency_key
120
+ order by created_at desc, id desc
121
+ limit 1;
122
+ end if;
123
+
124
+ if v_message.id is null then
125
+ v_metadata := coalesce(p_metadata, '{}'::jsonb)
126
+ || jsonb_build_object(
127
+ 'rpcName', 'post_board_message',
128
+ 'subject', trim(p_subject),
129
+ 'priority', p_priority,
130
+ 'boardId', p_board_id::text
131
+ );
132
+ if v_idempotency_key is not null then
133
+ v_metadata := v_metadata || jsonb_build_object('idempotencyKey', v_idempotency_key);
134
+ end if;
135
+
136
+ select *
137
+ into v_created_message
138
+ from agent_board.post_message(
139
+ p_thread_id,
140
+ v_actor_agent_id,
141
+ p_body,
142
+ p_message_type,
143
+ null,
144
+ v_metadata
145
+ );
146
+
147
+ select *
148
+ into v_message
149
+ from agent_board.messages
150
+ where id = v_created_message.message_id;
151
+ end if;
152
+
153
+ v_preview := agent_board._trim_inbox_text(p_body, 280);
154
+ v_fetch_descriptor := agent_board._build_fetch_descriptor(
155
+ 'board',
156
+ p_board_id,
157
+ 'agent_board.v_board_feed',
158
+ 'board delivery',
159
+ jsonb_build_object('messageId', v_message.id::text)
160
+ );
161
+
162
+ for v_membership in
163
+ select bm.id, bm.agent_id
164
+ from agent_board.board_memberships bm
165
+ where bm.board_id = p_board_id
166
+ and bm.membership_state in ('joined', 'subscribed')
167
+ order by bm.created_at
168
+ loop
169
+ -- #608: the author is not a recipient of its own post. Its row stays, FEED-ONLY
170
+ -- (visible_in_inbox = false), because v_board_feed is built from these rows and a post on a
171
+ -- board whose only member is its author would otherwise vanish from the feed. A feed-only
172
+ -- row is outside v_agent_inbox, v_agent_wake_recovery and v_agent_inbox_counts by their own
173
+ -- `visible_in_inbox` filter, and _queue_inbox_callbacks queues nothing for it.
174
+ v_is_author := v_membership.agent_id is not distinct from v_actor_agent_id;
175
+ v_recipient := agent_board._upsert_delivery_recipient(
176
+ 'board',
177
+ v_membership.agent_id,
178
+ 'message',
179
+ v_message.id,
180
+ null,
181
+ null,
182
+ p_board_id,
183
+ v_membership.id,
184
+ jsonb_build_object('priority', p_priority)
185
+ );
186
+
187
+ v_inbox := agent_board._upsert_inbox_item(
188
+ v_recipient.id,
189
+ v_membership.agent_id,
190
+ p_board_id,
191
+ v_recipient.delivery_mode,
192
+ 'message',
193
+ v_message.id,
194
+ null,
195
+ null,
196
+ trim(p_subject),
197
+ v_preview,
198
+ p_priority,
199
+ not v_is_author,
200
+ true,
201
+ v_fetch_descriptor,
202
+ jsonb_build_object('threadId', p_thread_id::text, 'boardId', p_board_id::text)
203
+ );
204
+
205
+ -- delivered_recipient_count counts RECIPIENTS; the author's feed-only row is not one.
206
+ if not v_is_author then
207
+ v_delivered_count := v_delivered_count + 1;
208
+ end if;
209
+ v_callback_count := v_callback_count + agent_board._queue_inbox_callbacks(v_inbox.id, 'inbox.item.delivered');
210
+ end loop;
211
+
212
+ v_callback_count := v_callback_count + agent_board._queue_board_post_callbacks(
213
+ p_board_id,
214
+ v_message.id,
215
+ trim(p_subject),
216
+ v_preview,
217
+ p_priority,
218
+ v_fetch_descriptor
219
+ );
220
+
221
+ return query
222
+ select v_message.id, p_board_id, v_delivered_count, v_callback_count, v_message.created_at;
223
+ end;
224
+ $function$;
225
+
226
+ -- =============================================================================
227
+ -- 2. create_tasking_delivery: the creator's board fan-out row is feed-only and not counted
228
+ -- =============================================================================
229
+ 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)
230
+ 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)
231
+ LANGUAGE plpgsql
232
+ SECURITY DEFINER
233
+ SET search_path TO 'agent_board', 'public'
234
+ AS $function$
235
+ declare
236
+ v_task agent_board.tasks%rowtype;
237
+ v_created_event_id uuid;
238
+ v_created_task record;
239
+ v_recipient_ids uuid[];
240
+ v_recipient_id uuid;
241
+ v_membership record;
242
+ v_recipient agent_board.delivery_recipients%rowtype;
243
+ v_inbox agent_board.inbox_items%rowtype;
244
+ v_delivered_count integer := 0;
245
+ v_inbox_count integer := 0;
246
+ v_callback_count integer := 0;
247
+ v_metadata jsonb;
248
+ v_preview text;
249
+ v_fetch_descriptor jsonb;
250
+ v_idempotency_key text := nullif(trim(p_idempotency_key), '');
251
+ v_actor_agent_id uuid;
252
+ v_is_creator boolean;
253
+ begin
254
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
255
+
256
+ if nullif(trim(p_title), '') is null then
257
+ raise exception 'task title is required' using errcode = '22023';
258
+ end if;
259
+ if p_priority not in ('low', 'medium', 'high', 'urgent') then
260
+ raise exception 'invalid priority' using errcode = '22023';
261
+ end if;
262
+ if p_orchestration_mode not in ('distributed', 'hierarchical', 'hybrid') then
263
+ raise exception 'invalid orchestration mode' using errcode = '22023';
264
+ end if;
265
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
266
+ raise exception 'thread not found' using errcode = 'P0002';
267
+ end if;
268
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
269
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
270
+ end if;
271
+ if (p_recipient_agent_ids is null or cardinality(p_recipient_agent_ids) = 0) and p_board_id is null then
272
+ raise exception 'tasking delivery requires explicit recipients or a board target' using errcode = '22023';
273
+ end if;
274
+ if p_recipient_agent_ids is not null and array_position(p_recipient_agent_ids, null) is not null then
275
+ raise exception 'recipient_agent_ids must not contain nulls' using errcode = '22023';
276
+ end if;
277
+ if p_board_id is not null then
278
+ if not exists (select 1 from agent_board.boards where id = p_board_id) then
279
+ raise exception 'board not found' using errcode = 'P0002';
280
+ end if;
281
+ if not agent_board._current_agent_has_board_access(p_board_id) then
282
+ raise exception 'board not accessible to current agent' using errcode = '42501';
283
+ end if;
284
+ if not agent_board._current_agent_can_send_to_board(p_board_id) then
285
+ raise exception 'board send requires a non-observer board role' using errcode = '42501';
286
+ end if;
287
+ end if;
288
+
289
+ select array_agg(distinct recipient_id)
290
+ into v_recipient_ids
291
+ from unnest(coalesce(p_recipient_agent_ids, '{}'::uuid[])) as recipient_id;
292
+
293
+ if v_recipient_ids is not null and (
294
+ select count(*)
295
+ from agent_board.agents
296
+ where id = any(v_recipient_ids)
297
+ ) <> cardinality(v_recipient_ids) then
298
+ raise exception 'recipient agent not found' using errcode = 'P0002';
299
+ end if;
300
+
301
+ if agent_board._is_service_role() then
302
+ perform agent_board._record_send_authz_telemetry(
303
+ 'service_role_bypass',
304
+ coalesce(nullif(p_metadata -> 'signed_write' ->> 'origin', ''), 'unknown'),
305
+ v_actor_agent_id,
306
+ p_board_id,
307
+ 'create_tasking_delivery',
308
+ agent_board._resolve_send_fleet_scope(v_actor_agent_id),
309
+ null,
310
+ jsonb_build_object('redacted', true)
311
+ );
312
+ end if;
313
+
314
+ perform agent_board._lock_idempotency_key(
315
+ 'create_tasking_delivery:' || p_thread_id::text || ':' || v_actor_agent_id::text,
316
+ v_idempotency_key
317
+ );
318
+
319
+ if v_idempotency_key is not null then
320
+ select *
321
+ into v_task
322
+ from agent_board.tasks
323
+ where thread_id = p_thread_id
324
+ and created_by_agent_id = v_actor_agent_id
325
+ and metadata ->> 'rpcName' = 'create_tasking_delivery'
326
+ and metadata ->> 'idempotencyKey' = v_idempotency_key
327
+ order by created_at desc, id desc
328
+ limit 1;
329
+ end if;
330
+
331
+ if v_task.id is null then
332
+ v_metadata := coalesce(p_metadata, '{}'::jsonb)
333
+ || jsonb_build_object(
334
+ 'rpcName', 'create_tasking_delivery',
335
+ 'priority', p_priority
336
+ );
337
+ if p_board_id is not null then
338
+ v_metadata := v_metadata || jsonb_build_object('boardId', p_board_id::text);
339
+ end if;
340
+ if v_idempotency_key is not null then
341
+ v_metadata := v_metadata || jsonb_build_object('idempotencyKey', v_idempotency_key);
342
+ end if;
343
+
344
+ select *
345
+ into v_created_task
346
+ from agent_board.create_task(
347
+ p_thread_id,
348
+ v_actor_agent_id,
349
+ trim(p_title),
350
+ p_details,
351
+ null,
352
+ null,
353
+ p_priority,
354
+ p_orchestration_mode,
355
+ v_metadata
356
+ );
357
+
358
+ v_created_event_id := v_created_task.created_event_id;
359
+
360
+ select *
361
+ into v_task
362
+ from agent_board.tasks
363
+ where id = v_created_task.task_id;
364
+ else
365
+ select te.id
366
+ into v_created_event_id
367
+ from agent_board.task_events te
368
+ where te.task_id = v_task.id
369
+ and te.event_sequence = 1;
370
+ end if;
371
+
372
+ v_preview := agent_board._trim_inbox_text(coalesce(p_details, p_title), 280);
373
+ v_fetch_descriptor := agent_board._build_fetch_descriptor(
374
+ 'task',
375
+ v_task.id,
376
+ 'agent_board.v_task_tree',
377
+ 'tasking delivery',
378
+ jsonb_build_object('threadId', p_thread_id::text)
379
+ );
380
+
381
+ if v_recipient_ids is not null then
382
+ foreach v_recipient_id in array v_recipient_ids loop
383
+ v_recipient := agent_board._upsert_delivery_recipient(
384
+ 'tasking',
385
+ v_recipient_id,
386
+ 'task',
387
+ null,
388
+ v_task.id,
389
+ null,
390
+ null,
391
+ null,
392
+ jsonb_build_object('priority', p_priority)
393
+ );
394
+
395
+ v_inbox := agent_board._upsert_inbox_item(
396
+ v_recipient.id,
397
+ v_recipient_id,
398
+ null,
399
+ v_recipient.delivery_mode,
400
+ 'task',
401
+ null,
402
+ v_task.id,
403
+ null,
404
+ trim(p_title),
405
+ v_preview,
406
+ p_priority,
407
+ true,
408
+ false,
409
+ v_fetch_descriptor,
410
+ jsonb_build_object('threadId', p_thread_id::text)
411
+ );
412
+
413
+ v_delivered_count := v_delivered_count + 1;
414
+ v_inbox_count := v_inbox_count + 1;
415
+ v_callback_count := v_callback_count + agent_board._queue_inbox_callbacks(v_inbox.id, 'inbox.item.delivered');
416
+ end loop;
417
+ end if;
418
+
419
+ if p_board_id is not null then
420
+ for v_membership in
421
+ select bm.id, bm.agent_id
422
+ from agent_board.board_memberships bm
423
+ where bm.board_id = p_board_id
424
+ and bm.membership_state in ('joined', 'subscribed')
425
+ order by bm.created_at
426
+ loop
427
+ -- #608: the board fan-out does not deliver a task to the agent that created it. The
428
+ -- creator's row is KEPT, feed-only, exactly as a board post's author row is: its
429
+ -- _upsert_delivery_recipient is what stamps threads.board_id (OBSGATE #78), so skipping it
430
+ -- left a creator-only board's thread unlinked and post_message on the access-only branch of
431
+ -- the observer gate (codex and grok seats, PR #615 round 1). It also keeps the board
432
+ -- directory's activity and delegate visibility as they were. An explicit recipient list
433
+ -- naming the creator still delivers to it: that is the caller addressing itself, and the
434
+ -- board loop must not then demote the row the explicit loop wrote. It shares that row (the
435
+ -- delivery upsert keeps the explicit row's null board_id), so demoting it wrote both
436
+ -- visibility flags false and violated inbox_items_visibility_shape, aborting the whole task
437
+ -- (every seat on PR #615 round 2).
438
+ v_is_creator := v_membership.agent_id is not distinct from v_actor_agent_id
439
+ and not (v_membership.agent_id = any(coalesce(v_recipient_ids, '{}'::uuid[])));
440
+ v_recipient := agent_board._upsert_delivery_recipient(
441
+ 'tasking',
442
+ v_membership.agent_id,
443
+ 'task',
444
+ null,
445
+ v_task.id,
446
+ null,
447
+ p_board_id,
448
+ v_membership.id,
449
+ jsonb_build_object('priority', p_priority)
450
+ );
451
+
452
+ v_inbox := agent_board._upsert_inbox_item(
453
+ v_recipient.id,
454
+ v_membership.agent_id,
455
+ v_recipient.board_id,
456
+ v_recipient.delivery_mode,
457
+ 'task',
458
+ null,
459
+ v_task.id,
460
+ null,
461
+ trim(p_title),
462
+ v_preview,
463
+ p_priority,
464
+ not v_is_creator,
465
+ v_recipient.board_id is not null,
466
+ v_fetch_descriptor,
467
+ jsonb_build_object('threadId', p_thread_id::text, 'boardId', p_board_id::text)
468
+ );
469
+
470
+ -- The counts are of RECIPIENTS; the creator's feed-only row is not one.
471
+ if not v_is_creator then
472
+ v_delivered_count := v_delivered_count + 1;
473
+ v_inbox_count := v_inbox_count + 1;
474
+ end if;
475
+ v_callback_count := v_callback_count + agent_board._queue_inbox_callbacks(v_inbox.id, 'inbox.item.delivered');
476
+ end loop;
477
+ end if;
478
+
479
+ return query
480
+ 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;
481
+ end;
482
+ $function$;
483
+
484
+ -- =============================================================================
485
+ -- 3. _queue_inbox_callbacks: nothing for a feed-only row
486
+ -- =============================================================================
487
+ create or replace function agent_board._queue_inbox_callbacks(p_inbox_item_id uuid, p_event_category text)
488
+ RETURNS integer
489
+ LANGUAGE plpgsql
490
+ SECURITY DEFINER
491
+ SET search_path TO 'agent_board', 'public'
492
+ AS $function$
493
+ declare
494
+ v_inserted_count integer := 0;
495
+ begin
496
+ if p_event_category not in ('inbox.item.delivered', 'inbox.item.updated') then
497
+ raise exception 'unsupported inbox callback event category' using errcode = '22023';
498
+ end if;
499
+
500
+ with inserted as (
501
+ insert into agent_board.callback_outbox_events(
502
+ endpoint_id,
503
+ subscription_id,
504
+ recipient_agent_id,
505
+ inbox_item_id,
506
+ board_id,
507
+ event_category,
508
+ delivery_mode,
509
+ source_kind,
510
+ source_message_id,
511
+ source_task_id,
512
+ source_delegation_id,
513
+ idempotency_key,
514
+ subject,
515
+ preview,
516
+ priority,
517
+ fetch_descriptor,
518
+ metadata
519
+ )
520
+ select
521
+ cs.endpoint_id,
522
+ cs.id,
523
+ ii.recipient_agent_id,
524
+ ii.id,
525
+ null,
526
+ p_event_category,
527
+ ii.delivery_mode,
528
+ ii.source_kind,
529
+ ii.source_message_id,
530
+ ii.source_task_id,
531
+ ii.source_delegation_id,
532
+ case
533
+ when p_event_category = 'inbox.item.delivered' then
534
+ p_event_category || ':' || ii.id::text || ':' || cs.id::text
535
+ else
536
+ p_event_category || ':' || ii.id::text || ':' || cs.id::text || ':' || replace(ii.updated_at::text, ' ', 'T') || ':' || ii.action_state
537
+ end,
538
+ ii.subject,
539
+ ii.preview,
540
+ ii.priority,
541
+ ii.fetch_descriptor,
542
+ jsonb_build_object(
543
+ 'subscriptionScope', cs.subscription_scope,
544
+ 'subscriptionStatus', cs.subscription_status,
545
+ 'actionState', ii.action_state
546
+ )
547
+ from agent_board.inbox_items ii
548
+ join agent_board.callback_subscriptions cs
549
+ on cs.agent_id = ii.recipient_agent_id
550
+ and cs.subscription_scope = 'own_inbox'
551
+ and cs.subscription_status = 'active'
552
+ and cs.event_category = p_event_category
553
+ join agent_board.callback_endpoints ce
554
+ on ce.id = cs.endpoint_id
555
+ and ce.owner_agent_id = cs.agent_id
556
+ and ce.endpoint_status = 'verified'
557
+ where ii.id = p_inbox_item_id
558
+ -- #608: an own_inbox callback is about the inbox. A feed-only row (the author's own board
559
+ -- post) is not in it, so it queues nothing -- for every caller, not just post_board_message.
560
+ and ii.visible_in_inbox
561
+ on conflict (idempotency_key) do nothing
562
+ returning 1
563
+ )
564
+ select count(*) into v_inserted_count from inserted;
565
+
566
+ return v_inserted_count;
567
+ end;
568
+ $function$;
569
+
570
+ -- =============================================================================
571
+ -- 4. _addressing_agent_ids_or_raise: a set-naming descriptor does not reach the actor
572
+ -- =============================================================================
573
+ -- Found by the correctness seat on PR #615 round 1: group_alias and role_selector expand to every
574
+ -- matching active agent, the actor included, and the descriptor overloads of send_multicast_message
575
+ -- and create_tasking_delivery (and the signed paths) pass that set on as an explicit list. So the
576
+ -- board fan-out was not the only expansion: this is the other one, two calls before
577
+ -- _upsert_inbox_item, which is why a writer-caller scan did not see it.
578
+ create or replace function agent_board._addressing_agent_ids_or_raise(p_actor_agent_id uuid, p_recipient_descriptors jsonb)
579
+ RETURNS uuid[]
580
+ LANGUAGE plpgsql
581
+ SECURITY DEFINER
582
+ SET search_path TO 'agent_board', 'public'
583
+ AS $function$
584
+ declare
585
+ v_result record;
586
+ v_ids uuid[];
587
+ v_actor uuid;
588
+ begin
589
+ select * into v_result
590
+ from agent_board.resolve_recipients(p_actor_agent_id, p_recipient_descriptors);
591
+
592
+ -- HOSTED (D16, NEW-4): naming a hosted delegate refuses the whole send as an authorization
593
+ -- failure, before any delivery is written.
594
+ if exists (
595
+ select 1
596
+ from jsonb_array_elements(v_result."deniedRecipients") d
597
+ where d ->> 'reason' = 'recipient_is_hosted_delegate'
598
+ ) then
599
+ raise exception 'a hosted delegate is reached only through a board it is on' using errcode = '42501', detail = v_result."deniedRecipients"::text;
600
+ end if;
601
+
602
+ if jsonb_array_length(v_result."deniedRecipients") > 0 then
603
+ raise exception 'recipient descriptor resolution failed' using errcode = '22023', detail = v_result."deniedRecipients"::text;
604
+ end if;
605
+
606
+ select array_agg((value ->> 'agentId')::uuid)
607
+ into v_ids
608
+ from jsonb_array_elements(v_result."resolvedRecipients") as value;
609
+
610
+ if v_ids is null or cardinality(v_ids) = 0 then
611
+ raise exception 'recipient descriptors resolved no recipients' using errcode = '22023';
612
+ end if;
613
+
614
+ -- #608: a descriptor that names a SET (group_alias, role_selector, and any kind added later)
615
+ -- does not deliver to the actor; only a descriptor that names the actor itself does (agent_id,
616
+ -- agent_handle, human_handle, directly or inside an explicit_set). The decision reads every
617
+ -- resolved row, not resolve_recipients' collapsed set, whose `distinct on` keeps the FIRST
618
+ -- descriptor for an agent: a group listed before an explicit self-mention would otherwise hide
619
+ -- the explicit one. The allowlist is of EXPLICIT kinds on purpose: a new set kind defaults to
620
+ -- not delivering an agent's own message to it. The actor is bound as resolve_recipients binds it.
621
+ v_actor := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
622
+ if v_actor = any(v_ids)
623
+ and not exists (
624
+ select 1
625
+ from agent_board._resolve_addressing_recipient_rows(v_actor, p_recipient_descriptors) r
626
+ where r.recipient_agent_id = v_actor
627
+ and r.descriptor ->> 'kind' in ('agent_id', 'agent_handle', 'human_handle')
628
+ ) then
629
+ v_ids := array_remove(v_ids, v_actor);
630
+ if cardinality(v_ids) = 0 then
631
+ raise exception 'recipient descriptors resolved only the sender' using errcode = '22023';
632
+ end if;
633
+ end if;
634
+
635
+ return v_ids;
636
+ end;
637
+ $function$;