@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,1287 @@
1
+ -- Enforce board/thread identity and serialize first association with private-history writers.
2
+ -- Serialize first association and inspect committed history in fresh VOLATILE statements.
3
+ create or replace function agent_board._associate_empty_thread(p_thread_id uuid, p_board_id uuid)
4
+ returns void language plpgsql volatile security definer
5
+ set search_path = agent_board, public as $$
6
+ declare v_board_id uuid;
7
+ begin
8
+ if p_thread_id is null or p_board_id is null then
9
+ raise exception 'thread and board are required' using errcode = '22023';
10
+ end if;
11
+ perform 1 from agent_board.threads where id = p_thread_id for no key update;
12
+ if not found then
13
+ raise exception 'thread not found' using errcode = 'P0002';
14
+ end if;
15
+ select board_id into v_board_id from agent_board.threads where id = p_thread_id;
16
+ if v_board_id is not null then
17
+ if v_board_id <> p_board_id then
18
+ raise exception 'thread belongs to a different board' using errcode = '42501';
19
+ end if;
20
+ return;
21
+ end if;
22
+ if current_setting('transaction_isolation') <> 'read committed' then
23
+ raise exception 'first thread association requires read committed isolation' using errcode = '0A000';
24
+ end if;
25
+ if exists (select 1 from agent_board.messages where thread_id = p_thread_id)
26
+ or exists (select 1 from agent_board.tasks where thread_id = p_thread_id)
27
+ or exists (select 1 from agent_board.task_events where thread_id = p_thread_id)
28
+ or exists (select 1 from agent_board.artifacts where thread_id = p_thread_id)
29
+ or exists (select 1 from agent_board.claims where thread_id = p_thread_id)
30
+ or exists (select 1 from agent_board.conflicts where thread_id = p_thread_id)
31
+ or exists (select 1 from agent_board.escalations where thread_id = p_thread_id)
32
+ or exists (select 1 from agent_board.presence where thread_id = p_thread_id)
33
+ or exists (select 1 from agent_board.thread_memberships where thread_id = p_thread_id)
34
+ then
35
+ raise exception 'thread has private history' using errcode = '42501';
36
+ end if;
37
+ update agent_board.threads set board_id = p_board_id
38
+ where id = p_thread_id and board_id is null;
39
+ end;
40
+ $$;
41
+ revoke all on function agent_board._associate_empty_thread(uuid, uuid) from public, anon, authenticated, service_role;
42
+ comment on function agent_board._associate_empty_thread(uuid, uuid) is 'Internal first-association boundary; caller validates board and actor.';
43
+
44
+
45
+ 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)
46
+ RETURNS TABLE(message_id uuid, board_id uuid, delivered_recipient_count integer, callback_outbox_count integer, created_at timestamp with time zone)
47
+ LANGUAGE plpgsql
48
+ SECURITY DEFINER
49
+ SET search_path TO 'agent_board', 'public'
50
+ AS $function$
51
+ declare
52
+ v_message agent_board.messages%rowtype;
53
+ v_created_message record;
54
+ v_membership record;
55
+ v_recipient agent_board.delivery_recipients%rowtype;
56
+ v_inbox agent_board.inbox_items%rowtype;
57
+ v_delivered_count integer := 0;
58
+ v_callback_count integer := 0;
59
+ v_metadata jsonb;
60
+ v_preview text;
61
+ v_fetch_descriptor jsonb;
62
+ v_idempotency_key text := nullif(trim(p_idempotency_key), '');
63
+ v_actor_agent_id uuid;
64
+ v_is_author boolean;
65
+ begin
66
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_author_agent_id, 'p_author_agent_id');
67
+
68
+ if nullif(trim(p_subject), '') is null or nullif(trim(p_body), '') is null then
69
+ raise exception 'subject and body are required' using errcode = '22023';
70
+ end if;
71
+ if p_priority not in ('low', 'medium', 'high', 'urgent') then
72
+ raise exception 'invalid priority' using errcode = '22023';
73
+ end if;
74
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
75
+ raise exception 'thread not found' using errcode = 'P0002';
76
+ end if;
77
+ if not exists (select 1 from agent_board.boards where id = p_board_id) then
78
+ raise exception 'board not found' using errcode = 'P0002';
79
+ end if;
80
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
81
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
82
+ end if;
83
+ if not agent_board._current_agent_has_board_access(p_board_id) then
84
+ raise exception 'board not accessible to current agent' using errcode = '42501';
85
+ end if;
86
+ if not agent_board._current_agent_can_send_to_board(p_board_id) then
87
+ raise exception 'board send requires a non-observer board role' using errcode = '42501';
88
+ end if;
89
+
90
+ if agent_board._is_service_role() then
91
+ perform agent_board._record_send_authz_telemetry(
92
+ 'service_role_bypass',
93
+ coalesce(nullif(p_metadata -> 'signed_write' ->> 'origin', ''), 'unknown'),
94
+ v_actor_agent_id,
95
+ p_board_id,
96
+ 'post_board_message',
97
+ agent_board._resolve_send_fleet_scope(v_actor_agent_id),
98
+ null,
99
+ jsonb_build_object('redacted', true)
100
+ );
101
+ end if;
102
+
103
+ perform agent_board._lock_idempotency_key(
104
+ 'post_board_message:' || p_thread_id::text || ':' || v_actor_agent_id::text || ':' || p_board_id::text,
105
+ v_idempotency_key
106
+ );
107
+
108
+ if p_board_id is not null then
109
+ perform agent_board._associate_empty_thread(p_thread_id, p_board_id);
110
+ -- Both checks are new statements after the thread-lock wait.
111
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
112
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
113
+ end if;
114
+ if not agent_board._current_agent_has_board_access(p_board_id) then
115
+ raise exception 'board not accessible to current agent' using errcode = '42501';
116
+ end if;
117
+ if not agent_board._current_agent_can_send_to_board(p_board_id) then
118
+ raise exception 'board send requires a non-observer board role' using errcode = '42501';
119
+ end if;
120
+ end if;
121
+
122
+ if v_idempotency_key is not null then
123
+ select *
124
+ into v_message
125
+ from agent_board.messages
126
+ where thread_id = p_thread_id
127
+ and author_agent_id = v_actor_agent_id
128
+ and metadata ->> 'rpcName' = 'post_board_message'
129
+ and metadata ->> 'idempotencyKey' = v_idempotency_key
130
+ order by created_at desc, id desc
131
+ limit 1;
132
+ end if;
133
+
134
+ if v_message.id is null then
135
+ v_metadata := coalesce(p_metadata, '{}'::jsonb)
136
+ || jsonb_build_object(
137
+ 'rpcName', 'post_board_message',
138
+ 'subject', trim(p_subject),
139
+ 'priority', p_priority,
140
+ 'boardId', p_board_id::text
141
+ );
142
+ if v_idempotency_key is not null then
143
+ v_metadata := v_metadata || jsonb_build_object('idempotencyKey', v_idempotency_key);
144
+ end if;
145
+
146
+ select *
147
+ into v_created_message
148
+ from agent_board.post_message(
149
+ p_thread_id,
150
+ v_actor_agent_id,
151
+ p_body,
152
+ p_message_type,
153
+ null,
154
+ v_metadata
155
+ );
156
+
157
+ select *
158
+ into v_message
159
+ from agent_board.messages
160
+ where id = v_created_message.message_id;
161
+ end if;
162
+
163
+ v_preview := agent_board._trim_inbox_text(p_body, 280);
164
+ v_fetch_descriptor := agent_board._build_fetch_descriptor(
165
+ 'board',
166
+ p_board_id,
167
+ 'agent_board.v_board_feed',
168
+ 'board delivery',
169
+ jsonb_build_object('messageId', v_message.id::text)
170
+ );
171
+
172
+ for v_membership in
173
+ select bm.id, bm.agent_id
174
+ from agent_board.board_memberships bm
175
+ where bm.board_id = p_board_id
176
+ and bm.membership_state in ('joined', 'subscribed')
177
+ order by bm.created_at
178
+ loop
179
+ -- #608: the author is not a recipient of its own post. Its row stays, FEED-ONLY
180
+ -- (visible_in_inbox = false), because v_board_feed is built from these rows and a post on a
181
+ -- board whose only member is its author would otherwise vanish from the feed. A feed-only
182
+ -- row is outside v_agent_inbox, v_agent_wake_recovery and v_agent_inbox_counts by their own
183
+ -- `visible_in_inbox` filter, and _queue_inbox_callbacks queues nothing for it.
184
+ v_is_author := v_membership.agent_id is not distinct from v_actor_agent_id;
185
+ v_recipient := agent_board._upsert_delivery_recipient(
186
+ 'board',
187
+ v_membership.agent_id,
188
+ 'message',
189
+ v_message.id,
190
+ null,
191
+ null,
192
+ p_board_id,
193
+ v_membership.id,
194
+ jsonb_build_object('priority', p_priority)
195
+ );
196
+
197
+ v_inbox := agent_board._upsert_inbox_item(
198
+ v_recipient.id,
199
+ v_membership.agent_id,
200
+ p_board_id,
201
+ v_recipient.delivery_mode,
202
+ 'message',
203
+ v_message.id,
204
+ null,
205
+ null,
206
+ trim(p_subject),
207
+ v_preview,
208
+ p_priority,
209
+ not v_is_author,
210
+ true,
211
+ v_fetch_descriptor,
212
+ jsonb_build_object('threadId', p_thread_id::text, 'boardId', p_board_id::text)
213
+ );
214
+
215
+ -- delivered_recipient_count counts RECIPIENTS; the author's feed-only row is not one.
216
+ if not v_is_author then
217
+ v_delivered_count := v_delivered_count + 1;
218
+ end if;
219
+ v_callback_count := v_callback_count + agent_board._queue_inbox_callbacks(v_inbox.id, 'inbox.item.delivered');
220
+ end loop;
221
+
222
+ v_callback_count := v_callback_count + agent_board._queue_board_post_callbacks(
223
+ p_board_id,
224
+ v_message.id,
225
+ trim(p_subject),
226
+ v_preview,
227
+ p_priority,
228
+ v_fetch_descriptor
229
+ );
230
+
231
+ return query
232
+ select v_message.id, p_board_id, v_delivered_count, v_callback_count, v_message.created_at;
233
+ end;
234
+ $function$
235
+ ;
236
+
237
+ 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)
238
+ 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)
239
+ LANGUAGE plpgsql
240
+ SECURITY DEFINER
241
+ SET search_path TO 'agent_board', 'public'
242
+ AS $function$
243
+ declare
244
+ v_task agent_board.tasks%rowtype;
245
+ v_created_event_id uuid;
246
+ v_created_task record;
247
+ v_recipient_ids uuid[];
248
+ v_recipient_id uuid;
249
+ v_membership record;
250
+ v_recipient agent_board.delivery_recipients%rowtype;
251
+ v_inbox agent_board.inbox_items%rowtype;
252
+ v_delivered_count integer := 0;
253
+ v_inbox_count integer := 0;
254
+ v_callback_count integer := 0;
255
+ v_metadata jsonb;
256
+ v_preview text;
257
+ v_fetch_descriptor jsonb;
258
+ v_idempotency_key text := nullif(trim(p_idempotency_key), '');
259
+ v_actor_agent_id uuid;
260
+ v_is_creator boolean;
261
+ begin
262
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
263
+
264
+ if nullif(trim(p_title), '') is null then
265
+ raise exception 'task title is required' using errcode = '22023';
266
+ end if;
267
+ if p_priority not in ('low', 'medium', 'high', 'urgent') then
268
+ raise exception 'invalid priority' using errcode = '22023';
269
+ end if;
270
+ if p_orchestration_mode not in ('distributed', 'hierarchical', 'hybrid') then
271
+ raise exception 'invalid orchestration mode' using errcode = '22023';
272
+ end if;
273
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
274
+ raise exception 'thread not found' using errcode = 'P0002';
275
+ end if;
276
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
277
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
278
+ end if;
279
+ if (p_recipient_agent_ids is null or cardinality(p_recipient_agent_ids) = 0) and p_board_id is null then
280
+ raise exception 'tasking delivery requires explicit recipients or a board target' using errcode = '22023';
281
+ end if;
282
+ if p_recipient_agent_ids is not null and array_position(p_recipient_agent_ids, null) is not null then
283
+ raise exception 'recipient_agent_ids must not contain nulls' using errcode = '22023';
284
+ end if;
285
+ if p_board_id is not null then
286
+ if not exists (select 1 from agent_board.boards where id = p_board_id) then
287
+ raise exception 'board not found' using errcode = 'P0002';
288
+ end if;
289
+ if not agent_board._current_agent_has_board_access(p_board_id) then
290
+ raise exception 'board not accessible to current agent' using errcode = '42501';
291
+ end if;
292
+ if not agent_board._current_agent_can_send_to_board(p_board_id) then
293
+ raise exception 'board send requires a non-observer board role' using errcode = '42501';
294
+ end if;
295
+ end if;
296
+
297
+ select array_agg(distinct recipient_id)
298
+ into v_recipient_ids
299
+ from unnest(coalesce(p_recipient_agent_ids, '{}'::uuid[])) as recipient_id;
300
+
301
+ if v_recipient_ids is not null and (
302
+ select count(*)
303
+ from agent_board.agents
304
+ where id = any(v_recipient_ids)
305
+ ) <> cardinality(v_recipient_ids) then
306
+ raise exception 'recipient agent not found' using errcode = 'P0002';
307
+ end if;
308
+
309
+ if agent_board._is_service_role() then
310
+ perform agent_board._record_send_authz_telemetry(
311
+ 'service_role_bypass',
312
+ coalesce(nullif(p_metadata -> 'signed_write' ->> 'origin', ''), 'unknown'),
313
+ v_actor_agent_id,
314
+ p_board_id,
315
+ 'create_tasking_delivery',
316
+ agent_board._resolve_send_fleet_scope(v_actor_agent_id),
317
+ null,
318
+ jsonb_build_object('redacted', true)
319
+ );
320
+ end if;
321
+
322
+ perform agent_board._lock_idempotency_key(
323
+ 'create_tasking_delivery:' || p_thread_id::text || ':' || v_actor_agent_id::text,
324
+ v_idempotency_key
325
+ );
326
+
327
+ if p_board_id is not null then
328
+ perform agent_board._associate_empty_thread(p_thread_id, p_board_id);
329
+ -- Both checks are new statements after the thread-lock wait.
330
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
331
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
332
+ end if;
333
+ if not agent_board._current_agent_has_board_access(p_board_id) then
334
+ raise exception 'board not accessible to current agent' using errcode = '42501';
335
+ end if;
336
+ if not agent_board._current_agent_can_send_to_board(p_board_id) then
337
+ raise exception 'board send requires a non-observer board role' using errcode = '42501';
338
+ end if;
339
+ end if;
340
+
341
+ if v_idempotency_key is not null then
342
+ select *
343
+ into v_task
344
+ from agent_board.tasks
345
+ where thread_id = p_thread_id
346
+ and created_by_agent_id = v_actor_agent_id
347
+ and metadata ->> 'rpcName' = 'create_tasking_delivery'
348
+ and metadata ->> 'idempotencyKey' = v_idempotency_key
349
+ order by created_at desc, id desc
350
+ limit 1;
351
+ end if;
352
+
353
+ if v_task.id is null then
354
+ v_metadata := coalesce(p_metadata, '{}'::jsonb)
355
+ || jsonb_build_object(
356
+ 'rpcName', 'create_tasking_delivery',
357
+ 'priority', p_priority
358
+ );
359
+ if p_board_id is not null then
360
+ v_metadata := v_metadata || jsonb_build_object('boardId', p_board_id::text);
361
+ end if;
362
+ if v_idempotency_key is not null then
363
+ v_metadata := v_metadata || jsonb_build_object('idempotencyKey', v_idempotency_key);
364
+ end if;
365
+
366
+ select *
367
+ into v_created_task
368
+ from agent_board.create_task(
369
+ p_thread_id,
370
+ v_actor_agent_id,
371
+ trim(p_title),
372
+ p_details,
373
+ null,
374
+ null,
375
+ p_priority,
376
+ p_orchestration_mode,
377
+ v_metadata
378
+ );
379
+
380
+ v_created_event_id := v_created_task.created_event_id;
381
+
382
+ select *
383
+ into v_task
384
+ from agent_board.tasks
385
+ where id = v_created_task.task_id;
386
+ else
387
+ select te.id
388
+ into v_created_event_id
389
+ from agent_board.task_events te
390
+ where te.task_id = v_task.id
391
+ and te.event_sequence = 1;
392
+ end if;
393
+
394
+ v_preview := agent_board._trim_inbox_text(coalesce(p_details, p_title), 280);
395
+ v_fetch_descriptor := agent_board._build_fetch_descriptor(
396
+ 'task',
397
+ v_task.id,
398
+ 'agent_board.v_task_tree',
399
+ 'tasking delivery',
400
+ jsonb_build_object('threadId', p_thread_id::text)
401
+ );
402
+
403
+ if v_recipient_ids is not null then
404
+ foreach v_recipient_id in array v_recipient_ids loop
405
+ v_recipient := agent_board._upsert_delivery_recipient(
406
+ 'tasking',
407
+ v_recipient_id,
408
+ 'task',
409
+ null,
410
+ v_task.id,
411
+ null,
412
+ null,
413
+ null,
414
+ jsonb_build_object('priority', p_priority)
415
+ );
416
+
417
+ v_inbox := agent_board._upsert_inbox_item(
418
+ v_recipient.id,
419
+ v_recipient_id,
420
+ null,
421
+ v_recipient.delivery_mode,
422
+ 'task',
423
+ null,
424
+ v_task.id,
425
+ null,
426
+ trim(p_title),
427
+ v_preview,
428
+ p_priority,
429
+ true,
430
+ false,
431
+ v_fetch_descriptor,
432
+ jsonb_build_object('threadId', p_thread_id::text)
433
+ );
434
+
435
+ v_delivered_count := v_delivered_count + 1;
436
+ v_inbox_count := v_inbox_count + 1;
437
+ v_callback_count := v_callback_count + agent_board._queue_inbox_callbacks(v_inbox.id, 'inbox.item.delivered');
438
+ end loop;
439
+ end if;
440
+
441
+ if p_board_id is not null then
442
+ for v_membership in
443
+ select bm.id, bm.agent_id
444
+ from agent_board.board_memberships bm
445
+ where bm.board_id = p_board_id
446
+ and bm.membership_state in ('joined', 'subscribed')
447
+ order by bm.created_at
448
+ loop
449
+ -- #608: the board fan-out does not deliver a task to the agent that created it. The
450
+ -- creator's row is KEPT, feed-only, exactly as a board post's author row is. Before #667,
451
+ -- _upsert_delivery_recipient was the only board-stamp path; skipping that row
452
+ -- left a creator-only board's thread unlinked and post_message on the access-only branch of
453
+ -- the observer gate (codex and grok seats, PR #615 round 1). It also keeps the board
454
+ -- directory's activity and delegate visibility as they were. An explicit recipient list
455
+ -- naming the creator still delivers to it: that is the caller addressing itself, and the
456
+ -- board loop must not then demote the row the explicit loop wrote. It shares that row (the
457
+ -- delivery upsert keeps the explicit row's null board_id), so demoting it wrote both
458
+ -- visibility flags false and violated inbox_items_visibility_shape, aborting the whole task
459
+ -- (every seat on PR #615 round 2).
460
+ v_is_creator := v_membership.agent_id is not distinct from v_actor_agent_id
461
+ and not (v_membership.agent_id = any(coalesce(v_recipient_ids, '{}'::uuid[])));
462
+ v_recipient := agent_board._upsert_delivery_recipient(
463
+ 'tasking',
464
+ v_membership.agent_id,
465
+ 'task',
466
+ null,
467
+ v_task.id,
468
+ null,
469
+ p_board_id,
470
+ v_membership.id,
471
+ jsonb_build_object('priority', p_priority)
472
+ );
473
+
474
+ v_inbox := agent_board._upsert_inbox_item(
475
+ v_recipient.id,
476
+ v_membership.agent_id,
477
+ v_recipient.board_id,
478
+ v_recipient.delivery_mode,
479
+ 'task',
480
+ null,
481
+ v_task.id,
482
+ null,
483
+ trim(p_title),
484
+ v_preview,
485
+ p_priority,
486
+ not v_is_creator,
487
+ v_recipient.board_id is not null,
488
+ v_fetch_descriptor,
489
+ jsonb_build_object('threadId', p_thread_id::text, 'boardId', p_board_id::text)
490
+ );
491
+
492
+ -- The counts are of RECIPIENTS; the creator's feed-only row is not one.
493
+ if not v_is_creator then
494
+ v_delivered_count := v_delivered_count + 1;
495
+ v_inbox_count := v_inbox_count + 1;
496
+ end if;
497
+ v_callback_count := v_callback_count + agent_board._queue_inbox_callbacks(v_inbox.id, 'inbox.item.delivered');
498
+ end loop;
499
+ end if;
500
+
501
+ return query
502
+ 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;
503
+ end;
504
+ $function$
505
+ ;
506
+
507
+ CREATE OR REPLACE FUNCTION agent_board._upsert_delivery_recipient(p_delivery_mode text, p_recipient_agent_id uuid, p_source_kind text, p_source_message_id uuid DEFAULT NULL::uuid, p_source_task_id uuid DEFAULT NULL::uuid, p_source_delegation_id uuid DEFAULT NULL::uuid, p_board_id uuid DEFAULT NULL::uuid, p_board_membership_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
508
+ RETURNS agent_board.delivery_recipients
509
+ LANGUAGE plpgsql
510
+ SECURITY DEFINER
511
+ SET search_path TO 'agent_board', 'public'
512
+ AS $function$
513
+ declare
514
+ v_row agent_board.delivery_recipients%rowtype;
515
+ begin
516
+ -- HOSTED (D16): a delivery reaches a hosted delegate only through a board it is active on
517
+ -- (the board-post fan-out to members). It can read nothing without a board.
518
+ perform agent_board._refuse_delegate_addressee(p_recipient_agent_id, p_board_id, 'delivery');
519
+ if p_board_id is not null then
520
+ perform agent_board._associate_empty_thread(coalesce(
521
+ (select m.thread_id from agent_board.messages m where m.id = p_source_message_id),
522
+ (select tk.thread_id from agent_board.tasks tk where tk.id = p_source_task_id),
523
+ (select tk.thread_id from agent_board.delegations d
524
+ join agent_board.tasks tk on tk.id = d.child_task_id where d.id = p_source_delegation_id)
525
+ ), p_board_id);
526
+ end if;
527
+
528
+ insert into agent_board.delivery_recipients(
529
+ delivery_mode,
530
+ recipient_agent_id,
531
+ source_kind,
532
+ source_message_id,
533
+ source_task_id,
534
+ source_delegation_id,
535
+ board_id,
536
+ board_membership_id,
537
+ metadata
538
+ )
539
+ values (
540
+ p_delivery_mode,
541
+ p_recipient_agent_id,
542
+ p_source_kind,
543
+ p_source_message_id,
544
+ p_source_task_id,
545
+ p_source_delegation_id,
546
+ p_board_id,
547
+ p_board_membership_id,
548
+ coalesce(p_metadata, '{}'::jsonb)
549
+ )
550
+ on conflict (recipient_agent_id, canonical_source_key) do update
551
+ set metadata = coalesce(agent_board.delivery_recipients.metadata, '{}'::jsonb) || coalesce(excluded.metadata, '{}'::jsonb)
552
+ returning * into v_row;
553
+
554
+ return v_row;
555
+ end;
556
+ $function$
557
+ ;
558
+
559
+ 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)
560
+ RETURNS TABLE(message_id uuid, thread_id uuid, task_id uuid, message_sequence bigint, created_at timestamp with time zone)
561
+ LANGUAGE plpgsql
562
+ SECURITY DEFINER
563
+ SET search_path TO 'agent_board', 'public'
564
+ AS $function$
565
+ declare
566
+ v_sequence bigint;
567
+ v_actor_agent_id uuid;
568
+ v_metadata jsonb;
569
+ v_instance agent_board.delegate_instances%rowtype;
570
+ begin
571
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_author_agent_id, 'p_author_agent_id');
572
+
573
+ if nullif(trim(p_body), '') is null then
574
+ raise exception 'message body is required' using errcode = '22023';
575
+ end if;
576
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
577
+ raise exception 'thread not found' using errcode = 'P0002';
578
+ end if;
579
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
580
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
581
+ end if;
582
+ -- OBSGATE (#78): a board-linked thread under 'inherit' requires a non-observer board role.
583
+ -- post_message is the sole authenticated writer to messages, so this gate cannot be bypassed
584
+ -- by any send path. Board-less threads and service_role fall through (access check above).
585
+ if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
586
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
587
+ end if;
588
+ 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
589
+ raise exception 'task not found in thread' using errcode = '22023';
590
+ end if;
591
+
592
+ -- HOSTED attribution: the `delegate` key is written by the server, never by a caller. A
593
+ -- hosted delegate's post carries it (overwriting anything supplied); every other author's
594
+ -- caller-supplied `delegate` key is removed, so no keyed agent can forge the mark.
595
+ -- client_verified is always false: under dynamic client registration anyone can name a client.
596
+ v_metadata := coalesce(p_metadata, '{}'::jsonb);
597
+ if jsonb_typeof(v_metadata) = 'object' then
598
+ v_metadata := v_metadata - 'delegate';
599
+ end if;
600
+ select di.* into v_instance
601
+ from agent_board.agents a
602
+ join agent_board.delegate_instances di on di.agent_id = a.id
603
+ where a.id = v_actor_agent_id
604
+ and a.is_delegate;
605
+ if found then
606
+ if jsonb_typeof(v_metadata) <> 'object' then
607
+ raise exception 'a hosted delegate''s message metadata must be a JSON object' using errcode = '22023';
608
+ end if;
609
+ v_metadata := v_metadata || jsonb_build_object('delegate', jsonb_build_object(
610
+ 'delegate', true,
611
+ 'instance_id', v_instance.instance_id,
612
+ 'owner_agent_id', agent_board._delegate_principal_agent_id(v_actor_agent_id),
613
+ 'client_id', v_instance.client_id,
614
+ 'client_verified', false));
615
+ end if;
616
+
617
+ v_sequence := agent_board._next_thread_message_sequence(p_thread_id);
618
+ -- The sequence update may have waited for an association; re-read after that wait.
619
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
620
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
621
+ end if;
622
+ if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
623
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
624
+ end if;
625
+
626
+ return query
627
+ insert into agent_board.messages(thread_id, author_agent_id, task_id, message_sequence, message_type, body, metadata)
628
+ values (p_thread_id, v_actor_agent_id, p_task_id, v_sequence, coalesce(p_message_type, 'comment'), p_body, v_metadata)
629
+ returning id, messages.thread_id, messages.task_id, messages.message_sequence, messages.created_at;
630
+ end;
631
+ $function$
632
+ ;
633
+
634
+ CREATE OR REPLACE FUNCTION agent_board.create_task(p_thread_id uuid, p_created_by_agent_id uuid, p_title text, p_details text DEFAULT NULL::text, p_assigned_agent_id uuid DEFAULT NULL::uuid, p_parent_task_id uuid DEFAULT NULL::uuid, p_priority text DEFAULT 'medium'::text, p_orchestration_mode text DEFAULT 'distributed'::text, p_metadata jsonb DEFAULT '{}'::jsonb)
635
+ RETURNS TABLE(task_id uuid, parent_task_id uuid, root_task_id uuid, created_event_id uuid, created_event_sequence bigint, status text, created_at timestamp with time zone, updated_at timestamp with time zone)
636
+ LANGUAGE plpgsql
637
+ SECURITY DEFINER
638
+ SET search_path TO 'agent_board', 'public'
639
+ AS $function$
640
+ declare
641
+ v_task_id uuid;
642
+ v_root_task_id uuid;
643
+ v_event_id uuid;
644
+ v_actor_agent_id uuid;
645
+ begin
646
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
647
+
648
+ if nullif(trim(p_title), '') is null then
649
+ raise exception 'task title is required' using errcode = '22023';
650
+ end if;
651
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
652
+ raise exception 'thread not found' using errcode = 'P0002';
653
+ end if;
654
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
655
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
656
+ end if;
657
+ if (select board_id is null from agent_board.threads where id = p_thread_id) then
658
+ perform 1 from agent_board.threads where id = p_thread_id for no key update;
659
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
660
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
661
+ end if;
662
+ end if;
663
+ -- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role to author.
664
+ if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
665
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
666
+ end if;
667
+ if p_assigned_agent_id is not null and not exists (select 1 from agent_board.agents where id = p_assigned_agent_id) then
668
+ raise exception 'assigned agent not found' using errcode = 'P0002';
669
+ end if;
670
+ -- HOSTED (D16): a hosted delegate can act on no task, so it is never an assignee.
671
+ perform agent_board._refuse_delegate_addressee(p_assigned_agent_id, null, 'task assignment');
672
+
673
+ if p_parent_task_id is not null then
674
+ select coalesce(tasks.root_task_id, tasks.id)
675
+ into v_root_task_id
676
+ from agent_board.tasks
677
+ where id = p_parent_task_id and tasks.thread_id = p_thread_id;
678
+ if v_root_task_id is null then
679
+ raise exception 'parent task not found in thread' using errcode = '22023';
680
+ end if;
681
+ if not agent_board._current_agent_can_access_task(p_parent_task_id) then
682
+ raise exception 'parent task not accessible to current agent' using errcode = '42501';
683
+ end if;
684
+ end if;
685
+
686
+ v_task_id := gen_random_uuid();
687
+ if p_parent_task_id is null then
688
+ v_root_task_id := v_task_id;
689
+ end if;
690
+
691
+ insert into agent_board.tasks(id, thread_id, created_by_agent_id, assigned_agent_id, parent_task_id, root_task_id, title, details, priority, orchestration_mode, metadata)
692
+ values (v_task_id, p_thread_id, v_actor_agent_id, p_assigned_agent_id, p_parent_task_id, v_root_task_id, trim(p_title), p_details, coalesce(p_priority, 'medium'), coalesce(p_orchestration_mode, 'distributed'), coalesce(p_metadata, '{}'::jsonb));
693
+
694
+ -- #288: create_task is the ONE writer that inserts into task_events directly rather than
695
+ -- through the funnel, which is why a fix instrumenting only append_task_event's callers
696
+ -- would miss it. Its own declared posture is signed-metadata (it declares p_metadata and the
697
+ -- client signs it). No parameter is threaded here deliberately -- see the plan's section 5:
698
+ -- where a signed wrapper is involved (signed_create_tasking_delivery keys its evidence to
699
+ -- THIS event, two levels up), a real signature_evidence row exists and v_task_tree already
700
+ -- surfaces it, which is the stronger signal. A threaded parameter would change no answer.
701
+ insert into agent_board.task_events(task_id, thread_id, actor_agent_id, event_sequence, event_type, payload, origin_posture)
702
+ values (v_task_id, p_thread_id, v_actor_agent_id, 1, 'created', jsonb_build_object('title', trim(p_title)), 'unattested:signed-metadata-rpc')
703
+ returning id into v_event_id;
704
+
705
+ return query
706
+ select t.id, t.parent_task_id, t.root_task_id, v_event_id, 1::bigint, t.status, t.created_at, t.updated_at
707
+ from agent_board.tasks t
708
+ where t.id = v_task_id;
709
+ end;
710
+ $function$
711
+ ;
712
+
713
+ CREATE OR REPLACE FUNCTION agent_board.raise_conflict(p_thread_id uuid, p_raised_by_agent_id uuid, p_summary text, p_conflict_type text DEFAULT 'other'::text, p_severity text DEFAULT 'medium'::text, p_task_id uuid DEFAULT NULL::uuid, p_details jsonb DEFAULT '{}'::jsonb)
714
+ RETURNS TABLE(conflict_id uuid, status text, task_event_sequence bigint, created_at timestamp with time zone, updated_at timestamp with time zone)
715
+ LANGUAGE plpgsql
716
+ SECURITY DEFINER
717
+ SET search_path TO 'agent_board', 'public'
718
+ AS $function$
719
+ declare
720
+ v_conflict_id uuid;
721
+ v_event_sequence bigint;
722
+ v_actor_agent_id uuid;
723
+ begin
724
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_raised_by_agent_id, 'p_raised_by_agent_id');
725
+
726
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
727
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
728
+ end if;
729
+ if (select board_id is null from agent_board.threads where id = p_thread_id) then
730
+ perform 1 from agent_board.threads where id = p_thread_id for no key update;
731
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
732
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
733
+ end if;
734
+ end if;
735
+ -- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role.
736
+ if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
737
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
738
+ end if;
739
+
740
+ insert into agent_board.conflicts(thread_id, task_id, raised_by_agent_id, summary, conflict_type, severity, details)
741
+ values (p_thread_id, p_task_id, v_actor_agent_id, p_summary, coalesce(p_conflict_type, 'other'), coalesce(p_severity, 'medium'), coalesce(p_details, '{}'::jsonb))
742
+ returning id into v_conflict_id;
743
+
744
+ -- v_event_sequence stays NULL for a thread-level conflict (no task) — fixes the same latent
745
+ -- "record not assigned yet" bug on the no-task path.
746
+ if p_task_id is not null then
747
+ select event_sequence into v_event_sequence
748
+ from agent_board._append_task_event('unattested:unsigned-deliberate-rpc', p_task_id, v_actor_agent_id, 'conflict_raised', jsonb_build_object('conflict_id', v_conflict_id));
749
+ end if;
750
+
751
+ return query
752
+ select c.id, c.status, v_event_sequence, c.created_at, c.updated_at
753
+ from agent_board.conflicts c
754
+ where c.id = v_conflict_id;
755
+ end;
756
+ $function$
757
+ ;
758
+
759
+ CREATE OR REPLACE FUNCTION agent_board.raise_escalation(p_thread_id uuid, p_raised_by_agent_id uuid, p_summary text, p_escalation_type text DEFAULT 'other'::text, p_severity text DEFAULT 'medium'::text, p_task_id uuid DEFAULT NULL::uuid, p_assigned_agent_id uuid DEFAULT NULL::uuid, p_details jsonb DEFAULT '{}'::jsonb)
760
+ RETURNS TABLE(escalation_id uuid, status text, task_event_sequence bigint, created_at timestamp with time zone, updated_at timestamp with time zone)
761
+ LANGUAGE plpgsql
762
+ SECURITY DEFINER
763
+ SET search_path TO 'agent_board', 'public'
764
+ AS $function$
765
+ declare
766
+ v_escalation_id uuid;
767
+ v_event_sequence bigint;
768
+ v_actor_agent_id uuid;
769
+ begin
770
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_raised_by_agent_id, 'p_raised_by_agent_id');
771
+
772
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
773
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
774
+ end if;
775
+ if (select board_id is null from agent_board.threads where id = p_thread_id) then
776
+ perform 1 from agent_board.threads where id = p_thread_id for no key update;
777
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
778
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
779
+ end if;
780
+ end if;
781
+ -- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role.
782
+ if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
783
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
784
+ end if;
785
+
786
+ -- HOSTED (D16): a hosted delegate can act on no escalation, so it is never an assignee.
787
+ perform agent_board._refuse_delegate_addressee(p_assigned_agent_id, null, 'escalation assignment');
788
+
789
+ insert into agent_board.escalations(thread_id, task_id, raised_by_agent_id, assigned_agent_id, summary, escalation_type, severity, details)
790
+ values (p_thread_id, p_task_id, v_actor_agent_id, p_assigned_agent_id, p_summary, coalesce(p_escalation_type, 'other'), coalesce(p_severity, 'medium'), coalesce(p_details, '{}'::jsonb))
791
+ returning id into v_escalation_id;
792
+
793
+ -- v_event_sequence stays NULL for a thread-level escalation (no task) — fixes the same latent
794
+ -- "record not assigned yet" bug on the no-task path.
795
+ if p_task_id is not null then
796
+ select event_sequence into v_event_sequence
797
+ from agent_board._append_task_event('unattested:unsigned-deliberate-rpc', p_task_id, v_actor_agent_id, 'escalated', jsonb_build_object('escalation_id', v_escalation_id));
798
+ end if;
799
+
800
+ return query
801
+ select e.id, e.status, v_event_sequence, e.created_at, e.updated_at
802
+ from agent_board.escalations e
803
+ where e.id = v_escalation_id;
804
+ end;
805
+ $function$
806
+ ;
807
+
808
+
809
+ CREATE OR REPLACE FUNCTION agent_board.add_artifact(p_created_by_agent_id uuid, p_uri text, p_artifact_type text DEFAULT 'url'::text, p_storage_kind text DEFAULT 'external_url'::text, p_thread_id uuid DEFAULT NULL::uuid, p_task_id uuid DEFAULT NULL::uuid, p_message_id uuid DEFAULT NULL::uuid, p_task_event_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
810
+ RETURNS TABLE(artifact_id uuid, task_event_sequence bigint, created_at timestamp with time zone)
811
+ LANGUAGE plpgsql
812
+ SECURITY DEFINER
813
+ SET search_path TO 'agent_board', 'public'
814
+ AS $function$
815
+ declare
816
+ v_artifact_id uuid;
817
+ v_event_sequence bigint;
818
+ v_actor_agent_id uuid;
819
+ v_thread_id uuid;
820
+ v_anchor_thread uuid;
821
+ v_anchor_threads uuid[];
822
+ begin
823
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
824
+
825
+ if p_thread_id is null and p_task_id is null and p_message_id is null and p_task_event_id is null then
826
+ raise exception 'artifact target is required' using errcode = '22023';
827
+ end if;
828
+
829
+ if p_task_id is not null then
830
+ if not agent_board._current_agent_can_access_task(p_task_id) then
831
+ raise exception 'task not accessible to current agent' using errcode = '42501';
832
+ end if;
833
+ end if;
834
+
835
+ if p_thread_id is not null and not agent_board._current_agent_can_access_thread(p_thread_id) then
836
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
837
+ end if;
838
+
839
+ if p_message_id is not null then
840
+ select m.thread_id into v_thread_id from agent_board.messages m where m.id = p_message_id;
841
+ if v_thread_id is null or not agent_board._current_agent_can_access_thread(v_thread_id) then
842
+ raise exception 'message not accessible to current agent' using errcode = '42501';
843
+ end if;
844
+ end if;
845
+
846
+ if p_task_event_id is not null then
847
+ select te.thread_id into v_thread_id from agent_board.task_events te where te.id = p_task_event_id;
848
+ if v_thread_id is null or not agent_board._current_agent_can_access_thread(v_thread_id) then
849
+ raise exception 'task event not accessible to current agent' using errcode = '42501';
850
+ end if;
851
+ end if;
852
+
853
+ -- OBSGATE-2 (#123): apply the posting gate to the artifact's effective thread (resolved from
854
+ -- whichever target was supplied). A board-linked thread requires a non-observer board role.
855
+ v_thread_id := coalesce(
856
+ p_thread_id,
857
+ v_thread_id,
858
+ (select tk.thread_id from agent_board.tasks tk where tk.id = p_task_id)
859
+ );
860
+ if v_thread_id is not null and not agent_board._current_agent_can_post_to_thread(v_thread_id) then
861
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
862
+ end if;
863
+
864
+ -- Every supplied anchor can persist a reference, even when another anchor is also
865
+ -- supplied. Lock all distinct thread identities in deterministic order before task locks.
866
+ select array_agg(anchor_thread order by anchor_thread) into v_anchor_threads
867
+ from (select distinct anchor_thread from (
868
+ select p_thread_id as anchor_thread
869
+ union all select thread_id from agent_board.tasks where id = p_task_id
870
+ union all select thread_id from agent_board.messages where id = p_message_id
871
+ union all select thread_id from agent_board.task_events where id = p_task_event_id
872
+ ) anchors where anchor_thread is not null) distinct_anchors;
873
+ foreach v_anchor_thread in array coalesce(v_anchor_threads, '{}'::uuid[]) loop
874
+ if (select board_id is null from agent_board.threads where id = v_anchor_thread) then
875
+ perform 1 from agent_board.threads where id = v_anchor_thread for no key update;
876
+ if not agent_board._current_agent_can_access_thread(v_anchor_thread) then
877
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
878
+ end if;
879
+ if not agent_board._current_agent_can_post_to_thread(v_anchor_thread) then
880
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
881
+ end if;
882
+ end if;
883
+ end loop;
884
+
885
+ insert into agent_board.artifacts(thread_id, task_id, message_id, task_event_id, created_by_agent_id, artifact_type, storage_kind, uri, metadata)
886
+ values (p_thread_id, p_task_id, p_message_id, p_task_event_id, v_actor_agent_id, coalesce(p_artifact_type, 'url'), coalesce(p_storage_kind, 'external_url'), p_uri, coalesce(p_metadata, '{}'::jsonb))
887
+ returning id into v_artifact_id;
888
+
889
+ -- v_event_sequence stays NULL when there is no task (a thread/message/task_event artifact).
890
+ -- The prior `v_event record` + unconditional `v_event.event_sequence` raised
891
+ -- "record not assigned yet" on that no-task path — a latent bug fixed here while these
892
+ -- functions are being rewritten for the gate.
893
+ if p_task_id is not null then
894
+ select event_sequence into v_event_sequence
895
+ from agent_board._append_task_event('unattested:signed-metadata-rpc', p_task_id, v_actor_agent_id, 'artifact_added', jsonb_build_object('artifact_id', v_artifact_id));
896
+ end if;
897
+
898
+ return query
899
+ select v_artifact_id, v_event_sequence, now();
900
+ end;
901
+ $function$
902
+ ;
903
+
904
+ CREATE OR REPLACE FUNCTION agent_board.acquire_claim(p_agent_id uuid, p_resource_kind text, p_resource_key text, p_lease_expires_at timestamp with time zone, p_task_id uuid DEFAULT NULL::uuid, p_thread_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
905
+ RETURNS TABLE(claim_id uuid, task_id uuid, thread_id uuid, agent_id uuid, resource_kind text, resource_key text, status text, lease_token uuid, lease_expires_at timestamp with time zone, released_at timestamp with time zone, created_at timestamp with time zone, updated_at timestamp with time zone)
906
+ LANGUAGE plpgsql
907
+ SECURITY DEFINER
908
+ SET search_path TO 'agent_board', 'public'
909
+ AS $function$
910
+ declare
911
+ v_thread_id uuid;
912
+ v_claim_id uuid;
913
+ v_actor_agent_id uuid;
914
+ v_shadow_authorized boolean;
915
+ v_ownerless boolean;
916
+ v_active_ownerless int;
917
+ v_board_id uuid;
918
+ v_board_policy text;
919
+ begin
920
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
921
+
922
+ if nullif(trim(p_resource_key), '') is null then
923
+ raise exception 'resource key is required' using errcode = '22023';
924
+ end if;
925
+
926
+ -- CLAIMKEY (#133): normalize the resource key to its canonical, fleet-fingerprint-carrying
927
+ -- form BEFORE any lookup/insert, so the row stored (and the uniqueness index contended)
928
+ -- carry canonical identity. NORMALIZE-ONLY — this never rejects (rejection is ENFORCE).
929
+ p_resource_key := agent_board._canonical_claim_key(p_resource_kind, p_resource_key);
930
+
931
+ if p_task_id is not null then
932
+ select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
933
+ if v_thread_id is null then
934
+ raise exception 'task not found' using errcode = 'P0002';
935
+ end if;
936
+ if not agent_board._current_agent_can_access_task(p_task_id) then
937
+ raise exception 'task not accessible to current agent' using errcode = '42501';
938
+ end if;
939
+ else
940
+ v_thread_id := p_thread_id;
941
+ end if;
942
+
943
+ if v_thread_id is not null and not agent_board._current_agent_can_access_thread(v_thread_id) then
944
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
945
+ end if;
946
+
947
+ -- message-board#291: the SUBJECT gate. The checks above answer "may this SESSION touch the
948
+ -- task/thread" and are short-circuited true for service_role; this one answers "does the
949
+ -- agent the claim is ABOUT already have access". Without it a service_role caller could name
950
+ -- any agent, and the resulting claims row -- itself an access disjunct, and a PERMANENT one
951
+ -- -- would MINT thread access (and, through the thread disjunct of
952
+ -- _current_agent_can_access_task, task access) rather than record a lock on something the
953
+ -- subject could already see.
954
+ --
955
+ -- UNCONDITIONAL IN *WHO*. DO NOT "optimize" this into `if _is_service_role() and not ...` or
956
+ -- `if v_actor_agent_id is distinct from agent_board.current_agent_id()`. For an authenticated
957
+ -- caller v_actor_agent_id IS current_agent_id() and the session check above already
958
+ -- established this same expression, so the redundancy is PROVABLE rather than suspected; it
959
+ -- is the price of an invariant this function states for itself instead of borrowing from
960
+ -- _resolved_actor_agent_id continuing to raise on a subject mismatch.
961
+ --
962
+ -- CONDITIONAL ON *THREAD PRESENCE*, which is not a loophole. A claim bound to neither a task
963
+ -- nor a thread stores thread_id = null, is a disjunct of nothing and mints no access.
964
+ -- _agent_can_access_thread answers false for a null thread, so gating unconditionally would
965
+ -- refuse every machine / repository / repo_file / ownerless claim in the fleet. This covers
966
+ -- exactly the claims that are keys.
967
+ --
968
+ -- PLACED AFTER THE TASK LOOKUP so a nonexistent task still answers P0002, not 42501 -- that
969
+ -- ordering IS test-pinned. Placed BEFORE the sweep only to match the predicate-before-side-
970
+ -- effects convention CLAIMGATE/ENFORCE states below; it is NOT what makes a denial free of
971
+ -- side effects. `raise exception` unwinds the whole call, so a refused caller changes no state
972
+ -- wherever this sits -- measured, by moving the gate below the sweep and observing the test
973
+ -- file stay entirely green.
974
+ if v_thread_id is not null
975
+ and not agent_board._agent_can_access_thread(v_thread_id, v_actor_agent_id) then
976
+ raise exception 'claim subject has no access to thread' using errcode = '42501';
977
+ end if;
978
+
979
+ -- OBSGATE-4 (#127): a claim on a TASK resource is authorized on the LOCKED task = the task
980
+ -- named by RESOURCE_KEY (resource_kind='task'). The active-resource uniqueness is on
981
+ -- (resource_kind, resource_key), so that is the task a squat contends for — gating resource_key
982
+ -- (NOT p_task_id, which only associates the claim/event) prevents locking task B's key while
983
+ -- authorizing on an unrelated task A passed as p_task_id. A non-uuid 'task' key cannot collide
984
+ -- with a uuid-keyed legitimate claim.
985
+ --
986
+ -- The composition MIRRORS append_task_event's: ACCESS then MUTATE. mutate alone is
987
+ -- insufficient — _current_agent_can_mutate_task admits any non-observer board member via
988
+ -- _current_agent_can_post_to_thread (board send-role), which is NOT thread access; on a
989
+ -- RESTRICTED thread a board member lacking thread access would pass mutate but must still be
990
+ -- denied (they cannot access the task). Requiring access too closes that gap and matches the
991
+ -- authorization a task-BOUND acquire already gets through append_task_event.
992
+ if p_resource_kind = 'task'
993
+ and p_resource_key ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' then
994
+ if not agent_board._current_agent_can_access_task(p_resource_key::uuid) then
995
+ raise exception 'task not accessible to current agent' using errcode = '42501';
996
+ end if;
997
+ if not agent_board._current_agent_can_mutate_task(p_resource_key::uuid) then
998
+ raise exception 'claiming a task resource requires a non-observer board role, or task assignment/creation'
999
+ using errcode = '42501';
1000
+ end if;
1001
+ end if;
1002
+
1003
+ -- CLAIMGATE/SHADOW (#133): compute the fleet-graph authority verdict BEFORE the sweep (so
1004
+ -- ENFORCE can flip this same point fail-closed with no observable side effect leaking to an
1005
+ -- unauthorized caller). The verdict is stamped below regardless of enforcement.
1006
+ v_shadow_authorized := agent_board._can_claim_resource(p_resource_kind, p_resource_key);
1007
+
1008
+ -- CLAIMGATE/ENFORCE (#133): per-kind GATED DENIAL, BEFORE the sweep (predicate-before-side-
1009
+ -- effects). When this fleet-backed kind's flag is ON and the actor is UNAUTHORIZED, DENY —
1010
+ -- no state has changed yet (sweep/insert are below). When OFF, fall through to the shadow-
1011
+ -- stamp only (SHADOW behavior; blocks nothing). Non-fleet kinds never kind-enforce (resolver
1012
+ -- returns FALSE). service_role is authorized by the predicate (wrapper short-circuit), so the
1013
+ -- operator carve-out holds. Fail-closed-on-unknown is automatic: an unresolvable fleet resource
1014
+ -- makes v_shadow_authorized FALSE, so an enforced acquire of it denies.
1015
+ if p_resource_kind in ('machine', 'repository', 'repo_file')
1016
+ and agent_board._claimgate_kind_enforced(p_resource_kind)
1017
+ and not v_shadow_authorized then
1018
+ raise exception 'not authorized to claim this % resource (fleet authority required)', p_resource_kind
1019
+ using errcode = '42501';
1020
+ end if;
1021
+
1022
+ -- CLAIMGATE/ENFORCE (#133): max-lease horizon. For an ENFORCED fleet-backed kind, CLAMP the
1023
+ -- lease to now()+horizon (default 30d, operator-configurable) — a bound on lease term, NOT an
1024
+ -- authority block. Clamp (not reject) so an over-long lease reconciles here / at its next renew.
1025
+ -- service_role exempt (operator carve-out). Only reached by an AUTHORIZED acquire (unauthorized
1026
+ -- was denied above), or when the flag is OFF (then the guard below is skipped entirely).
1027
+ if p_resource_kind in ('machine', 'repository', 'repo_file')
1028
+ and not agent_board._is_service_role()
1029
+ and agent_board._claimgate_kind_enforced(p_resource_kind)
1030
+ and p_lease_expires_at > now() + agent_board._claimgate_max_lease_horizon() then
1031
+ p_lease_expires_at := now() + agent_board._claimgate_max_lease_horizon();
1032
+ end if;
1033
+
1034
+ -- Ownerless tail (file/tool/custom): the ONE guard SHADOW carries — GATED OFF BY DEFAULT.
1035
+ -- _claimgate_ownerless_guards_enabled() is false unless an operator flips it post-census, so in
1036
+ -- SHADOW this block is skipped entirely and NOTHING is blocked (only the stamp above lands).
1037
+ -- service_role is also exempt (operator carve-out, consistent with the predicate short-circuit).
1038
+ v_ownerless := p_resource_kind in ('file', 'tool', 'custom');
1039
+ if v_ownerless
1040
+ and not agent_board._is_service_role()
1041
+ and agent_board._claimgate_ownerless_guards_enabled() then
1042
+ -- #405 THE BOARD LOCK, BEFORE THE POLICY DECISION AND ON THE PATH THAT SKIPS EVERYTHING.
1043
+ -- The board opt-out USED TO BE a conjunct of the `if` above, which put the policy read before
1044
+ -- any lock and — worse — before the agent-scoped quota lock that lives INSIDE this block. An
1045
+ -- acquire that read a stale 'advisory' skipped the whole block and therefore took no lock at
1046
+ -- all, so ordering against the agent lock was meaningless. The lock is hoisted OUT of the
1047
+ -- decision and taken first, on every path through it.
1048
+ --
1049
+ -- THREE STATEMENTS, DELIBERATELY. Under READ COMMITTED each of these takes a FRESH SNAPSHOT,
1050
+ -- so the policy read below observes a revert that committed while the lock was being waited
1051
+ -- for. Fused into one statement it would not: a single statement's snapshot is taken before
1052
+ -- the lock is granted. `_claimgate_lock_board_policy` REFUSES any other isolation level for
1053
+ -- exactly this reason (see its comment) rather than silently reading stale policy.
1054
+ v_board_id := agent_board._claimgate_board_of_thread(v_thread_id);
1055
+ perform agent_board._claimgate_lock_board_policy(v_board_id);
1056
+ -- RESOLVED FROM THE BOARD WE LOCKED, not from the thread again. `threads.board_id` is
1057
+ -- first-board-wins (_associate_empty_thread stamps it only `where board_id is null`), so a
1058
+ -- board can never MOVE — but a null board can be STAMPED during the wait. Re-resolving through
1059
+ -- the thread would then read a board whose lock we do not hold, which is the same race one
1060
+ -- level down. Resolving from v_board_id makes a stamp arriving mid-wait resolve to 'inherit',
1061
+ -- the ENFORCING answer, so the drift is conservative by construction.
1062
+ v_board_policy := agent_board._claimgate_board_claim_policy_of(v_board_id);
1063
+
1064
+ -- BOARD OPT-OUT: a board whose work is coordinated by SOMEONE ELSE'S locking (a repo lock, a
1065
+ -- scheduler lease, an external mutex) can set claim_policy='advisory'. Our claims are then
1066
+ -- RECORDED for visibility and never ENFORCED, so we cannot reject an acquire that their own
1067
+ -- lock already permitted. Resolved from the claim's thread; a claim with no thread, or a
1068
+ -- thread with no board, has no board to ask and falls back to the fleet-wide flags.
1069
+ if v_board_policy <> 'advisory' then
1070
+ -- TTL CAP: an ownerless lease may not exceed the max horizon. Rejects an over-long acquire;
1071
+ -- this is a lease-length bound, NOT an authority block.
1072
+ if p_lease_expires_at > now() + agent_board._claimgate_ownerless_ttl_cap() then
1073
+ raise exception 'ownerless claim lease horizon exceeds cap (%)', agent_board._claimgate_ownerless_ttl_cap()
1074
+ using errcode = '22023';
1075
+ end if;
1076
+
1077
+ -- QUOTA (transactionally serialized): an actor-scoped advisory XACT lock serializes this
1078
+ -- actor's concurrent acquisitions (on DIFFERENT keys, which the row-level uniqueness index
1079
+ -- does NOT), so a count-before-insert cannot race two claims past the cap. The lock releases
1080
+ -- at commit; it does not block other actors (the key mixes in the actor id). TAKEN AFTER THE
1081
+ -- BOARD LOCK, never before it: board-then-agent is the one order every path here uses.
1082
+ perform pg_advisory_xact_lock(hashtext('agent_board.claimgate_ownerless_quota'), hashtext(v_actor_agent_id::text));
1083
+ -- #388: THE COUNT IS THE SHARED HELPER NOW, not an inline predicate. renew_claim asks the
1084
+ -- same question one line of arithmetic away, and two hand-copies of a predicate across two
1085
+ -- enforcement paths is rule 1's own failure shape. The ADVISORY LOCK ABOVE STAYS HERE AND IS
1086
+ -- NOT MIRRORED INTO RENEW, deliberately: it exists because acquire COUNTS THEN INSERTS, and a
1087
+ -- renew inserts nothing (see 20260928000100's header).
1088
+ v_active_ownerless := agent_board._claimgate_active_ownerless_claims(v_actor_agent_id);
1089
+ if v_active_ownerless >= agent_board._claimgate_ownerless_quota() then
1090
+ raise exception 'ownerless active-claim quota reached (% per agent)', agent_board._claimgate_ownerless_quota()
1091
+ using errcode = '54000';
1092
+ end if;
1093
+ end if;
1094
+ end if;
1095
+
1096
+ -- Task-bound claims store the thread resolved from the task above. Thread-only
1097
+ -- claims create independent history and must serialize with first association.
1098
+ -- This is after the existing board/actor advisory locks and before claim writes.
1099
+ if p_task_id is null and v_thread_id is not null
1100
+ and (select board_id is null from agent_board.threads where id = v_thread_id) then
1101
+ perform 1 from agent_board.threads where id = v_thread_id for no key update;
1102
+ if not agent_board._current_agent_can_access_thread(v_thread_id) then
1103
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
1104
+ end if;
1105
+ if not agent_board._agent_can_access_thread(v_thread_id, v_actor_agent_id) then
1106
+ raise exception 'claim subject has no access to thread' using errcode = '42501';
1107
+ end if;
1108
+ if not agent_board._current_agent_can_post_to_thread(v_thread_id) then
1109
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
1110
+ end if;
1111
+ end if;
1112
+
1113
+ -- CLAIMKEY (#133) MUST-FIX 3: the sweep AND the conflict check are ALIAS-AWARE. p_resource_key
1114
+ -- is already canonical (above); comparing against the CANONICAL form of each existing row's key
1115
+ -- matches both a canonical row AND an un-healed LEGACY row for the same physical resource. Without
1116
+ -- this, acquire('machine','machine:fpDH') would normalize to 'fpDH', see no exact-'fpDH' conflict
1117
+ -- against a still-active legacy 'machine:fpDH' row, and INSERT a second live hold — breaking mutual
1118
+ -- exclusion during the legacy window. Closing it here in code does NOT depend on the operator-owned
1119
+ -- live census.
1120
+ update agent_board.claims c
1121
+ set status = 'expired',
1122
+ updated_at = now()
1123
+ where c.resource_kind = p_resource_kind
1124
+ and agent_board._canonical_claim_key(c.resource_kind, c.resource_key) = p_resource_key
1125
+ and c.status = 'active'
1126
+ and c.released_at is null
1127
+ and c.lease_expires_at <= now();
1128
+
1129
+ if exists (
1130
+ select 1 from agent_board.claims
1131
+ where claims.resource_kind = p_resource_kind
1132
+ and agent_board._canonical_claim_key(claims.resource_kind, claims.resource_key) = p_resource_key
1133
+ and claims.status = 'active'
1134
+ and claims.released_at is null
1135
+ and claims.lease_expires_at > now()
1136
+ ) then
1137
+ raise exception 'conflicting active claim' using errcode = '23505';
1138
+ end if;
1139
+
1140
+ insert into agent_board.claims(task_id, thread_id, agent_id, resource_kind, resource_key, lease_expires_at, metadata)
1141
+ values (
1142
+ p_task_id, v_thread_id, v_actor_agent_id, p_resource_kind, p_resource_key, p_lease_expires_at,
1143
+ -- SHADOW stamp: record the authority verdict alongside any caller metadata. Merged last so it
1144
+ -- is authoritative over a caller-supplied claimgate_shadow key.
1145
+ coalesce(p_metadata, '{}'::jsonb)
1146
+ || jsonb_build_object(
1147
+ 'claimgate_shadow',
1148
+ jsonb_build_object('kind', p_resource_kind, 'authorized', v_shadow_authorized)
1149
+ )
1150
+ )
1151
+ returning id into v_claim_id;
1152
+
1153
+ if p_task_id is not null then
1154
+ perform agent_board._append_task_event('unattested:signed-metadata-rpc', p_task_id, v_actor_agent_id, 'claimed', jsonb_build_object('claim_id', v_claim_id, 'resource_kind', p_resource_kind, 'resource_key', p_resource_key));
1155
+ end if;
1156
+
1157
+ return query
1158
+ select c.id, c.task_id, c.thread_id, c.agent_id, c.resource_kind, c.resource_key, c.status, c.lease_token, c.lease_expires_at, c.released_at, c.created_at, c.updated_at
1159
+ from agent_board.claims c
1160
+ where c.id = v_claim_id;
1161
+ end;
1162
+ $function$
1163
+ ;
1164
+
1165
+ CREATE OR REPLACE FUNCTION agent_board.heartbeat(p_agent_id uuid DEFAULT NULL::uuid, p_thread_id uuid DEFAULT NULL::uuid, p_availability text DEFAULT NULL::text, p_activity_summary text DEFAULT NULL::text, p_expires_in interval DEFAULT '00:05:00'::interval, p_metadata jsonb DEFAULT NULL::jsonb)
1166
+ RETURNS TABLE(agent_id uuid, availability text, thread_id uuid, last_heartbeat_at timestamp with time zone, expires_at timestamp with time zone, updated_at timestamp with time zone)
1167
+ LANGUAGE plpgsql
1168
+ SECURITY DEFINER
1169
+ SET search_path TO 'agent_board', 'public'
1170
+ AS $function$
1171
+ declare
1172
+ v_actor_agent_id uuid;
1173
+ v_effective_thread uuid;
1174
+ begin
1175
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
1176
+
1177
+ -- Unchanged: a supplied thread must exist and be reachable by the caller. A NULL thread now
1178
+ -- means "leave my thread as it is", so it is not a claim about any thread and needs no check.
1179
+ if p_thread_id is not null then
1180
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
1181
+ raise exception 'thread not found' using errcode = 'P0002';
1182
+ end if;
1183
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
1184
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
1185
+ end if;
1186
+ end if;
1187
+
1188
+ -- Serialize presence changes for the actor before resolving a retained thread. A
1189
+ -- NULL argument preserves the stored thread while changing caller text/metadata.
1190
+ perform pg_advisory_xact_lock(hashtext('agent_board.heartbeat_thread'), hashtext(v_actor_agent_id::text));
1191
+ select coalesce(p_thread_id, pr.thread_id) into v_effective_thread
1192
+ from agent_board.presence pr where pr.agent_id = v_actor_agent_id;
1193
+ v_effective_thread := coalesce(v_effective_thread, p_thread_id);
1194
+ if v_effective_thread is not null
1195
+ and (select board_id is null from agent_board.threads where id = v_effective_thread) then
1196
+ perform 1 from agent_board.threads where id = v_effective_thread for no key update;
1197
+ if not agent_board._current_agent_can_access_thread(v_effective_thread) then
1198
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
1199
+ end if;
1200
+ if not agent_board._current_agent_can_post_to_thread(v_effective_thread) then
1201
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
1202
+ end if;
1203
+ end if;
1204
+
1205
+ return query
1206
+ insert into agent_board.presence(agent_id, thread_id, availability, activity_summary, last_heartbeat_at, expires_at, metadata, updated_at)
1207
+ values (
1208
+ v_actor_agent_id,
1209
+ p_thread_id,
1210
+ -- INSERT: a null still lands the documented starting value, so a first heartbeat behaves
1211
+ -- exactly as it did before this migration.
1212
+ coalesce(p_availability, 'ready'),
1213
+ p_activity_summary,
1214
+ now(),
1215
+ now() + coalesce(p_expires_in, interval '5 minutes'),
1216
+ coalesce(p_metadata, '{}'::jsonb),
1217
+ now()
1218
+ )
1219
+ on conflict on constraint presence_pkey do update
1220
+ -- UPDATE: null preserves. `presence.<col>` is the EXISTING row; `excluded.<col>` is what this
1221
+ -- call proposed. Coalescing onto the existing value is the whole fix -- it is what lets a
1222
+ -- lease refresh say nothing about availability.
1223
+ set thread_id = coalesce(excluded.thread_id, presence.thread_id),
1224
+ availability = coalesce(p_availability, presence.availability),
1225
+ activity_summary = coalesce(excluded.activity_summary, presence.activity_summary),
1226
+ metadata = coalesce(p_metadata, presence.metadata),
1227
+ -- These three are the POINT of a heartbeat and always advance.
1228
+ last_heartbeat_at = excluded.last_heartbeat_at,
1229
+ expires_at = excluded.expires_at,
1230
+ updated_at = excluded.updated_at
1231
+ returning presence.agent_id, presence.availability, presence.thread_id, presence.last_heartbeat_at, presence.expires_at, presence.updated_at;
1232
+ end;
1233
+ $function$
1234
+ ;
1235
+
1236
+ -- Restate canonical permissions and catalog comments with every replacement.
1237
+ -- These declarations preserve the grants from a fresh build of the prior tree.
1238
+ revoke all on function agent_board._upsert_delivery_recipient(text,uuid,text,uuid,uuid,uuid,uuid,uuid,jsonb) from public, anon, authenticated, service_role, agent_board_delegate;
1239
+ grant execute on function agent_board._upsert_delivery_recipient(text,uuid,text,uuid,uuid,uuid,uuid,uuid,jsonb) to "service_role";
1240
+ comment on function agent_board._upsert_delivery_recipient(text,uuid,text,uuid,uuid,uuid,uuid,uuid,jsonb) is 'Enforces thread/board identity and the locked empty-history boundary for message, task and delegation delivery sources. Supplying a source ID does not exempt its private history. The sending RPC validates caller authority before its own writes.';
1241
+
1242
+ revoke all on function agent_board.acquire_claim(uuid,text,text,timestamp with time zone,uuid,uuid,jsonb) from public, anon, authenticated, service_role, agent_board_delegate;
1243
+ grant execute on function agent_board.acquire_claim(uuid,text,text,timestamp with time zone,uuid,uuid,jsonb) to "service_role";
1244
+ grant execute on function agent_board.acquire_claim(uuid,text,text,timestamp with time zone,uuid,uuid,jsonb) to "authenticated";
1245
+ comment on function agent_board.acquire_claim(uuid,text,text,timestamp with time zone,uuid,uuid,jsonb) is 'Takes an exclusive lease on (resource_kind, resource_key), optionally bound to a task and/or a thread. The subject is _resolved_actor_agent_id(p_agent_id): authenticated callers are forced to current_agent_id() and fail closed on mismatch; service_role may claim on behalf of another agent. A CLAIM IS A LOCK, NEVER A KEY (message-board#291): because a claims row for (thread_id, agent_id) is itself a disjunct of _agent_can_access_thread, a claim bound to a thread requires the NAMED agent to already pass that predicate, or the call raises 42501 ''claim subject has no access to thread''. This closes the only route to a claims row (no role holds INSERT on agent_board.claims by any route, and acquire_claim is the only agent_board function that inserts one), and with it the only route by which service_role could hand an arbitrary agent durable access to an inherit thread -- and, through the thread disjunct of _current_agent_can_access_task, to its tasks. The gate is UNCONDITIONAL in who (never branched on _is_service_role() or on the subject differing from the session agent) and applies only when the claim is bound to a thread: a task-less, thread-less claim stores thread_id = null, is a disjunct of nothing and mints no access, so gating it would refuse every machine/repository/repo_file/ownerless claim in the fleet. It sits after the task lookup, so a nonexistent task still answers P0002; it also sits before the expiry sweep, by CLAIMGATE convention rather than necessity (a raise unwinds the whole call, so a refused caller changes no state wherever the check sits). On the authenticated path it is provably a no-op: the session check just evaluated the same expression for the same agent. ONE ERROR CODE CHANGES, deliberately: for a service_role caller on a thread-bound claim, a nonexistent p_thread_id -- or a nonexistent named subject -- used to reach the insert and surface as 23503 on the claims foreign keys, and now answers 42501 here instead. That is one rule with two instances, not two changes, and 23503 is NOT restored: preserving it would require probing thread existence before the access gate, which leaks existence to a caller with no access. A THREAD-LESS claim for a nonexistent subject still answers 23503, which is the boundary. THE CLAIM IT MINTS IS PERMANENT, AND THAT IS RATIFIED, NOT AN OVERSIGHT -- see the comment on _current_agent_can_access_thread. Other gates on this function are unchanged: OBSGATE-4 task-resource authorization (#127), CLAIMKEY canonicalization and CLAIMGATE shadow/enforce fleet authority (#133).';
1246
+
1247
+ revoke all on function agent_board.add_artifact(uuid,text,text,text,uuid,uuid,uuid,uuid,jsonb) from public, anon, authenticated, service_role, agent_board_delegate;
1248
+ grant execute on function agent_board.add_artifact(uuid,text,text,text,uuid,uuid,uuid,uuid,jsonb) to "service_role";
1249
+ grant execute on function agent_board.add_artifact(uuid,text,text,text,uuid,uuid,uuid,uuid,jsonb) to "authenticated";
1250
+ comment on function agent_board.add_artifact(uuid,text,text,text,uuid,uuid,uuid,uuid,jsonb) is 'Records an artifact using its supplied thread, message, task and event anchors. Unassociated anchor threads are locked in deterministic order and access is rechecked after waiting.';
1251
+
1252
+ revoke all on function agent_board.create_task(uuid,uuid,text,text,uuid,uuid,text,text,jsonb) from public, anon, authenticated, service_role, agent_board_delegate;
1253
+ grant execute on function agent_board.create_task(uuid,uuid,text,text,uuid,uuid,text,text,jsonb) to "service_role";
1254
+ grant execute on function agent_board.create_task(uuid,uuid,text,text,uuid,uuid,text,text,jsonb) to "authenticated";
1255
+ comment on function agent_board.create_task(uuid,uuid,text,text,uuid,uuid,text,text,jsonb) is 'Creates a task under the existing access and posting gates. Writes to an unassociated thread serialize with first board association and recheck access after waiting.';
1256
+
1257
+ revoke all on function agent_board.create_tasking_delivery(uuid,uuid,text,text,uuid[],uuid,text,text,text,jsonb) from public, anon, authenticated, service_role, agent_board_delegate;
1258
+ grant execute on function agent_board.create_tasking_delivery(uuid,uuid,text,text,uuid[],uuid,text,text,text,jsonb) to "service_role";
1259
+ grant execute on function agent_board.create_tasking_delivery(uuid,uuid,text,text,uuid[],uuid,text,text,text,jsonb) to "authenticated";
1260
+ comment on function agent_board.create_tasking_delivery(uuid,uuid,text,text,uuid[],uuid,text,text,text,jsonb) is 'Creates or reuses a tasking send after validating the session actor and any board target. First board association precedes its own task and event writes; explicit recipient delivery rows preserve their private scope.';
1261
+
1262
+ revoke all on function agent_board.heartbeat(uuid,uuid,text,text,interval,jsonb) from public, anon, authenticated, service_role, agent_board_delegate;
1263
+ grant execute on function agent_board.heartbeat(uuid,uuid,text,text,interval,jsonb) to "service_role";
1264
+ grant execute on function agent_board.heartbeat(uuid,uuid,text,text,interval,jsonb) to "authenticated";
1265
+ comment on function agent_board.heartbeat(uuid,uuid,text,text,interval,jsonb) is '#366/F1+F4: asserts liveness for the acting agent. Every optional field defaults to NULL and NULL means LEAVE UNCHANGED on an existing row -- so a caller refreshing only the lease does not reset availability to ''ready'' or clear thread_id/activity_summary/metadata. On a FIRST heartbeat a null still lands the documented starting value (''ready'', ''{}''). Consequence, deliberate: thread_id and activity_summary cannot be CLEARED through this RPC, because null is how a caller declines to change them. Before this migration p_availability defaulted to ''ready'', which made "not supplied" indistinguishable from "supplied as ready" and made an implicit activity-coupled heartbeat destructive. p_agent_id also gains a null default (#366/F1): it was required by the SIGNATURE though the body already resolves the actor from the session via _resolved_actor_agent_id, so an authenticated caller may now omit it entirely. service_role must still supply it -- that resolver raises 22023 for a null id under service_role, unchanged.';
1266
+
1267
+ 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;
1268
+ grant execute on function agent_board.post_board_message(uuid,uuid,uuid,text,text,text,text,text,jsonb) to "service_role";
1269
+ grant execute on function agent_board.post_board_message(uuid,uuid,uuid,text,text,text,text,text,jsonb) to "authenticated";
1270
+ grant execute on function agent_board.post_board_message(uuid,uuid,uuid,text,text,text,text,text,jsonb) to "agent_board_delegate";
1271
+ 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.';
1272
+
1273
+ revoke all on function agent_board.post_message(uuid,uuid,text,text,uuid,jsonb) from public, anon, authenticated, service_role, agent_board_delegate;
1274
+ grant execute on function agent_board.post_message(uuid,uuid,text,text,uuid,jsonb) to "service_role";
1275
+ grant execute on function agent_board.post_message(uuid,uuid,text,text,uuid,jsonb) to "authenticated";
1276
+ grant execute on function agent_board.post_message(uuid,uuid,text,text,uuid,jsonb) to "agent_board_delegate";
1277
+ comment on function agent_board.post_message(uuid,uuid,text,text,uuid,jsonb) is 'Posts to an accessible thread after the message-sequence lock and a fresh posting-authority check, preserving session-derived author and hosted-delegate metadata.';
1278
+
1279
+ revoke all on function agent_board.raise_conflict(uuid,uuid,text,text,text,uuid,jsonb) from public, anon, authenticated, service_role, agent_board_delegate;
1280
+ grant execute on function agent_board.raise_conflict(uuid,uuid,text,text,text,uuid,jsonb) to "service_role";
1281
+ grant execute on function agent_board.raise_conflict(uuid,uuid,text,text,text,uuid,jsonb) to "authenticated";
1282
+ comment on function agent_board.raise_conflict(uuid,uuid,text,text,text,uuid,jsonb) is 'Records a conflict after validating access and serializing an unassociated target thread with first board association, including taskful conflicts whose target thread differs from the task thread.';
1283
+
1284
+ revoke all on function agent_board.raise_escalation(uuid,uuid,text,text,text,uuid,uuid,jsonb) from public, anon, authenticated, service_role, agent_board_delegate;
1285
+ grant execute on function agent_board.raise_escalation(uuid,uuid,text,text,text,uuid,uuid,jsonb) to "service_role";
1286
+ grant execute on function agent_board.raise_escalation(uuid,uuid,text,text,text,uuid,uuid,jsonb) to "authenticated";
1287
+ comment on function agent_board.raise_escalation(uuid,uuid,text,text,text,uuid,uuid,jsonb) is 'Records an escalation under the existing actor and assignee gates after serializing an unassociated target thread with first board association.';