@consiliency/agent-board-schema 1.3.0 → 1.5.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 (66) hide show
  1. package/bin/agent-board-schema.js +0 -0
  2. package/fixtures/already-applied.json +112 -0
  3. package/migrations/20260717000100_agent_board_board_role.sql +180 -0
  4. package/migrations/20260717000200_agent_board_thread_acl.sql +149 -0
  5. package/migrations/20260717000300_agent_board_agent_lineage.sql +54 -0
  6. package/migrations/20260717000400_agent_board_delegation_policies.sql +175 -0
  7. package/migrations/20260718000100_agent_board_send_authz_config.sql +172 -0
  8. package/migrations/20260718000200_agent_board_send_role_gate.sql +804 -0
  9. package/migrations/20260718000300_agent_board_fleet_policy_default_deny.sql +192 -0
  10. package/migrations/20260718000400_agent_board_grant_ceiling_wiring.sql +58 -0
  11. package/migrations/20260718000500_agent_board_fleet_agent_session_class.sql +24 -0
  12. package/migrations/20260718000600_agent_board_mint_fleet_agent_session.sql +172 -0
  13. package/migrations/20260718000700_agent_board_nonfleet_session_reaper.sql +35 -0
  14. package/migrations/20260718000800_agent_board_regrant_scoped_send.sql +24 -0
  15. package/migrations/20260718000900_agent_board_agents_status_revoked.sql +24 -0
  16. package/migrations/20260718001000_agent_board_delegation_enforcement.sql +296 -0
  17. package/migrations/20260718001100_agent_board_revoke_agent_subtree.sql +238 -0
  18. package/migrations/20260718001200_agent_board_resigning_refresh.sql +129 -0
  19. package/migrations/20260718001300_agent_board_identity_providers.sql +70 -0
  20. package/migrations/20260718001400_agent_board_enrollment_federation_audit.sql +50 -0
  21. package/migrations/20260718001500_agent_board_enroll_federated_identity.sql +220 -0
  22. package/migrations/20260719000100_agent_board_signing_challenge_pending_cap.sql +124 -0
  23. package/migrations/20260719000200_agent_board_signing_challenge_reaper.sql +34 -0
  24. package/migrations/20260719000300_agent_board_enroll_rate_cap.sql +72 -0
  25. package/migrations/20260719001000_agent_board_identity_providers_portal_contract.sql +38 -0
  26. package/migrations/20260719001100_agent_board_enroll_federated_identity_pop.sql +267 -0
  27. package/migrations/20260719001200_agent_board_enroll_federated_identity_raw_fingerprint.sql +296 -0
  28. package/migrations/20260719001300_agent_board_enroll_rate_events_reaper_rls.sql +62 -0
  29. package/migrations/20260723000100_agent_board_roomown_owner_seed.sql +167 -0
  30. package/migrations/20260723000200_agent_board_roomown_grant_role_live.sql +72 -0
  31. package/package.json +9 -9
  32. package/schema-manifest.json +256 -4
  33. package/src/tooling.test.mjs +46 -1
  34. package/tests/agent_board_agent_lineage.test.sql +73 -0
  35. package/tests/agent_board_agents_status_revoked.test.sql +60 -0
  36. package/tests/agent_board_auth_rpcs.test.sql +17 -16
  37. package/tests/agent_board_board_role.test.sql +129 -0
  38. package/tests/agent_board_bundle_endpoint_compat.test.sql +18 -6
  39. package/tests/agent_board_canary_signed_fleet_enforcement.test.sql +8 -8
  40. package/tests/agent_board_delegation_enforcement.test.sql +175 -0
  41. package/tests/agent_board_delegation_policies.test.sql +120 -0
  42. package/tests/agent_board_enroll_federated_identity.test.sql +217 -0
  43. package/tests/agent_board_enroll_pop_binding.test.sql +131 -0
  44. package/tests/agent_board_enroll_rate_cap.test.sql +53 -0
  45. package/tests/agent_board_enrollment_audit_content.test.sql +139 -0
  46. package/tests/agent_board_fleet_agent_session_class.test.sql +64 -0
  47. package/tests/agent_board_fleet_policy_default_deny.test.sql +149 -0
  48. package/tests/agent_board_fleet_registry.test.sql +2 -2
  49. package/tests/agent_board_identity_providers.test.sql +104 -0
  50. package/tests/agent_board_inbox_rpcs.test.sql +10 -10
  51. package/tests/agent_board_mint_fleet_agent_session.test.sql +290 -0
  52. package/tests/agent_board_nonfleet_session_reaper.test.sql +61 -0
  53. package/tests/agent_board_portal_offline_invariant.test.sql +134 -0
  54. package/tests/agent_board_resigning_refresh.test.sql +73 -0
  55. package/tests/agent_board_revoke_agent_subtree.test.sql +188 -0
  56. package/tests/agent_board_roomown.test.sql +233 -0
  57. package/tests/agent_board_schema_version.test.sql +2 -2
  58. package/tests/agent_board_schema_version_rpc.test.sql +1 -1
  59. package/tests/agent_board_scoped_send_regrant.test.sql +142 -0
  60. package/tests/agent_board_send_authz_config.test.sql +84 -0
  61. package/tests/agent_board_send_authz_matrix.test.sql +162 -0
  62. package/tests/agent_board_send_role_gate.test.sql +181 -0
  63. package/tests/agent_board_signing_challenge_pending_cap.test.sql +87 -0
  64. package/tests/agent_board_signing_challenge_reaper.test.sql +74 -0
  65. package/tests/agent_board_thread_acl.test.sql +129 -0
  66. package/tests/agent_board_v2_invariants.test.sql +5 -5
@@ -0,0 +1,296 @@
1
+ -- MBDELEG SL-1 (IF-0-MBDELEG-2): per-child delegation grant table + bounded
2
+ -- root-minted spinoff RPCs.
3
+ --
4
+ -- Additive, forward-only. First consumer of delegation_policies (20260717000400).
5
+ --
6
+ -- Scope model (FROZEN): a root's scope IS delegation_policies.allowed_scopes (the
7
+ -- only machine-readable scope a root carries). allowed_scopes / granted_scopes are
8
+ -- JSON ARRAYS; "child scope ⊆ root scope" is exactly the jsonb array-containment
9
+ -- check `allowed_scopes @> requested_scopes`.
10
+ --
11
+ -- Two clocks: delegation_grants.grant_expires_at is the HOURS-SCALE working/grant
12
+ -- clock (bounded by delegation_policies.child_ttl_ceiling), distinct from the
13
+ -- <=900s fleet_agent_session token clock minted by mint_fleet_agent_session.
14
+ --
15
+ -- independent_auth is the default mode and opts out of ALL of the below: an
16
+ -- independent_auth subagent is provisioned lineage-independent (parent_agent_id and
17
+ -- root_agent_id NULL, sharing only owner_user_id), holds no delegation_grants row,
18
+ -- and enrolls its own key + PoP. Because it carries no parent, _agent_principal_active
19
+ -- reduces to its own status='active' on the up-walk, so it survives any other
20
+ -- agent's/root's revocation. Only mint_delegated_child (root_minted) rows are
21
+ -- created here.
22
+
23
+ -- 1. delegation_grants (per-child) -------------------------------------------
24
+ create table if not exists agent_board.delegation_grants (
25
+ id uuid primary key default gen_random_uuid(),
26
+ child_agent_id uuid not null unique references agent_board.agents(id) on delete cascade,
27
+ root_agent_id uuid not null references agent_board.agents(id) on delete cascade,
28
+ parent_agent_id uuid not null references agent_board.agents(id),
29
+ delegation_policy_id uuid not null references agent_board.delegation_policies(id),
30
+ granted_scopes jsonb not null,
31
+ -- Hours-scale working/grant clock, bounded by delegation_policies.child_ttl_ceiling.
32
+ grant_expires_at timestamptz not null,
33
+ last_renewed_at timestamptz,
34
+ revoked_at timestamptz,
35
+ created_at timestamptz not null default now()
36
+ );
37
+
38
+ comment on table agent_board.delegation_grants is
39
+ 'Per-child root-minted delegation grant. grant_expires_at is the hours-scale working clock (<= root policy child_ttl_ceiling), distinct from the <=900s session-token clock. independent_auth children hold NO row here.';
40
+
41
+ create index if not exists idx_agent_board_delegation_grants_root
42
+ on agent_board.delegation_grants(root_agent_id)
43
+ where revoked_at is null;
44
+
45
+ alter table agent_board.delegation_grants enable row level security;
46
+
47
+ drop policy if exists agent_board_authenticated_select_delegation_grants on agent_board.delegation_grants;
48
+ create policy agent_board_authenticated_select_delegation_grants
49
+ on agent_board.delegation_grants
50
+ as permissive
51
+ for select
52
+ to authenticated
53
+ using (root_agent_id = agent_board.current_agent_id());
54
+
55
+ grant select on agent_board.delegation_grants to authenticated;
56
+ grant select, insert, update on agent_board.delegation_grants to service_role;
57
+
58
+ -- 2. mint_delegated_child ----------------------------------------------------
59
+ -- service_role-only. Mints a bounded root_minted child ONLY when: the root's
60
+ -- delegation_policies row exists, is unrevoked, and mode='root_minted'; the root
61
+ -- principal is active; requested scopes ⊆ allowed_scopes; the root is a TRUE
62
+ -- lineage root (parent_agent_id NULL and holds no delegation_grants row of its own
63
+ -- => max_depth=1, no transitive minting); and max_active_children is not exceeded.
64
+ -- Provisions the child (parent = root = p_root_agent_id, same owner_user_id, own
65
+ -- auth binding), enrolls a scoped ephemeral REVOCABLE signing key when the policy
66
+ -- requires it, and inserts a delegation_grants row with a ceiling-bounded
67
+ -- grant_expires_at.
68
+ create or replace function agent_board.mint_delegated_child(
69
+ p_root_agent_id uuid,
70
+ p_child_slug text,
71
+ p_requested_scopes jsonb,
72
+ p_child_public_key text default null,
73
+ p_ttl interval default null
74
+ )
75
+ returns table(
76
+ child_agent_id uuid,
77
+ grant_expires_at timestamptz,
78
+ signing_key_id uuid
79
+ )
80
+ language plpgsql
81
+ security definer
82
+ set search_path = agent_board, public
83
+ as $$
84
+ declare
85
+ v_policy agent_board.delegation_policies%rowtype;
86
+ v_root agent_board.agents%rowtype;
87
+ v_slug text;
88
+ v_requested jsonb := coalesce(p_requested_scopes, '[]'::jsonb);
89
+ v_allowed jsonb;
90
+ v_active_children int;
91
+ v_child_auth_user_id uuid;
92
+ v_child_id uuid;
93
+ v_key_id uuid := null;
94
+ v_grant_expires_at timestamptz;
95
+ begin
96
+ if not agent_board._is_service_role() then
97
+ raise exception 'mint_delegated_child is service_role-only' using errcode = '42501';
98
+ end if;
99
+
100
+ v_slug := agent_board._normalize_provision_slug(p_child_slug);
101
+
102
+ -- Root must exist.
103
+ select * into v_root from agent_board.agents where id = p_root_agent_id;
104
+ if not found then
105
+ raise exception 'root agent not found' using errcode = 'P0002';
106
+ end if;
107
+
108
+ -- Root policy must exist, be unrevoked, and be root_minted. #11: take a row lock
109
+ -- on the policy up front so a concurrent mint against the same root serializes
110
+ -- here — the max_active_children count->insert below cannot then be raced past
111
+ -- the ceiling (TOCTOU close).
112
+ select * into v_policy
113
+ from agent_board.delegation_policies dp
114
+ where dp.root_agent_id = p_root_agent_id
115
+ and dp.revoked_at is null
116
+ order by dp.created_at desc
117
+ limit 1
118
+ for update;
119
+ if not found then
120
+ raise exception 'no active delegation policy for root agent' using errcode = '42501';
121
+ end if;
122
+ if v_policy.mode <> 'root_minted' then
123
+ raise exception 'delegation policy mode is not root_minted (independent_auth children self-provision)' using errcode = '42501';
124
+ end if;
125
+
126
+ -- Root principal must be active (self + lineage).
127
+ if not agent_board._agent_principal_active(p_root_agent_id) then
128
+ raise exception 'principal_inactive: root agent principal is not fully active' using errcode = '42501';
129
+ end if;
130
+
131
+ -- max_depth = 1 / no transitive minting: p_root_agent_id must be a TRUE lineage
132
+ -- root (unparented) and must not itself be a delegated child.
133
+ if v_root.parent_agent_id is not null then
134
+ raise exception 'max_depth: only a true lineage root may mint (no transitive delegation)' using errcode = '42501';
135
+ end if;
136
+ if exists (select 1 from agent_board.delegation_grants g where g.child_agent_id = p_root_agent_id) then
137
+ raise exception 'max_depth: a delegated child may not mint further children' using errcode = '42501';
138
+ end if;
139
+
140
+ -- Scope subset: requested ⊆ allowed (JSON array containment). A NULL allowed_scopes
141
+ -- means the root carries no delegable scope, so only an empty request is a subset.
142
+ v_allowed := coalesce(v_policy.allowed_scopes, '[]'::jsonb);
143
+ if not (v_allowed @> v_requested) then
144
+ raise exception 'scope_exceeded: requested scopes are not a subset of the root allowed_scopes' using errcode = '42501';
145
+ end if;
146
+
147
+ -- max_active_children: when set, the root's live (non-revoked, unexpired) grant
148
+ -- count must remain <= max_active_children after this mint.
149
+ if v_policy.max_active_children is not null then
150
+ select count(*) into v_active_children
151
+ from agent_board.delegation_grants g
152
+ where g.root_agent_id = p_root_agent_id
153
+ and g.revoked_at is null
154
+ and g.grant_expires_at > now();
155
+ if v_active_children >= v_policy.max_active_children then
156
+ raise exception 'max_active_children: root has reached its active delegated-child ceiling' using errcode = '42501';
157
+ end if;
158
+ end if;
159
+
160
+ -- Provision the child: parent = root = p_root_agent_id, same owner_user_id, own
161
+ -- auth binding (distinct auth_user_id for the active-unique index).
162
+ v_child_auth_user_id := agent_board._ensure_agent_auth_user(v_slug);
163
+
164
+ insert into agent_board.agents(
165
+ agent_type, kind, handle, display_name, status,
166
+ auth_user_id, owner_user_id, parent_agent_id, root_agent_id, metadata
167
+ )
168
+ values (
169
+ 'agent', 'agent'::agent_board.agent_kind, v_slug,
170
+ agent_board._default_provision_display_name(v_slug), 'active',
171
+ v_child_auth_user_id, v_root.owner_user_id, p_root_agent_id, p_root_agent_id,
172
+ jsonb_build_object('delegatedByRootAgentId', p_root_agent_id, 'grantedScopes', v_requested)
173
+ )
174
+ returning id into v_child_id;
175
+
176
+ -- Scoped REVOCABLE signing key when the policy requires it. #5: the child's
177
+ -- public key MUST be supplied by the caller (generated client-side; the private
178
+ -- key never touches the DB). We NEVER seed a placeholder/zero key and NEVER
179
+ -- generate a key in-DB. status='active' + proof_state='proven' so the child can
180
+ -- immediately re-sign with the private key it already holds. The fingerprint is
181
+ -- derived deterministically from the supplied public key.
182
+ if v_policy.require_child_signing_key then
183
+ if p_child_public_key is null then
184
+ raise exception 'a child public key is required when the delegation policy requires a signing key' using errcode = '22023';
185
+ end if;
186
+ -- Raw Ed25519 public keys are 32 bytes => 43 base64url chars (no padding).
187
+ if p_child_public_key !~ '^[A-Za-z0-9_-]{43}$' then
188
+ raise exception 'malformed child public key: expected a 43-char base64url raw Ed25519 key' using errcode = '22023';
189
+ end if;
190
+ -- Reject the all-''A'' key: 43 base64url ''A''s decode to 32 zero bytes (a
191
+ -- small-order point), which is exactly the retired placeholder.
192
+ if p_child_public_key = repeat('A', 43) then
193
+ raise exception 'child public key is the all-zero small-order placeholder and is rejected' using errcode = '22023';
194
+ end if;
195
+
196
+ insert into agent_board.agent_signing_keys(
197
+ id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
198
+ status, proof_state, metadata
199
+ )
200
+ values (
201
+ gen_random_uuid(), v_child_id, 'delegated-ephemeral', 1, p_child_public_key,
202
+ 'raw_ed25519_base64url',
203
+ 'sha256:' || rtrim(translate(encode(extensions.digest(convert_to(p_child_public_key, 'UTF8'), 'sha256'), 'base64'), '+/', '-_'), '='),
204
+ 'active', 'proven',
205
+ jsonb_build_object('ephemeral', true, 'scopes', v_requested)
206
+ )
207
+ returning id into v_key_id;
208
+ end if;
209
+
210
+ -- Grant row: ceiling-bounded hours-scale working clock.
211
+ v_grant_expires_at := now() + least(coalesce(p_ttl, v_policy.child_ttl_ceiling), v_policy.child_ttl_ceiling);
212
+
213
+ insert into agent_board.delegation_grants(
214
+ child_agent_id, root_agent_id, parent_agent_id, delegation_policy_id,
215
+ granted_scopes, grant_expires_at
216
+ )
217
+ values (
218
+ v_child_id, p_root_agent_id, p_root_agent_id, v_policy.id,
219
+ v_requested, v_grant_expires_at
220
+ );
221
+
222
+ return query select v_child_id, v_grant_expires_at, v_key_id;
223
+ end;
224
+ $$;
225
+
226
+ comment on function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval) is
227
+ 'Mints a bounded root_minted delegated child (scope ⊆ root allowed_scopes, max_depth=1, policy-row-locked max_active_children, ceiling-bounded grant TTL) enrolling the CALLER-SUPPLIED Ed25519 public key (never a placeholder; private key stays client-side) as a revocable signing key. service_role-only.';
228
+
229
+ revoke all on function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval) from public, anon, authenticated;
230
+ grant execute on function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval) to service_role;
231
+
232
+ -- 3. renew_delegated_grant ---------------------------------------------------
233
+ -- service_role-only. Extends grant_expires_at to now() + least(coalesce(p_extend,
234
+ -- child_ttl_ceiling), child_ttl_ceiling) ONLY while the root principal is active,
235
+ -- the root policy is unrevoked, and the grant is unrevoked. A revoked/inactive root
236
+ -- => renewal raises, so the grant can never outlive the root's authority.
237
+ create or replace function agent_board.renew_delegated_grant(
238
+ p_child_agent_id uuid,
239
+ p_extend interval default null
240
+ )
241
+ returns table(
242
+ grant_expires_at timestamptz
243
+ )
244
+ language plpgsql
245
+ security definer
246
+ set search_path = agent_board, public
247
+ as $$
248
+ declare
249
+ v_grant agent_board.delegation_grants%rowtype;
250
+ v_policy agent_board.delegation_policies%rowtype;
251
+ v_new_expires_at timestamptz;
252
+ begin
253
+ if not agent_board._is_service_role() then
254
+ raise exception 'renew_delegated_grant is service_role-only' using errcode = '42501';
255
+ end if;
256
+
257
+ select * into v_grant
258
+ from agent_board.delegation_grants g
259
+ where g.child_agent_id = p_child_agent_id
260
+ for update;
261
+ if not found then
262
+ raise exception 'delegation grant not found for child' using errcode = 'P0002';
263
+ end if;
264
+ if v_grant.revoked_at is not null then
265
+ raise exception 'grant_revoked: cannot renew a revoked delegation grant' using errcode = '42501';
266
+ end if;
267
+
268
+ select * into v_policy
269
+ from agent_board.delegation_policies dp
270
+ where dp.id = v_grant.delegation_policy_id;
271
+ if not found or v_policy.revoked_at is not null then
272
+ raise exception 'policy_revoked: the root delegation policy is revoked' using errcode = '42501';
273
+ end if;
274
+
275
+ -- The operational meaning of "<= root's remaining authority": a revoked/inactive
276
+ -- root makes _agent_principal_active(root) false, so renewal raises.
277
+ if not agent_board._agent_principal_active(v_grant.root_agent_id) then
278
+ raise exception 'principal_inactive: root agent principal is not active; grant cannot outlive its root' using errcode = '42501';
279
+ end if;
280
+
281
+ v_new_expires_at := now() + least(coalesce(p_extend, v_policy.child_ttl_ceiling), v_policy.child_ttl_ceiling);
282
+
283
+ update agent_board.delegation_grants
284
+ set grant_expires_at = v_new_expires_at,
285
+ last_renewed_at = now()
286
+ where child_agent_id = p_child_agent_id;
287
+
288
+ return query select v_new_expires_at;
289
+ end;
290
+ $$;
291
+
292
+ comment on function agent_board.renew_delegated_grant(uuid, interval) is
293
+ 'Extends a child grant''s hours-scale grant_expires_at only while the root principal is active and unrevoked; raises otherwise so a grant cannot outlive the root''s authority. service_role-only.';
294
+
295
+ revoke all on function agent_board.renew_delegated_grant(uuid, interval) from public, anon, authenticated;
296
+ grant execute on function agent_board.renew_delegated_grant(uuid, interval) to service_role;
@@ -0,0 +1,238 @@
1
+ -- MBDELEG SL-1 (IF-0-MBDELEG-3): refresh-checked subtree revocation cascade.
2
+ --
3
+ -- Additive, forward-only. Net-new. Requires the 'revoked' agents.status literal
4
+ -- from 20260718000900 (this file writes agents.status='revoked').
5
+ --
6
+ -- Two cascades share one per-principal primitive, _revoke_agent_principal:
7
+ -- * revoke_agent_subtree(root) walks DOWN the grant-derived lineage (the root
8
+ -- itself + every descendant that holds a delegation_grants row). This is the
9
+ -- OPPOSITE direction to _agent_principal_active (20260718000500), which walks UP
10
+ -- to ancestors — do not invert either. Because independent_auth principals carry
11
+ -- NULL parent_agent_id and hold no grant row, they are structurally NOT
12
+ -- descendants of any root and are never in this set: they survive a root/parent
13
+ -- revocation and are killed only by a self-revoke or by revoke_human_subtree.
14
+ -- * revoke_human_subtree(owner) is TOTAL for the human: it resolves EVERY agent
15
+ -- owned by owner_user_id (all lineage roots AND every lineage-independent
16
+ -- independent_auth principal) and revokes each. It is deliberately broader than
17
+ -- revoke_agent_subtree because current_agent_id() joins on the agent's own
18
+ -- auth_user_id, so disabling the human's auth.users row alone flips no agent.
19
+ --
20
+ -- Per revoked principal, in one transaction: flip every non-revoked signing key to
21
+ -- status='revoked'/proof_state='revoked' (revoked_at=now()), flip agents.status to
22
+ -- 'revoked', revoke any live delegation_grants row, and append fleet_revocation_events
23
+ -- rows (subject_kind='agent' per principal, 'signing_key' per key) FOR AUDIT ONLY —
24
+ -- no refresh/send path reads that ledger, so a ledger row alone is inert. The kill is
25
+ -- delivered by the key + status flips: the next <=15-min mint_fleet_agent_session
26
+ -- re-mint FAILS (revoked key => 42501; non-active status => _agent_principal_active
27
+ -- false), so the subtree dies within one token TTL even though its grant lifetime was
28
+ -- hours.
29
+
30
+ -- Per-principal revocation primitive. Returns the number of signing keys it flipped.
31
+ create or replace function agent_board._revoke_agent_principal(
32
+ p_agent_id uuid,
33
+ p_revoked_by_agent_id uuid,
34
+ p_reason text
35
+ )
36
+ returns integer
37
+ language plpgsql
38
+ security definer
39
+ set search_path = agent_board, public
40
+ as $$
41
+ declare
42
+ v_key record;
43
+ v_key_count integer := 0;
44
+ v_agent_flipped boolean := false;
45
+ begin
46
+ -- Flip every live signing key + emit a signing_key audit row per key.
47
+ for v_key in
48
+ select id
49
+ from agent_board.agent_signing_keys
50
+ where agent_id = p_agent_id
51
+ and status not in ('revoked', 'compromised')
52
+ for update
53
+ loop
54
+ update agent_board.agent_signing_keys
55
+ set status = 'revoked',
56
+ proof_state = 'revoked',
57
+ revoked_at = now(),
58
+ revoked_by_agent_id = p_revoked_by_agent_id,
59
+ revocation_reason = p_reason,
60
+ updated_at = now()
61
+ where id = v_key.id;
62
+
63
+ insert into agent_board.fleet_revocation_events(
64
+ subject_kind, subject_id, signing_key_id, agent_id,
65
+ revoked_by_agent_id, revocation_reason, metadata
66
+ )
67
+ values (
68
+ 'signing_key', v_key.id, v_key.id, p_agent_id,
69
+ p_revoked_by_agent_id, p_reason,
70
+ jsonb_build_object('source', 'revoke_agent_subtree')
71
+ )
72
+ on conflict (subject_kind, subject_id) do update
73
+ set revoked_by_agent_id = excluded.revoked_by_agent_id,
74
+ revocation_reason = excluded.revocation_reason,
75
+ signing_key_id = excluded.signing_key_id,
76
+ agent_id = excluded.agent_id,
77
+ effective_at = now(),
78
+ metadata = excluded.metadata;
79
+
80
+ v_key_count := v_key_count + 1;
81
+ end loop;
82
+
83
+ -- Flip the agent status (idempotent: only count a genuine active->revoked flip).
84
+ update agent_board.agents
85
+ set status = 'revoked',
86
+ updated_at = now()
87
+ where id = p_agent_id
88
+ and status <> 'revoked';
89
+ if found then
90
+ v_agent_flipped := true;
91
+ end if;
92
+
93
+ -- Revoke any live grant this principal holds as a child.
94
+ update agent_board.delegation_grants
95
+ set revoked_at = now()
96
+ where child_agent_id = p_agent_id
97
+ and revoked_at is null;
98
+
99
+ -- Also revoke the principal's legacy agent_credentials (mirrors revoke_agent in
100
+ -- 20260512000000). Without this, a later status flip-back would re-open the
101
+ -- refresh-token credential path that the key/status flips did not touch.
102
+ update agent_board.agent_credentials ac
103
+ set revoked_at = coalesce(ac.revoked_at, now()),
104
+ updated_at = now(),
105
+ metadata = coalesce(ac.metadata, '{}'::jsonb)
106
+ || jsonb_build_object(
107
+ 'revokedByAgentId', p_revoked_by_agent_id,
108
+ 'revocationReason', p_reason,
109
+ 'source', 'revoke_agent_subtree'
110
+ )
111
+ where ac.agent_id = p_agent_id
112
+ and ac.revoked_at is null;
113
+
114
+ -- Audit row for the principal itself.
115
+ insert into agent_board.fleet_revocation_events(
116
+ subject_kind, subject_id, agent_id,
117
+ revoked_by_agent_id, revocation_reason, metadata
118
+ )
119
+ values (
120
+ 'agent', p_agent_id, p_agent_id,
121
+ p_revoked_by_agent_id, p_reason,
122
+ jsonb_build_object('source', 'revoke_agent_subtree', 'statusFlipped', v_agent_flipped)
123
+ )
124
+ on conflict (subject_kind, subject_id) do update
125
+ set revoked_by_agent_id = excluded.revoked_by_agent_id,
126
+ revocation_reason = excluded.revocation_reason,
127
+ agent_id = excluded.agent_id,
128
+ effective_at = now(),
129
+ metadata = excluded.metadata;
130
+
131
+ return v_key_count;
132
+ end;
133
+ $$;
134
+
135
+ comment on function agent_board._revoke_agent_principal(uuid, uuid, text) is
136
+ 'Per-principal revocation primitive: flips all live signing keys + agents.status to revoked, revokes any child grant, appends audit-only fleet_revocation_events. Shared by revoke_agent_subtree and revoke_human_subtree.';
137
+
138
+ revoke all on function agent_board._revoke_agent_principal(uuid, uuid, text) from public, anon, authenticated;
139
+ grant execute on function agent_board._revoke_agent_principal(uuid, uuid, text) to service_role;
140
+
141
+ -- revoke_agent_subtree: DOWN-walking grant-derived cascade from a root.
142
+ create or replace function agent_board.revoke_agent_subtree(
143
+ p_root_agent_id uuid,
144
+ p_revoked_by_agent_id uuid,
145
+ p_reason text default 'root revocation'
146
+ )
147
+ returns table(
148
+ revoked_agent_count integer,
149
+ revoked_key_count integer
150
+ )
151
+ language plpgsql
152
+ security definer
153
+ set search_path = agent_board, public
154
+ as $$
155
+ declare
156
+ v_agent uuid;
157
+ v_agent_count integer := 0;
158
+ v_key_count integer := 0;
159
+ begin
160
+ if not agent_board._is_service_role() then
161
+ raise exception 'revoke_agent_subtree is service_role-only' using errcode = '42501';
162
+ end if;
163
+
164
+ if not exists (select 1 from agent_board.agents where id = p_root_agent_id) then
165
+ raise exception 'root agent not found' using errcode = 'P0002';
166
+ end if;
167
+
168
+ -- DOWN walk: the root itself (the revocation target) plus every descendant whose
169
+ -- authority derives from the root (it holds a delegation_grants row parented at a
170
+ -- member of the set). Recurses child.parent_agent_id -> parent.id via the grant
171
+ -- table, so lineage-independent independent_auth principals (no grant row) are
172
+ -- never reached.
173
+ for v_agent in
174
+ with recursive subtree(agent_id) as (
175
+ select p_root_agent_id
176
+ union
177
+ select g.child_agent_id
178
+ from agent_board.delegation_grants g
179
+ join subtree s on g.parent_agent_id = s.agent_id
180
+ )
181
+ select agent_id from subtree
182
+ loop
183
+ v_key_count := v_key_count + agent_board._revoke_agent_principal(v_agent, p_revoked_by_agent_id, p_reason);
184
+ v_agent_count := v_agent_count + 1;
185
+ end loop;
186
+
187
+ return query select v_agent_count, v_key_count;
188
+ end;
189
+ $$;
190
+
191
+ comment on function agent_board.revoke_agent_subtree(uuid, uuid, text) is
192
+ 'DOWN-walking refresh-checked cascade: revokes a root and every grant-derived descendant (keys + agents.status + grants + audit rows). independent_auth principals are structurally excluded (NULL lineage / no grant). service_role-only.';
193
+
194
+ revoke all on function agent_board.revoke_agent_subtree(uuid, uuid, text) from public, anon, authenticated;
195
+ grant execute on function agent_board.revoke_agent_subtree(uuid, uuid, text) to service_role;
196
+
197
+ -- revoke_human_subtree: TOTAL for the human — every principal owned by owner_user_id.
198
+ create or replace function agent_board.revoke_human_subtree(
199
+ p_owner_user_id uuid,
200
+ p_revoked_by_agent_id uuid,
201
+ p_reason text default 'human revocation'
202
+ )
203
+ returns table(
204
+ revoked_agent_count integer,
205
+ revoked_key_count integer
206
+ )
207
+ language plpgsql
208
+ security definer
209
+ set search_path = agent_board, public
210
+ as $$
211
+ declare
212
+ v_agent uuid;
213
+ v_agent_count integer := 0;
214
+ v_key_count integer := 0;
215
+ begin
216
+ if not agent_board._is_service_role() then
217
+ raise exception 'revoke_human_subtree is service_role-only' using errcode = '42501';
218
+ end if;
219
+
220
+ -- Total human revocation: every agent owned by this human — lineage roots,
221
+ -- their delegated descendants, AND every lineage-independent independent_auth
222
+ -- principal — because current_agent_id() joins on the agent's own auth_user_id.
223
+ for v_agent in
224
+ select id from agent_board.agents where owner_user_id = p_owner_user_id
225
+ loop
226
+ v_key_count := v_key_count + agent_board._revoke_agent_principal(v_agent, p_revoked_by_agent_id, p_reason);
227
+ v_agent_count := v_agent_count + 1;
228
+ end loop;
229
+
230
+ return query select v_agent_count, v_key_count;
231
+ end;
232
+ $$;
233
+
234
+ comment on function agent_board.revoke_human_subtree(uuid, uuid, text) is
235
+ 'TOTAL human revocation: revokes every agent owned by owner_user_id (lineage roots AND lineage-independent independent_auth principals). Broader than revoke_agent_subtree. service_role-only.';
236
+
237
+ revoke all on function agent_board.revoke_human_subtree(uuid, uuid, text) from public, anon, authenticated;
238
+ grant execute on function agent_board.revoke_human_subtree(uuid, uuid, text) to service_role;
@@ -0,0 +1,129 @@
1
+ -- MBDELEG SL-2 (IF-0-MBDELEG-4): re-signing refresh; demote the 30-day
2
+ -- agent_credentials refresh token to an enrollment/lineage record.
3
+ --
4
+ -- Additive, forward-only. Redefines two existing functions via CREATE OR REPLACE in
5
+ -- this NEW file — no released historical bytes are edited:
6
+ --
7
+ -- * rotate_agent_token(uuid) — the 30-day session-exchange RPC — now RAISES a
8
+ -- deprecation error (0A000). Session refresh is SOLELY re-signing through the
9
+ -- already-built PoP -> mint_fleet_agent_session loop (MBSESSION, 20260718000600).
10
+ -- The RETURNS-TABLE signature is preserved (CREATE OR REPLACE cannot change it),
11
+ -- but the body raises before returning any row.
12
+ --
13
+ -- * _issue_agent_credential(...) — the single choke point every issuer funnels
14
+ -- through (provision_agent still routes here) — now tags every minted credential
15
+ -- with metadata.credential_role = 'enrollment', demoting it to an
16
+ -- enrollment/lineage record: it is NO LONGER a session-exchangeable refresh token
17
+ -- (the only exchange RPC, rotate_agent_token, now raises). The body is carried
18
+ -- forward from the latest definition (20260711000000 system-principal fail-closed
19
+ -- guard) so that guard is preserved.
20
+ --
21
+ -- Maintainer decision (recommended, surfaced in the closeout): demotion mechanism is
22
+ -- raise-on-exchange (rotate_agent_token) + enrollment-only reissue
23
+ -- (_issue_agent_credential), rather than dropping the column/table.
24
+
25
+ -- 1. _issue_agent_credential: preserve system-principal fail-closed guard, tag
26
+ -- every credential as an enrollment/lineage record.
27
+ create or replace function agent_board._issue_agent_credential(
28
+ p_agent_id uuid,
29
+ p_credential_label text,
30
+ p_replaces_credential_id uuid default null,
31
+ p_metadata jsonb default '{}'::jsonb
32
+ )
33
+ returns table(
34
+ credential_id uuid,
35
+ refresh_token text,
36
+ expires_at timestamptz,
37
+ created_at timestamptz,
38
+ replaces_credential_id uuid
39
+ )
40
+ language plpgsql
41
+ security definer
42
+ set search_path = ''
43
+ as $$
44
+ declare
45
+ v_refresh_token text;
46
+ v_expires_at timestamptz := now() + interval '30 days';
47
+ v_created_at timestamptz := now();
48
+ v_credential_id uuid;
49
+ begin
50
+ -- Fail closed for system principals (carried forward from 20260711000000): they
51
+ -- have no auth binding, so any credential minted here is un-exchangeable.
52
+ if exists (
53
+ select 1
54
+ from agent_board.agents a
55
+ where a.id = p_agent_id
56
+ and a.kind = 'system'::agent_board.agent_kind
57
+ ) then
58
+ raise exception 'cannot issue an exchangeable credential for a system principal (no auth binding)'
59
+ using errcode = '22023';
60
+ end if;
61
+
62
+ v_refresh_token := agent_board._mint_agent_refresh_token();
63
+
64
+ insert into agent_board.agent_credentials(
65
+ agent_id,
66
+ refresh_token_hash,
67
+ hash_version,
68
+ credential_label,
69
+ expires_at,
70
+ replaces_credential_id,
71
+ metadata,
72
+ created_at,
73
+ updated_at
74
+ )
75
+ values (
76
+ p_agent_id,
77
+ extensions.digest(v_refresh_token, 'sha256'),
78
+ 1,
79
+ p_credential_label,
80
+ v_expires_at,
81
+ p_replaces_credential_id,
82
+ -- MBDELEG: demote to an enrollment/lineage record. No session is exchangeable
83
+ -- from it (rotate_agent_token now raises).
84
+ coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('credential_role', 'enrollment'),
85
+ v_created_at,
86
+ v_created_at
87
+ )
88
+ returning id into v_credential_id;
89
+
90
+ if p_replaces_credential_id is not null then
91
+ update agent_board.agent_credentials
92
+ set revoked_at = coalesce(revoked_at, v_created_at),
93
+ rotated_at = coalesce(rotated_at, v_created_at),
94
+ replaced_by_credential_id = v_credential_id,
95
+ updated_at = v_created_at
96
+ where id = p_replaces_credential_id;
97
+ end if;
98
+
99
+ return query
100
+ select v_credential_id, v_refresh_token, v_expires_at, v_created_at, p_replaces_credential_id;
101
+ end;
102
+ $$;
103
+
104
+ -- 2. rotate_agent_token: retire the 30-day session-exchange path. Raise on call.
105
+ create or replace function agent_board.rotate_agent_token(
106
+ p_agent_id uuid
107
+ )
108
+ returns table(
109
+ agent_id uuid,
110
+ credential_id uuid,
111
+ refresh_token text,
112
+ expires_at timestamptz,
113
+ rotated_at timestamptz,
114
+ replaces_credential_id uuid
115
+ )
116
+ language plpgsql
117
+ security definer
118
+ set search_path = agent_board, public
119
+ as $$
120
+ begin
121
+ -- MBDELEG (no-long-lived-tokens): the 30-day refresh-token exchange is retired.
122
+ -- Session refresh is solely re-signing: PoP -> agent_board.mint_fleet_agent_session.
123
+ raise exception 'rotate_agent_token is deprecated: session refresh is re-signing (PoP -> mint_fleet_agent_session), not a 30-day refresh-token exchange'
124
+ using errcode = '0A000';
125
+ end;
126
+ $$;
127
+
128
+ comment on function agent_board.rotate_agent_token(uuid) is
129
+ 'DEPRECATED (MBDELEG): raises 0A000. The 30-day refresh-token exchange is retired; session refresh is re-signing via PoP -> mint_fleet_agent_session.';