@consiliency/agent-board-schema 1.60.0 → 1.63.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 (68) hide show
  1. package/README.md +80 -0
  2. package/fixtures/already-applied.json +12 -0
  3. package/migrations/20261011000100_agent_board_session_identities.sql +1964 -0
  4. package/migrations/20261012000100_agent_board_sidowner.sql +209 -0
  5. package/migrations/20261014000100_agent_board_grant_target_role.sql +218 -0
  6. package/package.json +1 -1
  7. package/schema-manifest.json +30 -3
  8. package/src/tooling.test.mjs +6 -2
  9. package/tests/agent_board_atomic_fleet_onboarding.test.sql +13 -4
  10. package/tests/agent_board_consent_emission.test.sql +30 -11
  11. package/tests/agent_board_consent_gating.test.sql +16 -2
  12. package/tests/agent_board_delegate_role_class.test.sql +1 -1
  13. package/tests/agent_board_delegation_cap_ceiling.test.sql +26 -15
  14. package/tests/agent_board_delegation_enforcement.test.sql +1 -1
  15. package/tests/agent_board_enroll_resumption.test.sql +22 -4
  16. package/tests/agent_board_enroll_token_single_use.test.sql +18 -3
  17. package/tests/agent_board_federated_lock_order_class.test.sql +2 -2
  18. package/tests/agent_board_fk_lock_edges.test.sql +74 -14
  19. package/tests/agent_board_grant_target_role.test.sql +1118 -0
  20. package/tests/agent_board_invite_no_downgrade.test.sql +10 -8
  21. package/tests/agent_board_ledger_authority.test.sql +70 -12
  22. package/tests/agent_board_membership_current_row_class.test.sql +9 -1
  23. package/tests/agent_board_mint_fleet_agent_session.test.sql +2 -2
  24. package/tests/agent_board_mint_harden.test.sql +38 -27
  25. package/tests/agent_board_rolereset.test.sql +8 -2
  26. package/tests/agent_board_roomdeleg_lane_b.test.sql +6 -6
  27. package/tests/agent_board_rowid.test.sql +3 -1
  28. package/tests/agent_board_session_identity_approval_revocation.test.sql +334 -0
  29. package/tests/agent_board_session_identity_backfill.test.sql +82 -0
  30. package/tests/agent_board_session_identity_backfill_refusal.test.sql +14 -0
  31. package/tests/agent_board_session_identity_behavior_classes.test.sql +176 -0
  32. package/tests/agent_board_session_identity_class.test.sql +118 -0
  33. package/tests/agent_board_session_identity_fingerprint.test.sql +271 -0
  34. package/tests/agent_board_session_identity_labels_limits.test.sql +205 -0
  35. package/tests/agent_board_session_identity_lease.test.sql +235 -0
  36. package/tests/agent_board_session_identity_lifetime.test.sql +230 -0
  37. package/tests/agent_board_session_identity_limits.test.sql +208 -0
  38. package/tests/agent_board_session_identity_lineage.test.sql +64 -0
  39. package/tests/agent_board_session_identity_link.test.sql +201 -0
  40. package/tests/agent_board_session_identity_mint.test.sql +233 -0
  41. package/tests/agent_board_session_identity_panel_r1.test.sql +194 -0
  42. package/tests/agent_board_session_identity_panel_r2.test.sql +174 -0
  43. package/tests/agent_board_session_identity_protected_actions.test.sql +156 -0
  44. package/tests/agent_board_session_identity_revoke_closure.test.sql +131 -0
  45. package/tests/agent_board_sidowner_leases.test.sql +107 -0
  46. package/tests/agent_board_sidowner_owner_required.test.sql +97 -0
  47. package/tests/agent_board_sidowner_read_class.test.sql +171 -0
  48. package/tests/agent_board_sidowner_reads.test.sql +136 -0
  49. package/tests/agent_board_signer_lifecycle.test.sql +26 -4
  50. package/tests/agent_board_signing_challenge_pending_cap.test.sql +1 -1
  51. package/tests/agent_board_write_enforcement_class.test.sql +74 -15
  52. package/tests/catalog/function_execute_privileges.json +218 -2
  53. package/tests/catalog/p_metadata_parameters.json +98 -2
  54. package/tests/isolation/expected/grant_target_role_race.out +348 -0
  55. package/tests/isolation/expected/session_identity_limits.out +165 -0
  56. package/tests/isolation/expected/session_identity_links.out +235 -0
  57. package/tests/isolation/expected/session_identity_reclaim.out +253 -0
  58. package/tests/isolation/expected/session_identity_renew_revoke.out +115 -0
  59. package/tests/isolation/expected/session_identity_renew_revoke_key_gate.out +138 -0
  60. package/tests/isolation/expected/session_identity_tombstone_onboarding.out +247 -0
  61. package/tests/isolation/specs/grant_target_role_race.spec +207 -0
  62. package/tests/isolation/specs/session_identity_limits.spec +81 -0
  63. package/tests/isolation/specs/session_identity_links.spec +80 -0
  64. package/tests/isolation/specs/session_identity_reclaim.spec +125 -0
  65. package/tests/isolation/specs/session_identity_renew_revoke.spec +84 -0
  66. package/tests/isolation/specs/session_identity_renew_revoke_key_gate.spec +94 -0
  67. package/tests/isolation/specs/session_identity_tombstone_onboarding.spec +108 -0
  68. package/tests/seeds/20261011000100_agent_board_session_identities.sql.pre.sql +47 -0
@@ -0,0 +1,1964 @@
1
+ -- SIDSCHEMA: durable per-session identities. Transport attestation is deliberately
2
+ -- not established here: SIDSERVER is the only producer allowed to bind harness
3
+ -- evidence to the optional session arguments below.
4
+
5
+ create table agent_board.session_identities (
6
+ agent_id uuid primary key references agent_board.agents(id) on delete cascade,
7
+ parent_agent_id uuid references agent_board.agents(id),
8
+ root_agent_id uuid not null references agent_board.agents(id),
9
+ depth smallint not null check (depth between 0 and 2),
10
+ identity_kind text not null default 'legacy' check (identity_kind in ('legacy', 'session')),
11
+ harness text check (harness is null or harness in ('agy', 'claude', 'codex', 'grok', 'opencode', 'pi')),
12
+ forked_from uuid references agent_board.session_identities(agent_id),
13
+ readmitted_from uuid references agent_board.session_identities(agent_id),
14
+ tombstoned_at timestamptz,
15
+ tombstone_reason text check (tombstone_reason is null or tombstone_reason in ('revoked', 'cascade', 'superseded_unclaimed')),
16
+ credential_epoch bigint not null default 0 check (credential_epoch >= 0),
17
+ holder_secret_hash bytea,
18
+ lease_alias_id uuid,
19
+ last_heartbeat timestamptz,
20
+ approved_risky_digest text,
21
+ current_risky_digest text,
22
+ recorded_fingerprint jsonb not null default '{}'::jsonb check (jsonb_typeof(recorded_fingerprint) = 'object'),
23
+ resolution_state text not null default 'active' check (resolution_state in ('active', 'frozen')),
24
+ created_at timestamptz not null default now(),
25
+ updated_at timestamptz not null default now(),
26
+ check ((identity_kind = 'session') = (harness is not null)),
27
+ check ((tombstoned_at is null) = (tombstone_reason is null)),
28
+ check (identity_kind <> 'session' or parent_agent_id is not null)
29
+ );
30
+
31
+ create table agent_board.session_id_aliases (
32
+ id uuid primary key default gen_random_uuid(),
33
+ identity_agent_id uuid not null references agent_board.session_identities(agent_id) on delete cascade,
34
+ root_agent_id uuid not null references agent_board.agents(id),
35
+ harness text not null check (harness in ('agy', 'claude', 'codex', 'grok', 'opencode', 'pi')),
36
+ session_id text not null check (length(session_id) > 0),
37
+ created_at timestamptz not null default now(),
38
+ unique (root_agent_id, harness, session_id)
39
+ );
40
+ alter table agent_board.session_identities
41
+ add constraint session_identities_lease_alias_fkey foreign key (lease_alias_id)
42
+ references agent_board.session_id_aliases(id) deferrable initially deferred;
43
+
44
+ -- Existing principals retain their historical behavior. Delegated legacy children
45
+ -- are represented as depth one but never acquire a session lease implicitly.
46
+ do $$
47
+ declare v_count bigint; v_mismatch record;
48
+ begin
49
+ with recursive lineage(agent_id, parent_agent_id, root_agent_id, depth) as (
50
+ select id, parent_agent_id, id, 0 from agent_board.agents where parent_agent_id is null
51
+ union all
52
+ select a.id, a.parent_agent_id, l.root_agent_id, l.depth + 1
53
+ from agent_board.agents a join lineage l on a.parent_agent_id = l.agent_id
54
+ where l.depth < 3
55
+ )
56
+ select count(*) into v_count from lineage where depth > 2;
57
+ if v_count > 0 then raise exception 'identity_depth_exceeded' using errcode = '23514'; end if;
58
+ with recursive lineage(agent_id, root_agent_id) as (
59
+ select id, id from agent_board.agents where parent_agent_id is null
60
+ union all
61
+ select a.id, l.root_agent_id from agent_board.agents a join lineage l on a.parent_agent_id = l.agent_id
62
+ )
63
+ select a.id, a.root_agent_id as stored_root, l.root_agent_id as computed_root into v_mismatch
64
+ from lineage l join agent_board.agents a on a.id = l.agent_id
65
+ where coalesce(a.root_agent_id, a.id) is distinct from l.root_agent_id
66
+ order by a.id limit 1;
67
+ if found then
68
+ raise exception 'session identity backfill lineage mismatch: agent %, stored root %, computed root %',
69
+ v_mismatch.id, v_mismatch.stored_root, v_mismatch.computed_root using errcode = '23514';
70
+ end if;
71
+ with recursive lineage(agent_id, parent_agent_id, root_agent_id, depth) as (
72
+ select id, parent_agent_id, id, 0 from agent_board.agents where parent_agent_id is null
73
+ union all
74
+ select a.id, a.parent_agent_id, l.root_agent_id, l.depth + 1
75
+ from agent_board.agents a join lineage l on a.parent_agent_id = l.agent_id
76
+ )
77
+ insert into agent_board.session_identities(agent_id, parent_agent_id, root_agent_id, depth)
78
+ select l.agent_id, l.parent_agent_id, l.root_agent_id, l.depth from lineage l;
79
+ if (select count(*) from agent_board.session_identities) <> (select count(*) from agent_board.agents) then
80
+ raise exception 'identity lineage is cyclic or incomplete' using errcode = '23514';
81
+ end if;
82
+ end;
83
+ $$;
84
+
85
+ -- Flush the backfill's deferred FK events before indexes, triggers and RLS DDL.
86
+ -- It is the only deferred constraint on the backfilled relation at this point.
87
+ -- Leave every unrelated constraint deferred for following statements in this
88
+ -- same migration-application transaction.
89
+ set constraints agent_board.session_identities_lease_alias_fkey immediate;
90
+
91
+ create index session_identities_root_active_idx
92
+ on agent_board.session_identities(root_agent_id, identity_kind, tombstoned_at);
93
+ create index session_identities_parent_idx on agent_board.session_identities(parent_agent_id);
94
+
95
+ create or replace function agent_board._session_identity_kind_immutable()
96
+ returns trigger language plpgsql security definer set search_path = '' as $$
97
+ begin
98
+ if new.identity_kind is distinct from old.identity_kind then
99
+ raise exception 'session identity kind is immutable' using errcode = '42501';
100
+ end if;
101
+ if old.holder_secret_hash is not null and new.holder_secret_hash is null then
102
+ raise exception 'a claimed session lease cannot be cleared' using errcode = '42501';
103
+ end if;
104
+ return new;
105
+ end;
106
+ $$;
107
+ drop trigger if exists trg_session_identity_kind_immutable on agent_board.session_identities;
108
+ create trigger trg_session_identity_kind_immutable
109
+ before update of identity_kind, holder_secret_hash on agent_board.session_identities
110
+ for each row execute function agent_board._session_identity_kind_immutable();
111
+ revoke all on function agent_board._session_identity_kind_immutable() from public, anon, authenticated;
112
+ grant execute on function agent_board._session_identity_kind_immutable() to service_role;
113
+ comment on function agent_board._session_identity_kind_immutable() is 'Keeps session identity kind immutable and prevents clearing a claimed lease to disable epoch fencing.';
114
+
115
+ create function agent_board._session_identity_lineage()
116
+ returns trigger language plpgsql security definer set search_path = '' as $$
117
+ declare v_target uuid;
118
+ begin
119
+ if tg_op = 'DELETE' then v_target := old.agent_id; else v_target := new.agent_id; end if;
120
+ if exists (
121
+ select 1 from agent_board.session_identities si
122
+ join agent_board.agents a on a.id = si.agent_id
123
+ left join agent_board.session_identities parent on parent.agent_id = si.parent_agent_id
124
+ where (si.agent_id = v_target or si.parent_agent_id = v_target)
125
+ and (si.parent_agent_id is distinct from a.parent_agent_id
126
+ or si.root_agent_id is distinct from coalesce(a.root_agent_id, a.id)
127
+ or si.depth is distinct from case when si.parent_agent_id is null then 0 else parent.depth + 1 end)
128
+ ) then raise exception 'session identity lineage mismatch' using errcode = '23514'; end if;
129
+ return new;
130
+ end;
131
+ $$;
132
+ create constraint trigger trg_session_identity_lineage
133
+ after insert or update or delete on agent_board.session_identities
134
+ deferrable initially deferred for each row execute function agent_board._session_identity_lineage();
135
+ revoke all on function agent_board._session_identity_lineage() from public, anon, authenticated;
136
+ grant execute on function agent_board._session_identity_lineage() to service_role;
137
+ comment on function agent_board._session_identity_lineage() is 'Pins identity parent, root and depth to the stored agent lineage; checked at transaction end.';
138
+
139
+ -- Deferred construction lets explicit session mints supply their session row
140
+ -- before commit, while every legacy constructor receives a lease-free identity.
141
+ create function agent_board._session_identity_agent_lineage()
142
+ returns trigger language plpgsql security definer set search_path = '' as $$
143
+ declare v_root uuid; v_depth integer; v_node record;
144
+ begin
145
+ if not exists (select 1 from agent_board.agents where id = new.id) then return new; end if;
146
+ with recursive ancestry(agent_id, parent_agent_id, distance, path, cyclic) as (
147
+ select a.id, a.parent_agent_id, 0, array[a.id], false from agent_board.agents a where a.id = new.id
148
+ union all
149
+ select a.id, a.parent_agent_id, c.distance + 1, c.path || a.id, a.id = any(c.path)
150
+ from ancestry c join agent_board.agents a on a.id = c.parent_agent_id
151
+ where not c.cyclic and c.distance < 3
152
+ )
153
+ select agent_id, distance into v_root, v_depth from ancestry where parent_agent_id is null and not cyclic;
154
+ if v_root is null then raise exception 'session identity lineage mismatch' using errcode = '23514'; end if;
155
+ if v_depth > 2 then raise exception 'identity_depth_exceeded' using errcode = '23514'; end if;
156
+ for v_node in
157
+ with recursive ancestry(agent_id, distance) as (
158
+ select new.id, 0
159
+ union all
160
+ select a.parent_agent_id, c.distance + 1 from ancestry c join agent_board.agents a on a.id = c.agent_id
161
+ where a.parent_agent_id is not null and c.distance < 2
162
+ )
163
+ select a.*, v_depth - c.distance as identity_depth from ancestry c join agent_board.agents a on a.id = c.agent_id
164
+ order by c.distance desc
165
+ loop
166
+ if coalesce(v_node.root_agent_id, v_node.id) is distinct from v_root then
167
+ raise exception 'session identity lineage mismatch' using errcode = '23514';
168
+ end if;
169
+ if tg_op = 'INSERT' and v_node.id = new.id and not exists (
170
+ select 1 from agent_board.session_identities where agent_id = new.id
171
+ ) then
172
+ insert into agent_board.session_identities(agent_id, parent_agent_id, root_agent_id, depth)
173
+ values (new.id, v_node.parent_agent_id, v_root, v_node.identity_depth);
174
+ end if;
175
+ if not exists (select 1 from agent_board.session_identities where agent_id = v_node.id) then
176
+ raise exception 'session identity lineage mismatch' using errcode = '23514';
177
+ end if;
178
+ if exists (select 1 from agent_board.session_identities si where si.agent_id = v_node.id
179
+ and (si.parent_agent_id is distinct from v_node.parent_agent_id
180
+ or si.root_agent_id is distinct from v_root or si.depth is distinct from v_node.identity_depth)) then
181
+ raise exception 'session identity lineage mismatch' using errcode = '23514';
182
+ end if;
183
+ end loop;
184
+ return new;
185
+ end;
186
+ $$;
187
+ create constraint trigger trg_session_identity_agent_lineage
188
+ after insert or update of parent_agent_id, root_agent_id on agent_board.agents
189
+ deferrable initially deferred for each row execute function agent_board._session_identity_agent_lineage();
190
+ revoke all on function agent_board._session_identity_agent_lineage() from public, anon, authenticated;
191
+ grant execute on function agent_board._session_identity_agent_lineage() to service_role;
192
+ comment on function agent_board._session_identity_agent_lineage() is 'At commit every legacy constructor gets an identity with no implicit lease or fingerprint approval. Existing explicit session rows retain their immutable kind. Derives depth and root from agents, refuses depth three and cycles, and enforces lineage equality on agent inserts and lineage updates.';
193
+
194
+ alter table agent_board.session_identities enable row level security;
195
+ alter table agent_board.session_id_aliases enable row level security;
196
+ revoke all on table agent_board.session_identities, agent_board.session_id_aliases from public, anon, authenticated;
197
+ grant select on agent_board.session_identities, agent_board.session_id_aliases to service_role;
198
+
199
+ -- A session credential is usable only when its epoch claim is current. Compare
200
+ -- text-to-text so malformed JWT input denies rather than raises.
201
+ create or replace function agent_board.current_agent_id()
202
+ returns uuid language sql stable security definer set search_path = '' as $$
203
+ with identity_claims as (
204
+ select coalesce(
205
+ nullif(current_setting('request.jwt.claim.identity_lease_epoch', true), ''),
206
+ nullif(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'identity_lease_epoch', '')
207
+ ) as lease_epoch,
208
+ coalesce(
209
+ nullif(current_setting('request.jwt.claim.agent_credential_id', true), ''),
210
+ nullif(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'agent_credential_id', '')
211
+ ) as credential_id
212
+ )
213
+ select a.id
214
+ from agent_board.agents a cross join identity_claims c
215
+ left join agent_board.session_identities si on si.agent_id = a.id
216
+ where a.auth_user_id = auth.uid()
217
+ and a.status = 'active'
218
+ and (
219
+ (c.credential_id is null and not a.is_delegate)
220
+ or (c.credential_id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' and exists (
221
+ select 1 from agent_board.agent_credentials ac
222
+ where ac.id::text = lower(c.credential_id) and ac.agent_id = a.id
223
+ and ac.revoked_at is null and (ac.expires_at is null or ac.expires_at > now())
224
+ ))
225
+ )
226
+ and (si.identity_kind is distinct from 'session' or c.lease_epoch = si.credential_epoch::text)
227
+ limit 1;
228
+ $$;
229
+ revoke all on function agent_board.current_agent_id() from public;
230
+ grant execute on function agent_board.current_agent_id() to authenticated, service_role;
231
+ comment on function agent_board.current_agent_id() is 'Resolves an active principal, preserves hosted delegates'' mandatory credential claim, and denies a session identity unless identity_lease_epoch from either JWT GUC equals its current stored epoch.';
232
+
233
+ create or replace function agent_board._session_identity_lease_live(p_identity uuid)
234
+ returns boolean language sql stable security definer set search_path = '' as $$
235
+ select exists (
236
+ select 1 from agent_board.session_identities si
237
+ where si.agent_id = p_identity and si.identity_kind = 'session'
238
+ and si.tombstoned_at is null and si.holder_secret_hash is not null
239
+ and si.last_heartbeat > now() - interval '180 seconds'
240
+ );
241
+ $$;
242
+ revoke all on function agent_board._session_identity_lease_live(uuid) from public, anon, authenticated;
243
+ grant execute on function agent_board._session_identity_lease_live(uuid) to service_role;
244
+ comment on function agent_board._session_identity_lease_live(uuid) is 'A non-tombstoned claimed session lease is live strictly before 180 seconds since its heartbeat.';
245
+
246
+ create or replace function agent_board.heartbeat_session_identity(p_identity_agent_id uuid)
247
+ returns void language plpgsql security definer set search_path = agent_board, public as $$
248
+ declare v_identity agent_board.session_identities%rowtype;
249
+ begin
250
+ if agent_board.current_agent_id() is distinct from p_identity_agent_id then
251
+ raise exception 'identity_lease_not_held' using errcode = '42501';
252
+ end if;
253
+ select * into v_identity from agent_board.session_identities where agent_id = p_identity_agent_id for update;
254
+ if agent_board.current_agent_id() is distinct from p_identity_agent_id then
255
+ raise exception 'identity_lease_not_held' using errcode = '42501';
256
+ end if;
257
+ if v_identity.agent_id is null or not agent_board._session_identity_lease_live(p_identity_agent_id) then
258
+ raise exception 'identity_lease_lapsed' using errcode = '42501';
259
+ end if;
260
+ if v_identity.resolution_state = 'frozen' or v_identity.approved_risky_digest is null
261
+ or v_identity.current_risky_digest is distinct from v_identity.approved_risky_digest then
262
+ raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
263
+ end if;
264
+ update agent_board.session_identities set last_heartbeat = now(), updated_at = now() where agent_id = p_identity_agent_id;
265
+ end;
266
+ $$;
267
+ revoke all on function agent_board.heartbeat_session_identity(uuid) from public, anon;
268
+ grant execute on function agent_board.heartbeat_session_identity(uuid) to authenticated;
269
+ comment on function agent_board.heartbeat_session_identity(uuid) is 'Current epoch holder refreshes a live session lease only; a lapsed lease must be reclaimed through mint_fleet_agent_session.';
270
+
271
+ create or replace function agent_board.renew_session_identity(p_identity_agent_id uuid, p_risky_digest text)
272
+ returns void language plpgsql security definer set search_path = agent_board, public as $$
273
+ declare v_identity agent_board.session_identities%rowtype;
274
+ begin
275
+ if not agent_board._is_service_role() then
276
+ raise exception 'session renewal is service_role-only' using errcode = '42501';
277
+ end if;
278
+ perform 1 from agent_board.agents where id = (
279
+ select root_agent_id from agent_board.session_identities where agent_id = p_identity_agent_id
280
+ ) for update;
281
+ select * into v_identity from agent_board.session_identities where agent_id = p_identity_agent_id for update;
282
+ if v_identity.tombstoned_at is not null then raise exception 'identity_tombstoned' using errcode = '42501'; end if;
283
+ if agent_board.current_agent_id() is distinct from p_identity_agent_id then raise exception 'identity_lease_not_held' using errcode = '42501'; end if;
284
+ if v_identity.agent_id is null or not agent_board._session_identity_lease_live(p_identity_agent_id) then raise exception 'identity_lease_lapsed' using errcode = '42501'; end if;
285
+ if v_identity.resolution_state = 'frozen' or v_identity.approved_risky_digest is null
286
+ or v_identity.approved_risky_digest is distinct from p_risky_digest
287
+ or v_identity.current_risky_digest is distinct from v_identity.approved_risky_digest then
288
+ raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
289
+ end if;
290
+ perform * from agent_board.renew_delegated_grant(p_identity_agent_id, interval '7 days');
291
+ update agent_board.session_identities set current_risky_digest = p_risky_digest, last_heartbeat = now(), updated_at = now() where agent_id = p_identity_agent_id;
292
+ end;
293
+ $$;
294
+ revoke all on function agent_board.renew_session_identity(uuid, text) from public, anon, authenticated;
295
+ grant execute on function agent_board.renew_session_identity(uuid, text) to service_role;
296
+ comment on function agent_board.renew_session_identity(uuid, text) is 'Service-only fresh-fingerprint renewal; SIDSERVER must bind the reading and forward the verified holder subject and epoch. Extends the same live identity grant by at most seven days, current ancestor authority and tighter operator policy; never rotates the holder or revives a tombstone.';
297
+
298
+ create function agent_board.link_session_alias(
299
+ p_identity_agent_id uuid,
300
+ p_identity_lease_epoch text,
301
+ p_session_id text
302
+ )
303
+ returns void language plpgsql security definer set search_path = agent_board, public as $$
304
+ declare v_identity agent_board.session_identities%rowtype;
305
+ v_root uuid; v_board uuid; v_locked_boards uuid[] := '{}'; v_subject record;
306
+ v_membership agent_board.board_memberships%rowtype; v_allowed jsonb;
307
+ v_parent_role text; v_role text;
308
+ begin
309
+ if not agent_board._is_service_role() then
310
+ raise exception 'link_session_alias is service_role-only' using errcode = '42501';
311
+ end if;
312
+ -- Board mutexes precede the root and deterministic identity/alias row locks.
313
+ select root_agent_id into v_root from agent_board.session_identities where agent_id = p_identity_agent_id;
314
+ for v_board in
315
+ with recursive children(agent_id) as (
316
+ select p_identity_agent_id union all
317
+ select si.agent_id from agent_board.session_identities si join children c on si.parent_agent_id = c.agent_id
318
+ )
319
+ select distinct bm.board_id from agent_board.board_memberships bm join children c on c.agent_id = bm.agent_id order by 1
320
+ loop
321
+ perform agent_board._lock_board_authority_ceiling(v_board);
322
+ v_locked_boards := array_append(v_locked_boards, v_board);
323
+ end loop;
324
+ perform 1 from agent_board.agents where id = v_root for update;
325
+ perform 1 from agent_board.session_identities where agent_id in (
326
+ with recursive children(agent_id) as (
327
+ select p_identity_agent_id union all
328
+ select si.agent_id from agent_board.session_identities si join children c on si.parent_agent_id = c.agent_id
329
+ ) select agent_id from children
330
+ ) order by agent_id for update;
331
+ select * into v_identity from agent_board.session_identities where agent_id = p_identity_agent_id for update;
332
+ if not found or v_identity.identity_kind <> 'session' then raise exception 'identity_lease_not_held' using errcode = '42501'; end if;
333
+ if not exists (select 1 from agent_board.agents a where a.id = p_identity_agent_id
334
+ and a.auth_user_id = auth.uid() and a.status = 'active') then
335
+ raise exception 'identity_lease_not_held' using errcode = '42501';
336
+ end if;
337
+ if p_identity_lease_epoch is distinct from v_identity.credential_epoch::text then raise exception 'identity_lease_not_held' using errcode = '42501'; end if;
338
+ if not agent_board._session_identity_lease_live(v_identity.agent_id) then raise exception 'identity_lease_lapsed' using errcode = '42501'; end if;
339
+ if v_identity.resolution_state = 'frozen' or v_identity.approved_risky_digest is null
340
+ or v_identity.current_risky_digest is distinct from v_identity.approved_risky_digest then
341
+ raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
342
+ end if;
343
+ perform agent_board._session_identity_ancestor_ceiling(v_identity.parent_agent_id);
344
+ if p_session_id is null or length(p_session_id) = 0 then raise exception 'unattributed_session_refused' using errcode = '22023'; end if;
345
+ if exists (
346
+ select 1 from agent_board.session_id_aliases a
347
+ where a.root_agent_id = v_identity.root_agent_id
348
+ and a.harness = v_identity.harness
349
+ and a.session_id = p_session_id
350
+ and a.identity_agent_id <> v_identity.agent_id
351
+ ) then raise exception 'identity_continuity_refused' using errcode = '42501'; end if;
352
+ if not exists (
353
+ select 1 from agent_board.session_id_aliases a
354
+ where a.identity_agent_id = v_identity.agent_id and a.session_id = p_session_id
355
+ ) and v_identity.harness <> 'claude' then
356
+ raise exception 'identity_continuity_refused' using errcode = '42501';
357
+ end if;
358
+ -- Re-read current authority, parent before descendant. No parent authority can
359
+ -- add to the subject's stored grant, role or currently active room set.
360
+ for v_subject in
361
+ with recursive children(agent_id) as (
362
+ select p_identity_agent_id union all
363
+ select si.agent_id from agent_board.session_identities si join children c on si.parent_agent_id = c.agent_id
364
+ ) select si.* from agent_board.session_identities si join children c on c.agent_id = si.agent_id
365
+ order by si.depth, si.agent_id
366
+ loop
367
+ perform agent_board._refuse_delegate_membership_target(v_subject.agent_id, 'link_session_alias');
368
+ if exists (select 1 from agent_board.board_memberships bm where bm.agent_id = v_subject.agent_id
369
+ and not (bm.board_id = any(v_locked_boards))) then
370
+ raise exception 'identity authority changed during link; retry' using errcode = '40001';
371
+ end if;
372
+ if exists (select 1 from agent_board.agents a where a.id = v_subject.parent_agent_id and a.parent_agent_id is null) then
373
+ select dp.allowed_scopes into v_allowed from agent_board.delegation_policies dp
374
+ where dp.root_agent_id = v_root and dp.revoked_at is null order by dp.created_at desc, dp.id desc limit 1;
375
+ else
376
+ select g.granted_scopes into v_allowed from agent_board.delegation_grants g where g.child_agent_id = v_subject.parent_agent_id;
377
+ end if;
378
+ update agent_board.delegation_grants g
379
+ set granted_scopes = (select coalesce(jsonb_agg(s.value), '[]'::jsonb)
380
+ from jsonb_array_elements(g.granted_scopes) s where coalesce(v_allowed, '[]'::jsonb) @> jsonb_build_array(s.value))
381
+ where g.child_agent_id = v_subject.agent_id;
382
+ for v_membership in select bm.* from agent_board.board_memberships bm
383
+ where bm.agent_id = v_subject.agent_id and bm.membership_state in ('joined', 'subscribed')
384
+ order by bm.board_id, bm.id for update
385
+ loop
386
+ select bm.role into v_parent_role from agent_board.board_memberships bm
387
+ where bm.agent_id = v_subject.parent_agent_id and bm.board_id = v_membership.board_id
388
+ and bm.membership_state in ('joined', 'subscribed')
389
+ order by agent_board._role_rank(bm.role) desc, bm.created_at desc, bm.id desc limit 1;
390
+ v_role := null;
391
+ if v_parent_role is not null then
392
+ select r into v_role from unnest(array['owner', 'admin', 'member', 'observer']) r
393
+ where agent_board._role_rank(r) <= agent_board._role_rank(v_membership.role)
394
+ and agent_board._role_rank(r) <= agent_board._role_rank(v_parent_role)
395
+ and agent_board._grant_within_ceiling(v_subject.parent_agent_id, v_membership.board_id, r)
396
+ order by agent_board._role_rank(r) desc limit 1;
397
+ end if;
398
+ if v_role is null then
399
+ perform agent_board._upsert_board_membership_state(v_membership.board_id, v_subject.agent_id,
400
+ 'removed', null, '{}'::jsonb, v_membership.id);
401
+ elsif v_role is distinct from v_membership.role then
402
+ update agent_board.board_memberships set role = v_role where id = v_membership.id;
403
+ end if;
404
+ end loop;
405
+ end loop;
406
+ perform 1 from agent_board.session_id_aliases a where a.root_agent_id = v_identity.root_agent_id
407
+ and a.harness = v_identity.harness and a.session_id = p_session_id for update;
408
+ insert into agent_board.session_id_aliases(identity_agent_id, root_agent_id, harness, session_id)
409
+ values (v_identity.agent_id, v_identity.root_agent_id, v_identity.harness, p_session_id)
410
+ on conflict (root_agent_id, harness, session_id) do nothing;
411
+ update agent_board.session_identities
412
+ set lease_alias_id = (select a.id from agent_board.session_id_aliases a
413
+ where a.identity_agent_id = v_identity.agent_id and a.session_id = p_session_id), updated_at = now()
414
+ where agent_id = v_identity.agent_id;
415
+ perform agent_board._assert_actor_not_fleet_revoked(p_identity_agent_id, 'link_session_alias');
416
+ end;
417
+ $$;
418
+ revoke all on function agent_board.link_session_alias(uuid, text, text) from public, anon, authenticated;
419
+ grant execute on function agent_board.link_session_alias(uuid, text, text) to service_role;
420
+ comment on function agent_board.link_session_alias(uuid, text, text) is 'Service-only continuity alias link: SIDSERVER forwards the verified holder subject, identity, epoch and measured id. Board mutexes precede root and deterministic identity/alias locks. Rechecks live lease, approved digest and ancestors; scopes, active rooms and roles only shrink, parent before descendants, through the consent sink for removals. A concurrent newly added room refuses 40001 for a safe retry. The latest binding is recorded even for an existing alias; only Claude may add a new id.';
421
+
422
+ -- Every session-form issuer walks the stored lineage, not a caller's root or
423
+ -- the child's self-written policy. Legacy API calls retain their original gate.
424
+ create function agent_board._session_identity_ancestor_ceiling(p_agent_id uuid)
425
+ returns timestamptz language plpgsql security definer set search_path = '' as $$
426
+ declare v_ancestor record; v_expires timestamptz := 'infinity';
427
+ begin
428
+ for v_ancestor in
429
+ with recursive lineage as (
430
+ select a.id, a.parent_agent_id from agent_board.agents a where a.id = p_agent_id
431
+ union all
432
+ select a.id, a.parent_agent_id from agent_board.agents a join lineage l on a.id = l.parent_agent_id
433
+ )
434
+ select a.id, a.status, si.identity_kind, si.tombstoned_at, si.resolution_state,
435
+ si.current_risky_digest, si.approved_risky_digest,
436
+ g.id as grant_id, g.revoked_at, g.grant_expires_at, dp.revoked_at as policy_revoked_at
437
+ from lineage l join agent_board.agents a on a.id = l.id
438
+ left join agent_board.session_identities si on si.agent_id = a.id
439
+ left join agent_board.delegation_grants g on g.child_agent_id = a.id
440
+ left join agent_board.delegation_policies dp on dp.id = g.delegation_policy_id
441
+ loop
442
+ if agent_board._actor_fleet_revoked(v_ancestor.id) then
443
+ raise exception 'identity_parent_inactive' using errcode = '42501';
444
+ end if;
445
+ if v_ancestor.status <> 'active' or v_ancestor.tombstoned_at is not null then
446
+ raise exception 'identity_parent_inactive' using errcode = '42501';
447
+ end if;
448
+ if v_ancestor.grant_id is not null and (v_ancestor.revoked_at is not null
449
+ or v_ancestor.policy_revoked_at is not null or v_ancestor.grant_expires_at <= now()) then
450
+ raise exception 'identity_parent_inactive' using errcode = '42501';
451
+ end if;
452
+ if v_ancestor.resolution_state = 'frozen' or (v_ancestor.identity_kind = 'session'
453
+ and (v_ancestor.approved_risky_digest is null
454
+ or v_ancestor.current_risky_digest is distinct from v_ancestor.approved_risky_digest)) then
455
+ raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
456
+ end if;
457
+ v_expires := least(v_expires, v_ancestor.grant_expires_at);
458
+ end loop;
459
+ return v_expires;
460
+ end;
461
+ $$;
462
+ revoke all on function agent_board._session_identity_ancestor_ceiling(uuid) from public, anon, authenticated, service_role;
463
+ comment on function agent_board._session_identity_ancestor_ceiling(uuid) is 'Internal session-form ancestor gate: every stored ancestor must be active, unrevoked, unexpired and non-frozen; returns the tightest grant expiry.';
464
+
465
+ create function agent_board._session_identity_check_limits(p_root uuid, p_parent uuid, p_reactivation uuid default null)
466
+ returns void language plpgsql security definer set search_path = '' as $$
467
+ declare v_active bigint; v_hourly bigint; v_parent_depth smallint; v_policy_limit int;
468
+ begin
469
+ -- Caller has already locked the true root; count both depths and legacy grants.
470
+ select count(*) into v_active
471
+ from agent_board.delegation_grants g
472
+ join agent_board.agents a on a.id = g.child_agent_id
473
+ left join agent_board.session_identities si on si.agent_id = a.id
474
+ where g.root_agent_id = p_root and a.status = 'active' and g.revoked_at is null
475
+ and g.grant_expires_at > now() and a.id is distinct from p_reactivation
476
+ and (si.identity_kind is distinct from 'session' or agent_board._session_identity_lease_live(a.id));
477
+ select count(*) into v_hourly from agent_board.delegation_grants g
478
+ where g.root_agent_id = p_root and g.created_at > now() - interval '3600 seconds';
479
+ if v_active >= 200 then raise exception 'identity_limit_active_exceeded' using errcode = '42501'; end if;
480
+ select least(coalesce(dp.max_active_children, 200), coalesce(dp.operator_max_active_children, 200), 200)
481
+ into v_policy_limit from agent_board.delegation_policies dp
482
+ where dp.root_agent_id = p_root and dp.revoked_at is null
483
+ order by dp.created_at desc, dp.id desc limit 1;
484
+ if v_active >= v_policy_limit then
485
+ raise exception 'max_active_children: root has reached its active delegated-child ceiling' using errcode = '42501';
486
+ end if;
487
+ if p_reactivation is null and v_hourly >= 60 then raise exception 'identity_limit_hourly_exceeded' using errcode = '42501'; end if;
488
+ select depth into v_parent_depth from agent_board.session_identities where agent_id = p_parent;
489
+ if v_parent_depth = 1 then
490
+ select count(*) into v_active
491
+ from agent_board.delegation_grants g join agent_board.agents a on a.id = g.child_agent_id
492
+ left join agent_board.session_identities si on si.agent_id = a.id
493
+ where g.parent_agent_id = p_parent and a.status = 'active' and g.revoked_at is null
494
+ and g.grant_expires_at > now() and a.id is distinct from p_reactivation
495
+ and (si.identity_kind is distinct from 'session' or agent_board._session_identity_lease_live(a.id));
496
+ select count(*) into v_hourly from agent_board.delegation_grants
497
+ where parent_agent_id = p_parent and created_at > now() - interval '3600 seconds';
498
+ if v_active >= 20 then raise exception 'identity_limit_active_exceeded' using errcode = '42501'; end if;
499
+ if p_reactivation is null and v_hourly >= 10 then raise exception 'identity_limit_hourly_exceeded' using errcode = '42501'; end if;
500
+ end if;
501
+ end;
502
+ $$;
503
+ revoke all on function agent_board._session_identity_check_limits(uuid, uuid, uuid) from public, anon, authenticated, service_role;
504
+ comment on function agent_board._session_identity_check_limits(uuid, uuid, uuid) is 'Internal root-locked descendant-wide mixed legacy/session census: 200/60 per root and 20/10 per depth-one parent; unclaimed sessions are hourly-only and tombstoned mints remain in the rolling hour.';
505
+
506
+ create or replace function agent_board.upsert_delegation_policy(
507
+ p_root_agent_id uuid, p_mode text default 'root_minted', p_allowed_scopes jsonb default null,
508
+ p_max_active_children int default null, p_child_ttl_ceiling interval default null,
509
+ p_require_child_signing_key boolean default null
510
+ )
511
+ returns agent_board.delegation_policies language plpgsql security definer set search_path = agent_board, public as $$
512
+ declare
513
+ v_actor uuid := agent_board._resolved_actor_agent_id(p_root_agent_id, 'p_root_agent_id');
514
+ v_is_service boolean := agent_board._is_service_role();
515
+ v_max int; v_ttl interval := coalesce(p_child_ttl_ceiling, interval '8 hours');
516
+ v_operator_max int; v_operator_ttl interval;
517
+ v_existing agent_board.delegation_policies%rowtype;
518
+ v_policy agent_board.delegation_policies%rowtype;
519
+ begin
520
+ perform 1 from agent_board.agents where id = v_actor for update;
521
+ if not found then raise exception 'root agent not found' using errcode = 'P0002'; end if;
522
+ perform pg_advisory_xact_lock(hashtext('agent_board.delegation_policy:' || v_actor::text));
523
+ if p_mode is not null and p_mode not in ('independent_auth', 'root_minted') then
524
+ raise exception 'invalid delegation policy mode' using errcode = '22023';
525
+ end if;
526
+ if v_ttl <= interval '0' then raise exception 'child_ttl_ceiling must be positive' using errcode = '22023'; end if;
527
+ v_max := case when v_is_service then p_max_active_children else coalesce(p_max_active_children, 5) end;
528
+ if not v_is_service then
529
+ if v_max < 1 or v_max > 200 then
530
+ raise exception 'max_active_children must be between 1 and 200 for self-service delegation policies' using errcode = '22023';
531
+ end if;
532
+ if v_ttl > interval '24 hours' then
533
+ raise exception 'child_ttl_ceiling may not exceed 24 hours for self-service delegation policies' using errcode = '22023';
534
+ end if;
535
+ end if;
536
+ select * into v_existing from agent_board.delegation_policies dp
537
+ where dp.root_agent_id = v_actor and dp.revoked_at is null
538
+ order by dp.created_at desc, dp.id desc limit 1 for update;
539
+ v_operator_max := case when v_is_service then v_max else v_existing.operator_max_active_children end;
540
+ v_operator_ttl := case when v_is_service then v_ttl else v_existing.operator_child_ttl_ceiling end;
541
+ if v_existing.id is not null then
542
+ update agent_board.delegation_policies dp
543
+ set mode = coalesce(p_mode, 'root_minted'), max_depth = 2, max_active_children = v_max,
544
+ allowed_scopes = coalesce(p_allowed_scopes, '[]'::jsonb), child_ttl_ceiling = v_ttl,
545
+ require_child_signing_key = case when v_is_service then coalesce(p_require_child_signing_key, true) else true end,
546
+ scope_attestation = case when v_is_service then 'operator_vetted' else 'self_declared' end,
547
+ operator_max_active_children = v_operator_max, operator_child_ttl_ceiling = v_operator_ttl
548
+ where dp.id = v_existing.id returning * into v_policy;
549
+ else
550
+ insert into agent_board.delegation_policies(root_agent_id, mode, max_depth, max_active_children, allowed_scopes,
551
+ child_ttl_ceiling, require_child_signing_key, scope_attestation, operator_max_active_children, operator_child_ttl_ceiling)
552
+ values (v_actor, coalesce(p_mode, 'root_minted'), 2, v_max, coalesce(p_allowed_scopes, '[]'::jsonb), v_ttl,
553
+ case when v_is_service then coalesce(p_require_child_signing_key, true) else true end,
554
+ case when v_is_service then 'operator_vetted' else 'self_declared' end, v_operator_max, v_operator_ttl)
555
+ returning * into v_policy;
556
+ end if;
557
+ return v_policy;
558
+ end;
559
+ $$;
560
+ revoke all on function agent_board.upsert_delegation_policy(uuid, text, jsonb, int, interval, boolean) from public, anon;
561
+ grant execute on function agent_board.upsert_delegation_policy(uuid, text, jsonb, int, interval, boolean) to authenticated, service_role;
562
+ comment on function agent_board.upsert_delegation_policy(uuid, text, jsonb, int, interval, boolean) is 'Sole delegation-policy writer, serialized with mint and reactivation on the root. Self-service actor is session-derived, max children is 1..200, depth is two, keys are required and unattributed TTL stays <=24h. Operator writes establish durable ceilings; self-service preserves them.';
563
+
564
+ create or replace function agent_board.renew_delegated_grant(p_child_agent_id uuid, p_extend interval default null)
565
+ returns table(grant_expires_at timestamptz) language plpgsql security definer set search_path = agent_board, public as $$
566
+ declare
567
+ v_grant agent_board.delegation_grants%rowtype;
568
+ v_policy agent_board.delegation_policies%rowtype;
569
+ v_identity agent_board.session_identities%rowtype;
570
+ v_root uuid; v_ceiling interval; v_ancestor_expires timestamptz; v_new_expires_at timestamptz;
571
+ begin
572
+ if not agent_board._is_service_role() then
573
+ raise exception 'renew_delegated_grant is service_role-only' using errcode = '42501';
574
+ end if;
575
+ if p_extend <= interval '0' then raise exception 'non_positive_ttl: requested grant TTL must be positive' using errcode = '22023'; end if;
576
+ select g.root_agent_id into v_root from agent_board.delegation_grants g where g.child_agent_id = p_child_agent_id;
577
+ perform 1 from agent_board.agents where id = v_root for update;
578
+ select * into v_identity from agent_board.session_identities where agent_id = p_child_agent_id for update;
579
+ if v_identity.identity_kind = 'session' then
580
+ if v_identity.tombstoned_at is not null then raise exception 'identity_tombstoned' using errcode = '42501'; end if;
581
+ if v_identity.resolution_state = 'frozen' or v_identity.approved_risky_digest is null
582
+ or v_identity.current_risky_digest is distinct from v_identity.approved_risky_digest then
583
+ raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
584
+ end if;
585
+ if agent_board.current_agent_id() is distinct from p_child_agent_id then raise exception 'identity_lease_not_held' using errcode = '42501'; end if;
586
+ if not agent_board._session_identity_lease_live(p_child_agent_id) then raise exception 'identity_lease_lapsed' using errcode = '42501'; end if;
587
+ end if;
588
+ select * into v_grant from agent_board.delegation_grants g where g.child_agent_id = p_child_agent_id for update;
589
+ if not found then raise exception 'delegation grant not found for child' using errcode = 'P0002'; end if;
590
+ if v_grant.revoked_at is not null then raise exception 'grant_revoked: cannot renew a revoked delegation grant' using errcode = '42501'; end if;
591
+ select * into v_policy from agent_board.delegation_policies dp where dp.id = v_grant.delegation_policy_id;
592
+ if not found or v_policy.revoked_at is not null then
593
+ raise exception 'policy_revoked: the root delegation policy is revoked' using errcode = '42501';
594
+ end if;
595
+ if not agent_board._agent_principal_active(v_grant.root_agent_id) then
596
+ raise exception 'principal_inactive: root agent principal is not active; grant cannot outlive its root' using errcode = '42501';
597
+ end if;
598
+ v_ancestor_expires := agent_board._session_identity_ancestor_ceiling(v_grant.parent_agent_id);
599
+ if v_grant.grant_expires_at <= now() or v_identity.identity_kind = 'session' then
600
+ perform agent_board._session_identity_check_limits(v_grant.root_agent_id, v_grant.parent_agent_id, p_child_agent_id);
601
+ end if;
602
+ v_ceiling := least(v_policy.child_ttl_ceiling, v_policy.operator_child_ttl_ceiling);
603
+ if v_identity.identity_kind = 'session' then v_ceiling := least(v_ceiling, interval '7 days');
604
+ elsif v_policy.scope_attestation = 'self_declared' then v_ceiling := least(v_ceiling, interval '24 hours'); end if;
605
+ v_new_expires_at := least(now() + least(coalesce(p_extend, v_ceiling), v_ceiling), v_ancestor_expires);
606
+ update agent_board.delegation_grants set grant_expires_at = v_new_expires_at, last_renewed_at = now()
607
+ where child_agent_id = p_child_agent_id;
608
+ return query select v_new_expires_at;
609
+ end;
610
+ $$;
611
+ revoke all on function agent_board.renew_delegated_grant(uuid, interval) from public, anon, authenticated;
612
+ grant execute on function agent_board.renew_delegated_grant(uuid, interval) to service_role;
613
+ comment on function agent_board.renew_delegated_grant(uuid, interval) is 'Service-role renewal under root then identity and grant locks. Every path honors durable operator TTL, all ancestor authority and expired-renew active recount. Session identities additionally require the verified current-epoch live holder and approved unchanged fingerprint, with a seven-day bound; legacy self-declared grants retain 24h and operator legacy grants retain their bounded latitude.';
614
+
615
+ drop function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean);
616
+
617
+ create function agent_board.mint_delegated_child(
618
+ p_root_agent_id uuid, p_child_slug text, p_requested_scopes jsonb,
619
+ p_child_public_key text default null, p_ttl interval default null, p_self_service boolean default false,
620
+ p_session_harness text default null, p_session_id text default null, p_minter_lease_epoch text default null,
621
+ p_requested_rooms jsonb default '[]'::jsonb, p_fingerprint jsonb default null, p_forked_from uuid default null
622
+ ) returns table(child_agent_id uuid, grant_expires_at timestamptz, signing_key_id uuid)
623
+ language plpgsql security definer set search_path = agent_board, public as $$
624
+ declare
625
+ v_minter agent_board.session_identities%rowtype;
626
+ v_root agent_board.agents%rowtype;
627
+ v_policy agent_board.delegation_policies%rowtype;
628
+ v_true_root uuid;
629
+ v_depth smallint;
630
+ v_allowed jsonb;
631
+ v_requested jsonb := coalesce(p_requested_scopes, '[]'::jsonb);
632
+ v_child_id uuid;
633
+ v_key_id uuid;
634
+ v_child_auth uuid;
635
+ v_slug text;
636
+ v_ceiling interval;
637
+ v_ancestor_expires timestamptz := 'infinity';
638
+ v_expires timestamptz;
639
+ v_room jsonb;
640
+ v_board uuid;
641
+ v_role text;
642
+ v_parent_role text;
643
+ v_membership agent_board.board_memberships%rowtype;
644
+ v_approved text;
645
+ v_reserved agent_board.session_identities%rowtype;
646
+ v_reserved_id uuid;
647
+ v_reserved_alias uuid;
648
+ v_owner uuid;
649
+ begin
650
+ if not agent_board._is_service_role() then
651
+ raise exception 'mint_delegated_child is service_role-only' using errcode = '42501';
652
+ end if;
653
+ if p_ttl is not null and p_ttl <= interval '0' then
654
+ raise exception 'non_positive_ttl: requested grant TTL must be positive' using errcode = '22023';
655
+ end if;
656
+ v_slug := agent_board._normalize_provision_slug(p_child_slug);
657
+ select coalesce(a.root_agent_id, a.id) into v_true_root from agent_board.agents a where a.id = p_root_agent_id;
658
+ if not found then raise exception 'root agent not found' using errcode = 'P0002'; end if;
659
+ -- Board mutexes precede agent and membership row locks, as in grant_board_role.
660
+ if jsonb_typeof(coalesce(p_requested_rooms, '[]'::jsonb)) <> 'array'
661
+ or jsonb_typeof(v_requested) <> 'array' then
662
+ raise exception 'rooms and scopes must be arrays' using errcode = '22023';
663
+ end if;
664
+ for v_board in select distinct (r ->> 'board_id')::uuid
665
+ from jsonb_array_elements(coalesce(p_requested_rooms, '[]'::jsonb)) r order by 1 loop
666
+ perform agent_board._lock_board_authority_ceiling(v_board);
667
+ perform 1 from agent_board.boards where id = v_board for key share;
668
+ end loop;
669
+ select owner_user_id into v_owner from agent_board.agents where id = p_root_agent_id;
670
+ perform 1 from auth.users where id = v_owner for key share;
671
+ perform 1 from agent_board.agents where id = v_true_root for update;
672
+ select * into v_root from agent_board.agents where id = p_root_agent_id;
673
+ if v_root.owner_user_id is distinct from v_owner then
674
+ raise exception 'identity owner changed during mint; retry' using errcode = '40001';
675
+ end if;
676
+ select * into v_minter from agent_board.session_identities where agent_id = p_root_agent_id for update;
677
+ v_depth := coalesce(v_minter.depth, case when v_root.parent_agent_id is null then 0 else 1 end);
678
+ if v_minter.tombstoned_at is not null then raise exception 'identity_tombstoned' using errcode = '42501'; end if;
679
+ if exists (
680
+ with recursive ancestry(id, parent_agent_id) as (
681
+ select id, parent_agent_id from agent_board.agents where id = p_root_agent_id
682
+ union all
683
+ select a.id, a.parent_agent_id from agent_board.agents a join ancestry c on a.id = c.parent_agent_id
684
+ )
685
+ select 1 from ancestry a join agent_board.session_identities si on si.agent_id = a.id
686
+ where si.resolution_state = 'frozen'
687
+ ) then raise exception 'identity_fingerprint_unapproved' using errcode = '42501'; end if;
688
+ if v_minter.identity_kind = 'session' and p_minter_lease_epoch is distinct from v_minter.credential_epoch::text then
689
+ raise exception 'identity_lease_not_held' using errcode = '42501';
690
+ end if;
691
+ if (p_session_harness is null) <> (p_session_id is null) then
692
+ raise exception 'unattributed_session_refused' using errcode = '22023';
693
+ end if;
694
+ if p_session_harness is null then
695
+ if v_root.parent_agent_id is not null or exists (select 1 from agent_board.delegation_grants g where g.child_agent_id = p_root_agent_id) then
696
+ raise exception 'max_depth: only a true lineage root may mint (no transitive delegation)' using errcode = '42501';
697
+ end if;
698
+ if jsonb_array_length(coalesce(p_requested_rooms, '[]'::jsonb)) <> 0
699
+ or p_fingerprint is not null or p_forked_from is not null then
700
+ raise exception 'unattributed_session_refused' using errcode = '22023';
701
+ end if;
702
+ else
703
+ if p_session_harness not in ('agy', 'claude', 'codex', 'grok', 'opencode', 'pi') or length(p_session_id) = 0 then
704
+ raise exception 'unattributed_session_refused' using errcode = '22023';
705
+ end if;
706
+ if v_depth >= 2 then raise exception 'identity_depth_exceeded' using errcode = '42501'; end if;
707
+ if v_depth = 1 and v_minter.identity_kind <> 'session' then
708
+ raise exception 'unattributed_session_refused' using errcode = '42501';
709
+ end if;
710
+ v_ancestor_expires := agent_board._session_identity_ancestor_ceiling(p_root_agent_id);
711
+ if p_forked_from is not null and not exists (
712
+ select 1 from agent_board.session_identities si
713
+ where si.agent_id = p_forked_from and si.identity_kind = 'session'
714
+ and si.root_agent_id = v_true_root and si.parent_agent_id = p_root_agent_id and si.tombstoned_at is null
715
+ ) then raise exception 'identity_tombstoned' using errcode = '42501'; end if;
716
+ end if;
717
+ select * into v_policy from agent_board.delegation_policies dp
718
+ where dp.root_agent_id = v_true_root and dp.revoked_at is null
719
+ order by dp.created_at desc, dp.id desc limit 1 for update;
720
+ if not found then raise exception 'no active delegation policy for root agent' using errcode = '42501'; end if;
721
+ if v_policy.mode <> 'root_minted' then
722
+ raise exception 'delegation policy mode is not root_minted (independent_auth children self-provision)' using errcode = '42501';
723
+ end if;
724
+ if not agent_board._agent_principal_active(p_root_agent_id) then
725
+ raise exception 'principal_inactive: root agent principal is not fully active' using errcode = '42501';
726
+ end if;
727
+ v_allowed := coalesce(v_policy.allowed_scopes, '[]'::jsonb);
728
+ if v_depth > 0 then
729
+ select g.granted_scopes into v_allowed from agent_board.delegation_grants g where g.child_agent_id = p_root_agent_id;
730
+ end if;
731
+ if not (coalesce(v_allowed, '[]'::jsonb) @> v_requested) then
732
+ raise exception 'scope_exceeded: requested scopes are not a subset of the root allowed_scopes' using errcode = '42501';
733
+ end if;
734
+ if p_session_harness is not null then
735
+ -- Match issuance/readmission: lock an existing alias before any supersede
736
+ -- revocation can acquire its old principal's keys or credentials.
737
+ perform 1 from agent_board.session_id_aliases
738
+ where root_agent_id = v_true_root and harness = p_session_harness and session_id = p_session_id
739
+ order by id for update;
740
+ select a.identity_agent_id, a.id into v_reserved_id, v_reserved_alias
741
+ from agent_board.session_id_aliases a
742
+ join agent_board.session_identities si on si.agent_id = a.identity_agent_id
743
+ where a.root_agent_id = coalesce(v_minter.root_agent_id, p_root_agent_id)
744
+ and a.harness = p_session_harness and a.session_id = p_session_id
745
+ for update of si;
746
+ if found then
747
+ select * into v_reserved from agent_board.session_identities
748
+ where agent_id = v_reserved_id;
749
+ if v_reserved.tombstoned_at is not null then
750
+ raise exception 'identity_tombstoned' using errcode = '42501';
751
+ elsif v_reserved.holder_secret_hash is not null and v_reserved.last_heartbeat > now() - interval '180 seconds' then
752
+ raise exception 'duplicate_live_session' using errcode = '42501';
753
+ elsif v_reserved.holder_secret_hash is null and v_reserved.created_at > now() - interval '180 seconds' then
754
+ raise exception 'identity_lease_live' using errcode = '42501';
755
+ elsif v_reserved.holder_secret_hash is not null then
756
+ raise exception 'identity_lease_lapsed' using errcode = '42501';
757
+ end if;
758
+ perform agent_board._revoke_agent_principal(v_reserved.agent_id, p_root_agent_id, 'superseded_unclaimed');
759
+ update agent_board.session_identities
760
+ set tombstoned_at = now(), tombstone_reason = 'superseded_unclaimed', updated_at = now()
761
+ where agent_id = v_reserved.agent_id;
762
+ end if;
763
+ end if;
764
+ perform agent_board._session_identity_check_limits(v_true_root, p_root_agent_id);
765
+ if p_fingerprint is not null then
766
+ if jsonb_typeof(p_fingerprint) <> 'object' or p_fingerprint ->> 'resolution' is distinct from 'resolved'
767
+ or nullif(p_fingerprint ->> 'risky_digest', '') is null then
768
+ raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
769
+ end if;
770
+ if v_minter.resolution_state = 'active' and v_minter.approved_risky_digest = p_fingerprint ->> 'risky_digest'
771
+ and v_minter.approved_risky_digest = v_minter.current_risky_digest then
772
+ v_approved := v_minter.approved_risky_digest;
773
+ end if;
774
+ end if;
775
+ v_ceiling := least(v_policy.child_ttl_ceiling,
776
+ v_policy.operator_child_ttl_ceiling,
777
+ case when p_session_harness is null and not p_self_service then null
778
+ when p_session_harness is null or v_approved is null then interval '24 hours' else interval '7 days' end);
779
+ v_expires := least(now() + least(coalesce(p_ttl, v_ceiling), v_ceiling), v_ancestor_expires);
780
+ v_child_auth := agent_board._ensure_agent_auth_user(v_slug);
781
+ insert into agent_board.agents(agent_type, kind, handle, display_name, status,
782
+ auth_user_id, owner_user_id, parent_agent_id, root_agent_id, metadata)
783
+ values ('agent', 'agent'::agent_board.agent_kind, v_slug, agent_board._default_provision_display_name(v_slug), 'active',
784
+ v_child_auth, v_root.owner_user_id, p_root_agent_id, v_true_root,
785
+ jsonb_build_object('delegatedByRootAgentId', v_true_root, 'grantedScopes', v_requested)) returning id into v_child_id;
786
+ if v_policy.require_child_signing_key or p_session_harness is not null then
787
+ if p_child_public_key is null then
788
+ raise exception 'a child public key is required when the delegation policy requires a signing key' using errcode = '22023';
789
+ end if;
790
+ if p_child_public_key !~ '^[A-Za-z0-9_-]{43}$' then
791
+ raise exception 'malformed child public key: expected a 43-char base64url raw Ed25519 key' using errcode = '22023';
792
+ end if;
793
+ if p_child_public_key = repeat('A', 43) then
794
+ raise exception 'child public key is the all-zero small-order placeholder and is rejected' using errcode = '22023';
795
+ end if;
796
+ insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format,
797
+ fingerprint, status, proof_state, metadata)
798
+ values (gen_random_uuid(), v_child_id, 'delegated-ephemeral', 1, p_child_public_key, 'raw_ed25519_base64url',
799
+ agent_board._raw_key_fingerprint(p_child_public_key), 'active', 'proven',
800
+ jsonb_build_object('ephemeral', true, 'scopes', v_requested)) returning id into v_key_id;
801
+ end if;
802
+ insert into agent_board.delegation_grants(child_agent_id, root_agent_id, parent_agent_id, delegation_policy_id,
803
+ granted_scopes, grant_expires_at)
804
+ values (v_child_id, v_true_root, p_root_agent_id, v_policy.id, v_requested, v_expires);
805
+ insert into agent_board.session_identities(agent_id, parent_agent_id, root_agent_id, depth, identity_kind, harness,
806
+ forked_from, approved_risky_digest, current_risky_digest, recorded_fingerprint, resolution_state)
807
+ values (v_child_id, p_root_agent_id, v_true_root, v_depth + 1,
808
+ case when p_session_harness is null then 'legacy' else 'session' end, p_session_harness, p_forked_from,
809
+ v_approved, p_fingerprint ->> 'risky_digest',
810
+ case when p_fingerprint is null then '{}'::jsonb else
811
+ coalesce(p_fingerprint -> 'recorded', '{}'::jsonb) || jsonb_build_object('resolution', p_fingerprint ->> 'resolution') end,
812
+ case when p_session_harness is null or v_approved is not null then 'active' else 'frozen' end);
813
+ perform agent_board._refuse_delegate_membership_target(v_child_id, 'mint_delegated_child');
814
+ if p_session_harness is not null then
815
+ if v_reserved_alias is null then
816
+ insert into agent_board.session_id_aliases(identity_agent_id, root_agent_id, harness, session_id)
817
+ values (v_child_id, v_true_root, p_session_harness, p_session_id) returning id into v_reserved_alias;
818
+ else
819
+ update agent_board.session_id_aliases set identity_agent_id = v_child_id
820
+ where id = v_reserved_alias;
821
+ end if;
822
+ update agent_board.session_identities set lease_alias_id = v_reserved_alias where agent_id = v_child_id;
823
+ for v_room in select value from jsonb_array_elements(coalesce(p_requested_rooms, '[]'::jsonb)) order by value ->> 'board_id' loop
824
+ v_board := (v_room ->> 'board_id')::uuid;
825
+ v_role := v_room ->> 'role';
826
+ select bm.role into v_parent_role from agent_board.board_memberships bm
827
+ where bm.board_id = v_board and bm.agent_id = p_root_agent_id and bm.membership_state in ('joined', 'subscribed')
828
+ order by agent_board._role_rank(bm.role) desc limit 1;
829
+ if v_role not in ('owner', 'admin', 'member', 'observer') or v_role is null or v_parent_role is null
830
+ or agent_board._role_rank(v_role) > agent_board._role_rank(v_parent_role)
831
+ or not agent_board._grant_within_ceiling(p_root_agent_id, v_board, v_role) then
832
+ raise exception 'room_authority_exceeded' using errcode = '42501';
833
+ end if;
834
+ select * into v_membership from agent_board._upsert_board_membership_state(v_board, v_child_id, 'joined');
835
+ update agent_board.board_memberships set role = v_role where id = v_membership.id;
836
+ end loop;
837
+ end if;
838
+ perform agent_board._assert_actor_not_fleet_revoked(p_root_agent_id, 'mint_delegated_child');
839
+ return query select v_child_id, v_expires, v_key_id;
840
+ end;
841
+ $$;
842
+ revoke all on function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean, text, text, text, jsonb, jsonb, uuid) from public, anon, authenticated;
843
+ grant execute on function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean, text, text, text, jsonb, jsonb, uuid) to service_role;
844
+ comment on function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean, text, text, text, jsonb, jsonb, uuid) is 'Single service-role child mint. The six legacy inputs/results remain compatible. Trusted session context adds measured harness/id, verified minter epoch, explicit [{board_id,role}] rooms, {resolution,risky_digest,recorded} fingerprint and optional same-parent fork. SIDSERVER must bind every new field in child-key PoP before forwarding; public Edge shapes do not supply them. Enforces true root, depth two, granted scopes, ancestor validity, root-locked mixed census and effective lifetime; only matching approved fingerprints get seven days. Membership provenance is derived by the consent sink, never supplied by the caller.';
845
+
846
+ -- The server-facing mint keeps all historical challenge/key gates in the renamed
847
+ -- implementation and adds session lease claim/reclaim semantics around its result.
848
+ alter function agent_board.mint_fleet_agent_session(uuid, text, uuid, uuid, integer)
849
+ rename to _mint_fleet_agent_session_legacy;
850
+ revoke all on function agent_board._mint_fleet_agent_session_legacy(uuid, text, uuid, uuid, integer) from public, anon, authenticated, service_role;
851
+ comment on function agent_board._mint_fleet_agent_session_legacy(uuid,text,uuid,uuid,integer) is 'Private whole-principal PoP issuer, retaining the legacy challenge/key/lineage validation and 900-second credential ceiling. No PostgREST role, including service_role, can execute it directly; the service-only mint_fleet_agent_session wrapper is its sole session-token caller and applies identity lease, fingerprint and epoch gates.';
852
+ create function agent_board.mint_fleet_agent_session(
853
+ p_challenge_id uuid, p_nonce text, p_signing_key_id uuid, p_agent_id uuid,
854
+ p_max_ttl_seconds integer default 900, p_holder_secret text default null, p_session_id text default null
855
+ ) returns table(session_id text, expires_at timestamptz, identity_lease_epoch bigint, holder_secret text)
856
+ language plpgsql security definer set search_path = agent_board, public as $$
857
+ declare v_identity agent_board.session_identities%rowtype; v_session record; v_new_secret text; v_alias uuid;
858
+ v_ancestor_expires timestamptz; v_grant_expires timestamptz;
859
+ begin
860
+ if not agent_board._is_service_role() then raise exception 'session mint is service_role-only' using errcode = '42501'; end if;
861
+ -- Match the existing PoP issuer's challenge-before-principal order.
862
+ perform 1 from agent_board.agent_signing_key_challenges where id = p_challenge_id for update;
863
+ -- Serialize the census without conflicting with key-issuer FK KEY SHARE locks.
864
+ perform 1 from agent_board.agents where id = (
865
+ select root_agent_id from agent_board.session_identities where agent_id = p_agent_id
866
+ ) for no key update;
867
+ select * into v_identity from agent_board.session_identities where agent_id = p_agent_id for update;
868
+ -- Readmission repoints aliases before inserting its new key. Both legacy and
869
+ -- session issuances use the same identity/alias-before-key acquisition order.
870
+ perform 1 from agent_board.session_id_aliases where identity_agent_id = p_agent_id order by id for update;
871
+ if v_identity.tombstoned_at is not null then raise exception 'identity_tombstoned' using errcode = '42501'; end if;
872
+ if exists (
873
+ with recursive ancestry(id, parent_agent_id) as (
874
+ select id, parent_agent_id from agent_board.agents where id = p_agent_id
875
+ union all
876
+ select a.id, a.parent_agent_id from agent_board.agents a join ancestry c on a.id = c.parent_agent_id
877
+ )
878
+ select 1 from ancestry a join agent_board.session_identities si on si.agent_id = a.id
879
+ where si.resolution_state = 'frozen'
880
+ ) then raise exception 'identity_fingerprint_unapproved' using errcode = '42501'; end if;
881
+ if v_identity.agent_id is null or v_identity.identity_kind <> 'session' then
882
+ select * into v_session from agent_board._mint_fleet_agent_session_legacy(p_challenge_id, p_nonce, p_signing_key_id, p_agent_id, p_max_ttl_seconds);
883
+ return query select v_session.session_id, v_session.expires_at, null::bigint, null::text; return;
884
+ end if;
885
+ if v_identity.resolution_state = 'frozen' or v_identity.approved_risky_digest is null
886
+ or v_identity.current_risky_digest is distinct from v_identity.approved_risky_digest then
887
+ raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
888
+ end if;
889
+ v_ancestor_expires := agent_board._session_identity_ancestor_ceiling(v_identity.parent_agent_id);
890
+ if not agent_board._session_identity_lease_live(p_agent_id) then
891
+ perform agent_board._session_identity_check_limits(v_identity.root_agent_id, v_identity.parent_agent_id, p_agent_id);
892
+ end if;
893
+ if not exists (select 1 from agent_board.agent_signing_keys k where k.id = p_signing_key_id
894
+ and k.agent_id = p_agent_id and k.proof_state = 'proven') then
895
+ raise exception 'key_unproven: session key requires proof' using errcode = '42501';
896
+ end if;
897
+ select * into v_session from agent_board._mint_fleet_agent_session_legacy(p_challenge_id, p_nonce, p_signing_key_id, p_agent_id, p_max_ttl_seconds);
898
+ select g.grant_expires_at into v_grant_expires from agent_board.delegation_grants g where g.child_agent_id = p_agent_id;
899
+ v_session.expires_at := least(v_session.expires_at, v_ancestor_expires, v_grant_expires);
900
+ update agent_board.nonfleet_sessions s set expires_at = v_session.expires_at where s.session_id = v_session.session_id;
901
+ if v_identity.holder_secret_hash is not null and v_identity.last_heartbeat > now() - interval '180 seconds' then
902
+ if p_holder_secret is not null and extensions.digest(p_holder_secret, 'sha256') = v_identity.holder_secret_hash then
903
+ return query select v_session.session_id, v_session.expires_at, v_identity.credential_epoch, null::text; return;
904
+ end if;
905
+ if p_holder_secret is not null then raise exception 'identity_lease_not_held' using errcode = '42501'; end if;
906
+ if p_session_id is not null and exists (select 1 from agent_board.session_id_aliases a where a.id = v_identity.lease_alias_id and a.session_id = p_session_id) then
907
+ raise exception 'duplicate_live_session' using errcode = '42501';
908
+ end if;
909
+ raise exception 'identity_lease_live' using errcode = '42501';
910
+ end if;
911
+ select a.id into v_alias from agent_board.session_id_aliases a
912
+ where a.identity_agent_id = p_agent_id and a.session_id = p_session_id;
913
+ if v_identity.holder_secret_hash is null then
914
+ if v_alias is null or v_alias is distinct from v_identity.lease_alias_id then raise exception 'identity_continuity_refused' using errcode = '42501'; end if;
915
+ elsif v_alias is null then
916
+ if p_session_id is null or length(p_session_id) = 0 or v_identity.harness <> 'claude' then
917
+ raise exception 'identity_continuity_refused' using errcode = '42501';
918
+ end if;
919
+ if exists (select 1 from agent_board.session_id_aliases a where a.root_agent_id = v_identity.root_agent_id
920
+ and a.harness = v_identity.harness and a.session_id = p_session_id) then
921
+ raise exception 'identity_continuity_refused' using errcode = '42501';
922
+ end if;
923
+ insert into agent_board.session_id_aliases(identity_agent_id, root_agent_id, harness, session_id)
924
+ values (p_agent_id, v_identity.root_agent_id, v_identity.harness, p_session_id) returning id into v_alias;
925
+ end if;
926
+ v_new_secret := encode(extensions.gen_random_bytes(32), 'hex');
927
+ update agent_board.session_identities
928
+ set holder_secret_hash = extensions.digest(v_new_secret, 'sha256'), lease_alias_id = v_alias,
929
+ credential_epoch = case when holder_secret_hash is null then credential_epoch else credential_epoch + 1 end,
930
+ last_heartbeat = now(), updated_at = now()
931
+ where agent_id = p_agent_id
932
+ returning credential_epoch into identity_lease_epoch;
933
+ return query select v_session.session_id, v_session.expires_at, identity_lease_epoch, v_new_secret;
934
+ end;
935
+ $$;
936
+ revoke all on function agent_board.mint_fleet_agent_session(uuid, text, uuid, uuid, integer, text, text) from public, anon, authenticated;
937
+ grant execute on function agent_board.mint_fleet_agent_session(uuid, text, uuid, uuid, integer, text, text) to service_role;
938
+ comment on function agent_board.mint_fleet_agent_session(uuid, text, uuid, uuid, integer, text, text) is 'Service-role PoP mint with one-live-holder session lease fencing; a newly generated holder secret is returned only on claim or reclaim.';
939
+
940
+ create or replace function agent_board._session_identity_owner(p_identity_agent_id uuid)
941
+ returns boolean language sql stable security definer set search_path = '' as $$
942
+ select exists (
943
+ select 1
944
+ from agent_board.session_identities si
945
+ join agent_board.agents root on root.id = si.root_agent_id
946
+ join agent_board.agents human on human.auth_user_id = auth.uid()
947
+ where si.agent_id = p_identity_agent_id
948
+ and root.owner_user_id = auth.uid()
949
+ and human.agent_type = 'human' and human.status = 'active'
950
+ );
951
+ $$;
952
+ revoke all on function agent_board._session_identity_owner(uuid) from public, anon, authenticated;
953
+ grant execute on function agent_board._session_identity_owner(uuid) to service_role;
954
+ comment on function agent_board._session_identity_owner(uuid) is 'Checks the authenticated active human against the identity root owner. No caller-supplied actor is accepted.';
955
+
956
+ create or replace function agent_board._session_identity_ancestor_actor(p_identity_agent_id uuid)
957
+ returns boolean language sql stable security definer set search_path = '' as $$
958
+ with recursive lineage(agent_id, parent_agent_id) as (
959
+ select parent.agent_id, parent.parent_agent_id
960
+ from agent_board.session_identities target
961
+ join agent_board.session_identities parent on parent.agent_id = target.parent_agent_id
962
+ where target.agent_id = p_identity_agent_id
963
+ union all
964
+ select parent.agent_id, parent.parent_agent_id
965
+ from agent_board.session_identities parent
966
+ join lineage child on child.parent_agent_id = parent.agent_id
967
+ )
968
+ select exists (
969
+ select 1 from lineage l
970
+ join agent_board.session_identities si on si.agent_id = l.agent_id
971
+ join agent_board.session_identities target on target.agent_id = p_identity_agent_id
972
+ where l.agent_id = agent_board.current_agent_id()
973
+ and si.tombstoned_at is null
974
+ and si.resolution_state = 'active'
975
+ and si.approved_risky_digest is not null
976
+ and si.approved_risky_digest = si.current_risky_digest
977
+ and si.approved_risky_digest = target.current_risky_digest
978
+ );
979
+ $$;
980
+ revoke all on function agent_board._session_identity_ancestor_actor(uuid) from public, anon, authenticated;
981
+ grant execute on function agent_board._session_identity_ancestor_actor(uuid) to service_role;
982
+ comment on function agent_board._session_identity_ancestor_actor(uuid) is 'Strict ancestor approval predicate: resolver-derived actor, never the target itself, must be active, non-tombstoned and non-frozen with its own approved current digest matching the target reading. Applies to legacy roots too.';
983
+
984
+ create function agent_board._session_identity_revocation_actor(p_identity_agent_id uuid)
985
+ returns boolean language sql stable security definer set search_path = '' as $$
986
+ with recursive lineage(agent_id, parent_agent_id) as (
987
+ select parent.agent_id, parent.parent_agent_id
988
+ from agent_board.session_identities target
989
+ join agent_board.session_identities parent on parent.agent_id = target.parent_agent_id
990
+ where target.agent_id = p_identity_agent_id
991
+ union all
992
+ select parent.agent_id, parent.parent_agent_id
993
+ from agent_board.session_identities parent join lineage child on child.parent_agent_id = parent.agent_id
994
+ )
995
+ select exists (select 1 from lineage where agent_id = agent_board.current_agent_id());
996
+ $$;
997
+ revoke all on function agent_board._session_identity_revocation_actor(uuid) from public, anon, authenticated, service_role;
998
+ comment on function agent_board._session_identity_revocation_actor(uuid) is 'Resolver-derived strict ancestor revocation actor. Unlike approval, any active ancestor may revoke, including a frozen one; target self and siblings are not ancestors.';
999
+
1000
+ create function agent_board.record_session_identity_state(p_identity_agent_id uuid, p_fingerprint jsonb)
1001
+ returns void language plpgsql security definer set search_path = '' as $$
1002
+ declare v_identity agent_board.session_identities%rowtype;
1003
+ v_risky text := nullif(p_fingerprint ->> 'risky_digest', '');
1004
+ v_recorded jsonb := coalesce(p_fingerprint -> 'recorded', '{}'::jsonb)
1005
+ || jsonb_build_object('resolution', coalesce(p_fingerprint ->> 'resolution', 'unresolved'));
1006
+ begin
1007
+ if not agent_board._is_service_role() then
1008
+ raise exception 'identity reading is service_role-only' using errcode = '42501';
1009
+ end if;
1010
+ if coalesce(p_fingerprint ->> 'resolution', 'unresolved') not in ('resolved', 'unresolved') then
1011
+ raise exception 'invalid identity fingerprint resolution' using errcode = '22023';
1012
+ end if;
1013
+ perform 1 from agent_board.agents where id = (
1014
+ select root_agent_id from agent_board.session_identities where agent_id = p_identity_agent_id
1015
+ ) for update;
1016
+ select * into v_identity from agent_board.session_identities where agent_id = p_identity_agent_id for update;
1017
+ if not found then raise exception 'identity not found' using errcode = 'P0002'; end if;
1018
+ if v_identity.tombstoned_at is not null then raise exception 'identity_tombstoned' using errcode = '42501'; end if;
1019
+ if not exists (select 1 from agent_board.agents a where a.id = p_identity_agent_id and a.auth_user_id = auth.uid() and a.status = 'active')
1020
+ or (v_identity.identity_kind = 'session' and agent_board.current_agent_id() is distinct from p_identity_agent_id) then
1021
+ raise exception 'identity_lease_not_held' using errcode = '42501';
1022
+ end if;
1023
+ if jsonb_typeof(coalesce(p_fingerprint, '{}'::jsonb)) <> 'object' or jsonb_typeof(v_recorded) <> 'object' then
1024
+ raise exception 'invalid identity fingerprint reading' using errcode = '22023';
1025
+ end if;
1026
+ -- Return normally: throwing here would roll back the freeze. SIDSERVER must
1027
+ -- commit this measured reading before invoking a protected action separately.
1028
+ update agent_board.session_identities
1029
+ set current_risky_digest = v_risky, recorded_fingerprint = v_recorded,
1030
+ resolution_state = case
1031
+ when p_fingerprint ->> 'resolution' is distinct from 'resolved' or v_risky is null
1032
+ or v_identity.approved_risky_digest is null then 'frozen'
1033
+ when v_risky is distinct from v_identity.approved_risky_digest or v_identity.resolution_state = 'frozen' then 'frozen'
1034
+ else 'active' end,
1035
+ updated_at = now()
1036
+ where agent_id = p_identity_agent_id;
1037
+ end;
1038
+ $$;
1039
+ revoke all on function agent_board.record_session_identity_state(uuid,jsonb) from public, anon, authenticated;
1040
+ grant execute on function agent_board.record_session_identity_state(uuid,jsonb) to service_role;
1041
+ comment on function agent_board.record_session_identity_state(uuid,jsonb) is 'Service-only fresh measured fingerprint recording, bound to the verified subject and epoch. Normal return durably records risky/unresolved freeze without replacing approval or refreshing a lease. Recorded-only changes remain active; existing freeze needs explicit approval. SIDSERVER commits this reading before a separate protected action; no public body attestation is accepted.';
1042
+
1043
+ create function agent_board.approve_identity_state(
1044
+ p_identity_agent_id uuid,
1045
+ p_risky_digest text,
1046
+ p_recorded_fingerprint jsonb default '{}'::jsonb
1047
+ )
1048
+ returns void language plpgsql security definer set search_path = agent_board, public as $$
1049
+ declare v_identity agent_board.session_identities%rowtype;
1050
+ begin
1051
+ if not agent_board._is_service_role() then
1052
+ raise exception 'identity approval is service_role-only' using errcode = '42501';
1053
+ end if;
1054
+ perform 1 from agent_board.agents where id = (
1055
+ select root_agent_id from agent_board.session_identities where agent_id = p_identity_agent_id
1056
+ ) for update;
1057
+ select * into v_identity from agent_board.session_identities where agent_id = p_identity_agent_id for update;
1058
+ if not found then raise exception 'identity not found' using errcode = 'P0002'; end if;
1059
+ if v_identity.tombstoned_at is not null then raise exception 'identity_tombstoned' using errcode = '42501'; end if;
1060
+ if p_risky_digest is null or jsonb_typeof(coalesce(p_recorded_fingerprint, '{}'::jsonb)) <> 'object'
1061
+ or p_recorded_fingerprint ->> 'resolution' is distinct from 'resolved'
1062
+ or v_identity.recorded_fingerprint ->> 'resolution' is distinct from 'resolved'
1063
+ or p_risky_digest is distinct from v_identity.current_risky_digest then
1064
+ raise exception 'identity_fingerprint_unapproved' using errcode = '42501';
1065
+ end if;
1066
+ if v_identity.depth = 0 then
1067
+ if not agent_board._session_identity_owner(p_identity_agent_id) then
1068
+ raise exception 'identity approval requires the human owner' using errcode = '42501';
1069
+ end if;
1070
+ elsif not agent_board._session_identity_owner(p_identity_agent_id)
1071
+ and not agent_board._session_identity_ancestor_actor(p_identity_agent_id) then
1072
+ raise exception 'identity approval requires the owner or an approved ancestor' using errcode = '42501';
1073
+ end if;
1074
+ if agent_board._session_identity_owner(p_identity_agent_id) then
1075
+ perform agent_board._assert_actor_not_fleet_revoked(agent_board.current_agent_id(), 'approve_identity_state');
1076
+ else
1077
+ perform agent_board._assert_actor_not_fleet_revoked(agent_board.current_agent_id(), 'approve_identity_state');
1078
+ end if;
1079
+ update agent_board.session_identities
1080
+ set approved_risky_digest = p_risky_digest,
1081
+ current_risky_digest = p_risky_digest,
1082
+ recorded_fingerprint = v_identity.recorded_fingerprint || p_recorded_fingerprint,
1083
+ resolution_state = 'active', updated_at = now()
1084
+ where agent_id = p_identity_agent_id;
1085
+ end;
1086
+ $$;
1087
+ revoke all on function agent_board.approve_identity_state(uuid, text, jsonb) from public, anon, authenticated;
1088
+ grant execute on function agent_board.approve_identity_state(uuid, text, jsonb) to service_role;
1089
+ comment on function agent_board.approve_identity_state(uuid, text, jsonb) is 'Service-only measured-fingerprint approval preserving the resolver/auth.uid human or ancestor actor. Depth zero is human-owner-only. Strict ancestors must have their own matching approved current digest and cannot be frozen. Fresh resolution must be explicit and resolved, stored unresolved inputs and unrecorded digests refuse, and tombstones never revive. Uses root-before-identity locking.';
1090
+
1091
+ -- The shared primitive closes forks as well as lineage descendants. Legacy
1092
+ -- human, subtree, platform-admin and fleet-agent revocations use the same path.
1093
+ create or replace function agent_board._revoke_agent_principal(
1094
+ p_agent_id uuid,
1095
+ p_revoked_by_agent_id uuid,
1096
+ p_reason text
1097
+ )
1098
+ returns integer
1099
+ language plpgsql
1100
+ security definer
1101
+ set search_path = agent_board, public
1102
+ as $$
1103
+ declare
1104
+ v_key record;
1105
+ v_key_count integer := 0;
1106
+ v_agent_flipped boolean := false;
1107
+ v_ids uuid[];
1108
+ v_id uuid;
1109
+ begin
1110
+ perform 1 from agent_board.agents a where a.id = (
1111
+ select si.root_agent_id from agent_board.session_identities si where si.agent_id = p_agent_id
1112
+ ) for no key update;
1113
+ with recursive closure(agent_id) as (
1114
+ select p_agent_id
1115
+ union
1116
+ select a.id from agent_board.agents a
1117
+ left join agent_board.session_identities si on si.agent_id = a.id
1118
+ join closure c on a.parent_agent_id = c.agent_id or si.forked_from = c.agent_id
1119
+ where p_reason is distinct from 'superseded_unclaimed'
1120
+ )
1121
+ select array_agg(agent_id order by agent_id) into v_ids from closure;
1122
+ perform 1 from agent_board.session_identities si
1123
+ where si.agent_id = any(v_ids) order by si.agent_id for update;
1124
+ for v_id in select unnest(v_ids)
1125
+ loop
1126
+ v_agent_flipped := false;
1127
+ update agent_board.session_identities
1128
+ set tombstoned_at = now(),
1129
+ tombstone_reason = case when v_id <> p_agent_id then 'cascade'
1130
+ when p_reason = 'superseded_unclaimed' then 'superseded_unclaimed' else 'revoked' end,
1131
+ credential_epoch = credential_epoch + 1, updated_at = now()
1132
+ where agent_id = v_id and tombstoned_at is null;
1133
+ perform 1 from agent_board.agents a where a.id = v_id for update;
1134
+
1135
+ for v_key in
1136
+ select id
1137
+ from agent_board.agent_signing_keys
1138
+ where agent_id = v_id
1139
+ and status not in ('revoked', 'compromised')
1140
+ order by id
1141
+ for update
1142
+ loop
1143
+ update agent_board.agent_signing_keys
1144
+ set status = 'revoked',
1145
+ proof_state = 'revoked',
1146
+ revoked_at = now(),
1147
+ revoked_by_agent_id = p_revoked_by_agent_id,
1148
+ revocation_reason = p_reason,
1149
+ updated_at = now()
1150
+ where id = v_key.id;
1151
+
1152
+ insert into agent_board.fleet_revocation_events(
1153
+ subject_kind, subject_id, signing_key_id, agent_id,
1154
+ revoked_by_agent_id, revocation_reason, metadata
1155
+ )
1156
+ values (
1157
+ 'signing_key', v_key.id, v_key.id, v_id,
1158
+ p_revoked_by_agent_id, p_reason,
1159
+ jsonb_build_object('source', 'revoke_agent_subtree')
1160
+ )
1161
+ on conflict (subject_kind, subject_id) do update
1162
+ set revoked_by_agent_id = excluded.revoked_by_agent_id,
1163
+ revocation_reason = excluded.revocation_reason,
1164
+ signing_key_id = excluded.signing_key_id,
1165
+ agent_id = excluded.agent_id,
1166
+ effective_at = now(),
1167
+ metadata = excluded.metadata;
1168
+
1169
+ v_key_count := v_key_count + 1;
1170
+ end loop;
1171
+
1172
+ update agent_board.agents
1173
+ set status = 'revoked', updated_at = now()
1174
+ where id = v_id and status <> 'revoked';
1175
+ if found then
1176
+ v_agent_flipped := true;
1177
+ end if;
1178
+
1179
+ update agent_board.delegation_grants
1180
+ set revoked_at = now()
1181
+ where child_agent_id = v_id and revoked_at is null;
1182
+
1183
+ update agent_board.agent_credentials ac
1184
+ set revoked_at = coalesce(ac.revoked_at, now()),
1185
+ updated_at = now(),
1186
+ metadata = coalesce(ac.metadata, '{}'::jsonb)
1187
+ || jsonb_build_object(
1188
+ 'revokedByAgentId', p_revoked_by_agent_id,
1189
+ 'revocationReason', p_reason,
1190
+ 'source', 'revoke_agent_subtree'
1191
+ )
1192
+ where ac.agent_id = v_id and ac.revoked_at is null;
1193
+
1194
+ insert into agent_board.fleet_revocation_events(
1195
+ subject_kind, subject_id, agent_id,
1196
+ revoked_by_agent_id, revocation_reason, metadata
1197
+ )
1198
+ values (
1199
+ 'agent', v_id, v_id,
1200
+ p_revoked_by_agent_id, p_reason,
1201
+ jsonb_build_object('source', 'revoke_agent_subtree', 'statusFlipped', v_agent_flipped)
1202
+ )
1203
+ on conflict (subject_kind, subject_id) do update
1204
+ set revoked_by_agent_id = excluded.revoked_by_agent_id,
1205
+ revocation_reason = excluded.revocation_reason,
1206
+ agent_id = excluded.agent_id,
1207
+ effective_at = now(),
1208
+ metadata = excluded.metadata;
1209
+
1210
+ end loop;
1211
+ return v_key_count;
1212
+ end;
1213
+ $$;
1214
+ revoke all on function agent_board._revoke_agent_principal(uuid, uuid, text) from public, anon, authenticated, service_role;
1215
+ grant execute on function agent_board._revoke_agent_principal(uuid, uuid, text) to service_role;
1216
+ comment on function agent_board._revoke_agent_principal(uuid, uuid, text) is 'Revokes a principal and transitive identity forks and lineage descendants, except superseded_unclaimed revokes only its never-claimed target. Takes root then identity locks before principal and keys. Tombstones retained alias targets, fences each epoch once, preserves permanent ledger and writes status, keys, grants and legacy credentials. Returns the number of newly revoked signing keys.';
1217
+
1218
+ create or replace function agent_board.revoke_agent_subtree(
1219
+ p_root_agent_id uuid,
1220
+ p_revoked_by_agent_id uuid,
1221
+ p_reason text default 'root revocation'
1222
+ )
1223
+ returns table(revoked_agent_count integer, revoked_key_count integer)
1224
+ language plpgsql
1225
+ security definer
1226
+ set search_path = agent_board, public
1227
+ as $$
1228
+ declare
1229
+ v_agent uuid;
1230
+ v_agent_count integer := 0;
1231
+ v_key_count integer := 0;
1232
+ begin
1233
+ if not agent_board._is_service_role() then
1234
+ raise exception 'revoke_agent_subtree is service_role-only' using errcode = '42501';
1235
+ end if;
1236
+ if trim(p_reason) = 'superseded_unclaimed' then
1237
+ raise exception 'superseded_unclaimed is reserved for the supersede path' using errcode = '22023';
1238
+ end if;
1239
+ if not exists (select 1 from agent_board.agents where id = p_root_agent_id) then
1240
+ raise exception 'root agent not found' using errcode = 'P0002';
1241
+ end if;
1242
+
1243
+ perform 1 from agent_board.agents a where a.id = (
1244
+ select si.root_agent_id from agent_board.session_identities si where si.agent_id = p_root_agent_id
1245
+ ) for no key update;
1246
+
1247
+ for v_agent in
1248
+ with recursive subtree(agent_id) as (
1249
+ select p_root_agent_id
1250
+ union
1251
+ select a.id from agent_board.agents a
1252
+ left join agent_board.session_identities si on si.agent_id = a.id
1253
+ join subtree s on a.parent_agent_id = s.agent_id or si.forked_from = s.agent_id
1254
+ )
1255
+ select agent_id from subtree order by agent_id
1256
+ loop
1257
+ v_key_count := v_key_count
1258
+ + agent_board._revoke_agent_principal(v_agent, p_revoked_by_agent_id, p_reason);
1259
+ v_agent_count := v_agent_count + 1;
1260
+ end loop;
1261
+
1262
+ return query select v_agent_count, v_key_count;
1263
+ end;
1264
+ $$;
1265
+ revoke all on function agent_board.revoke_agent_subtree(uuid, uuid, text) from public, anon, authenticated, service_role;
1266
+ grant execute on function agent_board.revoke_agent_subtree(uuid, uuid, text) to service_role;
1267
+ comment on function agent_board.revoke_agent_subtree(uuid, uuid, text) is 'Service-only root and descendant revocation, including transitive forks. Root-before-identity lock order and the shared principal primitive serialize renewals; returned agent and key counts cover the full closure.';
1268
+
1269
+ create function agent_board._revoke_session_identity_family(p_agent_id uuid, p_actor uuid, p_reason text)
1270
+ returns void language plpgsql security definer set search_path = '' as $$
1271
+ declare v_id uuid;
1272
+ begin
1273
+ -- A legacy ledger-only revoke keeps its legacy principal/key state. New
1274
+ -- session descendants still receive the full physical write-through.
1275
+ for v_id in
1276
+ with recursive family(agent_id) as (
1277
+ select p_agent_id
1278
+ union
1279
+ select a.id from agent_board.agents a left join agent_board.session_identities si on si.agent_id = a.id
1280
+ join family f on a.parent_agent_id = f.agent_id or si.forked_from = f.agent_id
1281
+ )
1282
+ select si.agent_id from family f join agent_board.session_identities si on si.agent_id = f.agent_id
1283
+ where si.identity_kind = 'session' order by si.agent_id
1284
+ loop
1285
+ perform agent_board._revoke_agent_principal(v_id, p_actor, p_reason);
1286
+ end loop;
1287
+ end;
1288
+ $$;
1289
+ revoke all on function agent_board._revoke_session_identity_family(uuid, uuid, text) from public, anon, authenticated;
1290
+ grant execute on function agent_board._revoke_session_identity_family(uuid, uuid, text) to service_role;
1291
+ comment on function agent_board._revoke_session_identity_family(uuid, uuid, text) is 'Closes session identities and transitive forks/descendants through the principal primitive without changing the pre-SIDSCHEMA state semantics of a legacy parent. Internal verified-actor helper for fleet and platform-admin revocation.';
1292
+
1293
+ create or replace function agent_board.revoke_fleet_subject(
1294
+ p_subject_kind text,
1295
+ p_subject_id uuid,
1296
+ p_revoked_by_agent_id uuid,
1297
+ p_revocation_reason text,
1298
+ p_metadata jsonb default '{}'::jsonb
1299
+ )
1300
+ returns table(revocation_event_id uuid, subject_kind text, effective_at timestamptz)
1301
+ language plpgsql
1302
+ security definer
1303
+ set search_path = agent_board, public
1304
+ as $$
1305
+ declare
1306
+ v_event agent_board.fleet_revocation_events%rowtype;
1307
+ v_actor_agent_id uuid;
1308
+ v_lock_machine_id uuid;
1309
+ v_lock_agent_id uuid;
1310
+ begin
1311
+ if trim(p_revocation_reason) = 'superseded_unclaimed' then
1312
+ raise exception 'superseded_unclaimed is reserved for the supersede path' using errcode = '22023';
1313
+ end if;
1314
+ if p_subject_kind not in ('trust_domain', 'machine', 'harness', 'repo_scope', 'agent', 'signing_key', 'credential') then
1315
+ raise exception 'unsupported fleet revocation subject' using errcode = '22023';
1316
+ end if;
1317
+
1318
+ if p_subject_kind in ('machine', 'harness', 'repo_scope') then
1319
+ v_lock_machine_id := case
1320
+ when p_subject_kind = 'machine' then p_subject_id
1321
+ when p_subject_kind = 'harness' then (
1322
+ select fhr.machine_id
1323
+ from agent_board.fleet_harness_registrations fhr
1324
+ where fhr.id = p_subject_id)
1325
+ when p_subject_kind = 'repo_scope' then (
1326
+ select fhr.machine_id
1327
+ from agent_board.fleet_repo_scopes frs
1328
+ join agent_board.fleet_harness_registrations fhr
1329
+ on fhr.id = frs.harness_registration_id
1330
+ where frs.id = p_subject_id)
1331
+ end;
1332
+ if v_lock_machine_id is not null then
1333
+ perform 1
1334
+ from agent_board.fleet_machines fm
1335
+ where fm.id = v_lock_machine_id
1336
+ for update;
1337
+ end if;
1338
+ elsif p_subject_kind = 'agent' then
1339
+ for v_lock_machine_id in
1340
+ select distinct fma.machine_id
1341
+ from agent_board.fleet_machine_agents fma
1342
+ where fma.agent_id = p_subject_id
1343
+ order by fma.machine_id
1344
+ loop
1345
+ perform 1
1346
+ from agent_board.fleet_machines fm
1347
+ where fm.id = v_lock_machine_id
1348
+ for update;
1349
+ end loop;
1350
+ perform 1 from agent_board.agents a where a.id = (
1351
+ select si.root_agent_id from agent_board.session_identities si where si.agent_id = p_subject_id
1352
+ ) for no key update;
1353
+ perform 1 from agent_board.agents a where a.id = p_subject_id for update;
1354
+ perform 1
1355
+ from agent_board.fleet_machine_agents fma
1356
+ where fma.agent_id = p_subject_id
1357
+ order by fma.id
1358
+ for update;
1359
+ elsif p_subject_kind = 'signing_key' then
1360
+ select ask.agent_id
1361
+ into v_lock_agent_id
1362
+ from agent_board.agent_signing_keys ask
1363
+ where ask.id = p_subject_id;
1364
+ if v_lock_agent_id is not null then
1365
+ perform 1 from agent_board.agents a where a.id = v_lock_agent_id for update;
1366
+ end if;
1367
+ perform 1 from agent_board.agent_signing_keys ask where ask.id = p_subject_id for update;
1368
+ end if;
1369
+
1370
+ if p_subject_kind = 'trust_domain' then
1371
+ perform 1
1372
+ from agent_board.fleet_trust_domains ftd
1373
+ where ftd.id = p_subject_id
1374
+ for update;
1375
+ end if;
1376
+
1377
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(
1378
+ p_revoked_by_agent_id,
1379
+ 'p_revoked_by_agent_id'
1380
+ );
1381
+
1382
+ if not agent_board._is_service_role() then
1383
+ if p_subject_kind = 'trust_domain' and not exists (
1384
+ select 1 from agent_board.fleet_trust_domains ftd
1385
+ where ftd.id = p_subject_id and ftd.owner_agent_id = v_actor_agent_id
1386
+ ) then
1387
+ raise exception 'trust domain is not owned by current_agent_id()' using errcode = '42501';
1388
+ elsif p_subject_kind = 'machine' and not exists (
1389
+ select 1 from agent_board.fleet_machines fm
1390
+ where fm.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
1391
+ ) then
1392
+ raise exception 'machine is not owned by current_agent_id()' using errcode = '42501';
1393
+ elsif p_subject_kind = 'harness' and not exists (
1394
+ select 1
1395
+ from agent_board.fleet_harness_registrations fhr
1396
+ join agent_board.fleet_machines fm on fm.id = fhr.machine_id
1397
+ where fhr.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
1398
+ ) then
1399
+ raise exception 'harness is not owned by current_agent_id()' using errcode = '42501';
1400
+ elsif p_subject_kind = 'repo_scope' and not exists (
1401
+ select 1
1402
+ from agent_board.fleet_repo_scopes frs
1403
+ join agent_board.fleet_harness_registrations fhr
1404
+ on fhr.id = frs.harness_registration_id
1405
+ join agent_board.fleet_machines fm on fm.id = fhr.machine_id
1406
+ where frs.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
1407
+ ) then
1408
+ raise exception 'repo scope is not owned by current_agent_id()' using errcode = '42501';
1409
+ elsif p_subject_kind = 'agent' and p_subject_id <> v_actor_agent_id then
1410
+ raise exception 'agent revocation must target current_agent_id()' using errcode = '42501';
1411
+ elsif p_subject_kind = 'signing_key' and not exists (
1412
+ select 1 from agent_board.agent_signing_keys ask
1413
+ where ask.id = p_subject_id and ask.agent_id = v_actor_agent_id
1414
+ ) then
1415
+ raise exception 'signing key is not owned by current_agent_id()' using errcode = '42501';
1416
+ elsif p_subject_kind = 'credential' and not exists (
1417
+ select 1 from agent_board.agent_credentials ac
1418
+ where ac.id = p_subject_id and ac.agent_id = v_actor_agent_id
1419
+ ) then
1420
+ raise exception 'credential is not owned by current_agent_id()' using errcode = '42501';
1421
+ end if;
1422
+ end if;
1423
+
1424
+ select *
1425
+ into v_event
1426
+ from agent_board.fleet_revocation_events fre
1427
+ where fre.subject_kind = p_subject_kind
1428
+ and fre.subject_id = p_subject_id
1429
+ limit 1;
1430
+
1431
+ if v_event.id is null then
1432
+ insert into agent_board.fleet_revocation_events(
1433
+ subject_kind,
1434
+ subject_id,
1435
+ trust_domain_id,
1436
+ machine_id,
1437
+ harness_registration_id,
1438
+ repo_scope_id,
1439
+ agent_id,
1440
+ signing_key_id,
1441
+ revoked_by_agent_id,
1442
+ revocation_reason,
1443
+ metadata
1444
+ )
1445
+ values (
1446
+ p_subject_kind,
1447
+ p_subject_id,
1448
+ case when p_subject_kind = 'trust_domain' then p_subject_id end,
1449
+ case when p_subject_kind = 'machine' then p_subject_id end,
1450
+ case when p_subject_kind = 'harness' then p_subject_id end,
1451
+ case when p_subject_kind = 'repo_scope' then p_subject_id end,
1452
+ case when p_subject_kind = 'agent' then p_subject_id end,
1453
+ case when p_subject_kind = 'signing_key' then p_subject_id end,
1454
+ p_revoked_by_agent_id,
1455
+ trim(coalesce(p_revocation_reason, 'operator revocation')),
1456
+ coalesce(p_metadata, '{}'::jsonb)
1457
+ || jsonb_build_object('redacted', true, 'metadata-safe', true)
1458
+ )
1459
+ returning * into v_event;
1460
+ else
1461
+ update agent_board.fleet_revocation_events fre
1462
+ set effective_at = now(),
1463
+ revoked_by_agent_id = p_revoked_by_agent_id,
1464
+ revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
1465
+ metadata = coalesce(p_metadata, '{}'::jsonb)
1466
+ || jsonb_build_object('redacted', true, 'metadata-safe', true)
1467
+ where fre.id = v_event.id
1468
+ returning * into v_event;
1469
+ end if;
1470
+
1471
+ if p_subject_kind = 'trust_domain' then
1472
+ update agent_board.fleet_trust_domains
1473
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
1474
+ where id = p_subject_id;
1475
+ elsif p_subject_kind = 'machine' then
1476
+ update agent_board.fleet_machines
1477
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
1478
+ where id = p_subject_id;
1479
+ elsif p_subject_kind = 'harness' then
1480
+ update agent_board.fleet_harness_registrations
1481
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
1482
+ where id = p_subject_id;
1483
+ elsif p_subject_kind = 'repo_scope' then
1484
+ update agent_board.fleet_repo_scopes
1485
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
1486
+ where id = p_subject_id;
1487
+ elsif p_subject_kind = 'agent' then
1488
+ update agent_board.fleet_machine_agents
1489
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
1490
+ where agent_id = p_subject_id;
1491
+ elsif p_subject_kind = 'signing_key' then
1492
+ update agent_board.agent_signing_keys
1493
+ set status = case when status = 'compromised' then status else 'revoked' end,
1494
+ proof_state = 'revoked',
1495
+ revoked_at = coalesce(revoked_at, v_event.effective_at),
1496
+ revoked_by_agent_id = p_revoked_by_agent_id,
1497
+ revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
1498
+ updated_at = now()
1499
+ where id = p_subject_id;
1500
+ end if;
1501
+
1502
+ if p_subject_kind = 'agent' then
1503
+ perform agent_board._revoke_session_identity_family(p_subject_id, v_actor_agent_id, p_revocation_reason);
1504
+ end if;
1505
+ return query select v_event.id, v_event.subject_kind, v_event.effective_at;
1506
+ end;
1507
+ $$;
1508
+ revoke all on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) from public, anon, authenticated, service_role;
1509
+ grant execute on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) to authenticated, service_role;
1510
+ comment on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) is 'Effective fleet revocation preserving existing actor ownership and machine-before-agent locking. Agent subjects additionally take their lineage root before the agent and close identity aliases, forks, descendants, epochs and all principal credential state through the shared primitive.';
1511
+
1512
+ create or replace function agent_board.revoke_agent(
1513
+ p_agent_id uuid,
1514
+ p_reason text default null
1515
+ )
1516
+ returns table(
1517
+ agent_id uuid,
1518
+ status text,
1519
+ revoked_credential_count integer,
1520
+ revoked_at timestamptz
1521
+ )
1522
+ language plpgsql
1523
+ security definer
1524
+ set search_path = agent_board, public
1525
+ as $$
1526
+ declare
1527
+ v_admin_user_id uuid;
1528
+ v_agent agent_board.agents%rowtype;
1529
+ v_revoked_at timestamptz := now();
1530
+ v_revoked_count integer := 0;
1531
+ v_actor uuid;
1532
+ begin
1533
+ v_admin_user_id := agent_board._require_platform_admin_user_id();
1534
+ v_actor := agent_board.current_agent_id();
1535
+ if trim(p_reason) = 'superseded_unclaimed' then
1536
+ raise exception 'superseded_unclaimed is reserved for the supersede path' using errcode = '22023';
1537
+ end if;
1538
+ if v_actor is not null then
1539
+ perform agent_board._assert_actor_not_fleet_revoked(v_actor, 'revoke_agent');
1540
+ end if;
1541
+
1542
+ perform 1 from agent_board.agents a where a.id = (
1543
+ select si.root_agent_id from agent_board.session_identities si where si.agent_id = p_agent_id
1544
+ ) for no key update;
1545
+
1546
+ -- Legacy service scripts and Portal admins need no board actor. Only a
1547
+ -- session family writes the permanent ledger, whose attribution is required.
1548
+ if v_actor is null and exists (
1549
+ with recursive family(agent_id) as (
1550
+ select p_agent_id
1551
+ union
1552
+ select a.id from agent_board.agents a left join agent_board.session_identities si on si.agent_id = a.id
1553
+ join family f on a.parent_agent_id = f.agent_id or si.forked_from = f.agent_id
1554
+ )
1555
+ select 1 from family f join agent_board.session_identities si on si.agent_id = f.agent_id
1556
+ where si.identity_kind = 'session'
1557
+ ) then raise exception 'revocation requires a resolved actor' using errcode = '42501'; end if;
1558
+
1559
+ select count(*)::integer into v_revoked_count from agent_board.agent_credentials ac
1560
+ where ac.agent_id = p_agent_id and ac.revoked_at is null;
1561
+ perform agent_board._revoke_session_identity_family(p_agent_id,
1562
+ v_actor, coalesce(p_reason, 'operator revocation'));
1563
+ update agent_board.agents a
1564
+ set status = case when exists (select 1 from agent_board.session_identities si
1565
+ where si.agent_id = p_agent_id and si.identity_kind = 'session') then 'revoked' else 'suspended' end,
1566
+ updated_at = v_revoked_at,
1567
+ metadata = coalesce(a.metadata, '{}'::jsonb)
1568
+ || jsonb_build_object(
1569
+ 'revokedByUserId', v_admin_user_id,
1570
+ 'revokedAt', v_revoked_at,
1571
+ 'revocationReason', nullif(trim(p_reason), '')
1572
+ )
1573
+ where a.id = p_agent_id
1574
+ returning * into v_agent;
1575
+
1576
+ if v_agent.id is null then
1577
+ raise exception 'agent not found' using errcode = 'P0002';
1578
+ end if;
1579
+
1580
+ update agent_board.agent_credentials ac
1581
+ set revoked_at = coalesce(ac.revoked_at, v_revoked_at),
1582
+ updated_at = v_revoked_at,
1583
+ metadata = coalesce(ac.metadata, '{}'::jsonb)
1584
+ || jsonb_build_object(
1585
+ 'revokedByUserId', v_admin_user_id,
1586
+ 'revokedAt', v_revoked_at,
1587
+ 'revocationReason', nullif(trim(p_reason), '')
1588
+ )
1589
+ where ac.agent_id = p_agent_id
1590
+ and ac.revoked_at is null;
1591
+
1592
+ return query
1593
+ select v_agent.id, v_agent.status, v_revoked_count, v_revoked_at;
1594
+ end;
1595
+ $$;
1596
+ revoke all on function agent_board.revoke_agent(uuid, text) from public, anon, authenticated, service_role;
1597
+ grant execute on function agent_board.revoke_agent(uuid, text) to authenticated, service_role;
1598
+ comment on function agent_board.revoke_agent(uuid, text) is 'Platform-admin agent revocation preserves its admin gate and returned credential count while closing the full identity fork and descendant closure under root-before-identity locks; returns the final revoked principal status.';
1599
+
1600
+ create function agent_board.revoke_session_identity(p_identity_agent_id uuid)
1601
+ returns void language plpgsql security definer set search_path = agent_board, public as $$
1602
+ declare v_target agent_board.session_identities%rowtype; v_id uuid; v_actor uuid := agent_board.current_agent_id();
1603
+ begin
1604
+ if not agent_board._session_identity_owner(p_identity_agent_id)
1605
+ and not agent_board._session_identity_revocation_actor(p_identity_agent_id) then
1606
+ raise exception 'identity revocation requires the owner or an ancestor' using errcode = '42501';
1607
+ end if;
1608
+ perform 1 from agent_board.agents where id = (
1609
+ select root_agent_id from agent_board.session_identities where agent_id = p_identity_agent_id
1610
+ ) for update;
1611
+ select * into v_target from agent_board.session_identities where agent_id = p_identity_agent_id for update;
1612
+ if not found then raise exception 'identity not found' using errcode = 'P0002'; end if;
1613
+ if not agent_board._session_identity_owner(p_identity_agent_id)
1614
+ and not agent_board._session_identity_revocation_actor(p_identity_agent_id) then
1615
+ raise exception 'identity revocation requires the owner or an ancestor' using errcode = '42501';
1616
+ end if;
1617
+ if agent_board._session_identity_owner(p_identity_agent_id) then
1618
+ perform agent_board._assert_actor_not_fleet_revoked(v_actor, 'revoke_session_identity');
1619
+ end if;
1620
+ perform agent_board._revoke_agent_principal(p_identity_agent_id,
1621
+ coalesce(v_actor, v_target.root_agent_id), 'session_identity_revoked');
1622
+ end;
1623
+ $$;
1624
+ revoke all on function agent_board.revoke_session_identity(uuid) from public, anon;
1625
+ grant execute on function agent_board.revoke_session_identity(uuid) to authenticated;
1626
+ comment on function agent_board.revoke_session_identity(uuid) is 'Owner or ancestor revocation of an identity, its descendants and its transitive forks. Each affected principal is revoked through _revoke_agent_principal and has its lease epoch fenced.';
1627
+
1628
+ create function agent_board.readmit_session_identity(
1629
+ p_identity_agent_id uuid, p_child_slug text, p_child_public_key text,
1630
+ p_requested_scopes jsonb default '[]'::jsonb,
1631
+ p_requested_rooms jsonb default '[]'::jsonb, p_ttl interval default null
1632
+ )
1633
+ returns table(child_agent_id uuid, grant_expires_at timestamptz, signing_key_id uuid)
1634
+ language plpgsql security definer set search_path = agent_board, public as $$
1635
+ declare
1636
+ v_old agent_board.session_identities%rowtype;
1637
+ v_parent agent_board.agents%rowtype;
1638
+ v_original_grant agent_board.delegation_grants%rowtype;
1639
+ v_policy agent_board.delegation_policies%rowtype;
1640
+ v_root uuid;
1641
+ v_owner uuid;
1642
+ v_parent_scopes jsonb;
1643
+ v_requested jsonb := coalesce(p_requested_scopes, '[]'::jsonb);
1644
+ v_rooms jsonb := coalesce(p_requested_rooms, '[]'::jsonb);
1645
+ v_alias uuid;
1646
+ v_new_id uuid := gen_random_uuid();
1647
+ v_new_auth uuid;
1648
+ v_key_id uuid;
1649
+ v_slug text;
1650
+ v_expires timestamptz;
1651
+ v_ceiling interval;
1652
+ v_ancestor_expires timestamptz;
1653
+ v_room jsonb;
1654
+ v_board uuid;
1655
+ v_role text;
1656
+ v_old_role text;
1657
+ v_membership agent_board.board_memberships%rowtype;
1658
+ begin
1659
+ -- Owner identity comes from this authenticated session, never from an argument.
1660
+ if not agent_board._session_identity_owner(p_identity_agent_id) then
1661
+ raise exception 'identity readmission requires the human owner' using errcode = '42501';
1662
+ end if;
1663
+ if jsonb_typeof(v_requested) <> 'array' or jsonb_typeof(v_rooms) <> 'array'
1664
+ or p_ttl <= interval '0' then raise exception 'invalid readmission authority request' using errcode = '22023'; end if;
1665
+ -- All board locks precede the root/accounting lock and identity rows.
1666
+ for v_board in select distinct (r ->> 'board_id')::uuid from jsonb_array_elements(v_rooms) r order by 1 loop
1667
+ perform agent_board._lock_board_authority_ceiling(v_board);
1668
+ perform 1 from agent_board.boards where id = v_board for key share;
1669
+ end loop;
1670
+ select root_agent_id into v_root from agent_board.session_identities where agent_id = p_identity_agent_id;
1671
+ select owner_user_id into v_owner from agent_board.agents where id = v_root;
1672
+ perform 1 from auth.users where id = v_owner for key share;
1673
+ perform 1 from agent_board.agents where id = v_root for update;
1674
+ select * into v_old from agent_board.session_identities where agent_id = p_identity_agent_id for update;
1675
+ if v_old.tombstoned_at is null or v_old.tombstone_reason = 'superseded_unclaimed'
1676
+ or v_old.forked_from is null then
1677
+ raise exception 'identity readmission requires a revoked fork' using errcode = '42501';
1678
+ end if;
1679
+ if exists (select 1 from agent_board.session_identities where readmitted_from = p_identity_agent_id) then
1680
+ raise exception 'identity_already_readmitted' using errcode = '42501';
1681
+ end if;
1682
+ select * into v_parent from agent_board.agents where id = v_old.parent_agent_id;
1683
+ if v_parent.id is null or v_parent.status <> 'active' or exists (
1684
+ select 1 from agent_board.session_identities where agent_id = v_parent.id and tombstoned_at is not null
1685
+ ) then raise exception 'identity_parent_inactive' using errcode = '42501'; end if;
1686
+ v_ancestor_expires := agent_board._session_identity_ancestor_ceiling(v_parent.id);
1687
+ select * into v_original_grant from agent_board.delegation_grants g where g.child_agent_id = p_identity_agent_id;
1688
+ select * into v_policy from agent_board.delegation_policies dp
1689
+ where dp.root_agent_id = v_root and dp.revoked_at is null order by dp.created_at desc, dp.id desc limit 1 for update;
1690
+ if not found or v_policy.mode <> 'root_minted' then
1691
+ raise exception 'identity_parent_inactive' using errcode = '42501';
1692
+ end if;
1693
+ if v_parent.parent_agent_id is null then v_parent_scopes := v_policy.allowed_scopes;
1694
+ else select g.granted_scopes into v_parent_scopes from agent_board.delegation_grants g where g.child_agent_id = v_parent.id;
1695
+ end if;
1696
+ if not (coalesce(v_parent_scopes, '[]'::jsonb) @> v_requested)
1697
+ or not (coalesce(v_original_grant.granted_scopes, '[]'::jsonb) @> v_requested) then
1698
+ raise exception 'scope_exceeded: readmission cannot widen original or parent scopes' using errcode = '42501';
1699
+ end if;
1700
+ perform agent_board._session_identity_check_limits(v_root, v_parent.id);
1701
+ -- The most recently bound alias is reserved; no caller supplies a session id.
1702
+ select a.id into v_alias from agent_board.session_id_aliases a
1703
+ where a.identity_agent_id = p_identity_agent_id and a.id = v_old.lease_alias_id for update;
1704
+ if v_alias is null then raise exception 'identity_continuity_refused' using errcode = '42501'; end if;
1705
+ v_slug := agent_board._normalize_provision_slug(p_child_slug);
1706
+ if exists (select 1 from agent_board.agents where handle = v_slug) then
1707
+ raise exception 'readmission requires a new unique agent handle' using errcode = '23505';
1708
+ end if;
1709
+ if p_child_public_key is null or p_child_public_key = repeat('A', 43) then
1710
+ raise exception 'readmission requires a fresh child public key' using errcode = '22023';
1711
+ end if;
1712
+ if exists (select 1 from agent_board.agent_signing_keys k where k.agent_id = p_identity_agent_id and k.public_key = p_child_public_key) then
1713
+ raise exception 'readmission cannot reuse the revoked key' using errcode = '42501';
1714
+ end if;
1715
+ v_new_auth := agent_board._ensure_agent_auth_user(v_slug);
1716
+ v_ceiling := least(v_policy.child_ttl_ceiling, v_policy.operator_child_ttl_ceiling, interval '24 hours');
1717
+ v_expires := least(now() + least(coalesce(p_ttl, v_ceiling), v_ceiling), v_ancestor_expires);
1718
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id,
1719
+ parent_agent_id, root_agent_id, metadata)
1720
+ values (v_new_id, 'agent', 'agent'::agent_board.agent_kind, v_slug, agent_board._default_provision_display_name(v_slug),
1721
+ 'active', v_new_auth, v_parent.owner_user_id, v_parent.id, v_root, jsonb_build_object('readmittedFrom', p_identity_agent_id));
1722
+ insert into agent_board.agent_signing_keys(agent_id, key_id, key_version, public_key, public_key_format,
1723
+ fingerprint, status, proof_state, metadata)
1724
+ values (v_new_id, 'readmitted-ephemeral', 1, p_child_public_key, 'raw_ed25519_base64url',
1725
+ agent_board._raw_key_fingerprint(p_child_public_key), 'pending_proof', 'unproven', jsonb_build_object('readmittedFrom', p_identity_agent_id))
1726
+ returning id into v_key_id;
1727
+ insert into agent_board.delegation_grants(child_agent_id, root_agent_id, parent_agent_id, delegation_policy_id,
1728
+ granted_scopes, grant_expires_at)
1729
+ values (v_new_id, v_root, v_parent.id, v_policy.id, v_requested, v_expires);
1730
+ insert into agent_board.session_identities(agent_id, parent_agent_id, root_agent_id, depth, identity_kind, harness,
1731
+ forked_from, readmitted_from, current_risky_digest, recorded_fingerprint, resolution_state)
1732
+ values (v_new_id, v_parent.id, v_root, v_old.depth, 'session', v_old.harness, v_old.forked_from,
1733
+ p_identity_agent_id, v_old.current_risky_digest, v_old.recorded_fingerprint, 'frozen');
1734
+ update agent_board.session_id_aliases set identity_agent_id = v_new_id where identity_agent_id = p_identity_agent_id;
1735
+ update agent_board.session_identities set lease_alias_id = v_alias where agent_id = v_new_id;
1736
+ perform agent_board._refuse_delegate_membership_target(v_new_id, 'readmit_session_identity');
1737
+ for v_room in select value from jsonb_array_elements(v_rooms) order by value ->> 'board_id' loop
1738
+ v_board := (v_room ->> 'board_id')::uuid;
1739
+ v_role := v_room ->> 'role';
1740
+ select bm.role into v_old_role from agent_board.board_memberships bm
1741
+ where bm.board_id = v_board and bm.agent_id = p_identity_agent_id
1742
+ order by case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
1743
+ bm.created_at desc, bm.id desc limit 1;
1744
+ if v_role not in ('owner', 'admin', 'member', 'observer') or v_role is null or v_old_role is null
1745
+ or agent_board._role_rank(v_role) > agent_board._role_rank(v_old_role)
1746
+ or not agent_board._grant_within_ceiling(v_parent.id, v_board, v_role) then
1747
+ raise exception 'room_authority_exceeded' using errcode = '42501';
1748
+ end if;
1749
+ select * into v_membership from agent_board._upsert_board_membership_state(v_board, v_new_id, 'joined');
1750
+ update agent_board.board_memberships set role = v_role where id = v_membership.id;
1751
+ end loop;
1752
+ perform agent_board._assert_actor_not_fleet_revoked(agent_board.current_agent_id(), 'readmit_session_identity');
1753
+ return query select v_new_id, v_expires, v_key_id;
1754
+ end;
1755
+ $$;
1756
+ revoke all on function agent_board.readmit_session_identity(uuid, text, text, jsonb, jsonb, interval) from public, anon, service_role;
1757
+ grant execute on function agent_board.readmit_session_identity(uuid, text, text, jsonb, jsonb, interval) to authenticated;
1758
+ comment on function agent_board.readmit_session_identity(uuid, text, text, jsonb, jsonb, interval) is 'Human root owner readmits a selected revoked fork by minting a fresh principal, unclaimed frozen identity and pending fresh key. The old principal, key and permanent ledger remain revoked. Parent/original scopes and roles only shrink, all ancestors and mint quotas are checked under the root lock, all aliases repoint and the latest is reserved. Approve the resolved fingerprint and prove the pending key via record_signing_key_proof_result before first claim.';
1759
+
1760
+ create function agent_board.rename_agent(p_agent_id uuid, p_display_name text)
1761
+ returns void language plpgsql security definer set search_path = agent_board, public as $$
1762
+ begin
1763
+ if p_display_name is null or length(trim(p_display_name)) = 0 then
1764
+ raise exception 'display name is required' using errcode = '22023';
1765
+ end if;
1766
+ if not exists (
1767
+ select 1 from agent_board.agents target join agent_board.agents human
1768
+ on human.auth_user_id = auth.uid()
1769
+ where target.id = p_agent_id and target.owner_user_id = auth.uid()
1770
+ and human.agent_type = 'human' and human.status = 'active'
1771
+ ) then
1772
+ raise exception 'agent rename requires the human owner' using errcode = '42501';
1773
+ end if;
1774
+ perform agent_board._assert_actor_not_fleet_revoked(agent_board.current_agent_id(), 'rename_agent');
1775
+ update agent_board.agents set display_name = p_display_name, updated_at = now() where id = p_agent_id;
1776
+ end;
1777
+ $$;
1778
+ revoke all on function agent_board.rename_agent(uuid, text) from public, anon;
1779
+ grant execute on function agent_board.rename_agent(uuid, text) to authenticated;
1780
+ comment on function agent_board.rename_agent(uuid, text) is 'Human-owner-only display-name update. Agent handles remain immutable.';
1781
+
1782
+ create function agent_board.set_board_member_label(p_board_id uuid, p_agent_id uuid, p_label text)
1783
+ returns void language plpgsql security definer set search_path = agent_board, public as $$
1784
+ declare v_actor uuid := agent_board.current_agent_id();
1785
+ begin
1786
+ if v_actor is null or not exists (
1787
+ select 1 from agent_board.board_memberships bm
1788
+ where bm.board_id = p_board_id and bm.agent_id = v_actor and bm.role in ('owner', 'admin')
1789
+ and bm.membership_state in ('joined', 'subscribed')
1790
+ ) then
1791
+ raise exception 'board label requires a board owner or admin' using errcode = '42501';
1792
+ end if;
1793
+ if length(p_label) > 200 then raise exception 'board label exceeds 200 characters' using errcode = '22023'; end if;
1794
+ perform agent_board._refuse_delegate_membership_target(p_agent_id, 'set_board_member_label');
1795
+ update agent_board.board_memberships
1796
+ set metadata = metadata || jsonb_build_object('label', p_label), updated_at = now()
1797
+ where board_id = p_board_id and agent_id = p_agent_id;
1798
+ if not found then raise exception 'membership not found on this board' using errcode = 'P0002'; end if;
1799
+ end;
1800
+ $$;
1801
+ revoke all on function agent_board.set_board_member_label(uuid, uuid, text) from public, anon;
1802
+ grant execute on function agent_board.set_board_member_label(uuid, uuid, text) to authenticated;
1803
+ comment on function agent_board.set_board_member_label(uuid, uuid, text) is 'Board-admin-only local membership label. It changes membership metadata on the named board and never the agent handle or global display name.';
1804
+
1805
+ -- Catalog-derived by agent_board_atomic_fleet_onboarding.test.sql. Adding the
1806
+ -- three SIDSCHEMA readers changes the helper's machine-checked registry too.
1807
+ create or replace function agent_board.tombstone_delegate_instance(
1808
+ p_instance_id text, p_issuer text, p_owner_sub text, p_reason text)
1809
+ returns table(instance_id text, tombstoned_at timestamptz, first boolean)
1810
+ language plpgsql
1811
+ security definer
1812
+ set search_path = ''
1813
+ as $fn$
1814
+ #variable_conflict use_column
1815
+ declare
1816
+ v_owner uuid;
1817
+ v_owner_slug text;
1818
+ v_inserted boolean;
1819
+ v_row agent_board.delegate_instances%rowtype;
1820
+ v_board uuid;
1821
+ begin
1822
+ if not agent_board._is_service_role() then
1823
+ raise exception 'tombstone_delegate_instance is service_role-only' using errcode = '42501';
1824
+ end if;
1825
+ if p_instance_id is null or p_instance_id !~ '^[A-Za-z0-9_-]{16,128}$'
1826
+ or coalesce(p_issuer, '') = '' or coalesce(p_owner_sub, '') = ''
1827
+ or p_reason is null
1828
+ or p_reason not in ('owner_revoked', 'owner_retired_after_upgrade', 'owner_account_closed', 'portal_admin') then
1829
+ raise exception 'revocation_malformed' using errcode = '22023';
1830
+ end if;
1831
+
1832
+ -- Board advisory mutexes precede every owner/instance and identity-family row lock,
1833
+ -- as in onboarding and add_delegate_to_board. Lock all existing boards in UUID order:
1834
+ -- an active-membership snapshot misses a concurrent placement onto another board,
1835
+ -- which takes that board mutex and the owner's agents row before the instance row.
1836
+ -- Taking a board mutex after the instance row would invert that established order.
1837
+ for v_board in select b.id from agent_board.boards b order by b.id loop
1838
+ perform agent_board._lock_board_authority_ceiling(v_board);
1839
+ end loop;
1840
+
1841
+ -- The owner lock, then the owner, exactly as the exchange takes and derives them (same key,
1842
+ -- same position: before the instance row), so a revocation and an exchange of a new owner's
1843
+ -- instance never provision two auth users for one owner.
1844
+ v_owner_slug := agent_board._federated_identity_slug(p_issuer, p_owner_sub);
1845
+ perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('agent_board.delegate_owner:' || v_owner_slug, 0));
1846
+ v_owner := agent_board._ensure_agent_auth_user(v_owner_slug);
1847
+
1848
+ -- HOSTED #591: NO auth.users ROW LOCK here, deliberately, unlike the exchange. This function
1849
+ -- reaches boards (update_board_membership below writes board_membership_events, whose foreign
1850
+ -- key takes FOR KEY SHARE on the board), and every route that locks both a board and an owner
1851
+ -- row must take the board first (agent_board_federated_lock_order_class EC-3):
1852
+ -- enroll_federated_human holds its default board FOR UPDATE and then wants the owner's row FOR
1853
+ -- UPDATE, so an owner-row lock taken here first closes a cycle with it. The deadlock against
1854
+ -- revoke_human_subtree (#591 item 8) is closed by the agents-row order below instead
1855
+ -- (hosted_delegate_tombstone_revoke_race P1, P2). What the owner-row lock would have added is
1856
+ -- stated in AB15: see revoke_human_subtree (section 4).
1857
+
1858
+ -- A revocation BEFORE the first exchange creates the tombstoned row, so the first exchange
1859
+ -- is refused. Insert-then-lock, as the exchange does, so a concurrent exchange serialises.
1860
+ -- HOSTED #591: with the owner's verified (iss, sub) (section 1).
1861
+ insert into agent_board.delegate_instances as di(
1862
+ instance_id, owner_user_id, owner_issuer, owner_subject, tombstoned_at, tombstone_reason)
1863
+ values (p_instance_id, v_owner, p_issuer, p_owner_sub, now(), p_reason)
1864
+ on conflict (instance_id) do nothing
1865
+ returning true into v_inserted;
1866
+
1867
+ if v_inserted then
1868
+ return query select p_instance_id, now(), true;
1869
+ return;
1870
+ end if;
1871
+
1872
+ select * into v_row
1873
+ from agent_board.delegate_instances di
1874
+ where di.instance_id = p_instance_id
1875
+ for update;
1876
+
1877
+ if v_row.owner_user_id is distinct from v_owner then
1878
+ raise exception 'owner_mismatch' using errcode = '42501';
1879
+ end if;
1880
+
1881
+ -- Idempotent: the key is the instance, and a tombstone is permanent.
1882
+ if v_row.tombstoned_at is not null then
1883
+ -- HOSTED #591: a legacy (pre-1.59.0) tombstoned row has no owner pair, and nothing else ever
1884
+ -- writes it again: the exchange refuses it (instance_revoked) before its UPDATE. So a repeated
1885
+ -- revocation fills the pair here, and nothing else about the row changes. Sound for the same
1886
+ -- reason as the fill below: owner_mismatch above proved this row's uid is the one
1887
+ -- (p_issuer, p_owner_sub) derives. A recorded pair is never touched (the trigger refuses).
1888
+ if v_row.owner_issuer is null then
1889
+ update agent_board.delegate_instances di
1890
+ set owner_issuer = p_issuer,
1891
+ owner_subject = p_owner_sub
1892
+ where di.instance_id = p_instance_id;
1893
+ end if;
1894
+ return query select v_row.instance_id, v_row.tombstoned_at, false;
1895
+ return;
1896
+ end if;
1897
+
1898
+ update agent_board.delegate_instances di
1899
+ set tombstoned_at = now(),
1900
+ tombstone_reason = p_reason,
1901
+ -- HOSTED #591: fills a legacy row's null pair; sound because owner_mismatch above
1902
+ -- proved this row's uid is the one (p_issuer, p_owner_sub) derives.
1903
+ owner_issuer = coalesce(di.owner_issuer, p_issuer),
1904
+ owner_subject = coalesce(di.owner_subject, p_owner_sub),
1905
+ updated_at = now()
1906
+ where di.instance_id = p_instance_id;
1907
+
1908
+ if v_row.agent_id is not null then
1909
+ for v_board in
1910
+ select bm.board_id from agent_board.board_memberships bm
1911
+ where bm.agent_id = v_row.agent_id and bm.membership_state in ('joined', 'subscribed')
1912
+ order by bm.board_id
1913
+ loop
1914
+ perform 1 from agent_board.boards b where b.id = v_board for key share of b;
1915
+ end loop;
1916
+ perform agent_board._revoke_session_identity_family(v_row.agent_id, v_row.agent_id, 'instance_tombstoned');
1917
+ -- HOSTED #591 (item 8): THE DELEGATE'S agents ROW FIRST, before any credential. Every other
1918
+ -- writer of a delegate's agents row and its credentials takes the agents row first
1919
+ -- (_revoke_agent_principal, for revoke_agent_subtree and revoke_human_subtree: FOR UPDATE,
1920
+ -- then credentials; revoke_agent: its UPDATE, then credentials). This UPDATE used to come
1921
+ -- last, after the credentials and the memberships, so either subtree revoker interleaved
1922
+ -- with a tombstone of the same delegate deadlocked (hosted_delegate_tombstone_revoke_race
1923
+ -- P1, P2). It is the same statement and the same FOR NO KEY UPDATE lock the 1.58.0 function
1924
+ -- took, moved ahead; it adds no lock. FOR NO KEY UPDATE does not block the FOR KEY SHARE of a
1925
+ -- foreign-key check, so add_delegate_to_board's sink insert is not newly blocked (the two
1926
+ -- already serialise on the instance row above). An agent already revoked and committed is
1927
+ -- not matched and not locked; its revoker has finished, so there is nothing to order against.
1928
+ update agent_board.agents a
1929
+ set status = 'revoked', updated_at = now()
1930
+ where a.id = v_row.agent_id
1931
+ and a.status <> 'revoked';
1932
+
1933
+ update agent_board.agent_credentials ac
1934
+ set revoked_at = coalesce(ac.revoked_at, now()),
1935
+ updated_at = now(),
1936
+ metadata = coalesce(ac.metadata, '{}'::jsonb)
1937
+ || jsonb_build_object('revocationReason', 'instance_tombstoned', 'tombstoneReason', p_reason)
1938
+ where ac.agent_id = v_row.agent_id
1939
+ and ac.revoked_at is null;
1940
+
1941
+ -- Every active membership ends through update_board_membership(..., 'left') under
1942
+ -- service_role, so each ends with an append-only operator_removal event.
1943
+ for v_board in
1944
+ select bm.board_id
1945
+ from agent_board.board_memberships bm
1946
+ where bm.agent_id = v_row.agent_id
1947
+ and bm.membership_state in ('joined', 'subscribed')
1948
+ order by bm.board_id
1949
+ loop
1950
+ perform agent_board.update_board_membership(v_board, v_row.agent_id, 'left');
1951
+ end loop;
1952
+ end if;
1953
+
1954
+ return query select p_instance_id, now(), true;
1955
+ end;
1956
+ $fn$;
1957
+
1958
+ revoke all on function agent_board.tombstone_delegate_instance(text, text, text, text) from public, anon, authenticated;
1959
+ grant execute on function agent_board.tombstone_delegate_instance(text, text, text, text) to service_role;
1960
+
1961
+ comment on function agent_board.tombstone_delegate_instance(text,text,text,text) is 'Service-only verified Portal instance tombstone, preserving owner/instance gates, permanent instance state, legacy principal credential and membership behavior. Closes any session descendants and transitive forks through the shared identity-family primitive before principal credentials; never revives an identity.';
1962
+
1963
+ comment on function agent_board._lock_board_authority_ceiling(uuid) is
1964
+ 'Board-keyed transaction advisory lock serializing the ABSENCE of a board_authority_ceilings row, held to commit. WRITERS[serialized: set_board_authority_ceiling][UNSERIALIZED: (none)] READERS[serialized: add_delegate_to_board, grant_board_role, link_session_alias, mint_delegated_child, onboard_federated_fleet_agent, readmit_session_identity][UNSERIALIZED: _redeem_unbounded_invite_for_agent, create_invite_link] The SIDSCHEMA readers take the mutexes for every requested or existing descendant room before root, identity and membership locks. The two UNSERIALIZED readers still evaluate a missing ceiling as default-owner without serializing its absence; that known race (message-board#277) is not changed here. Both lists are machine-compared against the catalog; a new reader/writer or changed serialization state fails by name. Internal invoker helper, not an authorization gate; caller privileges determine table access.';