@consiliency/agent-board-schema 1.58.0 → 1.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,665 @@
1
+ -- HOSTED follow-ups (Consiliency/message-board#591, items 4, 8 and 9): the owner's verified
2
+ -- (iss, sub) is recorded on every delegate instance and revoke_human_subtree derives a durable
3
+ -- marker from it; tombstone_delegate_instance takes the delegate's agents row before its
4
+ -- credentials, so it serialises with revoke_human_subtree and revoke_agent_subtree (both
5
+ -- agent-before-credentials) instead of deadlocking against them; the exchange takes the delegate's
6
+ -- agents row before the credentials for the same reason. schema_version stays 8.
7
+ --
8
+ -- THE CONTRACT is the HOSTED section of specs/agent-client-contract.md, amendment AB15.
9
+ --
10
+ -- Sources, each at its LATEST definition, read from the catalog of a replay of this tree
11
+ -- (pg_get_functiondef / md5(prosrc) compared with the file text before editing):
12
+ -- * exchange_delegate_credential, tombstone_delegate_instance -- 20261008000100
13
+ -- * revoke_human_subtree -- 20260901000120
14
+ -- Each is redefined below VERBATIM plus the lines marked `HOSTED #591`. create or replace keeps
15
+ -- the ACL and COMMENT; both are restated anyway so this file says what each function is.
16
+ --
17
+ -- SUPERSEDED COMMENT. add_delegate_to_board's body comment (20261008000100, section 9) says the
18
+ -- tombstone takes "its own owner-keyed advisory lock, then the instance row". That is still
19
+ -- true; the tombstone now takes the delegate's agents row (FOR NO KEY UPDATE, its status UPDATE)
20
+ -- right after the instance row instead of last. add_delegate_to_board's only lock on that row is
21
+ -- the foreign-key FOR KEY SHARE of the sink's insert, which FOR NO KEY UPDATE does not block, so
22
+ -- that comment's conclusion -- the instance row is the only lock the two contend on -- still
23
+ -- holds (measured: hosted_delegate_add_tombstone_race). add_delegate_to_board is not redefined.
24
+
25
+ -- =============================================================================
26
+ -- 1. The owner's verified identity on each instance (#591 item 4)
27
+ -- =============================================================================
28
+ -- revoke_human_subtree writes the durable marker fleet_human_revocations (issuer, subject) only
29
+ -- from enrollment_federation_audit. An owner whose only principals are hosted delegates has no
30
+ -- such row, so revoking it left no marker, and -- with no human agents row either -- a fresh first
31
+ -- exchange passed the exchange's owner_inactive check and created a new active delegate.
32
+ --
33
+ -- The marker is keyed by (issuer, subject) and the owner uid is a one-way derivation of them
34
+ -- (_federated_identity_slug, then the auth user of that slug), so revoke_human_subtree cannot
35
+ -- rebuild the pair from the uid. The exchange and the tombstone both have the verified pair; they
36
+ -- now record it. It is REVOCATION EVIDENCE, never identity: owner_mismatch still compares the uid,
37
+ -- and nothing compares these columns with a token.
38
+ --
39
+ -- NULLABLE, not NOT NULL. A 1.58.0 adopter may already have rows, and no backfill exists: the
40
+ -- subject is not derivable from the uid, and a delegate-only owner has no other row that carries
41
+ -- it. NOT NULL would therefore fail this migration on exactly the databases it must upgrade. The
42
+ -- two columns are paired (both or neither, neither empty), every write path of both RPCs fills
43
+ -- them, and both fill a legacy row's null pair when they write it (sound because owner_mismatch
44
+ -- has just proved the row's uid is the one this (iss, sub) derives): the exchange on a successful
45
+ -- exchange, the tombstone on any call, including a repeat of an existing tombstone (the only write
46
+ -- a legacy TOMBSTONED row can ever get, since the exchange refuses it before writing). A legacy row
47
+ -- not filled before its owner is revoked gives revoke_human_subtree nothing: stated in AB15.
48
+ alter table agent_board.delegate_instances
49
+ add column if not exists owner_issuer text,
50
+ add column if not exists owner_subject text;
51
+
52
+ alter table agent_board.delegate_instances
53
+ drop constraint if exists delegate_instances_owner_identity_shape;
54
+ alter table agent_board.delegate_instances
55
+ add constraint delegate_instances_owner_identity_shape check (
56
+ (owner_issuer is null and owner_subject is null)
57
+ or (owner_issuer is not null and owner_subject is not null
58
+ and owner_issuer <> '' and owner_subject <> '')
59
+ );
60
+
61
+ comment on column agent_board.delegate_instances.owner_issuer is
62
+ 'HOSTED #591 (AB15): the verified issuer of the owner token that created or last wrote this instance, recorded by exchange_delegate_credential and tombstone_delegate_instance. Revocation evidence only: revoke_human_subtree derives a fleet_human_revocations marker from (owner_issuer, owner_subject). Paired with owner_subject; never changes once set (trg_delegate_instances_owner_identity_immutable). Null only on a row written before 1.59.0 and not filled since (any tombstone call for it, or a successful exchange of it, fills it).';
63
+ comment on column agent_board.delegate_instances.owner_subject is
64
+ 'HOSTED #591 (AB15): the verified owner subject (the Portal sub) paired with owner_issuer. Revocation evidence only, never compared for identity: the owner identity is owner_user_id. Never changes once set.';
65
+
66
+ -- Once set, never changed. A null legacy pair may be filled once.
67
+ create or replace function agent_board._delegate_instances_owner_identity_immutable()
68
+ returns trigger
69
+ language plpgsql
70
+ set search_path = ''
71
+ as $fn$
72
+ begin
73
+ if old.owner_issuer is not null
74
+ and (new.owner_issuer is distinct from old.owner_issuer
75
+ or new.owner_subject is distinct from old.owner_subject) then
76
+ raise exception 'delegate_instances owner identity never changes once recorded'
77
+ using errcode = '23514';
78
+ end if;
79
+ return new;
80
+ end;
81
+ $fn$;
82
+
83
+ revoke all on function agent_board._delegate_instances_owner_identity_immutable() from public;
84
+
85
+ comment on function agent_board._delegate_instances_owner_identity_immutable() is
86
+ 'HOSTED #591 (AB15): trigger function refusing any UPDATE that changes delegate_instances.owner_issuer or owner_subject once owner_issuer is set. A null legacy pair may be filled once. EXECUTE revoked from PUBLIC; a trigger function needs none at fire time.';
87
+
88
+ drop trigger if exists trg_delegate_instances_owner_identity_immutable on agent_board.delegate_instances;
89
+ create trigger trg_delegate_instances_owner_identity_immutable
90
+ before update of owner_issuer, owner_subject on agent_board.delegate_instances
91
+ for each row execute function agent_board._delegate_instances_owner_identity_immutable();
92
+
93
+ -- =============================================================================
94
+ -- 2. exchange_delegate_credential -- records the owner's (iss, sub) (#591 item 4)
95
+ -- =============================================================================
96
+ create or replace function agent_board.exchange_delegate_credential(p_claims jsonb)
97
+ returns table(agent_id uuid, auth_user_id uuid, credential_id uuid, expires_at timestamptz)
98
+ language plpgsql
99
+ security definer
100
+ set search_path = ''
101
+ as $fn$
102
+ #variable_conflict use_column
103
+ declare
104
+ v_iss text;
105
+ v_sub text;
106
+ v_slot text;
107
+ v_claim jsonb;
108
+ v_instance_id text;
109
+ v_grant_id text;
110
+ v_generation bigint;
111
+ v_client_id text;
112
+ v_change jsonb;
113
+ v_change_gen bigint;
114
+ v_owner uuid;
115
+ v_owner_slug text;
116
+ v_row agent_board.delegate_instances%rowtype;
117
+ v_first boolean;
118
+ v_live integer;
119
+ v_slug text;
120
+ v_auth uuid;
121
+ v_agent uuid;
122
+ v_status text;
123
+ v_credential uuid;
124
+ v_expires timestamptz;
125
+ begin
126
+ if not agent_board._is_service_role() then
127
+ raise exception 'exchange_delegate_credential is service_role-only' using errcode = '42501';
128
+ end if;
129
+
130
+ -- SHAPE. The Edge Function refuses every malformed token first; this is the database's own
131
+ -- refusal of the same shape, so a caller that skips it cannot write a malformed instance.
132
+ if jsonb_typeof(p_claims) is distinct from 'object'
133
+ or jsonb_typeof(p_claims -> 'iss') is distinct from 'string'
134
+ or jsonb_typeof(p_claims -> 'sub') is distinct from 'string'
135
+ or coalesce(p_claims ->> 'iss', '') = ''
136
+ or coalesce(p_claims ->> 'sub', '') = ''
137
+ or jsonb_typeof(p_claims -> 'slot_resource') not in ('string', 'null')
138
+ or jsonb_typeof(p_claims -> 'agent_board_delegate_instance') is distinct from 'object' then
139
+ raise exception 'claims_malformed' using errcode = '22023';
140
+ end if;
141
+ v_iss := p_claims ->> 'iss';
142
+ v_sub := p_claims ->> 'sub';
143
+ v_slot := p_claims ->> 'slot_resource';
144
+ v_claim := p_claims -> 'agent_board_delegate_instance';
145
+
146
+ -- The instance claim: exactly these seven fields, each of its type.
147
+ if (select array_agg(k order by k) from jsonb_object_keys(v_claim) k)
148
+ is distinct from array['client_change', 'client_id', 'generation', 'grant_id', 'instance_id', 'owner_sub', 'status']
149
+ or jsonb_typeof(v_claim -> 'instance_id') is distinct from 'string'
150
+ or (v_claim ->> 'instance_id') !~ '^[A-Za-z0-9_-]{16,128}$'
151
+ or jsonb_typeof(v_claim -> 'grant_id') is distinct from 'string'
152
+ or (v_claim ->> 'grant_id') !~ '^[A-Za-z0-9_-]{16,128}$'
153
+ or jsonb_typeof(v_claim -> 'generation') is distinct from 'number'
154
+ or (v_claim ->> 'generation') !~ '^[1-9][0-9]{0,15}$'
155
+ or jsonb_typeof(v_claim -> 'owner_sub') is distinct from 'string'
156
+ or (v_claim ->> 'owner_sub') is distinct from v_sub
157
+ or jsonb_typeof(v_claim -> 'client_id') is distinct from 'string'
158
+ or char_length(v_claim ->> 'client_id') not between 1 and 512
159
+ or jsonb_typeof(v_claim -> 'status') is distinct from 'string'
160
+ or (v_claim ->> 'status') not in ('active', 'revoked')
161
+ or jsonb_typeof(v_claim -> 'client_change') not in ('object', 'null') then
162
+ raise exception 'claims_malformed' using errcode = '22023';
163
+ end if;
164
+ v_generation := (v_claim ->> 'generation')::bigint;
165
+ if v_generation > 9007199254740991 then
166
+ raise exception 'claims_malformed' using errcode = '22023';
167
+ end if;
168
+ v_instance_id := v_claim ->> 'instance_id';
169
+ v_grant_id := v_claim ->> 'grant_id';
170
+ v_client_id := v_claim ->> 'client_id';
171
+
172
+ v_change := case when jsonb_typeof(v_claim -> 'client_change') = 'object' then v_claim -> 'client_change' end;
173
+ if v_change is not null then
174
+ if (select array_agg(k order by k) from jsonb_object_keys(v_change) k)
175
+ is distinct from array['confirmed_at', 'confirmed_generation', 'from_client_id']
176
+ or jsonb_typeof(v_change -> 'from_client_id') is distinct from 'string'
177
+ or jsonb_typeof(v_change -> 'confirmed_at') is distinct from 'number'
178
+ or (v_change ->> 'confirmed_at') !~ '^[0-9]{1,16}$'
179
+ or jsonb_typeof(v_change -> 'confirmed_generation') is distinct from 'number'
180
+ or (v_change ->> 'confirmed_generation') !~ '^[1-9][0-9]{0,15}$'
181
+ or (v_change ->> 'confirmed_generation')::bigint > 9007199254740991 then
182
+ raise exception 'claims_malformed' using errcode = '22023';
183
+ end if;
184
+ v_change_gen := (v_change ->> 'confirmed_generation')::bigint;
185
+ end if;
186
+
187
+ if v_slot is not null and v_slot !~ '^https://[^/?#@[:space:]]+(/[^?#[:space:]]*)?/s/[A-Za-z0-9_-]{22,64}$' then
188
+ raise exception 'claims_malformed' using errcode = '22023';
189
+ end if;
190
+
191
+ -- A token the Portal itself reports revoked is refused, and changes nothing here: only a
192
+ -- Portal revocation (tombstone_delegate_instance) tombstones.
193
+ if v_claim ->> 'status' <> 'active' then
194
+ raise exception 'instance_revoked' using errcode = '42501';
195
+ end if;
196
+
197
+ -- THE OWNER LOCK, before anything else. A transaction-scoped advisory lock keyed by the
198
+ -- owner's identity serialises every exchange of one owner: it is what serialises D13's count
199
+ -- below, and tombstone_delegate_instance takes it too, in the same position, so the order is
200
+ -- always: owner lock, then the owner's auth.users row, then the instance row. (HOSTED #591: "the
201
+ -- owner's auth.users row" in that order is the exchange's alone; the tombstone takes the owner
202
+ -- lock and then the instance row, and no auth.users row: section 3.) It was first added for the
203
+ -- owner-provisioning race (two first exchanges each creating an auth user for
204
+ -- one owner, measured by hosted_delegate_first_exchange_race); that class is now closed for
205
+ -- every caller inside _ensure_agent_auth_user itself (section 5), which this lock alone could
206
+ -- not do: a first human enroll never takes it.
207
+ --
208
+ -- THE OWNER, derived exactly as enroll_federated_human derives the human's auth user, from
209
+ -- the verified issuer and subject. The raw sub is never compared for identity.
210
+ -- HOSTED #591: it IS stored, on the instance row, as revocation evidence (section 1).
211
+ v_owner_slug := agent_board._federated_identity_slug(v_iss, v_sub);
212
+ perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('agent_board.delegate_owner:' || v_owner_slug, 0));
213
+ v_owner := agent_board._ensure_agent_auth_user(v_owner_slug);
214
+
215
+ -- THE OWNER MUST BE LIVE (fix N2). enroll_federated_human refuses a durably revoked human
216
+ -- (fleet_human_revocations (iss, sub)) and a non-active human row, and so does this: a
217
+ -- revoked human whose Portal still signs tokens must not bring up a NEW delegate instance, and
218
+ -- a suspended owner's existing delegates must not keep exchanging. Refused owner_inactive,
219
+ -- before the instance row is touched (so it wins over every instance-level code, including a
220
+ -- tombstone), and nothing is tombstoned.
221
+ --
222
+ -- UNDER THE OWNER'S auth.users ROW LOCK, which revoke_human_subtree takes FOR UPDATE first and
223
+ -- holds while it writes the marker and revokes every agent it then lists. FOR KEY SHARE
224
+ -- conflicts with that and with nothing an ordinary auth.users update takes. Without it an
225
+ -- exchange reads the marker before the revocation commits, and the revocation's agent list
226
+ -- (read once, at its loop) misses the delegate this exchange creates: an active delegate of a
227
+ -- revoked human, or a fresh credential for a delegate the revocation just revoked
228
+ -- (hosted_delegate_owner_revoke_race). revoke_human_subtree never takes the owner advisory
229
+ -- lock or an instance row, so no cycle closes. revoke_agent (suspension) does not take this
230
+ -- row and does not touch delegate credentials; an exchange racing it is simply ordered before
231
+ -- it.
232
+ --
233
+ -- The owner's human row is the agents row whose auth_user_id is the owner uid
234
+ -- (idx_agent_board_agents_auth_user_id_unique: at most one), the row enroll_federated_human
235
+ -- creates. None is not a refusal: an owner the board has never enrolled as a human is allowed
236
+ -- here, as before (it cannot add a delegate to a board without a session of its own).
237
+ perform 1 from auth.users u where u.id = v_owner for key share;
238
+ if exists (
239
+ select 1 from agent_board.fleet_human_revocations r
240
+ where r.issuer = v_iss and r.subject = v_sub
241
+ )
242
+ or exists (
243
+ select 1 from agent_board.agents a
244
+ where a.auth_user_id = v_owner and a.status <> 'active'
245
+ ) then
246
+ raise exception 'owner_inactive' using errcode = '42501';
247
+ end if;
248
+
249
+ -- INSERT-THEN-LOCK. A bare `select ... for update` locks nothing when the row does not yet
250
+ -- exist, so two first exchanges would both create an agent. Inserting first makes the
251
+ -- second block on the primary key until the first commits; both then lock the same row.
252
+ -- HOSTED #591: the owner's verified (iss, sub), recorded with the row (section 1).
253
+ insert into agent_board.delegate_instances(instance_id, owner_user_id, owner_issuer, owner_subject)
254
+ values (v_instance_id, v_owner, v_iss, v_sub)
255
+ on conflict (instance_id) do nothing;
256
+
257
+ select * into v_row
258
+ from agent_board.delegate_instances di
259
+ where di.instance_id = v_instance_id
260
+ for update;
261
+
262
+ -- A tombstone is permanent, whatever else the token says.
263
+ if v_row.tombstoned_at is not null then
264
+ raise exception 'instance_revoked' using errcode = '42501';
265
+ end if;
266
+ if v_row.owner_user_id is distinct from v_owner then
267
+ raise exception 'owner_mismatch' using errcode = '42501';
268
+ end if;
269
+
270
+ -- FIRST EXCHANGE is decided UNDER the lock, by whether the row has an agent yet -- never by
271
+ -- whether this transaction's insert happened: the loser of two concurrent first exchanges
272
+ -- finds the winner's agent after it blocks, and takes the ordinary path.
273
+ v_first := v_row.agent_id is null;
274
+
275
+ if not v_first then
276
+ -- HOSTED #591: FOR KEY SHARE on the delegate's agents row, BEFORE any credential. This is the
277
+ -- lock the credential insert below takes anyway (its foreign key), moved ahead of the
278
+ -- credential UPDATE that revokes lower generations. Taken after the credentials, it made an
279
+ -- exchange that supersedes a generation deadlock against revoke_agent_subtree of the same
280
+ -- delegate, which takes the agents row FOR UPDATE and then the credentials
281
+ -- (hosted_delegate_exchange_agent_revoke_race). It conflicts with nothing it did not already
282
+ -- conflict with.
283
+ select a.status into v_status from agent_board.agents a where a.id = v_row.agent_id for key share;
284
+ if v_status is distinct from 'active' then
285
+ -- Revoked by revoke_agent_subtree or revoke_human_subtree, or suspended. Refused, and
286
+ -- NOT tombstoned: only a Portal revocation tombstones.
287
+ raise exception 'agent_inactive' using errcode = '42501';
288
+ end if;
289
+ else
290
+ -- D13: at most 20 live instances per owner. The owner lock taken at the top serialises two
291
+ -- first exchanges of DIFFERENT instances of one owner (their instance rows are different
292
+ -- rows), so the count cannot be passed twice.
293
+ select count(*) into v_live
294
+ from agent_board.delegate_instances di
295
+ where di.owner_user_id = v_owner
296
+ and di.tombstoned_at is null
297
+ and di.instance_id <> v_instance_id;
298
+ if v_live >= 20 then
299
+ raise exception 'instance_cap' using errcode = '42501';
300
+ end if;
301
+ end if;
302
+
303
+ -- THE RESOURCE (D8), S = the recorded slot_resource.
304
+ if not v_first then
305
+ if v_row.slot_resource is not null then
306
+ if v_slot is distinct from v_row.slot_resource then
307
+ raise exception 'resource_mismatch' using errcode = '42501';
308
+ end if;
309
+ elsif v_slot is not null and not (v_generation > v_row.generation) then
310
+ -- The one move, base URL -> slot, needs a new grant (a strictly higher generation).
311
+ raise exception 'resource_mismatch' using errcode = '42501';
312
+ end if;
313
+ end if;
314
+
315
+ -- THE CLIENT AND GENERATION, r = recorded generation, g = the token's, C = the recorded
316
+ -- client_change_generation.
317
+ if not v_first then
318
+ if v_generation < v_row.generation then
319
+ raise exception 'generation_superseded' using errcode = '42501';
320
+ elsif v_generation = v_row.generation then
321
+ if v_grant_id is distinct from v_row.grant_id then
322
+ raise exception 'grant_conflict' using errcode = '42501';
323
+ end if;
324
+ if v_client_id is distinct from v_row.client_id
325
+ or v_change_gen is distinct from v_row.client_change_generation then
326
+ raise exception 'client_mismatch' using errcode = '42501';
327
+ end if;
328
+ elsif v_client_id is distinct from v_row.client_id then
329
+ -- g > r from another client: only with the owner's confirmation of a client change made
330
+ -- AFTER the last binding the board saw (r < confirmed_generation <= g). from_client_id
331
+ -- is recorded for audit but not compared: the board may have missed an intermediate
332
+ -- reconnect.
333
+ if v_change_gen is null
334
+ or not (v_row.generation < v_change_gen and v_change_gen <= v_generation) then
335
+ raise exception 'client_mismatch' using errcode = '42501';
336
+ end if;
337
+ end if;
338
+ end if;
339
+
340
+ -- FIRST USE: the instance's own auth user and agents row. One auth user per instance.
341
+ if v_first then
342
+ v_slug := agent_board._delegate_instance_slug(v_iss, v_instance_id);
343
+ -- A second agent on this auth user is impossible: idx_agent_board_agents_auth_user_id_unique
344
+ -- refuses the insert (23505), and the whole exchange with it.
345
+ v_auth := agent_board._ensure_agent_auth_user(v_slug);
346
+ insert into agent_board.agents(
347
+ agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id, is_delegate, metadata)
348
+ values (
349
+ 'agent', 'agent', v_slug, 'Hosted assistant ' || left(v_instance_id, 8), 'active',
350
+ v_auth, v_owner, true,
351
+ jsonb_build_object('hosted_delegate', jsonb_build_object('instance_id', v_instance_id)))
352
+ returning id into v_agent;
353
+ else
354
+ v_agent := v_row.agent_id;
355
+ select a.auth_user_id into v_auth from agent_board.agents a where a.id = v_agent;
356
+ end if;
357
+
358
+ begin
359
+ update agent_board.delegate_instances di
360
+ set agent_id = v_agent,
361
+ client_id = v_client_id,
362
+ grant_id = v_grant_id,
363
+ generation = v_generation,
364
+ slot_resource = coalesce(di.slot_resource, v_slot),
365
+ client_change_generation = v_change_gen,
366
+ -- HOSTED #591: fills a legacy (pre-1.59.0) row's null pair; a recorded pair is kept.
367
+ -- Sound because owner_mismatch above proved this row's uid is the one (iss, sub)
368
+ -- derives.
369
+ owner_issuer = coalesce(di.owner_issuer, v_iss),
370
+ owner_subject = coalesce(di.owner_subject, v_sub),
371
+ updated_at = now()
372
+ where di.instance_id = v_instance_id;
373
+ exception when unique_violation then
374
+ -- D8: a slot belongs to one instance. Raising aborts the whole call, so nothing in this
375
+ -- transaction is kept.
376
+ raise exception 'slot_in_use' using errcode = '42501';
377
+ end;
378
+
379
+ -- A higher generation supersedes every lower one, in this transaction.
380
+ if not v_first and v_generation > v_row.generation then
381
+ update agent_board.agent_credentials ac
382
+ set revoked_at = now(),
383
+ updated_at = now(),
384
+ metadata = coalesce(ac.metadata, '{}'::jsonb)
385
+ || jsonb_build_object('revocationReason', 'generation_superseded', 'supersededByGeneration', v_generation)
386
+ where ac.agent_id = v_agent
387
+ and ac.revoked_at is null
388
+ and coalesce(ac.generation, 0) < v_generation;
389
+ end if;
390
+
391
+ -- The board credential row. No refresh token exists for it: the hash is random and never
392
+ -- disclosed, so the refresh path can never match it. expires_at <= now() + 900 s, and the
393
+ -- Edge Function mints exp = min(iat + 900, expires_at).
394
+ v_expires := now() + interval '900 seconds';
395
+ insert into agent_board.agent_credentials(
396
+ agent_id, refresh_token_hash, hash_version, credential_label, expires_at, metadata, grant_id, generation)
397
+ values (
398
+ v_agent,
399
+ extensions.digest(extensions.gen_random_bytes(32), 'sha256'),
400
+ 1,
401
+ 'hosted-delegate',
402
+ v_expires,
403
+ jsonb_build_object(
404
+ 'source', 'exchange_delegate_credential',
405
+ 'instance_id', v_instance_id,
406
+ 'client_id', v_client_id,
407
+ 'client_change', coalesce(v_change, 'null'::jsonb)),
408
+ v_grant_id,
409
+ v_generation)
410
+ returning id into v_credential;
411
+
412
+ return query select v_agent, v_auth, v_credential, v_expires;
413
+ end;
414
+ $fn$;
415
+
416
+ revoke all on function agent_board.exchange_delegate_credential(jsonb) from public, anon, authenticated;
417
+ grant execute on function agent_board.exchange_delegate_credential(jsonb) to service_role;
418
+
419
+ comment on function agent_board.exchange_delegate_credential(jsonb) is
420
+ '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. Records the owner''s verified (iss, sub) on the instance as revocation evidence (AB15; filled once on a legacy row, never changed). 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).';
421
+
422
+ -- =============================================================================
423
+ -- 3. tombstone_delegate_instance -- records (iss, sub); owner row and agent row first (#591
424
+ -- items 4 and 8)
425
+ -- =============================================================================
426
+ create or replace function agent_board.tombstone_delegate_instance(
427
+ p_instance_id text, p_issuer text, p_owner_sub text, p_reason text)
428
+ returns table(instance_id text, tombstoned_at timestamptz, first boolean)
429
+ language plpgsql
430
+ security definer
431
+ set search_path = ''
432
+ as $fn$
433
+ #variable_conflict use_column
434
+ declare
435
+ v_owner uuid;
436
+ v_owner_slug text;
437
+ v_inserted boolean;
438
+ v_row agent_board.delegate_instances%rowtype;
439
+ v_board uuid;
440
+ begin
441
+ if not agent_board._is_service_role() then
442
+ raise exception 'tombstone_delegate_instance is service_role-only' using errcode = '42501';
443
+ end if;
444
+ if p_instance_id is null or p_instance_id !~ '^[A-Za-z0-9_-]{16,128}$'
445
+ or coalesce(p_issuer, '') = '' or coalesce(p_owner_sub, '') = ''
446
+ or p_reason is null
447
+ or p_reason not in ('owner_revoked', 'owner_retired_after_upgrade', 'owner_account_closed', 'portal_admin') then
448
+ raise exception 'revocation_malformed' using errcode = '22023';
449
+ end if;
450
+
451
+ -- The owner lock, then the owner, exactly as the exchange takes and derives them (same key,
452
+ -- same position: before the instance row), so a revocation and an exchange of a new owner's
453
+ -- instance never provision two auth users for one owner.
454
+ v_owner_slug := agent_board._federated_identity_slug(p_issuer, p_owner_sub);
455
+ perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('agent_board.delegate_owner:' || v_owner_slug, 0));
456
+ v_owner := agent_board._ensure_agent_auth_user(v_owner_slug);
457
+
458
+ -- HOSTED #591: NO auth.users ROW LOCK here, deliberately, unlike the exchange. This function
459
+ -- reaches boards (update_board_membership below writes board_membership_events, whose foreign
460
+ -- key takes FOR KEY SHARE on the board), and every route that locks both a board and an owner
461
+ -- row must take the board first (agent_board_federated_lock_order_class EC-3):
462
+ -- enroll_federated_human holds its default board FOR UPDATE and then wants the owner's row FOR
463
+ -- UPDATE, so an owner-row lock taken here first closes a cycle with it. The deadlock against
464
+ -- revoke_human_subtree (#591 item 8) is closed by the agents-row order below instead
465
+ -- (hosted_delegate_tombstone_revoke_race P1, P2). What the owner-row lock would have added is
466
+ -- stated in AB15: see revoke_human_subtree (section 4).
467
+
468
+ -- A revocation BEFORE the first exchange creates the tombstoned row, so the first exchange
469
+ -- is refused. Insert-then-lock, as the exchange does, so a concurrent exchange serialises.
470
+ -- HOSTED #591: with the owner's verified (iss, sub) (section 1).
471
+ insert into agent_board.delegate_instances as di(
472
+ instance_id, owner_user_id, owner_issuer, owner_subject, tombstoned_at, tombstone_reason)
473
+ values (p_instance_id, v_owner, p_issuer, p_owner_sub, now(), p_reason)
474
+ on conflict (instance_id) do nothing
475
+ returning true into v_inserted;
476
+
477
+ if v_inserted then
478
+ return query select p_instance_id, now(), true;
479
+ return;
480
+ end if;
481
+
482
+ select * into v_row
483
+ from agent_board.delegate_instances di
484
+ where di.instance_id = p_instance_id
485
+ for update;
486
+
487
+ if v_row.owner_user_id is distinct from v_owner then
488
+ raise exception 'owner_mismatch' using errcode = '42501';
489
+ end if;
490
+
491
+ -- Idempotent: the key is the instance, and a tombstone is permanent.
492
+ if v_row.tombstoned_at is not null then
493
+ -- HOSTED #591: a legacy (pre-1.59.0) tombstoned row has no owner pair, and nothing else ever
494
+ -- writes it again: the exchange refuses it (instance_revoked) before its UPDATE. So a repeated
495
+ -- revocation fills the pair here, and nothing else about the row changes. Sound for the same
496
+ -- reason as the fill below: owner_mismatch above proved this row's uid is the one
497
+ -- (p_issuer, p_owner_sub) derives. A recorded pair is never touched (the trigger refuses).
498
+ if v_row.owner_issuer is null then
499
+ update agent_board.delegate_instances di
500
+ set owner_issuer = p_issuer,
501
+ owner_subject = p_owner_sub
502
+ where di.instance_id = p_instance_id;
503
+ end if;
504
+ return query select v_row.instance_id, v_row.tombstoned_at, false;
505
+ return;
506
+ end if;
507
+
508
+ update agent_board.delegate_instances di
509
+ set tombstoned_at = now(),
510
+ tombstone_reason = p_reason,
511
+ -- HOSTED #591: fills a legacy row's null pair; sound because owner_mismatch above
512
+ -- proved this row's uid is the one (p_issuer, p_owner_sub) derives.
513
+ owner_issuer = coalesce(di.owner_issuer, p_issuer),
514
+ owner_subject = coalesce(di.owner_subject, p_owner_sub),
515
+ updated_at = now()
516
+ where di.instance_id = p_instance_id;
517
+
518
+ if v_row.agent_id is not null then
519
+ -- HOSTED #591 (item 8): THE DELEGATE'S agents ROW FIRST, before any credential. Every other
520
+ -- writer of a delegate's agents row and its credentials takes the agents row first
521
+ -- (_revoke_agent_principal, for revoke_agent_subtree and revoke_human_subtree: FOR UPDATE,
522
+ -- then credentials; revoke_agent: its UPDATE, then credentials). This UPDATE used to come
523
+ -- last, after the credentials and the memberships, so either subtree revoker interleaved
524
+ -- with a tombstone of the same delegate deadlocked (hosted_delegate_tombstone_revoke_race
525
+ -- P1, P2). It is the same statement and the same FOR NO KEY UPDATE lock the 1.58.0 function
526
+ -- took, moved ahead; it adds no lock. FOR NO KEY UPDATE does not block the FOR KEY SHARE of a
527
+ -- foreign-key check, so add_delegate_to_board's sink insert is not newly blocked (the two
528
+ -- already serialise on the instance row above). An agent already revoked and committed is
529
+ -- not matched and not locked; its revoker has finished, so there is nothing to order against.
530
+ update agent_board.agents a
531
+ set status = 'revoked', updated_at = now()
532
+ where a.id = v_row.agent_id
533
+ and a.status <> 'revoked';
534
+
535
+ update agent_board.agent_credentials ac
536
+ set revoked_at = coalesce(ac.revoked_at, now()),
537
+ updated_at = now(),
538
+ metadata = coalesce(ac.metadata, '{}'::jsonb)
539
+ || jsonb_build_object('revocationReason', 'instance_tombstoned', 'tombstoneReason', p_reason)
540
+ where ac.agent_id = v_row.agent_id
541
+ and ac.revoked_at is null;
542
+
543
+ -- Every active membership ends through update_board_membership(..., 'left') under
544
+ -- service_role, so each ends with an append-only operator_removal event.
545
+ for v_board in
546
+ select bm.board_id
547
+ from agent_board.board_memberships bm
548
+ where bm.agent_id = v_row.agent_id
549
+ and bm.membership_state in ('joined', 'subscribed')
550
+ order by bm.board_id
551
+ loop
552
+ perform agent_board.update_board_membership(v_board, v_row.agent_id, 'left');
553
+ end loop;
554
+ end if;
555
+
556
+ return query select p_instance_id, now(), true;
557
+ end;
558
+ $fn$;
559
+
560
+ revoke all on function agent_board.tombstone_delegate_instance(text, text, text, text) from public, anon, authenticated;
561
+ grant execute on function agent_board.tombstone_delegate_instance(text, text, text, text) to service_role;
562
+
563
+ comment on function agent_board.tombstone_delegate_instance(text, text, text, text) is
564
+ '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. Lock order (AB15): the owner advisory lock, the instance row FOR UPDATE, the delegate''s agents row (its status UPDATE, FOR NO KEY UPDATE), then credentials and memberships; no auth.users row lock, because it reaches boards (board before owner, EC-3). Records the owner''s verified (iss, sub) on the instance as revocation evidence, filling a legacy row''s null pair on any call, including a repeat of an existing tombstone (which otherwise changes nothing). 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.';
565
+
566
+ -- =============================================================================
567
+ -- 4. revoke_human_subtree -- a marker from every recorded delegate owner identity (#591 item 4)
568
+ -- =============================================================================
569
+ create or replace function agent_board.revoke_human_subtree(
570
+ p_owner_user_id uuid,
571
+ p_revoked_by_agent_id uuid,
572
+ p_reason text default 'human revocation'
573
+ )
574
+ returns table(revoked_agent_count integer, revoked_key_count integer)
575
+ language plpgsql
576
+ security definer
577
+ set search_path = agent_board, public
578
+ as $$
579
+ declare
580
+ v_agent uuid;
581
+ v_agent_count integer := 0;
582
+ v_key_count integer := 0;
583
+ begin
584
+ if not agent_board._is_service_role() then
585
+ raise exception 'revoke_human_subtree is service_role-only' using errcode = '42501';
586
+ end if;
587
+
588
+ perform 1 from auth.users u where u.id = p_owner_user_id for update;
589
+ if not found then
590
+ raise exception 'human owner not found' using errcode = 'P0002';
591
+ end if;
592
+
593
+ if exists (
594
+ select 1
595
+ from agent_board.agents a
596
+ where a.owner_user_id = p_owner_user_id
597
+ and jsonb_typeof(a.metadata -> 'federation') = 'object'
598
+ and not exists (
599
+ select 1
600
+ from agent_board.enrollment_federation_audit e
601
+ where e.agent_id = a.id
602
+ and nullif(btrim(e.issuer), '') is not null
603
+ and nullif(btrim(e.subject), '') is not null
604
+ )
605
+ ) then
606
+ raise exception 'stable federation evidence not found for every linked agent' using errcode = 'P0002';
607
+ end if;
608
+
609
+ -- HOSTED #591 (item 4): the markers come from TWO kinds of verified evidence: the federated
610
+ -- enrollment audit of an agent the human owns, and the owner (iss, sub) a hosted delegate
611
+ -- instance of this human recorded from the Portal token that created or last wrote it. An
612
+ -- owner whose only principals are hosted delegates has none of the first, and before this
613
+ -- got no marker, so a fresh first exchange passed owner_inactive after the revocation. The
614
+ -- instance rows are read unlocked. An exchange that creates one holds this owner's auth.users
615
+ -- row FOR KEY SHARE, which the FOR UPDATE above excludes. A tombstone does not (it reaches
616
+ -- boards, so it may not lock the owner first: section 3), so a pre-exchange tombstone that
617
+ -- commits after this read records a pair this revocation does not see. That misses a marker
618
+ -- only when nothing else gives one: the owner has no audit row, no human agents row (which
619
+ -- the loop below revokes, and which the exchange's owner_inactive reads), and no earlier
620
+ -- instance row with a pair -- i.e. its only evidence is pre-1.59.0 (AB15). A legacy instance
621
+ -- with no recorded pair gives nothing, and does NOT gate the revocation as missing audit
622
+ -- evidence does: refusing would leave that owner unrevocable, since only an exchange or a
623
+ -- tombstone call can fill the pair (AB15). A pair filled after this revocation commits marks
624
+ -- nothing: markers are written here only.
625
+ insert into agent_board.fleet_human_revocations(
626
+ issuer, subject, owner_user_id, revoked_by_agent_id, revocation_reason
627
+ )
628
+ select distinct
629
+ ev.issuer,
630
+ ev.subject,
631
+ p_owner_user_id, p_revoked_by_agent_id, p_reason
632
+ from (
633
+ select e.issuer, e.subject
634
+ from agent_board.enrollment_federation_audit e
635
+ join agent_board.agents a on a.id = e.agent_id
636
+ where a.owner_user_id = p_owner_user_id
637
+ and nullif(btrim(e.issuer), '') is not null
638
+ and nullif(btrim(e.subject), '') is not null
639
+ union all
640
+ select di.owner_issuer, di.owner_subject
641
+ from agent_board.delegate_instances di
642
+ where di.owner_user_id = p_owner_user_id
643
+ and di.owner_issuer is not null
644
+ ) ev
645
+ on conflict (issuer, subject) do nothing;
646
+
647
+ for v_agent in
648
+ select id
649
+ from agent_board.agents
650
+ where owner_user_id = p_owner_user_id
651
+ order by id
652
+ loop
653
+ v_key_count := v_key_count
654
+ + agent_board._revoke_agent_principal(v_agent, p_revoked_by_agent_id, p_reason);
655
+ v_agent_count := v_agent_count + 1;
656
+ end loop;
657
+
658
+ return query select v_agent_count, v_key_count;
659
+ end;
660
+ $$;
661
+
662
+ comment on function agent_board.revoke_human_subtree(uuid, uuid, text) is
663
+ 'TOTAL human revocation. Locks the human owner; requires append-only stable federation evidence for every federation-linked owned agent before any mutation; derives durable issuer/subject markers from that audit evidence and from the owner (iss, sub) each of the human''s hosted delegate instances recorded (HOSTED #591, AB15; a legacy instance with none gives no marker and does not gate); then visits every owned agent in UUID order before the agent-before-key primitive. service_role-only.';
664
+ revoke all on function agent_board.revoke_human_subtree(uuid, uuid, text) from public, anon, authenticated;
665
+ grant execute on function agent_board.revoke_human_subtree(uuid, uuid, text) to service_role;