@consiliency/agent-board-schema 1.28.0 → 1.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +40 -0
- package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
- package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
- package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
- package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
- package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
- package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
- package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
- package/migrations/20260917000100_agent_board_invite_no_downgrade.sql +398 -0
- package/migrations/20260918000100_agent_board_signed_write_enforced_repoint.sql +139 -0
- package/migrations/20260919000100_agent_board_signed_write_model_key.sql +107 -0
- package/package.json +1 -1
- package/schema-manifest.json +93 -3
- package/src/deployment-drift-script.test.mjs +424 -0
- package/src/deployment-drift.test.mjs +301 -0
- package/src/tooling.test.mjs +83 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +144 -29
- package/tests/agent_board_bundle_endpoint_compat.test.sql +73 -18
- package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
- package/tests/agent_board_consent_substrate.test.sql +8 -1
- package/tests/agent_board_enroll_resumption.test.sql +7 -1
- package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
- package/tests/agent_board_fedsession.test.sql +17 -2
- package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
- package/tests/agent_board_fk_lock_edges.test.sql +422 -0
- package/tests/agent_board_invite_no_downgrade.test.sql +689 -0
- package/tests/agent_board_ledger_authority.test.sql +142 -71
- package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
- package/tests/agent_board_signed_write_enforced.test.sql +366 -0
- package/tests/agent_board_signer_lifecycle.test.sql +23 -6
- package/tests/agent_board_source_strip_helper.test.sql +256 -1
- package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
- package/tests/agent_board_write_enforcement_class.test.sql +1016 -0
- package/tests/catalog/p_metadata_parameters.json +12 -0
- package/tests/fixtures/isolation-substrate.sql +1 -1
- package/tests/fixtures/test-prereqs.sql +442 -11
- package/tests/isolation/expected/ledger_authority_revoke.out +219 -1
- package/tests/isolation/expected/task_thread_lock_order.out +203 -0
- package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
- package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
- package/tests/isolation/specs/ledger_authority_revoke.spec +115 -0
- package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
- 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;
|