@consiliency/agent-board-schema 1.28.0 → 1.35.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 (32) hide show
  1. package/fixtures/already-applied.json +28 -0
  2. package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
  3. package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
  4. package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
  5. package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
  6. package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
  7. package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
  8. package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
  9. package/package.json +1 -1
  10. package/schema-manifest.json +66 -3
  11. package/src/tooling.test.mjs +47 -2
  12. package/tests/agent_board_atomic_fleet_onboarding.test.sql +144 -29
  13. package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
  14. package/tests/agent_board_enroll_resumption.test.sql +7 -1
  15. package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
  16. package/tests/agent_board_fedsession.test.sql +17 -2
  17. package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
  18. package/tests/agent_board_fk_lock_edges.test.sql +422 -0
  19. package/tests/agent_board_ledger_authority.test.sql +142 -71
  20. package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
  21. package/tests/agent_board_signer_lifecycle.test.sql +23 -6
  22. package/tests/agent_board_source_strip_helper.test.sql +157 -1
  23. package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
  24. package/tests/catalog/p_metadata_parameters.json +12 -0
  25. package/tests/fixtures/test-prereqs.sql +420 -11
  26. package/tests/isolation/expected/ledger_authority_revoke.out +219 -1
  27. package/tests/isolation/expected/task_thread_lock_order.out +203 -0
  28. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
  29. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
  30. package/tests/isolation/specs/ledger_authority_revoke.spec +115 -0
  31. package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
  32. package/tests/seeds/20260916000100_agent_board_federated_identity_injective_slug.sql.pre.sql +196 -0
@@ -0,0 +1,1646 @@
1
+ -- Consiliency/message-board#246 (change 2 of 3): the federated identity is derived
2
+ -- through ONE injective serialization, and the ambiguous one is gone.
3
+ --
4
+ -- WHAT WAS WRONG. Every federated site derived the owner slug as
5
+ --
6
+ -- 'fed-human-' || substr(encode(digest(convert_to(p_issuer || '|' || p_subject,
7
+ -- 'UTF8'), 'sha256'), 'hex'), 1, 32)
8
+ --
9
+ -- A single-character separator with no length prefix and no escaping is NOT an
10
+ -- injective serialization: the delimiter can also occur inside either component, so
11
+ -- the concatenation does not determine the pair. Concretely, and measured on this
12
+ -- schema rather than asserted:
13
+ --
14
+ -- issuer='https://idp.example' subject='user|123' -> fed-human-7abc9cea64fb2629f7780b96c4570573
15
+ -- issuer='https://idp.example|user' subject='123' -> fed-human-7abc9cea64fb2629f7780b96c4570573
16
+ --
17
+ -- Two distinct principals, one slug. And the slug is not a label: it is fed to
18
+ -- _ensure_agent_auth_user, which turns it into 'agent+<slug>@agent-board.local' and
19
+ -- finds-or-creates the auth.users row. The ambiguous value IS the account key.
20
+ --
21
+ -- WHAT IS INJECTIVE ABOUT THE REPLACEMENT. The new serialization is the netstring
22
+ -- form
23
+ --
24
+ -- length(issuer) || ':' || issuer || ':' || length(subject) || ':' || subject
25
+ --
26
+ -- and its injectivity is a decoding argument, not a "looks different" argument:
27
+ -- reading left to right, the digits before the first ':' are a decimal count n1 (the
28
+ -- delimiter is unambiguous because a decimal count contains no ':'); consume exactly
29
+ -- the next n1 characters -- whatever they contain, including ':' or '|' -- and that
30
+ -- IS the issuer; require a ':'; repeat for the subject; require end of string. Every
31
+ -- serialized string therefore has exactly one preimage pair, so distinct pairs
32
+ -- produce distinct serializations, and convert_to(..., 'UTF8') is itself injective on
33
+ -- text. Under the old form no such decoder exists, which is the whole defect.
34
+ --
35
+ -- The 32-hex truncation is UNCHANGED and is a different property. Truncating SHA-256
36
+ -- to 128 bits bounds collisions at ~2^64 birthday work -- not constructible. The
37
+ -- defect being fixed here was constructible with a text editor. Widening the digest
38
+ -- would have moved every slug for a second, unrelated reason; it is deliberately not
39
+ -- bundled in.
40
+ --
41
+ -- ONE HELPER, NOT SIX COPIES. agent_board._federated_identity_slug(issuer, subject)
42
+ -- is the only place the serialization exists. Catalog-derived, at the vintage this
43
+ -- migration was written (2026-08-16), the live derivation sites were:
44
+ --
45
+ -- mqccjfqngptkemnchlko (canonical/prod, older bundle): 2 functions, 2 occurrences
46
+ -- enroll_federated_human, enroll_federated_identity
47
+ -- bxrbtugitzwgazmoslrx (Portal) and repo HEAD: 5 functions, 6 occurrences
48
+ -- enroll_federated_human, _enroll_federated_human_after_revocation_gate,
49
+ -- enroll_federated_identity (x2), _enroll_federated_identity_once,
50
+ -- onboard_federated_fleet_agent
51
+ --
52
+ -- The two lists differ because 20260903000100 / 20260906000100 / 20260915000100
53
+ -- restructured the call chain after prod's bundle was cut. Nobody hand-ports: prod
54
+ -- adopts the bundle IN ORDER, so it is already at the 5-function shape by the time
55
+ -- this migration applies. All 5 are rebuilt below from their catalog definitions
56
+ -- (pg_get_functiondef on a full replay), not from the migration files that first
57
+ -- introduced them -- the file that introduced a derivation is usually not the file
58
+ -- holding the live one.
59
+ --
60
+ -- THE HELPER RAISES ON A NULL OR EMPTY COMPONENT. These are two different cases and an
61
+ -- earlier version of this comment ran them together; separating them, because one of them
62
+ -- IS a behaviour change to a path that previously succeeded.
63
+ --
64
+ -- NULL is the harmless half. 'fed-human-' || NULL is NULL, _ensure_agent_auth_user(NULL)
65
+ -- builds 'agent+@agent-board.local', and every null-issuer caller converges on ONE
66
+ -- degenerate shared owner. Four of the five sites gate on identity_providers (which a NULL
67
+ -- issuer can never match) before deriving, so the raise is unreachable for them;
68
+ -- onboard_federated_fleet_agent derives BEFORE any issuer check, so there it fires earlier
69
+ -- than the delegate's own 22023 null-subject refusal. Nothing that succeeded stops
70
+ -- succeeding.
71
+ --
72
+ -- EMPTY STRING IS NOT THAT, and this migration DOES change it. The identity path's guard
73
+ -- is `if p_subject is null then raise` (enroll_federated_identity, live catalog) -- `is
74
+ -- null`, not `= ''`. So an allowlisted issuer presenting subject = '' passed every gate,
75
+ -- and under the old encoding derived issuer || '|' || '' -- an ISSUER-SPECIFIC slug, not
76
+ -- the degenerate shared one: two different issuers with an empty subject derived two
77
+ -- different owners. That was a genuine success and it now raises 22023. It is judged the
78
+ -- right refusal -- an empty subject identifies no principal, and an owner keyed on the
79
+ -- issuer alone is a whole-issuer account rather than a person's -- but it is a REFUSAL OF
80
+ -- SOMETHING THAT WORKED, not a no-op, and any adopter with such a row must be told rather
81
+ -- than discover it. Measured on the canonical project 2026-08-16: 0 agents reachable from
82
+ -- a fed-human owner carry an empty subject, so the live population is unaffected.
83
+ --
84
+ -- The backfill inherits the same distinction, and imperfectly: its recovery filter below
85
+ -- requires the stamp fields to be `is not null`, not non-empty, so a PRE-EXISTING
86
+ -- subject = '' stamp is accepted as a candidate and then raises inside the helper at the
87
+ -- v_new := line -- a correct refusal reached by an awkward route, because that raise
88
+ -- carries the helper's generic message and not the loop's uid-naming one. The population
89
+ -- for this is 0 on both projects; it is recorded rather than fixed because tightening the
90
+ -- filter is a behavioural change to a body four review seats have already cleared, and it
91
+ -- belongs in its own change with its own mutation.
92
+ --
93
+ -- NO DUAL DERIVATION, DELIBERATELY. There is no legacy arm, no fallback lookup, and
94
+ -- no legacy decoder left in the catalog. A permanent compatibility path would preserve
95
+ -- exactly the ambiguity this change exists to remove -- a caller who can still be
96
+ -- resolved by the old encoding is still a caller whose pair is unrecoverable. The old
97
+ -- expression appears exactly once more in this file, inside the backfill's DO block,
98
+ -- as the PROOF that each row being re-keyed is the row its recovered pair derives to.
99
+ -- That text is not a function; nothing in pg_proc retains it, which the pgTAP class
100
+ -- assertion checks catalog-side rather than by reading this file.
101
+ --
102
+ -- THE BACKFILL IS THE POINT, NOT A TIDY-UP. Changing the derivation without moving
103
+ -- auth.users.email ORPHANS the existing owner: the email still encodes the old slug,
104
+ -- so enroll_federated_identity's #244 resumption lookup (a pure lookup on
105
+ -- 'agent+<slug>@agent-board.local') misses, v_derived_owner_user_id comes back null,
106
+ -- the non-null owner_user_id conjunct fails, and the owner cannot resume. That reads
107
+ -- like a cosmetic relabel and is a break. Four surfaces carry the slug and all four
108
+ -- move together: auth.users.email, auth.users.raw_user_meta_data->>'slug',
109
+ -- auth.identities (provider_id and identity_data->>'sub'/'slug'), and
110
+ -- agent_board.agents.handle where the handle IS the slug (the human path's own agent
111
+ -- row; agents enrolled on the identity/fleet paths carry their own handles and only
112
+ -- reference the owner by uuid).
113
+ --
114
+ -- THE OWNER UUID NEVER CHANGES. The backfill rewrites the email that keys the lookup,
115
+ -- not the auth.users.id it resolves to. Every agents.owner_user_id, every
116
+ -- agents.auth_user_id, every issued session and every foreign key still points at the
117
+ -- same uuid. This is a re-key of the lookup value, not a re-mint of the principal.
118
+ --
119
+ -- POPULATION, RE-MEASURED READ-ONLY ON 2026-08-16 (management API, no writes):
120
+ -- mqccjfqngptkemnchlko : 1 auth.users row matching agent+fed-human-%@agent-board.local
121
+ -- uid bdd9671f-df9e-4ee5-8115-2a22b7daa441
122
+ -- slug 77213f39... -> 4fa7e7ad... under the new serialization
123
+ -- 1 agent on that auth_user_id, 6 agents owned by it,
124
+ -- 5 signing keys, 5 board memberships, ONE distinct
125
+ -- (issuer, subject) pair across all six stamps, and that pair
126
+ -- re-derives 77213f39... under the OLD encoding exactly.
127
+ -- bxrbtugitzwgazmoslrx : 0 rows. Portal's side of this change is code-only.
128
+ -- The count is taken on the OWNER UID, not on agents.handle: the resumption path
129
+ -- selects on auth_user_id, so a handle-keyed count is the wrong denominator.
130
+ -- The raw (issuer, subject) is a real person's identity and is deliberately NOT
131
+ -- recorded here; the slugs above are one-way digests of it.
132
+ --
133
+ -- THIS MIGRATION MUST BE APPLIED INSIDE A TRANSACTION, and the fail-closed argument below
134
+ -- is only true if it is. Every raise in the backfill assumes that aborting rolls the WHOLE
135
+ -- file back. Applied statement-at-a-time -- which is what `psql -v ON_ERROR_STOP=1 -f`
136
+ -- does, autocommit per statement, and it is how this repo's own replay harness runs
137
+ -- (scripts/run-agent-board-schema-tests.sh) -- an abort in the backfill leaves the six
138
+ -- CREATE OR REPLACE statements COMMITTED and every auth.users.email unmoved. That is
139
+ -- exactly the orphaned-owner state the TRAP paragraph above exists to prevent, reached by
140
+ -- the guard that was supposed to prevent it; and an enrollment of the same pair during
141
+ -- that window would mint a SECOND owner under the new email and then permanently trip the
142
+ -- collision guard on any retry. Supabase's migration runner wraps each file in a
143
+ -- transaction, which is why the replay is safe in practice -- but an operator applying
144
+ -- this file by hand must wrap it (BEGIN; \i <file>; COMMIT;) or run it through a runner
145
+ -- that does. This precondition is load-bearing and was previously unwritten.
146
+ --
147
+ -- FAIL CLOSED ON A ROW IT CANNOT PROVE. For each affected auth.users row the backfill
148
+ -- recovers the pair from the federation stamps of the agents that reference it (by
149
+ -- auth_user_id -- the human path -- or by owner_user_id -- the identity/fleet paths),
150
+ -- KEEPING ONLY pairs whose OLD derivation reproduces that row's current slug, and
151
+ -- requires exactly one DISTINCT such pair. Zero (no stamp, or no stamp that
152
+ -- reproduces) or more than one (a genuine collision) raises and aborts the migration
153
+ -- rather than guessing, because guessing here silently binds an account to the wrong
154
+ -- human. DISTINCT is load-bearing and is not count(*): production's row has six agents
155
+ -- carrying the same pair, and a count of rows would refuse it.
156
+
157
+ create or replace function agent_board._federated_identity_slug(
158
+ p_issuer text,
159
+ p_subject text
160
+ )
161
+ returns text
162
+ language plpgsql
163
+ immutable
164
+ set search_path = ''
165
+ as $fn$
166
+ begin
167
+ -- Fail closed rather than derive a degenerate shared slug from a missing component.
168
+ if p_issuer is null or p_issuer = '' or p_subject is null or p_subject = '' then
169
+ raise exception 'federated identity slug requires a non-empty issuer and subject'
170
+ using errcode = '22023';
171
+ end if;
172
+
173
+ -- Netstring serialization: <len>:<value>:<len>:<value>. Injective -- see the header.
174
+ -- The components are hashed VERBATIM; no trimming or case folding, because any
175
+ -- normalization here would silently move an existing identity.
176
+ return 'fed-human-' || substr(
177
+ encode(
178
+ extensions.digest(
179
+ convert_to(
180
+ length(p_issuer)::text || ':' || p_issuer || ':' ||
181
+ length(p_subject)::text || ':' || p_subject,
182
+ 'UTF8'),
183
+ 'sha256'),
184
+ 'hex'),
185
+ 1, 32);
186
+ end;
187
+ $fn$;
188
+
189
+ comment on function agent_board._federated_identity_slug(text, text) is
190
+ 'message-board#246: the ONE injective (issuer, subject) -> fed-human-<32hex> serialization. Netstring length-prefixed, so the pair is recoverable from the preimage and distinct pairs cannot share a slug. Every federated derivation site calls this; the expression exists nowhere else. Raises 22023 on a null/empty component rather than deriving a shared degenerate slug.';
191
+
192
+ revoke all on function agent_board._federated_identity_slug(text, text) from public;
193
+ grant execute on function agent_board._federated_identity_slug(text, text) to service_role;
194
+
195
+ -- ---------------------------------------------------------------------------
196
+ -- The five live derivation sites, rebuilt from their catalog definitions with the
197
+ -- inline expression replaced by the helper call. create or replace (never drop)
198
+ -- so each function keeps its ACL and its COMMENT.
199
+ -- ---------------------------------------------------------------------------
200
+
201
+ CREATE OR REPLACE FUNCTION agent_board.enroll_federated_human(p_issuer text, p_audience text, p_subject text, p_human_identity_claim jsonb, p_display_name text DEFAULT NULL::text)
202
+ RETURNS TABLE(agent_id uuid, auth_user_id uuid, handle text, kind text, status text, provider_id text, created_at timestamp with time zone)
203
+ LANGUAGE plpgsql
204
+ SECURITY DEFINER
205
+ SET search_path TO 'agent_board', 'public'
206
+ AS $function$
207
+ declare
208
+ v_provider_id agent_board.identity_providers.id%type;
209
+ v_default_board_id uuid;
210
+ v_locked_default_board_id uuid;
211
+ v_handle text;
212
+ v_uid uuid;
213
+ begin
214
+ if not agent_board._is_service_role() then
215
+ raise exception 'service_role required for enroll_federated_human' using errcode = '42501';
216
+ end if;
217
+ if p_subject is null then
218
+ raise exception 'verified subject is required' using errcode = '22023';
219
+ end if;
220
+
221
+ -- Snapshot the provider's auto-join target without a row lock so a non-null
222
+ -- target can be locked in canonical board -> provider order. Then lock the
223
+ -- provider row and require that the target did not change across the gap. A
224
+ -- concurrent NULL -> board designation is rejected for retry rather than
225
+ -- falling back to provider -> board and recreating the cross-route cycle.
226
+ select ip.id, ip.default_board_id
227
+ into v_provider_id, v_default_board_id
228
+ from agent_board.identity_providers ip
229
+ where ip.issuer = p_issuer
230
+ and ip.audience = p_audience
231
+ limit 1;
232
+
233
+ -- #193 PROVIDER-ABSENCE GATE, and it must come BEFORE the owner is provisioned
234
+ -- or locked. PostgreSQL cannot row-lock a row that does not exist, so an absent
235
+ -- provider left NOTHING to serialize on: this function used to skip the whole
236
+ -- board/provider ordering, lock the owner, and delegate -- and under READ
237
+ -- COMMITTED the delegate takes a FRESH snapshot, so a provider committed in that
238
+ -- gap became visible and its default board was auto-joined while this
239
+ -- transaction held auth.users. That is owner -> board, inverted against fleet
240
+ -- onboarding's board -> owner, and it deadlocked.
241
+ --
242
+ -- Rejecting here is a VALID serialization -- it orders this call before the
243
+ -- provider's creation, which is exactly what its own snapshot saw -- and it is
244
+ -- the same verdict _enroll_federated_human_after_revocation_gate step (4) would
245
+ -- have reached anyway for an allowlist miss, only now reached before any lock.
246
+ -- The message differs from the delegate's so a test can tell which gate fired.
247
+ -- NOTE: this is a permanent 42501, not a retryable 40001. A genuinely
248
+ -- unallowlisted issuer is a configuration error, and telling a caller to retry it
249
+ -- forever is worse than telling it no; the rare loser of a real provider-creation
250
+ -- race succeeds on its next /portal-session call.
251
+ if v_provider_id is null then
252
+ raise exception 'issuer/audience not in the identity-provider allowlist (rejected before any owner or board lock)'
253
+ using errcode = '42501';
254
+ end if;
255
+
256
+ if v_default_board_id is not null then
257
+ perform 1 from agent_board.boards b
258
+ where b.id = v_default_board_id
259
+ for update;
260
+ if not found then
261
+ raise exception 'federated human default board changed during enrollment'
262
+ using errcode = '40001';
263
+ end if;
264
+ end if;
265
+
266
+ -- Freeze the designation this transaction will delegate against. The provider row
267
+ -- is held FOR UPDATE through commit, and (issuer, audience) is UNIQUE, so the
268
+ -- delegate's own lookup cannot resolve to a different row or a changed
269
+ -- default_board_id than the one locked above.
270
+ select ip.default_board_id
271
+ into v_locked_default_board_id
272
+ from agent_board.identity_providers ip
273
+ where ip.id = v_provider_id
274
+ for update;
275
+ if not found or v_locked_default_board_id is distinct from v_default_board_id then
276
+ raise exception 'federated human default board changed during enrollment'
277
+ using errcode = '40001';
278
+ end if;
279
+
280
+ v_handle := agent_board._federated_identity_slug(p_issuer, p_subject);
281
+ v_uid := agent_board._ensure_agent_auth_user(v_handle);
282
+
283
+ perform 1 from auth.users u where u.id = v_uid for update;
284
+ if not found then
285
+ raise exception 'federated human owner not found after provisioning' using errcode = 'P0002';
286
+ end if;
287
+ if exists (
288
+ select 1
289
+ from agent_board.fleet_human_revocations r
290
+ where r.issuer = p_issuer
291
+ and r.subject = p_subject
292
+ ) then
293
+ raise exception 'human identity is durably revoked' using errcode = '42501';
294
+ end if;
295
+
296
+ return query
297
+ select * from agent_board._enroll_federated_human_after_revocation_gate(
298
+ p_issuer, p_audience, p_subject, p_human_identity_claim, p_display_name
299
+ );
300
+ end;
301
+ $function$
302
+
303
+ ;
304
+
305
+ CREATE OR REPLACE FUNCTION agent_board._enroll_federated_human_after_revocation_gate(p_issuer text, p_audience text, p_subject text, p_human_identity_claim jsonb, p_display_name text DEFAULT NULL::text)
306
+ RETURNS TABLE(agent_id uuid, auth_user_id uuid, handle text, kind text, status text, provider_id text, created_at timestamp with time zone)
307
+ LANGUAGE plpgsql
308
+ SECURITY DEFINER
309
+ SET search_path TO 'agent_board', 'public'
310
+ AS $function$
311
+ declare
312
+ v_provider agent_board.identity_providers%rowtype;
313
+ v_handle text;
314
+ v_uid uuid;
315
+ v_agent agent_board.agents%rowtype;
316
+ v_existing agent_board.agents%rowtype;
317
+ v_email text := nullif(trim(coalesce(p_human_identity_claim->>'email', '')), '');
318
+ v_email_verified boolean;
319
+ begin
320
+ -- Derive the verified-email boolean from the claim (never trust it as board-proven
321
+ -- possession; it only records what the Portal asserted). A non-boolean/absent value
322
+ -- collapses to false.
323
+ begin
324
+ v_email_verified := coalesce((p_human_identity_claim->>'email_verified')::boolean, false);
325
+ exception when others then
326
+ v_email_verified := false;
327
+ end;
328
+
329
+ -- (1) service_role-only (defense in depth on top of the execute-grant revocation).
330
+ if not agent_board._is_service_role() then
331
+ raise exception 'service_role required for enroll_federated_human' using errcode = '42501';
332
+ end if;
333
+
334
+ -- (2) A verified subject + human identity claim are always present from the EF.
335
+ if p_subject is null then
336
+ raise exception 'verified subject is required' using errcode = '22023';
337
+ end if;
338
+ if p_human_identity_claim is null then
339
+ raise exception 'human identity claim is required' using errcode = '22023';
340
+ end if;
341
+
342
+ -- (3) F-11: the synthetic-uid preimage is issuer||'|'||subject. Guard the separator so a
343
+ -- subject cannot inject a '|' to collide two distinct (issuer, subject) pairs onto one
344
+ -- owner uid. ALSO reject an email-shaped subject ('@'): the subject is stored VERBATIM in
345
+ -- agents.metadata.federation.subject, and the agents SELECT RLS is not row-scoped, so an
346
+ -- email `sub` would be a fleet-harvestable PII leak — the exact hazard the email is kept
347
+ -- out of agents.metadata to avoid. The operator's ENROLL_SUBJECT_CLAIM must resolve to an
348
+ -- opaque id; this is the fail-closed backstop, and the pgTAP PII guard asserts BOTH the
349
+ -- '@'-rejection and metadata carrying no '@'.
350
+ if position('|' in p_subject) > 0 then
351
+ raise exception 'verified subject must not contain a pipe separator character'
352
+ using errcode = '22023';
353
+ end if;
354
+ if position('@' in p_subject) > 0 then
355
+ raise exception 'verified subject must be an opaque id, not an email/@-bearing value'
356
+ using errcode = '22023';
357
+ end if;
358
+
359
+ -- (4) provider gate on (issuer, audience): must exist + supported + policy_approved.
360
+ -- Defense in depth atop the EF's signature verify (mirrors 20260719001200:154-181).
361
+ select *
362
+ into v_provider
363
+ from agent_board.identity_providers
364
+ where issuer = p_issuer
365
+ and audience = p_audience
366
+ limit 1;
367
+ if v_provider.id is null then
368
+ raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
369
+ end if;
370
+ if not v_provider.supported then
371
+ raise exception 'identity provider is not supported' using errcode = '42501';
372
+ end if;
373
+ if not v_provider.policy_approved then
374
+ raise exception 'identity provider is not policy-approved' using errcode = '42501';
375
+ end if;
376
+
377
+ -- (5) HUMAN-ONLY ceiling. allowed_agent_kinds is permissive on empty/null (the ceiling
378
+ -- is "unset"); reject ONLY when it is non-empty AND does not include 'human'.
379
+ if array_length(v_provider.allowed_agent_kinds, 1) is not null
380
+ and not ('human' = any (v_provider.allowed_agent_kinds)) then
381
+ raise exception 'provider ceiling does not permit the human kind' using errcode = '42501';
382
+ end if;
383
+
384
+ -- (6) SYNTHETIC uid — derived by agent_board._federated_identity_slug, the ONE
385
+ -- serialization every federated site shares (#246). "Byte-identical to
386
+ -- enroll_federated_identity" was a copied expression and a promise; it is now a single
387
+ -- call, so the sites cannot drift apart and the old line citation cannot go stale.
388
+ -- Idempotent per (issuer, sub) so re-provisioning converges to ONE owner auth.users row
389
+ -- = the shared IDLINK uid. Pass issuer/subject VERBATIM (no trim/normalize): the helper
390
+ -- length-prefixes them before hashing, so normalizing here would move the identity.
391
+ v_handle := agent_board._federated_identity_slug(p_issuer, p_subject);
392
+ v_uid := agent_board._ensure_agent_auth_user(v_handle);
393
+
394
+ -- (7) Idempotent + no-self-reactivation, keyed on auth_user_id = the synthetic uid (ANY
395
+ -- status). An ACTIVE row -> return it (idempotent). A NON-active row -> fail closed
396
+ -- (42501): a suspended/revoked human must NOT re-mint a session (mirrors
397
+ -- 20260726000100:78-81 and reinforces the F-13 revocation lever). F-5: assert the row is
398
+ -- human before returning it as a human session (a non-human agent on this owner uid must
399
+ -- never yield a "human" provision).
400
+ select *
401
+ into v_existing
402
+ from agent_board.agents a
403
+ where a.auth_user_id = v_uid
404
+ order by (a.status = 'active') desc, a.created_at asc
405
+ limit 1;
406
+ if found then
407
+ -- (7a) #246 STORED-IDENTITY EQUALITY. The select above keys on the DERIVED uid
408
+ -- and nothing else, and the derivation is not injective, so `found` does not
409
+ -- mean "the caller's own agent". Require the pair this row was stamped with at
410
+ -- creation to equal the pair the caller presented. coalesce(..., false): a row
411
+ -- with no stored stamp yields NULL, which must read as NOT-mine, not as
412
+ -- unchecked. First, before the status and kind branches, so a mismatching
413
+ -- caller learns only that it was refused.
414
+ if not coalesce(
415
+ v_existing.metadata #>> '{federation,issuer}' = p_issuer
416
+ and v_existing.metadata #>> '{federation,subject}' = p_subject, false) then
417
+ raise exception 'federated identity mismatch: the owner derived for this issuer/subject is already bound to a different verified identity; refusing to issue a session'
418
+ using errcode = '42501';
419
+ end if;
420
+ if v_existing.status = 'active' then
421
+ if v_existing.kind <> 'human' then
422
+ raise exception 'existing agent on this identity is not human; refusing human session'
423
+ using errcode = '42501';
424
+ end if;
425
+ v_agent := v_existing;
426
+ -- Refresh the verified-email snapshot on re-provision (MERGE, see step 9).
427
+ if v_email is not null then
428
+ update auth.users u
429
+ set raw_user_meta_data = coalesce(u.raw_user_meta_data, '{}'::jsonb)
430
+ || jsonb_build_object(
431
+ 'fedsession_email', v_email,
432
+ 'fedsession_email_verified', v_email_verified,
433
+ 'fedsession_email_provenance', 'portal-claim-unverified')
434
+ where u.id = v_uid;
435
+ end if;
436
+ -- (auto-join before the idempotent return — see step 11 for the rationale).
437
+ perform agent_board._fedsession_autojoin_default_board(v_provider, v_agent.id);
438
+ insert into agent_board.enrollment_federation_audit(
439
+ issuer, audience, subject, human_identity_claim, provider_id,
440
+ agent_id, auth_user_id, requested_scopes, granted_scopes, mode)
441
+ values (
442
+ p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
443
+ v_agent.id, v_uid, '{}', '{}', 'fedsession');
444
+ return query
445
+ select v_agent.id, v_agent.auth_user_id, v_agent.handle,
446
+ v_agent.kind::text, v_agent.status, v_provider.id, v_agent.created_at;
447
+ return;
448
+ else
449
+ raise exception 'a non-active identity already exists for this human; contact an operator'
450
+ using errcode = '42501';
451
+ end if;
452
+ end if;
453
+
454
+ -- (8) Create the kind='human' agent (self-owned: auth_user_id = owner_user_id = uid).
455
+ -- No agent_signing_keys row (humans hold no key). metadata.federation carries ONLY the
456
+ -- boolean email_verified — never the address (PII / fleet-readable RLS).
457
+ begin
458
+ insert into agent_board.agents(
459
+ agent_type,
460
+ kind,
461
+ handle,
462
+ display_name,
463
+ status,
464
+ auth_user_id,
465
+ owner_user_id,
466
+ metadata
467
+ )
468
+ values (
469
+ 'human',
470
+ 'human'::agent_board.agent_kind,
471
+ v_handle,
472
+ coalesce(nullif(trim(p_display_name), ''), 'Human'),
473
+ 'active',
474
+ v_uid,
475
+ v_uid,
476
+ jsonb_build_object(
477
+ 'federation', jsonb_build_object(
478
+ 'mode', 'fedsession',
479
+ 'issuer', p_issuer,
480
+ 'audience', p_audience,
481
+ 'subject', p_subject,
482
+ 'providerId', v_provider.id,
483
+ 'email_verified', v_email_verified,
484
+ 'enrolledAt', now()
485
+ )
486
+ )
487
+ )
488
+ returning * into v_agent;
489
+ exception when unique_violation then
490
+ -- Concurrent first-provision race on the synthetic uid. F-5: re-select the ACTIVE row
491
+ -- and ASSERT it is human (fail closed otherwise) — never return a non-human agent as a
492
+ -- human session.
493
+ select *
494
+ into v_existing
495
+ from agent_board.agents a
496
+ where a.auth_user_id = v_uid
497
+ and a.status = 'active'
498
+ and a.kind = 'human'
499
+ limit 1;
500
+ if found then
501
+ -- (8a) #246 STORED-IDENTITY EQUALITY, second return site. Same gate, same
502
+ -- shape, same message as (7a): this handler returns an EXISTING row on the
503
+ -- derived uid, so it needs the same proof that the row is the caller's. The
504
+ -- interleaving that reaches it is concurrency-only -- (7)'s unfiltered select
505
+ -- intercepts any already-committed row on this uid -- which is why this site
506
+ -- is pinned structurally by the class assertion rather than differentially by
507
+ -- a single-session test.
508
+ if not coalesce(
509
+ v_existing.metadata #>> '{federation,issuer}' = p_issuer
510
+ and v_existing.metadata #>> '{federation,subject}' = p_subject, false) then
511
+ raise exception 'federated identity mismatch: the owner derived for this issuer/subject is already bound to a different verified identity; refusing to issue a session'
512
+ using errcode = '42501';
513
+ end if;
514
+ v_agent := v_existing;
515
+ else
516
+ raise exception 'could not provision federated human (race or non-human on owner uid), please retry'
517
+ using errcode = '40001';
518
+ end if;
519
+ end;
520
+
521
+ -- (9) F-12/F-4: store the verified email ONLY in auth.users.raw_user_meta_data
522
+ -- (service-role-only), MERGED so _ensure_agent_auth_user's {slug, issuer} is preserved,
523
+ -- tagged provenance='portal-claim-unverified'. The definer owner (postgres) owns auth.*.
524
+ if v_email is not null then
525
+ update auth.users u
526
+ set raw_user_meta_data = coalesce(u.raw_user_meta_data, '{}'::jsonb)
527
+ || jsonb_build_object(
528
+ 'fedsession_email', v_email,
529
+ 'fedsession_email_verified', v_email_verified,
530
+ 'fedsession_email_provenance', 'portal-claim-unverified')
531
+ where u.id = v_uid;
532
+ end if;
533
+
534
+ -- (10) FEDSESSION auto-join (new). Baseline default-board membership for a fresh fed-human.
535
+ perform agent_board._fedsession_autojoin_default_board(v_provider, v_agent.id);
536
+
537
+ -- (11) F-6: append the audit row (mode='fedsession'). No requested/granted scopes on the
538
+ -- human path (authority is membership/role, not enrollment scopes) -> empty arrays.
539
+ insert into agent_board.enrollment_federation_audit(
540
+ issuer, audience, subject, human_identity_claim, provider_id,
541
+ agent_id, auth_user_id, requested_scopes, granted_scopes, mode)
542
+ values (
543
+ p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
544
+ v_agent.id, v_uid, '{}', '{}', 'fedsession');
545
+
546
+ return query
547
+ select v_agent.id, v_agent.auth_user_id, v_agent.handle,
548
+ v_agent.kind::text, v_agent.status, v_provider.id, v_agent.created_at;
549
+ end;
550
+ $function$
551
+
552
+ ;
553
+
554
+ CREATE OR REPLACE FUNCTION agent_board._enroll_federated_identity_once(p_issuer text, p_audience text, p_subject text, p_human_identity_claim jsonb, p_slug text, p_agent_public_key text, p_agent_public_key_format text, p_agent_public_key_fingerprint text, p_pop_verified boolean, p_actor_sub text DEFAULT NULL::text, p_agent_kind text DEFAULT 'agent'::text, p_requested_scopes text[] DEFAULT '{}'::text[], p_requested_board_scopes text[] DEFAULT '{}'::text[], p_invite_code text DEFAULT NULL::text)
555
+ RETURNS TABLE(agent_id uuid, auth_user_id uuid, provider_id text, signing_key_id uuid, granted_scopes text[], key_id text, key_version integer, agent_handle text, invite_redeemed boolean)
556
+ LANGUAGE plpgsql
557
+ SECURITY DEFINER
558
+ SET search_path TO 'agent_board', 'public'
559
+ AS $function$
560
+ declare
561
+ v_slug text;
562
+ v_kind text;
563
+ v_provider agent_board.identity_providers%rowtype;
564
+ v_owner_user_id uuid;
565
+ v_auth_user_id uuid;
566
+ v_agent_handle text;
567
+ v_agent_id uuid;
568
+ v_signing_key_id uuid;
569
+ v_fingerprint text;
570
+ v_key_hex text;
571
+ v_raw_key bytea;
572
+ v_reencoded text;
573
+ v_key_id text := 'portal-enroll';
574
+ v_key_version int := 1;
575
+ v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
576
+ v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
577
+ v_invite_code text := nullif(trim(coalesce(p_invite_code, '')), '');
578
+ v_invite_redeemed boolean := false;
579
+ v_invite_board_id uuid;
580
+ v_existing_handle text;
581
+ begin
582
+ -- service_role-only (defense in depth on top of the execute-grant revocation).
583
+ if not agent_board._is_service_role() then
584
+ raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
585
+ end if;
586
+
587
+ -- A verified subject + human identity claim are always present from the EF.
588
+ if p_subject is null then
589
+ raise exception 'verified subject is required' using errcode = '22023';
590
+ end if;
591
+ if p_human_identity_claim is null then
592
+ raise exception 'human identity claim is required' using errcode = '22023';
593
+ end if;
594
+
595
+ -- (0) PoP gate — the EF Ed25519-verified the proof-of-possession over the token
596
+ -- bytes and passes true. Refuse false BEFORE any write (no agent/key/audit row).
597
+ if p_pop_verified is not true then
598
+ raise exception 'proof-of-possession was not verified; enrollment refused'
599
+ using errcode = '42501';
600
+ end if;
601
+
602
+ -- (1) Public-key shape gate. The enroll contract is raw Ed25519 (32 bytes -> 43
603
+ -- base64url chars).
604
+ if p_agent_public_key_format is distinct from 'raw_ed25519_base64url' then
605
+ raise exception 'agent public key format must be raw_ed25519_base64url' using errcode = '22023';
606
+ end if;
607
+ if p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
608
+ raise exception 'malformed agent public key: expected a 43-char base64url raw Ed25519 key'
609
+ using errcode = '22023';
610
+ end if;
611
+
612
+ -- (1a) B2 — decode base64url -> raw 32 bytes and REJECT any non-canonical encoding.
613
+ v_raw_key := decode(translate(p_agent_public_key, '-_', '+/') || '=', 'base64');
614
+ if octet_length(v_raw_key) <> 32 then
615
+ raise exception 'agent public key must decode to exactly 32 raw Ed25519 bytes'
616
+ using errcode = '22023';
617
+ end if;
618
+ v_reencoded := rtrim(translate(encode(v_raw_key, 'base64'), '+/', '-_'), '=');
619
+ if v_reencoded is distinct from p_agent_public_key then
620
+ raise exception 'agent public key is not canonical base64url (aliased encoding rejected)'
621
+ using errcode = '22023';
622
+ end if;
623
+
624
+ -- (1b) Reject the all-zero small-order placeholder over the RAW bytes.
625
+ if v_raw_key = decode(repeat('00', 32), 'hex') then
626
+ raise exception 'agent public key is the all-zero small-order placeholder and is rejected'
627
+ using errcode = '22023';
628
+ end if;
629
+
630
+ -- (2) Recompute-and-assert the fingerprint in-DB over the RAW 32 bytes. Derived through
631
+ -- the shared _raw_key_fingerprint helper (single source of truth; catalog-sweep-pinned).
632
+ v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
633
+ if p_agent_public_key_fingerprint is distinct from v_fingerprint then
634
+ raise exception 'agent public key fingerprint mismatch (recompute != supplied)'
635
+ using errcode = '22023';
636
+ end if;
637
+
638
+ -- (3) act.sub is audit-only, but when present it must be a real value.
639
+ if p_actor_sub is not null and length(trim(p_actor_sub)) = 0 then
640
+ raise exception 'actor sub, when present, must be non-empty' using errcode = '22023';
641
+ end if;
642
+
643
+ v_slug := agent_board._normalize_provision_slug(p_slug);
644
+ v_kind := lower(coalesce(trim(p_agent_kind), ''));
645
+
646
+ -- (4) allowlist lookup by (issuer, audience); must exist + supported + policy-approved.
647
+ select *
648
+ into v_provider
649
+ from agent_board.identity_providers
650
+ where issuer = p_issuer
651
+ and audience = p_audience
652
+ limit 1;
653
+ if v_provider.id is null then
654
+ raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
655
+ end if;
656
+ if not v_provider.supported then
657
+ raise exception 'identity provider is not supported' using errcode = '42501';
658
+ end if;
659
+ if not v_provider.policy_approved then
660
+ raise exception 'identity provider is not policy-approved' using errcode = '42501';
661
+ end if;
662
+
663
+ -- (5) scope-ceiling + kind gate. 'system' is never federatable.
664
+ if v_kind = 'system' then
665
+ raise exception 'the system kind cannot be federated' using errcode = '42501';
666
+ end if;
667
+ if v_kind not in ('human', 'agent') then
668
+ raise exception 'agent kind must be human or agent' using errcode = '22023';
669
+ end if;
670
+ if array_length(v_provider.allowed_agent_kinds, 1) is not null
671
+ and not (v_kind = any (v_provider.allowed_agent_kinds)) then
672
+ raise exception 'agent kind % is outside the provider ceiling', v_kind using errcode = '42501';
673
+ end if;
674
+ if not (v_requested_scopes <@ v_provider.allowed_scopes) then
675
+ raise exception 'requested scopes exceed the provider allowed_scopes ceiling' using errcode = '42501';
676
+ end if;
677
+ if not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
678
+ raise exception 'requested board scopes exceed the provider allowed_board_scopes ceiling' using errcode = '42501';
679
+ end if;
680
+
681
+ -- (6) dedup on the enrolled key: a fingerprint already enrolled cannot be re-bound
682
+ -- HERE. #176: this is a BACKSTOP, not the resumption decision. enroll_federated_identity
683
+ -- is this function's only caller (pg_proc-verified) and it resolves an already-enrolled
684
+ -- fingerprint before ever reaching here, so a caller that lands on this raise reached the
685
+ -- internal creator directly. Resumption is deliberately NOT duplicated into this
686
+ -- function: it has no claim-projection equality gate, so an ownership decision made here
687
+ -- would rest on bare parameters rather than on the verified claim. What DID change is
688
+ -- that the raise is actionable -- it names the identity that already holds the key and
689
+ -- says enrollment need not be repeated.
690
+ --
691
+ -- The GATE is still the unchanged `exists` over agent_signing_keys alone. The handle is
692
+ -- looked up separately, for the MESSAGE only, through a join that a hypothetical
693
+ -- agent-less key would not satisfy: folding that join into the gate would have turned a
694
+ -- rendering nicety into a hole that lets such a key be re-bound.
695
+ if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = v_fingerprint) then
696
+ select a.handle into v_existing_handle
697
+ from agent_board.agent_signing_keys k
698
+ join agent_board.agents a on a.id = k.agent_id
699
+ where k.fingerprint = v_fingerprint
700
+ limit 1;
701
+ raise exception 'this signing key is already enrolled (duplicate fingerprint) as agent "%"; enrollment need not be repeated; call agent_board.enroll_federated_identity, which RESUMES an enrollment whose key is owned by the same verified (issuer, subject)',
702
+ coalesce(v_existing_handle, '(unknown)')
703
+ using errcode = '23505';
704
+ end if;
705
+
706
+ -- #194 LOCK ORDER -- BOARD BEFORE OWNER, and the reason is an FK this function
707
+ -- never names. The redemption below inserts board_memberships, which takes FOR
708
+ -- KEY SHARE on the invite's agent_board.boards row through
709
+ -- board_memberships_board_id_fkey. That acquisition used to land AFTER the
710
+ -- auth.users owner lock, inverting against enroll_federated_human and
711
+ -- onboard_federated_fleet_agent -- both of which take board -> owner -- and the
712
+ -- resulting cycle was reproduced as `deadlock detected ... while locking tuple
713
+ -- in relation "boards"`. Acquire the invite row and its board here, in the same
714
+ -- relative order _redeem_unbounded_invite_for_agent and
715
+ -- onboard_federated_fleet_agent take them (invite -> board), and in the SAME mode
716
+ -- the redemption itself uses, so the redemption below acquires nothing new. Absence is deliberately NOT fatal: a missing,
717
+ -- revoked, expired or exhausted code still falls through to redemption, which
718
+ -- stays the sole authority on invite usability (soft-honor contract).
719
+ if v_invite_code is not null then
720
+ select bit.board_id into v_invite_board_id
721
+ from agent_board.board_invite_tokens bit
722
+ where bit.code = v_invite_code
723
+ for update;
724
+ if v_invite_board_id is not null then
725
+ perform 1 from agent_board.boards b
726
+ where b.id = v_invite_board_id
727
+ for key share;
728
+ end if;
729
+ end if;
730
+
731
+ -- (7) HUMAN OWNER — established idempotently per (issuer, sub).
732
+ v_owner_user_id := agent_board._ensure_agent_auth_user(
733
+ agent_board._federated_identity_slug(p_issuer, p_subject));
734
+
735
+ -- (8) AGENT handle — unique per enrolled key.
736
+ v_key_hex := encode(extensions.digest(v_raw_key, 'sha256'), 'hex');
737
+ v_agent_handle := v_slug;
738
+ if exists (select 1 from agent_board.agents a where a.handle = v_agent_handle) then
739
+ v_agent_handle := v_slug || '-' || substr(v_key_hex, 1, 12);
740
+ end if;
741
+
742
+ -- (9) the agent's OWN board principal (session-usable via its enrolled key).
743
+ v_auth_user_id := agent_board._ensure_agent_auth_user(v_agent_handle);
744
+
745
+ -- (10) create the SEPARATE agent row (never keyed on sub; owner = the human).
746
+ insert into agent_board.agents(
747
+ agent_type,
748
+ kind,
749
+ handle,
750
+ display_name,
751
+ status,
752
+ auth_user_id,
753
+ owner_user_id,
754
+ metadata
755
+ )
756
+ values (
757
+ v_kind,
758
+ v_kind::agent_board.agent_kind,
759
+ v_agent_handle,
760
+ agent_board._default_provision_display_name(v_agent_handle),
761
+ 'active',
762
+ v_auth_user_id,
763
+ v_owner_user_id,
764
+ jsonb_build_object(
765
+ 'federation', jsonb_build_object(
766
+ 'issuer', p_issuer,
767
+ 'audience', p_audience,
768
+ 'subject', p_subject,
769
+ 'actorSub', p_actor_sub,
770
+ 'providerId', v_provider.id,
771
+ 'signingKeyFingerprint', v_fingerprint,
772
+ 'requestedScopes', to_jsonb(v_requested_scopes),
773
+ 'requestedBoardScopes', to_jsonb(v_requested_board_scopes),
774
+ 'popVerified', true,
775
+ 'enrolledAt', now()
776
+ )
777
+ )
778
+ )
779
+ returning id into v_agent_id;
780
+
781
+ -- (11) enroll the public key as active + proven (satisfies active_requires_proof).
782
+ insert into agent_board.agent_signing_keys(
783
+ id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
784
+ status, proof_state, metadata
785
+ )
786
+ values (
787
+ gen_random_uuid(), v_agent_id, v_key_id, v_key_version, p_agent_public_key,
788
+ p_agent_public_key_format, v_fingerprint,
789
+ 'active', 'proven',
790
+ jsonb_build_object('enrolledVia', 'federated_identity', 'popVerified', true)
791
+ )
792
+ returning id into v_signing_key_id;
793
+
794
+ -- (12) one static claim-snapshot audit row (the verified claim contains act.sub).
795
+ insert into agent_board.enrollment_federation_audit(
796
+ issuer, audience, subject, human_identity_claim, provider_id,
797
+ agent_id, auth_user_id, requested_scopes, granted_scopes
798
+ )
799
+ values (
800
+ p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
801
+ v_agent_id, v_auth_user_id, v_requested_scopes, v_requested_scopes
802
+ );
803
+
804
+ -- (13) ROOMLINK soft-honor: if a code is present, redeem it into a bounded board
805
+ -- membership for the freshly-created agent. Wrapped in a subtransaction so a
806
+ -- bad/expired/exhausted/unauthorized code rolls back ONLY this sub-block — the
807
+ -- agent-create above persists and enrollment still succeeds (invite_redeemed=false).
808
+ if v_invite_code is not null then
809
+ begin
810
+ perform agent_board._redeem_invite_for_agent(v_invite_code, v_agent_id);
811
+ v_invite_redeemed := true;
812
+ exception when others then
813
+ -- #194: a serialization failure is NOT "the invite was not redeemed".
814
+ -- Swallowing class 40 here reported enrollment SUCCESS to the caller with
815
+ -- invite_redeemed=false and no membership row, while the deadlock counter
816
+ -- ticked and no error reached the client. Re-raise the whole class
817
+ -- (40001 serialization_failure, 40P01 deadlock_detected, 40003, 40002)
818
+ -- and keep the soft-honor contract for every deterministic failure.
819
+ if left(sqlstate, 2) = '40' then
820
+ raise;
821
+ end if;
822
+ v_invite_redeemed := false;
823
+ end;
824
+ end if;
825
+
826
+ -- Server-authoritative identity: the client mints /session with the RETURNED
827
+ -- key_id/key_version/agent_handle (its private key stays in the keystore).
828
+ return query
829
+ select v_agent_id, v_auth_user_id, v_provider.id, v_signing_key_id, v_requested_scopes,
830
+ v_key_id, v_key_version, v_agent_handle, v_invite_redeemed;
831
+ end;
832
+ $function$
833
+
834
+ ;
835
+
836
+ CREATE OR REPLACE FUNCTION agent_board.enroll_federated_identity(p_issuer text, p_audience text, p_subject text, p_human_identity_claim jsonb, p_slug text, p_agent_public_key text, p_agent_public_key_format text, p_agent_public_key_fingerprint text, p_pop_verified boolean, p_actor_sub text DEFAULT NULL::text, p_agent_kind text DEFAULT 'agent'::text, p_requested_scopes text[] DEFAULT '{}'::text[], p_requested_board_scopes text[] DEFAULT '{}'::text[], p_invite_code text DEFAULT NULL::text)
837
+ RETURNS TABLE(agent_id uuid, auth_user_id uuid, provider_id text, signing_key_id uuid, granted_scopes text[], key_id text, key_version integer, agent_handle text, invite_redeemed boolean)
838
+ LANGUAGE plpgsql
839
+ SECURITY DEFINER
840
+ SET search_path TO 'agent_board', 'public'
841
+ AS $function$
842
+ #variable_conflict use_column
843
+ declare
844
+ v_fingerprint text;
845
+ v_existing record;
846
+ v_created record;
847
+ v_replay jsonb;
848
+ v_invite_redeemed boolean := false;
849
+ v_slug text;
850
+ v_kind text := lower(btrim(coalesce(p_agent_kind, 'agent')));
851
+ v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
852
+ v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
853
+ v_provider agent_board.identity_providers%rowtype;
854
+ v_invite_board_id uuid;
855
+ v_claim_audience jsonb;
856
+ v_owner_user_id uuid;
857
+ -- #176 resumption. `v_derived_owner_user_id` is the auth user the CALLER's verified
858
+ -- (issuer, subject) derives to; `v_creation_federation` is the federation object the
859
+ -- ORIGINAL enrollment stamped on the agent; `v_owned_by_caller` is the conjunction of
860
+ -- the three stored-evidence tests, coalesced so missing evidence reads as NOT-owned.
861
+ v_derived_owner_user_id uuid;
862
+ v_creation_federation jsonb;
863
+ v_owned_by_caller boolean;
864
+ begin
865
+ if not agent_board._is_service_role() then
866
+ raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
867
+ end if;
868
+ if p_pop_verified is not true then
869
+ raise exception 'proof-of-possession was not verified' using errcode = '42501';
870
+ end if;
871
+ if p_subject is null then
872
+ raise exception 'verified subject is required' using errcode = '22023';
873
+ end if;
874
+ if p_human_identity_claim is null then
875
+ raise exception 'human identity claim is required' using errcode = '22023';
876
+ end if;
877
+
878
+ select * into v_provider from agent_board.identity_providers ip
879
+ where ip.issuer = p_issuer and ip.audience = p_audience limit 1;
880
+ if v_provider.id is null then
881
+ raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
882
+ end if;
883
+
884
+ v_claim_audience := agent_board._normalized_federation_audience(p_human_identity_claim);
885
+ if p_human_identity_claim ->> 'iss' is distinct from p_issuer
886
+ or p_human_identity_claim ->> v_provider.subject_claim is distinct from p_subject
887
+ or v_claim_audience is null
888
+ or not (v_claim_audience ? p_audience) then
889
+ raise exception 'verified stable claim projection does not match enrollment inputs' using errcode = '22023';
890
+ end if;
891
+ if p_agent_public_key_format is distinct from 'raw_ed25519_base64url'
892
+ or p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
893
+ raise exception 'malformed agent public key' using errcode = '22023';
894
+ end if;
895
+ v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
896
+ if p_agent_public_key_fingerprint is distinct from v_fingerprint then
897
+ raise exception 'agent public key fingerprint mismatch' using errcode = '22023';
898
+ end if;
899
+ v_slug := agent_board._normalize_provision_slug(p_slug);
900
+
901
+ if not v_provider.supported or not v_provider.policy_approved then
902
+ raise exception 'identity provider is not approved' using errcode = '42501';
903
+ end if;
904
+ if v_kind = 'system' then
905
+ raise exception 'the system kind cannot be federated' using errcode = '42501';
906
+ end if;
907
+ if v_kind not in ('human', 'agent') then
908
+ raise exception 'agent kind must be human or agent' using errcode = '22023';
909
+ end if;
910
+ if array_length(v_provider.allowed_agent_kinds, 1) is not null
911
+ and not (v_kind = any(v_provider.allowed_agent_kinds)) then
912
+ raise exception 'agent kind is outside the provider ceiling' using errcode = '42501';
913
+ end if;
914
+ if not (v_requested_scopes <@ v_provider.allowed_scopes)
915
+ or not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
916
+ raise exception 'requested scopes exceed the provider ceiling' using errcode = '42501';
917
+ end if;
918
+
919
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is not null and exists (
920
+ select 1 from agent_board.board_invite_tokens bit
921
+ join agent_board.fleet_onboarding_invite_bounds b on b.invite_id = bit.id
922
+ where bit.code = btrim(p_invite_code)
923
+ ) then
924
+ raise exception 'fleet invite requires atomic fleet onboarding' using errcode = '42501';
925
+ end if;
926
+
927
+
928
+ -- #194 LOCK ORDER -- BOARD BEFORE OWNER, and the reason is an FK this function
929
+ -- never names. The redemption below inserts board_memberships, which takes FOR
930
+ -- KEY SHARE on the invite's agent_board.boards row through
931
+ -- board_memberships_board_id_fkey. That acquisition used to land AFTER the
932
+ -- auth.users owner lock, inverting against enroll_federated_human and
933
+ -- onboard_federated_fleet_agent -- both of which take board -> owner -- and the
934
+ -- resulting cycle was reproduced as `deadlock detected ... while locking tuple
935
+ -- in relation "boards"`. Acquire the invite row and its board here, in the same
936
+ -- relative order _redeem_unbounded_invite_for_agent and
937
+ -- onboard_federated_fleet_agent take them (invite -> board), and in the SAME mode
938
+ -- the redemption itself uses, so the redemption below acquires nothing new. Absence is deliberately NOT fatal: a missing,
939
+ -- revoked, expired or exhausted code still falls through to redemption, which
940
+ -- stays the sole authority on invite usability (soft-honor contract).
941
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
942
+ select bit.board_id into v_invite_board_id
943
+ from agent_board.board_invite_tokens bit
944
+ where bit.code = nullif(btrim(coalesce(p_invite_code, '')), '')
945
+ for update;
946
+ if v_invite_board_id is not null then
947
+ perform 1 from agent_board.boards b
948
+ where b.id = v_invite_board_id
949
+ for key share;
950
+ end if;
951
+ end if;
952
+
953
+ perform pg_advisory_xact_lock(hashtextextended(v_fingerprint, 0));
954
+
955
+ select a.owner_user_id into v_owner_user_id
956
+ from agent_board.agent_signing_keys k
957
+ join agent_board.agents a on a.id = k.agent_id
958
+ where k.fingerprint = v_fingerprint;
959
+ v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
960
+ agent_board._federated_identity_slug(p_issuer, p_subject)
961
+ ));
962
+ perform 1 from auth.users u where u.id = v_owner_user_id for update;
963
+ if exists (
964
+ select 1 from agent_board.fleet_human_revocations r
965
+ where r.issuer = p_issuer and r.subject = p_subject
966
+ ) then
967
+ raise exception 'human owner has been revoked' using errcode = '42501';
968
+ end if;
969
+
970
+ -- #176 (i): the auth user the CALLER's verified (issuer, subject) derives to, by the
971
+ -- SAME derivation _enroll_federated_identity_once step (7) used to mint the owner --
972
+ -- now literally the same function, agent_board._federated_identity_slug (#246), not a
973
+ -- second copy of the expression. Resolved by a PURE LOOKUP against the deterministic
974
+ -- email _ensure_agent_auth_user builds -- calling THAT function here would MINT an owner
975
+ -- row for whatever principal a squat attempt claimed. The slug helper is pure and mints
976
+ -- nothing, so computing the slug here is safe; only _ensure_agent_auth_user writes. Read-only: no FOR UPDATE, no row
977
+ -- lock, so the #194 board -> owner order above is untouched.
978
+ select u.id into v_derived_owner_user_id
979
+ from auth.users u
980
+ where lower(u.email) = format('agent+%s@agent-board.local',
981
+ agent_board._federated_identity_slug(p_issuer, p_subject))
982
+ limit 1;
983
+
984
+ select a.id as agent_id, a.auth_user_id, a.handle, a.agent_type, a.status,
985
+ a.metadata, a.owner_user_id, k.id as signing_key_id, k.key_id, k.key_version,
986
+ k.public_key, k.public_key_format, k.status as key_status, k.proof_state,
987
+ (select e.granted_scopes
988
+ from agent_board.enrollment_federation_audit e
989
+ where e.agent_id = a.id
990
+ and e.issuer = p_issuer
991
+ and e.subject = p_subject
992
+ and e.provider_id = v_provider.id) as granted_scopes
993
+ into v_existing
994
+ from agent_board.agent_signing_keys k
995
+ join agent_board.agents a on a.id = k.agent_id
996
+ where k.fingerprint = v_fingerprint
997
+ for update of a, k;
998
+
999
+ if found then
1000
+ v_replay := v_existing.metadata #> '{federation,replay}';
1001
+ v_creation_federation := v_existing.metadata #> '{federation}';
1002
+
1003
+ -- #176 OWNERSHIP, from evidence written at the ORIGINAL enrollment -- never from a
1004
+ -- caller argument asserting who acted. (i) the one-way-derived owner, (ii) the
1005
+ -- creation-time federation stamp, (iii) the original enrollment's audit row. All
1006
+ -- three must agree, and the whole conjunction is coalesced to FALSE: any NULL here
1007
+ -- (absent metadata, absent owner) means ownership is UNPROVEN, and unproven must
1008
+ -- read as not-owned rather than leaving the `if not` below untaken.
1009
+ v_owned_by_caller := coalesce(
1010
+ v_creation_federation ->> 'issuer' = p_issuer
1011
+ and v_creation_federation ->> 'subject' = p_subject
1012
+ and v_creation_federation ->> 'providerId' = v_provider.id
1013
+ and exists (
1014
+ select 1
1015
+ from agent_board.enrollment_federation_audit e
1016
+ where e.agent_id = v_existing.agent_id
1017
+ and e.issuer = p_issuer
1018
+ and e.subject = p_subject
1019
+ and e.provider_id = v_provider.id
1020
+ )
1021
+ -- (i) is REQUIRED WHENEVER IT EXISTS and is the one conjunct with an exemption,
1022
+ -- so its claim is stated positively rather than left as "unless". A row whose
1023
+ -- owner_user_id is NULL predates the owner model entirely (the atomic-fleet
1024
+ -- backfill fixture is exactly that shape, and replaying such a row is released,
1025
+ -- asserted behaviour): (i) is UNAVAILABLE there, not contradicted. It is also not
1026
+ -- the binding that does the work. (ii) and (iii) already name the principal, and
1027
+ -- reaching them at all requires a claim the edge function VERIFIED for that same
1028
+ -- issuer and subject, so a null owner narrows corroboration without widening who
1029
+ -- may resume. A NON-NULL owner that disagrees with the derived one is a hard
1030
+ -- refusal, which is what keeps this from being a hole.
1031
+ and (
1032
+ v_existing.owner_user_id is null
1033
+ or (v_derived_owner_user_id is not null
1034
+ and v_existing.owner_user_id = v_derived_owner_user_id)
1035
+ ), false);
1036
+
1037
+ -- (a) THE KEY-SQUAT this check exists to prevent, and the FIRST thing decided, so a
1038
+ -- caller that does not own the key learns nothing else about it. No handle is named:
1039
+ -- telling a squatter which agent holds the key it just probed is the leak this
1040
+ -- ordering avoids. Everything below runs only for the proven owner.
1041
+ if not v_owned_by_caller then
1042
+ raise exception 'this signing key is already enrolled to a different principal; enrollment is refused; generate a NEW key rather than re-presenting this one'
1043
+ using errcode = '23505';
1044
+ end if;
1045
+
1046
+ -- (b) the owner is asking to resume, but the identity is not in a resumable state.
1047
+ -- Resumption returns an existing identity; it never revives a revoked, unproven or
1048
+ -- drifted one, and it never invents an audit trail that is not there.
1049
+ if v_existing.public_key is distinct from p_agent_public_key
1050
+ or v_existing.public_key_format is distinct from p_agent_public_key_format
1051
+ or v_existing.key_status <> 'active'
1052
+ or v_existing.proof_state <> 'proven'
1053
+ or v_existing.status <> 'active'
1054
+ or v_existing.granted_scopes is null then
1055
+ raise exception 'this signing key is already enrolled to you as agent "%", but that identity is not resumable (its key or agent is inactive or unproven, its stored public key differs, or its enrollment audit records no granted scopes); enrollment need not be repeated; report this handle to an operator',
1056
+ v_existing.handle using errcode = '23505';
1057
+ end if;
1058
+
1059
+ -- (c) a STAMPED enrollment (20260901000100 and later) still holds its frozen identity
1060
+ -- contract exactly as before -- the check is unchanged, only its message improves to
1061
+ -- name the handle and say the enrollment already exists. An UNSTAMPED enrollment
1062
+ -- (every identity created before 20260901000100, including the #176 victim) has no
1063
+ -- frozen contract to check and resumes on the ownership evidence alone; that absence
1064
+ -- used to be the raise that trapped it.
1065
+ if v_replay is not null
1066
+ and (v_replay ->> 'issuer' is distinct from p_issuer
1067
+ or v_replay -> 'audience' is distinct from v_claim_audience
1068
+ or v_replay ->> 'subject' is distinct from p_subject
1069
+ or v_replay ->> 'requestedSlug' is distinct from v_slug
1070
+ or v_replay ->> 'agentKind' is distinct from v_kind
1071
+ or v_replay -> 'actorSub' is distinct from coalesce(to_jsonb(p_actor_sub), 'null'::jsonb)
1072
+ or v_replay -> 'requestedScopes' is distinct from to_jsonb(v_requested_scopes)
1073
+ or v_replay -> 'requestedBoardScopes' is distinct from to_jsonb(v_requested_board_scopes)
1074
+ or v_replay ->> 'providerId' is distinct from v_provider.id) then
1075
+ raise exception 'this signing key is already enrolled to you as agent "%" (enrollment need not be repeated), but this request differs from that enrollment''s frozen identity contract (slug, agent kind, actor or requested scopes); re-send the original inputs, or use the existing identity as-is',
1076
+ v_existing.handle using errcode = '23505';
1077
+ end if;
1078
+
1079
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
1080
+ begin
1081
+ perform agent_board._redeem_invite_for_agent(btrim(p_invite_code), v_existing.agent_id);
1082
+ v_invite_redeemed := true;
1083
+ exception when others then
1084
+ -- #194: a serialization failure is NOT "the invite was not redeemed".
1085
+ -- Swallowing class 40 here reported enrollment SUCCESS to the caller with
1086
+ -- invite_redeemed=false and no membership row, while the deadlock counter
1087
+ -- ticked and no error reached the client. Re-raise the whole class
1088
+ -- (40001 serialization_failure, 40P01 deadlock_detected, 40003, 40002)
1089
+ -- and keep the soft-honor contract for every deterministic failure.
1090
+ if left(sqlstate, 2) = '40' then
1091
+ raise;
1092
+ end if;
1093
+ v_invite_redeemed := false;
1094
+ end;
1095
+ end if;
1096
+
1097
+ -- provider_id comes from STORED evidence, not the request: the replay stamp when
1098
+ -- there is one, otherwise the creation-time federation object. The ownership gate
1099
+ -- already pinned that value to v_provider.id, so the two agree by construction --
1100
+ -- but reading it from the row keeps the returned receipt sourced where every other
1101
+ -- returned field is.
1102
+ return query select v_existing.agent_id, v_existing.auth_user_id,
1103
+ coalesce(v_replay ->> 'providerId', v_creation_federation ->> 'providerId'), v_existing.signing_key_id,
1104
+ v_existing.granted_scopes, v_existing.key_id, v_existing.key_version,
1105
+ v_existing.handle, v_invite_redeemed;
1106
+ return;
1107
+ end if;
1108
+
1109
+ select * into v_created from agent_board._enroll_federated_identity_once(
1110
+ p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
1111
+ p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
1112
+ p_pop_verified, p_actor_sub, p_agent_kind, v_requested_scopes,
1113
+ v_requested_board_scopes, p_invite_code
1114
+ );
1115
+
1116
+ update agent_board.agents a
1117
+ set metadata = jsonb_set(a.metadata, '{federation,replay}', jsonb_build_object(
1118
+ 'issuer', p_issuer, 'audience', v_claim_audience, 'subject', p_subject,
1119
+ 'providerId', v_created.provider_id,
1120
+ 'actorSub', coalesce(to_jsonb(p_actor_sub), 'null'::jsonb),
1121
+ 'requestedSlug', v_slug, 'agentKind', v_kind,
1122
+ 'requestedScopes', to_jsonb(v_requested_scopes),
1123
+ 'requestedBoardScopes', to_jsonb(v_requested_board_scopes)
1124
+ ), true)
1125
+ where a.id = v_created.agent_id;
1126
+
1127
+ return query select v_created.agent_id, v_created.auth_user_id,
1128
+ v_created.provider_id, v_created.signing_key_id, v_created.granted_scopes,
1129
+ v_created.key_id, v_created.key_version, v_created.agent_handle,
1130
+ v_created.invite_redeemed;
1131
+ end;
1132
+ $function$
1133
+
1134
+ ;
1135
+
1136
+ CREATE OR REPLACE FUNCTION agent_board.onboard_federated_fleet_agent(p_issuer text, p_audience text, p_subject text, p_human_identity_claim jsonb, p_slug text, p_agent_public_key text, p_agent_public_key_format text, p_agent_public_key_fingerprint text, p_pop_verified boolean, p_actor_sub text DEFAULT NULL::text, p_agent_kind text DEFAULT 'agent'::text, p_requested_scopes text[] DEFAULT '{}'::text[], p_requested_board_scopes text[] DEFAULT '{}'::text[], p_invite_code text DEFAULT NULL::text, p_board_id uuid DEFAULT NULL::uuid, p_machine_id uuid DEFAULT NULL::uuid, p_harness_kind text DEFAULT NULL::text, p_harness_label text DEFAULT NULL::text, p_repo_slug text DEFAULT NULL::text, p_repo_fingerprint text DEFAULT NULL::text, p_repo_scope_role text DEFAULT NULL::text, p_agent_role text DEFAULT NULL::text, p_expected_agent_handle text DEFAULT NULL::text)
1137
+ RETURNS TABLE(onboarding_contract_version text, agent_id uuid, auth_user_id uuid, provider_id text, signing_key_id uuid, granted_scopes text[], key_id text, key_version integer, agent_handle text, public_key_fingerprint text, board_id uuid, membership_role text, membership_state text, machine_id uuid, harness_registration_id uuid, repo_scope_id uuid, fleet_machine_agent_id uuid, binding_signing_key_id uuid, harness_kind text, harness_label text, repo_slug text, repo_fingerprint text, repo_scope_role text, agent_role text, expected_agent_handle text, identity_disposition text, membership_disposition text, harness_disposition text, repo_scope_disposition text, binding_disposition text)
1138
+ LANGUAGE plpgsql
1139
+ SECURITY DEFINER
1140
+ SET search_path TO 'agent_board', 'public'
1141
+ AS $function$
1142
+ #variable_conflict use_column
1143
+ declare
1144
+ v_inv agent_board.board_invite_tokens%rowtype;
1145
+ v_bounds agent_board.fleet_onboarding_invite_bounds%rowtype;
1146
+ v_board agent_board.boards%rowtype;
1147
+ v_machine agent_board.fleet_machines%rowtype;
1148
+ v_domain agent_board.fleet_trust_domains%rowtype;
1149
+ v_enroll record;
1150
+ v_membership record;
1151
+ v_harness agent_board.fleet_harness_registrations%rowtype;
1152
+ v_repo agent_board.fleet_repo_scopes%rowtype;
1153
+ v_binding agent_board.fleet_machine_agents%rowtype;
1154
+ v_kind text := lower(btrim(coalesce(p_harness_kind, '')));
1155
+ v_label text := btrim(coalesce(p_harness_label, ''));
1156
+ v_repo_slug text := btrim(coalesce(p_repo_slug, ''));
1157
+ v_repo_fingerprint text := btrim(coalesce(p_repo_fingerprint, ''));
1158
+ v_expected_handle text := btrim(coalesce(p_expected_agent_handle, ''));
1159
+ v_existing_agent_id uuid;
1160
+ v_owner_user_id uuid;
1161
+ v_identity_disposition text;
1162
+ v_membership_disposition text;
1163
+ v_harness_disposition text;
1164
+ v_repo_disposition text;
1165
+ v_binding_disposition text;
1166
+ v_completed_replay boolean := false;
1167
+ begin
1168
+ if not agent_board._is_service_role() then
1169
+ raise exception 'service_role required for onboard_federated_fleet_agent' using errcode = '42501';
1170
+ end if;
1171
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is null then
1172
+ raise exception 'bounded invite code is required' using errcode = '22023';
1173
+ end if;
1174
+
1175
+ select bit.* into v_inv
1176
+ from agent_board.board_invite_tokens bit
1177
+ where bit.code = btrim(p_invite_code)
1178
+ for update;
1179
+ if not found then
1180
+ raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
1181
+ end if;
1182
+ select * into v_bounds
1183
+ from agent_board.fleet_onboarding_invite_bounds b
1184
+ where b.invite_id = v_inv.id
1185
+ for update;
1186
+ if not found then
1187
+ raise exception 'fleet onboarding requires a bounded invite' using errcode = '42501';
1188
+ end if;
1189
+
1190
+ -- ADVISORY BEFORE BOARDS. set_board_authority_ceiling holds this lock and then
1191
+ -- reaches this same boards row through board_authority_ceilings' FK (FOR KEY SHARE
1192
+ -- on INSERT). Taking the boards lock first would invert the order against it and
1193
+ -- deadlock -- it did, until this line was hoisted above the select below.
1194
+ perform agent_board._lock_board_authority_ceiling(v_inv.board_id);
1195
+
1196
+ select * into v_board
1197
+ from agent_board.boards b
1198
+ where b.id = v_inv.board_id
1199
+ for update;
1200
+ if not found or v_board.status <> 'active' then
1201
+ raise exception 'fleet onboarding board is not usable' using errcode = '42501';
1202
+ end if;
1203
+
1204
+ if v_inv.board_id is distinct from p_board_id
1205
+ or v_bounds.machine_id is distinct from p_machine_id
1206
+ or v_bounds.harness_kind is distinct from v_kind
1207
+ or v_bounds.harness_label is distinct from v_label
1208
+ or v_bounds.repo_slug is distinct from v_repo_slug
1209
+ or v_bounds.repo_fingerprint is distinct from v_repo_fingerprint
1210
+ or v_bounds.repo_scope_role is distinct from p_repo_scope_role
1211
+ or v_bounds.agent_role is distinct from p_agent_role
1212
+ or v_bounds.expected_agent_handle is distinct from v_expected_handle then
1213
+ raise exception 'fleet onboarding bounds mismatch' using errcode = '42501';
1214
+ end if;
1215
+ if not ((p_agent_role = 'phase_worker' and p_repo_scope_role = 'worker')
1216
+ or (p_agent_role = 'reviewer' and p_repo_scope_role = 'reviewer')) then
1217
+ raise exception 'fleet onboarding authority is elevated' using errcode = '42501';
1218
+ end if;
1219
+
1220
+ select * into v_machine from agent_board.fleet_machines fm
1221
+ where fm.id = p_machine_id for update;
1222
+ if not found or v_machine.owner_agent_id <> v_inv.created_by_agent_id
1223
+ or v_machine.lifecycle_state <> 'active' then
1224
+ raise exception 'fleet onboarding machine is not usable' using errcode = '42501';
1225
+ end if;
1226
+ select * into v_domain from agent_board.fleet_trust_domains td
1227
+ where td.id = v_machine.trust_domain_id for update;
1228
+ if not found or v_domain.owner_agent_id <> v_inv.created_by_agent_id
1229
+ or v_domain.lifecycle_state <> 'active' then
1230
+ raise exception 'fleet onboarding trust domain is not usable' using errcode = '42501';
1231
+ end if;
1232
+
1233
+ -- Serialize cross-route identity classification before locking principals. For
1234
+ -- a replay, lock the creator and enrolled principal in UUID order so a human or
1235
+ -- agent subtree revocation that visits the same pair cannot invert their order.
1236
+ perform pg_advisory_xact_lock(hashtextextended(p_agent_public_key_fingerprint, 0));
1237
+ select k.agent_id, a.owner_user_id into v_existing_agent_id, v_owner_user_id
1238
+ from agent_board.agent_signing_keys k
1239
+ join agent_board.agents a on a.id = k.agent_id
1240
+ where k.fingerprint = p_agent_public_key_fingerprint;
1241
+ v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
1242
+ agent_board._federated_identity_slug(p_issuer, p_subject)
1243
+ ));
1244
+ perform 1 from auth.users u where u.id = v_owner_user_id for update;
1245
+ if exists (
1246
+ select 1 from agent_board.fleet_human_revocations r
1247
+ where r.issuer = p_issuer and r.subject = p_subject
1248
+ ) then
1249
+ raise exception 'human owner has been revoked' using errcode = '42501';
1250
+ end if;
1251
+ perform 1 from agent_board.agents a
1252
+ where a.id = any(array[v_inv.created_by_agent_id, v_existing_agent_id])
1253
+ order by a.id for update;
1254
+
1255
+ -- Creator authority is part of the invite's live authorization, not merely a
1256
+ -- snapshot from mint time. Lock every membership row and the current ceiling
1257
+ -- before evaluating its role so revocation, eviction, or demotion must serialize
1258
+ -- on one side of this transaction.
1259
+ --
1260
+ -- The ceiling ROW lock below covers demotion of an EXISTING ceiling only. No row
1261
+ -- lock can cover the ABSENCE of a row, and absence is the normal default-owner
1262
+ -- state, so a concurrent set_board_authority_ceiling inserting 'observer' used to
1263
+ -- interleave: this transaction read "no ceiling", passed as owner, and committed a
1264
+ -- 'member' membership after the ceiling committed. The board-keyed advisory lock
1265
+ -- that serializes the ABSENCE is already held -- it is taken far above, BEFORE the
1266
+ -- boards row lock, because the ceiling writer reaches boards through its FK and the
1267
+ -- two must agree on order.
1268
+ perform 1 from agent_board.board_memberships bm
1269
+ where bm.board_id = v_inv.board_id
1270
+ and bm.agent_id = v_inv.created_by_agent_id
1271
+ order by bm.id for update;
1272
+ perform 1 from agent_board.board_authority_ceilings c
1273
+ where c.board_id = v_inv.board_id for update;
1274
+
1275
+ -- Concurrent legacy enrollment or onboarding on another machine has committed
1276
+ -- before this snapshot decides whether the identity was created here.
1277
+ if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = p_agent_public_key_fingerprint) then
1278
+ v_identity_disposition := 'reused';
1279
+ else
1280
+ v_identity_disposition := 'created';
1281
+ end if;
1282
+
1283
+ select * into v_enroll from agent_board.enroll_federated_identity(
1284
+ p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
1285
+ p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
1286
+ p_pop_verified, p_actor_sub, p_agent_kind, p_requested_scopes,
1287
+ p_requested_board_scopes, null
1288
+ );
1289
+ if v_enroll.agent_handle is distinct from v_expected_handle then
1290
+ raise exception 'server-assigned handle does not match the authorized handle' using errcode = '23505';
1291
+ end if;
1292
+
1293
+ -- A complete replay may bypass expiry/exhaustion only while its redemption and
1294
+ -- active membership remain true through commit. Lock both ledgers before the
1295
+ -- proof; the invite row already serializes first redemption for an absent row.
1296
+ perform 1 from agent_board.board_invite_redemptions red
1297
+ where red.invite_id = v_inv.id and red.agent_id = v_enroll.agent_id
1298
+ order by red.id for update;
1299
+ perform 1 from agent_board.board_memberships bm
1300
+ where bm.board_id = v_inv.board_id and bm.agent_id = v_enroll.agent_id
1301
+ order by bm.id for update;
1302
+
1303
+ -- The released ROOMLINK core deliberately returns an existing redemption before
1304
+ -- checking current token authority. Fleet onboarding has a narrower replay
1305
+ -- contract: only an exact, fully active chain may ignore expiry/exhaustion. A
1306
+ -- partial chain must pass every current invite gate before any missing suffix is
1307
+ -- written, and revocation/current creator authority always apply.
1308
+ select exists (
1309
+ select 1
1310
+ from agent_board.board_invite_redemptions red
1311
+ join agent_board.board_memberships mem on mem.id = red.membership_id
1312
+ join agent_board.fleet_harness_registrations h
1313
+ on h.machine_id = p_machine_id
1314
+ and h.harness_kind = v_kind
1315
+ and h.harness_label = v_label
1316
+ join agent_board.fleet_repo_scopes repo
1317
+ on repo.harness_registration_id = h.id
1318
+ and repo.repo_slug = v_repo_slug
1319
+ and repo.repo_fingerprint = v_repo_fingerprint
1320
+ and repo.scope_role = p_repo_scope_role
1321
+ join agent_board.fleet_machine_agents binding
1322
+ on binding.machine_id = p_machine_id
1323
+ and binding.agent_id = v_enroll.agent_id
1324
+ and binding.repo_scope_id = repo.id
1325
+ and binding.signing_key_id = v_enroll.signing_key_id
1326
+ and binding.agent_role = p_agent_role
1327
+ join agent_board.agents principal on principal.id = v_enroll.agent_id
1328
+ join agent_board.agent_signing_keys signing_key on signing_key.id = v_enroll.signing_key_id
1329
+ where red.invite_id = v_inv.id
1330
+ and red.agent_id = v_enroll.agent_id
1331
+ and mem.board_id = v_inv.board_id
1332
+ and mem.agent_id = v_enroll.agent_id
1333
+ and mem.role = 'member'
1334
+ and mem.membership_state in ('joined', 'subscribed')
1335
+ and h.lifecycle_state = 'active'
1336
+ and repo.lifecycle_state = 'active'
1337
+ and binding.lifecycle_state = 'active'
1338
+ and principal.status = 'active'
1339
+ and signing_key.status = 'active'
1340
+ and signing_key.proof_state = 'proven'
1341
+ and signing_key.revoked_at is null
1342
+ and not exists (
1343
+ select 1 from agent_board.fleet_revocation_events e
1344
+ where (e.subject_kind = 'agent' and e.subject_id = v_enroll.agent_id)
1345
+ or (e.subject_kind = 'signing_key' and e.subject_id = v_enroll.signing_key_id)
1346
+ or (e.subject_kind = 'harness' and e.subject_id = h.id)
1347
+ or (e.subject_kind = 'repo_scope' and e.subject_id = repo.id)
1348
+ )
1349
+ ) into v_completed_replay;
1350
+
1351
+ if v_inv.revoked_at is not null
1352
+ or not exists (
1353
+ select 1 from agent_board.agents a
1354
+ where a.id = v_inv.created_by_agent_id and a.status = 'active'
1355
+ )
1356
+ or not agent_board._grant_within_ceiling(
1357
+ v_inv.created_by_agent_id, v_inv.board_id, v_inv.preset_role
1358
+ )
1359
+ or (
1360
+ exists (
1361
+ select 1 from agent_board.board_memberships bm
1362
+ where bm.board_id = v_inv.board_id
1363
+ and bm.agent_id = v_enroll.agent_id
1364
+ and bm.membership_state = 'removed'
1365
+ )
1366
+ and not exists (
1367
+ select 1 from agent_board.board_memberships bm
1368
+ where bm.board_id = v_inv.board_id
1369
+ and bm.agent_id = v_enroll.agent_id
1370
+ and bm.membership_state in ('joined', 'subscribed')
1371
+ )
1372
+ ) then
1373
+ raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
1374
+ end if;
1375
+ if not v_completed_replay
1376
+ and (
1377
+ v_inv.expires_at <= now()
1378
+ or (v_inv.max_uses is not null and v_inv.uses_count >= v_inv.max_uses)
1379
+ ) then
1380
+ raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
1381
+ end if;
1382
+
1383
+ if exists (
1384
+ select 1 from agent_board.board_invite_redemptions r
1385
+ where r.invite_id = v_inv.id and r.agent_id = v_enroll.agent_id
1386
+ ) then v_membership_disposition := 'reused'; else v_membership_disposition := 'created'; end if;
1387
+
1388
+ select * into v_membership
1389
+ from agent_board._redeem_fleet_invite_for_agent(btrim(p_invite_code), v_enroll.agent_id);
1390
+ if v_membership.role <> 'member' or v_membership.membership_state not in ('joined', 'subscribed') then
1391
+ raise exception 'fleet onboarding requires an active member membership' using errcode = '42501';
1392
+ end if;
1393
+
1394
+ perform 1 from agent_board.fleet_harness_registrations h
1395
+ where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
1396
+ order by h.id for update;
1397
+ if exists (
1398
+ select 1 from agent_board.fleet_harness_registrations h
1399
+ where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
1400
+ and h.lifecycle_state <> 'active'
1401
+ ) then
1402
+ raise exception 'revoked harness registration cannot be replaced' using errcode = '42501';
1403
+ end if;
1404
+ select * into v_harness
1405
+ from agent_board.fleet_harness_registrations h
1406
+ where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
1407
+ and h.lifecycle_state = 'active'
1408
+ order by h.id;
1409
+ if found then
1410
+ v_harness_disposition := 'reused';
1411
+ else
1412
+ insert into agent_board.fleet_harness_registrations(machine_id, harness_kind, harness_label)
1413
+ values (p_machine_id, v_kind, v_label) returning * into v_harness;
1414
+ v_harness_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
1415
+ end if;
1416
+
1417
+ perform 1 from agent_board.fleet_repo_scopes r
1418
+ where r.harness_registration_id = v_harness.id
1419
+ and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
1420
+ order by r.id for update;
1421
+ if exists (
1422
+ select 1 from agent_board.fleet_repo_scopes r
1423
+ where r.harness_registration_id = v_harness.id
1424
+ and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
1425
+ and (r.lifecycle_state <> 'active' or r.repo_slug <> v_repo_slug)
1426
+ ) then
1427
+ raise exception 'conflicting or revoked repo scope cannot be replaced' using errcode = '42501';
1428
+ end if;
1429
+ select * into v_repo
1430
+ from agent_board.fleet_repo_scopes r
1431
+ where r.harness_registration_id = v_harness.id
1432
+ and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
1433
+ and r.lifecycle_state = 'active' and r.repo_slug = v_repo_slug
1434
+ order by r.id;
1435
+ if found then
1436
+ v_repo_disposition := 'reused';
1437
+ else
1438
+ insert into agent_board.fleet_repo_scopes(
1439
+ harness_registration_id, repo_slug, repo_fingerprint, scope_role
1440
+ ) values (v_harness.id, v_repo_slug, v_repo_fingerprint, p_repo_scope_role)
1441
+ returning * into v_repo;
1442
+ v_repo_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
1443
+ end if;
1444
+
1445
+ -- Machine -> agent/key/binding lock order. Any tombstone for this natural key
1446
+ -- blocks replacement authority even though the active unique index would allow it.
1447
+ perform 1 from agent_board.agents a where a.id = v_enroll.agent_id for update;
1448
+ perform 1 from agent_board.agent_signing_keys k where k.id = v_enroll.signing_key_id for update;
1449
+ perform 1 from agent_board.fleet_machine_agents b
1450
+ where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
1451
+ and b.repo_scope_id = v_repo.id
1452
+ order by b.id for update;
1453
+ if exists (
1454
+ select 1 from agent_board.fleet_machine_agents b
1455
+ where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
1456
+ and b.repo_scope_id = v_repo.id
1457
+ and (
1458
+ b.lifecycle_state <> 'active'
1459
+ or b.signing_key_id is distinct from v_enroll.signing_key_id
1460
+ or b.agent_role is distinct from p_agent_role
1461
+ )
1462
+ ) then
1463
+ raise exception 'conflicting or revoked fleet binding cannot be replaced' using errcode = '42501';
1464
+ end if;
1465
+ select * into v_binding
1466
+ from agent_board.fleet_machine_agents b
1467
+ where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
1468
+ and b.repo_scope_id = v_repo.id and b.lifecycle_state = 'active'
1469
+ and b.signing_key_id = v_enroll.signing_key_id and b.agent_role = p_agent_role
1470
+ order by b.id;
1471
+ if found then
1472
+ v_binding_disposition := 'reused';
1473
+ else
1474
+ insert into agent_board.fleet_machine_agents(
1475
+ machine_id, agent_id, repo_scope_id, signing_key_id, agent_role
1476
+ ) values (
1477
+ p_machine_id, v_enroll.agent_id, v_repo.id, v_enroll.signing_key_id, p_agent_role
1478
+ ) returning * into v_binding;
1479
+ v_binding_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
1480
+ end if;
1481
+
1482
+ if exists (
1483
+ select 1 from agent_board.fleet_revocation_events e
1484
+ where (e.subject_kind = 'agent' and e.subject_id = v_enroll.agent_id)
1485
+ or (e.subject_kind = 'signing_key' and e.subject_id = v_enroll.signing_key_id)
1486
+ or (e.subject_kind = 'harness' and e.subject_id = v_harness.id)
1487
+ or (e.subject_kind = 'repo_scope' and e.subject_id = v_repo.id)
1488
+ ) then
1489
+ raise exception 'fleet onboarding authority has effective revocation evidence' using errcode = '42501';
1490
+ end if;
1491
+
1492
+ return query select
1493
+ 'fleet_onboarding.v1'::text, v_enroll.agent_id, v_enroll.auth_user_id,
1494
+ v_enroll.provider_id, v_enroll.signing_key_id, v_enroll.granted_scopes,
1495
+ v_enroll.key_id, v_enroll.key_version, v_enroll.agent_handle,
1496
+ p_agent_public_key_fingerprint, v_membership.board_id, v_membership.role,
1497
+ v_membership.membership_state, p_machine_id, v_harness.id, v_repo.id,
1498
+ v_binding.id, v_binding.signing_key_id, v_kind, v_label, v_repo_slug, v_repo_fingerprint,
1499
+ p_repo_scope_role, p_agent_role, v_expected_handle,
1500
+ v_identity_disposition, v_membership_disposition, v_harness_disposition,
1501
+ v_repo_disposition, v_binding_disposition;
1502
+ end;
1503
+ $function$
1504
+
1505
+ ;
1506
+
1507
+ -- ---------------------------------------------------------------------------
1508
+ -- BACKFILL: move every existing federated owner identity onto the new slug.
1509
+ --
1510
+ -- The old slug is a one-way digest, so it cannot be inverted; the pair is
1511
+ -- recovered from the federation stamps the enrollment paths wrote at creation
1512
+ -- (metadata->'federation'), and each candidate pair must REPRODUCE the row's
1513
+ -- current slug under the OLD encoding before it is trusted. That reproduction
1514
+ -- test is the only place the old expression survives, and it survives as a
1515
+ -- proof, not as a derivation: nothing keyed by it is created here.
1516
+ -- ---------------------------------------------------------------------------
1517
+ do $backfill$
1518
+ declare
1519
+ v_row record;
1520
+ v_iss text;
1521
+ v_sub text;
1522
+ v_pairs int;
1523
+ v_old text;
1524
+ v_new text;
1525
+ v_moved int := 0;
1526
+ v_unproven int;
1527
+ begin
1528
+ for v_row in
1529
+ select u.id as uid,
1530
+ substring(lower(u.email) from '^agent\+(fed-human-[0-9a-f]{32})@agent-board\.local$') as slug
1531
+ from auth.users u
1532
+ where lower(u.email) like 'agent+fed-human-%@agent-board.local'
1533
+ order by u.id
1534
+ loop
1535
+ v_old := v_row.slug;
1536
+ if v_old is null then
1537
+ raise exception
1538
+ '#246 backfill: auth.users % carries a fed-human email that does not parse as a slug',
1539
+ v_row.uid using errcode = '22023';
1540
+ end if;
1541
+
1542
+ -- Recover the pair from the stamps of every agent that references this owner --
1543
+ -- by auth_user_id (the human path's own row) or owner_user_id (identity/fleet) --
1544
+ -- keeping only pairs that reproduce this row's slug under the OLD encoding.
1545
+ select count(*), min(t.iss), min(t.sub)
1546
+ into v_pairs, v_iss, v_sub
1547
+ from (
1548
+ select distinct
1549
+ a.metadata->'federation'->>'issuer' as iss,
1550
+ a.metadata->'federation'->>'subject' as sub
1551
+ from agent_board.agents a
1552
+ where (a.auth_user_id = v_row.uid or a.owner_user_id = v_row.uid)
1553
+ and a.metadata->'federation'->>'issuer' is not null
1554
+ and a.metadata->'federation'->>'subject' is not null
1555
+ and 'fed-human-' || substr(
1556
+ encode(extensions.digest(convert_to(
1557
+ (a.metadata->'federation'->>'issuer') || '|' ||
1558
+ (a.metadata->'federation'->>'subject'), 'UTF8'), 'sha256'), 'hex'),
1559
+ 1, 32) = v_old
1560
+ ) t;
1561
+
1562
+ -- DISTINCT pairs, not rows: production's owner carries six agents stamped with the
1563
+ -- same pair, and a row count would refuse it. 0 = unrecoverable, >1 = a real
1564
+ -- collision. Either way, refuse rather than bind an account to a guessed human.
1565
+ if v_pairs <> 1 then
1566
+ raise exception
1567
+ '#246 backfill: auth.users % (%) has % distinct stamped (issuer, subject) pairs reproducing its slug; exactly 1 required',
1568
+ v_row.uid, v_old, v_pairs using errcode = '22023';
1569
+ end if;
1570
+
1571
+ v_new := agent_board._federated_identity_slug(v_iss, v_sub);
1572
+ if v_new = v_old then
1573
+ continue;
1574
+ end if;
1575
+
1576
+ if exists (
1577
+ select 1 from auth.users u2
1578
+ where lower(u2.email) = 'agent+' || v_new || '@agent-board.local'
1579
+ and u2.id <> v_row.uid
1580
+ ) then
1581
+ raise exception
1582
+ '#246 backfill: re-keying auth.users % to % would collide with an existing owner row',
1583
+ v_row.uid, v_new using errcode = '23505';
1584
+ end if;
1585
+
1586
+ -- (1) the account key itself, and (2) the slug _ensure_agent_auth_user recorded
1587
+ -- alongside it. The uuid is untouched: sessions, owner_user_id and every FK survive.
1588
+ update auth.users u
1589
+ set email = 'agent+' || v_new || '@agent-board.local',
1590
+ raw_user_meta_data = case
1591
+ when u.raw_user_meta_data->>'slug' = v_old
1592
+ then jsonb_set(u.raw_user_meta_data, '{slug}', to_jsonb(v_new))
1593
+ else u.raw_user_meta_data end,
1594
+ updated_at = now()
1595
+ where u.id = v_row.uid;
1596
+
1597
+ -- (3) the auth.identities row the same helper created, which keys on the slug in
1598
+ -- three places.
1599
+ update auth.identities i
1600
+ set provider_id = v_new,
1601
+ identity_data = i.identity_data
1602
+ || case when i.identity_data->>'sub' = v_old
1603
+ then jsonb_build_object('sub', v_new) else '{}'::jsonb end
1604
+ || case when i.identity_data->>'slug' = v_old
1605
+ then jsonb_build_object('slug', v_new) else '{}'::jsonb end,
1606
+ updated_at = now()
1607
+ where i.user_id = v_row.uid
1608
+ and i.provider = 'agent_board'
1609
+ and i.provider_id = v_old;
1610
+
1611
+ -- (4) the human path's own agent row, whose handle IS the slug. Agents enrolled on
1612
+ -- the identity/fleet paths keep their own handles and reference the owner by uuid.
1613
+ update agent_board.agents a
1614
+ set handle = v_new
1615
+ where a.handle = v_old;
1616
+
1617
+ v_moved := v_moved + 1;
1618
+ end loop;
1619
+
1620
+ -- Post-condition, asserted over the WHOLE population rather than the rows this loop
1621
+ -- happened to touch: every remaining fed-human owner email must be reproducible from
1622
+ -- a stamp under the NEW derivation. Old and new slugs are shape-identical
1623
+ -- (fed-human-<32hex>), so "no old-form rows remain" is not something a pattern can
1624
+ -- see; this is the check that can.
1625
+ select count(*) into v_unproven
1626
+ from auth.users u
1627
+ where lower(u.email) like 'agent+fed-human-%@agent-board.local'
1628
+ and not exists (
1629
+ select 1
1630
+ from agent_board.agents a
1631
+ where (a.auth_user_id = u.id or a.owner_user_id = u.id)
1632
+ and a.metadata->'federation'->>'issuer' is not null
1633
+ and a.metadata->'federation'->>'subject' is not null
1634
+ and agent_board._federated_identity_slug(
1635
+ a.metadata->'federation'->>'issuer',
1636
+ a.metadata->'federation'->>'subject')
1637
+ = substring(lower(u.email) from '^agent\+(fed-human-[0-9a-f]{32})@agent-board\.local$'));
1638
+ if v_unproven <> 0 then
1639
+ raise exception
1640
+ '#246 backfill: % federated owner row(s) are not reproducible under the new derivation after re-keying',
1641
+ v_unproven using errcode = '22023';
1642
+ end if;
1643
+
1644
+ raise notice '#246 backfill: re-keyed % federated owner identity row(s)', v_moved;
1645
+ end
1646
+ $backfill$;