@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.
- package/fixtures/already-applied.json +28 -4
- package/migrations/20261026000100_agent_board_thread_association.sql +1287 -0
- package/migrations/20261101000100_agent_board_session_measured_mint.sql +145 -0
- package/migrations/20261104065658_agent_board_reply_delivery.sql +659 -0
- package/migrations/20261104170000_agent_board_tasking_replay_scope.sql +320 -0
- package/migrations/20261104180000_agent_board_delivery_writer_execute.sql +51 -0
- package/migrations/20261104190000_agent_board_message_replay_scope.sql +373 -0
- package/package.json +1 -1
- package/schema-manifest.json +57 -3
- package/src/tooling.test.mjs +8 -2
- package/tests/agent_board_661_reply_delivery.test.sql +185 -0
- package/tests/agent_board_667_boundary.test.sql +162 -0
- package/tests/agent_board_667_conversion_branches.test.sql +91 -0
- package/tests/agent_board_667_conversion_profile.test.sql +112 -0
- package/tests/agent_board_667_history_class.test.sql +785 -0
- package/tests/agent_board_667_native_cast_effect.test.sql +88 -0
- package/tests/agent_board_667_realtime_apply_behavior.test.sql +117 -0
- package/tests/agent_board_667_realtime_apply_controls.test.sql +143 -0
- package/tests/agent_board_667_realtime_apply_profile.test.sql +61 -0
- package/tests/agent_board_667_retention.test.sql +201 -0
- package/tests/agent_board_667_routing.test.sql +294 -0
- package/tests/agent_board_667_sender_effects.test.sql +100 -0
- package/tests/agent_board_667_sender_profile_controls.test.sql +29 -0
- package/tests/agent_board_667_storage_delimiter_behavior.test.sql +84 -0
- package/tests/agent_board_667_storage_delimiter_controls.test.sql +23 -0
- package/tests/agent_board_667_storage_delimiter_profile.test.sql +61 -0
- package/tests/agent_board_667_storage_search_behavior.test.sql +93 -0
- package/tests/agent_board_667_storage_search_controls.test.sql +22 -0
- package/tests/agent_board_667_storage_search_profile.test.sql +60 -0
- package/tests/agent_board_667_storage_timestamp_behavior.test.sql +58 -0
- package/tests/agent_board_667_storage_timestamp_controls.test.sql +22 -0
- package/tests/agent_board_667_storage_timestamp_profile.test.sql +54 -0
- package/tests/agent_board_667_writers.test.sql +281 -0
- package/tests/agent_board_717_message_replay_scope.test.sql +828 -0
- package/tests/agent_board_717_tasking_replay_scope.test.sql +661 -0
- package/tests/agent_board_addressing.test.sql +8 -5
- package/tests/agent_board_claim_subject_gate.test.sql +7 -3
- package/tests/agent_board_claimpolicy_race.test.sql +39 -11
- package/tests/agent_board_delegate_role_class.test.sql +33 -15
- package/tests/agent_board_delivery_writer_execute.test.sql +371 -0
- package/tests/agent_board_fk_lock_edges.test.sql +3 -0
- package/tests/agent_board_hosted_delegates.test.sql +5 -2
- package/tests/agent_board_inbox_rpcs.test.sql +5 -3
- package/tests/agent_board_obsgate.test.sql +8 -4
- package/tests/agent_board_obsgate4.test.sql +2 -2
- package/tests/agent_board_outbox_wake_ordering.test.sql +25 -2
- package/tests/agent_board_own_post_not_delivered.test.sql +11 -9
- package/tests/agent_board_session_identity_behavior_classes.test.sql +2 -2
- package/tests/agent_board_session_identity_limits.test.sql +1 -1
- package/tests/agent_board_session_identity_measured_mint.test.sql +342 -0
- package/tests/agent_board_session_identity_predicate_isolation.test.sql +146 -0
- package/tests/agent_board_sidserver_authority.test.sql +69 -0
- package/tests/agent_board_sidserver_catalog.test.sql +47 -0
- package/tests/agent_board_signed_write_gateway.test.sql +4 -2
- package/tests/agent_board_task_thread_lock_order.test.sql +67 -42
- package/tests/agent_board_v2_invariants.test.sql +5 -2
- package/tests/agent_board_write_enforcement_class.test.sql +3 -3
- package/tests/catalog/function_execute_privileges.json +41 -5
- package/tests/catalog/p_metadata_parameters.json +16 -0
- package/tests/fixtures/agent_board_667_apply_rls_guard.sql +195 -0
- package/tests/fixtures/agent_board_667_apply_rls_provenance.md +125 -0
- package/tests/fixtures/agent_board_667_apply_rls_reference.sql +1903 -0
- package/tests/fixtures/agent_board_667_conversion_guard.sql +162 -0
- package/tests/fixtures/agent_board_667_conversion_reference.md +23 -0
- package/tests/fixtures/agent_board_667_conversion_reference.sql +5325 -0
- package/tests/fixtures/agent_board_667_delimiter_guard.sql +92 -0
- package/tests/fixtures/agent_board_667_delimiter_provenance.md +116 -0
- package/tests/fixtures/agent_board_667_delimiter_reference.sql +10150 -0
- package/tests/fixtures/agent_board_667_search_guard.sql +92 -0
- package/tests/fixtures/agent_board_667_search_provenance.md +141 -0
- package/tests/fixtures/agent_board_667_search_reference.sql +4779 -0
- package/tests/fixtures/agent_board_667_sender_guard.sql +102 -0
- package/tests/fixtures/agent_board_667_sender_reference.md +20 -0
- package/tests/fixtures/agent_board_667_sender_reference.sql +875 -0
- package/tests/fixtures/agent_board_667_timestamp_graph.sql +84 -0
- package/tests/fixtures/agent_board_667_timestamp_guard.sql +94 -0
- package/tests/fixtures/agent_board_667_timestamp_provenance.md +99 -0
- package/tests/fixtures/agent_board_667_timestamp_reference.sql +2938 -0
- package/tests/isolation/expected/association_artifact_anchor_order.out +108 -0
- package/tests/isolation/expected/association_board_vs_board.out +71 -0
- package/tests/isolation/expected/association_post_wait_authority.out +77 -0
- package/tests/isolation/expected/association_strong_history_writers.out +203 -0
- package/tests/isolation/expected/association_strong_isolation.out +185 -0
- package/tests/isolation/expected/association_vs_artifact_claim_escalation.out +199 -0
- package/tests/isolation/expected/association_vs_heartbeat.out +78 -0
- package/tests/isolation/expected/association_vs_multi_anchor_artifact.out +67 -0
- package/tests/isolation/expected/association_vs_private_message.out +67 -0
- package/tests/isolation/expected/association_vs_task_and_conflict.out +133 -0
- package/tests/isolation/expected/association_vs_taskful_cross_thread.out +133 -0
- package/tests/isolation/expected/message_replay_scope.out +209 -0
- package/tests/isolation/expected/tasking_replay_scope.out +125 -0
- package/tests/isolation/specs/association_artifact_anchor_order.spec +46 -0
- package/tests/isolation/specs/association_board_vs_board.spec +37 -0
- package/tests/isolation/specs/association_post_wait_authority.spec +48 -0
- package/tests/isolation/specs/association_strong_history_writers.spec +61 -0
- package/tests/isolation/specs/association_strong_isolation.spec +50 -0
- package/tests/isolation/specs/association_vs_artifact_claim_escalation.spec +41 -0
- package/tests/isolation/specs/association_vs_heartbeat.spec +47 -0
- package/tests/isolation/specs/association_vs_multi_anchor_artifact.spec +38 -0
- package/tests/isolation/specs/association_vs_private_message.spec +33 -0
- package/tests/isolation/specs/association_vs_task_and_conflict.spec +38 -0
- package/tests/isolation/specs/association_vs_taskful_cross_thread.spec +42 -0
- package/tests/isolation/specs/message_replay_scope.spec +77 -0
- package/tests/isolation/specs/tasking_replay_scope.spec +72 -0
- package/tests/mutations/717-f1/M0_prefix_main_acl_and_comments.sql +11 -0
- package/tests/mutations/717-f1/M10_wrapper_stops_delegating.sql +7 -0
- package/tests/mutations/717-f1/M11_transitive_recipient_content.sql +8 -0
- package/tests/mutations/717-f1/M12_transitive_recipient_visibility.sql +8 -0
- package/tests/mutations/717-f1/M13_transitive_worker_content.sql +15 -0
- package/tests/mutations/717-f1/M1a_regrant_upsert_delivery_recipient.sql +1 -0
- package/tests/mutations/717-f1/M1b_regrant_upsert_inbox_item.sql +1 -0
- package/tests/mutations/717-f1/M1c_regrant_queue_inbox_callbacks.sql +1 -0
- package/tests/mutations/717-f1/M1d_regrant_queue_board_post_callbacks.sql +1 -0
- package/tests/mutations/717-f1/M1e_regrant_next_thread_message_sequence.sql +1 -0
- package/tests/mutations/717-f1/M2_blanket_schema_grant.sql +1 -0
- package/tests/mutations/717-f1/M3_new_overload_of_sink.sql +10 -0
- package/tests/mutations/717-f1/M4_new_route_calling_sink.sql +7 -0
- package/tests/mutations/717-f1/M5_actor_trusts_supplied_id.sql +5 -0
- package/tests/mutations/717-f1/M6_signed_route_granted_to_authenticated.sql +1 -0
- package/tests/mutations/717-f1/M7_worker_granted_to_authenticated.sql +1 -0
- package/tests/mutations/717-f1/M8_recipient_state_rewrites_visibility.sql +5 -0
- package/tests/mutations/717-f1/M9_public_route_revoked_from_service_role.sql +1 -0
- package/tests/mutations/717-f1/outbox_wake_ordering/W1_regrant_queue_inbox_callbacks.sql +1 -0
- package/tests/mutations/717-f1/outbox_wake_ordering/W2_revoke_worker_from_service_role.sql +1 -0
- package/tests/mutations/717-f1/outbox_wake_ordering/W3_invoker_caller_of_sink.sql +1 -0
- package/tests/mutations/717-f1/outbox_wake_ordering/W4_public_regrant_board_post_sink.sql +1 -0
- package/tests/mutations/717-f1/run.sh +17 -0
- package/tests/mutations/717-f2b/C1_new_delivery_route.sql +4 -0
- package/tests/mutations/717-f2b/C2_new_replay_lookup.sql +3 -0
- package/tests/mutations/717-f2b/C3_internal_regranted.sql +2 -0
- package/tests/mutations/717-f2b/C4_wrapper_writes_delivery.sql +9 -0
- package/tests/mutations/717-f2b/C5_class_member_dynamic_sql.sql +9 -0
- package/tests/mutations/717-f2b/C6_drop_board_scope_constraint.sql +2 -0
- package/tests/mutations/717-f2b/C7_state_private_helper_content.sql +14 -0
- package/tests/mutations/717-f2b/L1_dm_lock_split_by_recipient.sql +8 -0
- package/tests/mutations/717-f2b/L2_dm_lock_removed.sql +12 -0
- package/tests/mutations/717-f2b/L3_mc_lock_split_by_recipients.sql +8 -0
- package/tests/mutations/717-f2b/L4_mc_lock_removed.sql +12 -0
- package/tests/mutations/717-f2b/M0_prefix_definitions.sql +17 -0
- package/tests/mutations/717-f2b/M10_mc_lookup_any_author.sql +15 -0
- package/tests/mutations/717-f2b/M11_dm_swallow_refusal.sql +8 -0
- package/tests/mutations/717-f2b/M12_mc_swallow_refusal.sql +8 -0
- package/tests/mutations/717-f2b/M13_signed_dm_swallows_refusal.sql +32 -0
- package/tests/mutations/717-f2b/M14_signed_mc_swallows_refusal.sql +20 -0
- package/tests/mutations/717-f2b/M15_mc_any_source.sql +7 -0
- package/tests/mutations/717-f2b/M1_dm_drop_recipient_proof.sql +16 -0
- package/tests/mutations/717-f2b/M2_mc_drop_recipient_proof.sql +16 -0
- package/tests/mutations/717-f2b/M3_dm_metadata_as_proof.sql +12 -0
- package/tests/mutations/717-f2b/M4_mc_metadata_as_proof.sql +16 -0
- package/tests/mutations/717-f2b/M5_dm_any_mode.sql +11 -0
- package/tests/mutations/717-f2b/M6_dm_board_delivery_counts.sql +11 -0
- package/tests/mutations/717-f2b/M7_mc_any_mode.sql +11 -0
- package/tests/mutations/717-f2b/M8_dm_thread_stamp_as_proof.sql +12 -0
- package/tests/mutations/717-f2b/M9_dm_lookup_any_author.sql +15 -0
- 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.';
|