@consiliency/agent-board-schema 1.12.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +124 -0
- package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
- package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
- package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
- package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
- package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
- package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
- package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
- package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
- package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
- package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
- package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
- package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
- package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
- package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
- package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
- package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
- package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
- package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
- package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
- package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
- package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
- package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
- package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
- package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
- package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
- package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
- package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
- package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
- package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
- package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
- package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
- package/package.json +1 -1
- package/schema-manifest.json +282 -3
- package/src/tooling.test.mjs +113 -2
- package/tests/agent_board_actorspoof.test.sql +349 -0
- package/tests/agent_board_agentsdir.test.sql +154 -0
- package/tests/agent_board_authbound_boundary.test.sql +9 -1
- package/tests/agent_board_claimgate_enforce.test.sql +464 -0
- package/tests/agent_board_claimgate_predicate.test.sql +360 -0
- package/tests/agent_board_claimkey.test.sql +229 -0
- package/tests/agent_board_consent_backfill.test.sql +48 -0
- package/tests/agent_board_consent_emission.test.sql +601 -0
- package/tests/agent_board_consent_gating.test.sql +489 -0
- package/tests/agent_board_consent_substrate.test.sql +720 -0
- package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
- package/tests/agent_board_delegation_enforcement.test.sql +55 -6
- package/tests/agent_board_delegation_policies.test.sql +107 -1
- package/tests/agent_board_fedsession.test.sql +6 -4
- package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
- package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
- package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
- package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
- package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
- package/tests/agent_board_fleet_policy.test.sql +2 -2
- package/tests/agent_board_grants_hygiene.test.sql +408 -0
- package/tests/agent_board_identity_providers.test.sql +9 -0
- package/tests/agent_board_inbox_reads.test.sql +29 -12
- package/tests/agent_board_inbox_rpcs.test.sql +44 -34
- package/tests/agent_board_invitepolicy.test.sql +205 -0
- package/tests/agent_board_leaveidem.test.sql +194 -0
- package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
- package/tests/agent_board_mint_harden.test.sql +303 -0
- package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
- package/tests/agent_board_obsgate.test.sql +178 -0
- package/tests/agent_board_obsgate2.test.sql +153 -0
- package/tests/agent_board_obsgate3.test.sql +170 -0
- package/tests/agent_board_obsgate4.test.sql +196 -0
- package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
- package/tests/agent_board_pubexec_class.test.sql +53 -0
- package/tests/agent_board_rejectsem.test.sql +271 -0
- package/tests/agent_board_resigning_refresh.test.sql +1 -1
- package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
- package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
- package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
- package/tests/agent_board_rolereset.test.sql +351 -0
- package/tests/agent_board_roomdeleg.test.sql +5 -3
- package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
- package/tests/agent_board_roomlink.test.sql +25 -9
- package/tests/agent_board_rowid.test.sql +1274 -0
- package/tests/agent_board_signature_rls_reads.test.sql +3 -3
- package/tests/agent_board_signed_write_gateway.test.sql +3 -3
- package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
- package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
- package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
- package/tests/agent_board_signing_store.test.sql +6 -6
- package/tests/fixtures/isolation-substrate.sql +93 -0
- package/tests/fixtures/test-prereqs.sql +33 -0
- package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
- package/tests/isolation/expected/claimkey_renew_race.out +38 -0
- package/tests/isolation/expected/consent_approval_lock.out +43 -0
- package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
- package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
- package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
- package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
- package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
- package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
- package/tests/isolation/specs/consent_approval_lock.spec +98 -0
- package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
- package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
- package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
- package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
- package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
- package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
-- OBSGATE-2 (#123) — extend the board posting rule to every thread-authoring RPC.
|
|
2
|
+
--
|
|
3
|
+
-- #78 (OBSGATE) closed the message-authoring door: an observer can no longer post a MESSAGE
|
|
4
|
+
-- into a board-linked thread (the gate _current_agent_can_post_to_thread is enforced in
|
|
5
|
+
-- post_message). But post_message is not the only way to author into a thread. These
|
|
6
|
+
-- authenticated RPCs author thread-scoped content and gated only on _current_agent_can_access_
|
|
7
|
+
-- thread — no posting-rule / send-role check — so an observer with board-thread access could
|
|
8
|
+
-- still author through them (verified live: create_task and delegate_task):
|
|
9
|
+
--
|
|
10
|
+
-- create_task -> a task + 'created' task_event
|
|
11
|
+
-- delegate_task -> a task (calls create_task) — closed TRANSITIVELY by gating create_task
|
|
12
|
+
-- add_artifact -> an artifact (targets thread / task / message / task_event)
|
|
13
|
+
-- raise_conflict -> a conflict record
|
|
14
|
+
-- raise_escalation -> an escalation record
|
|
15
|
+
--
|
|
16
|
+
-- Maintainer decision: an observer is PURE READ within a board (per-board role — the agent can
|
|
17
|
+
-- still participate on any OTHER board it belongs to, and board-less DM/multicast threads are
|
|
18
|
+
-- ungated), so ALL of the above are gated. The shared #78 predicate is reused: a board-linked
|
|
19
|
+
-- thread requires a non-observer board role; a board-less thread stays access-only; service_role
|
|
20
|
+
-- short-circuits.
|
|
21
|
+
--
|
|
22
|
+
-- Each function is CREATE OR REPLACE'd with the SAME body plus the gate after its existing
|
|
23
|
+
-- access check; grants are re-asserted. delegate_task is intentionally NOT modified — it reaches
|
|
24
|
+
-- create_task(v_parent.thread_id, ...), so the gate fires there.
|
|
25
|
+
--
|
|
26
|
+
-- Additive, forward-only. schema_version stays 8.
|
|
27
|
+
|
|
28
|
+
-- 1. create_task (also closes delegate_task transitively) --------------------
|
|
29
|
+
create or replace function agent_board.create_task(
|
|
30
|
+
p_thread_id uuid,
|
|
31
|
+
p_created_by_agent_id uuid,
|
|
32
|
+
p_title text,
|
|
33
|
+
p_details text default null,
|
|
34
|
+
p_assigned_agent_id uuid default null,
|
|
35
|
+
p_parent_task_id uuid default null,
|
|
36
|
+
p_priority text default 'medium',
|
|
37
|
+
p_orchestration_mode text default 'distributed',
|
|
38
|
+
p_metadata jsonb default '{}'::jsonb
|
|
39
|
+
)
|
|
40
|
+
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)
|
|
41
|
+
language plpgsql
|
|
42
|
+
security definer
|
|
43
|
+
set search_path to 'agent_board', 'public'
|
|
44
|
+
as $function$
|
|
45
|
+
declare
|
|
46
|
+
v_task_id uuid;
|
|
47
|
+
v_root_task_id uuid;
|
|
48
|
+
v_event_id uuid;
|
|
49
|
+
v_actor_agent_id uuid;
|
|
50
|
+
begin
|
|
51
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
|
|
52
|
+
|
|
53
|
+
if nullif(trim(p_title), '') is null then
|
|
54
|
+
raise exception 'task title is required' using errcode = '22023';
|
|
55
|
+
end if;
|
|
56
|
+
if not exists (select 1 from agent_board.threads where id = p_thread_id) then
|
|
57
|
+
raise exception 'thread not found' using errcode = 'P0002';
|
|
58
|
+
end if;
|
|
59
|
+
if not agent_board._current_agent_can_access_thread(p_thread_id) then
|
|
60
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
61
|
+
end if;
|
|
62
|
+
-- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role to author.
|
|
63
|
+
if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
|
|
64
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
65
|
+
end if;
|
|
66
|
+
if p_assigned_agent_id is not null and not exists (select 1 from agent_board.agents where id = p_assigned_agent_id) then
|
|
67
|
+
raise exception 'assigned agent not found' using errcode = 'P0002';
|
|
68
|
+
end if;
|
|
69
|
+
|
|
70
|
+
if p_parent_task_id is not null then
|
|
71
|
+
select coalesce(tasks.root_task_id, tasks.id)
|
|
72
|
+
into v_root_task_id
|
|
73
|
+
from agent_board.tasks
|
|
74
|
+
where id = p_parent_task_id and tasks.thread_id = p_thread_id;
|
|
75
|
+
if v_root_task_id is null then
|
|
76
|
+
raise exception 'parent task not found in thread' using errcode = '22023';
|
|
77
|
+
end if;
|
|
78
|
+
if not agent_board._current_agent_can_access_task(p_parent_task_id) then
|
|
79
|
+
raise exception 'parent task not accessible to current agent' using errcode = '42501';
|
|
80
|
+
end if;
|
|
81
|
+
end if;
|
|
82
|
+
|
|
83
|
+
v_task_id := gen_random_uuid();
|
|
84
|
+
if p_parent_task_id is null then
|
|
85
|
+
v_root_task_id := v_task_id;
|
|
86
|
+
end if;
|
|
87
|
+
|
|
88
|
+
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)
|
|
89
|
+
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));
|
|
90
|
+
|
|
91
|
+
insert into agent_board.task_events(task_id, thread_id, actor_agent_id, event_sequence, event_type, payload)
|
|
92
|
+
values (v_task_id, p_thread_id, v_actor_agent_id, 1, 'created', jsonb_build_object('title', trim(p_title)))
|
|
93
|
+
returning id into v_event_id;
|
|
94
|
+
|
|
95
|
+
return query
|
|
96
|
+
select t.id, t.parent_task_id, t.root_task_id, v_event_id, 1::bigint, t.status, t.created_at, t.updated_at
|
|
97
|
+
from agent_board.tasks t
|
|
98
|
+
where t.id = v_task_id;
|
|
99
|
+
end;
|
|
100
|
+
$function$;
|
|
101
|
+
revoke all on function agent_board.create_task(uuid, uuid, text, text, uuid, uuid, text, text, jsonb) from public, anon;
|
|
102
|
+
grant execute on function agent_board.create_task(uuid, uuid, text, text, uuid, uuid, text, text, jsonb) to authenticated, service_role;
|
|
103
|
+
|
|
104
|
+
-- 2. add_artifact -----------------------------------------------------------
|
|
105
|
+
create or replace function agent_board.add_artifact(
|
|
106
|
+
p_created_by_agent_id uuid,
|
|
107
|
+
p_uri text,
|
|
108
|
+
p_artifact_type text default 'url',
|
|
109
|
+
p_storage_kind text default 'external_url',
|
|
110
|
+
p_thread_id uuid default null,
|
|
111
|
+
p_task_id uuid default null,
|
|
112
|
+
p_message_id uuid default null,
|
|
113
|
+
p_task_event_id uuid default null,
|
|
114
|
+
p_metadata jsonb default '{}'::jsonb
|
|
115
|
+
)
|
|
116
|
+
returns table(artifact_id uuid, task_event_sequence bigint, created_at timestamp with time zone)
|
|
117
|
+
language plpgsql
|
|
118
|
+
security definer
|
|
119
|
+
set search_path to 'agent_board', 'public'
|
|
120
|
+
as $function$
|
|
121
|
+
declare
|
|
122
|
+
v_artifact_id uuid;
|
|
123
|
+
v_event_sequence bigint;
|
|
124
|
+
v_actor_agent_id uuid;
|
|
125
|
+
v_thread_id uuid;
|
|
126
|
+
begin
|
|
127
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
|
|
128
|
+
|
|
129
|
+
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
|
|
130
|
+
raise exception 'artifact target is required' using errcode = '22023';
|
|
131
|
+
end if;
|
|
132
|
+
|
|
133
|
+
if p_task_id is not null then
|
|
134
|
+
if not agent_board._current_agent_can_access_task(p_task_id) then
|
|
135
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
136
|
+
end if;
|
|
137
|
+
end if;
|
|
138
|
+
|
|
139
|
+
if p_thread_id is not null and not agent_board._current_agent_can_access_thread(p_thread_id) then
|
|
140
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
141
|
+
end if;
|
|
142
|
+
|
|
143
|
+
if p_message_id is not null then
|
|
144
|
+
select m.thread_id into v_thread_id from agent_board.messages m where m.id = p_message_id;
|
|
145
|
+
if v_thread_id is null or not agent_board._current_agent_can_access_thread(v_thread_id) then
|
|
146
|
+
raise exception 'message not accessible to current agent' using errcode = '42501';
|
|
147
|
+
end if;
|
|
148
|
+
end if;
|
|
149
|
+
|
|
150
|
+
if p_task_event_id is not null then
|
|
151
|
+
select te.thread_id into v_thread_id from agent_board.task_events te where te.id = p_task_event_id;
|
|
152
|
+
if v_thread_id is null or not agent_board._current_agent_can_access_thread(v_thread_id) then
|
|
153
|
+
raise exception 'task event not accessible to current agent' using errcode = '42501';
|
|
154
|
+
end if;
|
|
155
|
+
end if;
|
|
156
|
+
|
|
157
|
+
-- OBSGATE-2 (#123): apply the posting gate to the artifact's effective thread (resolved from
|
|
158
|
+
-- whichever target was supplied). A board-linked thread requires a non-observer board role.
|
|
159
|
+
v_thread_id := coalesce(
|
|
160
|
+
p_thread_id,
|
|
161
|
+
v_thread_id,
|
|
162
|
+
(select tk.thread_id from agent_board.tasks tk where tk.id = p_task_id)
|
|
163
|
+
);
|
|
164
|
+
if v_thread_id is not null and not agent_board._current_agent_can_post_to_thread(v_thread_id) then
|
|
165
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
166
|
+
end if;
|
|
167
|
+
|
|
168
|
+
insert into agent_board.artifacts(thread_id, task_id, message_id, task_event_id, created_by_agent_id, artifact_type, storage_kind, uri, metadata)
|
|
169
|
+
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))
|
|
170
|
+
returning id into v_artifact_id;
|
|
171
|
+
|
|
172
|
+
-- v_event_sequence stays NULL when there is no task (a thread/message/task_event artifact).
|
|
173
|
+
-- The prior `v_event record` + unconditional `v_event.event_sequence` raised
|
|
174
|
+
-- "record not assigned yet" on that no-task path — a latent bug fixed here while these
|
|
175
|
+
-- functions are being rewritten for the gate.
|
|
176
|
+
if p_task_id is not null then
|
|
177
|
+
select event_sequence into v_event_sequence
|
|
178
|
+
from agent_board.append_task_event(p_task_id, v_actor_agent_id, 'artifact_added', jsonb_build_object('artifact_id', v_artifact_id));
|
|
179
|
+
end if;
|
|
180
|
+
|
|
181
|
+
return query
|
|
182
|
+
select v_artifact_id, v_event_sequence, now();
|
|
183
|
+
end;
|
|
184
|
+
$function$;
|
|
185
|
+
revoke all on function agent_board.add_artifact(uuid, text, text, text, uuid, uuid, uuid, uuid, jsonb) from public, anon;
|
|
186
|
+
grant execute on function agent_board.add_artifact(uuid, text, text, text, uuid, uuid, uuid, uuid, jsonb) to authenticated, service_role;
|
|
187
|
+
|
|
188
|
+
-- 3. raise_conflict ---------------------------------------------------------
|
|
189
|
+
create or replace function agent_board.raise_conflict(
|
|
190
|
+
p_thread_id uuid,
|
|
191
|
+
p_raised_by_agent_id uuid,
|
|
192
|
+
p_summary text,
|
|
193
|
+
p_conflict_type text default 'other',
|
|
194
|
+
p_severity text default 'medium',
|
|
195
|
+
p_task_id uuid default null,
|
|
196
|
+
p_details jsonb default '{}'::jsonb
|
|
197
|
+
)
|
|
198
|
+
returns table(conflict_id uuid, status text, task_event_sequence bigint, created_at timestamp with time zone, updated_at timestamp with time zone)
|
|
199
|
+
language plpgsql
|
|
200
|
+
security definer
|
|
201
|
+
set search_path to 'agent_board', 'public'
|
|
202
|
+
as $function$
|
|
203
|
+
declare
|
|
204
|
+
v_conflict_id uuid;
|
|
205
|
+
v_event_sequence bigint;
|
|
206
|
+
v_actor_agent_id uuid;
|
|
207
|
+
begin
|
|
208
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_raised_by_agent_id, 'p_raised_by_agent_id');
|
|
209
|
+
|
|
210
|
+
if not agent_board._current_agent_can_access_thread(p_thread_id) then
|
|
211
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
212
|
+
end if;
|
|
213
|
+
-- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role.
|
|
214
|
+
if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
|
|
215
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
216
|
+
end if;
|
|
217
|
+
|
|
218
|
+
insert into agent_board.conflicts(thread_id, task_id, raised_by_agent_id, summary, conflict_type, severity, details)
|
|
219
|
+
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))
|
|
220
|
+
returning id into v_conflict_id;
|
|
221
|
+
|
|
222
|
+
-- v_event_sequence stays NULL for a thread-level conflict (no task) — fixes the same latent
|
|
223
|
+
-- "record not assigned yet" bug on the no-task path.
|
|
224
|
+
if p_task_id is not null then
|
|
225
|
+
select event_sequence into v_event_sequence
|
|
226
|
+
from agent_board.append_task_event(p_task_id, v_actor_agent_id, 'conflict_raised', jsonb_build_object('conflict_id', v_conflict_id));
|
|
227
|
+
end if;
|
|
228
|
+
|
|
229
|
+
return query
|
|
230
|
+
select c.id, c.status, v_event_sequence, c.created_at, c.updated_at
|
|
231
|
+
from agent_board.conflicts c
|
|
232
|
+
where c.id = v_conflict_id;
|
|
233
|
+
end;
|
|
234
|
+
$function$;
|
|
235
|
+
revoke all on function agent_board.raise_conflict(uuid, uuid, text, text, text, uuid, jsonb) from public, anon;
|
|
236
|
+
grant execute on function agent_board.raise_conflict(uuid, uuid, text, text, text, uuid, jsonb) to authenticated, service_role;
|
|
237
|
+
|
|
238
|
+
-- 4. raise_escalation -------------------------------------------------------
|
|
239
|
+
create or replace function agent_board.raise_escalation(
|
|
240
|
+
p_thread_id uuid,
|
|
241
|
+
p_raised_by_agent_id uuid,
|
|
242
|
+
p_summary text,
|
|
243
|
+
p_escalation_type text default 'other',
|
|
244
|
+
p_severity text default 'medium',
|
|
245
|
+
p_task_id uuid default null,
|
|
246
|
+
p_assigned_agent_id uuid default null,
|
|
247
|
+
p_details jsonb default '{}'::jsonb
|
|
248
|
+
)
|
|
249
|
+
returns table(escalation_id uuid, status text, task_event_sequence bigint, created_at timestamp with time zone, updated_at timestamp with time zone)
|
|
250
|
+
language plpgsql
|
|
251
|
+
security definer
|
|
252
|
+
set search_path to 'agent_board', 'public'
|
|
253
|
+
as $function$
|
|
254
|
+
declare
|
|
255
|
+
v_escalation_id uuid;
|
|
256
|
+
v_event_sequence bigint;
|
|
257
|
+
v_actor_agent_id uuid;
|
|
258
|
+
begin
|
|
259
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_raised_by_agent_id, 'p_raised_by_agent_id');
|
|
260
|
+
|
|
261
|
+
if not agent_board._current_agent_can_access_thread(p_thread_id) then
|
|
262
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
263
|
+
end if;
|
|
264
|
+
-- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role.
|
|
265
|
+
if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
|
|
266
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
267
|
+
end if;
|
|
268
|
+
|
|
269
|
+
insert into agent_board.escalations(thread_id, task_id, raised_by_agent_id, assigned_agent_id, summary, escalation_type, severity, details)
|
|
270
|
+
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))
|
|
271
|
+
returning id into v_escalation_id;
|
|
272
|
+
|
|
273
|
+
-- v_event_sequence stays NULL for a thread-level escalation (no task) — fixes the same latent
|
|
274
|
+
-- "record not assigned yet" bug on the no-task path.
|
|
275
|
+
if p_task_id is not null then
|
|
276
|
+
select event_sequence into v_event_sequence
|
|
277
|
+
from agent_board.append_task_event(p_task_id, v_actor_agent_id, 'escalated', jsonb_build_object('escalation_id', v_escalation_id));
|
|
278
|
+
end if;
|
|
279
|
+
|
|
280
|
+
return query
|
|
281
|
+
select e.id, e.status, v_event_sequence, e.created_at, e.updated_at
|
|
282
|
+
from agent_board.escalations e
|
|
283
|
+
where e.id = v_escalation_id;
|
|
284
|
+
end;
|
|
285
|
+
$function$;
|
|
286
|
+
revoke all on function agent_board.raise_escalation(uuid, uuid, text, text, text, uuid, uuid, jsonb) from public, anon;
|
|
287
|
+
grant execute on function agent_board.raise_escalation(uuid, uuid, text, text, text, uuid, uuid, jsonb) to authenticated, service_role;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
-- OBSGATE-3 (#125) — gate task-lifecycle mutation so a board observer cannot mutate a
|
|
2
|
+
-- board-thread task, WITHOUT over-blocking a legitimate assignee/creator.
|
|
3
|
+
--
|
|
4
|
+
-- #123 (OBSGATE-2) gated content CREATION (create_task/add_artifact/raise_conflict/
|
|
5
|
+
-- raise_escalation). But task-lifecycle STATE mutation is a separate door: append_task_event
|
|
6
|
+
-- (the task_events chokepoint — authenticated + MCP-exposed) authors a task_events row carrying
|
|
7
|
+
-- the task's thread_id AND flips task status/assignment, gating ONLY on
|
|
8
|
+
-- _current_agent_can_access_task. A board observer could call it (and its callers acquire_claim /
|
|
9
|
+
-- resolve_conflict / resolve_escalation, which all append through it) on a board-thread task.
|
|
10
|
+
--
|
|
11
|
+
-- A blanket board-send-role gate is WRONG here: a board-thread task can be assigned to a
|
|
12
|
+
-- NON-board-member, who legitimately needs to work it (verified: such an assignee has
|
|
13
|
+
-- can_access_task=true but can_post_to_thread=false). So the rule is assignee-aware
|
|
14
|
+
-- (maintainer decision — assignee + creator carve-out):
|
|
15
|
+
--
|
|
16
|
+
-- allow mutation of a task iff:
|
|
17
|
+
-- service_role (operator; also the signed_* path)
|
|
18
|
+
-- OR _current_agent_can_post_to_thread(thread) (non-observer board member; or any
|
|
19
|
+
-- participant on a board-less thread)
|
|
20
|
+
-- OR current_agent_id() = task.assigned_agent_id (the assignee — even a non-member/observer)
|
|
21
|
+
-- OR current_agent_id() = task.created_by_agent_id (the creator)
|
|
22
|
+
-- deny otherwise — i.e. a board observer meddling with a task it is not a party to.
|
|
23
|
+
--
|
|
24
|
+
-- Enforced at the append_task_event CHOKEPOINT, so acquire_claim / resolve_conflict /
|
|
25
|
+
-- resolve_escalation are closed transitively and atomically (they mutate their own table then
|
|
26
|
+
-- append; a denied append rolls the whole call back). create_task does NOT use append_task_event
|
|
27
|
+
-- (it inserts task_events directly) and is already #123-gated, so it is unaffected. The #123
|
|
28
|
+
-- creation RPCs that DO append (add_artifact/raise_conflict/raise_escalation/delegate_task) have
|
|
29
|
+
-- already passed their own gate, so the mutate-task predicate is a consistent no-op for them.
|
|
30
|
+
--
|
|
31
|
+
-- The predicate is derived from the SESSION (current_agent_id / _is_service_role), never the
|
|
32
|
+
-- caller-supplied p_actor_agent_id. Additive, forward-only. schema_version stays 8.
|
|
33
|
+
|
|
34
|
+
-- 1. predicate --------------------------------------------------------------
|
|
35
|
+
create or replace function agent_board._current_agent_can_mutate_task(p_task_id uuid)
|
|
36
|
+
returns boolean
|
|
37
|
+
language sql
|
|
38
|
+
stable
|
|
39
|
+
security definer
|
|
40
|
+
set search_path = ''
|
|
41
|
+
as $$
|
|
42
|
+
select agent_board._is_service_role()
|
|
43
|
+
or exists (
|
|
44
|
+
select 1
|
|
45
|
+
from agent_board.tasks t
|
|
46
|
+
where t.id = p_task_id
|
|
47
|
+
and (
|
|
48
|
+
agent_board._current_agent_can_post_to_thread(t.thread_id)
|
|
49
|
+
or agent_board.current_agent_id() = t.assigned_agent_id
|
|
50
|
+
or agent_board.current_agent_id() = t.created_by_agent_id
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
$$;
|
|
54
|
+
comment on function agent_board._current_agent_can_mutate_task(uuid) is
|
|
55
|
+
'OBSGATE-3 (#125): may the current agent mutate this task (append events / change status)? service_role, or a non-observer board sender on the task''s thread (board-less participant via the access fallback), or the task''s assignee, or its creator. A board observer that is not a party to the task is denied. Session-derived; internal-only, PUBLIC EXECUTE revoked.';
|
|
56
|
+
revoke all on function agent_board._current_agent_can_mutate_task(uuid) from public, anon, authenticated;
|
|
57
|
+
|
|
58
|
+
-- 2. gate the append_task_event chokepoint ----------------------------------
|
|
59
|
+
-- CREATE OR REPLACE with the SAME body plus the mutate-task gate after the access check; grants
|
|
60
|
+
-- re-asserted. The task row is already locked FOR UPDATE above, so the predicate's re-select is
|
|
61
|
+
-- consistent within the transaction.
|
|
62
|
+
create or replace function agent_board.append_task_event(
|
|
63
|
+
p_task_id uuid,
|
|
64
|
+
p_actor_agent_id uuid,
|
|
65
|
+
p_event_type text,
|
|
66
|
+
p_payload jsonb default '{}'::jsonb,
|
|
67
|
+
p_status text default null,
|
|
68
|
+
p_assigned_agent_id uuid default null,
|
|
69
|
+
p_completed_at timestamp with time zone default null
|
|
70
|
+
)
|
|
71
|
+
returns table(task_id uuid, thread_id uuid, event_id uuid, event_sequence bigint, task_status text, assigned_agent_id uuid, updated_at timestamp with time zone, completed_at timestamp with time zone)
|
|
72
|
+
language plpgsql
|
|
73
|
+
security definer
|
|
74
|
+
set search_path to 'agent_board', 'public'
|
|
75
|
+
as $function$
|
|
76
|
+
declare
|
|
77
|
+
v_thread_id uuid;
|
|
78
|
+
v_sequence bigint;
|
|
79
|
+
v_event_id uuid;
|
|
80
|
+
v_actor_agent_id uuid;
|
|
81
|
+
begin
|
|
82
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
|
|
83
|
+
|
|
84
|
+
select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id for update;
|
|
85
|
+
if v_thread_id is null then
|
|
86
|
+
raise exception 'task not found' using errcode = 'P0002';
|
|
87
|
+
end if;
|
|
88
|
+
if not agent_board._current_agent_can_access_task(p_task_id) then
|
|
89
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
90
|
+
end if;
|
|
91
|
+
-- OBSGATE-3 (#125): mutating a board-thread task requires a non-observer board role, or being
|
|
92
|
+
-- the task's assignee/creator. Closes acquire_claim/resolve_conflict/resolve_escalation
|
|
93
|
+
-- transitively; the assignee/creator carve-out preserves a legitimate non-member worker.
|
|
94
|
+
if not agent_board._current_agent_can_mutate_task(p_task_id) then
|
|
95
|
+
raise exception 'task mutation requires a non-observer board role, or task assignment/creation'
|
|
96
|
+
using errcode = '42501';
|
|
97
|
+
end if;
|
|
98
|
+
|
|
99
|
+
select coalesce(max(te.event_sequence), 0) + 1 into v_sequence from agent_board.task_events te where te.task_id = p_task_id;
|
|
100
|
+
insert into agent_board.task_events(task_id, thread_id, actor_agent_id, event_sequence, event_type, payload)
|
|
101
|
+
values (p_task_id, v_thread_id, v_actor_agent_id, v_sequence, p_event_type, coalesce(p_payload, '{}'::jsonb))
|
|
102
|
+
returning id into v_event_id;
|
|
103
|
+
|
|
104
|
+
update agent_board.tasks
|
|
105
|
+
set status = coalesce(p_status, tasks.status),
|
|
106
|
+
assigned_agent_id = coalesce(p_assigned_agent_id, tasks.assigned_agent_id),
|
|
107
|
+
completed_at = coalesce(p_completed_at, tasks.completed_at),
|
|
108
|
+
updated_at = now()
|
|
109
|
+
where id = p_task_id;
|
|
110
|
+
|
|
111
|
+
return query
|
|
112
|
+
select t.id, t.thread_id, v_event_id, v_sequence, t.status, t.assigned_agent_id, t.updated_at, t.completed_at
|
|
113
|
+
from agent_board.tasks t
|
|
114
|
+
where t.id = p_task_id;
|
|
115
|
+
end;
|
|
116
|
+
$function$;
|
|
117
|
+
revoke all on function agent_board.append_task_event(uuid, uuid, text, jsonb, text, uuid, timestamp with time zone) from public, anon;
|
|
118
|
+
grant execute on function agent_board.append_task_event(uuid, uuid, text, jsonb, text, uuid, timestamp with time zone) to authenticated, service_role;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
-- OBSGATE-4 (#127) — close the task-resource claim squat + the task-less resolve authz gap.
|
|
2
|
+
--
|
|
3
|
+
-- Found by the #126 (OBSGATE-3) panel. Two remaining task-lifecycle-adjacent doors that skip
|
|
4
|
+
-- the append_task_event chokepoint gated in #125:
|
|
5
|
+
--
|
|
6
|
+
-- (a) CLAIM SQUAT (DoS). acquire_claim on resource_kind='task' with resource_key = a task's id
|
|
7
|
+
-- performs NO task authz when p_task_id is null — verified: ANY authenticated agent (not
|
|
8
|
+
-- only a board observer, and even with no task/thread param) can acquire the claim and, via
|
|
9
|
+
-- the global active-resource uniqueness, block the legitimate claimant; release_claim is
|
|
10
|
+
-- ownership-only so the squat is sticky. Fix: a claim on a TASK resource requires ACCESS +
|
|
11
|
+
-- MUTATE rights (the same composition append_task_event uses) on the task named by
|
|
12
|
+
-- RESOURCE_KEY — the locked task the uniqueness index contends on, NOT p_task_id (whose
|
|
13
|
+
-- association is gated separately by the append below). This closes the squat for every
|
|
14
|
+
-- caller (a squat must key on the victim task's uuid to collide, and that uuid now gates on
|
|
15
|
+
-- access+mutate rights). Non-'task' resource kinds (file/machine/...) are a separate
|
|
16
|
+
-- coordination-authz concern, intentionally out of scope.
|
|
17
|
+
--
|
|
18
|
+
-- (b) TASK-LESS resolve_conflict / resolve_escalation. For a conflict/escalation WITH a task,
|
|
19
|
+
-- the append_task_event('status_changed') already applies the #125 mutate-task gate
|
|
20
|
+
-- (assignee-aware). For a TASK-LESS one, no append runs, so it gated only on
|
|
21
|
+
-- _current_agent_can_access_thread — an authz asymmetry that was fail-closed only by a
|
|
22
|
+
-- latent 55000 "record v_event is not assigned yet" bug (v_event referenced unconditionally
|
|
23
|
+
-- while assigned only on the task branch). Fix: gate the task-less path on the board posting
|
|
24
|
+
-- rule AND repair the v_event bug (nullable sequence, NULL when no task) so fixing the bug
|
|
25
|
+
-- can't silently open the path.
|
|
26
|
+
--
|
|
27
|
+
-- release_claim / renew_claim stay ownership-only (maintainer decision): the acquire gate closes
|
|
28
|
+
-- the squat at its source, so a demoted member retaining its own lease until TTL is a benign edge.
|
|
29
|
+
--
|
|
30
|
+
-- All gates are session-derived. Additive, forward-only. schema_version stays 8.
|
|
31
|
+
|
|
32
|
+
-- 1. acquire_claim: a task-resource claim requires task-mutate rights ---------
|
|
33
|
+
create or replace function agent_board.acquire_claim(
|
|
34
|
+
p_agent_id uuid,
|
|
35
|
+
p_resource_kind text,
|
|
36
|
+
p_resource_key text,
|
|
37
|
+
p_lease_expires_at timestamp with time zone,
|
|
38
|
+
p_task_id uuid default null,
|
|
39
|
+
p_thread_id uuid default null,
|
|
40
|
+
p_metadata jsonb default '{}'::jsonb
|
|
41
|
+
)
|
|
42
|
+
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)
|
|
43
|
+
language plpgsql
|
|
44
|
+
security definer
|
|
45
|
+
set search_path to 'agent_board', 'public'
|
|
46
|
+
as $function$
|
|
47
|
+
declare
|
|
48
|
+
v_thread_id uuid;
|
|
49
|
+
v_claim_id uuid;
|
|
50
|
+
v_actor_agent_id uuid;
|
|
51
|
+
begin
|
|
52
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
|
|
53
|
+
|
|
54
|
+
if nullif(trim(p_resource_key), '') is null then
|
|
55
|
+
raise exception 'resource key is required' using errcode = '22023';
|
|
56
|
+
end if;
|
|
57
|
+
|
|
58
|
+
if p_task_id is not null then
|
|
59
|
+
select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
|
|
60
|
+
if v_thread_id is null then
|
|
61
|
+
raise exception 'task not found' using errcode = 'P0002';
|
|
62
|
+
end if;
|
|
63
|
+
if not agent_board._current_agent_can_access_task(p_task_id) then
|
|
64
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
65
|
+
end if;
|
|
66
|
+
else
|
|
67
|
+
v_thread_id := p_thread_id;
|
|
68
|
+
end if;
|
|
69
|
+
|
|
70
|
+
if v_thread_id is not null and not agent_board._current_agent_can_access_thread(v_thread_id) then
|
|
71
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
72
|
+
end if;
|
|
73
|
+
|
|
74
|
+
-- OBSGATE-4 (#127): a claim on a TASK resource is authorized on the LOCKED task = the task
|
|
75
|
+
-- named by RESOURCE_KEY (resource_kind='task'). The active-resource uniqueness is on
|
|
76
|
+
-- (resource_kind, resource_key), so that is the task a squat contends for — gating resource_key
|
|
77
|
+
-- (NOT p_task_id, which only associates the claim/event) prevents locking task B's key while
|
|
78
|
+
-- authorizing on an unrelated task A passed as p_task_id. A non-uuid 'task' key cannot collide
|
|
79
|
+
-- with a uuid-keyed legitimate claim.
|
|
80
|
+
--
|
|
81
|
+
-- The composition MIRRORS append_task_event's: ACCESS then MUTATE. mutate alone is
|
|
82
|
+
-- insufficient — _current_agent_can_mutate_task admits any non-observer board member via
|
|
83
|
+
-- _current_agent_can_post_to_thread (board send-role), which is NOT thread access; on a
|
|
84
|
+
-- RESTRICTED thread a board member lacking thread access would pass mutate but must still be
|
|
85
|
+
-- denied (they cannot access the task). Requiring access too closes that gap and matches the
|
|
86
|
+
-- authorization a task-BOUND acquire already gets through append_task_event.
|
|
87
|
+
if p_resource_kind = 'task'
|
|
88
|
+
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
|
|
89
|
+
if not agent_board._current_agent_can_access_task(p_resource_key::uuid) then
|
|
90
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
91
|
+
end if;
|
|
92
|
+
if not agent_board._current_agent_can_mutate_task(p_resource_key::uuid) then
|
|
93
|
+
raise exception 'claiming a task resource requires a non-observer board role, or task assignment/creation'
|
|
94
|
+
using errcode = '42501';
|
|
95
|
+
end if;
|
|
96
|
+
end if;
|
|
97
|
+
|
|
98
|
+
update agent_board.claims c
|
|
99
|
+
set status = 'expired',
|
|
100
|
+
updated_at = now()
|
|
101
|
+
where c.resource_kind = p_resource_kind
|
|
102
|
+
and c.resource_key = p_resource_key
|
|
103
|
+
and c.status = 'active'
|
|
104
|
+
and c.released_at is null
|
|
105
|
+
and c.lease_expires_at <= now();
|
|
106
|
+
|
|
107
|
+
if exists (
|
|
108
|
+
select 1 from agent_board.claims
|
|
109
|
+
where claims.resource_kind = p_resource_kind
|
|
110
|
+
and claims.resource_key = p_resource_key
|
|
111
|
+
and claims.status = 'active'
|
|
112
|
+
and claims.released_at is null
|
|
113
|
+
and claims.lease_expires_at > now()
|
|
114
|
+
) then
|
|
115
|
+
raise exception 'conflicting active claim' using errcode = '23505';
|
|
116
|
+
end if;
|
|
117
|
+
|
|
118
|
+
insert into agent_board.claims(task_id, thread_id, agent_id, resource_kind, resource_key, lease_expires_at, metadata)
|
|
119
|
+
values (p_task_id, v_thread_id, v_actor_agent_id, p_resource_kind, p_resource_key, p_lease_expires_at, coalesce(p_metadata, '{}'::jsonb))
|
|
120
|
+
returning id into v_claim_id;
|
|
121
|
+
|
|
122
|
+
if p_task_id is not null then
|
|
123
|
+
perform agent_board.append_task_event(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));
|
|
124
|
+
end if;
|
|
125
|
+
|
|
126
|
+
return query
|
|
127
|
+
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
|
|
128
|
+
from agent_board.claims c
|
|
129
|
+
where c.id = v_claim_id;
|
|
130
|
+
end;
|
|
131
|
+
$function$;
|
|
132
|
+
revoke all on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) from public, anon;
|
|
133
|
+
grant execute on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) to authenticated, service_role;
|
|
134
|
+
|
|
135
|
+
-- 2. resolve_conflict: gate the task-less path + fix the v_event 55000 --------
|
|
136
|
+
create or replace function agent_board.resolve_conflict(
|
|
137
|
+
p_conflict_id uuid,
|
|
138
|
+
p_resolved_by_agent_id uuid,
|
|
139
|
+
p_details jsonb default '{}'::jsonb
|
|
140
|
+
)
|
|
141
|
+
returns table(conflict_id uuid, status text, resolved_at timestamp with time zone, updated_at timestamp with time zone, task_event_sequence bigint)
|
|
142
|
+
language plpgsql
|
|
143
|
+
security definer
|
|
144
|
+
set search_path to 'agent_board', 'public'
|
|
145
|
+
as $function$
|
|
146
|
+
declare
|
|
147
|
+
v_conflict agent_board.conflicts%rowtype;
|
|
148
|
+
v_event_sequence bigint;
|
|
149
|
+
v_actor_agent_id uuid;
|
|
150
|
+
begin
|
|
151
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_resolved_by_agent_id, 'p_resolved_by_agent_id');
|
|
152
|
+
|
|
153
|
+
select *
|
|
154
|
+
into v_conflict
|
|
155
|
+
from agent_board.conflicts c
|
|
156
|
+
where c.id = p_conflict_id
|
|
157
|
+
and c.status <> 'resolved';
|
|
158
|
+
if v_conflict.id is null then
|
|
159
|
+
raise exception 'conflict not found' using errcode = 'P0002';
|
|
160
|
+
end if;
|
|
161
|
+
if not agent_board._current_agent_can_access_thread(v_conflict.thread_id) then
|
|
162
|
+
raise exception 'conflict not accessible to current agent' using errcode = '42501';
|
|
163
|
+
end if;
|
|
164
|
+
-- OBSGATE-4 (#127): a task-bound resolve is gated by append_task_event (#125 mutate-task,
|
|
165
|
+
-- assignee-aware). A TASK-LESS conflict has no such chokepoint, so gate it on the board
|
|
166
|
+
-- posting rule here (a board observer may not resolve).
|
|
167
|
+
if v_conflict.task_id is null
|
|
168
|
+
and not agent_board._current_agent_can_post_to_thread(v_conflict.thread_id) then
|
|
169
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
170
|
+
end if;
|
|
171
|
+
|
|
172
|
+
update agent_board.conflicts
|
|
173
|
+
set status = 'resolved',
|
|
174
|
+
resolved_by_agent_id = v_actor_agent_id,
|
|
175
|
+
resolved_at = now(),
|
|
176
|
+
details = details || coalesce(p_details, '{}'::jsonb),
|
|
177
|
+
updated_at = now()
|
|
178
|
+
where id = p_conflict_id
|
|
179
|
+
returning * into v_conflict;
|
|
180
|
+
|
|
181
|
+
-- v_event_sequence stays NULL for a task-less conflict (fixes the latent "record not assigned"
|
|
182
|
+
-- bug; the unconditional v_event.event_sequence reference raised 55000 on this path).
|
|
183
|
+
if v_conflict.task_id is not null then
|
|
184
|
+
select event_sequence into v_event_sequence
|
|
185
|
+
from agent_board.append_task_event(v_conflict.task_id, v_actor_agent_id, 'status_changed', jsonb_build_object('kind', 'conflict_resolved', 'conflict_id', p_conflict_id));
|
|
186
|
+
end if;
|
|
187
|
+
|
|
188
|
+
return query
|
|
189
|
+
select v_conflict.id, v_conflict.status, v_conflict.resolved_at, v_conflict.updated_at, v_event_sequence;
|
|
190
|
+
end;
|
|
191
|
+
$function$;
|
|
192
|
+
revoke all on function agent_board.resolve_conflict(uuid, uuid, jsonb) from public, anon;
|
|
193
|
+
grant execute on function agent_board.resolve_conflict(uuid, uuid, jsonb) to authenticated, service_role;
|
|
194
|
+
|
|
195
|
+
-- 3. resolve_escalation: same task-less gate + v_event fix -------------------
|
|
196
|
+
create or replace function agent_board.resolve_escalation(
|
|
197
|
+
p_escalation_id uuid,
|
|
198
|
+
p_resolved_by_agent_id uuid,
|
|
199
|
+
p_details jsonb default '{}'::jsonb
|
|
200
|
+
)
|
|
201
|
+
returns table(escalation_id uuid, status text, resolved_at timestamp with time zone, updated_at timestamp with time zone, task_event_sequence bigint)
|
|
202
|
+
language plpgsql
|
|
203
|
+
security definer
|
|
204
|
+
set search_path to 'agent_board', 'public'
|
|
205
|
+
as $function$
|
|
206
|
+
declare
|
|
207
|
+
v_escalation agent_board.escalations%rowtype;
|
|
208
|
+
v_event_sequence bigint;
|
|
209
|
+
v_actor_agent_id uuid;
|
|
210
|
+
begin
|
|
211
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_resolved_by_agent_id, 'p_resolved_by_agent_id');
|
|
212
|
+
|
|
213
|
+
select *
|
|
214
|
+
into v_escalation
|
|
215
|
+
from agent_board.escalations e
|
|
216
|
+
where e.id = p_escalation_id
|
|
217
|
+
and e.status <> 'resolved';
|
|
218
|
+
if v_escalation.id is null then
|
|
219
|
+
raise exception 'escalation not found' using errcode = 'P0002';
|
|
220
|
+
end if;
|
|
221
|
+
if not agent_board._current_agent_can_access_thread(v_escalation.thread_id) then
|
|
222
|
+
raise exception 'escalation not accessible to current agent' using errcode = '42501';
|
|
223
|
+
end if;
|
|
224
|
+
-- OBSGATE-4 (#127): task-less escalation gated on the board posting rule (task-bound is gated
|
|
225
|
+
-- by the append below).
|
|
226
|
+
if v_escalation.task_id is null
|
|
227
|
+
and not agent_board._current_agent_can_post_to_thread(v_escalation.thread_id) then
|
|
228
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
229
|
+
end if;
|
|
230
|
+
|
|
231
|
+
update agent_board.escalations
|
|
232
|
+
set status = 'resolved',
|
|
233
|
+
resolved_by_agent_id = v_actor_agent_id,
|
|
234
|
+
resolved_at = now(),
|
|
235
|
+
details = details || coalesce(p_details, '{}'::jsonb),
|
|
236
|
+
updated_at = now()
|
|
237
|
+
where id = p_escalation_id
|
|
238
|
+
returning * into v_escalation;
|
|
239
|
+
|
|
240
|
+
if v_escalation.task_id is not null then
|
|
241
|
+
select event_sequence into v_event_sequence
|
|
242
|
+
from agent_board.append_task_event(v_escalation.task_id, v_actor_agent_id, 'status_changed', jsonb_build_object('kind', 'escalation_resolved', 'escalation_id', p_escalation_id));
|
|
243
|
+
end if;
|
|
244
|
+
|
|
245
|
+
return query
|
|
246
|
+
select v_escalation.id, v_escalation.status, v_escalation.resolved_at, v_escalation.updated_at, v_event_sequence;
|
|
247
|
+
end;
|
|
248
|
+
$function$;
|
|
249
|
+
revoke all on function agent_board.resolve_escalation(uuid, uuid, jsonb) from public, anon;
|
|
250
|
+
grant execute on function agent_board.resolve_escalation(uuid, uuid, jsonb) to authenticated, service_role;
|