@consiliency/agent-board-schema 1.22.0 → 1.23.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 (60) hide show
  1. package/fixtures/already-applied.json +36 -0
  2. package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
  3. package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
  4. package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
  5. package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
  6. package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
  7. package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
  8. package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
  9. package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
  10. package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
  11. package/package.json +1 -1
  12. package/schema-manifest.json +84 -3
  13. package/src/tooling.test.mjs +2 -2
  14. package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
  15. package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
  16. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
  17. package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
  18. package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
  19. package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
  20. package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
  21. package/tests/agent_board_fedsession.test.sql +46 -2
  22. package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
  23. package/tests/agent_board_inbox_reads.test.sql +16 -19
  24. package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
  25. package/tests/agent_board_portal_offline_invariant.test.sql +1 -1
  26. package/tests/agent_board_roomlink.test.sql +2 -2
  27. package/tests/agent_board_wake_recovery.test.sql +756 -0
  28. package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
  29. package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
  30. package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
  31. package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
  32. package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
  33. package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
  34. package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
  35. package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
  36. package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
  37. package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
  38. package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
  39. package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
  40. package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
  41. package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
  42. package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
  43. package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
  44. package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
  45. package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
  46. package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
  47. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
  48. package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
  49. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
  50. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
  51. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
  52. package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
  53. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
  54. package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
  55. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
  56. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
  57. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
  58. package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
  59. package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
  60. package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
@@ -0,0 +1,1496 @@
1
+ -- Consiliency/message-board#162: atomic, bounded fleet onboarding.
2
+ --
3
+ -- A fleet invite is not merely a ROOMLINK bearer. Its immutable server-side
4
+ -- bounds are creator-authorized at mint time and the service-role-only composed
5
+ -- RPC validates those exact bytes before creating any identity or fleet row.
6
+
7
+ create table agent_board.fleet_onboarding_invite_bounds (
8
+ invite_id uuid primary key references agent_board.board_invite_tokens(id) on delete cascade,
9
+ machine_id uuid not null references agent_board.fleet_machines(id) on delete restrict,
10
+ harness_kind text not null,
11
+ harness_label text not null,
12
+ repo_slug text not null,
13
+ repo_fingerprint text not null,
14
+ repo_scope_role text not null,
15
+ agent_role text not null,
16
+ expected_agent_handle text not null,
17
+ created_by_agent_id uuid not null references agent_board.agents(id) on delete restrict,
18
+ created_at timestamptz not null default now(),
19
+ constraint fleet_onboarding_bounds_harness_kind_shape check (
20
+ harness_kind = lower(harness_kind)
21
+ and harness_kind = btrim(harness_kind)
22
+ and length(harness_kind) between 1 and 64
23
+ and harness_kind ~ '^[a-z0-9][a-z0-9_.-]*$'
24
+ ),
25
+ constraint fleet_onboarding_bounds_text_shape check (
26
+ harness_label = btrim(harness_label) and length(harness_label) between 1 and 128
27
+ and repo_slug = btrim(repo_slug) and length(repo_slug) between 1 and 255
28
+ and repo_fingerprint = btrim(repo_fingerprint) and length(repo_fingerprint) between 1 and 255
29
+ and expected_agent_handle = btrim(expected_agent_handle) and length(expected_agent_handle) between 1 and 255
30
+ ),
31
+ constraint fleet_onboarding_bounds_role_pair check (
32
+ (agent_role = 'phase_worker' and repo_scope_role = 'worker')
33
+ or (agent_role = 'reviewer' and repo_scope_role = 'reviewer')
34
+ )
35
+ );
36
+
37
+ comment on table agent_board.fleet_onboarding_invite_bounds is
38
+ 'Immutable creator-authorized bounds for atomic fleet onboarding. A bounded invite is reserved from generic ROOMLINK redemption and legacy enrollment.';
39
+
40
+ alter table agent_board.fleet_onboarding_invite_bounds enable row level security;
41
+ revoke all on agent_board.fleet_onboarding_invite_bounds from public, anon, authenticated;
42
+ grant select on agent_board.fleet_onboarding_invite_bounds to service_role;
43
+
44
+ create table agent_board.fleet_human_revocations (
45
+ issuer text not null,
46
+ subject text not null,
47
+ owner_user_id uuid references auth.users(id) on delete set null,
48
+ revoked_by_agent_id uuid not null references agent_board.agents(id) on delete restrict,
49
+ revocation_reason text not null,
50
+ effective_at timestamptz not null default now(),
51
+ primary key (issuer, subject)
52
+ );
53
+
54
+ comment on table agent_board.fleet_human_revocations is
55
+ 'Durable total-revocation marker keyed by stable federation principal. The optional owner UUID may be nulled by auth-user deletion without erasing the marker.';
56
+
57
+ alter table agent_board.fleet_human_revocations enable row level security;
58
+ revoke all on agent_board.fleet_human_revocations from public, anon, authenticated;
59
+ grant select on agent_board.fleet_human_revocations to service_role;
60
+
61
+ create function agent_board._protect_federation_contract()
62
+ returns trigger
63
+ language plpgsql
64
+ set search_path = agent_board, public
65
+ as $$
66
+ begin
67
+ if not agent_board._is_service_role()
68
+ and new.metadata #> '{federation}' is distinct from old.metadata #> '{federation}' then
69
+ raise exception 'authenticated agents may not mutate reserved federation metadata' using errcode = '42501';
70
+ end if;
71
+ if not agent_board._is_service_role()
72
+ and old.metadata #> '{federation,replay}' is not null
73
+ and new.handle is distinct from old.handle then
74
+ raise exception 'federated replay handle is immutable' using errcode = '42501';
75
+ end if;
76
+ return new;
77
+ end;
78
+ $$;
79
+
80
+ create trigger agents_protect_federation_contract
81
+ before update of handle, metadata on agent_board.agents
82
+ for each row execute function agent_board._protect_federation_contract();
83
+
84
+ revoke all on function agent_board._protect_federation_contract() from public, anon, authenticated;
85
+
86
+ create function agent_board._fleet_onboarding_bounds_immutable()
87
+ returns trigger
88
+ language plpgsql
89
+ set search_path = agent_board, public
90
+ as $$
91
+ begin
92
+ raise exception 'fleet onboarding invite bounds are immutable' using errcode = '55000';
93
+ end;
94
+ $$;
95
+
96
+ create trigger fleet_onboarding_invite_bounds_immutable
97
+ before update on agent_board.fleet_onboarding_invite_bounds
98
+ for each row execute function agent_board._fleet_onboarding_bounds_immutable();
99
+
100
+ revoke all on function agent_board._fleet_onboarding_bounds_immutable() from public, anon, authenticated;
101
+
102
+ create function agent_board.create_fleet_onboarding_invite(
103
+ p_board_id uuid,
104
+ p_machine_id uuid,
105
+ p_harness_kind text,
106
+ p_harness_label text,
107
+ p_repo_slug text,
108
+ p_repo_fingerprint text,
109
+ p_repo_scope_role text,
110
+ p_agent_role text,
111
+ p_expected_agent_handle text,
112
+ p_max_uses int default 1,
113
+ p_expires_at timestamptz default null,
114
+ p_created_by_agent_id uuid default null,
115
+ p_metadata jsonb default '{}'::jsonb
116
+ )
117
+ returns table(
118
+ invite_id uuid,
119
+ board_id uuid,
120
+ code text,
121
+ preset_role text,
122
+ preset_membership_state text,
123
+ max_uses int,
124
+ uses_count int,
125
+ expires_at timestamptz,
126
+ created_at timestamptz,
127
+ machine_id uuid,
128
+ harness_kind text,
129
+ harness_label text,
130
+ repo_slug text,
131
+ repo_fingerprint text,
132
+ repo_scope_role text,
133
+ agent_role text,
134
+ expected_agent_handle text
135
+ )
136
+ language plpgsql
137
+ security definer
138
+ set search_path = agent_board, public
139
+ as $$
140
+ #variable_conflict use_column
141
+ declare
142
+ v_creator uuid;
143
+ v_board agent_board.boards%rowtype;
144
+ v_machine agent_board.fleet_machines%rowtype;
145
+ v_domain agent_board.fleet_trust_domains%rowtype;
146
+ v_inv record;
147
+ v_kind text := lower(btrim(coalesce(p_harness_kind, '')));
148
+ v_label text := btrim(coalesce(p_harness_label, ''));
149
+ v_repo_slug text := btrim(coalesce(p_repo_slug, ''));
150
+ v_repo_fingerprint text := btrim(coalesce(p_repo_fingerprint, ''));
151
+ v_expected_handle text := btrim(coalesce(p_expected_agent_handle, ''));
152
+ begin
153
+ v_creator := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
154
+
155
+ if p_board_id is null or p_machine_id is null then
156
+ raise exception 'board and machine are required' using errcode = '22023';
157
+ end if;
158
+ if v_kind !~ '^[a-z0-9][a-z0-9_.-]{0,63}$'
159
+ or length(v_label) not between 1 and 128
160
+ or length(v_repo_slug) not between 1 and 255
161
+ or length(v_repo_fingerprint) not between 1 and 255
162
+ or length(v_expected_handle) not between 1 and 255 then
163
+ raise exception 'invalid fleet onboarding bounds' using errcode = '22023';
164
+ end if;
165
+ if not ((p_agent_role = 'phase_worker' and p_repo_scope_role = 'worker')
166
+ or (p_agent_role = 'reviewer' and p_repo_scope_role = 'reviewer')) then
167
+ raise exception 'invalid fleet onboarding role pair' using errcode = '22023';
168
+ end if;
169
+ if p_max_uses is null or p_max_uses < 1 then
170
+ raise exception 'fleet onboarding invites require a positive use cap' using errcode = '22023';
171
+ end if;
172
+
173
+ select * into v_board
174
+ from agent_board.boards b
175
+ where b.id = p_board_id
176
+ for update;
177
+ if not found or v_board.status <> 'active' then
178
+ raise exception 'fleet onboarding board is not usable' using errcode = '42501';
179
+ end if;
180
+
181
+ select * into v_machine
182
+ from agent_board.fleet_machines fm
183
+ where fm.id = p_machine_id
184
+ for update;
185
+ if not found or v_machine.owner_agent_id <> v_creator or v_machine.lifecycle_state <> 'active' then
186
+ raise exception 'fleet onboarding machine is not usable' using errcode = '42501';
187
+ end if;
188
+
189
+ select * into v_domain
190
+ from agent_board.fleet_trust_domains td
191
+ where td.id = v_machine.trust_domain_id
192
+ for update;
193
+ if not found or v_domain.owner_agent_id <> v_creator or v_domain.lifecycle_state <> 'active' then
194
+ raise exception 'fleet onboarding trust domain is not usable' using errcode = '42501';
195
+ end if;
196
+
197
+ select * into v_inv
198
+ from agent_board.create_invite_link(
199
+ p_board_id, 'member', 'joined', p_max_uses, p_expires_at,
200
+ v_creator, coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('purpose', 'fleet_onboarding.v1')
201
+ );
202
+
203
+ insert into agent_board.fleet_onboarding_invite_bounds(
204
+ invite_id, machine_id, harness_kind, harness_label, repo_slug,
205
+ repo_fingerprint, repo_scope_role, agent_role, expected_agent_handle,
206
+ created_by_agent_id
207
+ ) values (
208
+ v_inv.invite_id, p_machine_id, v_kind, v_label, v_repo_slug,
209
+ v_repo_fingerprint, p_repo_scope_role, p_agent_role, v_expected_handle,
210
+ v_creator
211
+ );
212
+
213
+ return query select
214
+ v_inv.invite_id, v_inv.board_id, v_inv.code, v_inv.preset_role,
215
+ v_inv.preset_membership_state, v_inv.max_uses, v_inv.uses_count,
216
+ v_inv.expires_at, v_inv.created_at, p_machine_id, v_kind, v_label,
217
+ v_repo_slug, v_repo_fingerprint, p_repo_scope_role, p_agent_role,
218
+ v_expected_handle;
219
+ end;
220
+ $$;
221
+
222
+ comment on function agent_board.create_fleet_onboarding_invite(uuid, uuid, text, text, text, text, text, text, text, int, timestamptz, uuid, jsonb) is
223
+ 'Mints a member-only ROOMLINK and immutable creator-owned machine/harness/repo/role/handle bounds in one transaction. The bearer code is returned only to the authorized creator.';
224
+ revoke all on function agent_board.create_fleet_onboarding_invite(uuid, uuid, text, text, text, text, text, text, text, int, timestamptz, uuid, jsonb) from public, anon;
225
+ grant execute on function agent_board.create_fleet_onboarding_invite(uuid, uuid, text, text, text, text, text, text, text, int, timestamptz, uuid, jsonb) to authenticated, service_role;
226
+
227
+ -- Keep the released ROOMLINK core byte-for-byte and put class selection in two
228
+ -- non-interchangeable wrappers. The renamed primitive remains service-role-only.
229
+ alter function agent_board._redeem_invite_for_agent(text, uuid)
230
+ rename to _redeem_unbounded_invite_for_agent;
231
+ revoke all on function agent_board._redeem_unbounded_invite_for_agent(text, uuid) from public, anon, authenticated;
232
+ grant execute on function agent_board._redeem_unbounded_invite_for_agent(text, uuid) to service_role;
233
+
234
+ create function agent_board._redeem_invite_for_agent(p_code text, p_agent_id uuid)
235
+ returns table(board_id uuid, agent_id uuid, role text, membership_state text, updated_at timestamptz)
236
+ language plpgsql
237
+ security definer
238
+ set search_path = agent_board, public
239
+ as $$
240
+ begin
241
+ if exists (
242
+ select 1
243
+ from agent_board.board_invite_tokens bit
244
+ join agent_board.fleet_onboarding_invite_bounds b on b.invite_id = bit.id
245
+ where bit.code = p_code
246
+ ) then
247
+ raise exception 'invite is reserved for atomic fleet onboarding' using errcode = '42501';
248
+ end if;
249
+ return query select * from agent_board._redeem_unbounded_invite_for_agent(p_code, p_agent_id);
250
+ end;
251
+ $$;
252
+
253
+ create function agent_board._redeem_fleet_invite_for_agent(p_code text, p_agent_id uuid)
254
+ returns table(board_id uuid, agent_id uuid, role text, membership_state text, updated_at timestamptz)
255
+ language plpgsql
256
+ security definer
257
+ set search_path = agent_board, public
258
+ as $$
259
+ begin
260
+ if not exists (
261
+ select 1
262
+ from agent_board.board_invite_tokens bit
263
+ join agent_board.fleet_onboarding_invite_bounds b on b.invite_id = bit.id
264
+ where bit.code = p_code
265
+ ) then
266
+ raise exception 'fleet onboarding requires a bounded invite' using errcode = '42501';
267
+ end if;
268
+ return query select * from agent_board._redeem_unbounded_invite_for_agent(p_code, p_agent_id);
269
+ end;
270
+ $$;
271
+
272
+ comment on function agent_board._redeem_invite_for_agent(text, uuid) is
273
+ 'Generic ROOMLINK redemption guard. Fleet-bounded invites are categorically rejected before the released redemption core can write.';
274
+ comment on function agent_board._redeem_fleet_invite_for_agent(text, uuid) is
275
+ 'Fleet-only ROOMLINK redemption guard used exclusively by onboard_federated_fleet_agent.';
276
+ revoke all on function agent_board._redeem_invite_for_agent(text, uuid) from public, anon, authenticated;
277
+ revoke all on function agent_board._redeem_fleet_invite_for_agent(text, uuid) from public, anon, authenticated;
278
+ grant execute on function agent_board._redeem_invite_for_agent(text, uuid) to service_role;
279
+ grant execute on function agent_board._redeem_fleet_invite_for_agent(text, uuid) to service_role;
280
+
281
+ -- Preserve the released enrollment implementation as the one-time creator and
282
+ -- wrap it with a serialized exact-key replay contract.
283
+ alter function agent_board.enroll_federated_identity(
284
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
285
+ rename to _enroll_federated_identity_once;
286
+ revoke all on function agent_board._enroll_federated_identity_once(
287
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
288
+ from public, anon, authenticated;
289
+ grant execute on function agent_board._enroll_federated_identity_once(
290
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
291
+ to service_role;
292
+
293
+ create function agent_board._normalized_federation_audience(p_claim jsonb)
294
+ returns jsonb
295
+ language plpgsql
296
+ immutable
297
+ strict
298
+ set search_path = agent_board, public
299
+ as $$
300
+ declare
301
+ v_aud jsonb := p_claim -> 'aud';
302
+ v_result jsonb;
303
+ begin
304
+ if jsonb_typeof(v_aud) = 'string' then
305
+ if length(btrim(v_aud #>> '{}')) = 0 then return null; end if;
306
+ return jsonb_build_array(v_aud #>> '{}');
307
+ end if;
308
+ if jsonb_typeof(v_aud) <> 'array'
309
+ or jsonb_array_length(v_aud) = 0
310
+ or exists (select 1 from jsonb_array_elements(v_aud) e where jsonb_typeof(e) <> 'string') then
311
+ return null;
312
+ end if;
313
+ select jsonb_agg(value order by value)
314
+ into v_result
315
+ from (select distinct btrim(value) as value from jsonb_array_elements_text(v_aud)) normalized
316
+ where length(value) > 0;
317
+ if jsonb_array_length(v_result) <> jsonb_array_length(v_aud) then return null; end if;
318
+ return v_result;
319
+ end;
320
+ $$;
321
+ revoke all on function agent_board._normalized_federation_audience(jsonb) from public, anon, authenticated;
322
+ grant execute on function agent_board._normalized_federation_audience(jsonb) to service_role;
323
+
324
+ -- Shipped backfill: only rows with a complete, unambiguous legacy federation
325
+ -- record, exactly one proven key, and exactly one immutable audit claim gain replay
326
+ -- authority. Any historically agent-writable replay object is overwritten. Stable
327
+ -- identity and requested scopes come from the audit/key evidence; fields absent
328
+ -- from that append-only record are pinned to least privilege. The original audit
329
+ -- claim remains untouched.
330
+ update agent_board.agents
331
+ set metadata = metadata #- '{federation,replay}'::text[]
332
+ where (metadata #> '{federation}') ? 'replay';
333
+
334
+ with legacy as (
335
+ select a.id,
336
+ e.human_identity_claim,
337
+ e.issuer,
338
+ e.subject,
339
+ e.provider_id,
340
+ e.requested_scopes,
341
+ agent_board._normalized_federation_audience(e.human_identity_claim) as audience
342
+ from agent_board.agents a
343
+ join agent_board.enrollment_federation_audit e on e.agent_id = a.id
344
+ join agent_board.identity_providers ip on ip.id = e.provider_id
345
+ where (select count(*) from agent_board.enrollment_federation_audit ea where ea.agent_id = a.id) = 1
346
+ and (select count(*) from agent_board.agent_signing_keys k where k.agent_id = a.id) = 1
347
+ and e.issuer is not null
348
+ and e.audience is not null
349
+ and e.subject is not null
350
+ and ip.issuer = e.issuer
351
+ and ip.audience = e.audience
352
+ and e.human_identity_claim ->> 'iss' = e.issuer
353
+ and e.human_identity_claim ->> ip.subject_claim = e.subject
354
+ and agent_board._normalized_federation_audience(e.human_identity_claim) ? e.audience
355
+ and exists (
356
+ select 1 from agent_board.agent_signing_keys k
357
+ where k.agent_id = a.id
358
+ and k.status = 'active' and k.proof_state = 'proven'
359
+ and k.fingerprint = a.metadata #>> '{federation,signingKeyFingerprint}'
360
+ )
361
+ )
362
+ update agent_board.agents a
363
+ set metadata = jsonb_set(
364
+ a.metadata,
365
+ '{federation,replay}',
366
+ jsonb_build_object(
367
+ 'issuer', legacy.issuer,
368
+ 'audience', legacy.audience,
369
+ 'subject', legacy.subject,
370
+ 'providerId', legacy.provider_id,
371
+ 'actorSub', 'null'::jsonb,
372
+ 'requestedSlug', a.handle,
373
+ 'agentKind', a.agent_type,
374
+ 'requestedScopes', to_jsonb(coalesce(legacy.requested_scopes, '{}'::text[])),
375
+ 'requestedBoardScopes', '[]'::jsonb
376
+ ),
377
+ true
378
+ )
379
+ from legacy
380
+ where a.id = legacy.id
381
+ and legacy.issuer = a.metadata #>> '{federation,issuer}'
382
+ and legacy.subject = a.metadata #>> '{federation,subject}'
383
+ and legacy.provider_id = a.metadata #>> '{federation,providerId}'
384
+ and legacy.audience ? (a.metadata #>> '{federation,audience}')
385
+ and legacy.requested_scopes is not null;
386
+
387
+ create function agent_board.enroll_federated_identity(
388
+ p_issuer text,
389
+ p_audience text,
390
+ p_subject text,
391
+ p_human_identity_claim jsonb,
392
+ p_slug text,
393
+ p_agent_public_key text,
394
+ p_agent_public_key_format text,
395
+ p_agent_public_key_fingerprint text,
396
+ p_pop_verified boolean,
397
+ p_actor_sub text default null,
398
+ p_agent_kind text default 'agent',
399
+ p_requested_scopes text[] default '{}',
400
+ p_requested_board_scopes text[] default '{}',
401
+ p_invite_code text default null
402
+ )
403
+ returns table(
404
+ agent_id uuid,
405
+ auth_user_id uuid,
406
+ provider_id text,
407
+ signing_key_id uuid,
408
+ granted_scopes text[],
409
+ key_id text,
410
+ key_version int,
411
+ agent_handle text,
412
+ invite_redeemed boolean
413
+ )
414
+ language plpgsql
415
+ security definer
416
+ set search_path = agent_board, public
417
+ as $$
418
+ #variable_conflict use_column
419
+ declare
420
+ v_fingerprint text;
421
+ v_existing record;
422
+ v_created record;
423
+ v_replay jsonb;
424
+ v_invite_redeemed boolean := false;
425
+ v_slug text;
426
+ v_kind text := lower(btrim(coalesce(p_agent_kind, 'agent')));
427
+ v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
428
+ v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
429
+ v_provider agent_board.identity_providers%rowtype;
430
+ v_claim_audience jsonb;
431
+ v_owner_user_id uuid;
432
+ begin
433
+ if not agent_board._is_service_role() then
434
+ raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
435
+ end if;
436
+ if p_pop_verified is not true then
437
+ raise exception 'proof-of-possession was not verified' using errcode = '42501';
438
+ end if;
439
+ if p_subject is null then
440
+ raise exception 'verified subject is required' using errcode = '22023';
441
+ end if;
442
+ if p_human_identity_claim is null then
443
+ raise exception 'human identity claim is required' using errcode = '22023';
444
+ end if;
445
+
446
+ select * into v_provider from agent_board.identity_providers ip
447
+ where ip.issuer = p_issuer and ip.audience = p_audience limit 1;
448
+ if v_provider.id is null then
449
+ raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
450
+ end if;
451
+
452
+ v_claim_audience := agent_board._normalized_federation_audience(p_human_identity_claim);
453
+ if p_human_identity_claim ->> 'iss' is distinct from p_issuer
454
+ or p_human_identity_claim ->> v_provider.subject_claim is distinct from p_subject
455
+ or v_claim_audience is null
456
+ or not (v_claim_audience ? p_audience) then
457
+ raise exception 'verified stable claim projection does not match enrollment inputs' using errcode = '22023';
458
+ end if;
459
+ if p_agent_public_key_format is distinct from 'raw_ed25519_base64url'
460
+ or p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
461
+ raise exception 'malformed agent public key' using errcode = '22023';
462
+ end if;
463
+ v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
464
+ if p_agent_public_key_fingerprint is distinct from v_fingerprint then
465
+ raise exception 'agent public key fingerprint mismatch' using errcode = '22023';
466
+ end if;
467
+ v_slug := agent_board._normalize_provision_slug(p_slug);
468
+
469
+ if not v_provider.supported or not v_provider.policy_approved then
470
+ raise exception 'identity provider is not approved' using errcode = '42501';
471
+ end if;
472
+ if v_kind = 'system' then
473
+ raise exception 'the system kind cannot be federated' using errcode = '42501';
474
+ end if;
475
+ if v_kind not in ('human', 'agent') then
476
+ raise exception 'agent kind must be human or agent' using errcode = '22023';
477
+ end if;
478
+ if array_length(v_provider.allowed_agent_kinds, 1) is not null
479
+ and not (v_kind = any(v_provider.allowed_agent_kinds)) then
480
+ raise exception 'agent kind is outside the provider ceiling' using errcode = '42501';
481
+ end if;
482
+ if not (v_requested_scopes <@ v_provider.allowed_scopes)
483
+ or not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
484
+ raise exception 'requested scopes exceed the provider ceiling' using errcode = '42501';
485
+ end if;
486
+
487
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is not null and exists (
488
+ select 1 from agent_board.board_invite_tokens bit
489
+ join agent_board.fleet_onboarding_invite_bounds b on b.invite_id = bit.id
490
+ where bit.code = btrim(p_invite_code)
491
+ ) then
492
+ raise exception 'fleet invite requires atomic fleet onboarding' using errcode = '42501';
493
+ end if;
494
+
495
+ perform pg_advisory_xact_lock(hashtextextended(v_fingerprint, 0));
496
+
497
+ select a.owner_user_id into v_owner_user_id
498
+ from agent_board.agent_signing_keys k
499
+ join agent_board.agents a on a.id = k.agent_id
500
+ where k.fingerprint = v_fingerprint;
501
+ v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
502
+ 'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32)
503
+ ));
504
+ perform 1 from auth.users u where u.id = v_owner_user_id for update;
505
+ if exists (
506
+ select 1 from agent_board.fleet_human_revocations r
507
+ where r.issuer = p_issuer and r.subject = p_subject
508
+ ) then
509
+ raise exception 'human owner has been revoked' using errcode = '42501';
510
+ end if;
511
+
512
+ select a.id as agent_id, a.auth_user_id, a.handle, a.agent_type, a.status,
513
+ a.metadata, k.id as signing_key_id, k.key_id, k.key_version,
514
+ k.public_key, k.public_key_format, k.status as key_status, k.proof_state,
515
+ (select e.granted_scopes
516
+ from agent_board.enrollment_federation_audit e
517
+ where e.agent_id = a.id
518
+ and e.issuer = p_issuer
519
+ and e.subject = p_subject
520
+ and e.provider_id = v_provider.id) as granted_scopes
521
+ into v_existing
522
+ from agent_board.agent_signing_keys k
523
+ join agent_board.agents a on a.id = k.agent_id
524
+ where k.fingerprint = v_fingerprint
525
+ for update of a, k;
526
+
527
+ if found then
528
+ v_replay := v_existing.metadata #> '{federation,replay}';
529
+ if v_existing.public_key is distinct from p_agent_public_key
530
+ or v_existing.public_key_format is distinct from p_agent_public_key_format
531
+ or v_existing.key_status <> 'active'
532
+ or v_existing.proof_state <> 'proven'
533
+ or v_existing.status <> 'active'
534
+ or v_existing.granted_scopes is null
535
+ or v_replay is null
536
+ or v_replay ->> 'issuer' is distinct from p_issuer
537
+ or v_replay -> 'audience' is distinct from v_claim_audience
538
+ or v_replay ->> 'subject' is distinct from p_subject
539
+ or v_replay ->> 'requestedSlug' is distinct from v_slug
540
+ or v_replay ->> 'agentKind' is distinct from v_kind
541
+ or v_replay -> 'actorSub' is distinct from coalesce(to_jsonb(p_actor_sub), 'null'::jsonb)
542
+ or v_replay -> 'requestedScopes' is distinct from to_jsonb(v_requested_scopes)
543
+ or v_replay -> 'requestedBoardScopes' is distinct from to_jsonb(v_requested_board_scopes)
544
+ or v_replay ->> 'providerId' is distinct from v_provider.id then
545
+ raise exception 'enrollment replay conflicts with the frozen identity contract' using errcode = '23505';
546
+ end if;
547
+
548
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
549
+ begin
550
+ perform agent_board._redeem_invite_for_agent(btrim(p_invite_code), v_existing.agent_id);
551
+ v_invite_redeemed := true;
552
+ exception when others then
553
+ v_invite_redeemed := false;
554
+ end;
555
+ end if;
556
+
557
+ return query select v_existing.agent_id, v_existing.auth_user_id,
558
+ v_replay ->> 'providerId', v_existing.signing_key_id,
559
+ v_existing.granted_scopes, v_existing.key_id, v_existing.key_version,
560
+ v_existing.handle, v_invite_redeemed;
561
+ return;
562
+ end if;
563
+
564
+ select * into v_created from agent_board._enroll_federated_identity_once(
565
+ p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
566
+ p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
567
+ p_pop_verified, p_actor_sub, p_agent_kind, v_requested_scopes,
568
+ v_requested_board_scopes, p_invite_code
569
+ );
570
+
571
+ update agent_board.agents a
572
+ set metadata = jsonb_set(a.metadata, '{federation,replay}', jsonb_build_object(
573
+ 'issuer', p_issuer, 'audience', v_claim_audience, 'subject', p_subject,
574
+ 'providerId', v_created.provider_id,
575
+ 'actorSub', coalesce(to_jsonb(p_actor_sub), 'null'::jsonb),
576
+ 'requestedSlug', v_slug, 'agentKind', v_kind,
577
+ 'requestedScopes', to_jsonb(v_requested_scopes),
578
+ 'requestedBoardScopes', to_jsonb(v_requested_board_scopes)
579
+ ), true)
580
+ where a.id = v_created.agent_id;
581
+
582
+ return query select v_created.agent_id, v_created.auth_user_id,
583
+ v_created.provider_id, v_created.signing_key_id, v_created.granted_scopes,
584
+ v_created.key_id, v_created.key_version, v_created.agent_handle,
585
+ v_created.invite_redeemed;
586
+ end;
587
+ $$;
588
+
589
+ comment on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) is
590
+ 'Service-role federated enrollment with exact fingerprint replay. Stable owner, actor, slug, kind, and scope inputs are frozen; rotating presentation claims are revalidated upstream but are not equality-gated. Fleet-bounded codes are rejected before writes.';
591
+ revoke all on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) from public, anon, authenticated;
592
+ grant execute on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) to service_role;
593
+
594
+ create function agent_board.onboard_federated_fleet_agent(
595
+ p_issuer text,
596
+ p_audience text,
597
+ p_subject text,
598
+ p_human_identity_claim jsonb,
599
+ p_slug text,
600
+ p_agent_public_key text,
601
+ p_agent_public_key_format text,
602
+ p_agent_public_key_fingerprint text,
603
+ p_pop_verified boolean,
604
+ p_actor_sub text default null,
605
+ p_agent_kind text default 'agent',
606
+ p_requested_scopes text[] default '{}',
607
+ p_requested_board_scopes text[] default '{}',
608
+ p_invite_code text default null,
609
+ p_board_id uuid default null,
610
+ p_machine_id uuid default null,
611
+ p_harness_kind text default null,
612
+ p_harness_label text default null,
613
+ p_repo_slug text default null,
614
+ p_repo_fingerprint text default null,
615
+ p_repo_scope_role text default null,
616
+ p_agent_role text default null,
617
+ p_expected_agent_handle text default null
618
+ )
619
+ returns table(
620
+ onboarding_contract_version text,
621
+ agent_id uuid,
622
+ auth_user_id uuid,
623
+ provider_id text,
624
+ signing_key_id uuid,
625
+ granted_scopes text[],
626
+ key_id text,
627
+ key_version int,
628
+ agent_handle text,
629
+ public_key_fingerprint text,
630
+ board_id uuid,
631
+ membership_role text,
632
+ membership_state text,
633
+ machine_id uuid,
634
+ harness_registration_id uuid,
635
+ repo_scope_id uuid,
636
+ fleet_machine_agent_id uuid,
637
+ binding_signing_key_id uuid,
638
+ harness_kind text,
639
+ harness_label text,
640
+ repo_slug text,
641
+ repo_fingerprint text,
642
+ repo_scope_role text,
643
+ agent_role text,
644
+ expected_agent_handle text,
645
+ identity_disposition text,
646
+ membership_disposition text,
647
+ harness_disposition text,
648
+ repo_scope_disposition text,
649
+ binding_disposition text
650
+ )
651
+ language plpgsql
652
+ security definer
653
+ set search_path = agent_board, public
654
+ as $$
655
+ #variable_conflict use_column
656
+ declare
657
+ v_inv agent_board.board_invite_tokens%rowtype;
658
+ v_bounds agent_board.fleet_onboarding_invite_bounds%rowtype;
659
+ v_board agent_board.boards%rowtype;
660
+ v_machine agent_board.fleet_machines%rowtype;
661
+ v_domain agent_board.fleet_trust_domains%rowtype;
662
+ v_enroll record;
663
+ v_membership record;
664
+ v_harness agent_board.fleet_harness_registrations%rowtype;
665
+ v_repo agent_board.fleet_repo_scopes%rowtype;
666
+ v_binding agent_board.fleet_machine_agents%rowtype;
667
+ v_kind text := lower(btrim(coalesce(p_harness_kind, '')));
668
+ v_label text := btrim(coalesce(p_harness_label, ''));
669
+ v_repo_slug text := btrim(coalesce(p_repo_slug, ''));
670
+ v_repo_fingerprint text := btrim(coalesce(p_repo_fingerprint, ''));
671
+ v_expected_handle text := btrim(coalesce(p_expected_agent_handle, ''));
672
+ v_existing_agent_id uuid;
673
+ v_owner_user_id uuid;
674
+ v_identity_disposition text;
675
+ v_membership_disposition text;
676
+ v_harness_disposition text;
677
+ v_repo_disposition text;
678
+ v_binding_disposition text;
679
+ v_completed_replay boolean := false;
680
+ begin
681
+ if not agent_board._is_service_role() then
682
+ raise exception 'service_role required for onboard_federated_fleet_agent' using errcode = '42501';
683
+ end if;
684
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is null then
685
+ raise exception 'bounded invite code is required' using errcode = '22023';
686
+ end if;
687
+
688
+ select bit.* into v_inv
689
+ from agent_board.board_invite_tokens bit
690
+ where bit.code = btrim(p_invite_code)
691
+ for update;
692
+ if not found then
693
+ raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
694
+ end if;
695
+ select * into v_bounds
696
+ from agent_board.fleet_onboarding_invite_bounds b
697
+ where b.invite_id = v_inv.id
698
+ for update;
699
+ if not found then
700
+ raise exception 'fleet onboarding requires a bounded invite' using errcode = '42501';
701
+ end if;
702
+
703
+ select * into v_board
704
+ from agent_board.boards b
705
+ where b.id = v_inv.board_id
706
+ for update;
707
+ if not found or v_board.status <> 'active' then
708
+ raise exception 'fleet onboarding board is not usable' using errcode = '42501';
709
+ end if;
710
+
711
+ if v_inv.board_id is distinct from p_board_id
712
+ or v_bounds.machine_id is distinct from p_machine_id
713
+ or v_bounds.harness_kind is distinct from v_kind
714
+ or v_bounds.harness_label is distinct from v_label
715
+ or v_bounds.repo_slug is distinct from v_repo_slug
716
+ or v_bounds.repo_fingerprint is distinct from v_repo_fingerprint
717
+ or v_bounds.repo_scope_role is distinct from p_repo_scope_role
718
+ or v_bounds.agent_role is distinct from p_agent_role
719
+ or v_bounds.expected_agent_handle is distinct from v_expected_handle then
720
+ raise exception 'fleet onboarding bounds mismatch' using errcode = '42501';
721
+ end if;
722
+ if not ((p_agent_role = 'phase_worker' and p_repo_scope_role = 'worker')
723
+ or (p_agent_role = 'reviewer' and p_repo_scope_role = 'reviewer')) then
724
+ raise exception 'fleet onboarding authority is elevated' using errcode = '42501';
725
+ end if;
726
+
727
+ select * into v_machine from agent_board.fleet_machines fm
728
+ where fm.id = p_machine_id for update;
729
+ if not found or v_machine.owner_agent_id <> v_inv.created_by_agent_id
730
+ or v_machine.lifecycle_state <> 'active' then
731
+ raise exception 'fleet onboarding machine is not usable' using errcode = '42501';
732
+ end if;
733
+ select * into v_domain from agent_board.fleet_trust_domains td
734
+ where td.id = v_machine.trust_domain_id for update;
735
+ if not found or v_domain.owner_agent_id <> v_inv.created_by_agent_id
736
+ or v_domain.lifecycle_state <> 'active' then
737
+ raise exception 'fleet onboarding trust domain is not usable' using errcode = '42501';
738
+ end if;
739
+
740
+ -- Serialize cross-route identity classification before locking principals. For
741
+ -- a replay, lock the creator and enrolled principal in UUID order so a human or
742
+ -- agent subtree revocation that visits the same pair cannot invert their order.
743
+ perform pg_advisory_xact_lock(hashtextextended(p_agent_public_key_fingerprint, 0));
744
+ select k.agent_id, a.owner_user_id into v_existing_agent_id, v_owner_user_id
745
+ from agent_board.agent_signing_keys k
746
+ join agent_board.agents a on a.id = k.agent_id
747
+ where k.fingerprint = p_agent_public_key_fingerprint;
748
+ v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
749
+ 'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32)
750
+ ));
751
+ perform 1 from auth.users u where u.id = v_owner_user_id for update;
752
+ if exists (
753
+ select 1 from agent_board.fleet_human_revocations r
754
+ where r.issuer = p_issuer and r.subject = p_subject
755
+ ) then
756
+ raise exception 'human owner has been revoked' using errcode = '42501';
757
+ end if;
758
+ perform 1 from agent_board.agents a
759
+ where a.id = any(array[v_inv.created_by_agent_id, v_existing_agent_id])
760
+ order by a.id for update;
761
+
762
+ -- Creator authority is part of the invite's live authorization, not merely a
763
+ -- snapshot from mint time. Lock every membership row and the current ceiling
764
+ -- before evaluating its role so revocation, eviction, or demotion must serialize
765
+ -- on one side of this transaction. Absence retains default-owner semantics.
766
+ perform 1 from agent_board.board_memberships bm
767
+ where bm.board_id = v_inv.board_id
768
+ and bm.agent_id = v_inv.created_by_agent_id
769
+ order by bm.id for update;
770
+ perform 1 from agent_board.board_authority_ceilings c
771
+ where c.board_id = v_inv.board_id for update;
772
+
773
+ -- Concurrent legacy enrollment or onboarding on another machine has committed
774
+ -- before this snapshot decides whether the identity was created here.
775
+ if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = p_agent_public_key_fingerprint) then
776
+ v_identity_disposition := 'reused';
777
+ else
778
+ v_identity_disposition := 'created';
779
+ end if;
780
+
781
+ select * into v_enroll from agent_board.enroll_federated_identity(
782
+ p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
783
+ p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
784
+ p_pop_verified, p_actor_sub, p_agent_kind, p_requested_scopes,
785
+ p_requested_board_scopes, null
786
+ );
787
+ if v_enroll.agent_handle is distinct from v_expected_handle then
788
+ raise exception 'server-assigned handle does not match the authorized handle' using errcode = '23505';
789
+ end if;
790
+
791
+ -- A complete replay may bypass expiry/exhaustion only while its redemption and
792
+ -- active membership remain true through commit. Lock both ledgers before the
793
+ -- proof; the invite row already serializes first redemption for an absent row.
794
+ perform 1 from agent_board.board_invite_redemptions red
795
+ where red.invite_id = v_inv.id and red.agent_id = v_enroll.agent_id
796
+ order by red.id for update;
797
+ perform 1 from agent_board.board_memberships bm
798
+ where bm.board_id = v_inv.board_id and bm.agent_id = v_enroll.agent_id
799
+ order by bm.id for update;
800
+
801
+ -- The released ROOMLINK core deliberately returns an existing redemption before
802
+ -- checking current token authority. Fleet onboarding has a narrower replay
803
+ -- contract: only an exact, fully active chain may ignore expiry/exhaustion. A
804
+ -- partial chain must pass every current invite gate before any missing suffix is
805
+ -- written, and revocation/current creator authority always apply.
806
+ select exists (
807
+ select 1
808
+ from agent_board.board_invite_redemptions red
809
+ join agent_board.board_memberships mem on mem.id = red.membership_id
810
+ join agent_board.fleet_harness_registrations h
811
+ on h.machine_id = p_machine_id
812
+ and h.harness_kind = v_kind
813
+ and h.harness_label = v_label
814
+ join agent_board.fleet_repo_scopes repo
815
+ on repo.harness_registration_id = h.id
816
+ and repo.repo_slug = v_repo_slug
817
+ and repo.repo_fingerprint = v_repo_fingerprint
818
+ and repo.scope_role = p_repo_scope_role
819
+ join agent_board.fleet_machine_agents binding
820
+ on binding.machine_id = p_machine_id
821
+ and binding.agent_id = v_enroll.agent_id
822
+ and binding.repo_scope_id = repo.id
823
+ and binding.signing_key_id = v_enroll.signing_key_id
824
+ and binding.agent_role = p_agent_role
825
+ join agent_board.agents principal on principal.id = v_enroll.agent_id
826
+ join agent_board.agent_signing_keys signing_key on signing_key.id = v_enroll.signing_key_id
827
+ where red.invite_id = v_inv.id
828
+ and red.agent_id = v_enroll.agent_id
829
+ and mem.board_id = v_inv.board_id
830
+ and mem.agent_id = v_enroll.agent_id
831
+ and mem.role = 'member'
832
+ and mem.membership_state in ('joined', 'subscribed')
833
+ and h.lifecycle_state = 'active'
834
+ and repo.lifecycle_state = 'active'
835
+ and binding.lifecycle_state = 'active'
836
+ and principal.status = 'active'
837
+ and signing_key.status = 'active'
838
+ and signing_key.proof_state = 'proven'
839
+ and signing_key.revoked_at is null
840
+ and not exists (
841
+ select 1 from agent_board.fleet_revocation_events e
842
+ where (e.subject_kind = 'agent' and e.subject_id = v_enroll.agent_id)
843
+ or (e.subject_kind = 'signing_key' and e.subject_id = v_enroll.signing_key_id)
844
+ or (e.subject_kind = 'harness' and e.subject_id = h.id)
845
+ or (e.subject_kind = 'repo_scope' and e.subject_id = repo.id)
846
+ )
847
+ ) into v_completed_replay;
848
+
849
+ if v_inv.revoked_at is not null
850
+ or not exists (
851
+ select 1 from agent_board.agents a
852
+ where a.id = v_inv.created_by_agent_id and a.status = 'active'
853
+ )
854
+ or not agent_board._grant_within_ceiling(
855
+ v_inv.created_by_agent_id, v_inv.board_id, v_inv.preset_role
856
+ )
857
+ or (
858
+ exists (
859
+ select 1 from agent_board.board_memberships bm
860
+ where bm.board_id = v_inv.board_id
861
+ and bm.agent_id = v_enroll.agent_id
862
+ and bm.membership_state = 'removed'
863
+ )
864
+ and not exists (
865
+ select 1 from agent_board.board_memberships bm
866
+ where bm.board_id = v_inv.board_id
867
+ and bm.agent_id = v_enroll.agent_id
868
+ and bm.membership_state in ('joined', 'subscribed')
869
+ )
870
+ ) then
871
+ raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
872
+ end if;
873
+ if not v_completed_replay
874
+ and (
875
+ v_inv.expires_at <= now()
876
+ or (v_inv.max_uses is not null and v_inv.uses_count >= v_inv.max_uses)
877
+ ) then
878
+ raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
879
+ end if;
880
+
881
+ if exists (
882
+ select 1 from agent_board.board_invite_redemptions r
883
+ where r.invite_id = v_inv.id and r.agent_id = v_enroll.agent_id
884
+ ) then v_membership_disposition := 'reused'; else v_membership_disposition := 'created'; end if;
885
+
886
+ select * into v_membership
887
+ from agent_board._redeem_fleet_invite_for_agent(btrim(p_invite_code), v_enroll.agent_id);
888
+ if v_membership.role <> 'member' or v_membership.membership_state not in ('joined', 'subscribed') then
889
+ raise exception 'fleet onboarding requires an active member membership' using errcode = '42501';
890
+ end if;
891
+
892
+ perform 1 from agent_board.fleet_harness_registrations h
893
+ where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
894
+ order by h.id for update;
895
+ if exists (
896
+ select 1 from agent_board.fleet_harness_registrations h
897
+ where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
898
+ and h.lifecycle_state <> 'active'
899
+ ) then
900
+ raise exception 'revoked harness registration cannot be replaced' using errcode = '42501';
901
+ end if;
902
+ select * into v_harness
903
+ from agent_board.fleet_harness_registrations h
904
+ where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
905
+ and h.lifecycle_state = 'active'
906
+ order by h.id;
907
+ if found then
908
+ v_harness_disposition := 'reused';
909
+ else
910
+ insert into agent_board.fleet_harness_registrations(machine_id, harness_kind, harness_label)
911
+ values (p_machine_id, v_kind, v_label) returning * into v_harness;
912
+ v_harness_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
913
+ end if;
914
+
915
+ perform 1 from agent_board.fleet_repo_scopes r
916
+ where r.harness_registration_id = v_harness.id
917
+ and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
918
+ order by r.id for update;
919
+ if exists (
920
+ select 1 from agent_board.fleet_repo_scopes r
921
+ where r.harness_registration_id = v_harness.id
922
+ and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
923
+ and (r.lifecycle_state <> 'active' or r.repo_slug <> v_repo_slug)
924
+ ) then
925
+ raise exception 'conflicting or revoked repo scope cannot be replaced' using errcode = '42501';
926
+ end if;
927
+ select * into v_repo
928
+ from agent_board.fleet_repo_scopes r
929
+ where r.harness_registration_id = v_harness.id
930
+ and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
931
+ and r.lifecycle_state = 'active' and r.repo_slug = v_repo_slug
932
+ order by r.id;
933
+ if found then
934
+ v_repo_disposition := 'reused';
935
+ else
936
+ insert into agent_board.fleet_repo_scopes(
937
+ harness_registration_id, repo_slug, repo_fingerprint, scope_role
938
+ ) values (v_harness.id, v_repo_slug, v_repo_fingerprint, p_repo_scope_role)
939
+ returning * into v_repo;
940
+ v_repo_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
941
+ end if;
942
+
943
+ -- Machine -> agent/key/binding lock order. Any tombstone for this natural key
944
+ -- blocks replacement authority even though the active unique index would allow it.
945
+ perform 1 from agent_board.agents a where a.id = v_enroll.agent_id for update;
946
+ perform 1 from agent_board.agent_signing_keys k where k.id = v_enroll.signing_key_id for update;
947
+ perform 1 from agent_board.fleet_machine_agents b
948
+ where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
949
+ and b.repo_scope_id = v_repo.id
950
+ order by b.id for update;
951
+ if exists (
952
+ select 1 from agent_board.fleet_machine_agents b
953
+ where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
954
+ and b.repo_scope_id = v_repo.id
955
+ and (
956
+ b.lifecycle_state <> 'active'
957
+ or b.signing_key_id is distinct from v_enroll.signing_key_id
958
+ or b.agent_role is distinct from p_agent_role
959
+ )
960
+ ) then
961
+ raise exception 'conflicting or revoked fleet binding cannot be replaced' using errcode = '42501';
962
+ end if;
963
+ select * into v_binding
964
+ from agent_board.fleet_machine_agents b
965
+ where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
966
+ and b.repo_scope_id = v_repo.id and b.lifecycle_state = 'active'
967
+ and b.signing_key_id = v_enroll.signing_key_id and b.agent_role = p_agent_role
968
+ order by b.id;
969
+ if found then
970
+ v_binding_disposition := 'reused';
971
+ else
972
+ insert into agent_board.fleet_machine_agents(
973
+ machine_id, agent_id, repo_scope_id, signing_key_id, agent_role
974
+ ) values (
975
+ p_machine_id, v_enroll.agent_id, v_repo.id, v_enroll.signing_key_id, p_agent_role
976
+ ) returning * into v_binding;
977
+ v_binding_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
978
+ end if;
979
+
980
+ if exists (
981
+ select 1 from agent_board.fleet_revocation_events e
982
+ where (e.subject_kind = 'agent' and e.subject_id = v_enroll.agent_id)
983
+ or (e.subject_kind = 'signing_key' and e.subject_id = v_enroll.signing_key_id)
984
+ or (e.subject_kind = 'harness' and e.subject_id = v_harness.id)
985
+ or (e.subject_kind = 'repo_scope' and e.subject_id = v_repo.id)
986
+ ) then
987
+ raise exception 'fleet onboarding authority has effective revocation evidence' using errcode = '42501';
988
+ end if;
989
+
990
+ return query select
991
+ 'fleet_onboarding.v1'::text, v_enroll.agent_id, v_enroll.auth_user_id,
992
+ v_enroll.provider_id, v_enroll.signing_key_id, v_enroll.granted_scopes,
993
+ v_enroll.key_id, v_enroll.key_version, v_enroll.agent_handle,
994
+ p_agent_public_key_fingerprint, v_membership.board_id, v_membership.role,
995
+ v_membership.membership_state, p_machine_id, v_harness.id, v_repo.id,
996
+ v_binding.id, v_binding.signing_key_id, v_kind, v_label, v_repo_slug, v_repo_fingerprint,
997
+ p_repo_scope_role, p_agent_role, v_expected_handle,
998
+ v_identity_disposition, v_membership_disposition, v_harness_disposition,
999
+ v_repo_disposition, v_binding_disposition;
1000
+ end;
1001
+ $$;
1002
+
1003
+ comment on function agent_board.onboard_federated_fleet_agent(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text, uuid, uuid, text, text, text, text, text, text, text) is
1004
+ 'Service-role-only atomic fleet onboarding. Locks invite, machine, domain, principal, key, and binding in canonical order; validates immutable creator bounds; enrolls/replays identity; redeems member ROOMLINK; and creates/reuses the exact harness, repo, and key-bound machine-agent chain in one transaction.';
1005
+ revoke all on function agent_board.onboard_federated_fleet_agent(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text, uuid, uuid, text, text, text, text, text, text, text) from public, anon, authenticated;
1006
+ grant execute on function agent_board.onboard_federated_fleet_agent(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text, uuid, uuid, text, text, text, text, text, text, text) to service_role;
1007
+
1008
+ create function agent_board.revoke_fleet_machine_agent_binding(
1009
+ p_binding_id uuid,
1010
+ p_revoked_by_agent_id uuid default null,
1011
+ p_revocation_reason text default null,
1012
+ p_metadata jsonb default '{}'::jsonb
1013
+ )
1014
+ returns table(binding_id uuid, lifecycle_state text, revoked_at timestamptz)
1015
+ language plpgsql
1016
+ security definer
1017
+ set search_path = agent_board, public
1018
+ as $$
1019
+ #variable_conflict use_column
1020
+ declare
1021
+ v_actor uuid;
1022
+ v_machine_id uuid;
1023
+ v_binding agent_board.fleet_machine_agents%rowtype;
1024
+ begin
1025
+ v_actor := agent_board._resolved_actor_agent_id(p_revoked_by_agent_id, 'p_revoked_by_agent_id');
1026
+ if nullif(btrim(coalesce(p_revocation_reason, '')), '') is null then
1027
+ raise exception 'revocation reason is required' using errcode = '22023';
1028
+ end if;
1029
+ select b.machine_id into v_machine_id from agent_board.fleet_machine_agents b where b.id = p_binding_id;
1030
+ if v_machine_id is null then raise exception 'binding not found' using errcode = 'P0002'; end if;
1031
+ perform 1 from agent_board.fleet_machines m
1032
+ where m.id = v_machine_id and (agent_board._is_service_role() or m.owner_agent_id = v_actor)
1033
+ for update;
1034
+ if not found then raise exception 'binding revocation is not authorized' using errcode = '42501'; end if;
1035
+ select * into v_binding from agent_board.fleet_machine_agents b where b.id = p_binding_id for update;
1036
+ if v_binding.lifecycle_state = 'revoked' then
1037
+ return query select v_binding.id, v_binding.lifecycle_state, v_binding.revoked_at;
1038
+ return;
1039
+ end if;
1040
+ update agent_board.fleet_machine_agents b
1041
+ set lifecycle_state = 'revoked', revoked_at = now(), updated_at = now(),
1042
+ metadata = b.metadata || jsonb_build_object(
1043
+ 'bindingRevocation', jsonb_build_object(
1044
+ 'revokedByAgentId', v_actor, 'reason', btrim(p_revocation_reason),
1045
+ 'metadata', coalesce(p_metadata, '{}'::jsonb), 'revokedAt', now()
1046
+ )
1047
+ )
1048
+ where b.id = p_binding_id returning * into v_binding;
1049
+ return query select v_binding.id, v_binding.lifecycle_state, v_binding.revoked_at;
1050
+ end;
1051
+ $$;
1052
+
1053
+ comment on function agent_board.revoke_fleet_machine_agent_binding(uuid, uuid, text, jsonb) is
1054
+ 'Sole writer for permanent exact fleet-machine-agent binding revocation. Locks the owning machine before the binding, rechecks owner/service authority, and never deletes or reactivates the row.';
1055
+ revoke all on function agent_board.revoke_fleet_machine_agent_binding(uuid, uuid, text, jsonb) from public, anon;
1056
+ grant execute on function agent_board.revoke_fleet_machine_agent_binding(uuid, uuid, text, jsonb) to authenticated, service_role;
1057
+
1058
+ -- Refresh the public fleet revocation writer so onboarding and revocation share a
1059
+ -- single lock order. Machine-derived subjects already locked their machine first;
1060
+ -- agent subjects now lock all existing bound machines before the principal and
1061
+ -- bindings, while signing-key subjects lock their owning principal before the key.
1062
+ create or replace function agent_board.revoke_fleet_subject(
1063
+ p_subject_kind text,
1064
+ p_subject_id uuid,
1065
+ p_revoked_by_agent_id uuid,
1066
+ p_revocation_reason text,
1067
+ p_metadata jsonb default '{}'::jsonb
1068
+ )
1069
+ returns table(revocation_event_id uuid, subject_kind text, effective_at timestamptz)
1070
+ language plpgsql
1071
+ security definer
1072
+ set search_path = agent_board, public
1073
+ as $$
1074
+ declare
1075
+ v_event agent_board.fleet_revocation_events%rowtype;
1076
+ v_actor_agent_id uuid;
1077
+ v_lock_machine_id uuid;
1078
+ v_lock_agent_id uuid;
1079
+ begin
1080
+ if p_subject_kind not in ('trust_domain', 'machine', 'harness', 'repo_scope', 'agent', 'signing_key', 'credential') then
1081
+ raise exception 'unsupported fleet revocation subject' using errcode = '22023';
1082
+ end if;
1083
+
1084
+ if p_subject_kind in ('machine', 'harness', 'repo_scope') then
1085
+ v_lock_machine_id := case
1086
+ when p_subject_kind = 'machine' then p_subject_id
1087
+ when p_subject_kind = 'harness' then (
1088
+ select fhr.machine_id
1089
+ from agent_board.fleet_harness_registrations fhr
1090
+ where fhr.id = p_subject_id)
1091
+ when p_subject_kind = 'repo_scope' then (
1092
+ select fhr.machine_id
1093
+ from agent_board.fleet_repo_scopes frs
1094
+ join agent_board.fleet_harness_registrations fhr
1095
+ on fhr.id = frs.harness_registration_id
1096
+ where frs.id = p_subject_id)
1097
+ end;
1098
+ if v_lock_machine_id is not null then
1099
+ perform 1
1100
+ from agent_board.fleet_machines fm
1101
+ where fm.id = v_lock_machine_id
1102
+ for update;
1103
+ end if;
1104
+ elsif p_subject_kind = 'agent' then
1105
+ for v_lock_machine_id in
1106
+ select distinct fma.machine_id
1107
+ from agent_board.fleet_machine_agents fma
1108
+ where fma.agent_id = p_subject_id
1109
+ order by fma.machine_id
1110
+ loop
1111
+ perform 1
1112
+ from agent_board.fleet_machines fm
1113
+ where fm.id = v_lock_machine_id
1114
+ for update;
1115
+ end loop;
1116
+ perform 1 from agent_board.agents a where a.id = p_subject_id for update;
1117
+ perform 1
1118
+ from agent_board.fleet_machine_agents fma
1119
+ where fma.agent_id = p_subject_id
1120
+ order by fma.id
1121
+ for update;
1122
+ elsif p_subject_kind = 'signing_key' then
1123
+ select ask.agent_id
1124
+ into v_lock_agent_id
1125
+ from agent_board.agent_signing_keys ask
1126
+ where ask.id = p_subject_id;
1127
+ if v_lock_agent_id is not null then
1128
+ perform 1 from agent_board.agents a where a.id = v_lock_agent_id for update;
1129
+ end if;
1130
+ perform 1 from agent_board.agent_signing_keys ask where ask.id = p_subject_id for update;
1131
+ end if;
1132
+
1133
+ if p_subject_kind = 'trust_domain' then
1134
+ perform 1
1135
+ from agent_board.fleet_trust_domains ftd
1136
+ where ftd.id = p_subject_id
1137
+ for update;
1138
+ end if;
1139
+
1140
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(
1141
+ p_revoked_by_agent_id,
1142
+ 'p_revoked_by_agent_id'
1143
+ );
1144
+
1145
+ if not agent_board._is_service_role() then
1146
+ if p_subject_kind = 'trust_domain' and not exists (
1147
+ select 1 from agent_board.fleet_trust_domains ftd
1148
+ where ftd.id = p_subject_id and ftd.owner_agent_id = v_actor_agent_id
1149
+ ) then
1150
+ raise exception 'trust domain is not owned by current_agent_id()' using errcode = '42501';
1151
+ elsif p_subject_kind = 'machine' and not exists (
1152
+ select 1 from agent_board.fleet_machines fm
1153
+ where fm.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
1154
+ ) then
1155
+ raise exception 'machine is not owned by current_agent_id()' using errcode = '42501';
1156
+ elsif p_subject_kind = 'harness' and not exists (
1157
+ select 1
1158
+ from agent_board.fleet_harness_registrations fhr
1159
+ join agent_board.fleet_machines fm on fm.id = fhr.machine_id
1160
+ where fhr.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
1161
+ ) then
1162
+ raise exception 'harness is not owned by current_agent_id()' using errcode = '42501';
1163
+ elsif p_subject_kind = 'repo_scope' and not exists (
1164
+ select 1
1165
+ from agent_board.fleet_repo_scopes frs
1166
+ join agent_board.fleet_harness_registrations fhr
1167
+ on fhr.id = frs.harness_registration_id
1168
+ join agent_board.fleet_machines fm on fm.id = fhr.machine_id
1169
+ where frs.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
1170
+ ) then
1171
+ raise exception 'repo scope is not owned by current_agent_id()' using errcode = '42501';
1172
+ elsif p_subject_kind = 'agent' and p_subject_id <> v_actor_agent_id then
1173
+ raise exception 'agent revocation must target current_agent_id()' using errcode = '42501';
1174
+ elsif p_subject_kind = 'signing_key' and not exists (
1175
+ select 1 from agent_board.agent_signing_keys ask
1176
+ where ask.id = p_subject_id and ask.agent_id = v_actor_agent_id
1177
+ ) then
1178
+ raise exception 'signing key is not owned by current_agent_id()' using errcode = '42501';
1179
+ elsif p_subject_kind = 'credential' and not exists (
1180
+ select 1 from agent_board.agent_credentials ac
1181
+ where ac.id = p_subject_id and ac.agent_id = v_actor_agent_id
1182
+ ) then
1183
+ raise exception 'credential is not owned by current_agent_id()' using errcode = '42501';
1184
+ end if;
1185
+ end if;
1186
+
1187
+ select *
1188
+ into v_event
1189
+ from agent_board.fleet_revocation_events fre
1190
+ where fre.subject_kind = p_subject_kind
1191
+ and fre.subject_id = p_subject_id
1192
+ limit 1;
1193
+
1194
+ if v_event.id is null then
1195
+ insert into agent_board.fleet_revocation_events(
1196
+ subject_kind,
1197
+ subject_id,
1198
+ trust_domain_id,
1199
+ machine_id,
1200
+ harness_registration_id,
1201
+ repo_scope_id,
1202
+ agent_id,
1203
+ signing_key_id,
1204
+ revoked_by_agent_id,
1205
+ revocation_reason,
1206
+ metadata
1207
+ )
1208
+ values (
1209
+ p_subject_kind,
1210
+ p_subject_id,
1211
+ case when p_subject_kind = 'trust_domain' then p_subject_id end,
1212
+ case when p_subject_kind = 'machine' then p_subject_id end,
1213
+ case when p_subject_kind = 'harness' then p_subject_id end,
1214
+ case when p_subject_kind = 'repo_scope' then p_subject_id end,
1215
+ case when p_subject_kind = 'agent' then p_subject_id end,
1216
+ case when p_subject_kind = 'signing_key' then p_subject_id end,
1217
+ p_revoked_by_agent_id,
1218
+ trim(coalesce(p_revocation_reason, 'operator revocation')),
1219
+ coalesce(p_metadata, '{}'::jsonb)
1220
+ || jsonb_build_object('redacted', true, 'metadata-safe', true)
1221
+ )
1222
+ returning * into v_event;
1223
+ else
1224
+ update agent_board.fleet_revocation_events fre
1225
+ set effective_at = now(),
1226
+ revoked_by_agent_id = p_revoked_by_agent_id,
1227
+ revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
1228
+ metadata = coalesce(p_metadata, '{}'::jsonb)
1229
+ || jsonb_build_object('redacted', true, 'metadata-safe', true)
1230
+ where fre.id = v_event.id
1231
+ returning * into v_event;
1232
+ end if;
1233
+
1234
+ if p_subject_kind = 'trust_domain' then
1235
+ update agent_board.fleet_trust_domains
1236
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
1237
+ where id = p_subject_id;
1238
+ elsif p_subject_kind = 'machine' then
1239
+ update agent_board.fleet_machines
1240
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
1241
+ where id = p_subject_id;
1242
+ elsif p_subject_kind = 'harness' then
1243
+ update agent_board.fleet_harness_registrations
1244
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
1245
+ where id = p_subject_id;
1246
+ elsif p_subject_kind = 'repo_scope' then
1247
+ update agent_board.fleet_repo_scopes
1248
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
1249
+ where id = p_subject_id;
1250
+ elsif p_subject_kind = 'agent' then
1251
+ update agent_board.fleet_machine_agents
1252
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
1253
+ where agent_id = p_subject_id;
1254
+ elsif p_subject_kind = 'signing_key' then
1255
+ update agent_board.agent_signing_keys
1256
+ set status = case when status = 'compromised' then status else 'revoked' end,
1257
+ proof_state = 'revoked',
1258
+ revoked_at = coalesce(revoked_at, v_event.effective_at),
1259
+ revoked_by_agent_id = p_revoked_by_agent_id,
1260
+ revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
1261
+ updated_at = now()
1262
+ where id = p_subject_id;
1263
+ end if;
1264
+
1265
+ return query select v_event.id, v_event.subject_kind, v_event.effective_at;
1266
+ end;
1267
+ $$;
1268
+
1269
+ revoke all on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) from public, anon;
1270
+ grant execute on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) to authenticated, service_role;
1271
+
1272
+ comment on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) is
1273
+ 'Records effective fleet revocation while sharing atomic onboarding lock order: machine-derived subjects lock the machine first, agent revocation locks affected machines in UUID order before the agent and bindings, and signing-key revocation locks agent before key.';
1274
+
1275
+ -- Refresh the subtree primitive and its public entrypoints. The primitive locks
1276
+ -- agent before keys; both entrypoints visit agents in UUID order.
1277
+ create or replace function agent_board._revoke_agent_principal(
1278
+ p_agent_id uuid,
1279
+ p_revoked_by_agent_id uuid,
1280
+ p_reason text
1281
+ )
1282
+ returns integer
1283
+ language plpgsql
1284
+ security definer
1285
+ set search_path = agent_board, public
1286
+ as $$
1287
+ declare
1288
+ v_key record;
1289
+ v_key_count integer := 0;
1290
+ v_agent_flipped boolean := false;
1291
+ begin
1292
+ perform 1 from agent_board.agents a where a.id = p_agent_id for update;
1293
+
1294
+ for v_key in
1295
+ select id
1296
+ from agent_board.agent_signing_keys
1297
+ where agent_id = p_agent_id
1298
+ and status not in ('revoked', 'compromised')
1299
+ order by id
1300
+ for update
1301
+ loop
1302
+ update agent_board.agent_signing_keys
1303
+ set status = 'revoked',
1304
+ proof_state = 'revoked',
1305
+ revoked_at = now(),
1306
+ revoked_by_agent_id = p_revoked_by_agent_id,
1307
+ revocation_reason = p_reason,
1308
+ updated_at = now()
1309
+ where id = v_key.id;
1310
+
1311
+ insert into agent_board.fleet_revocation_events(
1312
+ subject_kind, subject_id, signing_key_id, agent_id,
1313
+ revoked_by_agent_id, revocation_reason, metadata
1314
+ )
1315
+ values (
1316
+ 'signing_key', v_key.id, v_key.id, p_agent_id,
1317
+ p_revoked_by_agent_id, p_reason,
1318
+ jsonb_build_object('source', 'revoke_agent_subtree')
1319
+ )
1320
+ on conflict (subject_kind, subject_id) do update
1321
+ set revoked_by_agent_id = excluded.revoked_by_agent_id,
1322
+ revocation_reason = excluded.revocation_reason,
1323
+ signing_key_id = excluded.signing_key_id,
1324
+ agent_id = excluded.agent_id,
1325
+ effective_at = now(),
1326
+ metadata = excluded.metadata;
1327
+
1328
+ v_key_count := v_key_count + 1;
1329
+ end loop;
1330
+
1331
+ update agent_board.agents
1332
+ set status = 'revoked', updated_at = now()
1333
+ where id = p_agent_id and status <> 'revoked';
1334
+ if found then
1335
+ v_agent_flipped := true;
1336
+ end if;
1337
+
1338
+ update agent_board.delegation_grants
1339
+ set revoked_at = now()
1340
+ where child_agent_id = p_agent_id and revoked_at is null;
1341
+
1342
+ update agent_board.agent_credentials ac
1343
+ set revoked_at = coalesce(ac.revoked_at, now()),
1344
+ updated_at = now(),
1345
+ metadata = coalesce(ac.metadata, '{}'::jsonb)
1346
+ || jsonb_build_object(
1347
+ 'revokedByAgentId', p_revoked_by_agent_id,
1348
+ 'revocationReason', p_reason,
1349
+ 'source', 'revoke_agent_subtree'
1350
+ )
1351
+ where ac.agent_id = p_agent_id and ac.revoked_at is null;
1352
+
1353
+ insert into agent_board.fleet_revocation_events(
1354
+ subject_kind, subject_id, agent_id,
1355
+ revoked_by_agent_id, revocation_reason, metadata
1356
+ )
1357
+ values (
1358
+ 'agent', p_agent_id, p_agent_id,
1359
+ p_revoked_by_agent_id, p_reason,
1360
+ jsonb_build_object('source', 'revoke_agent_subtree', 'statusFlipped', v_agent_flipped)
1361
+ )
1362
+ on conflict (subject_kind, subject_id) do update
1363
+ set revoked_by_agent_id = excluded.revoked_by_agent_id,
1364
+ revocation_reason = excluded.revocation_reason,
1365
+ agent_id = excluded.agent_id,
1366
+ effective_at = now(),
1367
+ metadata = excluded.metadata;
1368
+
1369
+ return v_key_count;
1370
+ end;
1371
+ $$;
1372
+
1373
+ comment on function agent_board._revoke_agent_principal(uuid, uuid, text) is
1374
+ 'Per-principal revocation primitive. Locks agent before signing keys in UUID order, then preserves the existing key, status, grant, credential, and audit effects.';
1375
+ revoke all on function agent_board._revoke_agent_principal(uuid, uuid, text) from public, anon, authenticated;
1376
+ grant execute on function agent_board._revoke_agent_principal(uuid, uuid, text) to service_role;
1377
+
1378
+ create or replace function agent_board.revoke_agent_subtree(
1379
+ p_root_agent_id uuid,
1380
+ p_revoked_by_agent_id uuid,
1381
+ p_reason text default 'root revocation'
1382
+ )
1383
+ returns table(revoked_agent_count integer, revoked_key_count integer)
1384
+ language plpgsql
1385
+ security definer
1386
+ set search_path = agent_board, public
1387
+ as $$
1388
+ declare
1389
+ v_agent uuid;
1390
+ v_agent_count integer := 0;
1391
+ v_key_count integer := 0;
1392
+ begin
1393
+ if not agent_board._is_service_role() then
1394
+ raise exception 'revoke_agent_subtree is service_role-only' using errcode = '42501';
1395
+ end if;
1396
+ if not exists (select 1 from agent_board.agents where id = p_root_agent_id) then
1397
+ raise exception 'root agent not found' using errcode = 'P0002';
1398
+ end if;
1399
+
1400
+ for v_agent in
1401
+ with recursive subtree(agent_id) as (
1402
+ select p_root_agent_id
1403
+ union
1404
+ select g.child_agent_id
1405
+ from agent_board.delegation_grants g
1406
+ join subtree s on g.parent_agent_id = s.agent_id
1407
+ )
1408
+ select agent_id from subtree order by agent_id
1409
+ loop
1410
+ v_key_count := v_key_count
1411
+ + agent_board._revoke_agent_principal(v_agent, p_revoked_by_agent_id, p_reason);
1412
+ v_agent_count := v_agent_count + 1;
1413
+ end loop;
1414
+
1415
+ return query select v_agent_count, v_key_count;
1416
+ end;
1417
+ $$;
1418
+
1419
+ comment on function agent_board.revoke_agent_subtree(uuid, uuid, text) is
1420
+ 'DOWN-walking refresh-checked cascade. Visits the root and every grant-derived descendant in UUID order before the agent-before-key primitive. service_role-only.';
1421
+ revoke all on function agent_board.revoke_agent_subtree(uuid, uuid, text) from public, anon, authenticated;
1422
+ grant execute on function agent_board.revoke_agent_subtree(uuid, uuid, text) to service_role;
1423
+
1424
+ create or replace function agent_board.revoke_human_subtree(
1425
+ p_owner_user_id uuid,
1426
+ p_revoked_by_agent_id uuid,
1427
+ p_reason text default 'human revocation'
1428
+ )
1429
+ returns table(revoked_agent_count integer, revoked_key_count integer)
1430
+ language plpgsql
1431
+ security definer
1432
+ set search_path = agent_board, public
1433
+ as $$
1434
+ declare
1435
+ v_agent uuid;
1436
+ v_agent_count integer := 0;
1437
+ v_key_count integer := 0;
1438
+ begin
1439
+ if not agent_board._is_service_role() then
1440
+ raise exception 'revoke_human_subtree is service_role-only' using errcode = '42501';
1441
+ end if;
1442
+
1443
+ perform 1 from auth.users u where u.id = p_owner_user_id for update;
1444
+ if not found then
1445
+ raise exception 'human owner not found' using errcode = 'P0002';
1446
+ end if;
1447
+
1448
+ if exists (
1449
+ select 1
1450
+ from agent_board.agents a
1451
+ where a.owner_user_id = p_owner_user_id
1452
+ and jsonb_typeof(a.metadata -> 'federation') = 'object'
1453
+ ) and not exists (
1454
+ select 1
1455
+ from agent_board.enrollment_federation_audit e
1456
+ join agent_board.agents a on a.id = e.agent_id
1457
+ where a.owner_user_id = p_owner_user_id
1458
+ and nullif(btrim(e.issuer), '') is not null
1459
+ and nullif(btrim(e.subject), '') is not null
1460
+ ) then
1461
+ raise exception 'stable federation evidence not found for human owner' using errcode = 'P0002';
1462
+ end if;
1463
+
1464
+ insert into agent_board.fleet_human_revocations(
1465
+ issuer, subject, owner_user_id, revoked_by_agent_id, revocation_reason
1466
+ )
1467
+ select distinct
1468
+ e.issuer,
1469
+ e.subject,
1470
+ p_owner_user_id, p_revoked_by_agent_id, p_reason
1471
+ from agent_board.enrollment_federation_audit e
1472
+ join agent_board.agents a on a.id = e.agent_id
1473
+ where a.owner_user_id = p_owner_user_id
1474
+ and nullif(btrim(e.issuer), '') is not null
1475
+ and nullif(btrim(e.subject), '') is not null
1476
+ on conflict (issuer, subject) do nothing;
1477
+
1478
+ for v_agent in
1479
+ select id
1480
+ from agent_board.agents
1481
+ where owner_user_id = p_owner_user_id
1482
+ order by id
1483
+ loop
1484
+ v_key_count := v_key_count
1485
+ + agent_board._revoke_agent_principal(v_agent, p_revoked_by_agent_id, p_reason);
1486
+ v_agent_count := v_agent_count + 1;
1487
+ end loop;
1488
+
1489
+ return query select v_agent_count, v_key_count;
1490
+ end;
1491
+ $$;
1492
+
1493
+ comment on function agent_board.revoke_human_subtree(uuid, uuid, text) is
1494
+ 'TOTAL human revocation. Locks the human owner, derives durable issuer/subject markers only from append-only enrollment audit evidence, then visits every owned agent in UUID order before the agent-before-key primitive. service_role-only.';
1495
+ revoke all on function agent_board.revoke_human_subtree(uuid, uuid, text) from public, anon, authenticated;
1496
+ grant execute on function agent_board.revoke_human_subtree(uuid, uuid, text) to service_role;