@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.
@@ -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;