@consiliency/agent-board-schema 1.56.0 → 1.58.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 (33) hide show
  1. package/fixtures/already-applied.json +8 -0
  2. package/migrations/20261007000100_agent_board_onboard_requests.sql +139 -0
  3. package/migrations/20261008000100_agent_board_hosted_delegates.sql +3152 -0
  4. package/package.json +1 -1
  5. package/schema-manifest.json +21 -3
  6. package/src/tooling.test.mjs +8 -2
  7. package/tests/agent_board_atomic_fleet_onboarding.test.sql +13 -0
  8. package/tests/agent_board_consent_emission.test.sql +4 -2
  9. package/tests/agent_board_delegate_role_class.test.sql +736 -0
  10. package/tests/agent_board_federated_identity_injective_slug.test.sql +5 -2
  11. package/tests/agent_board_fedsession_stored_identity_gate.test.sql +71 -14
  12. package/tests/agent_board_fk_lock_edges.test.sql +9 -0
  13. package/tests/agent_board_hosted_delegates.test.sql +1134 -0
  14. package/tests/agent_board_ledger_authority.test.sql +34 -5
  15. package/tests/agent_board_membership_current_row_class.test.sql +3 -1
  16. package/tests/agent_board_onboard_requests.test.sql +353 -0
  17. package/tests/agent_board_rowid.test.sql +18 -5
  18. package/tests/agent_board_signer_lifecycle.test.sql +3 -0
  19. package/tests/agent_board_write_enforcement_class.test.sql +8 -3
  20. package/tests/catalog/function_execute_privileges.json +278 -2
  21. package/tests/catalog/p_metadata_parameters.json +48 -0
  22. package/tests/isolation/expected/hosted_delegate_exchange_race.out +93 -0
  23. package/tests/isolation/expected/hosted_delegate_first_exchange_race.out +129 -0
  24. package/tests/isolation/expected/hosted_delegate_owner_provisioning_race.out +91 -0
  25. package/tests/isolation/expected/hosted_delegate_owner_revoke_race.out +137 -0
  26. package/tests/isolation/expected/hosted_delegate_revoke_race.out +87 -0
  27. package/tests/isolation/expected/hosted_delegate_withdraw_evict_race.out +235 -0
  28. package/tests/isolation/specs/hosted_delegate_exchange_race.spec +64 -0
  29. package/tests/isolation/specs/hosted_delegate_first_exchange_race.spec +72 -0
  30. package/tests/isolation/specs/hosted_delegate_owner_provisioning_race.spec +73 -0
  31. package/tests/isolation/specs/hosted_delegate_owner_revoke_race.spec +93 -0
  32. package/tests/isolation/specs/hosted_delegate_revoke_race.spec +59 -0
  33. package/tests/isolation/specs/hosted_delegate_withdraw_evict_race.spec +145 -0
@@ -0,0 +1,3152 @@
1
+ -- HOSTED (v11) SL-2: hosted board delegates -- the delegate database role, delegate rows and
2
+ -- instances, the exchange and tombstone RPCs, add_delegate_to_board, the delegate read
3
+ -- predicates and policies, and the addressing refusals. schema_version stays 8.
4
+ --
5
+ -- THE CONTRACT is the "Hosted board MCP server and delegates (HOSTED)" section of
6
+ -- specs/agent-client-contract.md, accepted by the owner on 2026-10-01 (EC-HOSTED-1,
7
+ -- plans/evidence/hosted/contract-acceptance.md). Every rule below cites it by its decision
8
+ -- number (D1-D16) or section. Where this file and the contract differ, the contract wins and
9
+ -- the difference is a defect here.
10
+ --
11
+ -- Sources read, each at its LATEST definition (found from the catalog of a replay of this
12
+ -- tree, pg_get_functiondef, never from a migration file alone -- several are uppercase):
13
+ -- * current_agent_id -- 20260920000100
14
+ -- * _upsert_board_membership_state -- 20260917000100
15
+ -- * _emit_membership_event -- 20260807000100
16
+ -- * _board_membership_events_provenance_guard, _membership_consent_present,
17
+ -- _membership_consent_kind, board_membership_events -- 20260806000100
18
+ -- * update_board_membership -- 20260813000100
19
+ -- * post_message -- 20260814000100
20
+ -- * grant_board_role, create_board, bootstrap_board_owner_memberships,
21
+ -- _fedsession_autojoin_default_board, _redeem_unbounded_invite_for_agent,
22
+ -- create_task, _append_task_event, delegate_task, raise_escalation,
23
+ -- _upsert_delivery_recipient, _upsert_inbox_item, _resolve_addressing_recipient_rows,
24
+ -- _addressing_agent_ids_or_raise, get_bundle_compatibility -- their latest definitions,
25
+ -- each redefined below VERBATIM from the catalog plus the marked HOSTED lines.
26
+ -- * _federated_identity_slug, enroll_federated_human -- 20260916000100 (the owner uid).
27
+ -- * _ensure_agent_auth_user -- 20260512000000 (redefined in section 5, fix lane:
28
+ -- the miss path serialised per slug).
29
+ -- * revoke_human_subtree, revoke_agent -- read for the exchange's owner-liveness lock order;
30
+ -- not redefined.
31
+ --
32
+ -- WHAT IS DELIBERATELY NOT HERE: the Edge Functions (SL-4), the strict audience helper (SL-3),
33
+ -- the hosted handler (SL-6). Rate limiting is in the exchange Edge Function, before this RPC
34
+ -- (D9), because a rate bump inside a refused RPC would roll back with it.
35
+ --
36
+ -- REDEFINITIONS AND ACLs. Every redefined function is `create or replace`, which keeps its ACL
37
+ -- and COMMENT (AGENTS.md: only DROP discards them). No function is dropped here. Every NEW
38
+ -- function has its EXECUTE stated below: revoked from PUBLIC (agent_board_pubexec_class), and
39
+ -- granted to exactly the role the contract names.
40
+
41
+ -- =============================================================================
42
+ -- 1. The delegate database role (IF-0-HOSTED-5, §The delegate database role)
43
+ -- =============================================================================
44
+ -- Roles are CLUSTER-level, so two databases on one cluster share it: create it only if
45
+ -- absent, and grant it to `authenticator` only if that role exists and is not already a
46
+ -- member. Plain PostgreSQL, the pgTAP replay and an adopter without Supabase roles therefore
47
+ -- apply cleanly, and a second database on the same cluster skips both steps. The migration
48
+ -- role needs CREATEROLE the first time the role is created on a cluster (measured, contract).
49
+ --
50
+ -- NOLOGIN, NOINHERIT, no superuser, no BYPASSRLS, a member of NO role (never `authenticated`,
51
+ -- which would inherit every authenticated grant). PostgREST switches to it from the board
52
+ -- credential's `role` claim.
53
+ do $role$
54
+ begin
55
+ if not exists (select 1 from pg_catalog.pg_roles where rolname = 'agent_board_delegate') then
56
+ create role agent_board_delegate nologin noinherit nosuperuser nobypassrls nocreatedb nocreaterole;
57
+ end if;
58
+ if exists (select 1 from pg_catalog.pg_roles where rolname = 'authenticator')
59
+ and not pg_catalog.pg_has_role('authenticator', 'agent_board_delegate', 'MEMBER') then
60
+ grant agent_board_delegate to authenticator;
61
+ end if;
62
+ end
63
+ $role$;
64
+
65
+ grant usage on schema agent_board to agent_board_delegate;
66
+
67
+ -- =============================================================================
68
+ -- 2. The delegate fact (D11, §Delegate rows and instances)
69
+ -- =============================================================================
70
+ -- A separate boolean, not a new agent_kind: a new enum value cannot be used in the
71
+ -- transaction that adds it, and kind is coupled to agent_type by a trigger and a CHECK. A
72
+ -- delegate row keeps kind = 'agent' and agent_type = 'agent'.
73
+ alter table agent_board.agents
74
+ add column if not exists is_delegate boolean not null default false;
75
+
76
+ -- Every existing row is a non-delegate. The default does the backfill; this asserts it.
77
+ do $backfill$
78
+ begin
79
+ if exists (select 1 from agent_board.agents where is_delegate) then
80
+ raise exception 'HOSTED backfill: an existing agents row has is_delegate = true';
81
+ end if;
82
+ end
83
+ $backfill$;
84
+
85
+ comment on column agent_board.agents.is_delegate is
86
+ 'HOSTED (D11): the delegate fact. True only for a hosted delegate instance''s agent, set by exchange_delegate_credential''s first-use insert and never changed afterwards (trg_agents_is_delegate_immutable). Not granted to authenticated or agent_board_delegate.';
87
+
88
+ -- It never changes on an existing row.
89
+ create or replace function agent_board._agents_is_delegate_immutable()
90
+ returns trigger
91
+ language plpgsql
92
+ set search_path = ''
93
+ as $fn$
94
+ begin
95
+ if new.is_delegate is distinct from old.is_delegate then
96
+ raise exception 'agents.is_delegate never changes on an existing row'
97
+ using errcode = '23514';
98
+ end if;
99
+ return new;
100
+ end;
101
+ $fn$;
102
+
103
+ revoke all on function agent_board._agents_is_delegate_immutable() from public;
104
+
105
+ comment on function agent_board._agents_is_delegate_immutable() is
106
+ 'HOSTED (D11): trigger function refusing any UPDATE that changes agents.is_delegate. A delegate never becomes a keyed agent and a keyed agent never becomes a delegate. EXECUTE revoked from PUBLIC; a trigger function needs none at fire time.';
107
+
108
+ drop trigger if exists trg_agents_is_delegate_immutable on agent_board.agents;
109
+ create trigger trg_agents_is_delegate_immutable
110
+ before update of is_delegate on agent_board.agents
111
+ for each row execute function agent_board._agents_is_delegate_immutable();
112
+
113
+ -- =============================================================================
114
+ -- 3. delegate_instances and the credential columns (IF-0-HOSTED-6)
115
+ -- =============================================================================
116
+ create table if not exists agent_board.delegate_instances (
117
+ instance_id text primary key
118
+ check (instance_id ~ '^[A-Za-z0-9_-]{16,128}$'),
119
+ -- The auth user id of the owner's human principal, derived exactly as
120
+ -- enroll_federated_human derives it (never the raw Portal sub). Never changes.
121
+ owner_user_id uuid not null,
122
+ -- Null until the first exchange; then never changes.
123
+ agent_id uuid unique references agent_board.agents(id),
124
+ client_id text check (client_id is null or char_length(client_id) between 1 and 512),
125
+ grant_id text check (grant_id is null or grant_id ~ '^[A-Za-z0-9_-]{16,128}$'),
126
+ generation bigint check (generation is null or generation between 1 and 9007199254740991),
127
+ -- The canonical slot URL, or null for the base URL (D8).
128
+ slot_resource text,
129
+ client_change_generation bigint
130
+ check (client_change_generation is null or client_change_generation between 1 and 9007199254740991),
131
+ tombstoned_at timestamptz,
132
+ tombstone_reason text
133
+ check (tombstone_reason is null
134
+ or tombstone_reason in ('owner_revoked', 'owner_retired_after_upgrade', 'owner_account_closed', 'portal_admin')),
135
+ created_at timestamptz not null default now(),
136
+ updated_at timestamptz not null default now(),
137
+ constraint delegate_instances_tombstone_shape
138
+ check ((tombstoned_at is null) = (tombstone_reason is null))
139
+ );
140
+
141
+ -- D8: a slot belongs to one instance.
142
+ create unique index if not exists idx_agent_board_delegate_instances_slot_unique
143
+ on agent_board.delegate_instances (slot_resource)
144
+ where slot_resource is not null;
145
+
146
+ -- D13: the per-owner live-instance count.
147
+ create index if not exists idx_agent_board_delegate_instances_owner_live
148
+ on agent_board.delegate_instances (owner_user_id)
149
+ where tombstoned_at is null;
150
+
151
+ alter table agent_board.delegate_instances enable row level security;
152
+ revoke all on table agent_board.delegate_instances from public, anon, authenticated, service_role, agent_board_delegate;
153
+ -- READ for service_role only, so the operator check (scripts/check-delegate-tombstones.mjs,
154
+ -- SL-4) can list which instances the board has tombstoned. Never a write: the two RPCs are the
155
+ -- only writers. service_role has BYPASSRLS, so no policy is needed.
156
+ grant select on table agent_board.delegate_instances to service_role;
157
+
158
+ comment on table agent_board.delegate_instances is
159
+ 'HOSTED (IF-0-HOSTED-6): one row per hosted delegate instance, keyed by the Portal''s instance_id. Written ONLY by exchange_delegate_credential and tombstone_delegate_instance (both service_role-only SECURITY DEFINER). RLS on with zero policies; SELECT for service_role only (the operator check), no other API-role grant. A tombstone (tombstoned_at) is set once and never cleared: the exchange refuses the id forever.';
160
+
161
+ alter table agent_board.agent_credentials
162
+ add column if not exists grant_id text,
163
+ add column if not exists generation bigint;
164
+
165
+ comment on column agent_board.agent_credentials.grant_id is
166
+ 'HOSTED (IF-0-HOSTED-6): for a hosted delegate''s credential, the Portal grant it was issued under. Null for every other credential.';
167
+ comment on column agent_board.agent_credentials.generation is
168
+ 'HOSTED (IF-0-HOSTED-6): for a hosted delegate''s credential, the grant generation it was issued at. A higher generation''s exchange revokes every lower one. Null for every other credential.';
169
+
170
+ -- =============================================================================
171
+ -- 4. The allow set (§The allow set) -- ONE SQL-visible list
172
+ -- =============================================================================
173
+ -- The class tests read this table; nothing else is the list. Section 13 GRANTS FROM it, so a
174
+ -- grant cannot exist without a row here unless someone adds it by hand, which the class
175
+ -- assertion (agent_board_delegate_role_class.test.sql) names.
176
+ -- kind : rpc | ambient_rpc | predicate | read_view | read_table
177
+ -- name : the function or relation name, unqualified (schema agent_board)
178
+ -- signature : for a function, name(oidvectortypes(proargtypes)); for a relation, the name
179
+ create table if not exists agent_board.delegate_allowed_operations (
180
+ kind text not null check (kind in ('rpc', 'ambient_rpc', 'predicate', 'read_view', 'read_table')),
181
+ name text not null,
182
+ signature text not null,
183
+ reason text not null check (char_length(btrim(reason)) > 0),
184
+ primary key (kind, signature)
185
+ );
186
+
187
+ alter table agent_board.delegate_allowed_operations enable row level security;
188
+ revoke all on table agent_board.delegate_allowed_operations from public, anon, authenticated, service_role, agent_board_delegate;
189
+
190
+ comment on table agent_board.delegate_allowed_operations is
191
+ 'HOSTED (IF-0-HOSTED-5): the delegate allow set, the ONE list of what agent_board_delegate may execute (rpc, ambient_rpc, predicate) and select (read_view, read_table). The migration grants from it; agent_board_delegate_role_class asserts has_function_privilege / has_table_privilege iff listed. Widening it is an additive contract change. RLS on, no API-role grants.';
192
+
193
+ insert into agent_board.delegate_allowed_operations(kind, name, signature, reason) values
194
+ ('rpc', 'post_board_message',
195
+ 'post_board_message(uuid, uuid, uuid, text, text, text, text, text, jsonb)',
196
+ 'post to a board by id (tools post_board_message, post_board)'),
197
+ ('rpc', 'post_board_message',
198
+ 'post_board_message(uuid, uuid, text, text, text, text, text, text, text, jsonb)',
199
+ 'the by-slug overload; both overloads must agree, or the boundary test refuses the name'),
200
+ ('rpc', 'post_message',
201
+ 'post_message(uuid, uuid, text, text, uuid, jsonb)',
202
+ 'reply in a thread (tool post_message)'),
203
+ ('rpc', 'create_or_find_thread',
204
+ 'create_or_find_thread(uuid, text, text, text, jsonb)',
205
+ 'the thread a board post needs (tools create_or_find_thread, post_board)'),
206
+ ('ambient_rpc', 'get_schema_version',
207
+ 'get_schema_version()',
208
+ 'the schema preflight state() runs before every client operation'),
209
+ ('predicate', 'current_agent_id',
210
+ 'current_agent_id()',
211
+ 'invoked by the delegate predicates and the board_memberships delegate policy'),
212
+ ('predicate', '_delegate_visible_board_ids',
213
+ '_delegate_visible_board_ids()',
214
+ 'D14: the boards the caller is joined or subscribed on, computed once per query'),
215
+ ('predicate', '_delegate_visible_agent',
216
+ '_delegate_visible_agent(uuid)',
217
+ 'invoked by the delegate agents policy'),
218
+ ('read_view', 'v_agent_dashboard', 'v_agent_dashboard',
219
+ 'ambient: resolveActingAgentId reads it on every session refresh'),
220
+ ('read_view', 'v_board_feed', 'v_board_feed', 'read the board feed'),
221
+ ('read_view', 'v_thread_feed', 'v_thread_feed', 'read a thread'),
222
+ ('read_view', 'v_agent_inbox', 'v_agent_inbox', 'read its own inbox'),
223
+ ('read_view', 'v_board_memberships', 'v_board_memberships', 'read its own memberships'),
224
+ ('read_table', 'boards', 'boards', 'read closure: v_board_feed, v_agent_inbox, v_board_memberships'),
225
+ ('read_table', 'board_memberships', 'board_memberships', 'read closure: v_board_memberships'),
226
+ ('read_table', 'agents', 'agents', 'read closure: every view (the eight authenticated columns only)'),
227
+ ('read_table', 'messages', 'messages', 'read closure: v_board_feed, v_thread_feed'),
228
+ ('read_table', 'delivery_recipients', 'delivery_recipients', 'read closure: v_board_feed'),
229
+ ('read_table', 'inbox_items', 'inbox_items', 'read closure: v_board_feed, v_agent_inbox, v_agent_dashboard'),
230
+ ('read_table', 'tasks', 'tasks', 'read closure: v_agent_dashboard'),
231
+ ('read_table', 'task_events', 'task_events', 'read closure: v_agent_dashboard'),
232
+ ('read_table', 'claims', 'claims', 'read closure: v_agent_dashboard'),
233
+ ('read_table', 'escalations', 'escalations', 'read closure: v_agent_dashboard'),
234
+ ('read_table', 'signature_evidence', 'signature_evidence', 'read closure: v_thread_feed, v_agent_dashboard'),
235
+ ('read_table', 'presence', 'presence', 'read closure: v_agent_dashboard (left join; policy is false)'),
236
+ ('read_table', 'agent_signing_keys', 'agent_signing_keys', 'read closure: v_thread_feed, v_agent_dashboard (left joins; policy is false)')
237
+ on conflict do nothing;
238
+
239
+ -- =============================================================================
240
+ -- 5. Identity helpers
241
+ -- =============================================================================
242
+ -- One auth user per instance: 'delegate-' || the first 32 hex characters of sha256 over the
243
+ -- netstring <len>:<iss>:<len>:<instance_id>, the _federated_identity_slug construction with a
244
+ -- different prefix. Injective, and never equal to a human's 'fed-human-' handle.
245
+ create or replace function agent_board._delegate_instance_slug(p_issuer text, p_instance_id text)
246
+ returns text
247
+ language plpgsql
248
+ immutable
249
+ set search_path = ''
250
+ as $fn$
251
+ begin
252
+ if p_issuer is null or p_issuer = '' or p_instance_id is null or p_instance_id = '' then
253
+ raise exception 'delegate instance slug requires a non-empty issuer and instance id'
254
+ using errcode = '22023';
255
+ end if;
256
+ return 'delegate-' || substr(
257
+ encode(
258
+ extensions.digest(
259
+ convert_to(
260
+ length(p_issuer)::text || ':' || p_issuer || ':' ||
261
+ length(p_instance_id)::text || ':' || p_instance_id,
262
+ 'UTF8'),
263
+ 'sha256'),
264
+ 'hex'),
265
+ 1, 32);
266
+ end;
267
+ $fn$;
268
+
269
+ revoke all on function agent_board._delegate_instance_slug(text, text) from public;
270
+
271
+ comment on function agent_board._delegate_instance_slug(text, text) is
272
+ 'HOSTED: the handle and auth-user slug of a hosted delegate instance: ''delegate-'' || first 32 hex of sha256(netstring(iss, instance_id)). Injective; never a fed-human- handle. EXECUTE to no role; definers reach it as owner.';
273
+
274
+ -- ONE AUTH USER PER SLUG, for every caller. _ensure_agent_auth_user (latest definition
275
+ -- 20260512000000, unchanged since) looked up by email and then inserted, so two transactions
276
+ -- provisioning one slug at once each created an auth user: measured for two first exchanges of
277
+ -- one owner (hosted_delegate_first_exchange_race, SL-2), and the same shape exists for every
278
+ -- one of its callers, found from the catalog (pg_proc.prosrc), not from memory:
279
+ -- provision_agent, mint_delegated_child, enroll_federated_human and its
280
+ -- _enroll_federated_human_after_revocation_gate, enroll_federated_identity and
281
+ -- _enroll_federated_identity_once, onboard_federated_fleet_agent, exchange_delegate_credential
282
+ -- and tombstone_delegate_instance. The exchange's owner advisory lock covered only the last
283
+ -- two, so a first human enroll racing a first delegate exchange of the same (iss, sub) still
284
+ -- produced two owners (hosted_delegate_owner_provisioning_race). The class is closed here,
285
+ -- once, for all of them.
286
+ --
287
+ -- LOCK ON MISS, not before the lookup. A transaction-scoped advisory lock keyed by the slug is
288
+ -- taken only when the first lookup finds no user; the lookup is then repeated (a new statement,
289
+ -- so under READ COMMITTED a new snapshot that sees a user committed while this waited) before
290
+ -- inserting. Taking it before every lookup would invert against callers that already hold the
291
+ -- user's auth.users row FOR UPDATE and then reach this function again for the SAME slug
292
+ -- (enroll_federated_identity locks the owner's row and then calls
293
+ -- _enroll_federated_identity_once, which provisions the owner slug again), while the exchange
294
+ -- holds the slug lock and then wants that row (its own FOR KEY SHARE, and the agents FK). On a
295
+ -- hit nothing is locked, and a user that exists cannot be in anyone's miss path, so a holder of
296
+ -- the row never waits here. The one wait this adds is a second provisioner of a slug whose
297
+ -- first provisioner has not committed -- where a unique index on email, if the auth schema has
298
+ -- one, already waits today.
299
+ --
300
+ -- create or replace keeps the ACL ({postgres=X, service_role=X}) and the (absent) COMMENT; the
301
+ -- revoke and grant below restate the ACL so this file says what it is.
302
+ create or replace function agent_board._ensure_agent_auth_user(p_slug text)
303
+ returns uuid
304
+ language plpgsql
305
+ security definer
306
+ set search_path = ''
307
+ as $function$
308
+ declare
309
+ v_email text := format('agent+%s@agent-board.local', p_slug);
310
+ v_user_id uuid;
311
+ begin
312
+ select u.id
313
+ into v_user_id
314
+ from auth.users u
315
+ where lower(u.email) = v_email
316
+ limit 1;
317
+
318
+ if v_user_id is not null then
319
+ return v_user_id;
320
+ end if;
321
+
322
+ -- HOSTED: the miss path is serialised per slug, and the lookup repeated under the lock.
323
+ perform pg_catalog.pg_advisory_xact_lock(
324
+ pg_catalog.hashtextextended('agent_board.ensure_agent_auth_user:' || v_email, 0));
325
+
326
+ select u.id
327
+ into v_user_id
328
+ from auth.users u
329
+ where lower(u.email) = v_email
330
+ limit 1;
331
+
332
+ if v_user_id is not null then
333
+ return v_user_id;
334
+ end if;
335
+
336
+ v_user_id := gen_random_uuid();
337
+
338
+ insert into auth.users (
339
+ id,
340
+ instance_id,
341
+ email,
342
+ email_confirmed_at,
343
+ encrypted_password,
344
+ created_at,
345
+ updated_at,
346
+ raw_app_meta_data,
347
+ raw_user_meta_data,
348
+ aud,
349
+ role
350
+ )
351
+ values (
352
+ v_user_id,
353
+ '00000000-0000-0000-0000-000000000000',
354
+ v_email,
355
+ now(),
356
+ '',
357
+ now(),
358
+ now(),
359
+ jsonb_build_object('provider', 'agent_board', 'providers', jsonb_build_array('agent_board')),
360
+ jsonb_build_object('slug', p_slug, 'issuer', 'agent_board.provision_agent'),
361
+ 'authenticated',
362
+ 'authenticated'
363
+ );
364
+
365
+ insert into auth.identities (
366
+ id,
367
+ user_id,
368
+ provider_id,
369
+ provider,
370
+ identity_data,
371
+ last_sign_in_at,
372
+ created_at,
373
+ updated_at
374
+ )
375
+ values (
376
+ gen_random_uuid(),
377
+ v_user_id,
378
+ p_slug,
379
+ 'agent_board',
380
+ jsonb_build_object('sub', p_slug, 'slug', p_slug),
381
+ now(),
382
+ now(),
383
+ now()
384
+ )
385
+ on conflict do nothing;
386
+
387
+ return v_user_id;
388
+ end;
389
+ $function$;
390
+
391
+ revoke all on function agent_board._ensure_agent_auth_user(text) from public, anon, authenticated;
392
+ grant execute on function agent_board._ensure_agent_auth_user(text) to service_role;
393
+
394
+ -- The delegate's principal: the agent whose auth user owns the subject's instance. NULL for a
395
+ -- non-delegate. A definer because delegate_instances has RLS on and no grants; EXECUTE goes to
396
+ -- no role (the guard trigger runs it in its definer caller's context, as owner).
397
+ create or replace function agent_board._delegate_principal_agent_id(p_agent_id uuid)
398
+ returns uuid
399
+ language sql
400
+ stable
401
+ security definer
402
+ set search_path = ''
403
+ as $fn$
404
+ select owner_agent.id
405
+ from agent_board.delegate_instances di
406
+ join agent_board.agents d on d.id = di.agent_id and d.is_delegate
407
+ join agent_board.agents owner_agent on owner_agent.auth_user_id = di.owner_user_id
408
+ where di.agent_id = p_agent_id
409
+ limit 1
410
+ $fn$;
411
+
412
+ revoke all on function agent_board._delegate_principal_agent_id(uuid) from public, anon, authenticated, service_role;
413
+
414
+ comment on function agent_board._delegate_principal_agent_id(uuid) is
415
+ 'HOSTED (D2, D15): the agent id of a hosted delegate''s principal -- the agent whose auth_user_id is the delegate instance''s owner_user_id -- or NULL for a non-delegate. Read by the emitter, the provenance guard, the membership sink, update_board_membership and post_message. EXECUTE to no role.';
416
+
417
+ -- The placement / role-writer class refusal, for every direct board_memberships writer that
418
+ -- bypasses the sink (§What stays refused for a delegate). One shared helper so the class
419
+ -- assertion has a single schema-qualified marker to look for.
420
+ create or replace function agent_board._refuse_delegate_membership_target(p_agent_id uuid, p_operation text)
421
+ returns void
422
+ language plpgsql
423
+ stable
424
+ security definer
425
+ set search_path = ''
426
+ as $fn$
427
+ begin
428
+ if exists (select 1 from agent_board.agents a where a.id = p_agent_id and a.is_delegate) then
429
+ raise exception '% cannot place or raise a hosted delegate; add_delegate_to_board is its only way onto a board',
430
+ coalesce(nullif(btrim(p_operation), ''), 'this operation')
431
+ using errcode = '42501';
432
+ end if;
433
+ end;
434
+ $fn$;
435
+
436
+ revoke all on function agent_board._refuse_delegate_membership_target(uuid, text) from public, anon, authenticated, service_role;
437
+
438
+ comment on function agent_board._refuse_delegate_membership_target(uuid, text) is
439
+ 'HOSTED (placement and role-writer classes): raises 42501 when the target is a hosted delegate. Called by every direct board_memberships writer other than the sink (whose own rule is D15). is_delegate never changes, so the unlocked read has no TOCTOU. EXECUTE to no role.';
440
+
441
+ -- The D16 addressing refusal, for every writer of an addressee column.
442
+ create or replace function agent_board._refuse_delegate_addressee(p_agent_id uuid, p_board_id uuid, p_what text)
443
+ returns void
444
+ language plpgsql
445
+ stable
446
+ security definer
447
+ set search_path = ''
448
+ as $fn$
449
+ begin
450
+ if p_agent_id is null then
451
+ return;
452
+ end if;
453
+ if exists (select 1 from agent_board.agents a where a.id = p_agent_id and a.is_delegate)
454
+ and (
455
+ p_board_id is null
456
+ or not exists (
457
+ select 1
458
+ from agent_board.board_memberships bm
459
+ where bm.board_id = p_board_id
460
+ and bm.agent_id = p_agent_id
461
+ and bm.membership_state in ('joined', 'subscribed')
462
+ )
463
+ ) then
464
+ raise exception 'a hosted delegate is addressed only through a board it is active on (%)',
465
+ coalesce(nullif(btrim(p_what), ''), 'addressing')
466
+ using errcode = '42501';
467
+ end if;
468
+ end;
469
+ $fn$;
470
+
471
+ revoke all on function agent_board._refuse_delegate_addressee(uuid, uuid, text) from public, anon, authenticated, service_role;
472
+
473
+ comment on function agent_board._refuse_delegate_addressee(uuid, uuid, text) is
474
+ 'HOSTED (D16): raises 42501 when p_agent_id is a hosted delegate and p_board_id is null or a board it is not joined/subscribed on. Task and escalation assignment and task delegation pass a null board, so they are always refused for a delegate. Called by every writer of tasks.assigned_agent_id, escalations.assigned_agent_id, delegations.delegated_to_agent_id, delivery_recipients.recipient_agent_id and inbox_items.recipient_agent_id. EXECUTE to no role.';
475
+
476
+ -- =============================================================================
477
+ -- 6. The delegate read predicates (D14, §The read closure)
478
+ -- =============================================================================
479
+ -- Every delegate read requires the delegate's CURRENT active membership of the row's board,
480
+ -- and a row's board comes ONLY from delivery_recipients.board_id, never from the thread's
481
+ -- first-board-wins stamp. Authorship, recipiency, assignment and creation grant nothing.
482
+ -- SECURITY DEFINER (the caller sees only its own board_memberships rows), STABLE, EXECUTE to
483
+ -- agent_board_delegate only. Called directly, each answers only what selecting the rows already
484
+ -- answers.
485
+ -- THE BOARD SET, ONCE PER QUERY. The policies compare a row's board with this array through
486
+ -- an uncorrelated scalar subquery, `coalesce((select agent_board._delegate_visible_board_ids()),
487
+ -- '{}'::uuid[])`, which the planner evaluates ONCE per query (an InitPlan). The contract's first
488
+ -- form called a per-row definer predicate for every row, re-resolving current_agent_id() each
489
+ -- time: 7.7 s for one page of a 500-post board, against 234 ms for this form, with the same
490
+ -- meaning (plans/evidence/hosted/sl2-schema-evidence.md; the differential test is
491
+ -- agent_board_delegate_role_class.test.sql). Authorised by the team lead; SL-8 amends the
492
+ -- contract text.
493
+ create or replace function agent_board._delegate_visible_board_ids()
494
+ returns uuid[]
495
+ language sql
496
+ stable
497
+ security definer
498
+ set search_path = ''
499
+ as $fn$
500
+ select coalesce(array_agg(bm.board_id order by bm.board_id), '{}'::uuid[])
501
+ from agent_board.board_memberships bm
502
+ where bm.agent_id = agent_board.current_agent_id()
503
+ and bm.membership_state in ('joined', 'subscribed')
504
+ $fn$;
505
+
506
+ -- agents: the caller itself; a CURRENT co-member (joined/subscribed on a board the caller is
507
+ -- active on); or the author of a message already visible to the caller (v_board_feed and
508
+ -- v_thread_feed inner-join the author, so a departed author would otherwise drop the message).
509
+ create or replace function agent_board._delegate_visible_agent(p_agent_id uuid)
510
+ returns boolean
511
+ language sql
512
+ stable
513
+ security definer
514
+ set search_path = ''
515
+ as $fn$
516
+ with me as (select agent_board.current_agent_id() as id)
517
+ select exists (select 1 from me where me.id = p_agent_id)
518
+ or exists (
519
+ select 1
520
+ from me
521
+ join agent_board.board_memberships mine
522
+ on mine.agent_id = me.id
523
+ and mine.membership_state in ('joined', 'subscribed')
524
+ join agent_board.board_memberships other
525
+ on other.board_id = mine.board_id
526
+ and other.agent_id = p_agent_id
527
+ and other.membership_state in ('joined', 'subscribed')
528
+ )
529
+ or exists (
530
+ select 1
531
+ from me
532
+ join agent_board.board_memberships mine
533
+ on mine.agent_id = me.id
534
+ and mine.membership_state in ('joined', 'subscribed')
535
+ join agent_board.delivery_recipients dr
536
+ on dr.board_id = mine.board_id
537
+ join agent_board.messages m
538
+ on m.id = dr.source_message_id
539
+ where m.author_agent_id = p_agent_id
540
+ )
541
+ $fn$;
542
+
543
+ revoke all on function agent_board._delegate_visible_board_ids() from public, anon, authenticated, service_role;
544
+ revoke all on function agent_board._delegate_visible_agent(uuid) from public, anon, authenticated, service_role;
545
+
546
+ comment on function agent_board._delegate_visible_board_ids() is
547
+ 'HOSTED (D14) delegate predicate: the boards the caller is joined or subscribed on, as an array the delegate policies read once per query through an uncorrelated subquery. EXECUTE to agent_board_delegate only (allow set, kind predicate).';
548
+ comment on function agent_board._delegate_visible_agent(uuid) is
549
+ 'HOSTED (D14) delegate predicate for agents: the caller itself, a current co-member on a board the caller is active on, or the author of a message visible to the caller. EXECUTE to agent_board_delegate only (allow set, kind predicate).';
550
+
551
+ -- =============================================================================
552
+ -- 7. exchange_delegate_credential (IF-0-HOSTED-7, §The exchange; D8, D13)
553
+ -- =============================================================================
554
+ -- Called by the delegate-exchange Edge Function AFTER it has verified the Portal token (RS256,
555
+ -- iss, the strict audience helper, lifetime, token class, resource and instance claim) and
556
+ -- rate-limited per owner and per instance (D9). service_role only. Its arguments come from
557
+ -- that JWKS-verified token, not from any user: the enroll_federated_human trust shape.
558
+ --
559
+ -- p_claims, the shape this RPC accepts (frozen here for SL-4):
560
+ -- {
561
+ -- "iss": <the verified issuer>,
562
+ -- "sub": <the verified owner subject>,
563
+ -- "slot_resource": <the token's canonical resource when it is a SLOT, or null when it is
564
+ -- the base URL; the Edge Function has already refused anything else>,
565
+ -- "agent_board_delegate_instance": <the instance claim, verbatim>
566
+ -- }
567
+ -- Every refusal raises with the refusal CODE as the message (the Edge Function maps it to
568
+ -- `403 {"error": <code>}`): instance_revoked, owner_inactive, owner_mismatch, agent_inactive,
569
+ -- instance_cap, resource_mismatch, slot_in_use, generation_superseded, grant_conflict,
570
+ -- client_mismatch (errcode 42501), and claims_malformed (22023) for a shape the Edge Function
571
+ -- should already have refused.
572
+ create or replace function agent_board.exchange_delegate_credential(p_claims jsonb)
573
+ returns table(agent_id uuid, auth_user_id uuid, credential_id uuid, expires_at timestamptz)
574
+ language plpgsql
575
+ security definer
576
+ set search_path = ''
577
+ as $fn$
578
+ #variable_conflict use_column
579
+ declare
580
+ v_iss text;
581
+ v_sub text;
582
+ v_slot text;
583
+ v_claim jsonb;
584
+ v_instance_id text;
585
+ v_grant_id text;
586
+ v_generation bigint;
587
+ v_client_id text;
588
+ v_change jsonb;
589
+ v_change_gen bigint;
590
+ v_owner uuid;
591
+ v_owner_slug text;
592
+ v_row agent_board.delegate_instances%rowtype;
593
+ v_first boolean;
594
+ v_live integer;
595
+ v_slug text;
596
+ v_auth uuid;
597
+ v_agent uuid;
598
+ v_status text;
599
+ v_credential uuid;
600
+ v_expires timestamptz;
601
+ begin
602
+ if not agent_board._is_service_role() then
603
+ raise exception 'exchange_delegate_credential is service_role-only' using errcode = '42501';
604
+ end if;
605
+
606
+ -- SHAPE. The Edge Function refuses every malformed token first; this is the database's own
607
+ -- refusal of the same shape, so a caller that skips it cannot write a malformed instance.
608
+ if jsonb_typeof(p_claims) is distinct from 'object'
609
+ or jsonb_typeof(p_claims -> 'iss') is distinct from 'string'
610
+ or jsonb_typeof(p_claims -> 'sub') is distinct from 'string'
611
+ or coalesce(p_claims ->> 'iss', '') = ''
612
+ or coalesce(p_claims ->> 'sub', '') = ''
613
+ or jsonb_typeof(p_claims -> 'slot_resource') not in ('string', 'null')
614
+ or jsonb_typeof(p_claims -> 'agent_board_delegate_instance') is distinct from 'object' then
615
+ raise exception 'claims_malformed' using errcode = '22023';
616
+ end if;
617
+ v_iss := p_claims ->> 'iss';
618
+ v_sub := p_claims ->> 'sub';
619
+ v_slot := p_claims ->> 'slot_resource';
620
+ v_claim := p_claims -> 'agent_board_delegate_instance';
621
+
622
+ -- The instance claim: exactly these seven fields, each of its type.
623
+ if (select array_agg(k order by k) from jsonb_object_keys(v_claim) k)
624
+ is distinct from array['client_change', 'client_id', 'generation', 'grant_id', 'instance_id', 'owner_sub', 'status']
625
+ or jsonb_typeof(v_claim -> 'instance_id') is distinct from 'string'
626
+ or (v_claim ->> 'instance_id') !~ '^[A-Za-z0-9_-]{16,128}$'
627
+ or jsonb_typeof(v_claim -> 'grant_id') is distinct from 'string'
628
+ or (v_claim ->> 'grant_id') !~ '^[A-Za-z0-9_-]{16,128}$'
629
+ or jsonb_typeof(v_claim -> 'generation') is distinct from 'number'
630
+ or (v_claim ->> 'generation') !~ '^[1-9][0-9]{0,15}$'
631
+ or jsonb_typeof(v_claim -> 'owner_sub') is distinct from 'string'
632
+ or (v_claim ->> 'owner_sub') is distinct from v_sub
633
+ or jsonb_typeof(v_claim -> 'client_id') is distinct from 'string'
634
+ or char_length(v_claim ->> 'client_id') not between 1 and 512
635
+ or jsonb_typeof(v_claim -> 'status') is distinct from 'string'
636
+ or (v_claim ->> 'status') not in ('active', 'revoked')
637
+ or jsonb_typeof(v_claim -> 'client_change') not in ('object', 'null') then
638
+ raise exception 'claims_malformed' using errcode = '22023';
639
+ end if;
640
+ v_generation := (v_claim ->> 'generation')::bigint;
641
+ if v_generation > 9007199254740991 then
642
+ raise exception 'claims_malformed' using errcode = '22023';
643
+ end if;
644
+ v_instance_id := v_claim ->> 'instance_id';
645
+ v_grant_id := v_claim ->> 'grant_id';
646
+ v_client_id := v_claim ->> 'client_id';
647
+
648
+ v_change := case when jsonb_typeof(v_claim -> 'client_change') = 'object' then v_claim -> 'client_change' end;
649
+ if v_change is not null then
650
+ if (select array_agg(k order by k) from jsonb_object_keys(v_change) k)
651
+ is distinct from array['confirmed_at', 'confirmed_generation', 'from_client_id']
652
+ or jsonb_typeof(v_change -> 'from_client_id') is distinct from 'string'
653
+ or jsonb_typeof(v_change -> 'confirmed_at') is distinct from 'number'
654
+ or (v_change ->> 'confirmed_at') !~ '^[0-9]{1,16}$'
655
+ or jsonb_typeof(v_change -> 'confirmed_generation') is distinct from 'number'
656
+ or (v_change ->> 'confirmed_generation') !~ '^[1-9][0-9]{0,15}$'
657
+ or (v_change ->> 'confirmed_generation')::bigint > 9007199254740991 then
658
+ raise exception 'claims_malformed' using errcode = '22023';
659
+ end if;
660
+ v_change_gen := (v_change ->> 'confirmed_generation')::bigint;
661
+ end if;
662
+
663
+ if v_slot is not null and v_slot !~ '^https://[^/?#@[:space:]]+(/[^?#[:space:]]*)?/s/[A-Za-z0-9_-]{22,64}$' then
664
+ raise exception 'claims_malformed' using errcode = '22023';
665
+ end if;
666
+
667
+ -- A token the Portal itself reports revoked is refused, and changes nothing here: only a
668
+ -- Portal revocation (tombstone_delegate_instance) tombstones.
669
+ if v_claim ->> 'status' <> 'active' then
670
+ raise exception 'instance_revoked' using errcode = '42501';
671
+ end if;
672
+
673
+ -- THE OWNER LOCK, before anything else. A transaction-scoped advisory lock keyed by the
674
+ -- owner's identity serialises every exchange of one owner: it is what serialises D13's count
675
+ -- below, and tombstone_delegate_instance takes it too, in the same position, so the order is
676
+ -- always: owner lock, then the owner's auth.users row, then the instance row. It was first
677
+ -- added for the owner-provisioning race (two first exchanges each creating an auth user for
678
+ -- one owner, measured by hosted_delegate_first_exchange_race); that class is now closed for
679
+ -- every caller inside _ensure_agent_auth_user itself (section 5), which this lock alone could
680
+ -- not do: a first human enroll never takes it.
681
+ --
682
+ -- THE OWNER, derived exactly as enroll_federated_human derives the human's auth user, from
683
+ -- the verified issuer and subject. The raw sub is never stored or compared.
684
+ v_owner_slug := agent_board._federated_identity_slug(v_iss, v_sub);
685
+ perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('agent_board.delegate_owner:' || v_owner_slug, 0));
686
+ v_owner := agent_board._ensure_agent_auth_user(v_owner_slug);
687
+
688
+ -- THE OWNER MUST BE LIVE (fix N2). enroll_federated_human refuses a durably revoked human
689
+ -- (fleet_human_revocations (iss, sub)) and a non-active human row, and so does this: a
690
+ -- revoked human whose Portal still signs tokens must not bring up a NEW delegate instance, and
691
+ -- a suspended owner's existing delegates must not keep exchanging. Refused owner_inactive,
692
+ -- before the instance row is touched (so it wins over every instance-level code, including a
693
+ -- tombstone), and nothing is tombstoned.
694
+ --
695
+ -- UNDER THE OWNER'S auth.users ROW LOCK, which revoke_human_subtree takes FOR UPDATE first and
696
+ -- holds while it writes the marker and revokes every agent it then lists. FOR KEY SHARE
697
+ -- conflicts with that and with nothing an ordinary auth.users update takes. Without it an
698
+ -- exchange reads the marker before the revocation commits, and the revocation's agent list
699
+ -- (read once, at its loop) misses the delegate this exchange creates: an active delegate of a
700
+ -- revoked human, or a fresh credential for a delegate the revocation just revoked
701
+ -- (hosted_delegate_owner_revoke_race). revoke_human_subtree never takes the owner advisory
702
+ -- lock or an instance row, so no cycle closes. revoke_agent (suspension) does not take this
703
+ -- row and does not touch delegate credentials; an exchange racing it is simply ordered before
704
+ -- it.
705
+ --
706
+ -- The owner's human row is the agents row whose auth_user_id is the owner uid
707
+ -- (idx_agent_board_agents_auth_user_id_unique: at most one), the row enroll_federated_human
708
+ -- creates. None is not a refusal: an owner the board has never enrolled as a human is allowed
709
+ -- here, as before (it cannot add a delegate to a board without a session of its own).
710
+ perform 1 from auth.users u where u.id = v_owner for key share;
711
+ if exists (
712
+ select 1 from agent_board.fleet_human_revocations r
713
+ where r.issuer = v_iss and r.subject = v_sub
714
+ )
715
+ or exists (
716
+ select 1 from agent_board.agents a
717
+ where a.auth_user_id = v_owner and a.status <> 'active'
718
+ ) then
719
+ raise exception 'owner_inactive' using errcode = '42501';
720
+ end if;
721
+
722
+ -- INSERT-THEN-LOCK. A bare `select ... for update` locks nothing when the row does not yet
723
+ -- exist, so two first exchanges would both create an agent. Inserting first makes the
724
+ -- second block on the primary key until the first commits; both then lock the same row.
725
+ insert into agent_board.delegate_instances(instance_id, owner_user_id)
726
+ values (v_instance_id, v_owner)
727
+ on conflict (instance_id) do nothing;
728
+
729
+ select * into v_row
730
+ from agent_board.delegate_instances di
731
+ where di.instance_id = v_instance_id
732
+ for update;
733
+
734
+ -- A tombstone is permanent, whatever else the token says.
735
+ if v_row.tombstoned_at is not null then
736
+ raise exception 'instance_revoked' using errcode = '42501';
737
+ end if;
738
+ if v_row.owner_user_id is distinct from v_owner then
739
+ raise exception 'owner_mismatch' using errcode = '42501';
740
+ end if;
741
+
742
+ -- FIRST EXCHANGE is decided UNDER the lock, by whether the row has an agent yet -- never by
743
+ -- whether this transaction's insert happened: the loser of two concurrent first exchanges
744
+ -- finds the winner's agent after it blocks, and takes the ordinary path.
745
+ v_first := v_row.agent_id is null;
746
+
747
+ if not v_first then
748
+ select a.status into v_status from agent_board.agents a where a.id = v_row.agent_id;
749
+ if v_status is distinct from 'active' then
750
+ -- Revoked by revoke_agent_subtree or revoke_human_subtree, or suspended. Refused, and
751
+ -- NOT tombstoned: only a Portal revocation tombstones.
752
+ raise exception 'agent_inactive' using errcode = '42501';
753
+ end if;
754
+ else
755
+ -- D13: at most 20 live instances per owner. The owner lock taken at the top serialises two
756
+ -- first exchanges of DIFFERENT instances of one owner (their instance rows are different
757
+ -- rows), so the count cannot be passed twice.
758
+ select count(*) into v_live
759
+ from agent_board.delegate_instances di
760
+ where di.owner_user_id = v_owner
761
+ and di.tombstoned_at is null
762
+ and di.instance_id <> v_instance_id;
763
+ if v_live >= 20 then
764
+ raise exception 'instance_cap' using errcode = '42501';
765
+ end if;
766
+ end if;
767
+
768
+ -- THE RESOURCE (D8), S = the recorded slot_resource.
769
+ if not v_first then
770
+ if v_row.slot_resource is not null then
771
+ if v_slot is distinct from v_row.slot_resource then
772
+ raise exception 'resource_mismatch' using errcode = '42501';
773
+ end if;
774
+ elsif v_slot is not null and not (v_generation > v_row.generation) then
775
+ -- The one move, base URL -> slot, needs a new grant (a strictly higher generation).
776
+ raise exception 'resource_mismatch' using errcode = '42501';
777
+ end if;
778
+ end if;
779
+
780
+ -- THE CLIENT AND GENERATION, r = recorded generation, g = the token's, C = the recorded
781
+ -- client_change_generation.
782
+ if not v_first then
783
+ if v_generation < v_row.generation then
784
+ raise exception 'generation_superseded' using errcode = '42501';
785
+ elsif v_generation = v_row.generation then
786
+ if v_grant_id is distinct from v_row.grant_id then
787
+ raise exception 'grant_conflict' using errcode = '42501';
788
+ end if;
789
+ if v_client_id is distinct from v_row.client_id
790
+ or v_change_gen is distinct from v_row.client_change_generation then
791
+ raise exception 'client_mismatch' using errcode = '42501';
792
+ end if;
793
+ elsif v_client_id is distinct from v_row.client_id then
794
+ -- g > r from another client: only with the owner's confirmation of a client change made
795
+ -- AFTER the last binding the board saw (r < confirmed_generation <= g). from_client_id
796
+ -- is recorded for audit but not compared: the board may have missed an intermediate
797
+ -- reconnect.
798
+ if v_change_gen is null
799
+ or not (v_row.generation < v_change_gen and v_change_gen <= v_generation) then
800
+ raise exception 'client_mismatch' using errcode = '42501';
801
+ end if;
802
+ end if;
803
+ end if;
804
+
805
+ -- FIRST USE: the instance's own auth user and agents row. One auth user per instance.
806
+ if v_first then
807
+ v_slug := agent_board._delegate_instance_slug(v_iss, v_instance_id);
808
+ -- A second agent on this auth user is impossible: idx_agent_board_agents_auth_user_id_unique
809
+ -- refuses the insert (23505), and the whole exchange with it.
810
+ v_auth := agent_board._ensure_agent_auth_user(v_slug);
811
+ insert into agent_board.agents(
812
+ agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id, is_delegate, metadata)
813
+ values (
814
+ 'agent', 'agent', v_slug, 'Hosted assistant ' || left(v_instance_id, 8), 'active',
815
+ v_auth, v_owner, true,
816
+ jsonb_build_object('hosted_delegate', jsonb_build_object('instance_id', v_instance_id)))
817
+ returning id into v_agent;
818
+ else
819
+ v_agent := v_row.agent_id;
820
+ select a.auth_user_id into v_auth from agent_board.agents a where a.id = v_agent;
821
+ end if;
822
+
823
+ begin
824
+ update agent_board.delegate_instances di
825
+ set agent_id = v_agent,
826
+ client_id = v_client_id,
827
+ grant_id = v_grant_id,
828
+ generation = v_generation,
829
+ slot_resource = coalesce(di.slot_resource, v_slot),
830
+ client_change_generation = v_change_gen,
831
+ updated_at = now()
832
+ where di.instance_id = v_instance_id;
833
+ exception when unique_violation then
834
+ -- D8: a slot belongs to one instance. Raising aborts the whole call, so nothing in this
835
+ -- transaction is kept.
836
+ raise exception 'slot_in_use' using errcode = '42501';
837
+ end;
838
+
839
+ -- A higher generation supersedes every lower one, in this transaction.
840
+ if not v_first and v_generation > v_row.generation then
841
+ update agent_board.agent_credentials ac
842
+ set revoked_at = now(),
843
+ updated_at = now(),
844
+ metadata = coalesce(ac.metadata, '{}'::jsonb)
845
+ || jsonb_build_object('revocationReason', 'generation_superseded', 'supersededByGeneration', v_generation)
846
+ where ac.agent_id = v_agent
847
+ and ac.revoked_at is null
848
+ and coalesce(ac.generation, 0) < v_generation;
849
+ end if;
850
+
851
+ -- The board credential row. No refresh token exists for it: the hash is random and never
852
+ -- disclosed, so the refresh path can never match it. expires_at <= now() + 900 s, and the
853
+ -- Edge Function mints exp = min(iat + 900, expires_at).
854
+ v_expires := now() + interval '900 seconds';
855
+ insert into agent_board.agent_credentials(
856
+ agent_id, refresh_token_hash, hash_version, credential_label, expires_at, metadata, grant_id, generation)
857
+ values (
858
+ v_agent,
859
+ extensions.digest(extensions.gen_random_bytes(32), 'sha256'),
860
+ 1,
861
+ 'hosted-delegate',
862
+ v_expires,
863
+ jsonb_build_object(
864
+ 'source', 'exchange_delegate_credential',
865
+ 'instance_id', v_instance_id,
866
+ 'client_id', v_client_id,
867
+ 'client_change', coalesce(v_change, 'null'::jsonb)),
868
+ v_grant_id,
869
+ v_generation)
870
+ returning id into v_credential;
871
+
872
+ return query select v_agent, v_auth, v_credential, v_expires;
873
+ end;
874
+ $fn$;
875
+
876
+ revoke all on function agent_board.exchange_delegate_credential(jsonb) from public, anon, authenticated;
877
+ grant execute on function agent_board.exchange_delegate_credential(jsonb) to service_role;
878
+
879
+ comment on function agent_board.exchange_delegate_credential(jsonb) is
880
+ 'HOSTED (IF-0-HOSTED-7): turns a verified Portal delegate access token into a board credential row, in one transaction: under the owner lock and the owner''s auth.users row (FOR KEY SHARE), refuse a durably revoked or non-active owner (owner_inactive); insert-then-lock the instance; refuse a tombstone, another owner, an inactive agent, a 21st live instance (D13), a resource other than the recorded one (D8), a lower generation, an equal generation with another grant, and a client change without the owner''s confirmation; create the agent and auth user on first use; revoke lower generations; issue a credential with expires_at <= now()+900s. Returns (agent_id, auth_user_id, credential_id, expires_at). service_role only; called by the delegate-exchange Edge Function after it verified the token and rate-limited (D9).';
881
+
882
+ -- =============================================================================
883
+ -- 8. tombstone_delegate_instance (IF-0-HOSTED-8, D10)
884
+ -- =============================================================================
885
+ create or replace function agent_board.tombstone_delegate_instance(
886
+ p_instance_id text, p_issuer text, p_owner_sub text, p_reason text)
887
+ returns table(instance_id text, tombstoned_at timestamptz, first boolean)
888
+ language plpgsql
889
+ security definer
890
+ set search_path = ''
891
+ as $fn$
892
+ #variable_conflict use_column
893
+ declare
894
+ v_owner uuid;
895
+ v_owner_slug text;
896
+ v_inserted boolean;
897
+ v_row agent_board.delegate_instances%rowtype;
898
+ v_board uuid;
899
+ begin
900
+ if not agent_board._is_service_role() then
901
+ raise exception 'tombstone_delegate_instance is service_role-only' using errcode = '42501';
902
+ end if;
903
+ if p_instance_id is null or p_instance_id !~ '^[A-Za-z0-9_-]{16,128}$'
904
+ or coalesce(p_issuer, '') = '' or coalesce(p_owner_sub, '') = ''
905
+ or p_reason is null
906
+ or p_reason not in ('owner_revoked', 'owner_retired_after_upgrade', 'owner_account_closed', 'portal_admin') then
907
+ raise exception 'revocation_malformed' using errcode = '22023';
908
+ end if;
909
+
910
+ -- The owner lock, then the owner, exactly as the exchange takes and derives them (same key,
911
+ -- same position: before the instance row), so a revocation and an exchange of a new owner's
912
+ -- instance never provision two auth users for one owner.
913
+ v_owner_slug := agent_board._federated_identity_slug(p_issuer, p_owner_sub);
914
+ perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('agent_board.delegate_owner:' || v_owner_slug, 0));
915
+ v_owner := agent_board._ensure_agent_auth_user(v_owner_slug);
916
+
917
+ -- A revocation BEFORE the first exchange creates the tombstoned row, so the first exchange
918
+ -- is refused. Insert-then-lock, as the exchange does, so a concurrent exchange serialises.
919
+ insert into agent_board.delegate_instances as di(instance_id, owner_user_id, tombstoned_at, tombstone_reason)
920
+ values (p_instance_id, v_owner, now(), p_reason)
921
+ on conflict (instance_id) do nothing
922
+ returning true into v_inserted;
923
+
924
+ if v_inserted then
925
+ return query select p_instance_id, now(), true;
926
+ return;
927
+ end if;
928
+
929
+ select * into v_row
930
+ from agent_board.delegate_instances di
931
+ where di.instance_id = p_instance_id
932
+ for update;
933
+
934
+ if v_row.owner_user_id is distinct from v_owner then
935
+ raise exception 'owner_mismatch' using errcode = '42501';
936
+ end if;
937
+
938
+ -- Idempotent: the key is the instance, and a tombstone is permanent.
939
+ if v_row.tombstoned_at is not null then
940
+ return query select v_row.instance_id, v_row.tombstoned_at, false;
941
+ return;
942
+ end if;
943
+
944
+ update agent_board.delegate_instances di
945
+ set tombstoned_at = now(),
946
+ tombstone_reason = p_reason,
947
+ updated_at = now()
948
+ where di.instance_id = p_instance_id;
949
+
950
+ if v_row.agent_id is not null then
951
+ update agent_board.agent_credentials ac
952
+ set revoked_at = coalesce(ac.revoked_at, now()),
953
+ updated_at = now(),
954
+ metadata = coalesce(ac.metadata, '{}'::jsonb)
955
+ || jsonb_build_object('revocationReason', 'instance_tombstoned', 'tombstoneReason', p_reason)
956
+ where ac.agent_id = v_row.agent_id
957
+ and ac.revoked_at is null;
958
+
959
+ -- Every active membership ends through update_board_membership(..., 'left') under
960
+ -- service_role, so each ends with an append-only operator_removal event.
961
+ for v_board in
962
+ select bm.board_id
963
+ from agent_board.board_memberships bm
964
+ where bm.agent_id = v_row.agent_id
965
+ and bm.membership_state in ('joined', 'subscribed')
966
+ order by bm.board_id
967
+ loop
968
+ perform agent_board.update_board_membership(v_board, v_row.agent_id, 'left');
969
+ end loop;
970
+
971
+ update agent_board.agents a
972
+ set status = 'revoked', updated_at = now()
973
+ where a.id = v_row.agent_id
974
+ and a.status <> 'revoked';
975
+ end if;
976
+
977
+ return query select p_instance_id, now(), true;
978
+ end;
979
+ $fn$;
980
+
981
+ revoke all on function agent_board.tombstone_delegate_instance(text, text, text, text) from public, anon, authenticated;
982
+ grant execute on function agent_board.tombstone_delegate_instance(text, text, text, text) to service_role;
983
+
984
+ comment on function agent_board.tombstone_delegate_instance(text, text, text, text) is
985
+ 'HOSTED (IF-0-HOSTED-8, D10): tombstones a hosted delegate instance, idempotently and permanently. Before the first exchange it creates the tombstoned row; otherwise, in one transaction, it revokes every credential, ends every active membership through update_board_membership(..., ''left'') (operator_removal events) and moves the agent to revoked. A revocation naming an existing instance of another owner raises owner_mismatch. Returns (instance_id, tombstoned_at, first). service_role only; called by the delegate-revoke Edge Function with the verified revocation JWT''s iss, owner_sub and reason.';
986
+
987
+ -- =============================================================================
988
+ -- 9. add_delegate_to_board (IF-0-HOSTED-12, A5; D2, D15)
989
+ -- =============================================================================
990
+ create or replace function agent_board.add_delegate_to_board(p_agent_id uuid, p_board_id uuid)
991
+ returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, role text)
992
+ language plpgsql
993
+ security definer
994
+ set search_path = ''
995
+ as $fn$
996
+ #variable_conflict use_column
997
+ declare
998
+ v_actor uuid := agent_board.current_agent_id();
999
+ v_target agent_board.agents%rowtype;
1000
+ v_instance agent_board.delegate_instances%rowtype;
1001
+ v_m agent_board.board_memberships%rowtype;
1002
+ begin
1003
+ -- OWNER ONLY, from the session. A caller-supplied owner is never read.
1004
+ if v_actor is null then
1005
+ raise exception 'current agent principal not resolved' using errcode = '42501';
1006
+ end if;
1007
+
1008
+ select * into v_target from agent_board.agents a where a.id = p_agent_id;
1009
+ if not found or not v_target.is_delegate then
1010
+ raise exception 'add_delegate_to_board adds only a hosted delegate' using errcode = '42501';
1011
+ end if;
1012
+
1013
+ -- LOCK ORDER, each step before the next: the board advisory lock (this function row-locks both
1014
+ -- agents and board_memberships, so it is serialised against onboarding as grant_board_role is,
1015
+ -- agent_board_signer_lifecycle section 9, and like grant_board_role it takes the lock before
1016
+ -- any row lock); the owner's agents row (the fleet-ledger gate, FOR SHARE); the delegate's
1017
+ -- instance row (FOR SHARE, so a concurrent tombstone, which holds it FOR UPDATE, either commits
1018
+ -- first or waits for this placement); the owner's membership; the delegate's, in the sink.
1019
+ -- tombstone_delegate_instance takes its own owner-keyed advisory lock (a different key), then
1020
+ -- the instance row, and never the board advisory lock or the owner's agents row, so the
1021
+ -- instance row is the only lock the two share and no cycle closes between them.
1022
+ perform agent_board._lock_board_authority_ceiling(p_board_id);
1023
+ perform agent_board._assert_actor_not_fleet_revoked(v_actor, 'add_delegate_to_board');
1024
+
1025
+ select * into v_instance
1026
+ from agent_board.delegate_instances di
1027
+ where di.agent_id = p_agent_id
1028
+ for share;
1029
+ if not found or v_instance.tombstoned_at is not null or v_target.status <> 'active' then
1030
+ raise exception 'the hosted delegate instance is not live' using errcode = '42501';
1031
+ end if;
1032
+ if v_instance.owner_user_id is distinct from (select a.auth_user_id from agent_board.agents a where a.id = v_actor) then
1033
+ raise exception 'only the delegate''s owner may add it to a board' using errcode = '42501';
1034
+ end if;
1035
+
1036
+ if not exists (select 1 from agent_board.boards b where b.id = p_board_id and b.status = 'active') then
1037
+ raise exception 'board not found' using errcode = 'P0002';
1038
+ end if;
1039
+
1040
+ -- D12 ordering: hold the owner's active membership row (shared) so a concurrent owner
1041
+ -- departure either commits first (and the authority check below fails) or waits for this
1042
+ -- placement and then cascades it.
1043
+ perform 1
1044
+ from agent_board.board_memberships bm
1045
+ where bm.board_id = p_board_id
1046
+ and bm.agent_id = v_actor
1047
+ and bm.membership_state in ('joined', 'subscribed')
1048
+ for share;
1049
+
1050
+ -- UNDER THE OWNER'S OWN INVITE AUTHORITY: the gate create_invite_link applies before minting
1051
+ -- a member link, which places its redeemer straight into `joined`, as this does.
1052
+ if not agent_board._grant_within_ceiling(v_actor, p_board_id, 'member') then
1053
+ raise exception 'adding a delegate requires the owner''s own member-invite authority on this board'
1054
+ using errcode = '42501';
1055
+ end if;
1056
+
1057
+ -- R-LINK-7: a removed delegate stays removed (the set predicate every way back applies).
1058
+ if exists (
1059
+ select 1 from agent_board.board_memberships bm
1060
+ where bm.board_id = p_board_id and bm.agent_id = p_agent_id and bm.membership_state = 'removed'
1061
+ )
1062
+ and not exists (
1063
+ select 1 from agent_board.board_memberships bm
1064
+ where bm.board_id = p_board_id and bm.agent_id = p_agent_id and bm.membership_state in ('joined', 'subscribed')
1065
+ ) then
1066
+ raise exception 'a removed delegate stays removed; a board admin must lift the removal' using errcode = '42501';
1067
+ end if;
1068
+
1069
+ -- D15: the ONE setter of the admission flag. The sink reads it, with the owner's session,
1070
+ -- and refuses a delegate placement without both. Cleared before returning.
1071
+ perform pg_catalog.set_config('agent_board.delegate_admission', p_agent_id::text, true);
1072
+ select * into v_m
1073
+ from agent_board._upsert_board_membership_state(p_board_id, p_agent_id, 'joined', v_actor);
1074
+ perform pg_catalog.set_config('agent_board.delegate_admission', '', true);
1075
+
1076
+ -- CONSENT (D2): the emitter recorded the placement as principal-authored, with the owner as
1077
+ -- actor. The consent predicate must agree, or nothing is kept.
1078
+ if not agent_board._membership_consent_present(p_board_id, p_agent_id) then
1079
+ raise exception 'admitting % requires an act by that agent', p_agent_id
1080
+ using errcode = '42501';
1081
+ end if;
1082
+
1083
+ return query select v_m.id, v_m.board_id, v_m.agent_id, v_m.membership_state, v_m.role;
1084
+ end;
1085
+ $fn$;
1086
+
1087
+ revoke all on function agent_board.add_delegate_to_board(uuid, uuid) from public, anon, service_role;
1088
+ grant execute on function agent_board.add_delegate_to_board(uuid, uuid) to authenticated;
1089
+
1090
+ comment on function agent_board.add_delegate_to_board(uuid, uuid) is
1091
+ 'HOSTED (IF-0-HOSTED-12, A5): the ONLY way a hosted delegate joins a board. The caller (from the session) must be the instance''s owner, hold member-invite authority on the board (_grant_within_ceiling(caller, board, ''member'')) and not be fleet-revoked; the instance must be live; a removed delegate stays removed (R-LINK-7). Writes `joined` as member through the sink under the D15 admission flag, recorded principal-authored / principal_placement with the owner as actor, and requires _membership_consent_present. EXECUTE for authenticated.';
1092
+
1093
+ -- =============================================================================
1094
+ -- 10. principal-authored in the ledger (D2, §Joining a board and consent)
1095
+ -- =============================================================================
1096
+ -- The enum CHECK gains the value. The provenance/actor CHECK gains one arm, APPENDED after the
1097
+ -- existing three so their order is unchanged (agent_board_consent_substrate regex-pins
1098
+ -- target-authored.*operator-designated.*board-authored). Re-adding a CHECK validates every
1099
+ -- existing row, and none can be principal-authored yet.
1100
+ alter table agent_board.board_membership_events
1101
+ drop constraint if exists board_membership_events_provenance_check;
1102
+ alter table agent_board.board_membership_events
1103
+ add constraint board_membership_events_provenance_check
1104
+ check (provenance in ('board-authored', 'operator-designated', 'target-authored', 'principal-authored'));
1105
+
1106
+ alter table agent_board.board_membership_events
1107
+ drop constraint if exists board_membership_events_provenance_actor;
1108
+ alter table agent_board.board_membership_events
1109
+ add constraint board_membership_events_provenance_actor check (
1110
+ (provenance = 'target-authored' and actor_agent_id is not null and actor_agent_id = agent_id)
1111
+ or (provenance = 'operator-designated' and actor_agent_id is null)
1112
+ or (provenance = 'board-authored' and actor_agent_id is not null and actor_agent_id <> agent_id)
1113
+ or (provenance = 'principal-authored' and actor_agent_id is not null and actor_agent_id <> agent_id)
1114
+ );
1115
+
1116
+ -- =============================================================================
1117
+ -- 11. Redefinitions (each verbatim from the catalog plus its marked HOSTED lines)
1118
+ -- =============================================================================
1119
+ -- Order: the identity rule, the ledger (guard, emitter, readers), the sink and its callers,
1120
+ -- the direct writers, the addressing writers, attribution, the compatibility report.
1121
+
1122
+ CREATE OR REPLACE FUNCTION agent_board.current_agent_id()
1123
+ RETURNS uuid
1124
+ LANGUAGE sql
1125
+ STABLE SECURITY DEFINER
1126
+ SET search_path TO ''
1127
+ AS $function$
1128
+ with credential_claim as (
1129
+ -- #341: read BOTH sources. PostgREST sets the per-claim GUC; Realtime sets only the
1130
+ -- whole-JSON one. Reading a single source made the credential branch dead code for every
1131
+ -- Realtime subscriber. Mirrors auth.uid(), which has always coalesced for the same reason.
1132
+ select coalesce(
1133
+ nullif(current_setting('request.jwt.claim.agent_credential_id', true), ''),
1134
+ -- The OUTER nullif is not redundant, and its absence was a transport inconsistency
1135
+ -- caught by a test: the per-claim arm maps '' to absent, so without this the same
1136
+ -- logical claim ("" ) would be ABSENT on one transport and a malformed credential
1137
+ -- that DENIES on the other. Same input, different answer by transport, which is the
1138
+ -- exact bug class #341 is about. Empty means absent on both, matching what omission
1139
+ -- already permits.
1140
+ nullif(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'agent_credential_id', '')
1141
+ ) as credential_id_text
1142
+ )
1143
+ select a.id
1144
+ from agent_board.agents a
1145
+ cross join credential_claim cc
1146
+ where a.auth_user_id = auth.uid()
1147
+ and a.status = 'active'
1148
+ and (
1149
+ -- HOSTED (IF-0-HOSTED-6): a hosted delegate NEVER takes the null-claim branch. Its
1150
+ -- credential claim is mandatory, so a delegate board token with the claim stripped
1151
+ -- resolves to NULL. Every other row behaves exactly as before.
1152
+ (cc.credential_id_text is null and not a.is_delegate)
1153
+ or (
1154
+ cc.credential_id_text ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
1155
+ and exists (
1156
+ select 1
1157
+ from agent_board.agent_credentials ac
1158
+ -- TEXT comparison, never a cast of the untrusted claim: a malformed value must DENY,
1159
+ -- not raise. `ac.id::text` is canonical lowercase, hence lower() on the claim.
1160
+ where ac.id::text = lower(cc.credential_id_text)
1161
+ and ac.agent_id = a.id
1162
+ and ac.revoked_at is null
1163
+ and (ac.expires_at is null or ac.expires_at > now())
1164
+ )
1165
+ )
1166
+ )
1167
+ limit 1;
1168
+ $function$;
1169
+
1170
+ CREATE OR REPLACE FUNCTION agent_board._board_membership_events_provenance_guard()
1171
+ RETURNS trigger
1172
+ LANGUAGE plpgsql
1173
+ AS $function$
1174
+ begin
1175
+ if NEW.provenance = 'target-authored'
1176
+ and NEW.actor_agent_id is distinct from NEW.agent_id then
1177
+ raise exception 'substrate CHECK refuses target-authored actor <> subject'
1178
+ using errcode = '23514';
1179
+ end if;
1180
+ if NEW.provenance = 'operator-designated'
1181
+ and NEW.actor_agent_id is not null then
1182
+ raise exception 'substrate CHECK refuses operator-designated non-null actor'
1183
+ using errcode = '23514';
1184
+ end if;
1185
+ if NEW.provenance = 'board-authored'
1186
+ and (NEW.actor_agent_id is null or NEW.actor_agent_id = NEW.agent_id) then
1187
+ raise exception 'substrate CHECK refuses board-authored actor = subject'
1188
+ using errcode = '23514';
1189
+ end if;
1190
+ -- HOSTED (D2): what the CHECK arm cannot say. The subject must have the delegate fact and
1191
+ -- the actor must be its principal. _delegate_principal_agent_id returns NULL for a
1192
+ -- non-delegate, so `is distinct from` refuses both a wrong actor and a non-delegate subject.
1193
+ if NEW.provenance = 'principal-authored'
1194
+ and (NEW.actor_agent_id is null
1195
+ or NEW.actor_agent_id is distinct from agent_board._delegate_principal_agent_id(NEW.agent_id)) then
1196
+ raise exception 'substrate CHECK refuses principal-authored actor that is not the subject''s principal'
1197
+ using errcode = '23514';
1198
+ end if;
1199
+ return NEW;
1200
+ end;
1201
+ $function$;
1202
+
1203
+ CREATE OR REPLACE FUNCTION agent_board._emit_membership_event(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_event_kind text DEFAULT NULL::text)
1204
+ RETURNS void
1205
+ LANGUAGE plpgsql
1206
+ SECURITY DEFINER
1207
+ SET search_path TO 'agent_board', 'public'
1208
+ AS $function$
1209
+ declare
1210
+ v_actor_id uuid := agent_board.current_agent_id();
1211
+ v_prov text;
1212
+ v_event_actor uuid;
1213
+ v_event_kind text;
1214
+ begin
1215
+ -- Provenance is DERIVED HERE from the session and NEVER from a caller-supplied argument.
1216
+ -- A caller-supplied id is an ASSERTION about who acted; the session is EVIDENCE. Keying off
1217
+ -- the argument would let any caller mint a `target-authored` event -- the one provenance
1218
+ -- that means "the subject consented".
1219
+ --
1220
+ -- service_role is ALWAYS operator-designated, even when a subject resolves from its token.
1221
+ -- An operator acting while some agent id happens to be resolvable is still an operator
1222
+ -- placement, and recording it as the subject's own act launders exactly what this ledger
1223
+ -- exists to distinguish.
1224
+ if agent_board._is_service_role() then
1225
+ v_prov := 'operator-designated';
1226
+ v_event_actor := null;
1227
+ elsif v_actor_id is not null and v_actor_id = p_agent_id then
1228
+ v_prov := 'target-authored';
1229
+ v_event_actor := p_agent_id;
1230
+ -- HOSTED (D2): the owner acting on ITS OWN hosted delegate is the delegate's principal, and
1231
+ -- its act is the delegate's consent. Derived here, never supplied: the subject must have the
1232
+ -- delegate fact AND the session must be the agent whose auth user owns the subject's
1233
+ -- instance. An owner acting on a non-delegate stays board-authored below.
1234
+ elsif v_actor_id is not null
1235
+ and v_actor_id = agent_board._delegate_principal_agent_id(p_agent_id) then
1236
+ v_prov := 'principal-authored';
1237
+ v_event_actor := v_actor_id;
1238
+ elsif v_actor_id is not null then
1239
+ v_prov := 'board-authored';
1240
+ v_event_actor := v_actor_id;
1241
+ else
1242
+ -- Unresolved, non-service actor. GATENULL should have refused upstream; if it did not,
1243
+ -- fail CLOSED rather than invent a provenance for an unattributable write.
1244
+ raise exception 'membership change has no attributable actor'
1245
+ using errcode = '42501';
1246
+ end if;
1247
+
1248
+ -- The KIND may be supplied by the caller; the PROVENANCE never can. The frozen mapping
1249
+ -- names specific kinds for the raw writers -- owner_seed, consent_link_redeem,
1250
+ -- operator_repair -- which a generic (provenance, state) derivation cannot produce. Letting
1251
+ -- the caller name the kind while the security-critical field stays derived keeps the
1252
+ -- vocabulary honest without reopening the forge: a wrong kind mislabels an event, a wrong
1253
+ -- provenance would fabricate consent.
1254
+ v_event_kind := coalesce(p_event_kind, case
1255
+ when v_prov = 'target-authored' and p_membership_state in ('joined', 'subscribed') then 'consent_self_join'
1256
+ when v_prov = 'target-authored' and p_membership_state = 'requested' then 'consent_join_request'
1257
+ when v_prov = 'target-authored' and p_membership_state = 'invited' then 'consent_self_invite'
1258
+ when v_prov = 'target-authored' and p_membership_state in ('left', 'removed') then 'self_withdraw'
1259
+ when v_prov = 'board-authored' and p_membership_state = 'invited' then 'invite_offer'
1260
+ when v_prov = 'board-authored' and p_membership_state = 'requested' then 'request_manufactured'
1261
+ when v_prov = 'board-authored' and p_membership_state in ('joined', 'subscribed') then 'resolution'
1262
+ when v_prov = 'board-authored' and p_membership_state in ('left', 'removed') then 'eviction'
1263
+ when v_prov = 'operator-designated' and p_membership_state in ('joined', 'subscribed') then 'operator_designation'
1264
+ when v_prov = 'operator-designated' and p_membership_state in ('left', 'removed') then 'operator_removal'
1265
+ -- Operator writes to a PENDING state are NEUTRAL, and must not borrow a grant kind.
1266
+ -- `operator_repair` denotes bootstrap's grant; letting it be the catch-all made an ordinary
1267
+ -- service-role write to `invited`/`requested` read as a repair-grant. Bootstrap still gets
1268
+ -- operator_repair -- it passes the kind explicitly.
1269
+ when v_prov = 'operator-designated' and p_membership_state = 'invited' then 'invite_offer'
1270
+ when v_prov = 'operator-designated' and p_membership_state = 'requested' then 'request_manufactured'
1271
+ -- HOSTED (D2): a principal places or withdraws; it never offers or requests on the
1272
+ -- delegate's behalf, so a pending state has no principal kind and raises below.
1273
+ when v_prov = 'principal-authored' and p_membership_state in ('joined', 'subscribed') then 'principal_placement'
1274
+ when v_prov = 'principal-authored' and p_membership_state in ('left', 'removed') then 'principal_withdraw'
1275
+ -- No catch-all. The (provenance, state) space is fully enumerated above, so reaching here
1276
+ -- means a state was added without deciding what it MEANS. Fail loudly rather than silently
1277
+ -- labelling it -- a wrong kind in an append-only ledger cannot be corrected later.
1278
+ else null
1279
+ end);
1280
+
1281
+ if v_event_kind is null then
1282
+ raise exception 'no event kind for provenance % with resulting state %', v_prov, p_membership_state
1283
+ using errcode = '22023';
1284
+ end if;
1285
+
1286
+ insert into agent_board.board_membership_events(
1287
+ board_id, agent_id, event_kind, result_state, provenance, actor_agent_id)
1288
+ values (p_board_id, p_agent_id, v_event_kind, p_membership_state, v_prov, v_event_actor);
1289
+ end;
1290
+ $function$;
1291
+
1292
+ CREATE OR REPLACE FUNCTION agent_board._membership_consent_present(p_board_id uuid, p_agent_id uuid)
1293
+ RETURNS boolean
1294
+ LANGUAGE sql
1295
+ STABLE SECURITY DEFINER
1296
+ SET search_path TO 'agent_board', 'public'
1297
+ AS $function$
1298
+ select coalesce(
1299
+ (
1300
+ -- An operator-designated event counts as consent ONLY when it is an actual
1301
+ -- PLACEMENT (joined|subscribed). It previously counted for ANY non-removal state,
1302
+ -- so a bare operator `invited` OFFER -- an invitation the subject had never seen,
1303
+ -- let alone answered -- made this predicate return true. This function's whole
1304
+ -- contract is "did this subject consent", and it is the frozen interface
1305
+ -- (IF-0-MBCONSENT-1) every future caller reads, so a pending offer must be NEUTRAL
1306
+ -- here exactly as a board-authored offer already is.
1307
+ select case
1308
+ when e.result_state in ('left', 'removed') then false
1309
+ when e.provenance = 'target-authored' then true
1310
+ when e.provenance = 'operator-designated'
1311
+ and e.result_state in ('joined', 'subscribed') then true
1312
+ -- HOSTED (D2): a principal's PLACEMENT is the delegate's consent, the same rule
1313
+ -- as an operator's. A principal withdrawal is a removal (first arm).
1314
+ when e.provenance = 'principal-authored'
1315
+ and e.result_state in ('joined', 'subscribed') then true
1316
+ end
1317
+ from agent_board.board_membership_events e
1318
+ join agent_board.boards b on b.id = e.board_id
1319
+ where e.board_id = p_board_id
1320
+ and e.agent_id = p_agent_id
1321
+ -- An EXPIRED join request is NEUTRAL, not a refusal: it leaves the candidate set so an
1322
+ -- earlier genuine grant beneath it still stands. Treating it as a removal would silently
1323
+ -- revoke the membership of someone who joined and LATER also filed a request.
1324
+ -- This is the ONE place created_at is correct: ordering uses seq because created_at is
1325
+ -- transaction_timestamp() and ties within a transaction, but AGE is a wall-clock question.
1326
+ and not (
1327
+ e.provenance = 'target-authored'
1328
+ and e.result_state = 'requested'
1329
+ and e.created_at < now() - make_interval(days => b.join_request_ttl_days)
1330
+ )
1331
+ and (
1332
+ e.result_state in ('left', 'removed')
1333
+ or e.provenance = 'target-authored'
1334
+ or (e.provenance = 'operator-designated'
1335
+ and e.result_state in ('joined', 'subscribed'))
1336
+ or (e.provenance = 'principal-authored'
1337
+ and e.result_state in ('joined', 'subscribed'))
1338
+ )
1339
+ order by e.seq desc
1340
+ limit 1
1341
+ ),
1342
+ false
1343
+ );
1344
+ $function$;
1345
+
1346
+ CREATE OR REPLACE FUNCTION agent_board._membership_consent_kind(p_board_id uuid, p_agent_id uuid)
1347
+ RETURNS text
1348
+ LANGUAGE sql
1349
+ STABLE SECURITY DEFINER
1350
+ SET search_path TO 'agent_board', 'public'
1351
+ AS $function$
1352
+ select (
1353
+ -- MUST mirror _membership_consent_present exactly: an operator-designated event counts
1354
+ -- only when it is an actual PLACEMENT (joined|subscribed), never a pending offer. These
1355
+ -- two functions are read as a pair -- `_present` for the boolean, `_kind` for the reason --
1356
+ -- so a narrowing applied to only one makes them disagree about the SAME event. That is
1357
+ -- precisely what happened: `_present` was narrowed and this twin was not, so a pending
1358
+ -- operator invite read present=false but kind='operator'.
1359
+ select case
1360
+ when e.result_state in ('left', 'removed') then null
1361
+ when e.provenance = 'target-authored' then 'target'
1362
+ when e.provenance = 'operator-designated'
1363
+ and e.result_state in ('joined', 'subscribed') then 'operator'
1364
+ -- HOSTED (D2): mirrors _membership_consent_present exactly.
1365
+ when e.provenance = 'principal-authored'
1366
+ and e.result_state in ('joined', 'subscribed') then 'principal'
1367
+ end
1368
+ from agent_board.board_membership_events e
1369
+ join agent_board.boards b on b.id = e.board_id
1370
+ where e.board_id = p_board_id
1371
+ and e.agent_id = p_agent_id
1372
+ -- An EXPIRED join request is NEUTRAL, not a refusal: it leaves the candidate set so an
1373
+ -- earlier genuine grant beneath it still stands. Treating it as a removal would silently
1374
+ -- revoke the membership of someone who joined and LATER also filed a request.
1375
+ -- This is the ONE place created_at is correct: ordering uses seq because created_at is
1376
+ -- transaction_timestamp() and ties within a transaction, but AGE is a wall-clock question.
1377
+ and not (
1378
+ e.provenance = 'target-authored'
1379
+ and e.result_state = 'requested'
1380
+ and e.created_at < now() - make_interval(days => b.join_request_ttl_days)
1381
+ )
1382
+ and (
1383
+ e.result_state in ('left', 'removed')
1384
+ or e.provenance = 'target-authored'
1385
+ or (e.provenance = 'operator-designated'
1386
+ and e.result_state in ('joined', 'subscribed'))
1387
+ or (e.provenance = 'principal-authored'
1388
+ and e.result_state in ('joined', 'subscribed'))
1389
+ )
1390
+ order by e.seq desc
1391
+ limit 1
1392
+ );
1393
+ $function$;
1394
+
1395
+ CREATE OR REPLACE FUNCTION agent_board._upsert_board_membership_state(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_created_by_agent_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb, p_membership_id uuid DEFAULT NULL::uuid, p_event_kind text DEFAULT NULL::text)
1396
+ RETURNS agent_board.board_memberships
1397
+ LANGUAGE plpgsql
1398
+ SECURITY DEFINER
1399
+ SET search_path TO 'agent_board', 'public'
1400
+ AS $function$
1401
+ declare
1402
+ v_membership agent_board.board_memberships%rowtype;
1403
+ v_metadata jsonb;
1404
+ -- EC-MBROWID-3: currently-removed standing (R-LINK-7 set form), not a single row.
1405
+ v_currently_removed boolean;
1406
+ v_prior_state text;
1407
+ -- #317: the resolved next membership_state, computed ONCE and then both written and compared.
1408
+ -- An earlier draft wrote the guard as a CASE in the SET clause and RESTATED the same predicate
1409
+ -- to decide whether to emit. The two agreed, and nothing asserted they must: dropping
1410
+ -- `subscribed` from the emit copy alone re-created the false-ledger defect verbatim, and adding
1411
+ -- `removed` to it alone silently dropped a real state change from an append-only ledger. Both
1412
+ -- left the entire suite green. Deriving once removes the possibility rather than testing for it.
1413
+ v_next_state text;
1414
+ v_offer_noop boolean := false;
1415
+ -- HOSTED (D15, D12)
1416
+ v_is_delegate boolean;
1417
+ v_delegate_id uuid;
1418
+ begin
1419
+ if p_membership_state not in ('requested', 'invited', 'joined', 'subscribed', 'left', 'removed') then
1420
+ raise exception 'invalid board membership state' using errcode = '22023';
1421
+ end if;
1422
+
1423
+ -- HOSTED (D15): THE SINK RULE. A hosted delegate is placed into an active or pending state
1424
+ -- ONLY by add_delegate_to_board, and the sink proves it with two facts, both required:
1425
+ -- 1. the transaction-local setting agent_board.delegate_admission names this target. Only
1426
+ -- add_delegate_to_board sets it (set_config(..., true)), after its owner, R-LINK-7 and
1427
+ -- authority checks, and clears it before returning. The sink only READS it;
1428
+ -- 2. the session is the target's principal. service_role has no resolved actor, so a
1429
+ -- service_role caller fails this even if it could set the flag.
1430
+ -- A move to `left` or `removed` needs neither: removals, the D12 cascade and the tombstone
1431
+ -- run without it. The entrypoints that bypass this sink carry the class refusal instead
1432
+ -- (agent_board._refuse_delegate_membership_target).
1433
+ v_is_delegate := coalesce((select a.is_delegate from agent_board.agents a where a.id = p_agent_id), false);
1434
+ if v_is_delegate
1435
+ and p_membership_state in ('joined', 'subscribed', 'invited', 'requested')
1436
+ and (
1437
+ coalesce(current_setting('agent_board.delegate_admission', true), '') <> p_agent_id::text
1438
+ or agent_board.current_agent_id() is null
1439
+ or agent_board.current_agent_id() is distinct from agent_board._delegate_principal_agent_id(p_agent_id)
1440
+ ) then
1441
+ raise exception 'a hosted delegate joins a board only through add_delegate_to_board'
1442
+ using errcode = '42501';
1443
+ end if;
1444
+
1445
+ -- Metadata must be a JSON OBJECT. Previously a scalar/array was stored verbatim on insert and
1446
+ -- concatenated on update; the reserved-key strip below iterates keys, so a non-object would raise
1447
+ -- an opaque internal error. Fail explicitly instead. Reachable from the CLI `--metadata`, which
1448
+ -- parses arbitrary JSON — this is a deliberate, declared behavior change for ALL mutator callers.
1449
+ if jsonb_typeof(coalesce(p_metadata, '{}'::jsonb)) <> 'object' then
1450
+ raise exception 'board membership metadata must be a JSON object' using errcode = '22023';
1451
+ end if;
1452
+
1453
+ -- Reserved namespace: `ops_*` keys are operational state (the retention sweep's skip marker
1454
+ -- `ops_retained_at`, read for board_memberships at 20260513000001:122) and must never be writable
1455
+ -- by a caller whose own row they govern. Strip them from caller input; operational writers set
1456
+ -- the column directly.
1457
+ select coalesce(jsonb_object_agg(e.key, e.value), '{}'::jsonb)
1458
+ into v_metadata
1459
+ from jsonb_each(coalesce(p_metadata, '{}'::jsonb)) as e(key, value)
1460
+ where e.key not like 'ops\_%';
1461
+
1462
+ -- EC-MBROWID-3 / R-LINK-7: currently-removed standing is the SET predicate
1463
+ -- (exists removed AND no active), not a single total-ordered row. A higher-id
1464
+ -- removed row can coexist with an active row; inferring "removed" from the
1465
+ -- total-order winner alone would then try to activate the removed row and
1466
+ -- collide with idx_agent_board_board_memberships_active_unique (23505).
1467
+ -- Capture standing before the row lock so the predicate is independent of
1468
+ -- which physical row the active-preferring SELECT below returns.
1469
+ v_currently_removed :=
1470
+ exists (
1471
+ select 1 from agent_board.board_memberships bm
1472
+ where bm.board_id = p_board_id
1473
+ and bm.agent_id = p_agent_id
1474
+ and bm.membership_state = 'removed'
1475
+ ) and not exists (
1476
+ select 1 from agent_board.board_memberships bm
1477
+ where bm.board_id = p_board_id
1478
+ and bm.agent_id = p_agent_id
1479
+ and bm.membership_state in ('joined', 'subscribed')
1480
+ );
1481
+
1482
+ -- Duplicate-aware ACTIVE-row selection (3a): prefer an active row when one
1483
+ -- exists, then total-order by created_at desc, id desc. A pure total-order
1484
+ -- can select a higher-id REMOVED row while an ACTIVE row still exists.
1485
+ -- When v_currently_removed, no active row exists and the total-order pick is
1486
+ -- non-active (re-entry clamp applies below).
1487
+ -- When the caller names a row it has already validated AND LOCKED, act on THAT row. The
1488
+ -- active-preferring pick below is right when the sink chooses for itself, but wrong when a
1489
+ -- caller validated a specific one: rejecting a pending request would lock and mutate the
1490
+ -- subject's ACTIVE membership instead, downgrading a real member (#109).
1491
+ if p_membership_id is not null then
1492
+ select *
1493
+ into v_membership
1494
+ from agent_board.board_memberships bm
1495
+ where bm.id = p_membership_id
1496
+ and bm.board_id = p_board_id
1497
+ and bm.agent_id = p_agent_id
1498
+ for update;
1499
+
1500
+ if v_membership.id is null then
1501
+ raise exception 'membership row % not found for this subject', p_membership_id
1502
+ using errcode = 'P0002';
1503
+ end if;
1504
+ else
1505
+ select *
1506
+ into v_membership
1507
+ from agent_board.board_memberships bm
1508
+ where bm.board_id = p_board_id
1509
+ and bm.agent_id = p_agent_id
1510
+ order by
1511
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
1512
+ bm.created_at desc,
1513
+ bm.id desc
1514
+ limit 1
1515
+ for update;
1516
+ end if;
1517
+
1518
+ -- The PRIOR state, captured before the update below overwrites v_membership. The emitter
1519
+ -- cannot see it, and without it a joined<->subscribed self-toggle is indistinguishable from
1520
+ -- a fresh join.
1521
+ v_prior_state := v_membership.membership_state;
1522
+
1523
+ if v_membership.id is null then
1524
+ -- INSERT branch intentionally omits `role` so a no-prior-row entry takes the table default
1525
+ -- (20260717000100:13-15). Do not write role here — an explicit write is wrong.
1526
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, left_at, metadata)
1527
+ values (
1528
+ p_board_id,
1529
+ p_agent_id,
1530
+ p_membership_state,
1531
+ p_created_by_agent_id,
1532
+ case when p_membership_state in ('left', 'removed') then now() else null end,
1533
+ v_metadata
1534
+ )
1535
+ returning * into v_membership;
1536
+ else
1537
+ -- THE #317 RULE, in one place. `invited` and `requested` are OFFERS: they describe a
1538
+ -- membership that does not yet exist. Applying one to a membership that DOES exist
1539
+ -- (`joined`/`subscribed`) leaves the state alone -- the offer is already satisfied and the
1540
+ -- stronger standing wins. Every other transition is untouched, including joined <-> subscribed,
1541
+ -- active -> left/removed, and re-entry from any non-active state.
1542
+ v_next_state := case
1543
+ when p_membership_state in ('invited', 'requested')
1544
+ and v_membership.membership_state in ('joined', 'subscribed')
1545
+ then v_membership.membership_state
1546
+ else p_membership_state
1547
+ end;
1548
+
1549
+ -- Suppressed iff the state did not move AND the caller asked for something else. The second
1550
+ -- conjunct matters: join_board and update_board_membership both permit joined -> joined, which
1551
+ -- also does not move the state but IS the caller getting what they asked for, and still emits.
1552
+ v_offer_noop := v_next_state is not distinct from v_membership.membership_state
1553
+ and p_membership_state is distinct from v_next_state;
1554
+
1555
+ update agent_board.board_memberships bm
1556
+ -- #317: AN INVITE MUST NOT WEAKEN AN ACTIVE MEMBERSHIP.
1557
+ --
1558
+ -- Reproduced on production and again in a scratch database: an agent JOINED since
1559
+ -- 2026-08-05 was moved back to `invited` by a single invite_offer, because this UPDATE
1560
+ -- set the requested state unconditionally. The row was never deleted -- it was
1561
+ -- DOWNGRADED, and the agent silently lost board delivery while both sides saw a board
1562
+ -- that merely looked quiet.
1563
+ --
1564
+ -- This is NOT #109 and the #109 guard does not cover it. #109 was the sink acting on the
1565
+ -- WRONG ROW (an active row when a pending one had been validated), fixed by the
1566
+ -- p_membership_id targeting above. This is the RIGHT row moved in the WRONG DIRECTION,
1567
+ -- so it needs a direction rule, not a selection rule.
1568
+ --
1569
+ -- The rule: `invited` and `requested` are OFFERS -- they describe a membership that does
1570
+ -- not yet exist. Applying an offer to one that DOES exist (`joined`/`subscribed`) leaves
1571
+ -- the state alone: the offer is already satisfied and the stronger standing wins. Every
1572
+ -- other transition is untouched, including joined <-> subscribed, active -> left/removed,
1573
+ -- and re-entry from any non-active state.
1574
+ --
1575
+ -- A no-op rather than an exception, deliberately: invite is idempotent by design (the
1576
+ -- MBCONSENT invite flow re-offers freely) and raising here would break callers that
1577
+ -- succeed today.
1578
+ --
1579
+ -- "No-op" IS COLUMN-SCOPED, and saying so is the difference between a true claim and a
1580
+ -- comfortable one. What a suppressed offer preserves is the AUTHORITY-BEARING state:
1581
+ -- membership_state, left_at, and role. The row is still WRITTEN -- created_by_agent_id,
1582
+ -- metadata and updated_at all follow the ordinary path below. That is pre-existing
1583
+ -- behaviour this change does not alter: created_by_agent_id is audit provenance surfaced
1584
+ -- as created_by_handle (20260507000000:85-87), not authority, and the metadata merge is
1585
+ -- what every other caller gets. Making a suppressed offer skip the UPDATE entirely would
1586
+ -- be defensible and is deliberately NOT done here -- it would change metadata semantics
1587
+ -- for a path unrelated to the defect, in a fix meant to be narrow. Both the preserved
1588
+ -- columns and the written ones are asserted, so the scope is a fact rather than a promise.
1589
+ -- v_next_state, derived above. NOTE the deliberate asymmetry with the two CASEs below:
1590
+ -- `role` and `left_at` still key off p_membership_state, the caller's REQUEST, and that is
1591
+ -- correct rather than an oversight -- the #79 role clamp fires on ADMISSION to an active
1592
+ -- state, and a suppressed offer admits nobody.
1593
+ --
1594
+ -- Stated precisely, because a first draft of this comment overclaimed and a mutation
1595
+ -- refuted it: keying the clamp off v_next_state instead does NOT reset an ordinary owner,
1596
+ -- because the clamp's second conjunct already requires the PRIOR state to be non-active
1597
+ -- (or currently-removed standing to hold), and on a suppressed offer the prior state is
1598
+ -- active by construction. The two spellings differ only in the `v_currently_removed`
1599
+ -- corner, where the selected row is active while a removed duplicate exists. So the
1600
+ -- asymmetry is worth keeping and worth explaining, but it is not the gaping hole the
1601
+ -- draft claimed. The assertions below pin the ordinary case, which is what they cover.
1602
+ set membership_state = v_next_state,
1603
+ created_by_agent_id = coalesce(p_created_by_agent_id, bm.created_by_agent_id),
1604
+ -- PRESERVE the first departure time. Restamping it would let the departing agent (or a
1605
+ -- re-kicking owner) push its own row past the retention cutoff (20260513000001:121).
1606
+ left_at = case
1607
+ when p_membership_state in ('left', 'removed') then
1608
+ case
1609
+ -- SAME terminal state re-asserted: preserve the FIRST departure time so a
1610
+ -- repeat call cannot push the retention cutoff forward.
1611
+ when v_membership.membership_state = p_membership_state
1612
+ then coalesce(v_membership.left_at, now())
1613
+ -- GENUINE transition (notably left -> removed, an owner barring
1614
+ -- invite-link re-entry for an already-departed agent) stamps NOW. R-LINK-7
1615
+ -- deliberately does not block voluntary 'left' (20260724000100:321-322),
1616
+ -- so this is the real kick moment; inheriting the earlier departure would
1617
+ -- both lose the audit answer and make the marker row retention-eligible
1618
+ -- prematurely.
1619
+ else now()
1620
+ end
1621
+ else null
1622
+ end,
1623
+ -- ROLERESET (#79): clamp role to the table default on non-active -> active re-entry.
1624
+ -- Hardcoded 'member' (column default at 20260717000100:13-15); do not read
1625
+ -- information_schema at runtime. Active -> active preserves bm.role (including owner).
1626
+ -- Unconditional — no service_role exemption.
1627
+ -- MBROWID: origin is non-active when the selected row is non-active OR when
1628
+ -- currently-removed standing holds (R-LINK-7 set form). With active-preferring
1629
+ -- selection those agree; the set form is load-bearing against equal-created_at
1630
+ -- removed+left duplicates where a single-row read would flip with id order.
1631
+ role = case
1632
+ when p_membership_state in ('joined', 'subscribed')
1633
+ and (
1634
+ v_membership.membership_state not in ('joined', 'subscribed')
1635
+ or v_currently_removed
1636
+ )
1637
+ then 'member'
1638
+ else bm.role
1639
+ end,
1640
+ metadata = coalesce(bm.metadata, '{}'::jsonb) || v_metadata,
1641
+ updated_at = now()
1642
+ where bm.id = v_membership.id
1643
+ returning * into v_membership;
1644
+ end if;
1645
+
1646
+ -- Emission is delegated so the sink and the four raw writers cannot drift apart.
1647
+ -- The frozen mapping distinguishes a self-TOGGLE between two active states from a fresh
1648
+ -- self-JOIN. Only the sink knows the prior state, so it names the kind; the emitter derives
1649
+ -- everything security-critical regardless.
1650
+ -- A caller-supplied kind wins; otherwise the sink's own derivation stands. Only the
1651
+ -- DESCRIPTIVE field is delegated -- provenance is always derived from the session.
1652
+ --
1653
+ -- #317 -- NOTHING IS EMITTED WHEN NOTHING HAPPENED, and both of the obvious alternatives are
1654
+ -- WORSE. Before the guard above, the argument and the resulting row could not differ, so the
1655
+ -- question did not arise. The guard makes them differ on exactly one path, and that path then
1656
+ -- has three options, two of which are defects:
1657
+ --
1658
+ -- * emit `p_membership_state` (what the first draft of this migration did): appends
1659
+ -- `result_state = 'invited'` beside a row that says `joined`. `board_membership_events` is
1660
+ -- append-only, so the false row can never be corrected -- and it is the table the #317
1661
+ -- investigation itself read to establish what happened. The fix would have falsified its
1662
+ -- own diagnostic. Caught by three review seats.
1663
+ --
1664
+ -- * emit `v_membership.membership_state` (what the SECOND draft did, "fixing" the above):
1665
+ -- MINTS CONSENT OUT OF AN OFFER, which is strictly worse than a cosmetically wrong row.
1666
+ -- `_membership_consent_present` -- the FROZEN MBCONSENT interface (IF-0-MBCONSENT-1) that
1667
+ -- `update_board_membership` consults before admitting anyone -- treats
1668
+ -- `operator-designated` + `result_state in ('joined','subscribed')` as an actual PLACEMENT,
1669
+ -- i.e. as consent. An operator re-inviting a member whose ledger is silent would therefore
1670
+ -- turn a mere OFFER into an admission grant. Measured, not reasoned:
1671
+ --
1672
+ -- consent BEFORE operator re-invite = false
1673
+ -- consent AFTER operator re-invite = true
1674
+ --
1675
+ -- That is the precise narrowing MBCONSENT shipped 20260806000100 to make -- "a bare operator
1676
+ -- `invited` OFFER must be NEUTRAL" -- reopened from the other side. Delegating a kind cannot
1677
+ -- avoid it either: the predicate keys on (provenance, result_state) ONLY, and provenance is
1678
+ -- derived from the session and is not delegatable.
1679
+ --
1680
+ -- * emit nothing, which is what this does. `board_membership_events` records membership-state
1681
+ -- events; a suppressed offer changed no state, so there is no event. A silent ledger reads
1682
+ -- as `false` from the consent predicate, so this cannot mint anything, and it writes no row
1683
+ -- that a later reader must distrust.
1684
+ --
1685
+ -- WHAT THIS GIVES UP, stated rather than left to be discovered: an invite that lands on an
1686
+ -- already-active member is no longer visible in this ledger at all. That is a real loss of
1687
+ -- audit for a real action. It is accepted because the only two ways to record it here are a
1688
+ -- false statement about the row or a forged consent grant, and because the CALLER still sees
1689
+ -- the outcome -- invite_board_member and update_board_membership both return the row, which
1690
+ -- now reads `joined` rather than `invited`. If that action needs auditing, it needs a surface
1691
+ -- that is not the consent ledger.
1692
+ --
1693
+ -- Emission is otherwise unchanged and still delegated, so the sink and the raw writers cannot
1694
+ -- drift apart.
1695
+ -- HOSTED (D15): a hosted delegate is a member, never higher, whatever route wrote the row.
1696
+ if v_is_delegate and v_membership.role is distinct from 'member' then
1697
+ raise exception 'a hosted delegate''s board role is member, never higher'
1698
+ using errcode = '42501';
1699
+ end if;
1700
+
1701
+ if not v_offer_noop then
1702
+ perform agent_board._emit_membership_event(
1703
+ p_board_id, p_agent_id, p_membership_state,
1704
+ coalesce(p_event_kind, case
1705
+ when v_prior_state in ('joined', 'subscribed')
1706
+ and p_membership_state in ('joined', 'subscribed')
1707
+ -- The states must actually DIFFER. join_board and update_board_membership both permit
1708
+ -- joined->joined and subscribed->subscribed, and a write that changes nothing is not a
1709
+ -- toggle of anything -- recording it as one permanently mislabels an append-only event.
1710
+ and v_prior_state <> p_membership_state
1711
+ and not agent_board._is_service_role()
1712
+ and agent_board.current_agent_id() = p_agent_id
1713
+ then 'consent_self_toggle'
1714
+ else null
1715
+ end));
1716
+ end if;
1717
+
1718
+ -- HOSTED (D12): A DELEGATE LEAVES A BOARD WHEN ITS OWNER DOES. When this write leaves the
1719
+ -- target without an active membership (at most one active row exists per board and agent,
1720
+ -- idx_agent_board_board_memberships_active_unique), every hosted delegate of the target
1721
+ -- that is active on this board moves to `left` in the same transaction, through this sink,
1722
+ -- so each gets its own ledger event derived from the session (principal_withdraw when the
1723
+ -- owner leaves, eviction when an admin removes the owner, operator_removal under
1724
+ -- service_role). A delegate already removed is left as it is; the owner's return does not
1725
+ -- restore its delegates. Lock order: the owner's row (held above) before the delegates'.
1726
+ if not v_is_delegate and v_membership.membership_state not in ('joined', 'subscribed') then
1727
+ for v_delegate_id in
1728
+ select bm.agent_id
1729
+ from agent_board.board_memberships bm
1730
+ join agent_board.delegate_instances di on di.agent_id = bm.agent_id
1731
+ join agent_board.agents owner_agent on owner_agent.auth_user_id = di.owner_user_id
1732
+ where owner_agent.id = p_agent_id
1733
+ and bm.board_id = p_board_id
1734
+ and bm.membership_state in ('joined', 'subscribed')
1735
+ order by bm.agent_id
1736
+ loop
1737
+ perform agent_board._upsert_board_membership_state(p_board_id, v_delegate_id, 'left');
1738
+ end loop;
1739
+ end if;
1740
+
1741
+ return v_membership;
1742
+ end;
1743
+ $function$;
1744
+
1745
+ CREATE OR REPLACE FUNCTION agent_board.update_board_membership(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_metadata jsonb DEFAULT '{}'::jsonb)
1746
+ RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamp with time zone, updated_at timestamp with time zone)
1747
+ LANGUAGE plpgsql
1748
+ SECURITY DEFINER
1749
+ SET search_path TO 'agent_board', 'public'
1750
+ AS $function$
1751
+ declare
1752
+ v_membership agent_board.board_memberships%rowtype;
1753
+ v_board agent_board.boards%rowtype;
1754
+ v_actor uuid := agent_board.current_agent_id();
1755
+ v_is_service boolean := agent_board._is_service_role();
1756
+ v_existing_state text;
1757
+ v_target_role text;
1758
+ v_actor_role text;
1759
+ -- HOSTED (D7): the caller is the target hosted delegate's principal asking for `left`.
1760
+ v_principal_withdraw boolean;
1761
+ begin
1762
+ if v_actor is null and not v_is_service then
1763
+ raise exception 'current agent principal not resolved' using errcode = '42501';
1764
+ end if;
1765
+
1766
+ v_principal_withdraw := not v_is_service
1767
+ and v_actor <> p_agent_id
1768
+ and p_membership_state = 'left'
1769
+ and v_actor = agent_board._delegate_principal_agent_id(p_agent_id);
1770
+
1771
+ if not v_is_service and v_actor <> p_agent_id and not v_principal_withdraw then
1772
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
1773
+ if v_board.id is null then
1774
+ raise exception 'board not found' using errcode = 'P0002';
1775
+ end if;
1776
+ select bm.role
1777
+ into v_actor_role
1778
+ from agent_board.board_memberships bm
1779
+ where bm.board_id = p_board_id
1780
+ and bm.agent_id = v_actor
1781
+ and bm.membership_state in ('joined', 'subscribed')
1782
+ limit 1;
1783
+ if v_actor_role is null or v_actor_role not in ('owner', 'admin') then
1784
+ raise exception 'board membership change requires owner or admin role'
1785
+ using errcode = '42501';
1786
+ end if;
1787
+ end if;
1788
+
1789
+ -- HOSTED: LOCKED, so every transition rule below is decided on the state the sink will
1790
+ -- overwrite. Unlocked, an admin's uncommitted `removed` committed after this read, and the D7
1791
+ -- withdrawal (and an ordinary self-leave, the same branch shape) then wrote `left` over it in
1792
+ -- the sink, erasing the eviction R-LINK-7 reads (hosted_delegate_withdraw_evict_race). The
1793
+ -- sink's own first lock is this same row in the same order, so the lock set is unchanged;
1794
+ -- it is only taken earlier, before reads that lock nothing.
1795
+ select bm.membership_state, bm.role
1796
+ into v_existing_state, v_target_role
1797
+ from agent_board.board_memberships bm
1798
+ where bm.board_id = p_board_id
1799
+ and bm.agent_id = p_agent_id
1800
+ order by
1801
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
1802
+ bm.created_at desc,
1803
+ bm.id desc
1804
+ limit 1
1805
+ for update;
1806
+ if v_existing_state is null then
1807
+ raise exception 'board membership not found' using errcode = 'P0002';
1808
+ end if;
1809
+
1810
+ if v_principal_withdraw then
1811
+ -- HOSTED (D7): the owner withdraws its own delegate whatever its board role, but ONLY from
1812
+ -- the states a self-leave may leave. Never from `removed`: turning an eviction into `left`
1813
+ -- would erase the standing R-LINK-7 reads and let the owner re-add the evicted delegate.
1814
+ if v_existing_state not in ('joined', 'subscribed', 'invited', 'requested') then
1815
+ raise exception 'a principal may withdraw its delegate only from an active or pending membership'
1816
+ using errcode = '42501';
1817
+ end if;
1818
+ elsif not v_is_service then
1819
+ if v_actor = p_agent_id then
1820
+ if not (
1821
+ (p_membership_state = 'left'
1822
+ and v_existing_state in ('joined', 'subscribed', 'invited', 'requested'))
1823
+ or (p_membership_state in ('joined', 'subscribed')
1824
+ and v_existing_state in ('joined', 'subscribed'))
1825
+ or (p_membership_state = v_existing_state
1826
+ and p_membership_state in ('left', 'removed'))
1827
+ ) then
1828
+ raise exception 'self membership change not permitted; use the join/approval path'
1829
+ using errcode = '42501';
1830
+ end if;
1831
+ else
1832
+ if agent_board._role_rank(v_actor_role)
1833
+ <= agent_board._role_rank(
1834
+ case
1835
+ when p_membership_state in ('joined', 'subscribed')
1836
+ and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
1837
+ then 'member'
1838
+ else coalesce(v_target_role, '')
1839
+ end) then
1840
+ raise exception 'board membership change requires a higher board role than the target'
1841
+ using errcode = '42501';
1842
+ end if;
1843
+
1844
+ if p_membership_state in ('joined', 'subscribed')
1845
+ and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
1846
+ and not agent_board._membership_consent_present(p_board_id, p_agent_id) then
1847
+ raise exception 'admitting % requires an act by that agent', p_agent_id
1848
+ using errcode = '42501';
1849
+ end if;
1850
+
1851
+ -- INVITEPOLICY (#90): seating an inbound-disclosure membership ('invited'/'requested')
1852
+ -- for a target that has blocked THIS actor is refused, mirroring invite_board_member.
1853
+ -- Without this, an owner/admin re-seats the exact row the block refuses. Active-admission
1854
+ -- states are already consent-gated above; service_role/self never reach this branch.
1855
+ if p_membership_state in ('invited', 'requested')
1856
+ and not agent_board._invite_permitted(p_board_id, p_agent_id, v_actor) then
1857
+ raise exception 'target has blocked invitations from this inviter on this board'
1858
+ using errcode = '42501';
1859
+ end if;
1860
+ end if;
1861
+ end if;
1862
+
1863
+ if not v_is_service
1864
+ and p_membership_state = v_existing_state
1865
+ and p_membership_state in ('left', 'removed') then
1866
+ return query
1867
+ select bm.id, bm.board_id, bm.agent_id, bm.membership_state, bm.left_at, bm.updated_at
1868
+ from agent_board.board_memberships bm
1869
+ where bm.board_id = p_board_id
1870
+ and bm.agent_id = p_agent_id
1871
+ order by
1872
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
1873
+ bm.created_at desc,
1874
+ bm.id desc
1875
+ limit 1;
1876
+ return;
1877
+ end if;
1878
+
1879
+ select * into v_membership
1880
+ from agent_board._upsert_board_membership_state(
1881
+ p_board_id,
1882
+ p_agent_id,
1883
+ p_membership_state,
1884
+ null,
1885
+ p_metadata
1886
+ );
1887
+
1888
+ return query
1889
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
1890
+ end;
1891
+ $function$;
1892
+
1893
+ CREATE OR REPLACE FUNCTION agent_board.grant_board_role(p_board_id uuid, p_agent_id uuid, p_grantee_role text, p_grantor_agent_id uuid DEFAULT NULL::uuid)
1894
+ RETURNS TABLE(board_id uuid, agent_id uuid, role text, updated_at timestamp with time zone)
1895
+ LANGUAGE plpgsql
1896
+ SECURITY DEFINER
1897
+ SET search_path TO 'agent_board', 'public'
1898
+ AS $function$
1899
+ declare
1900
+ v_grantor uuid;
1901
+ v_updated agent_board.board_memberships%rowtype;
1902
+ begin
1903
+ if p_grantee_role not in ('owner', 'admin', 'member', 'observer') then
1904
+ raise exception 'invalid grantee role' using errcode = '22023';
1905
+ end if;
1906
+
1907
+ -- #276, FORCED BY AN EXISTING CLASS ASSERTION, not chosen. Adding the ledger gate makes this
1908
+ -- function row-lock `agents` (transitively, through _assert_actor_not_fleet_revoked) as well
1909
+ -- as board_memberships, which puts it in the class agent_board_signer_lifecycle.test.sql
1910
+ -- section 9 pins from the call-graph closure: every function locking BOTH relations must take
1911
+ -- the board advisory lock that serialises them against onboard_federated_fleet_agent. That
1912
+ -- assertion FAILED and named this function when the gate was added without this line.
1913
+ --
1914
+ -- The tempting reply is that this function's order is agents -> board_memberships, the SAME
1915
+ -- direction as onboarding, so it cannot close the F4 cycle. That is true today and is exactly
1916
+ -- the ordering argument #269 refused: mutual exclusion survives a lock being added downstream,
1917
+ -- a re-derived ordering does not. So the lock is taken rather than the assertion relaxed.
1918
+ --
1919
+ -- It also closes a gap 20260901000110 left open and explicitly declined to determine: this
1920
+ -- function reads the ceiling for authorization (via _grant_within_ceiling below) and was one
1921
+ -- of the three readers that did NOT take the lock serialising the ceiling row's ABSENCE, so a
1922
+ -- concurrent first-ceiling insert could be missed and the read pass as default-owner. It is
1923
+ -- now serialised, and agent_board_atomic_fleet_onboarding.test.sql's ceiling-reader registry
1924
+ -- records it as such.
1925
+ --
1926
+ -- FIRST, before any row lock, so both routes are advisory -> agents -> board_memberships.
1927
+ -- Re-taking it inside signed_grant_board_role (which already holds it) is a no-op: advisory
1928
+ -- transaction locks are re-entrant within a session.
1929
+ -- HOSTED (role-writer class): a hosted delegate is a member, never higher.
1930
+ perform agent_board._refuse_delegate_membership_target(p_agent_id, 'grant_board_role');
1931
+
1932
+ perform agent_board._lock_board_authority_ceiling(p_board_id);
1933
+
1934
+ -- Grantor is the RESOLVED actor. service_role -> supplied arg; authenticated ->
1935
+ -- current_agent_id() and raises 42501 if null (suspended/unmapped) or if the
1936
+ -- supplied arg <> current_agent_id(). This closes the single-principal escalation.
1937
+ v_grantor := agent_board._resolved_actor_agent_id(p_grantor_agent_id, 'p_grantor_agent_id');
1938
+
1939
+ -- #276. Fleet revocation removes board authority, not only send. Sited HERE — inside the
1940
+ -- chokepoint, not in the signed wrapper — because signed_grant_board_role is the only other
1941
+ -- referrer of this function in the whole database, so the signed route inherits this check
1942
+ -- by construction and the unsigned `authenticated` route (the wider door) is closed by the
1943
+ -- same line. Before the membership pre-check so the lock order is agents -> board_memberships,
1944
+ -- matching onboard_federated_fleet_agent; below the write it would be the inversion #269
1945
+ -- reproduced as `deadlock detected`, and this route has no advisory lock to be rescued by.
1946
+ perform agent_board._assert_actor_not_fleet_revoked(v_grantor, 'grant_board_role');
1947
+
1948
+ if not exists (
1949
+ select 1 from agent_board.board_memberships bm
1950
+ where bm.board_id = p_board_id and bm.agent_id = p_agent_id
1951
+ and bm.membership_state in ('joined', 'subscribed')
1952
+ ) then
1953
+ raise exception 'grantee has no membership on this board' using errcode = 'P0002';
1954
+ end if;
1955
+
1956
+ -- Ceiling consumption: grantee-bounded against min(grantor role, board ceiling).
1957
+ if not agent_board._grant_within_ceiling(v_grantor, p_board_id, p_grantee_role) then
1958
+ raise exception 'grant exceeds grantor role or board authority ceiling' using errcode = '42501';
1959
+ end if;
1960
+
1961
+ update agent_board.board_memberships bm
1962
+ set role = p_grantee_role,
1963
+ updated_at = now()
1964
+ where bm.board_id = p_board_id
1965
+ and bm.agent_id = p_agent_id
1966
+ and bm.membership_state in ('joined', 'subscribed')
1967
+ returning * into v_updated;
1968
+
1969
+ -- CR finding: the state predicate above closes the unauthorized WRITE, but on its own it
1970
+ -- CREATES a false-success path. If an eviction commits between the pre-check and this
1971
+ -- UPDATE (the TOCTOU window the mirrored predicate exists for), the UPDATE matches zero
1972
+ -- rows, v_updated stays NULL, and the RETURN QUERY below would emit ONE ALL-NULL ROW with
1973
+ -- no error -- a caller that checks "did I get a row back?" reads that as a completed grant.
1974
+ -- Fail closed with the same P0002 the pre-check raises, so the serial and racing cases are
1975
+ -- indistinguishable to the caller.
1976
+ if not found then
1977
+ raise exception 'grantee has no membership on this board' using errcode = 'P0002';
1978
+ end if;
1979
+
1980
+ return query
1981
+ select v_updated.board_id, v_updated.agent_id, v_updated.role, v_updated.updated_at;
1982
+ end;
1983
+ $function$;
1984
+
1985
+ CREATE OR REPLACE FUNCTION agent_board.create_board(p_created_by_agent_id uuid, p_slug text, p_name text, p_description text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb, p_visibility text DEFAULT 'private'::text)
1986
+ RETURNS TABLE(board_id uuid, slug text, name text, status text, visibility text, created_at timestamp with time zone, updated_at timestamp with time zone)
1987
+ LANGUAGE plpgsql
1988
+ SECURITY DEFINER
1989
+ SET search_path TO 'agent_board', 'public'
1990
+ AS $function$
1991
+ #variable_conflict use_column
1992
+ declare
1993
+ v_seed_state text;
1994
+ v_actor uuid;
1995
+ v_existing agent_board.boards%rowtype;
1996
+ v_board agent_board.boards%rowtype;
1997
+ begin
1998
+ if nullif(trim(p_slug), '') is null or nullif(trim(p_name), '') is null then
1999
+ raise exception 'board slug and name are required' using errcode = '22023';
2000
+ end if;
2001
+ if coalesce(p_visibility, 'private') not in ('private', 'invite_only', 'open', 'directory_hidden') then
2002
+ raise exception 'invalid board visibility' using errcode = '22023';
2003
+ end if;
2004
+
2005
+ -- Effective actor: service_role -> supplied arg (create-on-behalf-of);
2006
+ -- authenticated -> current_agent_id() and rejects arg <> current (42501).
2007
+ v_actor := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
2008
+
2009
+ -- HOSTED (placement class): create_board seeds its creator's own owner seat directly. A
2010
+ -- hosted delegate is never seated except by add_delegate_to_board.
2011
+ perform agent_board._refuse_delegate_membership_target(v_actor, 'create_board');
2012
+
2013
+ if not exists (select 1 from agent_board.agents where id = v_actor) then
2014
+ raise exception 'agent not found' using errcode = 'P0002';
2015
+ end if;
2016
+
2017
+ select * into v_existing
2018
+ from agent_board.boards b
2019
+ where b.slug = trim(p_slug);
2020
+
2021
+ if found then
2022
+ -- Slug-collision path is OWNER-ONLY, keyed on CURRENT authority: the actor must
2023
+ -- hold an ACTIVE owner-role membership on the existing board — NOT merely be its
2024
+ -- original `created_by_agent_id`. A demoted or departed original creator no longer
2025
+ -- owns the board and must NOT be able to re-open/rename/flip-visibility it.
2026
+ if not exists (
2027
+ select 1 from agent_board.board_memberships m
2028
+ where m.board_id = v_existing.id
2029
+ and m.agent_id = v_actor
2030
+ and m.role = 'owner'
2031
+ and m.membership_state in ('joined', 'subscribed')
2032
+ ) then
2033
+ raise exception 'board slug already in use' using errcode = '42501';
2034
+ end if;
2035
+
2036
+ update agent_board.boards as b
2037
+ set name = trim(p_name),
2038
+ description = nullif(trim(p_description), ''),
2039
+ status = 'active',
2040
+ visibility = coalesce(p_visibility, 'private'),
2041
+ metadata = coalesce(b.metadata, '{}'::jsonb) || coalesce(p_metadata, '{}'::jsonb),
2042
+ updated_at = now()
2043
+ where b.id = v_existing.id
2044
+ returning * into v_board;
2045
+ -- No owner re-seed: the owner was seeded at this board's original creation, and
2046
+ -- re-promoting on every create_board would resurrect a deliberately demoted owner.
2047
+ else
2048
+ insert into agent_board.boards as b(slug, name, description, created_by_agent_id, visibility, metadata)
2049
+ values (
2050
+ trim(p_slug),
2051
+ trim(p_name),
2052
+ nullif(trim(p_description), ''),
2053
+ v_actor,
2054
+ coalesce(p_visibility, 'private'),
2055
+ coalesce(p_metadata, '{}'::jsonb)
2056
+ )
2057
+ returning * into v_board;
2058
+
2059
+ -- Seed the creator's owner membership, bound to the board's ACTUAL
2060
+ -- created_by_agent_id (never the raw param). Idempotent against the active
2061
+ -- partial-unique index so at most one active owner row exists.
2062
+ insert into agent_board.board_memberships as bm(board_id, agent_id, membership_state, role, created_by_agent_id)
2063
+ values (v_board.id, v_board.created_by_agent_id, 'joined', 'owner', v_board.created_by_agent_id)
2064
+ on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
2065
+ do update set role = 'owner', updated_at = now()
2066
+ returning bm.membership_state into v_seed_state;
2067
+ -- The creator seeds their OWN owner seat, so the session is the subject and this emits
2068
+ -- target-authored. Under service_role it emits operator-designated instead -- correct
2069
+ -- either way, because the helper reads the session rather than being told.
2070
+ --
2071
+ -- v_seed_state, NOT a literal 'joined'. The ON CONFLICT above lifts only the ROLE, so a
2072
+ -- board whose creator already holds a `subscribed` row stays subscribed while a literal
2073
+ -- would record `joined` -- a ledger contradicting the table. Same defect the redemption
2074
+ -- path had; fixing one instance without sweeping the class is how it survived here.
2075
+ perform agent_board._emit_membership_event(v_board.id, v_board.created_by_agent_id, v_seed_state, 'owner_seed');
2076
+ end if;
2077
+
2078
+ -- #280. Revoked means revoked: a fleet-revoked agent creates nothing. #276 gated the three
2079
+ -- authority chokepoints and both invite halves and left this function open on a stated
2080
+ -- argument (a fresh board confers nothing on anyone else); the owner overturned that in favour
2081
+ -- of the simpler rule, and this line is the whole of the change.
2082
+ --
2083
+ -- SITED HERE, at the convergence of both branches and AFTER every write, for the lock order.
2084
+ -- Fresh branch: boards(insert) -> board_memberships(insert, FK KEY SHARE on agents) ->
2085
+ -- agents(SHARE). Collision branch: boards(FOR UPDATE) -> agents(SHARE). Both are
2086
+ -- boards-before-agents, the canonical order. HOISTED above the slug read it would become
2087
+ -- agents(SHARE) -> boards(FOR UPDATE) on the collision branch, which is #269 F4's exact ABBA
2088
+ -- against onboard_federated_fleet_agent's boards -> agents. One transaction, so this raise
2089
+ -- rolls back the board, the owner seat and the owner_seed event alike -- position costs
2090
+ -- nothing in correctness and everything in deadlock safety.
2091
+ --
2092
+ -- On the fresh branch the FK on the seat insert has ALREADY taken KEY SHARE on this row, so
2093
+ -- the FOR SHARE is a free upgrade and is NOT what serializes that path (see the header). On
2094
+ -- the collision branch it is the only agents lock there is, and it is the serialization point.
2095
+ perform agent_board._assert_actor_not_fleet_revoked(v_actor, 'create_board');
2096
+
2097
+ return query
2098
+ select v_board.id, v_board.slug, v_board.name, v_board.status, v_board.visibility, v_board.created_at, v_board.updated_at;
2099
+ end;
2100
+ $function$;
2101
+
2102
+ CREATE OR REPLACE FUNCTION agent_board.bootstrap_board_owner_memberships()
2103
+ RETURNS TABLE(board_id uuid, owner_agent_id uuid, membership_id uuid)
2104
+ LANGUAGE plpgsql
2105
+ SECURITY DEFINER
2106
+ SET search_path TO 'agent_board', 'public'
2107
+ AS $function$
2108
+ #variable_conflict use_column
2109
+ declare
2110
+ v_seed_state text;
2111
+ v_row record;
2112
+ v_membership_id uuid;
2113
+ begin
2114
+ -- service_role-only: current_agent_id() is null for service_role, non-null for any
2115
+ -- authenticated principal (guarded here in addition to the EXECUTE revoke below).
2116
+ if not agent_board._is_service_role() then
2117
+ raise exception 'bootstrap_board_owner_memberships is service_role-only' using errcode = '42501';
2118
+ end if;
2119
+
2120
+ -- R-LINK-7 guard shape (structural proof requires no-active AND no-removed
2121
+ -- contiguously): re-seed only when the creator has no active membership AND is
2122
+ -- not currently removed. NEVER suppress on 'left' alone — that is the
2123
+ -- sole-owner-lockout recovery trigger (agent_board_rolereset.test.sql assertion
2124
+ -- 23). requested/invited-only creators still re-seed; a removed+left pair is
2125
+ -- suppressed.
2126
+ for v_row in
2127
+ select b.id as board_id, b.created_by_agent_id as owner_agent_id
2128
+ from agent_board.boards b
2129
+ where not exists (
2130
+ select 1
2131
+ from agent_board.board_memberships bm
2132
+ where bm.board_id = b.id
2133
+ and bm.agent_id = b.created_by_agent_id
2134
+ and bm.membership_state in ('joined', 'subscribed')
2135
+ )
2136
+ and not exists (
2137
+ select 1
2138
+ from agent_board.board_memberships bm
2139
+ where bm.board_id = b.id
2140
+ and bm.agent_id = b.created_by_agent_id
2141
+ and bm.membership_state = 'removed'
2142
+ )
2143
+ loop
2144
+ -- HOSTED (placement class): bootstrap seats a board creator directly; never a delegate.
2145
+ perform agent_board._refuse_delegate_membership_target(v_row.owner_agent_id, 'bootstrap_board_owner_memberships');
2146
+ insert into agent_board.board_memberships as bm(board_id, agent_id, membership_state, role, created_by_agent_id)
2147
+ values (v_row.board_id, v_row.owner_agent_id, 'joined', 'owner', v_row.owner_agent_id)
2148
+ on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
2149
+ do update set role = 'owner', updated_at = now()
2150
+ returning bm.id, bm.membership_state into v_membership_id, v_seed_state;
2151
+
2152
+ -- Operator repair path: always operator-designated, never the owner's own act.
2153
+ -- v_seed_state, NOT a literal: the ON CONFLICT lifts only the ROLE, so a concurrently
2154
+ -- created `subscribed` membership stays subscribed while a literal would record `joined`.
2155
+ perform agent_board._emit_membership_event(v_row.board_id, v_row.owner_agent_id, v_seed_state, 'operator_repair');
2156
+
2157
+ board_id := v_row.board_id;
2158
+ owner_agent_id := v_row.owner_agent_id;
2159
+ membership_id := v_membership_id;
2160
+ return next;
2161
+ end loop;
2162
+ end;
2163
+ $function$;
2164
+
2165
+ CREATE OR REPLACE FUNCTION agent_board._fedsession_autojoin_default_board(p_provider agent_board.identity_providers, p_agent_id uuid)
2166
+ RETURNS void
2167
+ LANGUAGE plpgsql
2168
+ SECURITY DEFINER
2169
+ SET search_path TO 'agent_board', 'public'
2170
+ AS $function$
2171
+ begin
2172
+ if p_provider.default_board_id is null then
2173
+ return;
2174
+ end if;
2175
+ -- HOSTED (placement class): the auto-join seats a federated human directly; never a delegate.
2176
+ perform agent_board._refuse_delegate_membership_target(p_agent_id, 'fedsession auto-join');
2177
+ -- The NOT EXISTS below is FIRST-TIME-ONLY (any existing membership row, incl. 'left'/
2178
+ -- 'removed', suppresses the auto-join so a human who deliberately left is not re-joined).
2179
+ -- Under concurrency it is NOT sufficient on its own: two concurrent re-provisions of the
2180
+ -- same already-provisioned human can both pass NOT EXISTS, and the second insert then trips
2181
+ -- the partial unique index idx_agent_board_board_memberships_active_unique. Catch that
2182
+ -- unique_violation to a no-op (mirrors enroll_federated_human's step-8 agent-insert race
2183
+ -- handling) so auto-join is NEVER fatal to the session mint and never duplicates.
2184
+ begin
2185
+ insert into agent_board.board_memberships(
2186
+ board_id, agent_id, role, membership_state, created_by_agent_id, metadata)
2187
+ select p_provider.default_board_id, p_agent_id, 'member', 'joined', p_agent_id,
2188
+ jsonb_build_object('source', 'fedsession-autojoin', 'providerId', p_provider.id)
2189
+ where exists (
2190
+ select 1 from agent_board.boards b
2191
+ where b.id = p_provider.default_board_id
2192
+ and b.status = 'active')
2193
+ and not exists (
2194
+ select 1 from agent_board.board_memberships m
2195
+ where m.board_id = p_provider.default_board_id
2196
+ and m.agent_id = p_agent_id);
2197
+
2198
+ -- FOUND is true only if the conditional insert actually placed a row. The NOT EXISTS can
2199
+ -- suppress it entirely, and the race branch below is a no-op -- neither must emit an event
2200
+ -- for a placement that did not happen.
2201
+ if found then
2202
+ -- A LITERAL is correct here, and only here. The other writers use ON CONFLICT DO UPDATE,
2203
+ -- which can preserve a DIFFERENT existing state -- so a literal there records an outcome
2204
+ -- the row does not have. This insert has NO conflict-update path: it either inserts
2205
+ -- exactly this state or inserts nothing, and `found` gates the emit. Do not copy the
2206
+ -- literal to a writer that upserts.
2207
+ perform agent_board._emit_membership_event(p_provider.default_board_id, p_agent_id, 'joined', 'operator_designation');
2208
+ end if;
2209
+ exception when unique_violation then
2210
+ null; -- concurrent auto-join won the race; its membership stands. Non-fatal.
2211
+ end;
2212
+ end;
2213
+ $function$;
2214
+
2215
+ CREATE OR REPLACE FUNCTION agent_board._redeem_unbounded_invite_for_agent(p_code text, p_agent_id uuid)
2216
+ RETURNS TABLE(board_id uuid, agent_id uuid, role text, membership_state text, updated_at timestamp with time zone)
2217
+ LANGUAGE plpgsql
2218
+ SECURITY DEFINER
2219
+ SET search_path TO 'agent_board', 'public'
2220
+ AS $function$
2221
+ -- OUT params board_id/agent_id/role/membership_state/updated_at share names with the
2222
+ -- membership columns referenced in the on-conflict target + update set below; prefer
2223
+ -- the column in any collision (mirrors ROOMOWN create_board).
2224
+ #variable_conflict use_column
2225
+ declare
2226
+ v_inv agent_board.board_invite_tokens%rowtype;
2227
+ v_led agent_board.board_invite_redemptions%rowtype;
2228
+ v_m agent_board.board_memberships%rowtype;
2229
+ begin
2230
+ -- HOSTED (placement class): a hosted delegate redeems no invite; this helper seats directly.
2231
+ perform agent_board._refuse_delegate_membership_target(p_agent_id, 'invite redemption');
2232
+ -- Lock the token row FIRST (concurrency invariant: the exhaustion check and the
2233
+ -- same-txn uses_count increment happen under this lock, and concurrent redeemers
2234
+ -- of the same token serialize here).
2235
+ select * into v_inv
2236
+ from agent_board.board_invite_tokens t
2237
+ where t.code = p_code
2238
+ for update;
2239
+ if not found then
2240
+ raise exception 'invite is not usable' using errcode = '42501';
2241
+ end if;
2242
+
2243
+ -- M1 idempotency ordering: an existing (invite, agent) ledger row means this is a
2244
+ -- replay -> return the existing membership and SKIP every exhaustion/ceiling check
2245
+ -- (a maxed link re-redeemed by the same agent is a no-op, NOT 42501).
2246
+ select * into v_led
2247
+ from agent_board.board_invite_redemptions r
2248
+ where r.invite_id = v_inv.id
2249
+ and r.agent_id = p_agent_id;
2250
+ if found then
2251
+ select * into v_m
2252
+ from agent_board.board_memberships bm
2253
+ where bm.id = v_led.membership_id;
2254
+ return query
2255
+ select v_m.board_id, v_m.agent_id, v_m.role, v_m.membership_state, v_m.updated_at;
2256
+ return;
2257
+ end if;
2258
+
2259
+ -- Genuinely new redemption: uniform 42501 for revoked / expired / exhausted
2260
+ -- (no P0002-vs-42501 existence oracle).
2261
+ if v_inv.revoked_at is not null then
2262
+ raise exception 'invite is not usable' using errcode = '42501';
2263
+ end if;
2264
+ if v_inv.expires_at <= now() then
2265
+ raise exception 'invite is not usable' using errcode = '42501';
2266
+ end if;
2267
+ if v_inv.max_uses is not null and v_inv.uses_count >= v_inv.max_uses then
2268
+ raise exception 'invite is not usable' using errcode = '42501';
2269
+ end if;
2270
+
2271
+ -- R-LINK-2 suspension containment: the creator must still be an ACTIVE agent
2272
+ -- (revoke_agent suspends but does not drop memberships, so an outstanding link
2273
+ -- from a compromised-then-suspended owner must stop minting).
2274
+ if not exists (
2275
+ select 1 from agent_board.agents a
2276
+ where a.id = v_inv.created_by_agent_id and a.status = 'active'
2277
+ ) then
2278
+ -- Uniform message (see other 42501s) so a code-bearer cannot distinguish
2279
+ -- "creator suspended" from a dead/expired/exhausted link (no lifecycle oracle).
2280
+ raise exception 'invite is not usable' using errcode = '42501';
2281
+ end if;
2282
+
2283
+ -- #276 fleet containment, the sibling of R-LINK-2 one column over. Fleet revocation
2284
+ -- deliberately leaves agents.status = 'active', so the check above passes for a
2285
+ -- ledger-revoked creator and an outstanding admin link kept conferring admin -- the same
2286
+ -- principal the send path already refuses. Keyed on the CREATOR because the creator is who
2287
+ -- confers here; the redeemer is receiving, and its own standing is handled by
2288
+ -- current_agent_id() and by the R-LINK-7 clause below.
2289
+ --
2290
+ -- UNLOCKED (_actor_fleet_revoked, not the share-locking assert) and with the SAME uniform
2291
+ -- message as every other arm. Both choices are load-bearing, not stylistic: see the header.
2292
+ if agent_board._actor_fleet_revoked(v_inv.created_by_agent_id) then
2293
+ raise exception 'invite is not usable' using errcode = '42501';
2294
+ end if;
2295
+
2296
+ -- Re-bind: the creator's CURRENT authority must still cover the preset role
2297
+ -- (stale admin-link auto-degrades if the creator was demoted).
2298
+ if not agent_board._grant_within_ceiling(v_inv.created_by_agent_id, v_inv.board_id, v_inv.preset_role) then
2299
+ raise exception 'invite is not usable' using errcode = '42501';
2300
+ end if;
2301
+
2302
+ -- R-LINK-7 kicked-agent re-entry: refuse when the agent's CURRENT standing on the
2303
+ -- board is 'removed' — i.e. a 'removed' row exists AND there is no active
2304
+ -- (joined/subscribed) membership. The active partial-unique index does NOT cover
2305
+ -- 'removed' rows, so a fresh 'joined' insert would otherwise bypass revocation +
2306
+ -- join-approval. Scoping to "removed and not currently active" avoids a false
2307
+ -- negative for an agent who was removed and then legitimately re-added (an active
2308
+ -- row present ⇒ the rank-max upsert handles them correctly). Voluntary 'left' is
2309
+ -- intentionally NOT blocked (self-departure).
2310
+ if exists (
2311
+ select 1 from agent_board.board_memberships bm
2312
+ where bm.board_id = v_inv.board_id
2313
+ and bm.agent_id = p_agent_id
2314
+ and bm.membership_state = 'removed'
2315
+ ) and not exists (
2316
+ select 1 from agent_board.board_memberships bm
2317
+ where bm.board_id = v_inv.board_id
2318
+ and bm.agent_id = p_agent_id
2319
+ and bm.membership_state in ('joined', 'subscribed')
2320
+ ) then
2321
+ raise exception 'invite is not usable' using errcode = '42501';
2322
+ end if;
2323
+
2324
+ -- Record the ledger row (idempotent under concurrent same-agent redemption).
2325
+ insert into agent_board.board_invite_redemptions as r(invite_id, agent_id)
2326
+ values (v_inv.id, p_agent_id)
2327
+ on conflict (invite_id, agent_id) do nothing;
2328
+
2329
+ -- B1 atomic rank-max membership upsert: never downgrade a higher existing role,
2330
+ -- race-safe across concurrent tokens. On conflict targets the active partial-unique
2331
+ -- index; the CASE lifts ONLY when the preset outranks the current role.
2332
+ insert into agent_board.board_memberships (
2333
+ board_id, agent_id, membership_state, role, created_by_agent_id
2334
+ )
2335
+ values (
2336
+ v_inv.board_id, p_agent_id, v_inv.preset_membership_state, v_inv.preset_role,
2337
+ v_inv.created_by_agent_id
2338
+ )
2339
+ on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
2340
+ do update set
2341
+ role = case
2342
+ when agent_board._role_rank(excluded.role) > agent_board._role_rank(agent_board.board_memberships.role)
2343
+ then excluded.role
2344
+ else agent_board.board_memberships.role
2345
+ end,
2346
+ updated_at = now()
2347
+ returning * into v_m;
2348
+
2349
+ -- The redeemer places THEMSELVES -- the public wrapper passes current_agent_id() and takes no
2350
+ -- target parameter -- so this emits target-authored: redeeming a link IS the consenting act.
2351
+ --
2352
+ -- v_m.membership_state, NOT v_inv.preset_membership_state. The ON CONFLICT above lifts only
2353
+ -- the ROLE and leaves an existing membership_state untouched, so an already-`subscribed`
2354
+ -- member redeeming a `joined` link stays subscribed. Emitting the token's preset would
2355
+ -- record a resulting state the row does not have -- a ledger that contradicts the table it
2356
+ -- describes. Recording the redemption as a fresh consent act is right; recording the wrong
2357
+ -- outcome is not.
2358
+ perform agent_board._emit_membership_event(
2359
+ v_inv.board_id, p_agent_id, v_m.membership_state, 'consent_link_redeem');
2360
+
2361
+ -- Consume one use and bind the ledger row to the resulting membership.
2362
+ update agent_board.board_invite_tokens t
2363
+ set uses_count = t.uses_count + 1
2364
+ where t.id = v_inv.id;
2365
+
2366
+ update agent_board.board_invite_redemptions r
2367
+ set membership_id = v_m.id
2368
+ where r.invite_id = v_inv.id
2369
+ and r.agent_id = p_agent_id;
2370
+
2371
+ return query
2372
+ select v_m.board_id, v_m.agent_id, v_m.role, v_m.membership_state, v_m.updated_at;
2373
+ end;
2374
+ $function$;
2375
+
2376
+ 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)
2377
+ 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)
2378
+ LANGUAGE plpgsql
2379
+ SECURITY DEFINER
2380
+ SET search_path TO 'agent_board', 'public'
2381
+ AS $function$
2382
+ declare
2383
+ v_task_id uuid;
2384
+ v_root_task_id uuid;
2385
+ v_event_id uuid;
2386
+ v_actor_agent_id uuid;
2387
+ begin
2388
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
2389
+
2390
+ if nullif(trim(p_title), '') is null then
2391
+ raise exception 'task title is required' using errcode = '22023';
2392
+ end if;
2393
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
2394
+ raise exception 'thread not found' using errcode = 'P0002';
2395
+ end if;
2396
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
2397
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
2398
+ end if;
2399
+ -- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role to author.
2400
+ if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
2401
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
2402
+ end if;
2403
+ if p_assigned_agent_id is not null and not exists (select 1 from agent_board.agents where id = p_assigned_agent_id) then
2404
+ raise exception 'assigned agent not found' using errcode = 'P0002';
2405
+ end if;
2406
+ -- HOSTED (D16): a hosted delegate can act on no task, so it is never an assignee.
2407
+ perform agent_board._refuse_delegate_addressee(p_assigned_agent_id, null, 'task assignment');
2408
+
2409
+ if p_parent_task_id is not null then
2410
+ select coalesce(tasks.root_task_id, tasks.id)
2411
+ into v_root_task_id
2412
+ from agent_board.tasks
2413
+ where id = p_parent_task_id and tasks.thread_id = p_thread_id;
2414
+ if v_root_task_id is null then
2415
+ raise exception 'parent task not found in thread' using errcode = '22023';
2416
+ end if;
2417
+ if not agent_board._current_agent_can_access_task(p_parent_task_id) then
2418
+ raise exception 'parent task not accessible to current agent' using errcode = '42501';
2419
+ end if;
2420
+ end if;
2421
+
2422
+ v_task_id := gen_random_uuid();
2423
+ if p_parent_task_id is null then
2424
+ v_root_task_id := v_task_id;
2425
+ end if;
2426
+
2427
+ 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)
2428
+ 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));
2429
+
2430
+ -- #288: create_task is the ONE writer that inserts into task_events directly rather than
2431
+ -- through the funnel, which is why a fix instrumenting only append_task_event's callers
2432
+ -- would miss it. Its own declared posture is signed-metadata (it declares p_metadata and the
2433
+ -- client signs it). No parameter is threaded here deliberately -- see the plan's section 5:
2434
+ -- where a signed wrapper is involved (signed_create_tasking_delivery keys its evidence to
2435
+ -- THIS event, two levels up), a real signature_evidence row exists and v_task_tree already
2436
+ -- surfaces it, which is the stronger signal. A threaded parameter would change no answer.
2437
+ insert into agent_board.task_events(task_id, thread_id, actor_agent_id, event_sequence, event_type, payload, origin_posture)
2438
+ values (v_task_id, p_thread_id, v_actor_agent_id, 1, 'created', jsonb_build_object('title', trim(p_title)), 'unattested:signed-metadata-rpc')
2439
+ returning id into v_event_id;
2440
+
2441
+ return query
2442
+ select t.id, t.parent_task_id, t.root_task_id, v_event_id, 1::bigint, t.status, t.created_at, t.updated_at
2443
+ from agent_board.tasks t
2444
+ where t.id = v_task_id;
2445
+ end;
2446
+ $function$;
2447
+
2448
+ 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)
2449
+ 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)
2450
+ LANGUAGE plpgsql
2451
+ SECURITY DEFINER
2452
+ SET search_path TO 'agent_board', 'public'
2453
+ AS $function$
2454
+ declare
2455
+ v_thread_id uuid;
2456
+ v_locked_thread_id uuid;
2457
+ v_sequence bigint;
2458
+ v_event_id uuid;
2459
+ v_actor_agent_id uuid;
2460
+ begin
2461
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
2462
+
2463
+ select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
2464
+ if v_thread_id is null then
2465
+ raise exception 'task not found' using errcode = 'P0002';
2466
+ end if;
2467
+
2468
+ perform 1 from agent_board.threads th where th.id = v_thread_id for key share;
2469
+
2470
+ select tasks.thread_id into v_locked_thread_id from agent_board.tasks where id = p_task_id for update;
2471
+ if v_locked_thread_id is null then
2472
+ raise exception 'task not found' using errcode = 'P0002';
2473
+ end if;
2474
+ if v_locked_thread_id is distinct from v_thread_id then
2475
+ raise exception 'task thread changed while acquiring locks' using errcode = '40001';
2476
+ end if;
2477
+
2478
+ if not agent_board._current_agent_can_access_task(p_task_id) then
2479
+ raise exception 'task not accessible to current agent' using errcode = '42501';
2480
+ end if;
2481
+ if not agent_board._current_agent_can_mutate_task(p_task_id) then
2482
+ raise exception 'task mutation requires a non-observer board role, or task assignment/creation'
2483
+ using errcode = '42501';
2484
+ end if;
2485
+
2486
+ -- HOSTED (D16): a hosted delegate can act on no task, so it is never an assignee.
2487
+ perform agent_board._refuse_delegate_addressee(p_assigned_agent_id, null, 'task assignment');
2488
+
2489
+ select coalesce(max(te.event_sequence), 0) + 1 into v_sequence from agent_board.task_events te where te.task_id = p_task_id;
2490
+ insert into agent_board.task_events(task_id, thread_id, actor_agent_id, event_sequence, event_type, payload, origin_posture)
2491
+ values (p_task_id, v_thread_id, v_actor_agent_id, v_sequence, p_event_type, coalesce(p_payload, '{}'::jsonb), p_origin_posture)
2492
+ returning id into v_event_id;
2493
+
2494
+ update agent_board.tasks
2495
+ set status = coalesce(p_status, tasks.status),
2496
+ assigned_agent_id = coalesce(p_assigned_agent_id, tasks.assigned_agent_id),
2497
+ completed_at = coalesce(p_completed_at, tasks.completed_at),
2498
+ updated_at = now()
2499
+ where id = p_task_id;
2500
+
2501
+ return query
2502
+ select t.id, t.thread_id, v_event_id, v_sequence, t.status, t.assigned_agent_id, t.updated_at, t.completed_at
2503
+ from agent_board.tasks t
2504
+ where t.id = p_task_id;
2505
+ end;
2506
+ $function$;
2507
+
2508
+ 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)
2509
+ 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)
2510
+ LANGUAGE plpgsql
2511
+ SECURITY DEFINER
2512
+ SET search_path TO 'agent_board', 'public'
2513
+ AS $function$
2514
+ declare
2515
+ v_parent agent_board.tasks%rowtype;
2516
+ v_child record;
2517
+ v_delegation_id uuid;
2518
+ v_parent_event record;
2519
+ v_actor_agent_id uuid;
2520
+ begin
2521
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
2522
+
2523
+ select * into v_parent from agent_board.tasks where id = p_parent_task_id;
2524
+ if v_parent.id is null then
2525
+ raise exception 'parent task not found' using errcode = 'P0002';
2526
+ end if;
2527
+ if not agent_board._current_agent_can_access_task(p_parent_task_id) then
2528
+ raise exception 'parent task not accessible to current agent' using errcode = '42501';
2529
+ end if;
2530
+
2531
+ select * into v_child
2532
+ 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);
2533
+
2534
+ -- HOSTED (D16): a hosted delegate can act on no task, so nothing is delegated to it.
2535
+ perform agent_board._refuse_delegate_addressee(p_delegated_to_agent_id, null, 'task delegation');
2536
+ insert into agent_board.delegations(parent_task_id, child_task_id, delegated_by_agent_id, delegated_to_agent_id, note, metadata)
2537
+ values (p_parent_task_id, v_child.task_id, v_actor_agent_id, p_delegated_to_agent_id, p_note, coalesce(p_metadata, '{}'::jsonb))
2538
+ returning id into v_delegation_id;
2539
+
2540
+ select * into v_parent_event
2541
+ 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));
2542
+
2543
+ return query
2544
+ 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();
2545
+ end;
2546
+ $function$;
2547
+
2548
+ 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)
2549
+ RETURNS TABLE(escalation_id uuid, status text, task_event_sequence bigint, created_at timestamp with time zone, updated_at timestamp with time zone)
2550
+ LANGUAGE plpgsql
2551
+ SECURITY DEFINER
2552
+ SET search_path TO 'agent_board', 'public'
2553
+ AS $function$
2554
+ declare
2555
+ v_escalation_id uuid;
2556
+ v_event_sequence bigint;
2557
+ v_actor_agent_id uuid;
2558
+ begin
2559
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_raised_by_agent_id, 'p_raised_by_agent_id');
2560
+
2561
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
2562
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
2563
+ end if;
2564
+ -- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role.
2565
+ if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
2566
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
2567
+ end if;
2568
+
2569
+ -- HOSTED (D16): a hosted delegate can act on no escalation, so it is never an assignee.
2570
+ perform agent_board._refuse_delegate_addressee(p_assigned_agent_id, null, 'escalation assignment');
2571
+
2572
+ insert into agent_board.escalations(thread_id, task_id, raised_by_agent_id, assigned_agent_id, summary, escalation_type, severity, details)
2573
+ 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))
2574
+ returning id into v_escalation_id;
2575
+
2576
+ -- v_event_sequence stays NULL for a thread-level escalation (no task) — fixes the same latent
2577
+ -- "record not assigned yet" bug on the no-task path.
2578
+ if p_task_id is not null then
2579
+ select event_sequence into v_event_sequence
2580
+ 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));
2581
+ end if;
2582
+
2583
+ return query
2584
+ select e.id, e.status, v_event_sequence, e.created_at, e.updated_at
2585
+ from agent_board.escalations e
2586
+ where e.id = v_escalation_id;
2587
+ end;
2588
+ $function$;
2589
+
2590
+ CREATE OR REPLACE FUNCTION agent_board._upsert_delivery_recipient(p_delivery_mode text, p_recipient_agent_id uuid, p_source_kind text, p_source_message_id uuid DEFAULT NULL::uuid, p_source_task_id uuid DEFAULT NULL::uuid, p_source_delegation_id uuid DEFAULT NULL::uuid, p_board_id uuid DEFAULT NULL::uuid, p_board_membership_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
2591
+ RETURNS agent_board.delivery_recipients
2592
+ LANGUAGE plpgsql
2593
+ SECURITY DEFINER
2594
+ SET search_path TO 'agent_board', 'public'
2595
+ AS $function$
2596
+ declare
2597
+ v_row agent_board.delivery_recipients%rowtype;
2598
+ begin
2599
+ -- HOSTED (D16): a delivery reaches a hosted delegate only through a board it is active on
2600
+ -- (the board-post fan-out to members). It can read nothing without a board.
2601
+ perform agent_board._refuse_delegate_addressee(p_recipient_agent_id, p_board_id, 'delivery');
2602
+ -- OBSGATE (#78): stamp the thread's owning board on first board delivery so
2603
+ -- post_message's posting gate can resolve it. Resolve the thread from WHICHEVER source the
2604
+ -- delivery carries — message (post_board_message), task (create_tasking_delivery), or
2605
+ -- delegation — so EVERY board-fanning path links the thread, not just the message-sourced one.
2606
+ -- First-board-wins (never overwrites); board-less deliveries (p_board_id null) never stamp.
2607
+ if p_board_id is not null then
2608
+ update agent_board.threads t
2609
+ set board_id = p_board_id
2610
+ where t.board_id is null
2611
+ and t.id = coalesce(
2612
+ (select m.thread_id from agent_board.messages m where m.id = p_source_message_id),
2613
+ (select tk.thread_id from agent_board.tasks tk where tk.id = p_source_task_id),
2614
+ (select tk.thread_id
2615
+ from agent_board.delegations d
2616
+ join agent_board.tasks tk on tk.id = d.child_task_id
2617
+ where d.id = p_source_delegation_id)
2618
+ );
2619
+ end if;
2620
+
2621
+ insert into agent_board.delivery_recipients(
2622
+ delivery_mode,
2623
+ recipient_agent_id,
2624
+ source_kind,
2625
+ source_message_id,
2626
+ source_task_id,
2627
+ source_delegation_id,
2628
+ board_id,
2629
+ board_membership_id,
2630
+ metadata
2631
+ )
2632
+ values (
2633
+ p_delivery_mode,
2634
+ p_recipient_agent_id,
2635
+ p_source_kind,
2636
+ p_source_message_id,
2637
+ p_source_task_id,
2638
+ p_source_delegation_id,
2639
+ p_board_id,
2640
+ p_board_membership_id,
2641
+ coalesce(p_metadata, '{}'::jsonb)
2642
+ )
2643
+ on conflict (recipient_agent_id, canonical_source_key) do update
2644
+ set metadata = coalesce(agent_board.delivery_recipients.metadata, '{}'::jsonb) || coalesce(excluded.metadata, '{}'::jsonb)
2645
+ returning * into v_row;
2646
+
2647
+ return v_row;
2648
+ end;
2649
+ $function$;
2650
+
2651
+ CREATE OR REPLACE FUNCTION agent_board._upsert_inbox_item(p_delivery_recipient_id uuid, p_recipient_agent_id uuid, p_board_id uuid, p_delivery_mode text, p_source_kind text, p_source_message_id uuid DEFAULT NULL::uuid, p_source_task_id uuid DEFAULT NULL::uuid, p_source_delegation_id uuid DEFAULT NULL::uuid, p_subject text DEFAULT NULL::text, p_preview text DEFAULT NULL::text, p_priority text DEFAULT 'medium'::text, p_visible_in_inbox boolean DEFAULT true, p_visible_in_board_feed boolean DEFAULT false, p_fetch_descriptor jsonb DEFAULT '{}'::jsonb, p_metadata jsonb DEFAULT '{}'::jsonb)
2652
+ RETURNS agent_board.inbox_items
2653
+ LANGUAGE plpgsql
2654
+ SECURITY DEFINER
2655
+ SET search_path TO 'agent_board', 'public'
2656
+ AS $function$
2657
+ declare
2658
+ v_row agent_board.inbox_items%rowtype;
2659
+ begin
2660
+ -- HOSTED (D16): as for deliveries, only through a board the delegate is active on.
2661
+ perform agent_board._refuse_delegate_addressee(p_recipient_agent_id, p_board_id, 'inbox item');
2662
+ insert into agent_board.inbox_items(
2663
+ delivery_recipient_id,
2664
+ recipient_agent_id,
2665
+ board_id,
2666
+ delivery_mode,
2667
+ source_kind,
2668
+ source_message_id,
2669
+ source_task_id,
2670
+ source_delegation_id,
2671
+ subject,
2672
+ preview,
2673
+ priority,
2674
+ visible_in_inbox,
2675
+ visible_in_board_feed,
2676
+ fetch_descriptor,
2677
+ metadata
2678
+ )
2679
+ values (
2680
+ p_delivery_recipient_id,
2681
+ p_recipient_agent_id,
2682
+ p_board_id,
2683
+ p_delivery_mode,
2684
+ p_source_kind,
2685
+ p_source_message_id,
2686
+ p_source_task_id,
2687
+ p_source_delegation_id,
2688
+ p_subject,
2689
+ p_preview,
2690
+ p_priority,
2691
+ p_visible_in_inbox,
2692
+ p_visible_in_board_feed,
2693
+ coalesce(p_fetch_descriptor, '{}'::jsonb),
2694
+ coalesce(p_metadata, '{}'::jsonb)
2695
+ )
2696
+ on conflict (delivery_recipient_id) do update
2697
+ set board_id = excluded.board_id,
2698
+ subject = excluded.subject,
2699
+ preview = excluded.preview,
2700
+ priority = excluded.priority,
2701
+ visible_in_inbox = excluded.visible_in_inbox,
2702
+ visible_in_board_feed = excluded.visible_in_board_feed,
2703
+ fetch_descriptor = excluded.fetch_descriptor,
2704
+ metadata = coalesce(agent_board.inbox_items.metadata, '{}'::jsonb) || coalesce(excluded.metadata, '{}'::jsonb),
2705
+ updated_at = now()
2706
+ returning * into v_row;
2707
+
2708
+ return v_row;
2709
+ end;
2710
+ $function$;
2711
+
2712
+ CREATE OR REPLACE FUNCTION agent_board._resolve_addressing_recipient_rows(p_actor_agent_id uuid, p_recipient_descriptors jsonb)
2713
+ RETURNS TABLE(ordinal integer, recipient_agent_id uuid, handle text, display_name text, descriptor jsonb, warning text, denied_reason text)
2714
+ LANGUAGE plpgsql
2715
+ SECURITY DEFINER
2716
+ SET search_path TO 'agent_board', 'public'
2717
+ AS $function$
2718
+ declare
2719
+ v_descriptor jsonb;
2720
+ v_kind text;
2721
+ v_index integer := 0;
2722
+ v_match_count integer;
2723
+ v_group_alias text;
2724
+ v_role text;
2725
+ begin
2726
+ for v_descriptor in
2727
+ select value
2728
+ from jsonb_array_elements(agent_board._addressing_descriptors_array(p_recipient_descriptors))
2729
+ loop
2730
+ v_index := v_index + 1;
2731
+ v_kind := v_descriptor ->> 'kind';
2732
+
2733
+ if v_kind = 'explicit_set' then
2734
+ return query
2735
+ select v_index * 100000 + nested.ordinal,
2736
+ nested.recipient_agent_id,
2737
+ nested.handle,
2738
+ nested.display_name,
2739
+ nested.descriptor,
2740
+ nested.warning,
2741
+ nested.denied_reason
2742
+ from agent_board._resolve_addressing_recipient_rows(
2743
+ p_actor_agent_id,
2744
+ coalesce(v_descriptor -> 'recipients', '[]'::jsonb)
2745
+ ) nested;
2746
+ elsif v_kind = 'agent_id' then
2747
+ select count(*) into v_match_count
2748
+ from agent_board.agents a
2749
+ where a.id = (v_descriptor ->> 'agentId')::uuid
2750
+ and a.status = 'active';
2751
+
2752
+ if v_match_count = 1 then
2753
+ -- HOSTED (D16, NEW-4): naming a hosted delegate explicitly is DENIED, so the dry run
2754
+ -- shows it and the send refuses the whole call (_addressing_agent_ids_or_raise).
2755
+ return query
2756
+ select v_index,
2757
+ case when a.is_delegate then null::uuid else a.id end,
2758
+ case when a.is_delegate then null::text else a.handle end,
2759
+ case when a.is_delegate then null::text else a.display_name end,
2760
+ v_descriptor, null::text,
2761
+ case when a.is_delegate then 'recipient_is_hosted_delegate'::text else null::text end
2762
+ from agent_board.agents a
2763
+ where a.id = (v_descriptor ->> 'agentId')::uuid
2764
+ and a.status = 'active';
2765
+ else
2766
+ return query select v_index, null::uuid, null::text, null::text, v_descriptor, null::text, 'unknown agent_id'::text;
2767
+ end if;
2768
+ elsif v_kind in ('agent_handle', 'human_handle') then
2769
+ select count(*) into v_match_count
2770
+ from agent_board.agents a
2771
+ where a.handle = v_descriptor ->> 'handle'
2772
+ and a.status = 'active'
2773
+ and (
2774
+ (v_kind = 'agent_handle' and a.agent_type <> 'human')
2775
+ or (v_kind = 'human_handle' and a.agent_type = 'human')
2776
+ );
2777
+
2778
+ if v_match_count = 1 then
2779
+ -- HOSTED (D16, NEW-4): as for agent_id.
2780
+ return query
2781
+ select v_index,
2782
+ case when a.is_delegate then null::uuid else a.id end,
2783
+ case when a.is_delegate then null::text else a.handle end,
2784
+ case when a.is_delegate then null::text else a.display_name end,
2785
+ v_descriptor, null::text,
2786
+ case when a.is_delegate then 'recipient_is_hosted_delegate'::text else null::text end
2787
+ from agent_board.agents a
2788
+ where a.handle = v_descriptor ->> 'handle'
2789
+ and a.status = 'active'
2790
+ and (
2791
+ (v_kind = 'agent_handle' and a.agent_type <> 'human')
2792
+ or (v_kind = 'human_handle' and a.agent_type = 'human')
2793
+ );
2794
+ elsif v_match_count = 0 then
2795
+ return query select v_index, null::uuid, null::text, null::text, v_descriptor, null::text, 'unknown handle'::text;
2796
+ else
2797
+ return query select v_index, null::uuid, null::text, null::text, v_descriptor, null::text, 'ambiguous handle'::text;
2798
+ end if;
2799
+ elsif v_kind = 'group_alias' then
2800
+ v_group_alias := nullif(trim(v_descriptor ->> 'groupAlias'), '');
2801
+ return query
2802
+ select v_index,
2803
+ a.id,
2804
+ a.handle,
2805
+ a.display_name,
2806
+ v_descriptor,
2807
+ null::text,
2808
+ null::text
2809
+ from agent_board.agents a
2810
+ where a.status = 'active'
2811
+ -- HOSTED (D16, NEW-4): an expansion names a set, not an agent; it never includes a
2812
+ -- hosted delegate, so one delegate cannot break every send to the set.
2813
+ and not a.is_delegate
2814
+ and (
2815
+ a.metadata ->> 'groupAlias' = v_group_alias
2816
+ or a.metadata ->> 'group_alias' = v_group_alias
2817
+ or coalesce(a.metadata -> 'groupAliases', '[]'::jsonb) ? v_group_alias
2818
+ or coalesce(a.metadata -> 'group_aliases', '[]'::jsonb) ? v_group_alias
2819
+ )
2820
+ order by a.handle, a.id;
2821
+
2822
+ if not found then
2823
+ return query select v_index, null::uuid, null::text, null::text, v_descriptor, null::text, 'unknown group_alias'::text;
2824
+ end if;
2825
+ elsif v_kind = 'role_selector' then
2826
+ v_role := nullif(trim(v_descriptor ->> 'role'), '');
2827
+ return query
2828
+ select v_index,
2829
+ a.id,
2830
+ a.handle,
2831
+ a.display_name,
2832
+ v_descriptor,
2833
+ null::text,
2834
+ null::text
2835
+ from agent_board.agents a
2836
+ where a.status = 'active'
2837
+ -- HOSTED (D16, NEW-4): as for group_alias.
2838
+ and not a.is_delegate
2839
+ and (
2840
+ a.agent_type = v_role
2841
+ or a.kind::text = v_role
2842
+ or a.metadata ->> 'role' = v_role
2843
+ or coalesce(a.metadata -> 'roles', '[]'::jsonb) ? v_role
2844
+ or coalesce(a.metadata -> 'scopes', '[]'::jsonb) ? v_role
2845
+ )
2846
+ order by a.handle, a.id;
2847
+
2848
+ if not found then
2849
+ return query select v_index, null::uuid, null::text, null::text, v_descriptor, null::text, 'unknown role_selector'::text;
2850
+ end if;
2851
+ else
2852
+ return query select v_index, null::uuid, null::text, null::text, v_descriptor, null::text, 'unsupported descriptor kind'::text;
2853
+ end if;
2854
+ end loop;
2855
+ end;
2856
+ $function$;
2857
+
2858
+ CREATE OR REPLACE FUNCTION agent_board._addressing_agent_ids_or_raise(p_actor_agent_id uuid, p_recipient_descriptors jsonb)
2859
+ RETURNS uuid[]
2860
+ LANGUAGE plpgsql
2861
+ SECURITY DEFINER
2862
+ SET search_path TO 'agent_board', 'public'
2863
+ AS $function$
2864
+ declare
2865
+ v_result record;
2866
+ v_ids uuid[];
2867
+ begin
2868
+ select * into v_result
2869
+ from agent_board.resolve_recipients(p_actor_agent_id, p_recipient_descriptors);
2870
+
2871
+ -- HOSTED (D16, NEW-4): naming a hosted delegate refuses the whole send as an authorization
2872
+ -- failure, before any delivery is written.
2873
+ if exists (
2874
+ select 1
2875
+ from jsonb_array_elements(v_result."deniedRecipients") d
2876
+ where d ->> 'reason' = 'recipient_is_hosted_delegate'
2877
+ ) then
2878
+ raise exception 'a hosted delegate is reached only through a board it is on' using errcode = '42501', detail = v_result."deniedRecipients"::text;
2879
+ end if;
2880
+
2881
+ if jsonb_array_length(v_result."deniedRecipients") > 0 then
2882
+ raise exception 'recipient descriptor resolution failed' using errcode = '22023', detail = v_result."deniedRecipients"::text;
2883
+ end if;
2884
+
2885
+ select array_agg((value ->> 'agentId')::uuid)
2886
+ into v_ids
2887
+ from jsonb_array_elements(v_result."resolvedRecipients") as value;
2888
+
2889
+ if v_ids is null or cardinality(v_ids) = 0 then
2890
+ raise exception 'recipient descriptors resolved no recipients' using errcode = '22023';
2891
+ end if;
2892
+
2893
+ return v_ids;
2894
+ end;
2895
+ $function$;
2896
+
2897
+ CREATE OR REPLACE FUNCTION agent_board.post_message(p_thread_id uuid, p_author_agent_id uuid, p_body text, p_message_type text DEFAULT 'comment'::text, p_task_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
2898
+ RETURNS TABLE(message_id uuid, thread_id uuid, task_id uuid, message_sequence bigint, created_at timestamp with time zone)
2899
+ LANGUAGE plpgsql
2900
+ SECURITY DEFINER
2901
+ SET search_path TO 'agent_board', 'public'
2902
+ AS $function$
2903
+ declare
2904
+ v_sequence bigint;
2905
+ v_actor_agent_id uuid;
2906
+ v_metadata jsonb;
2907
+ v_instance agent_board.delegate_instances%rowtype;
2908
+ begin
2909
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_author_agent_id, 'p_author_agent_id');
2910
+
2911
+ if nullif(trim(p_body), '') is null then
2912
+ raise exception 'message body is required' using errcode = '22023';
2913
+ end if;
2914
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
2915
+ raise exception 'thread not found' using errcode = 'P0002';
2916
+ end if;
2917
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
2918
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
2919
+ end if;
2920
+ -- OBSGATE (#78): a board-linked thread under 'inherit' requires a non-observer board role.
2921
+ -- post_message is the sole authenticated writer to messages, so this gate cannot be bypassed
2922
+ -- by any send path. Board-less threads and service_role fall through (access check above).
2923
+ if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
2924
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
2925
+ end if;
2926
+ if p_task_id is not null and not exists (select 1 from agent_board.tasks where id = p_task_id and tasks.thread_id = p_thread_id) then
2927
+ raise exception 'task not found in thread' using errcode = '22023';
2928
+ end if;
2929
+
2930
+ -- HOSTED attribution: the `delegate` key is written by the server, never by a caller. A
2931
+ -- hosted delegate's post carries it (overwriting anything supplied); every other author's
2932
+ -- caller-supplied `delegate` key is removed, so no keyed agent can forge the mark.
2933
+ -- client_verified is always false: under dynamic client registration anyone can name a client.
2934
+ v_metadata := coalesce(p_metadata, '{}'::jsonb);
2935
+ if jsonb_typeof(v_metadata) = 'object' then
2936
+ v_metadata := v_metadata - 'delegate';
2937
+ end if;
2938
+ select di.* into v_instance
2939
+ from agent_board.agents a
2940
+ join agent_board.delegate_instances di on di.agent_id = a.id
2941
+ where a.id = v_actor_agent_id
2942
+ and a.is_delegate;
2943
+ if found then
2944
+ if jsonb_typeof(v_metadata) <> 'object' then
2945
+ raise exception 'a hosted delegate''s message metadata must be a JSON object' using errcode = '22023';
2946
+ end if;
2947
+ v_metadata := v_metadata || jsonb_build_object('delegate', jsonb_build_object(
2948
+ 'delegate', true,
2949
+ 'instance_id', v_instance.instance_id,
2950
+ 'owner_agent_id', agent_board._delegate_principal_agent_id(v_actor_agent_id),
2951
+ 'client_id', v_instance.client_id,
2952
+ 'client_verified', false));
2953
+ end if;
2954
+
2955
+ v_sequence := agent_board._next_thread_message_sequence(p_thread_id);
2956
+
2957
+ return query
2958
+ insert into agent_board.messages(thread_id, author_agent_id, task_id, message_sequence, message_type, body, metadata)
2959
+ values (p_thread_id, v_actor_agent_id, p_task_id, v_sequence, coalesce(p_message_type, 'comment'), p_body, v_metadata)
2960
+ returning id, messages.thread_id, messages.task_id, messages.message_sequence, messages.created_at;
2961
+ end;
2962
+ $function$;
2963
+
2964
+ CREATE OR REPLACE FUNCTION agent_board.get_bundle_compatibility()
2965
+ RETURNS jsonb
2966
+ LANGUAGE sql
2967
+ STABLE SECURITY DEFINER
2968
+ SET search_path TO 'agent_board', 'pg_temp'
2969
+ AS $function$
2970
+ select jsonb_build_object(
2971
+ 'schema_version', (select version from agent_board.schema_version limit 1),
2972
+ 'installed_bundle_version', (select package_version from agent_board.bundle_installation limit 1),
2973
+ 'installed_bundle_digest', (select bundle_digest from agent_board.bundle_installation limit 1),
2974
+ 'installed_schema_version', (select schema_version from agent_board.bundle_installation limit 1),
2975
+ 'source_commit', (select source_commit from agent_board.bundle_installation limit 1),
2976
+ -- #326: which PROPOSITION the boolean below is answering, said out loud, so a
2977
+ -- reader does not have to consult a version boundary to decode it. A constant
2978
+ -- rather than a derivation: it names the model this bundle's predicate encodes,
2979
+ -- which is a fact about the migration you are reading, not about the database.
2980
+ 'signed_write_model', 'operator-only-gateway',
2981
+ -- #310: enforcement means the SIGNED path stays operator-only, not that the unsigned path is
2982
+ -- unreachable. See 20260918000100's header for what this does and does not establish.
2983
+ 'signed_write_enforced', (
2984
+ -- The anchor. A schema with no signed gateway grants none of them, which would make the
2985
+ -- second clause vacuously true; requiring the gateway to exist is what stops "enforced"
2986
+ -- from meaning "nothing to enforce".
2987
+ exists (
2988
+ select 1
2989
+ from pg_catalog.pg_proc p
2990
+ join pg_catalog.pg_namespace n on n.oid = p.pronamespace
2991
+ where n.nspname = 'agent_board'
2992
+ and p.proname = 'signed_send_direct_message'
2993
+ )
2994
+ and not exists (
2995
+ -- Catalog-derived over pg_proc ROWS: covers all seven gateways and every overload of
2996
+ -- each, and asks the privilege RESOLVER rather than reading a direct-grant view.
2997
+ select 1
2998
+ from pg_catalog.pg_proc p
2999
+ join pg_catalog.pg_namespace n on n.oid = p.pronamespace
3000
+ cross join (values ('authenticated'), ('anon'), ('agent_board_delegate')) as r(rolname)
3001
+ where n.nspname = 'agent_board'
3002
+ and p.proname like 'signed\_%'
3003
+ and pg_catalog.has_function_privilege(r.rolname, p.oid, 'EXECUTE')
3004
+ )
3005
+ ),
3006
+ -- Ready only once the materializer has recorded an install AND the schema
3007
+ -- version handshake resolves.
3008
+ 'ready', (
3009
+ (select version from agent_board.schema_version limit 1) is not null
3010
+ and exists (select 1 from agent_board.bundle_installation)
3011
+ ),
3012
+ 'checked_at', now()
3013
+ );
3014
+ $function$;
3015
+
3016
+ -- =============================================================================
3017
+ -- 12. The delegate read policies (D14, §The read closure)
3018
+ -- =============================================================================
3019
+ -- One SELECT policy per read_table, TO agent_board_delegate only. No authenticated predicate
3020
+ -- is reused. The caller's board set and identity are read through UNCORRELATED subqueries, so
3021
+ -- each is computed once per query rather than once per row (see section 6). No policy FOR INSERT, UPDATE, DELETE or ALL is created for the role.
3022
+ -- Each expression is stated ONCE, here; agent_board_delegate_role_class pins its deparse.
3023
+ create policy agent_board_delegate_select_boards
3024
+ on agent_board.boards for select to agent_board_delegate
3025
+ using (id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[])));
3026
+
3027
+ create policy agent_board_delegate_select_board_memberships
3028
+ on agent_board.board_memberships for select to agent_board_delegate
3029
+ using (agent_id = (select agent_board.current_agent_id()) and membership_state in ('joined', 'subscribed'));
3030
+
3031
+ create policy agent_board_delegate_select_agents
3032
+ on agent_board.agents for select to agent_board_delegate
3033
+ using (agent_board._delegate_visible_agent(id));
3034
+
3035
+ create policy agent_board_delegate_select_messages
3036
+ on agent_board.messages for select to agent_board_delegate
3037
+ using (exists (select 1 from agent_board.delivery_recipients dr
3038
+ where dr.source_message_id = messages.id
3039
+ and dr.board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[]))));
3040
+
3041
+ create policy agent_board_delegate_select_delivery_recipients
3042
+ on agent_board.delivery_recipients for select to agent_board_delegate
3043
+ using (board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[])));
3044
+
3045
+ -- The own-or-feed conjunct is the authenticated policy's (20260713000000:80): per-recipient
3046
+ -- rows carry a board_id with visible_in_board_feed = false, and without it a delegate would
3047
+ -- read every co-member's triage rows. Recipiency here only NARROWS.
3048
+ create policy agent_board_delegate_select_inbox_items
3049
+ on agent_board.inbox_items for select to agent_board_delegate
3050
+ using (board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[]))
3051
+ and (recipient_agent_id = (select agent_board.current_agent_id()) or visible_in_board_feed));
3052
+
3053
+ create policy agent_board_delegate_select_tasks
3054
+ on agent_board.tasks for select to agent_board_delegate
3055
+ using (exists (select 1 from agent_board.delivery_recipients dr
3056
+ where dr.source_task_id = tasks.id
3057
+ and dr.board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[]))));
3058
+
3059
+ create policy agent_board_delegate_select_task_events
3060
+ on agent_board.task_events for select to agent_board_delegate
3061
+ using (exists (select 1 from agent_board.delivery_recipients dr
3062
+ where dr.source_task_id = task_events.task_id
3063
+ and dr.board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[]))));
3064
+
3065
+ create policy agent_board_delegate_select_claims
3066
+ on agent_board.claims for select to agent_board_delegate
3067
+ using (task_id is not null and exists (select 1 from agent_board.delivery_recipients dr
3068
+ where dr.source_task_id = claims.task_id
3069
+ and dr.board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[]))));
3070
+
3071
+ create policy agent_board_delegate_select_escalations
3072
+ on agent_board.escalations for select to agent_board_delegate
3073
+ using (task_id is not null and exists (select 1 from agent_board.delivery_recipients dr
3074
+ where dr.source_task_id = escalations.task_id
3075
+ and dr.board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[]))));
3076
+
3077
+ -- At least one source is non-null, and EVERY non-null source is visible. source_artifact_id is
3078
+ -- a source too, and artifacts are outside the read closure, so evidence over an artifact is
3079
+ -- invisible. The task-event and delivery subqueries are filtered by their own delegate
3080
+ -- policies. The authenticated policy's own-agent_id branch is not kept.
3081
+ create policy agent_board_delegate_select_signature_evidence
3082
+ on agent_board.signature_evidence for select to agent_board_delegate
3083
+ using ((source_message_id is not null or source_task_event_id is not null or source_delivery_recipient_id is not null)
3084
+ and source_artifact_id is null
3085
+ and (source_message_id is null
3086
+ or exists (select 1 from agent_board.delivery_recipients dr
3087
+ where dr.source_message_id = signature_evidence.source_message_id
3088
+ and dr.board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[]))))
3089
+ and (source_task_event_id is null
3090
+ or exists (select 1 from agent_board.task_events te where te.id = source_task_event_id))
3091
+ and (source_delivery_recipient_id is null
3092
+ or exists (select 1 from agent_board.delivery_recipients dr where dr.id = source_delivery_recipient_id)));
3093
+
3094
+ create policy agent_board_delegate_select_presence
3095
+ on agent_board.presence for select to agent_board_delegate
3096
+ using (false);
3097
+
3098
+ create policy agent_board_delegate_select_agent_signing_keys
3099
+ on agent_board.agent_signing_keys for select to agent_board_delegate
3100
+ using (false);
3101
+
3102
+ -- =============================================================================
3103
+ -- 13. Grants, FROM the allow set (§What it holds, exactly)
3104
+ -- =============================================================================
3105
+ -- EXECUTE on each rpc / ambient_rpc / predicate row, SELECT on each read_view / read_table
3106
+ -- row, and on agents only the eight columns authenticated holds (never auth_user_id or
3107
+ -- owner_user_id). Nothing else: no INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER,
3108
+ -- and no sequence privilege. A signature that names no function fails the apply, loudly.
3109
+ do $grants$
3110
+ declare
3111
+ r record;
3112
+ begin
3113
+ for r in
3114
+ select kind, name, signature from agent_board.delegate_allowed_operations order by kind, signature
3115
+ loop
3116
+ if r.kind in ('rpc', 'ambient_rpc', 'predicate') then
3117
+ execute format('grant execute on function agent_board.%s to agent_board_delegate', r.signature);
3118
+ elsif r.name = 'agents' then
3119
+ grant select (kind, display_name, agent_type, created_at, updated_at, id, handle, status)
3120
+ on agent_board.agents to agent_board_delegate;
3121
+ else
3122
+ execute format('grant select on agent_board.%I to agent_board_delegate', r.name);
3123
+ end if;
3124
+ end loop;
3125
+ end
3126
+ $grants$;
3127
+
3128
+ -- =============================================================================
3129
+ -- 14. Indexes for the per-row predicates (NEW-5, §Class assertions: the query plan)
3130
+ -- =============================================================================
3131
+ -- The predicates are STABLE SECURITY DEFINER functions the planner cannot inline into the
3132
+ -- feeds; each is an index probe per row. The plan evidence is in plans/evidence/hosted/.
3133
+ create index if not exists idx_agent_board_delivery_recipients_source_message_board
3134
+ on agent_board.delivery_recipients (source_message_id, board_id)
3135
+ where source_message_id is not null;
3136
+
3137
+ create index if not exists idx_agent_board_delivery_recipients_source_task_board
3138
+ on agent_board.delivery_recipients (source_task_id, board_id)
3139
+ where source_task_id is not null;
3140
+
3141
+ -- _delegate_visible_agent's past-author branch.
3142
+ create index if not exists idx_agent_board_messages_author
3143
+ on agent_board.messages (author_agent_id);
3144
+
3145
+ -- =============================================================================
3146
+ -- 15. The ceiling lock's machine-read COMMENT gains its new reader
3147
+ -- =============================================================================
3148
+ -- add_delegate_to_board reads the ceiling for authorization (_grant_within_ceiling) and takes
3149
+ -- the lock. agent_board_atomic_fleet_onboarding.test.sql compares these lists with pg_proc.
3150
+ -- Restated in full: COMMENT ON replaces, it does not append.
3151
+ comment on function agent_board._lock_board_authority_ceiling(uuid) is
3152
+ 'Board-keyed transaction advisory lock serializing the ABSENCE of a board_authority_ceilings row. Held to commit. WRITERS[serialized: set_board_authority_ceiling][UNSERIALIZED: (none)] -- the RPCs that write board_authority_ceilings. set_board_authority_ceiling takes this lock before its owner gate and its upsert, and therefore before the boards row that upsert reaches through board_authority_ceilings'' FK on INSERT. A direct table write by service_role bypasses every function-level lock (deliberate, 20260717000400). READERS[serialized: add_delegate_to_board, grant_board_role, onboard_federated_fleet_agent][UNSERIALIZED: _redeem_unbounded_invite_for_agent, create_invite_link] -- the RPCs that read the ceiling for authorization through _grant_within_ceiling. onboard_federated_fleet_agent takes this lock BEFORE its boards row lock, because the writer reaches boards through that FK and the two orders must agree (inverted, they deadlocked). grant_board_role takes no boards row lock, so it makes no ordering claim. add_delegate_to_board (HOSTED) takes no boards row lock either, and takes this lock FIRST, before any row lock, as grant_board_role does; tombstone_delegate_instance, the other holder of the delegate_instances row it locks next, never takes this lock. The UNSERIALIZED readers evaluate the ceiling WITHOUT serializing against its absence: a concurrent set_board_authority_ceiling can commit a first ceiling row after such a reader observed none, and it passes as default-owner. That race is real, known and accepted (#277); it is deliberately NOT closed by adding acquisitions, since each new edge in this lock graph has already produced a reproduced ABBA deadlock once (#269 F4). Both lists are machine-compared against pg_proc by agent_board_atomic_fleet_onboarding.test.sql, which fails when this COMMENT and the catalog disagree -- do not edit a list by hand without the catalog. Internal: it takes a lock with NO authorization check on an arbitrary board id, so it must never be grantable to authenticated.';