@consiliency/agent-board-schema 1.27.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/fixtures/already-applied.json +32 -0
  2. package/migrations/20260909000100_agent_board_ledger_revoked_authority.sql +1036 -0
  3. package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
  4. package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
  5. package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
  6. package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
  7. package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
  8. package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
  9. package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
  10. package/package.json +1 -1
  11. package/schema-manifest.json +75 -3
  12. package/src/tooling.test.mjs +47 -2
  13. package/tests/agent_board_atomic_fleet_onboarding.test.sql +161 -30
  14. package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
  15. package/tests/agent_board_enroll_resumption.test.sql +7 -1
  16. package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
  17. package/tests/agent_board_fedsession.test.sql +17 -2
  18. package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
  19. package/tests/agent_board_fk_lock_edges.test.sql +422 -0
  20. package/tests/agent_board_ledger_authority.test.sql +1081 -0
  21. package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
  22. package/tests/agent_board_signer_lifecycle.test.sql +29 -6
  23. package/tests/agent_board_source_strip_helper.test.sql +157 -1
  24. package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
  25. package/tests/catalog/p_metadata_parameters.json +20 -0
  26. package/tests/fixtures/test-prereqs.sql +420 -11
  27. package/tests/isolation/expected/ledger_authority_revoke.out +701 -0
  28. package/tests/isolation/expected/signer_lifecycle_revoke.out +1 -1
  29. package/tests/isolation/expected/task_thread_lock_order.out +203 -0
  30. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
  31. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
  32. package/tests/isolation/specs/ledger_authority_revoke.spec +405 -0
  33. package/tests/isolation/specs/signer_lifecycle_revoke.spec +10 -0
  34. package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
  35. package/tests/seeds/20260916000100_agent_board_federated_identity_injective_slug.sql.pre.sql +196 -0
@@ -0,0 +1,348 @@
1
+ -- Consiliency/message-board#287 — a read receipt is a MEMO of access, never a SOURCE of it.
2
+ --
3
+ -- ============================================================================
4
+ -- THE MEASURED DEFECT
5
+ -- ============================================================================
6
+ -- `agent_board.read_receipts` is an access KEY: `_current_agent_can_access_thread` treats
7
+ -- "there is a receipt for (thread, me)" as one of the seven disjuncts that grant access to an
8
+ -- `access_mode = 'inherit'` thread. `mark_read` is the ONLY route by which a `service_role`
9
+ -- caller can create such a row — `information_schema.role_table_grants` gives `service_role`
10
+ -- exactly SELECT on the table, no INSERT and no UPDATE, so the SECURITY DEFINER body is the
11
+ -- only path.
12
+ --
13
+ -- The subject of the receipt is `p_agent_id` and nothing gated it. The gate `mark_read` did
14
+ -- apply — `_current_agent_can_access_thread(p_thread_id)` — is evaluated for the SESSION, and
15
+ -- `_is_service_role()` short-circuits it to true; `_resolved_actor_agent_id` then returns
16
+ -- `p_agent_id` verbatim for a service_role caller. Measured on a live replay inside
17
+ -- BEGIN … ROLLBACK: an agent B with no relationship to an inherit thread T went
18
+ -- `can_access = false` -> service_role calls `mark_read(T, B, 0)` -> `can_access = true`,
19
+ -- without B calling anything. The memo minted a grant.
20
+ --
21
+ -- Under `authenticated` there was never a gap: `_resolved_actor_agent_id` forces the subject to
22
+ -- equal `current_agent_id()`, and the session must already pass the predicate. The asymmetry was
23
+ -- service_role-only.
24
+ --
25
+ -- ============================================================================
26
+ -- THE FIX — gate the SUBJECT, not the caller
27
+ -- ============================================================================
28
+ -- The NAMED agent must itself pass the thread-access predicate before a receipt is written for
29
+ -- it. Then a receipt can only ever record access already held.
30
+ --
31
+ -- HOW THE PREDICATE IS EVALUATED FOR AN ARBITRARY SUBJECT. The shipped predicate is
32
+ -- `current_agent_id()`-based, and under `service_role` there is no current agent, so it cannot
33
+ -- simply be called for the subject. Impersonating the subject (rewriting `request.jwt.claims`
34
+ -- so `current_agent_id()` resolves to it) was rejected: `current_agent_id()` additionally
35
+ -- requires the JWT's `agent_credential_id` claim to reference a live credential, so a forged
36
+ -- session would answer a DIFFERENT question than "does this agent have access", and
37
+ -- `set_config` is volatile inside a `stable` predicate.
38
+ --
39
+ -- Instead the predicate is PARAMETERIZED over the agent, and the current-agent form is
40
+ -- recomposed from it:
41
+ --
42
+ -- _agent_can_access_thread(p_thread_id, p_agent_id) -- the disjunct set, subject-explicit
43
+ -- _current_agent_can_access_thread(p_thread_id)
44
+ -- = _is_service_role() or _agent_can_access_thread(p_thread_id, current_agent_id())
45
+ --
46
+ -- There is therefore exactly ONE definition of the disjunct set. A future disjunct added to the
47
+ -- access model is added once and is seen by both the session check and the subject gate; the two
48
+ -- cannot drift, which is the failure mode a hand-copied second predicate would have guaranteed.
49
+ -- `_agent_has_board_access(p_board_id, p_agent_id)` is the same move for the board-delivery
50
+ -- disjunct, whose helper is also `current_agent_id()`-based.
51
+ --
52
+ -- THE INVARIANT THAT MAKES THE GATE REAL: neither parameterized helper carries the
53
+ -- `_is_service_role()` short-circuit. That short-circuit is what made the session check vacuous
54
+ -- for the very caller this closes; reintroducing it into `_agent_can_access_thread` would make
55
+ -- the gate a no-op while every test that exercises an agent WITH access stayed green. The
56
+ -- short-circuit lives in exactly one place, `_current_agent_can_access_thread`, where it is a
57
+ -- statement about the SESSION and remains correct.
58
+ --
59
+ -- Behaviour for `authenticated` is unchanged BY CONSTRUCTION, not by inspection: the session
60
+ -- check for a non-service_role caller already evaluates
61
+ -- `_agent_can_access_thread(p_thread_id, current_agent_id())`, and `_resolved_actor_agent_id`
62
+ -- makes `v_actor_agent_id = current_agent_id()`, so the new gate re-evaluates an expression that
63
+ -- was just proved true. It is applied unconditionally rather than only when the subject differs
64
+ -- from the session agent — a conditional gate would be one `is distinct from` away from being
65
+ -- skipped by a caller that presents both a service_role claim and a `sub`.
66
+ --
67
+ -- GATE PLACEMENT is deliberate: session check (42501) -> thread lookup (P0002) -> subject gate
68
+ -- (42501). Putting the subject gate last preserves the existing error CODES exactly, including
69
+ -- "a service_role caller naming a nonexistent thread still gets P0002 'thread not found'", which
70
+ -- moving the gate earlier would have silently turned into 42501.
71
+ --
72
+ -- ============================================================================
73
+ -- WHAT IS DELIBERATELY NOT CHANGED — the receipt grandfather
74
+ -- ============================================================================
75
+ -- The receipt disjunct itself STAYS. Nothing in this schema deletes a `read_receipts` row (there
76
+ -- is no DELETE grant to any Supabase role and no function body deletes from it), so a receipt is
77
+ -- PERMANENT: an agent that leaves a board, or is reassigned off a task, keeps access to the
78
+ -- threads it had already opened. That is the ratified rule — YOU KEEP WHAT YOU SAW — and it is
79
+ -- load-bearing for two measured flows: a late board joiner, and a reassigned task assignee, both
80
+ -- of which lose thread access entirely if the disjunct is removed. It is recorded here and in
81
+ -- the `comment on function` below because it arrived in a 2026-04 commit with an empty message
82
+ -- and the only prior record of intent was a later decision to "preserve the existing OR
83
+ -- verbatim" — nobody was on record CHOOSING it.
84
+ --
85
+ -- The gate and the grandfather are complementary, not in tension: the gate decides WHO MAY BE
86
+ -- NAMED in a receipt, the grandfather decides WHAT A RECEIPT MEANS afterwards. Gating the
87
+ -- subject is what makes the grandfather safe to keep, because a receipt can no longer be
88
+ -- conjured for an agent that never had access.
89
+ --
90
+ -- OUT OF SCOPE, stated rather than implied: `acquire_claim` has the same third-party-naming
91
+ -- shape on the sibling `claims` disjunct (and worse — a RELEASED claim row remains a permanent
92
+ -- key). The owner chose to close the receipt route only; the claims/inbox sibling survey is
93
+ -- filed separately. This migration does not touch `acquire_claim`.
94
+ --
95
+ -- Additive; no signature changes (so no DROP, so the ACLs and COMMENTs on the two redefined
96
+ -- functions survive). `schema_version` stays 8.
97
+
98
+ -- 1. The disjunct set, parameterized over the subject agent ------------------
99
+ -- NOTE the absence of `_is_service_role()`: see THE INVARIANT above.
100
+ create or replace function agent_board._agent_has_board_access(
101
+ p_board_id uuid,
102
+ p_agent_id uuid
103
+ )
104
+ returns boolean
105
+ language sql
106
+ stable
107
+ security definer
108
+ set search_path = ''
109
+ as $$
110
+ select exists (
111
+ select 1
112
+ from agent_board.board_memberships bm
113
+ where bm.board_id = p_board_id
114
+ and bm.agent_id = p_agent_id
115
+ and bm.membership_state in ('joined', 'subscribed')
116
+ )
117
+ $$;
118
+
119
+ revoke all on function agent_board._agent_has_board_access(uuid, uuid) from public, anon, authenticated;
120
+ grant execute on function agent_board._agent_has_board_access(uuid, uuid) to service_role;
121
+
122
+ comment on function agent_board._agent_has_board_access(uuid, uuid) is
123
+ 'Board access for an EXPLICIT agent: an active (joined|subscribed) board_memberships row. '
124
+ 'Subject-explicit twin of _current_agent_has_board_access, which is this predicate applied to '
125
+ 'current_agent_id() with an _is_service_role() short-circuit in front. This one has NO '
126
+ 'short-circuit and must never gain one: it is evaluated on behalf of a service_role caller to '
127
+ 'answer a question about a THIRD party (message-board#287), and a short-circuit would answer '
128
+ '"yes" for every agent. Internal; service_role only.';
129
+
130
+ create or replace function agent_board._agent_can_access_thread(
131
+ p_thread_id uuid,
132
+ p_agent_id uuid
133
+ )
134
+ returns boolean
135
+ language sql
136
+ stable
137
+ security definer
138
+ set search_path = ''
139
+ as $$
140
+ select exists (
141
+ select 1
142
+ from agent_board.threads t
143
+ where t.id = p_thread_id
144
+ and (
145
+ (
146
+ t.access_mode = 'inherit'
147
+ and (
148
+ t.created_by_agent_id = p_agent_id
149
+ or exists (
150
+ select 1
151
+ from agent_board.messages m
152
+ where m.thread_id = t.id
153
+ and m.author_agent_id = p_agent_id
154
+ )
155
+ or exists (
156
+ select 1
157
+ from agent_board.tasks task
158
+ where task.thread_id = t.id
159
+ and (
160
+ task.created_by_agent_id = p_agent_id
161
+ or task.assigned_agent_id = p_agent_id
162
+ )
163
+ )
164
+ or exists (
165
+ select 1
166
+ from agent_board.read_receipts rr
167
+ where rr.thread_id = t.id
168
+ and rr.agent_id = p_agent_id
169
+ )
170
+ or exists (
171
+ select 1
172
+ from agent_board.claims c
173
+ where c.thread_id = t.id
174
+ and c.agent_id = p_agent_id
175
+ )
176
+ or exists (
177
+ select 1
178
+ from agent_board.inbox_items ii
179
+ left join agent_board.messages im on im.id = ii.source_message_id
180
+ left join agent_board.tasks it on it.id = ii.source_task_id
181
+ left join agent_board.delegations ide on ide.id = ii.source_delegation_id
182
+ left join agent_board.tasks idt on idt.id = ide.child_task_id
183
+ where ii.recipient_agent_id = p_agent_id
184
+ and (
185
+ im.thread_id = t.id
186
+ or it.thread_id = t.id
187
+ or idt.thread_id = t.id
188
+ )
189
+ )
190
+ or exists (
191
+ select 1
192
+ from agent_board.delivery_recipients dr
193
+ join agent_board.messages bm on bm.id = dr.source_message_id
194
+ where dr.board_id is not null
195
+ and bm.thread_id = t.id
196
+ and agent_board._agent_has_board_access(dr.board_id, p_agent_id)
197
+ )
198
+ )
199
+ )
200
+ or (
201
+ t.access_mode = 'restricted'
202
+ and exists (
203
+ select 1
204
+ from agent_board.thread_memberships tm
205
+ where tm.thread_id = t.id
206
+ and tm.agent_id = p_agent_id
207
+ and tm.revoked_at is null
208
+ and (tm.expires_at is null or tm.expires_at > now())
209
+ )
210
+ )
211
+ )
212
+ )
213
+ $$;
214
+
215
+ revoke all on function agent_board._agent_can_access_thread(uuid, uuid) from public, anon, authenticated;
216
+ grant execute on function agent_board._agent_can_access_thread(uuid, uuid) to service_role;
217
+
218
+ comment on function agent_board._agent_can_access_thread(uuid, uuid) is
219
+ 'THE thread-access predicate, evaluated for an EXPLICIT agent. access_mode=inherit is the '
220
+ 'historical seven-way OR (creator | message author | task creator-or-assignee | read receipt | '
221
+ 'claim | inbox item | board-delivered message on a board the agent is in); '
222
+ 'access_mode=restricted requires an unexpired, unrevoked thread_memberships row. '
223
+ '_current_agent_can_access_thread is DEFINED AS _is_service_role() OR this predicate applied '
224
+ 'to current_agent_id(), so this is the single source of the disjunct set and the two cannot '
225
+ 'drift. It carries NO _is_service_role() short-circuit and must never gain one: mark_read '
226
+ 'evaluates it on behalf of a service_role caller to decide whether a THIRD party may be named '
227
+ 'in a receipt (message-board#287), and a short-circuit would make that gate vacuous while '
228
+ 'leaving every allow-side test green. A null p_agent_id yields false. Internal; '
229
+ 'service_role only.';
230
+
231
+ -- 2. Recompose the current-agent form from the parameterized one ------------
232
+ -- create or replace (not drop): the signature is unchanged, so the existing ACL survives.
233
+ create or replace function agent_board._current_agent_can_access_thread(
234
+ p_thread_id uuid
235
+ )
236
+ returns boolean
237
+ language sql
238
+ stable
239
+ security definer
240
+ set search_path = ''
241
+ as $$
242
+ select
243
+ agent_board._is_service_role()
244
+ or agent_board._agent_can_access_thread(p_thread_id, agent_board.current_agent_id())
245
+ $$;
246
+
247
+ revoke all on function agent_board._current_agent_can_access_thread(uuid) from public, anon, authenticated;
248
+
249
+ comment on function agent_board._current_agent_can_access_thread(uuid) is
250
+ 'Thread access for the CURRENT session: service_role short-circuits true, otherwise '
251
+ '_agent_can_access_thread(p_thread_id, current_agent_id()). The disjunct set lives in that '
252
+ 'helper and is not restated here. '
253
+ 'THE READ RECEIPT IS A PERMANENT GRANDFATHER, AND THAT IS DELIBERATE (message-board#287). '
254
+ 'An agent_board.read_receipts row is one of the inherit-mode disjuncts, and nothing in this '
255
+ 'schema ever deletes one: no role holds DELETE on the table and no function body removes a '
256
+ 'row. So access that was once exercised is never withdrawn -- LEAVING A BOARD, OR LOSING A '
257
+ 'TASK ASSIGNMENT, DOES NOT REVOKE ACCESS TO A THREAD THE AGENT HAD ALREADY READ. The rule is '
258
+ '"you keep what you saw". This is not an oversight and it is not a leak to be closed later: '
259
+ 'two shipped flows depend on it and lose thread access entirely without it -- an agent that '
260
+ 'joins a board late and reads history, and an agent reassigned off a task whose thread it '
261
+ 'worked. The same is true of the claims and inbox_items disjuncts, which are likewise never '
262
+ 'deleted. What IS closed is the mint: mark_read gates the NAMED subject through '
263
+ '_agent_can_access_thread, so a receipt can only ever record access already held and can no '
264
+ 'longer be conjured for an agent that never had any.';
265
+
266
+ -- 3. mark_read: gate the named subject --------------------------------------
267
+ -- create or replace (not drop): signature unchanged, ACL survives.
268
+ create or replace function agent_board.mark_read(p_thread_id uuid, p_agent_id uuid, p_last_read_message_sequence bigint)
269
+ returns table(thread_id uuid, agent_id uuid, last_read_message_sequence bigint, last_read_at timestamptz)
270
+ language plpgsql
271
+ security definer
272
+ set search_path = agent_board, public
273
+ as $$
274
+ declare
275
+ v_sequence bigint;
276
+ v_actor_agent_id uuid;
277
+ begin
278
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
279
+
280
+ if p_last_read_message_sequence < 0 then
281
+ raise exception 'negative read sequence' using errcode = '22023';
282
+ end if;
283
+
284
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
285
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
286
+ end if;
287
+
288
+ select least(p_last_read_message_sequence, t.last_message_sequence)
289
+ into v_sequence
290
+ from agent_board.threads t
291
+ where t.id = p_thread_id;
292
+
293
+ if v_sequence is null then
294
+ raise exception 'thread not found' using errcode = 'P0002';
295
+ end if;
296
+
297
+ -- message-board#287: the SUBJECT gate. The check above answers "may this SESSION touch the
298
+ -- thread" and is short-circuited true for service_role; this one answers "does the agent the
299
+ -- receipt is ABOUT already have access". Without it a service_role caller could name any
300
+ -- agent and the resulting receipt -- itself an access disjunct -- would MINT access rather
301
+ -- than record it. Unconditional: for an authenticated caller v_actor_agent_id is
302
+ -- current_agent_id() and the session check already established this same expression, so this
303
+ -- is provably a no-op on that path.
304
+ --
305
+ -- DO NOT "OPTIMIZE" THIS INTO `if _is_service_role() and not ...`. The redundancy on the
306
+ -- authenticated path is the PRICE of an unconditional invariant, and it is worth paying:
307
+ -- branching on _is_service_role() would make this gate's correctness depend on
308
+ -- _resolved_actor_agent_id continuing to raise 42501 whenever a non-service_role caller
309
+ -- supplies an id other than current_agent_id(). That is true today, but it converts a
310
+ -- property this function states for itself into one it borrows from another function.
311
+ --
312
+ -- The second evaluation does open a one-statement window at READ COMMITTED, since each
313
+ -- plpgsql statement takes a fresh snapshot: access revoked between the two checks turns a
314
+ -- would-be receipt into a 42501. That non-atomicity is PRE-EXISTING, not introduced here --
315
+ -- the old code already had a window between its single check and the INSERT -- and this
316
+ -- widens it by one statement. The outcome is a spurious refusal for an agent that has just
317
+ -- lost access, which retries to a consistent 42501: no security consequence, and no
318
+ -- grandfathered access lost by anyone who still holds it.
319
+ if not agent_board._agent_can_access_thread(p_thread_id, v_actor_agent_id) then
320
+ raise exception 'read receipt subject has no access to thread' using errcode = '42501';
321
+ end if;
322
+
323
+ return query
324
+ insert into agent_board.read_receipts(thread_id, agent_id, last_read_message_sequence, last_read_at)
325
+ values (p_thread_id, v_actor_agent_id, v_sequence, now())
326
+ on conflict on constraint read_receipts_pkey do update
327
+ set last_read_message_sequence = greatest(read_receipts.last_read_message_sequence, excluded.last_read_message_sequence),
328
+ last_read_at = now()
329
+ returning read_receipts.thread_id, read_receipts.agent_id, read_receipts.last_read_message_sequence, read_receipts.last_read_at;
330
+ end;
331
+ $$;
332
+
333
+ revoke all on function agent_board.mark_read(uuid, uuid, bigint) from public, anon;
334
+ grant execute on function agent_board.mark_read(uuid, uuid, bigint) to authenticated, service_role;
335
+
336
+ comment on function agent_board.mark_read(uuid, uuid, bigint) is
337
+ 'Advances the (thread, agent) read cursor in agent_board.read_receipts. The subject is '
338
+ '_resolved_actor_agent_id(p_agent_id): authenticated callers are forced to current_agent_id() '
339
+ 'and fail closed on mismatch; service_role may mark on behalf of another agent. '
340
+ 'A RECEIPT IS A MEMO OF ACCESS, NEVER A SOURCE OF IT (message-board#287): because a receipt '
341
+ 'row is itself a disjunct of _agent_can_access_thread, the NAMED agent must already pass that '
342
+ 'predicate or the call raises 42501 ''read receipt subject has no access to thread''. This '
343
+ 'closes the only route by which service_role could create a read_receipts row for an '
344
+ 'arbitrary agent (service_role holds SELECT only on the table, so this SECURITY DEFINER body '
345
+ 'is the sole path) and thereby hand it durable access to an inherit thread. The gate is placed '
346
+ 'after the thread lookup so a nonexistent thread still answers P0002, not 42501. All three '
347
+ 'shipped callers (client, CLI, MCP) default p_agent_id to the session''s own acting agent and '
348
+ 'name no third party, so nothing in the fleet changes shape.';
@@ -0,0 +1,292 @@
1
+ -- Consiliency/message-board#281 (R1) — agree on ONE order for (threads, tasks):
2
+ -- THREADS BEFORE TASKS, in every function that touches both.
3
+ --
4
+ -- WHAT WAS WRONG. #290's FK-aware detector (agent_board_test.lock_edges()) found 186
5
+ -- unserialised ABBA rows over 27 relation pairs. Forty of them — the single largest block,
6
+ -- and the only one whose operand coincidence is an ordinary workload rather than a
7
+ -- lifecycle event — sat on `tasks <-> threads`:
8
+ --
9
+ -- append_task_event: tasks (explicit FOR UPDATE) -> threads (KEY SHARE, via the
10
+ -- task_events.thread_id FK on the event insert)
11
+ -- post_message: threads (UPDATE, one frame down in
12
+ -- _next_thread_message_sequence) -> tasks (KEY SHARE, via the
13
+ -- messages.task_id FK on the message insert)
14
+ --
15
+ -- The colliding operand is `postMessage({ threadId: task.threadId, taskId: task.id })` —
16
+ -- commenting on a task in its own thread — concurrent with any agent claiming, delegating
17
+ -- or resolving that task. Both sides are foreground, user-attributable writes.
18
+ --
19
+ -- WHAT IS ACTUALLY REACHABLE TODAY, STATED HONESTLY. Those 40 rows are a lock-ORDER
20
+ -- disagreement, not a live deadlock. `_next_thread_message_sequence` updates
21
+ -- `threads.last_message_sequence` / `updated_at`; `threads` carries exactly one unique
22
+ -- index (threads_pkey on id), so that UPDATE takes FOR NO KEY UPDATE — which by
23
+ -- PostgreSQL's matrix (pinned verbatim as FKL-2) does NOT conflict with the FOR KEY SHARE
24
+ -- that append_task_event takes on threads through the FK. So post_message blocks on
25
+ -- append_task_event's `tasks FOR UPDATE`, but append_task_event never blocks on
26
+ -- post_message's threads lock: one-way blocking, no cycle. The detector reports the rows
27
+ -- because `_acq` aggregates any write-update to mode `update` — it over-reports by design,
28
+ -- which is the correct direction for a safety instrument.
29
+ --
30
+ -- That is a reason to fix the ORDER, not a reason to leave it. The CYCLE this closes is
31
+ -- LATENT, not live, and the permanent record should say so: nobody should later read this
32
+ -- migration as evidence that a real deadlock was occurring, because none was.
33
+ --
34
+ -- BUT "LATENT" UNDERSTATES THE CHANGE, AND THE UNDERSTATEMENT IS THE DANGEROUS DIRECTION —
35
+ -- "latent only" is exactly the framing that invites someone to deprioritise or revert this.
36
+ -- The reorder also fixes a LOCK-CONVOY THAT HAPPENS TODAY, against the real shipped
37
+ -- `create_or_find_thread` (which holds `threads … for update` on its reuse path).
38
+ -- * BEFORE: append_task_event took `tasks … for update` FIRST and did not reach its
39
+ -- threads lock until the task_events insert. So when it blocked behind
40
+ -- create_or_find_thread's thread lock, IT BLOCKED WHILE HOLDING tasks FOR UPDATE —
41
+ -- and every other writer of that task queued behind an unrelated thread lookup.
42
+ -- * AFTER: it takes the threads KEY SHARE first, HOLDING NOTHING, and reaches `tasks`
43
+ -- only once that is granted. Same wait for the appender; no convoy for anyone else.
44
+ -- Measured with isolationtester, three sessions — real create_or_find_thread, real
45
+ -- append_task_event, and a `tasks … for key share` observer standing in for any FK
46
+ -- reference to that task (messages.task_id, delivery_recipients.source_task_id,
47
+ -- delegations.child_task_id all take exactly that mode). PRE-FIX the observer WAITS; POST-FIX
48
+ -- it completes immediately. In both runs the appender itself waits — that part is unchanged
49
+ -- and is not what the fix improves.
50
+ --
51
+ -- IF YOU ARE THE AUTHOR WHO ACTIVATES IT, THIS PARAGRAPH IS FOR YOU. The cycle goes live
52
+ -- the moment anything takes a lock on `threads` that CONFLICTS with FOR KEY SHARE. Four
53
+ -- ways to do that without ever reading this file, and the first is NOT hypothetical:
54
+ -- * GIVING `create_or_find_thread` A `tasks` ACQUISITION. It already takes
55
+ -- `select … from agent_board.threads … for update`
56
+ -- (20260517000000_agent_board_addressing.sql:265-273) — it is the only function in the
57
+ -- bundle holding an explicit lock on threads — and it is harmless here for exactly one
58
+ -- reason: it touches NO `tasks` row (its only other edges are FK KEY SHARE on `agents`
59
+ -- and `boards`). So it already performs the first half of the adversary that the
60
+ -- isolation spec's `probe` session has to synthesize. Add any tasks acquisition to it —
61
+ -- a `tasks` lookup, an FK to tasks on a row it writes — and permutation 1 of
62
+ -- tests/isolation/specs/task_thread_lock_order.spec stops being an instrument and
63
+ -- becomes a shipped path. It is the shortest route from here to a live deadlock.
64
+ -- * an explicit `for update` (or `for share` / `for no key update`) on threads in some
65
+ -- OTHER function that also reaches tasks;
66
+ -- * a `delete from agent_board.threads`, which row-locks the deleted row outright;
67
+ -- * ADDING A UNIQUE INDEX THAT COVERS A COLUMN THE SEQUENCE UPDATE WRITES — with no
68
+ -- function change at all. Be precise about which columns those are, or this reads as a
69
+ -- warning against every index: `_next_thread_message_sequence` writes exactly
70
+ -- `last_message_sequence` and `updated_at`. A unique index that covers one of THOSE
71
+ -- promotes its UPDATE from FOR NO KEY UPDATE to FOR UPDATE; a unique index on any
72
+ -- other threads column (`board_id`, a slug, anything the statement does not SET)
73
+ -- leaves it at FOR NO KEY UPDATE and changes nothing here. Verified empirically with
74
+ -- isolationtester on a two-column toy table rather than asserted from the manual: with
75
+ -- the unique index on a column the UPDATE does not write, a concurrent FOR KEY SHARE
76
+ -- did not wait; moved onto the column it does write, the same FOR KEY SHARE waited.
77
+ -- `updated_at` is the live end of this: it is generic bookkeeping, it is the column
78
+ -- most likely to be swept into a unique or covering index for optimistic concurrency
79
+ -- (`unique (id, updated_at)`) or a dedup window, and nobody adding such an index would
80
+ -- connect it to task-event ordering.
81
+ -- None of those has any visible connection to append_task_event. What protects you is that
82
+ -- both relations already agree on ONE order, so a stronger mode changes how long a caller
83
+ -- waits and not whether it deadlocks. Keep the agreement: TT-1 in
84
+ -- agent_board_task_thread_lock_order.test.sql enumerates the class from the catalog and
85
+ -- will fail NAMING your function if it takes tasks before threads, and FKL-10 in
86
+ -- agent_board_fk_lock_edges.test.sql will fail if the relation pair returns to the cycle
87
+ -- set. An agreed order survives a mode change; an accidental mode mismatch does not.
88
+ --
89
+ -- THE TWO EDITS, AND WHY IT IS TWO AND NOT ONE. The #281 triage said reordering
90
+ -- append_task_event alone "removes all 40 rows — no other explicit lock on either relation
91
+ -- participates". Measured, that is wrong: it holds for EXPLICIT locks only.
92
+ -- `_upsert_delivery_recipient` has no explicit lock at all — it inserts into
93
+ -- delivery_recipients (KEY SHARE on tasks, via delivery_recipients_source_task_id_fkey)
94
+ -- and only then updates `threads.board_id`. That order was harmless while
95
+ -- append_task_event was also tasks-first; moving append_task_event to threads-first
96
+ -- promotes `_upsert_delivery_recipient` into the cycle set. Reordering append_task_event
97
+ -- alone measures 186 -> 150 with FOUR rows still on the pair and the pair still in FKL-10.
98
+ -- Both edits together measure 186 -> 146, the pair empty, and a per-pair diff showing no
99
+ -- other relation pair changed by a single row.
100
+ --
101
+ -- THE RESULTING TOTAL ORDER, now shared by every function that acquires both relations --
102
+ -- 20 by name, 24 by (function, signature), the difference being the four addressing-mode
103
+ -- overloads (post_board_message, send_direct_message, send_multicast_message,
104
+ -- create_tasking_delivery). The by-name count is what TT-2 asserts:
105
+ --
106
+ -- agent_board.threads BEFORE agent_board.tasks
107
+ --
108
+ -- It is the direction the whole message/board write surface already took for free
109
+ -- (post_message, post_board_message, send_direct_message, send_multicast_message,
110
+ -- create_tasking_delivery and their signed wrappers), and the direction create_task,
111
+ -- acquire_claim, add_artifact, delegate_task, raise_conflict and raise_escalation already
112
+ -- took. append_task_event and _upsert_delivery_recipient were the only two outliers.
113
+ --
114
+ -- WHAT IS NOT CHANGED. append_task_event still takes `tasks ... FOR UPDATE`. That lock is
115
+ -- not decoration: it is the mutual exclusion behind
116
+ -- `select coalesce(max(te.event_sequence), 0) + 1`
117
+ -- against task_events_task_id_event_sequence_key. Dropping it would trade a lock-order
118
+ -- disagreement for a live duplicate-key race, so it is kept at exactly its current
119
+ -- strength and asserted positively (TT-6) rather than left to a reader's confidence.
120
+ --
121
+ -- Additive and forward-only. No behaviour, gate, error code or grant changes.
122
+ -- schema_version stays 8.
123
+
124
+ -- 1. append_task_event: threads first ---------------------------------------
125
+ -- `v_thread_id` comes OUT of tasks, so taking threads first needs the thread id before the
126
+ -- task row is locked. The unlocked pre-read supplies it; the FOR UPDATE re-read then
127
+ -- returns the authoritative value and the two are compared. tasks.thread_id is never
128
+ -- re-pointed by any function in the bundle — asserted from the catalog as TT-4, not from a
129
+ -- grep — so the recheck is fail-closed against a state that cannot currently arise, and it
130
+ -- raises 40001 (serialization_failure, a retryable class) rather than silently writing a
131
+ -- task_events row whose thread_id disagrees with its task's.
132
+ --
133
+ -- The threads lock is FOR KEY SHARE: exactly the mode the task_events insert takes through
134
+ -- task_events_thread_id_fkey a few statements later, so hoisting it adds NO new blocking
135
+ -- anywhere (KEY SHARE conflicts only with FOR UPDATE, which nothing in the bundle takes on
136
+ -- threads). It makes the acquisition SCHEDULABLE, which an FK-implied lock is not.
137
+ --
138
+ -- Everything else — the actor resolution, the P0002 on a missing task, the
139
+ -- _current_agent_can_access_task check, the OBSGATE-3 _current_agent_can_mutate_task gate,
140
+ -- the sequence computation, both writes and the returned row — is byte-identical to
141
+ -- 20260816000100. CREATE OR REPLACE, so the ACL survives; the grants are restated anyway.
142
+ create or replace function agent_board.append_task_event(
143
+ p_task_id uuid,
144
+ p_actor_agent_id uuid,
145
+ p_event_type text,
146
+ p_payload jsonb default '{}'::jsonb,
147
+ p_status text default null,
148
+ p_assigned_agent_id uuid default null,
149
+ p_completed_at timestamp with time zone default null
150
+ )
151
+ 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)
152
+ language plpgsql
153
+ security definer
154
+ set search_path to 'agent_board', 'public'
155
+ as $function$
156
+ declare
157
+ v_thread_id uuid;
158
+ v_locked_thread_id uuid;
159
+ v_sequence bigint;
160
+ v_event_id uuid;
161
+ v_actor_agent_id uuid;
162
+ begin
163
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
164
+
165
+ select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
166
+ if v_thread_id is null then
167
+ raise exception 'task not found' using errcode = 'P0002';
168
+ end if;
169
+
170
+ perform 1 from agent_board.threads th where th.id = v_thread_id for key share;
171
+
172
+ select tasks.thread_id into v_locked_thread_id from agent_board.tasks where id = p_task_id for update;
173
+ if v_locked_thread_id is null then
174
+ raise exception 'task not found' using errcode = 'P0002';
175
+ end if;
176
+ if v_locked_thread_id is distinct from v_thread_id then
177
+ raise exception 'task thread changed while acquiring locks' using errcode = '40001';
178
+ end if;
179
+
180
+ if not agent_board._current_agent_can_access_task(p_task_id) then
181
+ raise exception 'task not accessible to current agent' using errcode = '42501';
182
+ end if;
183
+ if not agent_board._current_agent_can_mutate_task(p_task_id) then
184
+ raise exception 'task mutation requires a non-observer board role, or task assignment/creation'
185
+ using errcode = '42501';
186
+ end if;
187
+
188
+ select coalesce(max(te.event_sequence), 0) + 1 into v_sequence from agent_board.task_events te where te.task_id = p_task_id;
189
+ insert into agent_board.task_events(task_id, thread_id, actor_agent_id, event_sequence, event_type, payload)
190
+ values (p_task_id, v_thread_id, v_actor_agent_id, v_sequence, p_event_type, coalesce(p_payload, '{}'::jsonb))
191
+ returning id into v_event_id;
192
+
193
+ update agent_board.tasks
194
+ set status = coalesce(p_status, tasks.status),
195
+ assigned_agent_id = coalesce(p_assigned_agent_id, tasks.assigned_agent_id),
196
+ completed_at = coalesce(p_completed_at, tasks.completed_at),
197
+ updated_at = now()
198
+ where id = p_task_id;
199
+
200
+ return query
201
+ select t.id, t.thread_id, v_event_id, v_sequence, t.status, t.assigned_agent_id, t.updated_at, t.completed_at
202
+ from agent_board.tasks t
203
+ where t.id = p_task_id;
204
+ end;
205
+ $function$;
206
+ comment on function agent_board.append_task_event(uuid, uuid, text, jsonb, text, uuid, timestamp with time zone) is
207
+ '#281 R1 lock order: threads(KEY SHARE) BEFORE tasks(FOR UPDATE), matching every other function that touches both. The tasks FOR UPDATE is retained deliberately — it is the mutual exclusion behind the event_sequence computation against task_events_task_id_event_sequence_key. OBSGATE-3 (#125) gating and every error code are unchanged from 20260816000100.';
208
+ revoke all on function agent_board.append_task_event(uuid, uuid, text, jsonb, text, uuid, timestamp with time zone) from public, anon;
209
+ grant execute on function agent_board.append_task_event(uuid, uuid, text, jsonb, text, uuid, timestamp with time zone) to authenticated, service_role;
210
+
211
+ -- 2. _upsert_delivery_recipient: threads first ------------------------------
212
+ -- The OBSGATE (#78) board stamp is hoisted ABOVE the delivery_recipients insert. It is a
213
+ -- pure statement reorder: the stamp resolves its thread from the three source PARAMETERS
214
+ -- (p_source_message_id / p_source_task_id / p_source_delegation_id), never from `v_row`, so
215
+ -- it has no data dependency on the insert. Both statements are in the same transaction and
216
+ -- the function has no exception handler, so a failing insert still discards the stamp —
217
+ -- there is no observable ordering difference, only a lock-acquisition one.
218
+ --
219
+ -- Semantics preserved exactly: first-board-wins (`where t.board_id is null`), board-less
220
+ -- deliveries (p_board_id null) never stamp, and the coalesce order across message / task /
221
+ -- delegation sources is untouched.
222
+ create or replace function agent_board._upsert_delivery_recipient(
223
+ p_delivery_mode text,
224
+ p_recipient_agent_id uuid,
225
+ p_source_kind text,
226
+ p_source_message_id uuid default null,
227
+ p_source_task_id uuid default null,
228
+ p_source_delegation_id uuid default null,
229
+ p_board_id uuid default null,
230
+ p_board_membership_id uuid default null,
231
+ p_metadata jsonb default '{}'::jsonb
232
+ )
233
+ returns agent_board.delivery_recipients
234
+ language plpgsql
235
+ security definer
236
+ set search_path to 'agent_board', 'public'
237
+ as $function$
238
+ declare
239
+ v_row agent_board.delivery_recipients%rowtype;
240
+ begin
241
+ -- OBSGATE (#78): stamp the thread's owning board on first board delivery so
242
+ -- post_message's posting gate can resolve it. Resolve the thread from WHICHEVER source the
243
+ -- delivery carries — message (post_board_message), task (create_tasking_delivery), or
244
+ -- delegation — so EVERY board-fanning path links the thread, not just the message-sourced one.
245
+ -- First-board-wins (never overwrites); board-less deliveries (p_board_id null) never stamp.
246
+ if p_board_id is not null then
247
+ update agent_board.threads t
248
+ set board_id = p_board_id
249
+ where t.board_id is null
250
+ and t.id = coalesce(
251
+ (select m.thread_id from agent_board.messages m where m.id = p_source_message_id),
252
+ (select tk.thread_id from agent_board.tasks tk where tk.id = p_source_task_id),
253
+ (select tk.thread_id
254
+ from agent_board.delegations d
255
+ join agent_board.tasks tk on tk.id = d.child_task_id
256
+ where d.id = p_source_delegation_id)
257
+ );
258
+ end if;
259
+
260
+ insert into agent_board.delivery_recipients(
261
+ delivery_mode,
262
+ recipient_agent_id,
263
+ source_kind,
264
+ source_message_id,
265
+ source_task_id,
266
+ source_delegation_id,
267
+ board_id,
268
+ board_membership_id,
269
+ metadata
270
+ )
271
+ values (
272
+ p_delivery_mode,
273
+ p_recipient_agent_id,
274
+ p_source_kind,
275
+ p_source_message_id,
276
+ p_source_task_id,
277
+ p_source_delegation_id,
278
+ p_board_id,
279
+ p_board_membership_id,
280
+ coalesce(p_metadata, '{}'::jsonb)
281
+ )
282
+ on conflict (recipient_agent_id, canonical_source_key) do update
283
+ set metadata = coalesce(agent_board.delivery_recipients.metadata, '{}'::jsonb) || coalesce(excluded.metadata, '{}'::jsonb)
284
+ returning * into v_row;
285
+
286
+ return v_row;
287
+ end;
288
+ $function$;
289
+ comment on function agent_board._upsert_delivery_recipient(text, uuid, text, uuid, uuid, uuid, uuid, uuid, jsonb) is
290
+ '#281 R1 lock order: the OBSGATE (#78) threads board-stamp runs BEFORE the delivery_recipients insert, so this function is threads-before-tasks like every other function that touches both. Pure statement reorder — the stamp resolves its thread from the source parameters, not from the inserted row. Internal-only; PUBLIC EXECUTE revoked.';
291
+ revoke all on function agent_board._upsert_delivery_recipient(text, uuid, text, uuid, uuid, uuid, uuid, uuid, jsonb) from public, anon, authenticated;
292
+ grant execute on function agent_board._upsert_delivery_recipient(text, uuid, text, uuid, uuid, uuid, uuid, uuid, jsonb) to service_role;