@consiliency/agent-board-schema 1.12.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/fixtures/already-applied.json +160 -0
  2. package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
  3. package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
  4. package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
  5. package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
  6. package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
  7. package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
  8. package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
  9. package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
  10. package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
  11. package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
  12. package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
  13. package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
  14. package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
  15. package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
  16. package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
  17. package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
  18. package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
  19. package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
  20. package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
  21. package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
  22. package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
  23. package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
  24. package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
  25. package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
  26. package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
  27. package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
  28. package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
  29. package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
  30. package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
  31. package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
  32. package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
  33. package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
  34. package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
  35. package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
  36. package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
  37. package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
  38. package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
  39. package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
  40. package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
  41. package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
  42. package/package.json +1 -1
  43. package/schema-manifest.json +363 -3
  44. package/src/tooling.test.mjs +113 -2
  45. package/tests/agent_board_actorspoof.test.sql +349 -0
  46. package/tests/agent_board_agentsdir.test.sql +154 -0
  47. package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
  48. package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
  49. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
  50. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  51. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  52. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  53. package/tests/agent_board_claimkey.test.sql +229 -0
  54. package/tests/agent_board_consent_backfill.test.sql +48 -0
  55. package/tests/agent_board_consent_emission.test.sql +601 -0
  56. package/tests/agent_board_consent_gating.test.sql +489 -0
  57. package/tests/agent_board_consent_substrate.test.sql +720 -0
  58. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  59. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  60. package/tests/agent_board_delegation_policies.test.sql +107 -1
  61. package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
  62. package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
  63. package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
  64. package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
  65. package/tests/agent_board_fedsession.test.sql +51 -5
  66. package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
  67. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  68. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  69. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  70. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  71. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  72. package/tests/agent_board_fleet_policy.test.sql +2 -2
  73. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  74. package/tests/agent_board_identity_providers.test.sql +9 -0
  75. package/tests/agent_board_inbox_reads.test.sql +45 -31
  76. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  77. package/tests/agent_board_invitepolicy.test.sql +205 -0
  78. package/tests/agent_board_leaveidem.test.sql +194 -0
  79. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  80. package/tests/agent_board_mint_harden.test.sql +303 -0
  81. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  82. package/tests/agent_board_obsgate.test.sql +178 -0
  83. package/tests/agent_board_obsgate2.test.sql +153 -0
  84. package/tests/agent_board_obsgate3.test.sql +170 -0
  85. package/tests/agent_board_obsgate4.test.sql +196 -0
  86. package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
  87. package/tests/agent_board_portal_offline_invariant.test.sql +14 -3
  88. package/tests/agent_board_pubexec_class.test.sql +53 -0
  89. package/tests/agent_board_rejectsem.test.sql +271 -0
  90. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  91. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  92. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  93. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  94. package/tests/agent_board_rolereset.test.sql +351 -0
  95. package/tests/agent_board_roomdeleg.test.sql +5 -3
  96. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  97. package/tests/agent_board_roomlink.test.sql +27 -11
  98. package/tests/agent_board_rowid.test.sql +1274 -0
  99. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  100. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  101. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  102. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  103. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  104. package/tests/agent_board_signing_store.test.sql +6 -6
  105. package/tests/agent_board_wake_recovery.test.sql +756 -0
  106. package/tests/fixtures/isolation-substrate.sql +93 -0
  107. package/tests/fixtures/test-prereqs.sql +33 -0
  108. package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
  109. package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
  110. package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
  111. package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
  112. package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
  113. package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
  114. package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
  115. package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
  116. package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
  117. package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
  118. package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
  119. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  120. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  121. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  122. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  123. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  124. package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
  125. package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
  126. package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
  127. package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
  128. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  129. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  130. package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
  131. package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
  132. package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
  133. package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
  134. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
  135. package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
  136. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
  137. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
  138. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
  139. package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
  140. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
  141. package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
  142. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  143. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  144. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  145. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  146. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  147. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
  148. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
  149. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
  150. package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
  151. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  152. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  153. package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
  154. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  155. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
  156. package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
@@ -0,0 +1,710 @@
1
+ -- =============================================================================
2
+ -- MINTHARDEN (#138) — child-key fingerprint integrity + EF-mediated authenticated
3
+ -- minting (completes ROOMDELEG-2 / #59).
4
+ -- =============================================================================
5
+ -- Four defects, one class, closed together (CLAUDE.md rule #1 — enumerate from the
6
+ -- catalog, not a list):
7
+ --
8
+ -- 1. RAW-BYTE FINGERPRINT. mint_delegated_child derived a signing-key fingerprint
9
+ -- over the base64url STRING (`digest(convert_to(key,'UTF8'))`, 20260718001000:203)
10
+ -- while enroll_federated_identity was migrated to the RAW 32 bytes (20260719001200).
11
+ -- Same key, two fingerprints => agent_signing_keys.fingerprint UNIQUE did NOT dedup
12
+ -- mint vs enroll, defeating the squat backstop. This migration extracts ONE
13
+ -- derivation — `_raw_key_fingerprint(text)` — and routes BOTH writers through it.
14
+ -- A catalog-derived pgTAP sweep (pg_get_functiondef) pins that no fingerprint-
15
+ -- INSERTing function bypasses it, so a new divergent writer names itself.
16
+ --
17
+ -- 2. TABLE-BOUNDARY TRIGGER. The function sweep only catches FUNCTION writers; direct
18
+ -- or dynamic DML bypasses it. A BEFORE INSERT OR UPDATE trigger on
19
+ -- agent_signing_keys recomputes the fingerprint from public_key for the raw format
20
+ -- and RAISES on mismatch — the invariant is enforced at the table boundary.
21
+ --
22
+ -- 3. NON-POSITIVE TTL FLOOR. mint accepted p_ttl <= 0, minting an immediately-expired
23
+ -- grant that escapes the max_active_children count while still provisioning an
24
+ -- auth user + agent + key — a fan-out bypass once the authenticated path opens.
25
+ --
26
+ -- 4. EF-MEDIATED AUTHENTICATED MINTING (topology A, maintainer-frozen 2026-07-31).
27
+ -- There is NO in-DB Ed25519 verifier, so mint STAYS service_role-only; direct
28
+ -- authenticated EXECUTE remains REVOKED. The authenticated product surface is the
29
+ -- Edge Function supabase/functions/mint-child, which verifies the root's session
30
+ -- JWT + the child-key proof-of-possession signature (domain-separated) and calls
31
+ -- this RPC with p_self_service=true and the session-bound root identity. On that
32
+ -- path the RPC does NOT trust the stored policy caps (a root can self-widen its
33
+ -- operator_vetted row, #142): it RE-CLAMPS max_active_children to
34
+ -- least(coalesce(stored,25),25) and the TTL ceiling to <= 24h, and applies a
35
+ -- per-root provisioning RATE cap (the concurrent max_active_children cap does not
36
+ -- stop mint->expire->mint cumulative fan-out).
37
+ --
38
+ -- F2 (non-blocking, documented): policy revocation is NOT a sticky kill-switch under
39
+ -- self-service — a root can re-upsert a fresh unrevoked policy row. Agent deactivation
40
+ -- (_agent_principal_active) is the durable lockdown lever; revoked_at on a policy is not.
41
+ --
42
+ -- Additive, forward-only. schema_version stays 8 (helper + trigger + one table + two
43
+ -- function recreations; no data-shape change here — the backfill ships in 20260822000200).
44
+ -- The mint signature widens 5 -> 6 args (trailing p_self_service boolean default false),
45
+ -- so the 5-arg function is DROPped and recreated; existing 4-/5-positional callers still
46
+ -- resolve (the new arg defaults false => the pre-existing operator behavior is unchanged).
47
+ -- =============================================================================
48
+
49
+ -- ---------------------------------------------------------------------------
50
+ -- 1. _raw_key_fingerprint — the ONE canonical raw-byte fingerprint derivation.
51
+ -- Decodes base64url -> raw 32 bytes, REJECTS a non-canonical (aliased) encoding,
52
+ -- and returns 'sha256:' over the RAW bytes. Byte-identical to enroll's derivation
53
+ -- (20260724000200:120-123) and the client child_keygen keyRef.
54
+ -- ---------------------------------------------------------------------------
55
+ create or replace function agent_board._raw_key_fingerprint(p_public_key text)
56
+ returns text
57
+ language plpgsql
58
+ immutable
59
+ set search_path = agent_board, public
60
+ as $$
61
+ declare
62
+ v_raw bytea;
63
+ v_reencoded text;
64
+ begin
65
+ if p_public_key is null or p_public_key !~ '^[A-Za-z0-9_-]{43}$' then
66
+ raise exception 'malformed public key: expected a 43-char base64url raw Ed25519 key'
67
+ using errcode = '22023';
68
+ end if;
69
+ -- 43 base64url chars encode 32 bytes with a single '=' pad for the standard alphabet.
70
+ v_raw := decode(translate(p_public_key, '-_', '+/') || '=', 'base64');
71
+ if octet_length(v_raw) <> 32 then
72
+ raise exception 'public key must decode to exactly 32 raw Ed25519 bytes'
73
+ using errcode = '22023';
74
+ end if;
75
+ -- A 32-byte key has 2 don't-care low bits, so up to 4 distinct base64url strings
76
+ -- decode to the same key. Require the canonical re-encode to equal the input so ONE
77
+ -- keypair maps to exactly ONE fingerprint (dedup + squat backstop across all writers).
78
+ v_reencoded := rtrim(translate(encode(v_raw, 'base64'), '+/', '-_'), '=');
79
+ if v_reencoded is distinct from p_public_key then
80
+ raise exception 'public key is not canonical base64url (aliased encoding rejected)'
81
+ using errcode = '22023';
82
+ end if;
83
+ return 'sha256:' || rtrim(translate(encode(extensions.digest(v_raw, 'sha256'), 'base64'), '+/', '-_'), '=');
84
+ end;
85
+ $$;
86
+
87
+ comment on function agent_board._raw_key_fingerprint(text) is
88
+ 'The single canonical raw-byte signing-key fingerprint derivation: decode base64url -> '
89
+ 'raw 32 bytes, reject a non-canonical (aliased) encoding, return sha256:base64url over '
90
+ 'the RAW bytes. Every agent_signing_keys.fingerprint writer (mint_delegated_child, '
91
+ 'enroll_federated_identity) MUST derive through this — a catalog sweep pins it.';
92
+
93
+ revoke all on function agent_board._raw_key_fingerprint(text) from public, anon, authenticated;
94
+ grant execute on function agent_board._raw_key_fingerprint(text) to service_role;
95
+
96
+ -- ---------------------------------------------------------------------------
97
+ -- 2. Table-boundary integrity trigger. Recompute the fingerprint from public_key for
98
+ -- the raw format and raise on mismatch, catching direct/dynamic DML the function
99
+ -- sweep cannot see. Skips unrelated UPDATEs (neither key nor fingerprint changed) so
100
+ -- a legacy row predating the backfill stays operable until 20260822000200 corrects it.
101
+ -- ---------------------------------------------------------------------------
102
+ create or replace function agent_board._enforce_raw_key_fingerprint()
103
+ returns trigger
104
+ language plpgsql
105
+ security definer
106
+ set search_path = agent_board, public
107
+ as $$
108
+ declare
109
+ v_expected text;
110
+ begin
111
+ if new.public_key_format is distinct from 'raw_ed25519_base64url' then
112
+ return new;
113
+ end if;
114
+ -- Skip only a genuinely unrelated UPDATE: neither the key, the fingerprint, NOR the
115
+ -- format changed. Validating every transition INTO the raw format (format changed) closes
116
+ -- the toggle bypass — flip a raw row to a non-raw format with a bogus fingerprint (non-raw
117
+ -- rows skip validation), then flip back keeping key+fingerprint, and without the format
118
+ -- clause the short-circuit would smuggle the mismatch through.
119
+ if tg_op = 'UPDATE'
120
+ and new.public_key is not distinct from old.public_key
121
+ and new.fingerprint is not distinct from old.fingerprint
122
+ and new.public_key_format is not distinct from old.public_key_format then
123
+ return new;
124
+ end if;
125
+ v_expected := agent_board._raw_key_fingerprint(new.public_key);
126
+ if new.fingerprint is distinct from v_expected then
127
+ raise exception 'agent_signing_keys.fingerprint (%) does not match the canonical raw-byte derivation of public_key (expected %)',
128
+ new.fingerprint, v_expected
129
+ using errcode = '23514';
130
+ end if;
131
+ return new;
132
+ end;
133
+ $$;
134
+
135
+ comment on function agent_board._enforce_raw_key_fingerprint() is
136
+ 'BEFORE INSERT OR UPDATE trigger on agent_signing_keys: for raw_ed25519_base64url keys, '
137
+ 'asserts fingerprint = _raw_key_fingerprint(public_key), catching direct/dynamic DML the '
138
+ 'pg_get_functiondef sweep misses. Skips unrelated UPDATEs so pre-backfill legacy rows stay operable.';
139
+
140
+ revoke all on function agent_board._enforce_raw_key_fingerprint() from public, anon, authenticated;
141
+
142
+ drop trigger if exists trg_agent_signing_keys_raw_fingerprint on agent_board.agent_signing_keys;
143
+ create trigger trg_agent_signing_keys_raw_fingerprint
144
+ before insert or update on agent_board.agent_signing_keys
145
+ for each row
146
+ execute function agent_board._enforce_raw_key_fingerprint();
147
+
148
+ -- ---------------------------------------------------------------------------
149
+ -- 3. Per-root provisioning RATE cap (authenticated mint path). Mirrors
150
+ -- check_and_bump_enroll_rate (20260719000300): records+allows while under the cap in
151
+ -- the rolling window, returns false (recording nothing) at/over it. The concurrent
152
+ -- max_active_children cap bounds LIVE children; this bounds cumulative
153
+ -- mint->expire->mint fan-out of agents/auth.users rows.
154
+ -- ---------------------------------------------------------------------------
155
+ create table if not exists agent_board.child_mint_rate_events (
156
+ id uuid primary key default gen_random_uuid(),
157
+ root_agent_id uuid not null references agent_board.agents(id) on delete cascade,
158
+ created_at timestamptz not null default now()
159
+ );
160
+
161
+ comment on table agent_board.child_mint_rate_events is
162
+ 'Per-root rolling-window provisioning rate-cap events for the authenticated (EF-mediated) '
163
+ 'mint_delegated_child path. Bounds cumulative mint->expire->mint fan-out the concurrent '
164
+ 'max_active_children cap cannot.';
165
+
166
+ create index if not exists idx_agent_board_child_mint_rate_root_created
167
+ on agent_board.child_mint_rate_events(root_agent_id, created_at desc);
168
+
169
+ grant select, insert on agent_board.child_mint_rate_events to service_role;
170
+
171
+ create or replace function agent_board._check_and_bump_child_mint_rate(
172
+ p_root_agent_id uuid,
173
+ p_limit integer,
174
+ p_window interval
175
+ )
176
+ returns boolean
177
+ language plpgsql
178
+ security definer
179
+ set search_path = agent_board, public
180
+ as $$
181
+ declare
182
+ v_count integer;
183
+ begin
184
+ if p_root_agent_id is null then
185
+ raise exception 'root agent id is required' using errcode = '22023';
186
+ end if;
187
+ if p_limit is null or p_limit < 0 then
188
+ raise exception 'child mint rate limit must be non-negative' using errcode = '22023';
189
+ end if;
190
+
191
+ select count(*)
192
+ into v_count
193
+ from agent_board.child_mint_rate_events e
194
+ where e.root_agent_id = p_root_agent_id
195
+ and e.created_at > now() - coalesce(p_window, interval '1 hour');
196
+
197
+ if v_count >= p_limit then
198
+ return false;
199
+ end if;
200
+
201
+ insert into agent_board.child_mint_rate_events(root_agent_id) values (p_root_agent_id);
202
+ return true;
203
+ end;
204
+ $$;
205
+
206
+ comment on function agent_board._check_and_bump_child_mint_rate(uuid, integer, interval) is
207
+ 'Rolling per-root provisioning rate cap for the authenticated mint path: records+allows '
208
+ 'while under p_limit in p_window, rejects (without recording) at/over the cap. service_role-only.';
209
+
210
+ revoke all on function agent_board._check_and_bump_child_mint_rate(uuid, integer, interval) from public, anon, authenticated;
211
+ grant execute on function agent_board._check_and_bump_child_mint_rate(uuid, integer, interval) to service_role;
212
+
213
+ -- ---------------------------------------------------------------------------
214
+ -- 4. mint_delegated_child — recreated. Signature widens to add a trailing
215
+ -- p_self_service boolean default false (the EF-forwarded authenticated-path flag).
216
+ -- STAYS service_role-only (topology A); direct authenticated EXECUTE remains revoked.
217
+ -- Verbatim from 20260718001000 except: raw-byte fingerprint via _raw_key_fingerprint,
218
+ -- non-positive-TTL floor, and the self-service F1 re-clamp + per-root rate cap.
219
+ -- ---------------------------------------------------------------------------
220
+ drop function if exists agent_board.mint_delegated_child(uuid, text, jsonb, text, interval);
221
+
222
+ create or replace function agent_board.mint_delegated_child(
223
+ p_root_agent_id uuid,
224
+ p_child_slug text,
225
+ p_requested_scopes jsonb,
226
+ p_child_public_key text default null,
227
+ p_ttl interval default null,
228
+ p_self_service boolean default false
229
+ )
230
+ returns table(
231
+ child_agent_id uuid,
232
+ grant_expires_at timestamptz,
233
+ signing_key_id uuid
234
+ )
235
+ language plpgsql
236
+ security definer
237
+ set search_path = agent_board, public
238
+ as $$
239
+ declare
240
+ v_policy agent_board.delegation_policies%rowtype;
241
+ v_root agent_board.agents%rowtype;
242
+ v_slug text;
243
+ v_requested jsonb := coalesce(p_requested_scopes, '[]'::jsonb);
244
+ v_allowed jsonb;
245
+ v_active_children int;
246
+ v_child_auth_user_id uuid;
247
+ v_child_id uuid;
248
+ v_key_id uuid := null;
249
+ v_grant_expires_at timestamptz;
250
+ v_effective_max int;
251
+ v_ceiling interval;
252
+ -- Per-root provisioning rate cap for the authenticated (self-service) path.
253
+ v_rate_limit constant int := 25;
254
+ v_rate_window constant interval := interval '1 hour';
255
+ begin
256
+ if not agent_board._is_service_role() then
257
+ raise exception 'mint_delegated_child is service_role-only' using errcode = '42501';
258
+ end if;
259
+
260
+ -- Non-positive TTL floor: an immediately-expired grant escapes the
261
+ -- max_active_children count while still provisioning an auth user/agent/key.
262
+ if p_ttl is not null and p_ttl <= interval '0' then
263
+ raise exception 'non_positive_ttl: requested grant TTL must be positive' using errcode = '22023';
264
+ end if;
265
+
266
+ v_slug := agent_board._normalize_provision_slug(p_child_slug);
267
+
268
+ -- Root must exist.
269
+ select * into v_root from agent_board.agents where id = p_root_agent_id;
270
+ if not found then
271
+ raise exception 'root agent not found' using errcode = 'P0002';
272
+ end if;
273
+
274
+ -- Root policy must exist, be unrevoked, and be root_minted. #11: take a row lock
275
+ -- on the policy up front so a concurrent mint against the same root serializes
276
+ -- here — the max_active_children count->insert below cannot then be raced past
277
+ -- the ceiling (TOCTOU close).
278
+ select * into v_policy
279
+ from agent_board.delegation_policies dp
280
+ where dp.root_agent_id = p_root_agent_id
281
+ and dp.revoked_at is null
282
+ order by dp.created_at desc
283
+ limit 1
284
+ for update;
285
+ if not found then
286
+ raise exception 'no active delegation policy for root agent' using errcode = '42501';
287
+ end if;
288
+ if v_policy.mode <> 'root_minted' then
289
+ raise exception 'delegation policy mode is not root_minted (independent_auth children self-provision)' using errcode = '42501';
290
+ end if;
291
+
292
+ -- Root principal must be active (self + lineage).
293
+ if not agent_board._agent_principal_active(p_root_agent_id) then
294
+ raise exception 'principal_inactive: root agent principal is not fully active' using errcode = '42501';
295
+ end if;
296
+
297
+ -- max_depth = 1 / no transitive minting: p_root_agent_id must be a TRUE lineage
298
+ -- root (unparented) and must not itself be a delegated child.
299
+ if v_root.parent_agent_id is not null then
300
+ raise exception 'max_depth: only a true lineage root may mint (no transitive delegation)' using errcode = '42501';
301
+ end if;
302
+ if exists (select 1 from agent_board.delegation_grants g where g.child_agent_id = p_root_agent_id) then
303
+ raise exception 'max_depth: a delegated child may not mint further children' using errcode = '42501';
304
+ end if;
305
+
306
+ -- Scope subset: requested ⊆ allowed (JSON array containment). A NULL allowed_scopes
307
+ -- means the root carries no delegable scope, so only an empty request is a subset.
308
+ v_allowed := coalesce(v_policy.allowed_scopes, '[]'::jsonb);
309
+ if not (v_allowed @> v_requested) then
310
+ raise exception 'scope_exceeded: requested scopes are not a subset of the root allowed_scopes' using errcode = '42501';
311
+ end if;
312
+
313
+ -- max_active_children: the root's live (non-revoked, unexpired) grant count must
314
+ -- remain within the ceiling after this mint.
315
+ if p_self_service then
316
+ -- F1: never trust the stored caps on the authenticated (EF-forwarded) path. A root
317
+ -- can self-widen its own operator_vetted policy row (#142), so re-clamp at mint —
318
+ -- NULL is treated as the 25-child ceiling, and any stored value above 25 is clamped
319
+ -- down. The authenticated path can NEVER mint uncapped.
320
+ v_effective_max := least(coalesce(v_policy.max_active_children, 25), 25);
321
+ select count(*) into v_active_children
322
+ from agent_board.delegation_grants g
323
+ where g.root_agent_id = p_root_agent_id
324
+ and g.revoked_at is null
325
+ and g.grant_expires_at > now();
326
+ if v_active_children >= v_effective_max then
327
+ raise exception 'max_active_children: root has reached its active delegated-child ceiling' using errcode = '42501';
328
+ end if;
329
+ elsif v_policy.max_active_children is not null then
330
+ -- Operator path: trusted caps as supplied (NULL = unbounded, operator's choice).
331
+ select count(*) into v_active_children
332
+ from agent_board.delegation_grants g
333
+ where g.root_agent_id = p_root_agent_id
334
+ and g.revoked_at is null
335
+ and g.grant_expires_at > now();
336
+ if v_active_children >= v_policy.max_active_children then
337
+ raise exception 'max_active_children: root has reached its active delegated-child ceiling' using errcode = '42501';
338
+ end if;
339
+ end if;
340
+
341
+ -- Per-root provisioning RATE cap (authenticated path only): bounds cumulative
342
+ -- mint->expire->mint fan-out. Enforced after the gates so only would-be-successful
343
+ -- mints consume budget, before any provisioning.
344
+ if p_self_service then
345
+ if not agent_board._check_and_bump_child_mint_rate(p_root_agent_id, v_rate_limit, v_rate_window) then
346
+ raise exception 'mint_rate_exceeded: root has reached its provisioning rate cap for the current window' using errcode = '42501';
347
+ end if;
348
+ end if;
349
+
350
+ -- Provision the child: parent = root = p_root_agent_id, same owner_user_id, own
351
+ -- auth binding (distinct auth_user_id for the active-unique index).
352
+ v_child_auth_user_id := agent_board._ensure_agent_auth_user(v_slug);
353
+
354
+ insert into agent_board.agents(
355
+ agent_type, kind, handle, display_name, status,
356
+ auth_user_id, owner_user_id, parent_agent_id, root_agent_id, metadata
357
+ )
358
+ values (
359
+ 'agent', 'agent'::agent_board.agent_kind, v_slug,
360
+ agent_board._default_provision_display_name(v_slug), 'active',
361
+ v_child_auth_user_id, v_root.owner_user_id, p_root_agent_id, p_root_agent_id,
362
+ jsonb_build_object('delegatedByRootAgentId', p_root_agent_id, 'grantedScopes', v_requested)
363
+ )
364
+ returning id into v_child_id;
365
+
366
+ -- Scoped REVOCABLE signing key when the policy requires it. #5: the child's
367
+ -- public key MUST be supplied by the caller (generated client-side; the private
368
+ -- key never touches the DB). We NEVER seed a placeholder/zero key and NEVER
369
+ -- generate a key in-DB. status='active' + proof_state='proven' so the child can
370
+ -- immediately re-sign with the private key it already holds. The fingerprint is
371
+ -- derived over the RAW 32 bytes via _raw_key_fingerprint (single-sourced with enroll;
372
+ -- rejects a non-canonical alias) so the same key hashes identically across writers.
373
+ if v_policy.require_child_signing_key then
374
+ if p_child_public_key is null then
375
+ raise exception 'a child public key is required when the delegation policy requires a signing key' using errcode = '22023';
376
+ end if;
377
+ -- Raw Ed25519 public keys are 32 bytes => 43 base64url chars (no padding).
378
+ if p_child_public_key !~ '^[A-Za-z0-9_-]{43}$' then
379
+ raise exception 'malformed child public key: expected a 43-char base64url raw Ed25519 key' using errcode = '22023';
380
+ end if;
381
+ -- Reject the all-''A'' key: 43 base64url ''A''s decode to 32 zero bytes (a
382
+ -- small-order point), which is exactly the retired placeholder.
383
+ if p_child_public_key = repeat('A', 43) then
384
+ raise exception 'child public key is the all-zero small-order placeholder and is rejected' using errcode = '22023';
385
+ end if;
386
+
387
+ insert into agent_board.agent_signing_keys(
388
+ id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
389
+ status, proof_state, metadata
390
+ )
391
+ values (
392
+ gen_random_uuid(), v_child_id, 'delegated-ephemeral', 1, p_child_public_key,
393
+ 'raw_ed25519_base64url',
394
+ agent_board._raw_key_fingerprint(p_child_public_key),
395
+ 'active', 'proven',
396
+ jsonb_build_object('ephemeral', true, 'scopes', v_requested)
397
+ )
398
+ returning id into v_key_id;
399
+ end if;
400
+
401
+ -- Grant row: ceiling-bounded hours-scale working clock. On the authenticated path the
402
+ -- TTL ceiling is itself re-clamped to <= 24h (F1) — the stored ceiling is not trusted.
403
+ if p_self_service then
404
+ v_ceiling := least(coalesce(v_policy.child_ttl_ceiling, interval '24 hours'), interval '24 hours');
405
+ else
406
+ v_ceiling := v_policy.child_ttl_ceiling;
407
+ end if;
408
+ v_grant_expires_at := now() + least(coalesce(p_ttl, v_ceiling), v_ceiling);
409
+
410
+ insert into agent_board.delegation_grants(
411
+ child_agent_id, root_agent_id, parent_agent_id, delegation_policy_id,
412
+ granted_scopes, grant_expires_at
413
+ )
414
+ values (
415
+ v_child_id, p_root_agent_id, p_root_agent_id, v_policy.id,
416
+ v_requested, v_grant_expires_at
417
+ );
418
+
419
+ return query select v_child_id, v_grant_expires_at, v_key_id;
420
+ end;
421
+ $$;
422
+
423
+ comment on function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean) is
424
+ 'Mints a bounded root_minted delegated child (scope ⊆ root allowed_scopes, max_depth=1, '
425
+ 'policy-row-locked max_active_children, positive ceiling-bounded grant TTL) enrolling the '
426
+ 'CALLER-SUPPLIED Ed25519 public key with a RAW-byte fingerprint (_raw_key_fingerprint; '
427
+ 'private key stays client-side). service_role-only. p_self_service marks the EF-forwarded '
428
+ 'authenticated path: it re-clamps max_active_children to least(coalesce(stored,25),25) and '
429
+ 'the TTL ceiling to <=24h (stored caps are NOT trusted — a root can self-widen its policy, '
430
+ '#142) and applies a per-root provisioning rate cap. Direct authenticated EXECUTE stays revoked.';
431
+
432
+ revoke all on function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean) from public, anon, authenticated;
433
+ grant execute on function agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean) to service_role;
434
+
435
+ -- ---------------------------------------------------------------------------
436
+ -- 5. enroll_federated_identity — recreated to derive its fingerprint through the SAME
437
+ -- _raw_key_fingerprint helper (single source of truth for the catalog sweep). The
438
+ -- 14-arg (ROOMLINK) function from 20260724000200 is reproduced VERBATIM except the
439
+ -- fingerprint COMPUTATION (its inline raw-byte block) is replaced by the helper call;
440
+ -- the released 20260724000200 bytes are NOT edited. Signature is unchanged, so no
441
+ -- caller and no existence assertion moves.
442
+ -- ---------------------------------------------------------------------------
443
+ drop function if exists agent_board.enroll_federated_identity(
444
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text);
445
+
446
+ create or replace function agent_board.enroll_federated_identity(
447
+ p_issuer text,
448
+ p_audience text,
449
+ p_subject text,
450
+ p_human_identity_claim jsonb,
451
+ p_slug text,
452
+ p_agent_public_key text,
453
+ p_agent_public_key_format text,
454
+ p_agent_public_key_fingerprint text,
455
+ p_pop_verified boolean,
456
+ p_actor_sub text default null,
457
+ p_agent_kind text default 'agent',
458
+ p_requested_scopes text[] default '{}',
459
+ p_requested_board_scopes text[] default '{}',
460
+ p_invite_code text default null
461
+ )
462
+ returns table(
463
+ agent_id uuid,
464
+ auth_user_id uuid,
465
+ provider_id text,
466
+ signing_key_id uuid,
467
+ granted_scopes text[],
468
+ key_id text,
469
+ key_version int,
470
+ agent_handle text,
471
+ invite_redeemed boolean
472
+ )
473
+ language plpgsql
474
+ security definer
475
+ set search_path = agent_board, public
476
+ as $$
477
+ declare
478
+ v_slug text;
479
+ v_kind text;
480
+ v_provider agent_board.identity_providers%rowtype;
481
+ v_owner_user_id uuid;
482
+ v_auth_user_id uuid;
483
+ v_agent_handle text;
484
+ v_agent_id uuid;
485
+ v_signing_key_id uuid;
486
+ v_fingerprint text;
487
+ v_key_hex text;
488
+ v_raw_key bytea;
489
+ v_reencoded text;
490
+ v_key_id text := 'portal-enroll';
491
+ v_key_version int := 1;
492
+ v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
493
+ v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
494
+ v_invite_code text := nullif(trim(coalesce(p_invite_code, '')), '');
495
+ v_invite_redeemed boolean := false;
496
+ begin
497
+ -- service_role-only (defense in depth on top of the execute-grant revocation).
498
+ if not agent_board._is_service_role() then
499
+ raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
500
+ end if;
501
+
502
+ -- A verified subject + human identity claim are always present from the EF.
503
+ if p_subject is null then
504
+ raise exception 'verified subject is required' using errcode = '22023';
505
+ end if;
506
+ if p_human_identity_claim is null then
507
+ raise exception 'human identity claim is required' using errcode = '22023';
508
+ end if;
509
+
510
+ -- (0) PoP gate — the EF Ed25519-verified the proof-of-possession over the token
511
+ -- bytes and passes true. Refuse false BEFORE any write (no agent/key/audit row).
512
+ if p_pop_verified is not true then
513
+ raise exception 'proof-of-possession was not verified; enrollment refused'
514
+ using errcode = '42501';
515
+ end if;
516
+
517
+ -- (1) Public-key shape gate. The enroll contract is raw Ed25519 (32 bytes -> 43
518
+ -- base64url chars).
519
+ if p_agent_public_key_format is distinct from 'raw_ed25519_base64url' then
520
+ raise exception 'agent public key format must be raw_ed25519_base64url' using errcode = '22023';
521
+ end if;
522
+ if p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
523
+ raise exception 'malformed agent public key: expected a 43-char base64url raw Ed25519 key'
524
+ using errcode = '22023';
525
+ end if;
526
+
527
+ -- (1a) B2 — decode base64url -> raw 32 bytes and REJECT any non-canonical encoding.
528
+ v_raw_key := decode(translate(p_agent_public_key, '-_', '+/') || '=', 'base64');
529
+ if octet_length(v_raw_key) <> 32 then
530
+ raise exception 'agent public key must decode to exactly 32 raw Ed25519 bytes'
531
+ using errcode = '22023';
532
+ end if;
533
+ v_reencoded := rtrim(translate(encode(v_raw_key, 'base64'), '+/', '-_'), '=');
534
+ if v_reencoded is distinct from p_agent_public_key then
535
+ raise exception 'agent public key is not canonical base64url (aliased encoding rejected)'
536
+ using errcode = '22023';
537
+ end if;
538
+
539
+ -- (1b) Reject the all-zero small-order placeholder over the RAW bytes.
540
+ if v_raw_key = decode(repeat('00', 32), 'hex') then
541
+ raise exception 'agent public key is the all-zero small-order placeholder and is rejected'
542
+ using errcode = '22023';
543
+ end if;
544
+
545
+ -- (2) Recompute-and-assert the fingerprint in-DB over the RAW 32 bytes. Derived through
546
+ -- the shared _raw_key_fingerprint helper (single source of truth; catalog-sweep-pinned).
547
+ v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
548
+ if p_agent_public_key_fingerprint is distinct from v_fingerprint then
549
+ raise exception 'agent public key fingerprint mismatch (recompute != supplied)'
550
+ using errcode = '22023';
551
+ end if;
552
+
553
+ -- (3) act.sub is audit-only, but when present it must be a real value.
554
+ if p_actor_sub is not null and length(trim(p_actor_sub)) = 0 then
555
+ raise exception 'actor sub, when present, must be non-empty' using errcode = '22023';
556
+ end if;
557
+
558
+ v_slug := agent_board._normalize_provision_slug(p_slug);
559
+ v_kind := lower(coalesce(trim(p_agent_kind), ''));
560
+
561
+ -- (4) allowlist lookup by (issuer, audience); must exist + supported + policy-approved.
562
+ select *
563
+ into v_provider
564
+ from agent_board.identity_providers
565
+ where issuer = p_issuer
566
+ and audience = p_audience
567
+ limit 1;
568
+ if v_provider.id is null then
569
+ raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
570
+ end if;
571
+ if not v_provider.supported then
572
+ raise exception 'identity provider is not supported' using errcode = '42501';
573
+ end if;
574
+ if not v_provider.policy_approved then
575
+ raise exception 'identity provider is not policy-approved' using errcode = '42501';
576
+ end if;
577
+
578
+ -- (5) scope-ceiling + kind gate. 'system' is never federatable.
579
+ if v_kind = 'system' then
580
+ raise exception 'the system kind cannot be federated' using errcode = '42501';
581
+ end if;
582
+ if v_kind not in ('human', 'agent') then
583
+ raise exception 'agent kind must be human or agent' using errcode = '22023';
584
+ end if;
585
+ if array_length(v_provider.allowed_agent_kinds, 1) is not null
586
+ and not (v_kind = any (v_provider.allowed_agent_kinds)) then
587
+ raise exception 'agent kind % is outside the provider ceiling', v_kind using errcode = '42501';
588
+ end if;
589
+ if not (v_requested_scopes <@ v_provider.allowed_scopes) then
590
+ raise exception 'requested scopes exceed the provider allowed_scopes ceiling' using errcode = '42501';
591
+ end if;
592
+ if not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
593
+ raise exception 'requested board scopes exceed the provider allowed_board_scopes ceiling' using errcode = '42501';
594
+ end if;
595
+
596
+ -- (6) dedup on the enrolled key: a fingerprint already enrolled cannot be re-bound.
597
+ if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = v_fingerprint) then
598
+ raise exception 'this signing key is already enrolled (duplicate fingerprint)'
599
+ using errcode = '23505';
600
+ end if;
601
+
602
+ -- (7) HUMAN OWNER — established idempotently per (issuer, sub).
603
+ v_owner_user_id := agent_board._ensure_agent_auth_user(
604
+ 'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32));
605
+
606
+ -- (8) AGENT handle — unique per enrolled key.
607
+ v_key_hex := encode(extensions.digest(v_raw_key, 'sha256'), 'hex');
608
+ v_agent_handle := v_slug;
609
+ if exists (select 1 from agent_board.agents a where a.handle = v_agent_handle) then
610
+ v_agent_handle := v_slug || '-' || substr(v_key_hex, 1, 12);
611
+ end if;
612
+
613
+ -- (9) the agent's OWN board principal (session-usable via its enrolled key).
614
+ v_auth_user_id := agent_board._ensure_agent_auth_user(v_agent_handle);
615
+
616
+ -- (10) create the SEPARATE agent row (never keyed on sub; owner = the human).
617
+ insert into agent_board.agents(
618
+ agent_type,
619
+ kind,
620
+ handle,
621
+ display_name,
622
+ status,
623
+ auth_user_id,
624
+ owner_user_id,
625
+ metadata
626
+ )
627
+ values (
628
+ v_kind,
629
+ v_kind::agent_board.agent_kind,
630
+ v_agent_handle,
631
+ agent_board._default_provision_display_name(v_agent_handle),
632
+ 'active',
633
+ v_auth_user_id,
634
+ v_owner_user_id,
635
+ jsonb_build_object(
636
+ 'federation', jsonb_build_object(
637
+ 'issuer', p_issuer,
638
+ 'audience', p_audience,
639
+ 'subject', p_subject,
640
+ 'actorSub', p_actor_sub,
641
+ 'providerId', v_provider.id,
642
+ 'signingKeyFingerprint', v_fingerprint,
643
+ 'requestedScopes', to_jsonb(v_requested_scopes),
644
+ 'requestedBoardScopes', to_jsonb(v_requested_board_scopes),
645
+ 'popVerified', true,
646
+ 'enrolledAt', now()
647
+ )
648
+ )
649
+ )
650
+ returning id into v_agent_id;
651
+
652
+ -- (11) enroll the public key as active + proven (satisfies active_requires_proof).
653
+ insert into agent_board.agent_signing_keys(
654
+ id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
655
+ status, proof_state, metadata
656
+ )
657
+ values (
658
+ gen_random_uuid(), v_agent_id, v_key_id, v_key_version, p_agent_public_key,
659
+ p_agent_public_key_format, v_fingerprint,
660
+ 'active', 'proven',
661
+ jsonb_build_object('enrolledVia', 'federated_identity', 'popVerified', true)
662
+ )
663
+ returning id into v_signing_key_id;
664
+
665
+ -- (12) one static claim-snapshot audit row (the verified claim contains act.sub).
666
+ insert into agent_board.enrollment_federation_audit(
667
+ issuer, audience, subject, human_identity_claim, provider_id,
668
+ agent_id, auth_user_id, requested_scopes, granted_scopes
669
+ )
670
+ values (
671
+ p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
672
+ v_agent_id, v_auth_user_id, v_requested_scopes, v_requested_scopes
673
+ );
674
+
675
+ -- (13) ROOMLINK soft-honor: if a code is present, redeem it into a bounded board
676
+ -- membership for the freshly-created agent. Wrapped in a subtransaction so a
677
+ -- bad/expired/exhausted/unauthorized code rolls back ONLY this sub-block — the
678
+ -- agent-create above persists and enrollment still succeeds (invite_redeemed=false).
679
+ if v_invite_code is not null then
680
+ begin
681
+ perform agent_board._redeem_invite_for_agent(v_invite_code, v_agent_id);
682
+ v_invite_redeemed := true;
683
+ exception when others then
684
+ v_invite_redeemed := false;
685
+ end;
686
+ end if;
687
+
688
+ -- Server-authoritative identity: the client mints /session with the RETURNED
689
+ -- key_id/key_version/agent_handle (its private key stays in the keystore).
690
+ return query
691
+ select v_agent_id, v_auth_user_id, v_provider.id, v_signing_key_id, v_requested_scopes,
692
+ v_key_id, v_key_version, v_agent_handle, v_invite_redeemed;
693
+ end;
694
+ $$;
695
+
696
+ comment on function agent_board.enroll_federated_identity(
697
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) is
698
+ 'PoP-at-enrollment federated identity (ROOMLINK-extended). Same server-authoritative '
699
+ 'identity establishment as 20260724000200, with the fingerprint derived through the shared '
700
+ '_raw_key_fingerprint helper (single source of truth; catalog-sweep-pinned). Optional '
701
+ 'trailing p_invite_code soft-honored via _redeem_invite_for_agent in a BEGIN/EXCEPTION '
702
+ 'subtransaction (bad code -> enrollment still succeeds, invite_redeemed=false). '
703
+ 'service_role-only; never returns a Portal token.';
704
+
705
+ revoke all on function agent_board.enroll_federated_identity(
706
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
707
+ from public, anon, authenticated;
708
+ grant execute on function agent_board.enroll_federated_identity(
709
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
710
+ to service_role;