@consiliency/agent-board-schema 1.40.0 → 1.41.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 +4 -0
- package/migrations/20260921000100_agent_board_task_event_origin_posture.sql +881 -0
- package/package.json +1 -1
- package/schema-manifest.json +12 -3
- package/src/tooling.test.mjs +14 -2
- package/tests/agent_board_auth_rls.test.sql +8 -3
- package/tests/agent_board_signer_lifecycle.test.sql +1 -1
- package/tests/agent_board_task_event_origin_posture.test.sql +393 -0
- package/tests/agent_board_task_thread_lock_order.test.sql +10 -6
- package/tests/catalog/p_metadata_parameters.json +4 -0
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
-- #288 — task_events.origin_posture: make "how was this row written" a fact, not a blank.
|
|
2
|
+
--
|
|
3
|
+
-- THE DEFECT. v_task_tree.verification_status laterals the task's latest task_events row and
|
|
4
|
+
-- then laterals signature_evidence onto it. Only a writer calling
|
|
5
|
+
-- _record_signed_write_evidence('task_event', …) populates it, so every other writer leaves it
|
|
6
|
+
-- NULL permanently. NULL is indistinguishable from "evidence was lost", "the writer forgot",
|
|
7
|
+
-- and "unsigned on purpose" — three very different facts wearing one blank.
|
|
8
|
+
--
|
|
9
|
+
-- THE CLASS, from the catalog rather than from a list anyone wrote. Membership rule:
|
|
10
|
+
--
|
|
11
|
+
-- a function is a member iff its CALL CLOSURE reaches an INSERT INTO agent_board.task_events
|
|
12
|
+
--
|
|
13
|
+
-- with literals and comments stripped (agent_board_test.stripped_body defaults
|
|
14
|
+
-- p_keep_literals=false), so naming the table in prose is not membership. Twelve members,
|
|
15
|
+
-- of which two record evidence:
|
|
16
|
+
--
|
|
17
|
+
-- depth 0 append_task_event, create_task (direct insert)
|
|
18
|
+
-- depth 1 acquire_claim, add_artifact, create_tasking_delivery (×2 overloads),
|
|
19
|
+
-- delegate_task, raise_conflict, raise_escalation, resolve_conflict,
|
|
20
|
+
-- resolve_escalation, signed_append_task_event [evidence]
|
|
21
|
+
-- depth 2 signed_create_tasking_delivery [evidence]
|
|
22
|
+
--
|
|
23
|
+
-- The issue body said 8 and its own triage comment said 5–6. BOTH were wrong, in opposite
|
|
24
|
+
-- directions, and neither named create_tasking_delivery or signed_create_tasking_delivery.
|
|
25
|
+
-- The body used a prosrc grep, blind to multi-hop reach (#310's recorded trap); the comment
|
|
26
|
+
-- used pg_get_functiondef ilike '%task_events%', which matches comments and literals and
|
|
27
|
+
-- never walks the call graph. This is rule 1 catching two hand-written lists in one issue.
|
|
28
|
+
--
|
|
29
|
+
-- WHY A STAMP AND NOT THE TWO OBVIOUS ALTERNATIVES.
|
|
30
|
+
-- * Propagating the parent's evidence id CANNOT be complete: the four unsigned-deliberate
|
|
31
|
+
-- writers and create_task's direct insert have no parent evidence to propagate.
|
|
32
|
+
-- * Minting a signature_evidence row per derived event would FABRICATE evidence.
|
|
33
|
+
-- payload_digest and operation are NOT NULL, and for an unsigned derived event there is no
|
|
34
|
+
-- signed payload to digest. That is #317's shape, where emitting a row's own state minted
|
|
35
|
+
-- an attestation worse than the blank it replaced. Separately, verification_status
|
|
36
|
+
-- 'unsigned' sits in the column's CHECK but is defined NOWHERE in
|
|
37
|
+
-- specs/agent-provenance-signing.md; that spec defines 'observed'/'warn' as GATEWAY
|
|
38
|
+
-- enforcement-mode artifacts, so stamping either from an RPC that never passes the gateway
|
|
39
|
+
-- would misuse the vocabulary.
|
|
40
|
+
--
|
|
41
|
+
-- HARD RULE. A derived event's verification_status must never read 'verified', and the
|
|
42
|
+
-- writer's posture lands in a DISTINCT column — so no gate written against the evidence
|
|
43
|
+
-- vocabulary can ever be satisfied by a stamped value. The two columns answer different
|
|
44
|
+
-- questions and are not merged.
|
|
45
|
+
--
|
|
46
|
+
-- WHAT COMPLETENESS THIS BUYS — two layers, and calling the second one structural would be
|
|
47
|
+
-- the overclaim this paragraph exists to prevent:
|
|
48
|
+
-- * STRUCTURAL: origin_posture is NOT NULL with NO DEFAULT. A future writer that opens a new
|
|
49
|
+
-- DIRECT INSERT path (the shape create_task already has) cannot insert a row at all.
|
|
50
|
+
-- * NAMED, NOT STRUCTURAL: a future writer that goes through the funnel and omits the
|
|
51
|
+
-- argument gets the funnel's declared default rather than an error. No SQL construct
|
|
52
|
+
-- prevents that — a caller can always route through a defaulting wrapper. A catalog-derived
|
|
53
|
+
-- pgTAP assertion enumerates the closure and NAMES any member not passing an explicit
|
|
54
|
+
-- posture. Each layer covers what the other cannot.
|
|
55
|
+
--
|
|
56
|
+
-- NOTHING IS DROPPED. DROP FUNCTION discards the ACL and the COMMENT, and recreating a
|
|
57
|
+
-- SECURITY DEFINER function grants EXECUTE to PUBLIC by default — which opened a real gate
|
|
58
|
+
-- bypass in this repo's history. append_task_event and create_task are both SECURITY DEFINER,
|
|
59
|
+
-- granted to authenticated, and carry OBSGATE gating in their bodies, so BOTH PUBLIC
|
|
60
|
+
-- SIGNATURES STAY BYTE-IDENTICAL and every recreation here is a CREATE OR REPLACE that
|
|
61
|
+
-- preserves ACL and COMMENT.
|
|
62
|
+
|
|
63
|
+
-- 1. The column. Added nullable, backfilled, then constrained — the only order that works on
|
|
64
|
+
-- a table with existing rows.
|
|
65
|
+
alter table agent_board.task_events add column if not exists origin_posture text;
|
|
66
|
+
|
|
67
|
+
-- 'unattested:preexisting' is deliberately NOT a guess. The writer of a pre-migration row is unknowable;
|
|
68
|
+
-- back-dating a plausible posture onto it would be this same defect pointing the other way.
|
|
69
|
+
update agent_board.task_events set origin_posture = 'unattested:preexisting' where origin_posture is null;
|
|
70
|
+
|
|
71
|
+
alter table agent_board.task_events alter column origin_posture set not null;
|
|
72
|
+
|
|
73
|
+
-- NO DEFAULT is load-bearing, not an omission: a column default would silently satisfy a new
|
|
74
|
+
-- direct-INSERT writer and re-open exactly the hole this migration closes. Mutation M2 pins it.
|
|
75
|
+
alter table agent_board.task_events alter column origin_posture drop default;
|
|
76
|
+
|
|
77
|
+
alter table agent_board.task_events drop constraint if exists task_events_origin_posture_check;
|
|
78
|
+
alter table agent_board.task_events add constraint task_events_origin_posture_check
|
|
79
|
+
check (origin_posture in (
|
|
80
|
+
'attested:evidence-row', -- the writer records a signature_evidence row for THIS event
|
|
81
|
+
'unattested:signed-metadata-rpc', -- the entry-point RPC accepts client-signed p_metadata
|
|
82
|
+
'unattested:unsigned-deliberate-rpc', -- adjudicated unsigned in #261 step 2
|
|
83
|
+
'unattested:direct-rpc', -- public append_task_event called directly; caller declared nothing
|
|
84
|
+
'unattested:preexisting' -- backfilled; the writer of this row is unknowable
|
|
85
|
+
));
|
|
86
|
+
|
|
87
|
+
comment on column agent_board.task_events.origin_posture is
|
|
88
|
+
'#288: how this row was written. Every value declares its ATTESTATION STATE FIRST, so `origin_posture like ''unattested:%''` is a correct safety predicate and no value can be misread as a signature claim. DISTINCT from the verification_status that v_task_tree laterals off signature_evidence — a posture is a fact about the WRITER. Never fold the two together (mutation M8).';
|
|
89
|
+
|
|
90
|
+
-- WHY THE VALUES LEAD WITH attested:/unattested: RATHER THAN MIRRORING RPC_PROVENANCE_POSTURE
|
|
91
|
+
-- VERBATIM. The first draft used that catalog's own strings, so an UNSIGNED create_task write
|
|
92
|
+
-- was stamped 'signed-metadata'. That is TRUE about the RPC (it accepts client-signed
|
|
93
|
+
-- p_metadata) and dangerously misleading about the ROW (nobody signed this one). Two
|
|
94
|
+
-- independent reviewers read it as a possible attestation claim, and a third measurement
|
|
95
|
+
-- settled it: the column is ALREADY on a consumer surface — AgentBoardClient.taskTree reads
|
|
96
|
+
-- v_task_tree with select("*") and the MCP task-tree tool returns that result directly — so
|
|
97
|
+
-- "client surfacing is a follow-up" was false when it was written. A value a consumer can
|
|
98
|
+
-- misread as a signature is #317's failure shape, where emitting a row's own state minted an
|
|
99
|
+
-- attestation worse than the blank it replaced. The prefix makes the misreading IMPOSSIBLE
|
|
100
|
+
-- rather than warning against it in a comment nobody queries.
|
|
101
|
+
|
|
102
|
+
-- 2. The writers.
|
|
103
|
+
CREATE OR REPLACE FUNCTION agent_board._append_task_event(p_origin_posture text, p_task_id uuid, p_actor_agent_id uuid, p_event_type text, p_payload jsonb DEFAULT '{}'::jsonb, p_status text DEFAULT NULL::text, p_assigned_agent_id uuid DEFAULT NULL::uuid, p_completed_at timestamp with time zone DEFAULT NULL::timestamp with time zone)
|
|
104
|
+
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)
|
|
105
|
+
LANGUAGE plpgsql
|
|
106
|
+
SECURITY DEFINER
|
|
107
|
+
SET search_path TO 'agent_board', 'public'
|
|
108
|
+
AS $function$
|
|
109
|
+
declare
|
|
110
|
+
v_thread_id uuid;
|
|
111
|
+
v_locked_thread_id uuid;
|
|
112
|
+
v_sequence bigint;
|
|
113
|
+
v_event_id uuid;
|
|
114
|
+
v_actor_agent_id uuid;
|
|
115
|
+
begin
|
|
116
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
|
|
117
|
+
|
|
118
|
+
select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
|
|
119
|
+
if v_thread_id is null then
|
|
120
|
+
raise exception 'task not found' using errcode = 'P0002';
|
|
121
|
+
end if;
|
|
122
|
+
|
|
123
|
+
perform 1 from agent_board.threads th where th.id = v_thread_id for key share;
|
|
124
|
+
|
|
125
|
+
select tasks.thread_id into v_locked_thread_id from agent_board.tasks where id = p_task_id for update;
|
|
126
|
+
if v_locked_thread_id is null then
|
|
127
|
+
raise exception 'task not found' using errcode = 'P0002';
|
|
128
|
+
end if;
|
|
129
|
+
if v_locked_thread_id is distinct from v_thread_id then
|
|
130
|
+
raise exception 'task thread changed while acquiring locks' using errcode = '40001';
|
|
131
|
+
end if;
|
|
132
|
+
|
|
133
|
+
if not agent_board._current_agent_can_access_task(p_task_id) then
|
|
134
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
135
|
+
end if;
|
|
136
|
+
if not agent_board._current_agent_can_mutate_task(p_task_id) then
|
|
137
|
+
raise exception 'task mutation requires a non-observer board role, or task assignment/creation'
|
|
138
|
+
using errcode = '42501';
|
|
139
|
+
end if;
|
|
140
|
+
|
|
141
|
+
select coalesce(max(te.event_sequence), 0) + 1 into v_sequence from agent_board.task_events te where te.task_id = p_task_id;
|
|
142
|
+
insert into agent_board.task_events(task_id, thread_id, actor_agent_id, event_sequence, event_type, payload, origin_posture)
|
|
143
|
+
values (p_task_id, v_thread_id, v_actor_agent_id, v_sequence, p_event_type, coalesce(p_payload, '{}'::jsonb), p_origin_posture)
|
|
144
|
+
returning id into v_event_id;
|
|
145
|
+
|
|
146
|
+
update agent_board.tasks
|
|
147
|
+
set status = coalesce(p_status, tasks.status),
|
|
148
|
+
assigned_agent_id = coalesce(p_assigned_agent_id, tasks.assigned_agent_id),
|
|
149
|
+
completed_at = coalesce(p_completed_at, tasks.completed_at),
|
|
150
|
+
updated_at = now()
|
|
151
|
+
where id = p_task_id;
|
|
152
|
+
|
|
153
|
+
return query
|
|
154
|
+
select t.id, t.thread_id, v_event_id, v_sequence, t.status, t.assigned_agent_id, t.updated_at, t.completed_at
|
|
155
|
+
from agent_board.tasks t
|
|
156
|
+
where t.id = p_task_id;
|
|
157
|
+
end;
|
|
158
|
+
$function$;
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
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)
|
|
162
|
+
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)
|
|
163
|
+
LANGUAGE plpgsql
|
|
164
|
+
SECURITY DEFINER
|
|
165
|
+
SET search_path TO 'agent_board', 'public'
|
|
166
|
+
AS $function$
|
|
167
|
+
declare
|
|
168
|
+
v_task_id uuid;
|
|
169
|
+
v_root_task_id uuid;
|
|
170
|
+
v_event_id uuid;
|
|
171
|
+
v_actor_agent_id uuid;
|
|
172
|
+
begin
|
|
173
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
|
|
174
|
+
|
|
175
|
+
if nullif(trim(p_title), '') is null then
|
|
176
|
+
raise exception 'task title is required' using errcode = '22023';
|
|
177
|
+
end if;
|
|
178
|
+
if not exists (select 1 from agent_board.threads where id = p_thread_id) then
|
|
179
|
+
raise exception 'thread not found' using errcode = 'P0002';
|
|
180
|
+
end if;
|
|
181
|
+
if not agent_board._current_agent_can_access_thread(p_thread_id) then
|
|
182
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
183
|
+
end if;
|
|
184
|
+
-- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role to author.
|
|
185
|
+
if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
|
|
186
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
187
|
+
end if;
|
|
188
|
+
if p_assigned_agent_id is not null and not exists (select 1 from agent_board.agents where id = p_assigned_agent_id) then
|
|
189
|
+
raise exception 'assigned agent not found' using errcode = 'P0002';
|
|
190
|
+
end if;
|
|
191
|
+
|
|
192
|
+
if p_parent_task_id is not null then
|
|
193
|
+
select coalesce(tasks.root_task_id, tasks.id)
|
|
194
|
+
into v_root_task_id
|
|
195
|
+
from agent_board.tasks
|
|
196
|
+
where id = p_parent_task_id and tasks.thread_id = p_thread_id;
|
|
197
|
+
if v_root_task_id is null then
|
|
198
|
+
raise exception 'parent task not found in thread' using errcode = '22023';
|
|
199
|
+
end if;
|
|
200
|
+
if not agent_board._current_agent_can_access_task(p_parent_task_id) then
|
|
201
|
+
raise exception 'parent task not accessible to current agent' using errcode = '42501';
|
|
202
|
+
end if;
|
|
203
|
+
end if;
|
|
204
|
+
|
|
205
|
+
v_task_id := gen_random_uuid();
|
|
206
|
+
if p_parent_task_id is null then
|
|
207
|
+
v_root_task_id := v_task_id;
|
|
208
|
+
end if;
|
|
209
|
+
|
|
210
|
+
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)
|
|
211
|
+
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));
|
|
212
|
+
|
|
213
|
+
-- #288: create_task is the ONE writer that inserts into task_events directly rather than
|
|
214
|
+
-- through the funnel, which is why a fix instrumenting only append_task_event's callers
|
|
215
|
+
-- would miss it. Its own declared posture is signed-metadata (it declares p_metadata and the
|
|
216
|
+
-- client signs it). No parameter is threaded here deliberately -- see the plan's section 5:
|
|
217
|
+
-- where a signed wrapper is involved (signed_create_tasking_delivery keys its evidence to
|
|
218
|
+
-- THIS event, two levels up), a real signature_evidence row exists and v_task_tree already
|
|
219
|
+
-- surfaces it, which is the stronger signal. A threaded parameter would change no answer.
|
|
220
|
+
insert into agent_board.task_events(task_id, thread_id, actor_agent_id, event_sequence, event_type, payload, origin_posture)
|
|
221
|
+
values (v_task_id, p_thread_id, v_actor_agent_id, 1, 'created', jsonb_build_object('title', trim(p_title)), 'unattested:signed-metadata-rpc')
|
|
222
|
+
returning id into v_event_id;
|
|
223
|
+
|
|
224
|
+
return query
|
|
225
|
+
select t.id, t.parent_task_id, t.root_task_id, v_event_id, 1::bigint, t.status, t.created_at, t.updated_at
|
|
226
|
+
from agent_board.tasks t
|
|
227
|
+
where t.id = v_task_id;
|
|
228
|
+
end;
|
|
229
|
+
$function$;
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
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)
|
|
233
|
+
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)
|
|
234
|
+
LANGUAGE plpgsql
|
|
235
|
+
SECURITY DEFINER
|
|
236
|
+
SET search_path TO 'agent_board', 'public'
|
|
237
|
+
AS $function$
|
|
238
|
+
declare
|
|
239
|
+
v_thread_id uuid;
|
|
240
|
+
v_claim_id uuid;
|
|
241
|
+
v_actor_agent_id uuid;
|
|
242
|
+
v_shadow_authorized boolean;
|
|
243
|
+
v_ownerless boolean;
|
|
244
|
+
v_active_ownerless int;
|
|
245
|
+
begin
|
|
246
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
|
|
247
|
+
|
|
248
|
+
if nullif(trim(p_resource_key), '') is null then
|
|
249
|
+
raise exception 'resource key is required' using errcode = '22023';
|
|
250
|
+
end if;
|
|
251
|
+
|
|
252
|
+
-- CLAIMKEY (#133): normalize the resource key to its canonical, fleet-fingerprint-carrying
|
|
253
|
+
-- form BEFORE any lookup/insert, so the row stored (and the uniqueness index contended)
|
|
254
|
+
-- carry canonical identity. NORMALIZE-ONLY — this never rejects (rejection is ENFORCE).
|
|
255
|
+
p_resource_key := agent_board._canonical_claim_key(p_resource_kind, p_resource_key);
|
|
256
|
+
|
|
257
|
+
if p_task_id is not null then
|
|
258
|
+
select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
|
|
259
|
+
if v_thread_id is null then
|
|
260
|
+
raise exception 'task not found' using errcode = 'P0002';
|
|
261
|
+
end if;
|
|
262
|
+
if not agent_board._current_agent_can_access_task(p_task_id) then
|
|
263
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
264
|
+
end if;
|
|
265
|
+
else
|
|
266
|
+
v_thread_id := p_thread_id;
|
|
267
|
+
end if;
|
|
268
|
+
|
|
269
|
+
if v_thread_id is not null and not agent_board._current_agent_can_access_thread(v_thread_id) then
|
|
270
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
271
|
+
end if;
|
|
272
|
+
|
|
273
|
+
-- message-board#291: the SUBJECT gate. The checks above answer "may this SESSION touch the
|
|
274
|
+
-- task/thread" and are short-circuited true for service_role; this one answers "does the
|
|
275
|
+
-- agent the claim is ABOUT already have access". Without it a service_role caller could name
|
|
276
|
+
-- any agent, and the resulting claims row -- itself an access disjunct, and a PERMANENT one
|
|
277
|
+
-- -- would MINT thread access (and, through the thread disjunct of
|
|
278
|
+
-- _current_agent_can_access_task, task access) rather than record a lock on something the
|
|
279
|
+
-- subject could already see.
|
|
280
|
+
--
|
|
281
|
+
-- UNCONDITIONAL IN *WHO*. DO NOT "optimize" this into `if _is_service_role() and not ...` or
|
|
282
|
+
-- `if v_actor_agent_id is distinct from agent_board.current_agent_id()`. For an authenticated
|
|
283
|
+
-- caller v_actor_agent_id IS current_agent_id() and the session check above already
|
|
284
|
+
-- established this same expression, so the redundancy is PROVABLE rather than suspected; it
|
|
285
|
+
-- is the price of an invariant this function states for itself instead of borrowing from
|
|
286
|
+
-- _resolved_actor_agent_id continuing to raise on a subject mismatch.
|
|
287
|
+
--
|
|
288
|
+
-- CONDITIONAL ON *THREAD PRESENCE*, which is not a loophole. A claim bound to neither a task
|
|
289
|
+
-- nor a thread stores thread_id = null, is a disjunct of nothing and mints no access.
|
|
290
|
+
-- _agent_can_access_thread answers false for a null thread, so gating unconditionally would
|
|
291
|
+
-- refuse every machine / repository / repo_file / ownerless claim in the fleet. This covers
|
|
292
|
+
-- exactly the claims that are keys.
|
|
293
|
+
--
|
|
294
|
+
-- PLACED AFTER THE TASK LOOKUP so a nonexistent task still answers P0002, not 42501 -- that
|
|
295
|
+
-- ordering IS test-pinned. Placed BEFORE the sweep only to match the predicate-before-side-
|
|
296
|
+
-- effects convention CLAIMGATE/ENFORCE states below; it is NOT what makes a denial free of
|
|
297
|
+
-- side effects. `raise exception` unwinds the whole call, so a refused caller changes no state
|
|
298
|
+
-- wherever this sits -- measured, by moving the gate below the sweep and observing the test
|
|
299
|
+
-- file stay entirely green.
|
|
300
|
+
if v_thread_id is not null
|
|
301
|
+
and not agent_board._agent_can_access_thread(v_thread_id, v_actor_agent_id) then
|
|
302
|
+
raise exception 'claim subject has no access to thread' using errcode = '42501';
|
|
303
|
+
end if;
|
|
304
|
+
|
|
305
|
+
-- OBSGATE-4 (#127): a claim on a TASK resource is authorized on the LOCKED task = the task
|
|
306
|
+
-- named by RESOURCE_KEY (resource_kind='task'). The active-resource uniqueness is on
|
|
307
|
+
-- (resource_kind, resource_key), so that is the task a squat contends for — gating resource_key
|
|
308
|
+
-- (NOT p_task_id, which only associates the claim/event) prevents locking task B's key while
|
|
309
|
+
-- authorizing on an unrelated task A passed as p_task_id. A non-uuid 'task' key cannot collide
|
|
310
|
+
-- with a uuid-keyed legitimate claim.
|
|
311
|
+
--
|
|
312
|
+
-- The composition MIRRORS append_task_event's: ACCESS then MUTATE. mutate alone is
|
|
313
|
+
-- insufficient — _current_agent_can_mutate_task admits any non-observer board member via
|
|
314
|
+
-- _current_agent_can_post_to_thread (board send-role), which is NOT thread access; on a
|
|
315
|
+
-- RESTRICTED thread a board member lacking thread access would pass mutate but must still be
|
|
316
|
+
-- denied (they cannot access the task). Requiring access too closes that gap and matches the
|
|
317
|
+
-- authorization a task-BOUND acquire already gets through append_task_event.
|
|
318
|
+
if p_resource_kind = 'task'
|
|
319
|
+
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
|
|
320
|
+
if not agent_board._current_agent_can_access_task(p_resource_key::uuid) then
|
|
321
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
322
|
+
end if;
|
|
323
|
+
if not agent_board._current_agent_can_mutate_task(p_resource_key::uuid) then
|
|
324
|
+
raise exception 'claiming a task resource requires a non-observer board role, or task assignment/creation'
|
|
325
|
+
using errcode = '42501';
|
|
326
|
+
end if;
|
|
327
|
+
end if;
|
|
328
|
+
|
|
329
|
+
-- CLAIMGATE/SHADOW (#133): compute the fleet-graph authority verdict BEFORE the sweep (so
|
|
330
|
+
-- ENFORCE can flip this same point fail-closed with no observable side effect leaking to an
|
|
331
|
+
-- unauthorized caller). The verdict is stamped below regardless of enforcement.
|
|
332
|
+
v_shadow_authorized := agent_board._can_claim_resource(p_resource_kind, p_resource_key);
|
|
333
|
+
|
|
334
|
+
-- CLAIMGATE/ENFORCE (#133): per-kind GATED DENIAL, BEFORE the sweep (predicate-before-side-
|
|
335
|
+
-- effects). When this fleet-backed kind's flag is ON and the actor is UNAUTHORIZED, DENY —
|
|
336
|
+
-- no state has changed yet (sweep/insert are below). When OFF, fall through to the shadow-
|
|
337
|
+
-- stamp only (SHADOW behavior; blocks nothing). Non-fleet kinds never kind-enforce (resolver
|
|
338
|
+
-- returns FALSE). service_role is authorized by the predicate (wrapper short-circuit), so the
|
|
339
|
+
-- operator carve-out holds. Fail-closed-on-unknown is automatic: an unresolvable fleet resource
|
|
340
|
+
-- makes v_shadow_authorized FALSE, so an enforced acquire of it denies.
|
|
341
|
+
if p_resource_kind in ('machine', 'repository', 'repo_file')
|
|
342
|
+
and agent_board._claimgate_kind_enforced(p_resource_kind)
|
|
343
|
+
and not v_shadow_authorized then
|
|
344
|
+
raise exception 'not authorized to claim this % resource (fleet authority required)', p_resource_kind
|
|
345
|
+
using errcode = '42501';
|
|
346
|
+
end if;
|
|
347
|
+
|
|
348
|
+
-- CLAIMGATE/ENFORCE (#133): max-lease horizon. For an ENFORCED fleet-backed kind, CLAMP the
|
|
349
|
+
-- lease to now()+horizon (default 30d, operator-configurable) — a bound on lease term, NOT an
|
|
350
|
+
-- authority block. Clamp (not reject) so an over-long lease reconciles here / at its next renew.
|
|
351
|
+
-- service_role exempt (operator carve-out). Only reached by an AUTHORIZED acquire (unauthorized
|
|
352
|
+
-- was denied above), or when the flag is OFF (then the guard below is skipped entirely).
|
|
353
|
+
if p_resource_kind in ('machine', 'repository', 'repo_file')
|
|
354
|
+
and not agent_board._is_service_role()
|
|
355
|
+
and agent_board._claimgate_kind_enforced(p_resource_kind)
|
|
356
|
+
and p_lease_expires_at > now() + agent_board._claimgate_max_lease_horizon() then
|
|
357
|
+
p_lease_expires_at := now() + agent_board._claimgate_max_lease_horizon();
|
|
358
|
+
end if;
|
|
359
|
+
|
|
360
|
+
-- Ownerless tail (file/tool/custom): the ONE guard SHADOW carries — GATED OFF BY DEFAULT.
|
|
361
|
+
-- _claimgate_ownerless_guards_enabled() is false unless an operator flips it post-census, so in
|
|
362
|
+
-- SHADOW this block is skipped entirely and NOTHING is blocked (only the stamp above lands).
|
|
363
|
+
-- service_role is also exempt (operator carve-out, consistent with the predicate short-circuit).
|
|
364
|
+
v_ownerless := p_resource_kind in ('file', 'tool', 'custom');
|
|
365
|
+
if v_ownerless
|
|
366
|
+
and not agent_board._is_service_role()
|
|
367
|
+
and agent_board._claimgate_ownerless_guards_enabled() then
|
|
368
|
+
-- TTL CAP: an ownerless lease may not exceed the max horizon. Rejects an over-long acquire;
|
|
369
|
+
-- this is a lease-length bound, NOT an authority block.
|
|
370
|
+
if p_lease_expires_at > now() + agent_board._claimgate_ownerless_ttl_cap() then
|
|
371
|
+
raise exception 'ownerless claim lease horizon exceeds cap (%)', agent_board._claimgate_ownerless_ttl_cap()
|
|
372
|
+
using errcode = '22023';
|
|
373
|
+
end if;
|
|
374
|
+
|
|
375
|
+
-- QUOTA (transactionally serialized): an actor-scoped advisory XACT lock serializes this
|
|
376
|
+
-- actor's concurrent acquisitions (on DIFFERENT keys, which the row-level uniqueness index
|
|
377
|
+
-- does NOT), so a count-before-insert cannot race two claims past the cap. The lock releases
|
|
378
|
+
-- at commit; it does not block other actors (the key mixes in the actor id).
|
|
379
|
+
perform pg_advisory_xact_lock(hashtext('agent_board.claimgate_ownerless_quota'), hashtext(v_actor_agent_id::text));
|
|
380
|
+
select count(*) into v_active_ownerless
|
|
381
|
+
from agent_board.claims c
|
|
382
|
+
where c.agent_id = v_actor_agent_id
|
|
383
|
+
and c.resource_kind in ('file', 'tool', 'custom')
|
|
384
|
+
and c.status = 'active'
|
|
385
|
+
and c.released_at is null
|
|
386
|
+
and c.lease_expires_at > now();
|
|
387
|
+
if v_active_ownerless >= agent_board._claimgate_ownerless_quota() then
|
|
388
|
+
raise exception 'ownerless active-claim quota reached (% per agent)', agent_board._claimgate_ownerless_quota()
|
|
389
|
+
using errcode = '54000';
|
|
390
|
+
end if;
|
|
391
|
+
end if;
|
|
392
|
+
|
|
393
|
+
-- CLAIMKEY (#133) MUST-FIX 3: the sweep AND the conflict check are ALIAS-AWARE. p_resource_key
|
|
394
|
+
-- is already canonical (above); comparing against the CANONICAL form of each existing row's key
|
|
395
|
+
-- matches both a canonical row AND an un-healed LEGACY row for the same physical resource. Without
|
|
396
|
+
-- this, acquire('machine','machine:fpDH') would normalize to 'fpDH', see no exact-'fpDH' conflict
|
|
397
|
+
-- against a still-active legacy 'machine:fpDH' row, and INSERT a second live hold — breaking mutual
|
|
398
|
+
-- exclusion during the legacy window. Closing it here in code does NOT depend on the operator-owned
|
|
399
|
+
-- live census.
|
|
400
|
+
update agent_board.claims c
|
|
401
|
+
set status = 'expired',
|
|
402
|
+
updated_at = now()
|
|
403
|
+
where c.resource_kind = p_resource_kind
|
|
404
|
+
and agent_board._canonical_claim_key(c.resource_kind, c.resource_key) = p_resource_key
|
|
405
|
+
and c.status = 'active'
|
|
406
|
+
and c.released_at is null
|
|
407
|
+
and c.lease_expires_at <= now();
|
|
408
|
+
|
|
409
|
+
if exists (
|
|
410
|
+
select 1 from agent_board.claims
|
|
411
|
+
where claims.resource_kind = p_resource_kind
|
|
412
|
+
and agent_board._canonical_claim_key(claims.resource_kind, claims.resource_key) = p_resource_key
|
|
413
|
+
and claims.status = 'active'
|
|
414
|
+
and claims.released_at is null
|
|
415
|
+
and claims.lease_expires_at > now()
|
|
416
|
+
) then
|
|
417
|
+
raise exception 'conflicting active claim' using errcode = '23505';
|
|
418
|
+
end if;
|
|
419
|
+
|
|
420
|
+
insert into agent_board.claims(task_id, thread_id, agent_id, resource_kind, resource_key, lease_expires_at, metadata)
|
|
421
|
+
values (
|
|
422
|
+
p_task_id, v_thread_id, v_actor_agent_id, p_resource_kind, p_resource_key, p_lease_expires_at,
|
|
423
|
+
-- SHADOW stamp: record the authority verdict alongside any caller metadata. Merged last so it
|
|
424
|
+
-- is authoritative over a caller-supplied claimgate_shadow key.
|
|
425
|
+
coalesce(p_metadata, '{}'::jsonb)
|
|
426
|
+
|| jsonb_build_object(
|
|
427
|
+
'claimgate_shadow',
|
|
428
|
+
jsonb_build_object('kind', p_resource_kind, 'authorized', v_shadow_authorized)
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
returning id into v_claim_id;
|
|
432
|
+
|
|
433
|
+
if p_task_id is not null then
|
|
434
|
+
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));
|
|
435
|
+
end if;
|
|
436
|
+
|
|
437
|
+
return query
|
|
438
|
+
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
|
|
439
|
+
from agent_board.claims c
|
|
440
|
+
where c.id = v_claim_id;
|
|
441
|
+
end;
|
|
442
|
+
$function$;
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
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)
|
|
446
|
+
RETURNS TABLE(artifact_id uuid, task_event_sequence bigint, created_at timestamp with time zone)
|
|
447
|
+
LANGUAGE plpgsql
|
|
448
|
+
SECURITY DEFINER
|
|
449
|
+
SET search_path TO 'agent_board', 'public'
|
|
450
|
+
AS $function$
|
|
451
|
+
declare
|
|
452
|
+
v_artifact_id uuid;
|
|
453
|
+
v_event_sequence bigint;
|
|
454
|
+
v_actor_agent_id uuid;
|
|
455
|
+
v_thread_id uuid;
|
|
456
|
+
begin
|
|
457
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
|
|
458
|
+
|
|
459
|
+
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
|
|
460
|
+
raise exception 'artifact target is required' using errcode = '22023';
|
|
461
|
+
end if;
|
|
462
|
+
|
|
463
|
+
if p_task_id is not null then
|
|
464
|
+
if not agent_board._current_agent_can_access_task(p_task_id) then
|
|
465
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
466
|
+
end if;
|
|
467
|
+
end if;
|
|
468
|
+
|
|
469
|
+
if p_thread_id is not null and not agent_board._current_agent_can_access_thread(p_thread_id) then
|
|
470
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
471
|
+
end if;
|
|
472
|
+
|
|
473
|
+
if p_message_id is not null then
|
|
474
|
+
select m.thread_id into v_thread_id from agent_board.messages m where m.id = p_message_id;
|
|
475
|
+
if v_thread_id is null or not agent_board._current_agent_can_access_thread(v_thread_id) then
|
|
476
|
+
raise exception 'message not accessible to current agent' using errcode = '42501';
|
|
477
|
+
end if;
|
|
478
|
+
end if;
|
|
479
|
+
|
|
480
|
+
if p_task_event_id is not null then
|
|
481
|
+
select te.thread_id into v_thread_id from agent_board.task_events te where te.id = p_task_event_id;
|
|
482
|
+
if v_thread_id is null or not agent_board._current_agent_can_access_thread(v_thread_id) then
|
|
483
|
+
raise exception 'task event not accessible to current agent' using errcode = '42501';
|
|
484
|
+
end if;
|
|
485
|
+
end if;
|
|
486
|
+
|
|
487
|
+
-- OBSGATE-2 (#123): apply the posting gate to the artifact's effective thread (resolved from
|
|
488
|
+
-- whichever target was supplied). A board-linked thread requires a non-observer board role.
|
|
489
|
+
v_thread_id := coalesce(
|
|
490
|
+
p_thread_id,
|
|
491
|
+
v_thread_id,
|
|
492
|
+
(select tk.thread_id from agent_board.tasks tk where tk.id = p_task_id)
|
|
493
|
+
);
|
|
494
|
+
if v_thread_id is not null and not agent_board._current_agent_can_post_to_thread(v_thread_id) then
|
|
495
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
496
|
+
end if;
|
|
497
|
+
|
|
498
|
+
insert into agent_board.artifacts(thread_id, task_id, message_id, task_event_id, created_by_agent_id, artifact_type, storage_kind, uri, metadata)
|
|
499
|
+
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))
|
|
500
|
+
returning id into v_artifact_id;
|
|
501
|
+
|
|
502
|
+
-- v_event_sequence stays NULL when there is no task (a thread/message/task_event artifact).
|
|
503
|
+
-- The prior `v_event record` + unconditional `v_event.event_sequence` raised
|
|
504
|
+
-- "record not assigned yet" on that no-task path — a latent bug fixed here while these
|
|
505
|
+
-- functions are being rewritten for the gate.
|
|
506
|
+
if p_task_id is not null then
|
|
507
|
+
select event_sequence into v_event_sequence
|
|
508
|
+
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));
|
|
509
|
+
end if;
|
|
510
|
+
|
|
511
|
+
return query
|
|
512
|
+
select v_artifact_id, v_event_sequence, now();
|
|
513
|
+
end;
|
|
514
|
+
$function$;
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
CREATE OR REPLACE FUNCTION agent_board.delegate_task(p_parent_task_id uuid, p_actor_agent_id uuid, p_title text, p_details text DEFAULT NULL::text, p_delegated_to_agent_id uuid DEFAULT NULL::uuid, p_priority text DEFAULT 'medium'::text, p_note text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb)
|
|
518
|
+
RETURNS TABLE(delegation_id uuid, child_task_id uuid, parent_task_id uuid, root_task_id uuid, delegation_status text, child_event_sequence bigint, parent_event_sequence bigint, created_at timestamp with time zone)
|
|
519
|
+
LANGUAGE plpgsql
|
|
520
|
+
SECURITY DEFINER
|
|
521
|
+
SET search_path TO 'agent_board', 'public'
|
|
522
|
+
AS $function$
|
|
523
|
+
declare
|
|
524
|
+
v_parent agent_board.tasks%rowtype;
|
|
525
|
+
v_child record;
|
|
526
|
+
v_delegation_id uuid;
|
|
527
|
+
v_parent_event record;
|
|
528
|
+
v_actor_agent_id uuid;
|
|
529
|
+
begin
|
|
530
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
|
|
531
|
+
|
|
532
|
+
select * into v_parent from agent_board.tasks where id = p_parent_task_id;
|
|
533
|
+
if v_parent.id is null then
|
|
534
|
+
raise exception 'parent task not found' using errcode = 'P0002';
|
|
535
|
+
end if;
|
|
536
|
+
if not agent_board._current_agent_can_access_task(p_parent_task_id) then
|
|
537
|
+
raise exception 'parent task not accessible to current agent' using errcode = '42501';
|
|
538
|
+
end if;
|
|
539
|
+
|
|
540
|
+
select * into v_child
|
|
541
|
+
from agent_board.create_task(v_parent.thread_id, v_actor_agent_id, p_title, p_details, p_delegated_to_agent_id, p_parent_task_id, p_priority, 'hierarchical', p_metadata);
|
|
542
|
+
|
|
543
|
+
insert into agent_board.delegations(parent_task_id, child_task_id, delegated_by_agent_id, delegated_to_agent_id, note, metadata)
|
|
544
|
+
values (p_parent_task_id, v_child.task_id, v_actor_agent_id, p_delegated_to_agent_id, p_note, coalesce(p_metadata, '{}'::jsonb))
|
|
545
|
+
returning id into v_delegation_id;
|
|
546
|
+
|
|
547
|
+
select * into v_parent_event
|
|
548
|
+
from agent_board._append_task_event('unattested:signed-metadata-rpc', p_parent_task_id, v_actor_agent_id, 'delegated', jsonb_build_object('delegation_id', v_delegation_id, 'child_task_id', v_child.task_id));
|
|
549
|
+
|
|
550
|
+
return query
|
|
551
|
+
select v_delegation_id, v_child.task_id, p_parent_task_id, v_child.root_task_id, 'requested'::text, v_child.created_event_sequence, v_parent_event.event_sequence, now();
|
|
552
|
+
end;
|
|
553
|
+
$function$;
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
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)
|
|
557
|
+
RETURNS TABLE(conflict_id uuid, status text, task_event_sequence bigint, created_at timestamp with time zone, updated_at timestamp with time zone)
|
|
558
|
+
LANGUAGE plpgsql
|
|
559
|
+
SECURITY DEFINER
|
|
560
|
+
SET search_path TO 'agent_board', 'public'
|
|
561
|
+
AS $function$
|
|
562
|
+
declare
|
|
563
|
+
v_conflict_id uuid;
|
|
564
|
+
v_event_sequence bigint;
|
|
565
|
+
v_actor_agent_id uuid;
|
|
566
|
+
begin
|
|
567
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_raised_by_agent_id, 'p_raised_by_agent_id');
|
|
568
|
+
|
|
569
|
+
if not agent_board._current_agent_can_access_thread(p_thread_id) then
|
|
570
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
571
|
+
end if;
|
|
572
|
+
-- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role.
|
|
573
|
+
if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
|
|
574
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
575
|
+
end if;
|
|
576
|
+
|
|
577
|
+
insert into agent_board.conflicts(thread_id, task_id, raised_by_agent_id, summary, conflict_type, severity, details)
|
|
578
|
+
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))
|
|
579
|
+
returning id into v_conflict_id;
|
|
580
|
+
|
|
581
|
+
-- v_event_sequence stays NULL for a thread-level conflict (no task) — fixes the same latent
|
|
582
|
+
-- "record not assigned yet" bug on the no-task path.
|
|
583
|
+
if p_task_id is not null then
|
|
584
|
+
select event_sequence into v_event_sequence
|
|
585
|
+
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));
|
|
586
|
+
end if;
|
|
587
|
+
|
|
588
|
+
return query
|
|
589
|
+
select c.id, c.status, v_event_sequence, c.created_at, c.updated_at
|
|
590
|
+
from agent_board.conflicts c
|
|
591
|
+
where c.id = v_conflict_id;
|
|
592
|
+
end;
|
|
593
|
+
$function$;
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
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)
|
|
597
|
+
RETURNS TABLE(escalation_id uuid, status text, task_event_sequence bigint, created_at timestamp with time zone, updated_at timestamp with time zone)
|
|
598
|
+
LANGUAGE plpgsql
|
|
599
|
+
SECURITY DEFINER
|
|
600
|
+
SET search_path TO 'agent_board', 'public'
|
|
601
|
+
AS $function$
|
|
602
|
+
declare
|
|
603
|
+
v_escalation_id uuid;
|
|
604
|
+
v_event_sequence bigint;
|
|
605
|
+
v_actor_agent_id uuid;
|
|
606
|
+
begin
|
|
607
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_raised_by_agent_id, 'p_raised_by_agent_id');
|
|
608
|
+
|
|
609
|
+
if not agent_board._current_agent_can_access_thread(p_thread_id) then
|
|
610
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
611
|
+
end if;
|
|
612
|
+
-- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role.
|
|
613
|
+
if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
|
|
614
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
615
|
+
end if;
|
|
616
|
+
|
|
617
|
+
insert into agent_board.escalations(thread_id, task_id, raised_by_agent_id, assigned_agent_id, summary, escalation_type, severity, details)
|
|
618
|
+
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))
|
|
619
|
+
returning id into v_escalation_id;
|
|
620
|
+
|
|
621
|
+
-- v_event_sequence stays NULL for a thread-level escalation (no task) — fixes the same latent
|
|
622
|
+
-- "record not assigned yet" bug on the no-task path.
|
|
623
|
+
if p_task_id is not null then
|
|
624
|
+
select event_sequence into v_event_sequence
|
|
625
|
+
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));
|
|
626
|
+
end if;
|
|
627
|
+
|
|
628
|
+
return query
|
|
629
|
+
select e.id, e.status, v_event_sequence, e.created_at, e.updated_at
|
|
630
|
+
from agent_board.escalations e
|
|
631
|
+
where e.id = v_escalation_id;
|
|
632
|
+
end;
|
|
633
|
+
$function$;
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
CREATE OR REPLACE FUNCTION agent_board.resolve_conflict(p_conflict_id uuid, p_resolved_by_agent_id uuid, p_details jsonb DEFAULT '{}'::jsonb)
|
|
637
|
+
RETURNS TABLE(conflict_id uuid, status text, resolved_at timestamp with time zone, updated_at timestamp with time zone, task_event_sequence bigint)
|
|
638
|
+
LANGUAGE plpgsql
|
|
639
|
+
SECURITY DEFINER
|
|
640
|
+
SET search_path TO 'agent_board', 'public'
|
|
641
|
+
AS $function$
|
|
642
|
+
declare
|
|
643
|
+
v_conflict agent_board.conflicts%rowtype;
|
|
644
|
+
v_event_sequence bigint;
|
|
645
|
+
v_actor_agent_id uuid;
|
|
646
|
+
begin
|
|
647
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_resolved_by_agent_id, 'p_resolved_by_agent_id');
|
|
648
|
+
|
|
649
|
+
select *
|
|
650
|
+
into v_conflict
|
|
651
|
+
from agent_board.conflicts c
|
|
652
|
+
where c.id = p_conflict_id
|
|
653
|
+
and c.status <> 'resolved';
|
|
654
|
+
if v_conflict.id is null then
|
|
655
|
+
raise exception 'conflict not found' using errcode = 'P0002';
|
|
656
|
+
end if;
|
|
657
|
+
if not agent_board._current_agent_can_access_thread(v_conflict.thread_id) then
|
|
658
|
+
raise exception 'conflict not accessible to current agent' using errcode = '42501';
|
|
659
|
+
end if;
|
|
660
|
+
-- OBSGATE-4 (#127): a task-bound resolve is gated by append_task_event (#125 mutate-task,
|
|
661
|
+
-- assignee-aware). A TASK-LESS conflict has no such chokepoint, so gate it on the board
|
|
662
|
+
-- posting rule here (a board observer may not resolve).
|
|
663
|
+
if v_conflict.task_id is null
|
|
664
|
+
and not agent_board._current_agent_can_post_to_thread(v_conflict.thread_id) then
|
|
665
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
666
|
+
end if;
|
|
667
|
+
|
|
668
|
+
update agent_board.conflicts
|
|
669
|
+
set status = 'resolved',
|
|
670
|
+
resolved_by_agent_id = v_actor_agent_id,
|
|
671
|
+
resolved_at = now(),
|
|
672
|
+
details = details || coalesce(p_details, '{}'::jsonb),
|
|
673
|
+
updated_at = now()
|
|
674
|
+
where id = p_conflict_id
|
|
675
|
+
returning * into v_conflict;
|
|
676
|
+
|
|
677
|
+
-- v_event_sequence stays NULL for a task-less conflict (fixes the latent "record not assigned"
|
|
678
|
+
-- bug; the unconditional v_event.event_sequence reference raised 55000 on this path).
|
|
679
|
+
if v_conflict.task_id is not null then
|
|
680
|
+
select event_sequence into v_event_sequence
|
|
681
|
+
from agent_board._append_task_event('unattested:unsigned-deliberate-rpc', v_conflict.task_id, v_actor_agent_id, 'status_changed', jsonb_build_object('kind', 'conflict_resolved', 'conflict_id', p_conflict_id));
|
|
682
|
+
end if;
|
|
683
|
+
|
|
684
|
+
return query
|
|
685
|
+
select v_conflict.id, v_conflict.status, v_conflict.resolved_at, v_conflict.updated_at, v_event_sequence;
|
|
686
|
+
end;
|
|
687
|
+
$function$;
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
CREATE OR REPLACE FUNCTION agent_board.resolve_escalation(p_escalation_id uuid, p_resolved_by_agent_id uuid, p_details jsonb DEFAULT '{}'::jsonb)
|
|
691
|
+
RETURNS TABLE(escalation_id uuid, status text, resolved_at timestamp with time zone, updated_at timestamp with time zone, task_event_sequence bigint)
|
|
692
|
+
LANGUAGE plpgsql
|
|
693
|
+
SECURITY DEFINER
|
|
694
|
+
SET search_path TO 'agent_board', 'public'
|
|
695
|
+
AS $function$
|
|
696
|
+
declare
|
|
697
|
+
v_escalation agent_board.escalations%rowtype;
|
|
698
|
+
v_event_sequence bigint;
|
|
699
|
+
v_actor_agent_id uuid;
|
|
700
|
+
begin
|
|
701
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_resolved_by_agent_id, 'p_resolved_by_agent_id');
|
|
702
|
+
|
|
703
|
+
select *
|
|
704
|
+
into v_escalation
|
|
705
|
+
from agent_board.escalations e
|
|
706
|
+
where e.id = p_escalation_id
|
|
707
|
+
and e.status <> 'resolved';
|
|
708
|
+
if v_escalation.id is null then
|
|
709
|
+
raise exception 'escalation not found' using errcode = 'P0002';
|
|
710
|
+
end if;
|
|
711
|
+
if not agent_board._current_agent_can_access_thread(v_escalation.thread_id) then
|
|
712
|
+
raise exception 'escalation not accessible to current agent' using errcode = '42501';
|
|
713
|
+
end if;
|
|
714
|
+
-- OBSGATE-4 (#127): task-less escalation gated on the board posting rule (task-bound is gated
|
|
715
|
+
-- by the append below).
|
|
716
|
+
if v_escalation.task_id is null
|
|
717
|
+
and not agent_board._current_agent_can_post_to_thread(v_escalation.thread_id) then
|
|
718
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
719
|
+
end if;
|
|
720
|
+
|
|
721
|
+
update agent_board.escalations
|
|
722
|
+
set status = 'resolved',
|
|
723
|
+
resolved_by_agent_id = v_actor_agent_id,
|
|
724
|
+
resolved_at = now(),
|
|
725
|
+
details = details || coalesce(p_details, '{}'::jsonb),
|
|
726
|
+
updated_at = now()
|
|
727
|
+
where id = p_escalation_id
|
|
728
|
+
returning * into v_escalation;
|
|
729
|
+
|
|
730
|
+
if v_escalation.task_id is not null then
|
|
731
|
+
select event_sequence into v_event_sequence
|
|
732
|
+
from agent_board._append_task_event('unattested:unsigned-deliberate-rpc', v_escalation.task_id, v_actor_agent_id, 'status_changed', jsonb_build_object('kind', 'escalation_resolved', 'escalation_id', p_escalation_id));
|
|
733
|
+
end if;
|
|
734
|
+
|
|
735
|
+
return query
|
|
736
|
+
select v_escalation.id, v_escalation.status, v_escalation.resolved_at, v_escalation.updated_at, v_event_sequence;
|
|
737
|
+
end;
|
|
738
|
+
$function$;
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
CREATE OR REPLACE FUNCTION agent_board.signed_append_task_event(p_payload jsonb, p_gateway_decision jsonb)
|
|
742
|
+
RETURNS TABLE(task_id uuid, thread_id uuid, event_id uuid, event_sequence bigint, signature_evidence_id uuid, verification_status text, updated_at timestamp with time zone)
|
|
743
|
+
LANGUAGE plpgsql
|
|
744
|
+
SECURITY DEFINER
|
|
745
|
+
SET search_path TO 'agent_board', 'public'
|
|
746
|
+
AS $function$
|
|
747
|
+
declare
|
|
748
|
+
v_row record;
|
|
749
|
+
v_evidence_id uuid;
|
|
750
|
+
v_actor uuid;
|
|
751
|
+
begin
|
|
752
|
+
v_actor := agent_board._signed_write_actor_id(p_payload, p_gateway_decision, 'p_actor_agent_id');
|
|
753
|
+
select *
|
|
754
|
+
into v_row
|
|
755
|
+
from agent_board._append_task_event('attested:evidence-row',
|
|
756
|
+
(p_payload ->> 'p_task_id')::uuid,
|
|
757
|
+
v_actor,
|
|
758
|
+
p_payload ->> 'p_event_type',
|
|
759
|
+
coalesce(p_payload -> 'p_payload', '{}'::jsonb) || jsonb_build_object('signed_write', jsonb_build_object('verification_status', coalesce(p_gateway_decision ->> 'verification_status', 'unsigned'), 'redacted', true)),
|
|
760
|
+
nullif(p_payload ->> 'p_status', ''),
|
|
761
|
+
nullif(p_payload ->> 'p_assigned_agent_id', '')::uuid,
|
|
762
|
+
nullif(p_payload ->> 'p_completed_at', '')::timestamptz
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
v_evidence_id := agent_board._record_signed_write_evidence('task_event', v_row.event_id, 'append_task_event', p_payload, p_gateway_decision);
|
|
766
|
+
|
|
767
|
+
return query
|
|
768
|
+
select v_row.task_id, v_row.thread_id, v_row.event_id, v_row.event_sequence, v_evidence_id, coalesce(p_gateway_decision ->> 'verification_status', 'unsigned'), v_row.updated_at;
|
|
769
|
+
end;
|
|
770
|
+
$function$;
|
|
771
|
+
|
|
772
|
+
-- 3. The public entry point keeps its EXACT signature and becomes a thin delegation.
|
|
773
|
+
--
|
|
774
|
+
-- CREATE OR REPLACE with an unchanged argument list, so the ACL (authenticated, service_role)
|
|
775
|
+
-- and the COMMENT survive without being restated. Adding p_origin_posture to THIS signature
|
|
776
|
+
-- would have created an OVERLOAD rather than a replacement — two callable functions and an
|
|
777
|
+
-- ambiguity risk — which is why the parameter lives on the internal function instead.
|
|
778
|
+
--
|
|
779
|
+
-- 'unattested:direct-rpc' is the honest label for this path: a caller reaching the public RPC declared
|
|
780
|
+
-- nothing about its posture, and the database cannot introspect its caller.
|
|
781
|
+
create or replace function agent_board.append_task_event(
|
|
782
|
+
p_task_id uuid,
|
|
783
|
+
p_actor_agent_id uuid,
|
|
784
|
+
p_event_type text,
|
|
785
|
+
p_payload jsonb default '{}'::jsonb,
|
|
786
|
+
p_status text default null,
|
|
787
|
+
p_assigned_agent_id uuid default null,
|
|
788
|
+
p_completed_at timestamp with time zone default null
|
|
789
|
+
)
|
|
790
|
+
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)
|
|
791
|
+
language plpgsql
|
|
792
|
+
security definer
|
|
793
|
+
set search_path to 'agent_board', 'public'
|
|
794
|
+
as $function$
|
|
795
|
+
begin
|
|
796
|
+
return query
|
|
797
|
+
select *
|
|
798
|
+
from agent_board._append_task_event(
|
|
799
|
+
'unattested:direct-rpc',
|
|
800
|
+
p_task_id, p_actor_agent_id, p_event_type, p_payload, p_status, p_assigned_agent_id, p_completed_at
|
|
801
|
+
);
|
|
802
|
+
end;
|
|
803
|
+
$function$;
|
|
804
|
+
|
|
805
|
+
-- The COMMENT is restated because the body's behaviour changed: the gating and lock order it
|
|
806
|
+
-- described now live in _append_task_event. Restating is required whenever behaviour moves,
|
|
807
|
+
-- even though CREATE OR REPLACE would have preserved the stale text.
|
|
808
|
+
comment on function agent_board.append_task_event(uuid, uuid, text, jsonb, text, uuid, timestamp with time zone) is
|
|
809
|
+
'#288: delegates to agent_board._append_task_event with origin_posture ''unattested:direct-rpc'' — a caller reaching this public RPC declares no posture and the database cannot introspect its caller. The #281 R1 lock order (threads KEY SHARE before tasks FOR UPDATE), the OBSGATE-3 (#125) gating and every error code are unchanged; they moved verbatim into _append_task_event.';
|
|
810
|
+
|
|
811
|
+
-- 4. The internal funnel is a FRESH create, so its ACL does not exist yet and a SECURITY
|
|
812
|
+
-- DEFINER function grants EXECUTE to PUBLIC by default. Revoke, and grant to NOBODY:
|
|
813
|
+
-- every caller is itself SECURITY DEFINER, so the inner call already runs as the owner.
|
|
814
|
+
-- This is a net REDUCTION in reachable surface — the body is now behind a function no role
|
|
815
|
+
-- can invoke directly. Mutation M7 pins it.
|
|
816
|
+
revoke all on function agent_board._append_task_event(text, uuid, uuid, text, jsonb, text, uuid, timestamp with time zone) from public, anon, authenticated, service_role;
|
|
817
|
+
|
|
818
|
+
comment on function agent_board._append_task_event(text, uuid, uuid, text, jsonb, text, uuid, timestamp with time zone) is
|
|
819
|
+
'#288: the single funnel every task_events append passes through. p_origin_posture has NO DEFAULT deliberately — an internal caller must declare its posture, and a caller that omits it fails to compile rather than inheriting a plausible-looking label (mutation M5). Holds the #281 R1 lock order and OBSGATE-3 (#125) gating verbatim from the public append_task_event it was extracted from. Granted to no role: every caller is SECURITY DEFINER, so the inner call runs as the owner.';
|
|
820
|
+
|
|
821
|
+
-- 5. The read surface. origin_posture is appended as a TRAILING column — CREATE OR REPLACE
|
|
822
|
+
-- VIEW permits adding only at the end, and preserves the view's ACL.
|
|
823
|
+
--
|
|
824
|
+
-- verification_status is UNCHANGED, still NULL where no evidence row exists. That is the
|
|
825
|
+
-- point: the two columns answer different questions. A consumer asking "was this signed?"
|
|
826
|
+
-- reads verification_status and still gets NULL; a consumer asking "how was this written?"
|
|
827
|
+
-- now gets an answer instead of the same blank. Folding them would let a stamped posture
|
|
828
|
+
-- satisfy a gate written against the evidence vocabulary (mutation M8).
|
|
829
|
+
create or replace view agent_board.v_task_tree
|
|
830
|
+
with (security_invoker = true)
|
|
831
|
+
as
|
|
832
|
+
select
|
|
833
|
+
coalesce(t.root_task_id, t.id) as root_task_id,
|
|
834
|
+
t.id as task_id,
|
|
835
|
+
t.parent_task_id,
|
|
836
|
+
t.thread_id,
|
|
837
|
+
t.created_by_agent_id,
|
|
838
|
+
t.assigned_agent_id,
|
|
839
|
+
assigned.handle as assigned_agent_handle,
|
|
840
|
+
t.status,
|
|
841
|
+
t.priority,
|
|
842
|
+
t.orchestration_mode,
|
|
843
|
+
t.title,
|
|
844
|
+
t.details,
|
|
845
|
+
t.created_at,
|
|
846
|
+
t.updated_at,
|
|
847
|
+
t.completed_at,
|
|
848
|
+
latest.event_sequence as latest_event_sequence,
|
|
849
|
+
latest.event_type as latest_event_type,
|
|
850
|
+
latest.payload->>'kind' as latest_event_kind,
|
|
851
|
+
sig.verification_status,
|
|
852
|
+
sig.key_fingerprint,
|
|
853
|
+
sig.key_version,
|
|
854
|
+
sig.algorithm,
|
|
855
|
+
sig.verified_at,
|
|
856
|
+
sig.failure_class,
|
|
857
|
+
latest.origin_posture
|
|
858
|
+
from agent_board.tasks t
|
|
859
|
+
left join agent_board.agents assigned on assigned.id = t.assigned_agent_id
|
|
860
|
+
left join lateral (
|
|
861
|
+
select te.id, te.event_sequence, te.event_type, te.payload, te.origin_posture
|
|
862
|
+
from agent_board.task_events te
|
|
863
|
+
where te.task_id = t.id
|
|
864
|
+
order by te.event_sequence desc
|
|
865
|
+
limit 1
|
|
866
|
+
) latest on true
|
|
867
|
+
left join lateral (
|
|
868
|
+
select
|
|
869
|
+
se.verification_status,
|
|
870
|
+
ask.fingerprint as key_fingerprint,
|
|
871
|
+
se.key_version,
|
|
872
|
+
se.algorithm,
|
|
873
|
+
se.verified_at,
|
|
874
|
+
se.failure_class
|
|
875
|
+
from agent_board.signature_evidence se
|
|
876
|
+
left join agent_board.agent_signing_keys ask on ask.id = se.signing_key_id
|
|
877
|
+
where se.source_kind = 'task_event'
|
|
878
|
+
and se.source_task_event_id = latest.id
|
|
879
|
+
order by se.created_at desc
|
|
880
|
+
limit 1
|
|
881
|
+
) sig on true;
|