@consiliency/agent-board-schema 1.68.0 → 1.74.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 (155) hide show
  1. package/fixtures/already-applied.json +28 -4
  2. package/migrations/20261026000100_agent_board_thread_association.sql +1287 -0
  3. package/migrations/20261101000100_agent_board_session_measured_mint.sql +145 -0
  4. package/migrations/20261104065658_agent_board_reply_delivery.sql +659 -0
  5. package/migrations/20261104170000_agent_board_tasking_replay_scope.sql +320 -0
  6. package/migrations/20261104180000_agent_board_delivery_writer_execute.sql +51 -0
  7. package/migrations/20261104190000_agent_board_message_replay_scope.sql +373 -0
  8. package/package.json +1 -1
  9. package/schema-manifest.json +57 -3
  10. package/src/tooling.test.mjs +8 -2
  11. package/tests/agent_board_661_reply_delivery.test.sql +185 -0
  12. package/tests/agent_board_667_boundary.test.sql +162 -0
  13. package/tests/agent_board_667_conversion_branches.test.sql +91 -0
  14. package/tests/agent_board_667_conversion_profile.test.sql +112 -0
  15. package/tests/agent_board_667_history_class.test.sql +785 -0
  16. package/tests/agent_board_667_native_cast_effect.test.sql +88 -0
  17. package/tests/agent_board_667_realtime_apply_behavior.test.sql +117 -0
  18. package/tests/agent_board_667_realtime_apply_controls.test.sql +143 -0
  19. package/tests/agent_board_667_realtime_apply_profile.test.sql +61 -0
  20. package/tests/agent_board_667_retention.test.sql +201 -0
  21. package/tests/agent_board_667_routing.test.sql +294 -0
  22. package/tests/agent_board_667_sender_effects.test.sql +100 -0
  23. package/tests/agent_board_667_sender_profile_controls.test.sql +29 -0
  24. package/tests/agent_board_667_storage_delimiter_behavior.test.sql +84 -0
  25. package/tests/agent_board_667_storage_delimiter_controls.test.sql +23 -0
  26. package/tests/agent_board_667_storage_delimiter_profile.test.sql +61 -0
  27. package/tests/agent_board_667_storage_search_behavior.test.sql +93 -0
  28. package/tests/agent_board_667_storage_search_controls.test.sql +22 -0
  29. package/tests/agent_board_667_storage_search_profile.test.sql +60 -0
  30. package/tests/agent_board_667_storage_timestamp_behavior.test.sql +58 -0
  31. package/tests/agent_board_667_storage_timestamp_controls.test.sql +22 -0
  32. package/tests/agent_board_667_storage_timestamp_profile.test.sql +54 -0
  33. package/tests/agent_board_667_writers.test.sql +281 -0
  34. package/tests/agent_board_717_message_replay_scope.test.sql +828 -0
  35. package/tests/agent_board_717_tasking_replay_scope.test.sql +661 -0
  36. package/tests/agent_board_addressing.test.sql +8 -5
  37. package/tests/agent_board_claim_subject_gate.test.sql +7 -3
  38. package/tests/agent_board_claimpolicy_race.test.sql +39 -11
  39. package/tests/agent_board_delegate_role_class.test.sql +33 -15
  40. package/tests/agent_board_delivery_writer_execute.test.sql +371 -0
  41. package/tests/agent_board_fk_lock_edges.test.sql +3 -0
  42. package/tests/agent_board_hosted_delegates.test.sql +5 -2
  43. package/tests/agent_board_inbox_rpcs.test.sql +5 -3
  44. package/tests/agent_board_obsgate.test.sql +8 -4
  45. package/tests/agent_board_obsgate4.test.sql +2 -2
  46. package/tests/agent_board_outbox_wake_ordering.test.sql +25 -2
  47. package/tests/agent_board_own_post_not_delivered.test.sql +11 -9
  48. package/tests/agent_board_session_identity_behavior_classes.test.sql +2 -2
  49. package/tests/agent_board_session_identity_limits.test.sql +1 -1
  50. package/tests/agent_board_session_identity_measured_mint.test.sql +342 -0
  51. package/tests/agent_board_session_identity_predicate_isolation.test.sql +146 -0
  52. package/tests/agent_board_sidserver_authority.test.sql +69 -0
  53. package/tests/agent_board_sidserver_catalog.test.sql +47 -0
  54. package/tests/agent_board_signed_write_gateway.test.sql +4 -2
  55. package/tests/agent_board_task_thread_lock_order.test.sql +67 -42
  56. package/tests/agent_board_v2_invariants.test.sql +5 -2
  57. package/tests/agent_board_write_enforcement_class.test.sql +3 -3
  58. package/tests/catalog/function_execute_privileges.json +41 -5
  59. package/tests/catalog/p_metadata_parameters.json +16 -0
  60. package/tests/fixtures/agent_board_667_apply_rls_guard.sql +195 -0
  61. package/tests/fixtures/agent_board_667_apply_rls_provenance.md +125 -0
  62. package/tests/fixtures/agent_board_667_apply_rls_reference.sql +1903 -0
  63. package/tests/fixtures/agent_board_667_conversion_guard.sql +162 -0
  64. package/tests/fixtures/agent_board_667_conversion_reference.md +23 -0
  65. package/tests/fixtures/agent_board_667_conversion_reference.sql +5325 -0
  66. package/tests/fixtures/agent_board_667_delimiter_guard.sql +92 -0
  67. package/tests/fixtures/agent_board_667_delimiter_provenance.md +116 -0
  68. package/tests/fixtures/agent_board_667_delimiter_reference.sql +10150 -0
  69. package/tests/fixtures/agent_board_667_search_guard.sql +92 -0
  70. package/tests/fixtures/agent_board_667_search_provenance.md +141 -0
  71. package/tests/fixtures/agent_board_667_search_reference.sql +4779 -0
  72. package/tests/fixtures/agent_board_667_sender_guard.sql +102 -0
  73. package/tests/fixtures/agent_board_667_sender_reference.md +20 -0
  74. package/tests/fixtures/agent_board_667_sender_reference.sql +875 -0
  75. package/tests/fixtures/agent_board_667_timestamp_graph.sql +84 -0
  76. package/tests/fixtures/agent_board_667_timestamp_guard.sql +94 -0
  77. package/tests/fixtures/agent_board_667_timestamp_provenance.md +99 -0
  78. package/tests/fixtures/agent_board_667_timestamp_reference.sql +2938 -0
  79. package/tests/isolation/expected/association_artifact_anchor_order.out +108 -0
  80. package/tests/isolation/expected/association_board_vs_board.out +71 -0
  81. package/tests/isolation/expected/association_post_wait_authority.out +77 -0
  82. package/tests/isolation/expected/association_strong_history_writers.out +203 -0
  83. package/tests/isolation/expected/association_strong_isolation.out +185 -0
  84. package/tests/isolation/expected/association_vs_artifact_claim_escalation.out +199 -0
  85. package/tests/isolation/expected/association_vs_heartbeat.out +78 -0
  86. package/tests/isolation/expected/association_vs_multi_anchor_artifact.out +67 -0
  87. package/tests/isolation/expected/association_vs_private_message.out +67 -0
  88. package/tests/isolation/expected/association_vs_task_and_conflict.out +133 -0
  89. package/tests/isolation/expected/association_vs_taskful_cross_thread.out +133 -0
  90. package/tests/isolation/expected/message_replay_scope.out +209 -0
  91. package/tests/isolation/expected/tasking_replay_scope.out +125 -0
  92. package/tests/isolation/specs/association_artifact_anchor_order.spec +46 -0
  93. package/tests/isolation/specs/association_board_vs_board.spec +37 -0
  94. package/tests/isolation/specs/association_post_wait_authority.spec +48 -0
  95. package/tests/isolation/specs/association_strong_history_writers.spec +61 -0
  96. package/tests/isolation/specs/association_strong_isolation.spec +50 -0
  97. package/tests/isolation/specs/association_vs_artifact_claim_escalation.spec +41 -0
  98. package/tests/isolation/specs/association_vs_heartbeat.spec +47 -0
  99. package/tests/isolation/specs/association_vs_multi_anchor_artifact.spec +38 -0
  100. package/tests/isolation/specs/association_vs_private_message.spec +33 -0
  101. package/tests/isolation/specs/association_vs_task_and_conflict.spec +38 -0
  102. package/tests/isolation/specs/association_vs_taskful_cross_thread.spec +42 -0
  103. package/tests/isolation/specs/message_replay_scope.spec +77 -0
  104. package/tests/isolation/specs/tasking_replay_scope.spec +72 -0
  105. package/tests/mutations/717-f1/M0_prefix_main_acl_and_comments.sql +11 -0
  106. package/tests/mutations/717-f1/M10_wrapper_stops_delegating.sql +7 -0
  107. package/tests/mutations/717-f1/M11_transitive_recipient_content.sql +8 -0
  108. package/tests/mutations/717-f1/M12_transitive_recipient_visibility.sql +8 -0
  109. package/tests/mutations/717-f1/M13_transitive_worker_content.sql +15 -0
  110. package/tests/mutations/717-f1/M1a_regrant_upsert_delivery_recipient.sql +1 -0
  111. package/tests/mutations/717-f1/M1b_regrant_upsert_inbox_item.sql +1 -0
  112. package/tests/mutations/717-f1/M1c_regrant_queue_inbox_callbacks.sql +1 -0
  113. package/tests/mutations/717-f1/M1d_regrant_queue_board_post_callbacks.sql +1 -0
  114. package/tests/mutations/717-f1/M1e_regrant_next_thread_message_sequence.sql +1 -0
  115. package/tests/mutations/717-f1/M2_blanket_schema_grant.sql +1 -0
  116. package/tests/mutations/717-f1/M3_new_overload_of_sink.sql +10 -0
  117. package/tests/mutations/717-f1/M4_new_route_calling_sink.sql +7 -0
  118. package/tests/mutations/717-f1/M5_actor_trusts_supplied_id.sql +5 -0
  119. package/tests/mutations/717-f1/M6_signed_route_granted_to_authenticated.sql +1 -0
  120. package/tests/mutations/717-f1/M7_worker_granted_to_authenticated.sql +1 -0
  121. package/tests/mutations/717-f1/M8_recipient_state_rewrites_visibility.sql +5 -0
  122. package/tests/mutations/717-f1/M9_public_route_revoked_from_service_role.sql +1 -0
  123. package/tests/mutations/717-f1/outbox_wake_ordering/W1_regrant_queue_inbox_callbacks.sql +1 -0
  124. package/tests/mutations/717-f1/outbox_wake_ordering/W2_revoke_worker_from_service_role.sql +1 -0
  125. package/tests/mutations/717-f1/outbox_wake_ordering/W3_invoker_caller_of_sink.sql +1 -0
  126. package/tests/mutations/717-f1/outbox_wake_ordering/W4_public_regrant_board_post_sink.sql +1 -0
  127. package/tests/mutations/717-f1/run.sh +17 -0
  128. package/tests/mutations/717-f2b/C1_new_delivery_route.sql +4 -0
  129. package/tests/mutations/717-f2b/C2_new_replay_lookup.sql +3 -0
  130. package/tests/mutations/717-f2b/C3_internal_regranted.sql +2 -0
  131. package/tests/mutations/717-f2b/C4_wrapper_writes_delivery.sql +9 -0
  132. package/tests/mutations/717-f2b/C5_class_member_dynamic_sql.sql +9 -0
  133. package/tests/mutations/717-f2b/C6_drop_board_scope_constraint.sql +2 -0
  134. package/tests/mutations/717-f2b/C7_state_private_helper_content.sql +14 -0
  135. package/tests/mutations/717-f2b/L1_dm_lock_split_by_recipient.sql +8 -0
  136. package/tests/mutations/717-f2b/L2_dm_lock_removed.sql +12 -0
  137. package/tests/mutations/717-f2b/L3_mc_lock_split_by_recipients.sql +8 -0
  138. package/tests/mutations/717-f2b/L4_mc_lock_removed.sql +12 -0
  139. package/tests/mutations/717-f2b/M0_prefix_definitions.sql +17 -0
  140. package/tests/mutations/717-f2b/M10_mc_lookup_any_author.sql +15 -0
  141. package/tests/mutations/717-f2b/M11_dm_swallow_refusal.sql +8 -0
  142. package/tests/mutations/717-f2b/M12_mc_swallow_refusal.sql +8 -0
  143. package/tests/mutations/717-f2b/M13_signed_dm_swallows_refusal.sql +32 -0
  144. package/tests/mutations/717-f2b/M14_signed_mc_swallows_refusal.sql +20 -0
  145. package/tests/mutations/717-f2b/M15_mc_any_source.sql +7 -0
  146. package/tests/mutations/717-f2b/M1_dm_drop_recipient_proof.sql +16 -0
  147. package/tests/mutations/717-f2b/M2_mc_drop_recipient_proof.sql +16 -0
  148. package/tests/mutations/717-f2b/M3_dm_metadata_as_proof.sql +12 -0
  149. package/tests/mutations/717-f2b/M4_mc_metadata_as_proof.sql +16 -0
  150. package/tests/mutations/717-f2b/M5_dm_any_mode.sql +11 -0
  151. package/tests/mutations/717-f2b/M6_dm_board_delivery_counts.sql +11 -0
  152. package/tests/mutations/717-f2b/M7_mc_any_mode.sql +11 -0
  153. package/tests/mutations/717-f2b/M8_dm_thread_stamp_as_proof.sql +12 -0
  154. package/tests/mutations/717-f2b/M9_dm_lookup_any_author.sql +15 -0
  155. package/tests/mutations/717-f2b/run.sh +82 -0
@@ -0,0 +1,659 @@
1
+ -- #661: separate authorized insertion from public reply delivery.
2
+ -- Current-tree definitions replayed in an owned disposable database; no live catalog source.
3
+ CREATE OR REPLACE FUNCTION agent_board._insert_thread_message(p_thread_id uuid, p_author_agent_id uuid, p_body text, p_message_type text DEFAULT 'comment'::text, p_task_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
4
+ RETURNS TABLE(message_id uuid, thread_id uuid, task_id uuid, message_sequence bigint, created_at timestamp with time zone)
5
+ LANGUAGE plpgsql
6
+ SECURITY DEFINER
7
+ SET search_path TO 'agent_board', 'public'
8
+ AS $function$
9
+ declare
10
+ v_sequence bigint;
11
+ v_actor_agent_id uuid;
12
+ v_metadata jsonb;
13
+ v_instance agent_board.delegate_instances%rowtype;
14
+ begin
15
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_author_agent_id, 'p_author_agent_id');
16
+
17
+ if nullif(trim(p_body), '') is null then
18
+ raise exception 'message body is required' using errcode = '22023';
19
+ end if;
20
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
21
+ raise exception 'thread not found' using errcode = 'P0002';
22
+ end if;
23
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
24
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
25
+ end if;
26
+ -- OBSGATE (#78): a board-linked thread under 'inherit' requires a non-observer board role.
27
+ -- All message entrypoints use this authorized insertion primitive; the gate cannot be bypassed
28
+ -- by any send path. Board-less threads and service_role fall through (access check above).
29
+ if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
30
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
31
+ end if;
32
+ if p_task_id is not null and not exists (select 1 from agent_board.tasks where id = p_task_id and tasks.thread_id = p_thread_id) then
33
+ raise exception 'task not found in thread' using errcode = '22023';
34
+ end if;
35
+
36
+ -- HOSTED attribution: the `delegate` key is written by the server, never by a caller. A
37
+ -- hosted delegate's post carries it (overwriting anything supplied); every other author's
38
+ -- caller-supplied `delegate` key is removed, so no keyed agent can forge the mark.
39
+ -- client_verified is always false: under dynamic client registration anyone can name a client.
40
+ v_metadata := coalesce(p_metadata, '{}'::jsonb);
41
+ if jsonb_typeof(v_metadata) = 'object' then
42
+ v_metadata := v_metadata - 'delegate';
43
+ end if;
44
+ select di.* into v_instance
45
+ from agent_board.agents a
46
+ join agent_board.delegate_instances di on di.agent_id = a.id
47
+ where a.id = v_actor_agent_id
48
+ and a.is_delegate;
49
+ if found then
50
+ if jsonb_typeof(v_metadata) <> 'object' then
51
+ raise exception 'a hosted delegate''s message metadata must be a JSON object' using errcode = '22023';
52
+ end if;
53
+ v_metadata := v_metadata || jsonb_build_object('delegate', jsonb_build_object(
54
+ 'delegate', true,
55
+ 'instance_id', v_instance.instance_id,
56
+ 'owner_agent_id', agent_board._delegate_principal_agent_id(v_actor_agent_id),
57
+ 'client_id', v_instance.client_id,
58
+ 'client_verified', false));
59
+ end if;
60
+
61
+ v_sequence := agent_board._next_thread_message_sequence(p_thread_id);
62
+ -- The sequence update may have waited for an association; re-read after that wait.
63
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
64
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
65
+ end if;
66
+ if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
67
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
68
+ end if;
69
+
70
+ return query
71
+ insert into agent_board.messages(thread_id, author_agent_id, task_id, message_sequence, message_type, body, metadata)
72
+ values (p_thread_id, v_actor_agent_id, p_task_id, v_sequence, coalesce(p_message_type, 'comment'), p_body, v_metadata)
73
+ returning id, messages.thread_id, messages.task_id, messages.message_sequence, messages.created_at;
74
+ end;
75
+ $function$;
76
+ revoke all on function agent_board._insert_thread_message(uuid,uuid,text,text,uuid,jsonb) from public,anon,authenticated,service_role,agent_board_delegate;
77
+ comment on function agent_board._insert_thread_message(uuid,uuid,text,text,uuid,jsonb) is 'Internal authorized message insertion; preserves sequence locking, session author and hosted attribution. Delivery is the caller responsibility.';
78
+
79
+ create or replace function agent_board._deliver_board_message(p_message_id uuid, p_subject text, p_body text, p_priority text)
80
+ returns table(delivered_recipient_count integer, callback_outbox_count integer)
81
+ language plpgsql security definer set search_path=agent_board,public as $function$
82
+ declare
83
+ v_message agent_board.messages%rowtype;
84
+ v_membership record;
85
+ v_recipient agent_board.delivery_recipients%rowtype;
86
+ v_inbox agent_board.inbox_items%rowtype;
87
+ v_delivered_count integer := 0;
88
+ v_callback_count integer := 0;
89
+ v_preview text;
90
+ v_fetch_descriptor jsonb;
91
+ v_actor_agent_id uuid;
92
+ v_is_author boolean;
93
+ p_board_id uuid;
94
+ p_thread_id uuid;
95
+ begin
96
+ select * into strict v_message from agent_board.messages where id=p_message_id;
97
+ p_thread_id := v_message.thread_id;
98
+ v_actor_agent_id := v_message.author_agent_id;
99
+ select board_id into p_board_id from agent_board.threads where id=p_thread_id;
100
+ if p_board_id is null then
101
+ raise exception 'board delivery requires a board thread' using errcode='22023';
102
+ end if;
103
+ -- Keep the canonical early board/thread boundary before any recipient sink,
104
+ -- including boards whose current membership set is empty.
105
+ perform agent_board._associate_empty_thread(p_thread_id, p_board_id);
106
+ v_preview := agent_board._trim_inbox_text(p_body, 280);
107
+ v_fetch_descriptor := agent_board._build_fetch_descriptor(
108
+ 'board',
109
+ p_board_id,
110
+ 'agent_board.v_board_feed',
111
+ 'board delivery',
112
+ jsonb_build_object('messageId', v_message.id::text)
113
+ );
114
+
115
+ for v_membership in
116
+ select bm.id, bm.agent_id
117
+ from agent_board.board_memberships bm
118
+ where bm.board_id = p_board_id
119
+ and bm.membership_state in ('joined', 'subscribed')
120
+ order by bm.created_at
121
+ loop
122
+ -- #608: the author is not a recipient of its own post. Its row stays, FEED-ONLY
123
+ -- (visible_in_inbox = false), because v_board_feed is built from these rows and a post on a
124
+ -- board whose only member is its author would otherwise vanish from the feed. A feed-only
125
+ -- row is outside v_agent_inbox, v_agent_wake_recovery and v_agent_inbox_counts by their own
126
+ -- `visible_in_inbox` filter, and _queue_inbox_callbacks queues nothing for it.
127
+ v_is_author := v_membership.agent_id is not distinct from v_actor_agent_id;
128
+ v_recipient := agent_board._upsert_delivery_recipient(
129
+ 'board',
130
+ v_membership.agent_id,
131
+ 'message',
132
+ v_message.id,
133
+ null,
134
+ null,
135
+ p_board_id,
136
+ v_membership.id,
137
+ jsonb_build_object('priority', p_priority)
138
+ );
139
+
140
+ v_inbox := agent_board._upsert_inbox_item(
141
+ v_recipient.id,
142
+ v_membership.agent_id,
143
+ p_board_id,
144
+ v_recipient.delivery_mode,
145
+ 'message',
146
+ v_message.id,
147
+ null,
148
+ null,
149
+ trim(p_subject),
150
+ v_preview,
151
+ p_priority,
152
+ not v_is_author,
153
+ true,
154
+ v_fetch_descriptor,
155
+ jsonb_build_object('threadId', p_thread_id::text, 'boardId', p_board_id::text)
156
+ );
157
+
158
+ -- delivered_recipient_count counts RECIPIENTS; the author's feed-only row is not one.
159
+ if not v_is_author then
160
+ v_delivered_count := v_delivered_count + 1;
161
+ end if;
162
+ v_callback_count := v_callback_count + agent_board._queue_inbox_callbacks(v_inbox.id, 'inbox.item.delivered');
163
+ end loop;
164
+
165
+ v_callback_count := v_callback_count + agent_board._queue_board_post_callbacks(
166
+ p_board_id,
167
+ v_message.id,
168
+ trim(p_subject),
169
+ v_preview,
170
+ p_priority,
171
+ v_fetch_descriptor
172
+ );
173
+
174
+ return query select v_delivered_count,v_callback_count;
175
+ end;
176
+ $function$;
177
+ revoke all on function agent_board._deliver_board_message(uuid,text,text,text) from public,anon,authenticated,service_role,agent_board_delegate;
178
+ comment on function agent_board._deliver_board_message(uuid,text,text,text) is 'Internal canonical board fan-out for an authorized persisted message; board and author derive from the source, with feed-only self delivery and existing callbacks.';
179
+
180
+ 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)
181
+ RETURNS TABLE(message_id uuid, board_id uuid, delivered_recipient_count integer, callback_outbox_count integer, created_at timestamp with time zone)
182
+ LANGUAGE plpgsql
183
+ SECURITY DEFINER
184
+ SET search_path TO 'agent_board', 'public'
185
+ AS $function$
186
+ declare
187
+ v_message agent_board.messages%rowtype;
188
+ v_created_message record;
189
+ v_membership record;
190
+ v_recipient agent_board.delivery_recipients%rowtype;
191
+ v_inbox agent_board.inbox_items%rowtype;
192
+ v_delivered_count integer := 0;
193
+ v_callback_count integer := 0;
194
+ v_metadata jsonb;
195
+ v_preview text;
196
+ v_fetch_descriptor jsonb;
197
+ v_idempotency_key text := nullif(trim(p_idempotency_key), '');
198
+ v_actor_agent_id uuid;
199
+ v_is_author boolean;
200
+ begin
201
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_author_agent_id, 'p_author_agent_id');
202
+
203
+ if nullif(trim(p_subject), '') is null or nullif(trim(p_body), '') is null then
204
+ raise exception 'subject and body are required' using errcode = '22023';
205
+ end if;
206
+ if p_priority not in ('low', 'medium', 'high', 'urgent') then
207
+ raise exception 'invalid priority' using errcode = '22023';
208
+ end if;
209
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
210
+ raise exception 'thread not found' using errcode = 'P0002';
211
+ end if;
212
+ if not exists (select 1 from agent_board.boards where id = p_board_id) then
213
+ raise exception 'board not found' using errcode = 'P0002';
214
+ end if;
215
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
216
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
217
+ end if;
218
+ if not agent_board._current_agent_has_board_access(p_board_id) then
219
+ raise exception 'board not accessible to current agent' using errcode = '42501';
220
+ end if;
221
+ if not agent_board._current_agent_can_send_to_board(p_board_id) then
222
+ raise exception 'board send requires a non-observer board role' using errcode = '42501';
223
+ end if;
224
+
225
+ if agent_board._is_service_role() then
226
+ perform agent_board._record_send_authz_telemetry(
227
+ 'service_role_bypass',
228
+ coalesce(nullif(p_metadata -> 'signed_write' ->> 'origin', ''), 'unknown'),
229
+ v_actor_agent_id,
230
+ p_board_id,
231
+ 'post_board_message',
232
+ agent_board._resolve_send_fleet_scope(v_actor_agent_id),
233
+ null,
234
+ jsonb_build_object('redacted', true)
235
+ );
236
+ end if;
237
+
238
+ perform agent_board._lock_idempotency_key(
239
+ 'post_board_message:' || p_thread_id::text || ':' || v_actor_agent_id::text || ':' || p_board_id::text,
240
+ v_idempotency_key
241
+ );
242
+
243
+ if p_board_id is not null then
244
+ perform agent_board._associate_empty_thread(p_thread_id, p_board_id);
245
+ -- Both checks are new statements after the thread-lock wait.
246
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
247
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
248
+ end if;
249
+ if not agent_board._current_agent_has_board_access(p_board_id) then
250
+ raise exception 'board not accessible to current agent' using errcode = '42501';
251
+ end if;
252
+ if not agent_board._current_agent_can_send_to_board(p_board_id) then
253
+ raise exception 'board send requires a non-observer board role' using errcode = '42501';
254
+ end if;
255
+ end if;
256
+
257
+ if v_idempotency_key is not null then
258
+ select *
259
+ into v_message
260
+ from agent_board.messages
261
+ where thread_id = p_thread_id
262
+ and author_agent_id = v_actor_agent_id
263
+ and metadata ->> 'rpcName' = 'post_board_message'
264
+ and metadata ->> 'idempotencyKey' = v_idempotency_key
265
+ order by created_at desc, id desc
266
+ limit 1;
267
+ end if;
268
+
269
+ if v_message.id is null then
270
+ v_metadata := coalesce(p_metadata, '{}'::jsonb)
271
+ || jsonb_build_object(
272
+ 'rpcName', 'post_board_message',
273
+ 'subject', trim(p_subject),
274
+ 'priority', p_priority,
275
+ 'boardId', p_board_id::text
276
+ );
277
+ if v_idempotency_key is not null then
278
+ v_metadata := v_metadata || jsonb_build_object('idempotencyKey', v_idempotency_key);
279
+ end if;
280
+
281
+ select *
282
+ into v_created_message
283
+ from agent_board._insert_thread_message(
284
+ p_thread_id,
285
+ v_actor_agent_id,
286
+ p_body,
287
+ p_message_type,
288
+ null,
289
+ v_metadata
290
+ );
291
+
292
+ select *
293
+ into v_message
294
+ from agent_board.messages
295
+ where id = v_created_message.message_id;
296
+ end if;
297
+
298
+ select d.delivered_recipient_count, d.callback_outbox_count
299
+ into v_delivered_count, v_callback_count
300
+ from agent_board._deliver_board_message(v_message.id, trim(p_subject), p_body, p_priority) d;
301
+
302
+ return query
303
+ select v_message.id, p_board_id, v_delivered_count, v_callback_count, v_message.created_at;
304
+ end;
305
+ $function$;
306
+
307
+ CREATE OR REPLACE FUNCTION agent_board.post_message(p_thread_id uuid, p_author_agent_id uuid, p_body text, p_message_type text DEFAULT 'comment'::text, p_task_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
308
+ RETURNS TABLE(message_id uuid, thread_id uuid, task_id uuid, message_sequence bigint, created_at timestamp with time zone)
309
+ LANGUAGE plpgsql
310
+ SECURITY DEFINER
311
+ SET search_path TO 'agent_board', 'public'
312
+ AS $function$
313
+ declare
314
+ v_message record;
315
+ v_board_id uuid;
316
+ v_subject text;
317
+ v_access_mode text;
318
+ begin
319
+ select * into v_message from agent_board._insert_thread_message(
320
+ p_thread_id,p_author_agent_id,p_body,p_message_type,p_task_id,p_metadata);
321
+ -- Insertion holds the sequence/thread lock. Read the association after that
322
+ -- lock, so an initially unstamped thread cannot race past board delivery.
323
+ select t.board_id,coalesce(nullif(trim(t.subject),''),'Thread reply'),t.access_mode
324
+ into v_board_id,v_subject,v_access_mode from agent_board.threads t where t.id=p_thread_id;
325
+ -- Restricted threads keep their explicit audience; board association alone
326
+ -- does not authorize broadcasting an ordinary reply to all board members.
327
+ if v_board_id is not null and v_access_mode = 'inherit' then
328
+ if not agent_board._current_agent_has_board_access(v_board_id) then
329
+ raise exception 'board not accessible to current agent' using errcode='42501';
330
+ end if;
331
+ if not agent_board._current_agent_can_send_to_board(v_board_id) then
332
+ raise exception 'board send requires a non-observer board role' using errcode='42501';
333
+ end if;
334
+ perform * from agent_board._deliver_board_message(v_message.message_id,v_subject,p_body,'medium');
335
+ end if;
336
+ return query select v_message.message_id,v_message.thread_id,v_message.task_id,
337
+ v_message.message_sequence,v_message.created_at;
338
+ end;
339
+ $function$;
340
+
341
+ CREATE OR REPLACE FUNCTION agent_board.send_direct_message(p_thread_id uuid, p_author_agent_id uuid, p_recipient_agent_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)
342
+ RETURNS TABLE(message_id uuid, delivery_recipient_id uuid, inbox_item_id uuid, callback_outbox_count integer, created_at timestamp with time zone)
343
+ LANGUAGE plpgsql
344
+ SECURITY DEFINER
345
+ SET search_path TO 'agent_board', 'public'
346
+ AS $function$
347
+ declare
348
+ v_message agent_board.messages%rowtype;
349
+ v_created_message record;
350
+ v_recipient agent_board.delivery_recipients%rowtype;
351
+ v_inbox agent_board.inbox_items%rowtype;
352
+ v_callback_count integer := 0;
353
+ v_metadata jsonb;
354
+ v_preview text;
355
+ v_fetch_descriptor jsonb;
356
+ v_idempotency_key text := nullif(trim(p_idempotency_key), '');
357
+ v_actor_agent_id uuid;
358
+ begin
359
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_author_agent_id, 'p_author_agent_id');
360
+
361
+ if nullif(trim(p_subject), '') is null or nullif(trim(p_body), '') is null then
362
+ raise exception 'subject and body are required' using errcode = '22023';
363
+ end if;
364
+ if p_priority not in ('low', 'medium', 'high', 'urgent') then
365
+ raise exception 'invalid priority' using errcode = '22023';
366
+ end if;
367
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
368
+ raise exception 'thread not found' using errcode = 'P0002';
369
+ end if;
370
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
371
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
372
+ end if;
373
+ if not exists (select 1 from agent_board.agents where id = p_recipient_agent_id) then
374
+ raise exception 'recipient agent not found' using errcode = 'P0002';
375
+ end if;
376
+
377
+ if agent_board._is_service_role() then
378
+ perform agent_board._record_send_authz_telemetry(
379
+ 'service_role_bypass',
380
+ coalesce(nullif(p_metadata -> 'signed_write' ->> 'origin', ''), 'unknown'),
381
+ v_actor_agent_id,
382
+ null,
383
+ 'send_direct_message',
384
+ agent_board._resolve_send_fleet_scope(v_actor_agent_id),
385
+ null,
386
+ jsonb_build_object('redacted', true)
387
+ );
388
+ end if;
389
+
390
+ perform agent_board._lock_idempotency_key(
391
+ 'send_direct_message:' || p_thread_id::text || ':' || v_actor_agent_id::text,
392
+ v_idempotency_key
393
+ );
394
+
395
+ if v_idempotency_key is not null then
396
+ select *
397
+ into v_message
398
+ from agent_board.messages
399
+ where thread_id = p_thread_id
400
+ and author_agent_id = v_actor_agent_id
401
+ and metadata ->> 'rpcName' = 'send_direct_message'
402
+ and metadata ->> 'idempotencyKey' = v_idempotency_key
403
+ order by created_at desc
404
+ limit 1;
405
+ end if;
406
+
407
+ if v_message.id is null then
408
+ v_metadata := coalesce(p_metadata, '{}'::jsonb)
409
+ || jsonb_build_object(
410
+ 'rpcName', 'send_direct_message',
411
+ 'subject', trim(p_subject),
412
+ 'priority', p_priority
413
+ );
414
+ if v_idempotency_key is not null then
415
+ v_metadata := v_metadata || jsonb_build_object('idempotencyKey', v_idempotency_key);
416
+ end if;
417
+
418
+ select *
419
+ into v_created_message
420
+ from agent_board._insert_thread_message(
421
+ p_thread_id,
422
+ v_actor_agent_id,
423
+ p_body,
424
+ p_message_type,
425
+ null,
426
+ v_metadata
427
+ );
428
+
429
+ select *
430
+ into v_message
431
+ from agent_board.messages
432
+ where id = v_created_message.message_id;
433
+ end if;
434
+
435
+ v_preview := agent_board._trim_inbox_text(p_body, 280);
436
+ v_fetch_descriptor := agent_board._build_fetch_descriptor(
437
+ 'thread',
438
+ p_thread_id,
439
+ 'agent_board.v_thread_feed',
440
+ 'direct delivery',
441
+ jsonb_build_object('messageId', v_message.id::text, 'recipientAgentId', p_recipient_agent_id::text)
442
+ );
443
+
444
+ v_recipient := agent_board._upsert_delivery_recipient(
445
+ 'direct',
446
+ p_recipient_agent_id,
447
+ 'message',
448
+ v_message.id,
449
+ null,
450
+ null,
451
+ null,
452
+ null,
453
+ jsonb_build_object('priority', p_priority)
454
+ );
455
+
456
+ v_inbox := agent_board._upsert_inbox_item(
457
+ v_recipient.id,
458
+ p_recipient_agent_id,
459
+ null,
460
+ v_recipient.delivery_mode,
461
+ 'message',
462
+ v_message.id,
463
+ null,
464
+ null,
465
+ trim(p_subject),
466
+ v_preview,
467
+ p_priority,
468
+ true,
469
+ false,
470
+ v_fetch_descriptor,
471
+ jsonb_build_object('threadId', p_thread_id::text)
472
+ );
473
+
474
+ v_callback_count := agent_board._queue_inbox_callbacks(v_inbox.id, 'inbox.item.delivered');
475
+
476
+ return query
477
+ select v_message.id, v_recipient.id, v_inbox.id, v_callback_count, v_message.created_at;
478
+ end;
479
+ $function$;
480
+
481
+ CREATE OR REPLACE FUNCTION agent_board.send_multicast_message(p_thread_id uuid, p_author_agent_id uuid, p_recipient_agent_ids 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)
482
+ RETURNS TABLE(message_id uuid, delivered_recipient_count integer, inbox_item_count integer, callback_outbox_count integer, created_at timestamp with time zone)
483
+ LANGUAGE plpgsql
484
+ SECURITY DEFINER
485
+ SET search_path TO 'agent_board', 'public'
486
+ AS $function$
487
+ declare
488
+ v_message agent_board.messages%rowtype;
489
+ v_created_message record;
490
+ v_recipient_ids uuid[];
491
+ v_recipient_id uuid;
492
+ v_recipient agent_board.delivery_recipients%rowtype;
493
+ v_inbox agent_board.inbox_items%rowtype;
494
+ v_delivered_count integer := 0;
495
+ v_inbox_count integer := 0;
496
+ v_callback_count integer := 0;
497
+ v_metadata jsonb;
498
+ v_preview text;
499
+ v_fetch_descriptor jsonb;
500
+ v_idempotency_key text := nullif(trim(p_idempotency_key), '');
501
+ v_actor_agent_id uuid;
502
+ begin
503
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_author_agent_id, 'p_author_agent_id');
504
+
505
+ if nullif(trim(p_subject), '') is null or nullif(trim(p_body), '') is null then
506
+ raise exception 'subject and body are required' using errcode = '22023';
507
+ end if;
508
+ if p_priority not in ('low', 'medium', 'high', 'urgent') then
509
+ raise exception 'invalid priority' using errcode = '22023';
510
+ end if;
511
+ if p_recipient_agent_ids is null or cardinality(p_recipient_agent_ids) = 0 then
512
+ raise exception 'recipient_agent_ids must not be empty' using errcode = '22023';
513
+ end if;
514
+ if array_position(p_recipient_agent_ids, null) is not null then
515
+ raise exception 'recipient_agent_ids must not contain nulls' using errcode = '22023';
516
+ end if;
517
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
518
+ raise exception 'thread not found' using errcode = 'P0002';
519
+ end if;
520
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
521
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
522
+ end if;
523
+
524
+ select array_agg(distinct recipient_id)
525
+ into v_recipient_ids
526
+ from unnest(p_recipient_agent_ids) as recipient_id;
527
+
528
+ if (
529
+ select count(*)
530
+ from agent_board.agents
531
+ where id = any(v_recipient_ids)
532
+ ) <> cardinality(v_recipient_ids) then
533
+ raise exception 'recipient agent not found' using errcode = 'P0002';
534
+ end if;
535
+
536
+ if agent_board._is_service_role() then
537
+ perform agent_board._record_send_authz_telemetry(
538
+ 'service_role_bypass',
539
+ coalesce(nullif(p_metadata -> 'signed_write' ->> 'origin', ''), 'unknown'),
540
+ v_actor_agent_id,
541
+ null,
542
+ 'send_multicast_message',
543
+ agent_board._resolve_send_fleet_scope(v_actor_agent_id),
544
+ null,
545
+ jsonb_build_object('redacted', true)
546
+ );
547
+ end if;
548
+
549
+ perform agent_board._lock_idempotency_key(
550
+ 'send_multicast_message:' || p_thread_id::text || ':' || v_actor_agent_id::text,
551
+ v_idempotency_key
552
+ );
553
+
554
+ if v_idempotency_key is not null then
555
+ select *
556
+ into v_message
557
+ from agent_board.messages
558
+ where thread_id = p_thread_id
559
+ and author_agent_id = v_actor_agent_id
560
+ and metadata ->> 'rpcName' = 'send_multicast_message'
561
+ and metadata ->> 'idempotencyKey' = v_idempotency_key
562
+ order by created_at desc
563
+ limit 1;
564
+ end if;
565
+
566
+ if v_message.id is null then
567
+ v_metadata := coalesce(p_metadata, '{}'::jsonb)
568
+ || jsonb_build_object(
569
+ 'rpcName', 'send_multicast_message',
570
+ 'subject', trim(p_subject),
571
+ 'priority', p_priority
572
+ );
573
+ if v_idempotency_key is not null then
574
+ v_metadata := v_metadata || jsonb_build_object('idempotencyKey', v_idempotency_key);
575
+ end if;
576
+
577
+ select *
578
+ into v_created_message
579
+ from agent_board._insert_thread_message(
580
+ p_thread_id,
581
+ v_actor_agent_id,
582
+ p_body,
583
+ p_message_type,
584
+ null,
585
+ v_metadata
586
+ );
587
+
588
+ select *
589
+ into v_message
590
+ from agent_board.messages
591
+ where id = v_created_message.message_id;
592
+ end if;
593
+
594
+ v_preview := agent_board._trim_inbox_text(p_body, 280);
595
+
596
+ foreach v_recipient_id in array v_recipient_ids loop
597
+ v_fetch_descriptor := agent_board._build_fetch_descriptor(
598
+ 'thread',
599
+ p_thread_id,
600
+ 'agent_board.v_thread_feed',
601
+ 'multicast delivery',
602
+ jsonb_build_object('messageId', v_message.id::text, 'recipientAgentId', v_recipient_id::text)
603
+ );
604
+
605
+ v_recipient := agent_board._upsert_delivery_recipient(
606
+ 'multicast',
607
+ v_recipient_id,
608
+ 'message',
609
+ v_message.id,
610
+ null,
611
+ null,
612
+ null,
613
+ null,
614
+ jsonb_build_object('priority', p_priority)
615
+ );
616
+
617
+ v_inbox := agent_board._upsert_inbox_item(
618
+ v_recipient.id,
619
+ v_recipient_id,
620
+ null,
621
+ v_recipient.delivery_mode,
622
+ 'message',
623
+ v_message.id,
624
+ null,
625
+ null,
626
+ trim(p_subject),
627
+ v_preview,
628
+ p_priority,
629
+ true,
630
+ false,
631
+ v_fetch_descriptor,
632
+ jsonb_build_object('threadId', p_thread_id::text)
633
+ );
634
+
635
+ v_delivered_count := v_delivered_count + 1;
636
+ v_inbox_count := v_inbox_count + 1;
637
+ v_callback_count := v_callback_count + agent_board._queue_inbox_callbacks(v_inbox.id, 'inbox.item.delivered');
638
+ end loop;
639
+
640
+ return query
641
+ select v_message.id, v_delivered_count, v_inbox_count, v_callback_count, v_message.created_at;
642
+ end;
643
+ $function$;
644
+
645
+ revoke all on function agent_board.post_message(uuid,uuid,text,text,uuid,jsonb) from public,anon,authenticated,service_role,agent_board_delegate;
646
+ grant execute on function agent_board.post_message(uuid,uuid,text,text,uuid,jsonb) to authenticated,service_role,agent_board_delegate;
647
+ comment on function agent_board.post_message(uuid,uuid,text,text,uuid,jsonb) is 'Posts a thread reply; inherited board threads use canonical board delivery; non-board and restricted threads retain insertion-only behavior. Session-derived author and return shape are unchanged.';
648
+
649
+ revoke all on function agent_board.post_board_message(uuid,uuid,uuid,text,text,text,text,text,jsonb) from public,anon,authenticated,service_role,agent_board_delegate;
650
+ grant execute on function agent_board.post_board_message(uuid,uuid,uuid,text,text,text,text,text,jsonb) to authenticated,service_role,agent_board_delegate;
651
+ comment on function agent_board.post_board_message(uuid,uuid,uuid,text,text,text,text,text,jsonb) is 'Creates or reuses a canonical board post after validating the session actor, board authority and locked thread association; delivery fan-out and downstream effects are transactional.';
652
+
653
+ revoke all on function agent_board.send_direct_message(uuid,uuid,uuid,text,text,text,text,text,jsonb) from public,anon,authenticated,service_role,agent_board_delegate;
654
+ grant execute on function agent_board.send_direct_message(uuid,uuid,uuid,text,text,text,text,text,jsonb) to authenticated,service_role;
655
+ comment on function agent_board.send_direct_message(uuid,uuid,uuid,text,text,text,text,text,jsonb) is 'Sends privately to the explicit recipient, including on board threads; uses the common authorized insertion primitive.';
656
+
657
+ revoke all on function agent_board.send_multicast_message(uuid,uuid,uuid[],text,text,text,text,text,jsonb) from public,anon,authenticated,service_role,agent_board_delegate;
658
+ grant execute on function agent_board.send_multicast_message(uuid,uuid,uuid[],text,text,text,text,text,jsonb) to authenticated,service_role;
659
+ comment on function agent_board.send_multicast_message(uuid,uuid,uuid[],text,text,text,text,text,jsonb) is 'Sends privately to explicit recipients, including on board threads; uses the common authorized insertion primitive.';