@consiliency/agent-board-schema 1.24.0 → 1.25.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 (27) hide show
  1. package/fixtures/already-applied.json +8 -0
  2. package/migrations/20260906000100_agent_board_enroll_resumption.sql +749 -0
  3. package/migrations/20260907000100_agent_board_signed_authority_provenance.sql +243 -0
  4. package/package.json +1 -1
  5. package/schema-manifest.json +21 -3
  6. package/src/tooling.test.mjs +3 -3
  7. package/tests/agent_board_atomic_fleet_onboarding.test.sql +124 -112
  8. package/tests/agent_board_claimkey.test.sql +25 -3
  9. package/tests/agent_board_consent_emission.test.sql +17 -13
  10. package/tests/agent_board_consent_gating.test.sql +55 -11
  11. package/tests/agent_board_delegation_policies.test.sql +3 -1
  12. package/tests/agent_board_enroll_resumption.test.sql +412 -0
  13. package/tests/agent_board_federated_lock_order_class.test.sql +1 -3
  14. package/tests/agent_board_fedsession_provider_absence_gate.test.sql +9 -6
  15. package/tests/agent_board_fleet_machine_reactivation.test.sql +56 -5
  16. package/tests/agent_board_mint_harden.test.sql +39 -4
  17. package/tests/agent_board_outbox_wake_ordering.test.sql +27 -12
  18. package/tests/agent_board_rejectsem.test.sql +1 -1
  19. package/tests/agent_board_rolereset.test.sql +23 -2
  20. package/tests/agent_board_rowid.test.sql +24 -13
  21. package/tests/agent_board_scoped_send_regrant.test.sql +8 -4
  22. package/tests/agent_board_signed_authority_provenance.test.sql +454 -0
  23. package/tests/agent_board_source_strip_helper.test.sql +285 -0
  24. package/tests/agent_board_wake_recovery.test.sql +58 -40
  25. package/tests/catalog/p_metadata_parameters.json +689 -0
  26. package/tests/fixtures/test-prereqs.sql +313 -0
  27. package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +3 -3
@@ -0,0 +1,749 @@
1
+ -- Consiliency/message-board#176 -- ENROLL RESUMPTION.
2
+ --
3
+ -- WHAT HAPPENED, LIVE. An agent enrolled. `/enroll` SUCCEEDED: agent row, signing
4
+ -- key, human owner, proof_state=proven, all committed. Post-enroll provisioning then
5
+ -- failed (#173) and the client reported the WHOLE operation failed. Every retry after
6
+ -- that was refused with `23505 this signing key is already enrolled (duplicate
7
+ -- fingerprint)`, surfaced as an opaque 403. The agent held a valid identity for hours
8
+ -- while the tooling insisted it had none, and its loop could never succeed.
9
+ --
10
+ -- WHAT ALREADY EXISTS, AND WHY IT DID NOT SAVE THE VICTIM. 20260901000100 (merged the
11
+ -- day AFTER #176 was filed) wrapped the creator in a replay path that DOES return the
12
+ -- existing identity for an exact re-presentation. That path is gated on a
13
+ -- `metadata.federation.replay` STAMP the wrapper writes at creation time. Every
14
+ -- identity enrolled BEFORE that migration -- including the #176 victim -- has no stamp,
15
+ -- so `v_replay is null` sent it straight to
16
+ -- `23505 enrollment replay conflicts with the frozen identity contract`: a different
17
+ -- message for the same dead end. The trap is still shut for exactly the enrollments
18
+ -- that fell into it.
19
+ --
20
+ -- THE FIX, in one sentence: an already-enrolled key presented by the principal that
21
+ -- OWNS it is a RESUMPTION, not a violation. There is ONE resumption implementation and
22
+ -- it stays in the wrapper (agent_board.enroll_federated_identity). The internal creator
23
+ -- deliberately does NOT grow a second one -- a twin predicate is how this repo has
24
+ -- drifted before, and the creator lacks the wrapper's claim-projection equality gate,
25
+ -- so a resumption decided there would rest on bare parameters.
26
+ --
27
+ -- WHERE THE OWNERSHIP TEST GETS ITS PRINCIPAL, and why that source is EVIDENCE.
28
+ -- The DB session here is service_role (the /enroll edge function), which is the
29
+ -- verifier: it validated the Portal JWT signature and projected the verified claim.
30
+ -- `p_issuer`/`p_subject` are therefore trusted ONLY because the wrapper already
31
+ -- equality-gates them against the verified claim projection it was handed
32
+ -- (`p_human_identity_claim ->> 'iss'`, `->> subject_claim`, and the normalized `aud`
33
+ -- must all match, above -- that gate is unchanged and runs BEFORE anything here).
34
+ -- Ownership is then decided by comparing that gated principal against evidence WRITTEN
35
+ -- AT THE ORIGINAL ENROLLMENT, never against a caller argument describing who acted:
36
+ -- (i) agents.owner_user_id equals the auth user DERIVED from
37
+ -- sha256(issuer|subject) -- the same one-way derivation step (7) used to mint
38
+ -- it, so a different principal derives a different owner and cannot collide;
39
+ -- (ii) agents.metadata->'federation'->>'issuer'/'subject'/'providerId', written by
40
+ -- step (10) from the EF-verified inputs of the ORIGINAL enrollment;
41
+ -- (iii) a matching agent_board.enrollment_federation_audit row (agent, issuer,
42
+ -- subject, provider) -- the original enrollment's own audit record.
43
+ -- (ii) and (iii) are REQUIRED. (i) is required WHENEVER IT EXISTS: a non-null
44
+ -- owner_user_id that disagrees with the derived one is a hard refusal, and the single
45
+ -- exemption is a row with NO owner_user_id at all, which predates the owner model
46
+ -- entirely (the atomic-fleet backfill fixture is exactly that shape and replaying it is
47
+ -- released, asserted behaviour). Stated positively, because an exemption asserted only
48
+ -- by absence is how this repo has been bitten: such a row still proves ownership through
49
+ -- (ii) and (iii), which name the principal on their own, and reaching them at all
50
+ -- requires a claim the edge function VERIFIED for that same issuer and subject. The
51
+ -- exemption therefore narrows corroboration without widening who may resume, and it is
52
+ -- pinned in BOTH directions (a null-owner row resumes for its owner; the same row still
53
+ -- refuses a different subject).
54
+ -- The whole predicate is `coalesce(<conjunction>, false)`, because a NULL from any
55
+ -- missing evidence must read as NOT-OWNED, not as an untaken `if not`.
56
+ -- The derived-owner lookup is a pure SELECT against auth.users by the derived email --
57
+ -- it must NOT call _ensure_agent_auth_user, which would MINT an owner row for whatever
58
+ -- principal a squat attempt claimed.
59
+ --
60
+ -- THE THREE OUTCOMES, each with its own message:
61
+ -- (a) NOT owned by the caller -> raise 23505. This is the key-squat the check exists
62
+ -- to prevent and it is checked FIRST, so a non-owner learns nothing about the
63
+ -- key's state. The message names NO handle: telling a squatter which agent holds
64
+ -- the key it just probed is the leak this ordering avoids.
65
+ -- (b) owned, but the identity is not resumable (key or agent inactive, unproven,
66
+ -- public key/format drift, or an audit row recording no granted scopes) -> raise
67
+ -- 23505 naming the handle. Resumption never revives a revoked or unproven
68
+ -- identity, and the granted-scopes disjunct is what keeps this branch reachable
69
+ -- on its own rather than implied by (iii).
70
+ -- (c) owned, resumable, and a replay STAMP is present but a frozen field differs ->
71
+ -- raise 23505 naming the handle and saying enrollment need not be repeated. The
72
+ -- frozen contract is NOT loosened; only its message improves.
73
+ -- otherwise -> return the existing identity (idempotent success), which is the
74
+ -- resumption #176 asks for. `v_replay is null` (a pre-20260901000100 enrollment) is
75
+ -- no longer a raise: (i)-(iii) are the whole basis, and they are exactly the evidence
76
+ -- those rows do carry.
77
+ --
78
+ -- WHAT IS DELIBERATELY LEFT OUT. A legacy (unstamped) resumption does NOT write a
79
+ -- `federation.replay` stamp. The stamp's `requestedSlug` is not recoverable from stored
80
+ -- evidence -- only the RESULTING handle was persisted, and it may carry a collision
81
+ -- suffix -- so a stamp built here would have to take the slug from the CURRENT request,
82
+ -- which is not evidence of the original one and would freeze the contract around
83
+ -- whatever the retry happened to send. Unstamped rows therefore keep resuming on
84
+ -- (i)-(iii) every time. Consequence, stated plainly: for those rows a same-owner retry
85
+ -- with a different slug or scope request resumes instead of raising. It cannot escalate
86
+ -- -- `granted_scopes` is returned from the ORIGINAL audit row, never from the request --
87
+ -- and it is the recovery #176 exists to provide.
88
+ --
89
+ -- LOCK ORDER (#194) IS UNCHANGED, and this migration acquires nothing new. The two
90
+ -- additions are read-only: a `select ... from auth.users` by email (no FOR UPDATE, no
91
+ -- row lock) and a `select 1 from enrollment_federation_audit` existence probe. Both sit
92
+ -- AFTER the existing invite -> board -> fingerprint-advisory -> owner sequence, inside
93
+ -- the region that already holds `for update of a, k` on the agent and key. The whole
94
+ -- #194 header from 20260903000100 (board before owner; the FK this route never names;
95
+ -- the FOR KEY SHARE reasoning; the two advisory edges) still applies verbatim and is
96
+ -- reproduced at each of the two acquisition sites below.
97
+ --
98
+ -- The internal creator's step (6) still raises -- it is a backstop that the wrapper's
99
+ -- dedup makes unreachable in practice (the wrapper is its ONLY caller; verified from
100
+ -- pg_proc, and onboard_federated_fleet_agent calls the WRAPPER, not the creator) -- but
101
+ -- the raise is now ACTIONABLE: it names the existing handle and says enrollment need
102
+ -- not be repeated, which is the issue's minimum ask.
103
+ --
104
+ -- Both functions are recreated with CREATE OR REPLACE (ACL and COMMENT survive); the
105
+ -- grants and comments are restated below regardless, per the repo rule. The bodies are
106
+ -- byte-identical to 20260903000100 apart from the deltas described above.
107
+
108
+ -- 1. the internal one-time creator (renamed core).
109
+ create or replace function agent_board._enroll_federated_identity_once(
110
+ p_issuer text,
111
+ p_audience text,
112
+ p_subject text,
113
+ p_human_identity_claim jsonb,
114
+ p_slug text,
115
+ p_agent_public_key text,
116
+ p_agent_public_key_format text,
117
+ p_agent_public_key_fingerprint text,
118
+ p_pop_verified boolean,
119
+ p_actor_sub text default null,
120
+ p_agent_kind text default 'agent',
121
+ p_requested_scopes text[] default '{}',
122
+ p_requested_board_scopes text[] default '{}',
123
+ p_invite_code text default null
124
+ )
125
+ returns table(
126
+ agent_id uuid,
127
+ auth_user_id uuid,
128
+ provider_id text,
129
+ signing_key_id uuid,
130
+ granted_scopes text[],
131
+ key_id text,
132
+ key_version int,
133
+ agent_handle text,
134
+ invite_redeemed boolean
135
+ )
136
+ language plpgsql
137
+ security definer
138
+ set search_path = agent_board, public
139
+ as $$
140
+ declare
141
+ v_slug text;
142
+ v_kind text;
143
+ v_provider agent_board.identity_providers%rowtype;
144
+ v_owner_user_id uuid;
145
+ v_auth_user_id uuid;
146
+ v_agent_handle text;
147
+ v_agent_id uuid;
148
+ v_signing_key_id uuid;
149
+ v_fingerprint text;
150
+ v_key_hex text;
151
+ v_raw_key bytea;
152
+ v_reencoded text;
153
+ v_key_id text := 'portal-enroll';
154
+ v_key_version int := 1;
155
+ v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
156
+ v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
157
+ v_invite_code text := nullif(trim(coalesce(p_invite_code, '')), '');
158
+ v_invite_redeemed boolean := false;
159
+ v_invite_board_id uuid;
160
+ v_existing_handle text;
161
+ begin
162
+ -- service_role-only (defense in depth on top of the execute-grant revocation).
163
+ if not agent_board._is_service_role() then
164
+ raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
165
+ end if;
166
+
167
+ -- A verified subject + human identity claim are always present from the EF.
168
+ if p_subject is null then
169
+ raise exception 'verified subject is required' using errcode = '22023';
170
+ end if;
171
+ if p_human_identity_claim is null then
172
+ raise exception 'human identity claim is required' using errcode = '22023';
173
+ end if;
174
+
175
+ -- (0) PoP gate — the EF Ed25519-verified the proof-of-possession over the token
176
+ -- bytes and passes true. Refuse false BEFORE any write (no agent/key/audit row).
177
+ if p_pop_verified is not true then
178
+ raise exception 'proof-of-possession was not verified; enrollment refused'
179
+ using errcode = '42501';
180
+ end if;
181
+
182
+ -- (1) Public-key shape gate. The enroll contract is raw Ed25519 (32 bytes -> 43
183
+ -- base64url chars).
184
+ if p_agent_public_key_format is distinct from 'raw_ed25519_base64url' then
185
+ raise exception 'agent public key format must be raw_ed25519_base64url' using errcode = '22023';
186
+ end if;
187
+ if p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
188
+ raise exception 'malformed agent public key: expected a 43-char base64url raw Ed25519 key'
189
+ using errcode = '22023';
190
+ end if;
191
+
192
+ -- (1a) B2 — decode base64url -> raw 32 bytes and REJECT any non-canonical encoding.
193
+ v_raw_key := decode(translate(p_agent_public_key, '-_', '+/') || '=', 'base64');
194
+ if octet_length(v_raw_key) <> 32 then
195
+ raise exception 'agent public key must decode to exactly 32 raw Ed25519 bytes'
196
+ using errcode = '22023';
197
+ end if;
198
+ v_reencoded := rtrim(translate(encode(v_raw_key, 'base64'), '+/', '-_'), '=');
199
+ if v_reencoded is distinct from p_agent_public_key then
200
+ raise exception 'agent public key is not canonical base64url (aliased encoding rejected)'
201
+ using errcode = '22023';
202
+ end if;
203
+
204
+ -- (1b) Reject the all-zero small-order placeholder over the RAW bytes.
205
+ if v_raw_key = decode(repeat('00', 32), 'hex') then
206
+ raise exception 'agent public key is the all-zero small-order placeholder and is rejected'
207
+ using errcode = '22023';
208
+ end if;
209
+
210
+ -- (2) Recompute-and-assert the fingerprint in-DB over the RAW 32 bytes. Derived through
211
+ -- the shared _raw_key_fingerprint helper (single source of truth; catalog-sweep-pinned).
212
+ v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
213
+ if p_agent_public_key_fingerprint is distinct from v_fingerprint then
214
+ raise exception 'agent public key fingerprint mismatch (recompute != supplied)'
215
+ using errcode = '22023';
216
+ end if;
217
+
218
+ -- (3) act.sub is audit-only, but when present it must be a real value.
219
+ if p_actor_sub is not null and length(trim(p_actor_sub)) = 0 then
220
+ raise exception 'actor sub, when present, must be non-empty' using errcode = '22023';
221
+ end if;
222
+
223
+ v_slug := agent_board._normalize_provision_slug(p_slug);
224
+ v_kind := lower(coalesce(trim(p_agent_kind), ''));
225
+
226
+ -- (4) allowlist lookup by (issuer, audience); must exist + supported + policy-approved.
227
+ select *
228
+ into v_provider
229
+ from agent_board.identity_providers
230
+ where issuer = p_issuer
231
+ and audience = p_audience
232
+ limit 1;
233
+ if v_provider.id is null then
234
+ raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
235
+ end if;
236
+ if not v_provider.supported then
237
+ raise exception 'identity provider is not supported' using errcode = '42501';
238
+ end if;
239
+ if not v_provider.policy_approved then
240
+ raise exception 'identity provider is not policy-approved' using errcode = '42501';
241
+ end if;
242
+
243
+ -- (5) scope-ceiling + kind gate. 'system' is never federatable.
244
+ if v_kind = 'system' then
245
+ raise exception 'the system kind cannot be federated' using errcode = '42501';
246
+ end if;
247
+ if v_kind not in ('human', 'agent') then
248
+ raise exception 'agent kind must be human or agent' using errcode = '22023';
249
+ end if;
250
+ if array_length(v_provider.allowed_agent_kinds, 1) is not null
251
+ and not (v_kind = any (v_provider.allowed_agent_kinds)) then
252
+ raise exception 'agent kind % is outside the provider ceiling', v_kind using errcode = '42501';
253
+ end if;
254
+ if not (v_requested_scopes <@ v_provider.allowed_scopes) then
255
+ raise exception 'requested scopes exceed the provider allowed_scopes ceiling' using errcode = '42501';
256
+ end if;
257
+ if not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
258
+ raise exception 'requested board scopes exceed the provider allowed_board_scopes ceiling' using errcode = '42501';
259
+ end if;
260
+
261
+ -- (6) dedup on the enrolled key: a fingerprint already enrolled cannot be re-bound
262
+ -- HERE. #176: this is a BACKSTOP, not the resumption decision. enroll_federated_identity
263
+ -- is this function's only caller (pg_proc-verified) and it resolves an already-enrolled
264
+ -- fingerprint before ever reaching here, so a caller that lands on this raise reached the
265
+ -- internal creator directly. Resumption is deliberately NOT duplicated into this
266
+ -- function: it has no claim-projection equality gate, so an ownership decision made here
267
+ -- would rest on bare parameters rather than on the verified claim. What DID change is
268
+ -- that the raise is actionable -- it names the identity that already holds the key and
269
+ -- says enrollment need not be repeated.
270
+ --
271
+ -- The GATE is still the unchanged `exists` over agent_signing_keys alone. The handle is
272
+ -- looked up separately, for the MESSAGE only, through a join that a hypothetical
273
+ -- agent-less key would not satisfy: folding that join into the gate would have turned a
274
+ -- rendering nicety into a hole that lets such a key be re-bound.
275
+ if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = v_fingerprint) then
276
+ select a.handle into v_existing_handle
277
+ from agent_board.agent_signing_keys k
278
+ join agent_board.agents a on a.id = k.agent_id
279
+ where k.fingerprint = v_fingerprint
280
+ limit 1;
281
+ 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)',
282
+ coalesce(v_existing_handle, '(unknown)')
283
+ using errcode = '23505';
284
+ end if;
285
+
286
+ -- #194 LOCK ORDER -- BOARD BEFORE OWNER, and the reason is an FK this function
287
+ -- never names. The redemption below inserts board_memberships, which takes FOR
288
+ -- KEY SHARE on the invite's agent_board.boards row through
289
+ -- board_memberships_board_id_fkey. That acquisition used to land AFTER the
290
+ -- auth.users owner lock, inverting against enroll_federated_human and
291
+ -- onboard_federated_fleet_agent -- both of which take board -> owner -- and the
292
+ -- resulting cycle was reproduced as `deadlock detected ... while locking tuple
293
+ -- in relation "boards"`. Acquire the invite row and its board here, in the same
294
+ -- relative order _redeem_unbounded_invite_for_agent and
295
+ -- onboard_federated_fleet_agent take them (invite -> board), and in the SAME mode
296
+ -- the redemption itself uses, so the redemption below acquires nothing new. Absence is deliberately NOT fatal: a missing,
297
+ -- revoked, expired or exhausted code still falls through to redemption, which
298
+ -- stays the sole authority on invite usability (soft-honor contract).
299
+ if v_invite_code is not null then
300
+ select bit.board_id into v_invite_board_id
301
+ from agent_board.board_invite_tokens bit
302
+ where bit.code = v_invite_code
303
+ for update;
304
+ if v_invite_board_id is not null then
305
+ perform 1 from agent_board.boards b
306
+ where b.id = v_invite_board_id
307
+ for key share;
308
+ end if;
309
+ end if;
310
+
311
+ -- (7) HUMAN OWNER — established idempotently per (issuer, sub).
312
+ v_owner_user_id := agent_board._ensure_agent_auth_user(
313
+ 'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32));
314
+
315
+ -- (8) AGENT handle — unique per enrolled key.
316
+ v_key_hex := encode(extensions.digest(v_raw_key, 'sha256'), 'hex');
317
+ v_agent_handle := v_slug;
318
+ if exists (select 1 from agent_board.agents a where a.handle = v_agent_handle) then
319
+ v_agent_handle := v_slug || '-' || substr(v_key_hex, 1, 12);
320
+ end if;
321
+
322
+ -- (9) the agent's OWN board principal (session-usable via its enrolled key).
323
+ v_auth_user_id := agent_board._ensure_agent_auth_user(v_agent_handle);
324
+
325
+ -- (10) create the SEPARATE agent row (never keyed on sub; owner = the human).
326
+ insert into agent_board.agents(
327
+ agent_type,
328
+ kind,
329
+ handle,
330
+ display_name,
331
+ status,
332
+ auth_user_id,
333
+ owner_user_id,
334
+ metadata
335
+ )
336
+ values (
337
+ v_kind,
338
+ v_kind::agent_board.agent_kind,
339
+ v_agent_handle,
340
+ agent_board._default_provision_display_name(v_agent_handle),
341
+ 'active',
342
+ v_auth_user_id,
343
+ v_owner_user_id,
344
+ jsonb_build_object(
345
+ 'federation', jsonb_build_object(
346
+ 'issuer', p_issuer,
347
+ 'audience', p_audience,
348
+ 'subject', p_subject,
349
+ 'actorSub', p_actor_sub,
350
+ 'providerId', v_provider.id,
351
+ 'signingKeyFingerprint', v_fingerprint,
352
+ 'requestedScopes', to_jsonb(v_requested_scopes),
353
+ 'requestedBoardScopes', to_jsonb(v_requested_board_scopes),
354
+ 'popVerified', true,
355
+ 'enrolledAt', now()
356
+ )
357
+ )
358
+ )
359
+ returning id into v_agent_id;
360
+
361
+ -- (11) enroll the public key as active + proven (satisfies active_requires_proof).
362
+ insert into agent_board.agent_signing_keys(
363
+ id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
364
+ status, proof_state, metadata
365
+ )
366
+ values (
367
+ gen_random_uuid(), v_agent_id, v_key_id, v_key_version, p_agent_public_key,
368
+ p_agent_public_key_format, v_fingerprint,
369
+ 'active', 'proven',
370
+ jsonb_build_object('enrolledVia', 'federated_identity', 'popVerified', true)
371
+ )
372
+ returning id into v_signing_key_id;
373
+
374
+ -- (12) one static claim-snapshot audit row (the verified claim contains act.sub).
375
+ insert into agent_board.enrollment_federation_audit(
376
+ issuer, audience, subject, human_identity_claim, provider_id,
377
+ agent_id, auth_user_id, requested_scopes, granted_scopes
378
+ )
379
+ values (
380
+ p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
381
+ v_agent_id, v_auth_user_id, v_requested_scopes, v_requested_scopes
382
+ );
383
+
384
+ -- (13) ROOMLINK soft-honor: if a code is present, redeem it into a bounded board
385
+ -- membership for the freshly-created agent. Wrapped in a subtransaction so a
386
+ -- bad/expired/exhausted/unauthorized code rolls back ONLY this sub-block — the
387
+ -- agent-create above persists and enrollment still succeeds (invite_redeemed=false).
388
+ if v_invite_code is not null then
389
+ begin
390
+ perform agent_board._redeem_invite_for_agent(v_invite_code, v_agent_id);
391
+ v_invite_redeemed := true;
392
+ exception when others then
393
+ -- #194: a serialization failure is NOT "the invite was not redeemed".
394
+ -- Swallowing class 40 here reported enrollment SUCCESS to the caller with
395
+ -- invite_redeemed=false and no membership row, while the deadlock counter
396
+ -- ticked and no error reached the client. Re-raise the whole class
397
+ -- (40001 serialization_failure, 40P01 deadlock_detected, 40003, 40002)
398
+ -- and keep the soft-honor contract for every deterministic failure.
399
+ if left(sqlstate, 2) = '40' then
400
+ raise;
401
+ end if;
402
+ v_invite_redeemed := false;
403
+ end;
404
+ end if;
405
+
406
+ -- Server-authoritative identity: the client mints /session with the RETURNED
407
+ -- key_id/key_version/agent_handle (its private key stays in the keystore).
408
+ return query
409
+ select v_agent_id, v_auth_user_id, v_provider.id, v_signing_key_id, v_requested_scopes,
410
+ v_key_id, v_key_version, v_agent_handle, v_invite_redeemed;
411
+ end;
412
+ $$;
413
+
414
+ comment on function agent_board._enroll_federated_identity_once(
415
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) is
416
+ 'Internal one-time federated-identity creator (the released 20260724000200/20260822000100 implementation, renamed by 20260901000100). Call only through enroll_federated_identity, which holds the invite/board and owner locks. Takes the invite row and its board BEFORE the owner is provisioned so the board -> owner order holds even on a direct service_role call (#194); the optional trailing p_invite_code is still soft-honored via _redeem_invite_for_agent in a BEGIN/EXCEPTION subtransaction (bad code -> enrollment still succeeds, invite_redeemed=false), EXCEPT for SQLSTATE class 40, which propagates rather than being reported as a non-redemption. CREATES ONLY: step (6) still refuses an already-enrolled fingerprint unconditionally and does NOT resume (#176) -- resumption has exactly one implementation and it lives in enroll_federated_identity, which is this function''s only caller and which gates the (issuer, subject) against the verified claim projection first. The step (6) raise names the agent that already holds the key and says enrollment need not be repeated.';
417
+ revoke all on function agent_board._enroll_federated_identity_once(
418
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
419
+ from public, anon, authenticated;
420
+ grant execute on function agent_board._enroll_federated_identity_once(
421
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
422
+ to service_role;
423
+
424
+ -- 2. the replay wrapper -- the route the Portal /enroll endpoint calls.
425
+ create or replace function agent_board.enroll_federated_identity(
426
+ p_issuer text,
427
+ p_audience text,
428
+ p_subject text,
429
+ p_human_identity_claim jsonb,
430
+ p_slug text,
431
+ p_agent_public_key text,
432
+ p_agent_public_key_format text,
433
+ p_agent_public_key_fingerprint text,
434
+ p_pop_verified boolean,
435
+ p_actor_sub text default null,
436
+ p_agent_kind text default 'agent',
437
+ p_requested_scopes text[] default '{}',
438
+ p_requested_board_scopes text[] default '{}',
439
+ p_invite_code text default null
440
+ )
441
+ returns table(
442
+ agent_id uuid,
443
+ auth_user_id uuid,
444
+ provider_id text,
445
+ signing_key_id uuid,
446
+ granted_scopes text[],
447
+ key_id text,
448
+ key_version int,
449
+ agent_handle text,
450
+ invite_redeemed boolean
451
+ )
452
+ language plpgsql
453
+ security definer
454
+ set search_path = agent_board, public
455
+ as $$
456
+ #variable_conflict use_column
457
+ declare
458
+ v_fingerprint text;
459
+ v_existing record;
460
+ v_created record;
461
+ v_replay jsonb;
462
+ v_invite_redeemed boolean := false;
463
+ v_slug text;
464
+ v_kind text := lower(btrim(coalesce(p_agent_kind, 'agent')));
465
+ v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
466
+ v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
467
+ v_provider agent_board.identity_providers%rowtype;
468
+ v_invite_board_id uuid;
469
+ v_claim_audience jsonb;
470
+ v_owner_user_id uuid;
471
+ -- #176 resumption. `v_derived_owner_user_id` is the auth user the CALLER's verified
472
+ -- (issuer, subject) derives to; `v_creation_federation` is the federation object the
473
+ -- ORIGINAL enrollment stamped on the agent; `v_owned_by_caller` is the conjunction of
474
+ -- the three stored-evidence tests, coalesced so missing evidence reads as NOT-owned.
475
+ v_derived_owner_user_id uuid;
476
+ v_creation_federation jsonb;
477
+ v_owned_by_caller boolean;
478
+ begin
479
+ if not agent_board._is_service_role() then
480
+ raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
481
+ end if;
482
+ if p_pop_verified is not true then
483
+ raise exception 'proof-of-possession was not verified' using errcode = '42501';
484
+ end if;
485
+ if p_subject is null then
486
+ raise exception 'verified subject is required' using errcode = '22023';
487
+ end if;
488
+ if p_human_identity_claim is null then
489
+ raise exception 'human identity claim is required' using errcode = '22023';
490
+ end if;
491
+
492
+ select * into v_provider from agent_board.identity_providers ip
493
+ where ip.issuer = p_issuer and ip.audience = p_audience limit 1;
494
+ if v_provider.id is null then
495
+ raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
496
+ end if;
497
+
498
+ v_claim_audience := agent_board._normalized_federation_audience(p_human_identity_claim);
499
+ if p_human_identity_claim ->> 'iss' is distinct from p_issuer
500
+ or p_human_identity_claim ->> v_provider.subject_claim is distinct from p_subject
501
+ or v_claim_audience is null
502
+ or not (v_claim_audience ? p_audience) then
503
+ raise exception 'verified stable claim projection does not match enrollment inputs' using errcode = '22023';
504
+ end if;
505
+ if p_agent_public_key_format is distinct from 'raw_ed25519_base64url'
506
+ or p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
507
+ raise exception 'malformed agent public key' using errcode = '22023';
508
+ end if;
509
+ v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
510
+ if p_agent_public_key_fingerprint is distinct from v_fingerprint then
511
+ raise exception 'agent public key fingerprint mismatch' using errcode = '22023';
512
+ end if;
513
+ v_slug := agent_board._normalize_provision_slug(p_slug);
514
+
515
+ if not v_provider.supported or not v_provider.policy_approved then
516
+ raise exception 'identity provider is not approved' using errcode = '42501';
517
+ end if;
518
+ if v_kind = 'system' then
519
+ raise exception 'the system kind cannot be federated' using errcode = '42501';
520
+ end if;
521
+ if v_kind not in ('human', 'agent') then
522
+ raise exception 'agent kind must be human or agent' using errcode = '22023';
523
+ end if;
524
+ if array_length(v_provider.allowed_agent_kinds, 1) is not null
525
+ and not (v_kind = any(v_provider.allowed_agent_kinds)) then
526
+ raise exception 'agent kind is outside the provider ceiling' using errcode = '42501';
527
+ end if;
528
+ if not (v_requested_scopes <@ v_provider.allowed_scopes)
529
+ or not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
530
+ raise exception 'requested scopes exceed the provider ceiling' using errcode = '42501';
531
+ end if;
532
+
533
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is not null and exists (
534
+ select 1 from agent_board.board_invite_tokens bit
535
+ join agent_board.fleet_onboarding_invite_bounds b on b.invite_id = bit.id
536
+ where bit.code = btrim(p_invite_code)
537
+ ) then
538
+ raise exception 'fleet invite requires atomic fleet onboarding' using errcode = '42501';
539
+ end if;
540
+
541
+
542
+ -- #194 LOCK ORDER -- BOARD BEFORE OWNER, and the reason is an FK this function
543
+ -- never names. The redemption below inserts board_memberships, which takes FOR
544
+ -- KEY SHARE on the invite's agent_board.boards row through
545
+ -- board_memberships_board_id_fkey. That acquisition used to land AFTER the
546
+ -- auth.users owner lock, inverting against enroll_federated_human and
547
+ -- onboard_federated_fleet_agent -- both of which take board -> owner -- and the
548
+ -- resulting cycle was reproduced as `deadlock detected ... while locking tuple
549
+ -- in relation "boards"`. Acquire the invite row and its board here, in the same
550
+ -- relative order _redeem_unbounded_invite_for_agent and
551
+ -- onboard_federated_fleet_agent take them (invite -> board), and in the SAME mode
552
+ -- the redemption itself uses, so the redemption below acquires nothing new. Absence is deliberately NOT fatal: a missing,
553
+ -- revoked, expired or exhausted code still falls through to redemption, which
554
+ -- stays the sole authority on invite usability (soft-honor contract).
555
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
556
+ select bit.board_id into v_invite_board_id
557
+ from agent_board.board_invite_tokens bit
558
+ where bit.code = nullif(btrim(coalesce(p_invite_code, '')), '')
559
+ for update;
560
+ if v_invite_board_id is not null then
561
+ perform 1 from agent_board.boards b
562
+ where b.id = v_invite_board_id
563
+ for key share;
564
+ end if;
565
+ end if;
566
+
567
+ perform pg_advisory_xact_lock(hashtextextended(v_fingerprint, 0));
568
+
569
+ select a.owner_user_id into v_owner_user_id
570
+ from agent_board.agent_signing_keys k
571
+ join agent_board.agents a on a.id = k.agent_id
572
+ where k.fingerprint = v_fingerprint;
573
+ v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
574
+ 'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32)
575
+ ));
576
+ perform 1 from auth.users u where u.id = v_owner_user_id for update;
577
+ if exists (
578
+ select 1 from agent_board.fleet_human_revocations r
579
+ where r.issuer = p_issuer and r.subject = p_subject
580
+ ) then
581
+ raise exception 'human owner has been revoked' using errcode = '42501';
582
+ end if;
583
+
584
+ -- #176 (i): the auth user the CALLER's verified (issuer, subject) derives to, by the
585
+ -- SAME one-way derivation _enroll_federated_identity_once step (7) used to mint the
586
+ -- owner. Resolved by a PURE LOOKUP against the deterministic email
587
+ -- _ensure_agent_auth_user builds -- calling that function here would MINT an owner row
588
+ -- for whatever principal a squat attempt claimed. Read-only: no FOR UPDATE, no row
589
+ -- lock, so the #194 board -> owner order above is untouched.
590
+ select u.id into v_derived_owner_user_id
591
+ from auth.users u
592
+ where lower(u.email) = format('agent+%s@agent-board.local',
593
+ 'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32))
594
+ limit 1;
595
+
596
+ select a.id as agent_id, a.auth_user_id, a.handle, a.agent_type, a.status,
597
+ a.metadata, a.owner_user_id, k.id as signing_key_id, k.key_id, k.key_version,
598
+ k.public_key, k.public_key_format, k.status as key_status, k.proof_state,
599
+ (select e.granted_scopes
600
+ from agent_board.enrollment_federation_audit e
601
+ where e.agent_id = a.id
602
+ and e.issuer = p_issuer
603
+ and e.subject = p_subject
604
+ and e.provider_id = v_provider.id) as granted_scopes
605
+ into v_existing
606
+ from agent_board.agent_signing_keys k
607
+ join agent_board.agents a on a.id = k.agent_id
608
+ where k.fingerprint = v_fingerprint
609
+ for update of a, k;
610
+
611
+ if found then
612
+ v_replay := v_existing.metadata #> '{federation,replay}';
613
+ v_creation_federation := v_existing.metadata #> '{federation}';
614
+
615
+ -- #176 OWNERSHIP, from evidence written at the ORIGINAL enrollment -- never from a
616
+ -- caller argument asserting who acted. (i) the one-way-derived owner, (ii) the
617
+ -- creation-time federation stamp, (iii) the original enrollment's audit row. All
618
+ -- three must agree, and the whole conjunction is coalesced to FALSE: any NULL here
619
+ -- (absent metadata, absent owner) means ownership is UNPROVEN, and unproven must
620
+ -- read as not-owned rather than leaving the `if not` below untaken.
621
+ v_owned_by_caller := coalesce(
622
+ v_creation_federation ->> 'issuer' = p_issuer
623
+ and v_creation_federation ->> 'subject' = p_subject
624
+ and v_creation_federation ->> 'providerId' = v_provider.id
625
+ and exists (
626
+ select 1
627
+ from agent_board.enrollment_federation_audit e
628
+ where e.agent_id = v_existing.agent_id
629
+ and e.issuer = p_issuer
630
+ and e.subject = p_subject
631
+ and e.provider_id = v_provider.id
632
+ )
633
+ -- (i) is REQUIRED WHENEVER IT EXISTS and is the one conjunct with an exemption,
634
+ -- so its claim is stated positively rather than left as "unless". A row whose
635
+ -- owner_user_id is NULL predates the owner model entirely (the atomic-fleet
636
+ -- backfill fixture is exactly that shape, and replaying such a row is released,
637
+ -- asserted behaviour): (i) is UNAVAILABLE there, not contradicted. It is also not
638
+ -- the binding that does the work. (ii) and (iii) already name the principal, and
639
+ -- reaching them at all requires a claim the edge function VERIFIED for that same
640
+ -- issuer and subject, so a null owner narrows corroboration without widening who
641
+ -- may resume. A NON-NULL owner that disagrees with the derived one is a hard
642
+ -- refusal, which is what keeps this from being a hole.
643
+ and (
644
+ v_existing.owner_user_id is null
645
+ or (v_derived_owner_user_id is not null
646
+ and v_existing.owner_user_id = v_derived_owner_user_id)
647
+ ), false);
648
+
649
+ -- (a) THE KEY-SQUAT this check exists to prevent, and the FIRST thing decided, so a
650
+ -- caller that does not own the key learns nothing else about it. No handle is named:
651
+ -- telling a squatter which agent holds the key it just probed is the leak this
652
+ -- ordering avoids. Everything below runs only for the proven owner.
653
+ if not v_owned_by_caller then
654
+ 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'
655
+ using errcode = '23505';
656
+ end if;
657
+
658
+ -- (b) the owner is asking to resume, but the identity is not in a resumable state.
659
+ -- Resumption returns an existing identity; it never revives a revoked, unproven or
660
+ -- drifted one, and it never invents an audit trail that is not there.
661
+ if v_existing.public_key is distinct from p_agent_public_key
662
+ or v_existing.public_key_format is distinct from p_agent_public_key_format
663
+ or v_existing.key_status <> 'active'
664
+ or v_existing.proof_state <> 'proven'
665
+ or v_existing.status <> 'active'
666
+ or v_existing.granted_scopes is null then
667
+ 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',
668
+ v_existing.handle using errcode = '23505';
669
+ end if;
670
+
671
+ -- (c) a STAMPED enrollment (20260901000100 and later) still holds its frozen identity
672
+ -- contract exactly as before -- the check is unchanged, only its message improves to
673
+ -- name the handle and say the enrollment already exists. An UNSTAMPED enrollment
674
+ -- (every identity created before 20260901000100, including the #176 victim) has no
675
+ -- frozen contract to check and resumes on the ownership evidence alone; that absence
676
+ -- used to be the raise that trapped it.
677
+ if v_replay is not null
678
+ and (v_replay ->> 'issuer' is distinct from p_issuer
679
+ or v_replay -> 'audience' is distinct from v_claim_audience
680
+ or v_replay ->> 'subject' is distinct from p_subject
681
+ or v_replay ->> 'requestedSlug' is distinct from v_slug
682
+ or v_replay ->> 'agentKind' is distinct from v_kind
683
+ or v_replay -> 'actorSub' is distinct from coalesce(to_jsonb(p_actor_sub), 'null'::jsonb)
684
+ or v_replay -> 'requestedScopes' is distinct from to_jsonb(v_requested_scopes)
685
+ or v_replay -> 'requestedBoardScopes' is distinct from to_jsonb(v_requested_board_scopes)
686
+ or v_replay ->> 'providerId' is distinct from v_provider.id) then
687
+ 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',
688
+ v_existing.handle using errcode = '23505';
689
+ end if;
690
+
691
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
692
+ begin
693
+ perform agent_board._redeem_invite_for_agent(btrim(p_invite_code), v_existing.agent_id);
694
+ v_invite_redeemed := true;
695
+ exception when others then
696
+ -- #194: a serialization failure is NOT "the invite was not redeemed".
697
+ -- Swallowing class 40 here reported enrollment SUCCESS to the caller with
698
+ -- invite_redeemed=false and no membership row, while the deadlock counter
699
+ -- ticked and no error reached the client. Re-raise the whole class
700
+ -- (40001 serialization_failure, 40P01 deadlock_detected, 40003, 40002)
701
+ -- and keep the soft-honor contract for every deterministic failure.
702
+ if left(sqlstate, 2) = '40' then
703
+ raise;
704
+ end if;
705
+ v_invite_redeemed := false;
706
+ end;
707
+ end if;
708
+
709
+ -- provider_id comes from STORED evidence, not the request: the replay stamp when
710
+ -- there is one, otherwise the creation-time federation object. The ownership gate
711
+ -- already pinned that value to v_provider.id, so the two agree by construction --
712
+ -- but reading it from the row keeps the returned receipt sourced where every other
713
+ -- returned field is.
714
+ return query select v_existing.agent_id, v_existing.auth_user_id,
715
+ coalesce(v_replay ->> 'providerId', v_creation_federation ->> 'providerId'), v_existing.signing_key_id,
716
+ v_existing.granted_scopes, v_existing.key_id, v_existing.key_version,
717
+ v_existing.handle, v_invite_redeemed;
718
+ return;
719
+ end if;
720
+
721
+ select * into v_created from agent_board._enroll_federated_identity_once(
722
+ p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
723
+ p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
724
+ p_pop_verified, p_actor_sub, p_agent_kind, v_requested_scopes,
725
+ v_requested_board_scopes, p_invite_code
726
+ );
727
+
728
+ update agent_board.agents a
729
+ set metadata = jsonb_set(a.metadata, '{federation,replay}', jsonb_build_object(
730
+ 'issuer', p_issuer, 'audience', v_claim_audience, 'subject', p_subject,
731
+ 'providerId', v_created.provider_id,
732
+ 'actorSub', coalesce(to_jsonb(p_actor_sub), 'null'::jsonb),
733
+ 'requestedSlug', v_slug, 'agentKind', v_kind,
734
+ 'requestedScopes', to_jsonb(v_requested_scopes),
735
+ 'requestedBoardScopes', to_jsonb(v_requested_board_scopes)
736
+ ), true)
737
+ where a.id = v_created.agent_id;
738
+
739
+ return query select v_created.agent_id, v_created.auth_user_id,
740
+ v_created.provider_id, v_created.signing_key_id, v_created.granted_scopes,
741
+ v_created.key_id, v_created.key_version, v_created.agent_handle,
742
+ v_created.invite_redeemed;
743
+ end;
744
+ $$;
745
+
746
+ comment on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) is
747
+ 'Service-role federated enrollment with exact fingerprint replay AND owner-scoped resumption (#176). An already-enrolled key re-presented by the principal that OWNS it RESUMES: the existing agent identity is returned so a client whose post-enroll provisioning failed can finish, rather than being refused forever. Ownership is decided against evidence written at the ORIGINAL enrollment: the creation-time federation stamp (issuer/subject/providerId) and the enrollment_federation_audit row are both REQUIRED, and agents.owner_user_id must equal the auth user derived from sha256(issuer|subject) whenever that column is populated (a null owner_user_id predates the owner model and is the one exemption; such a row still resumes only for the principal the stamp and audit row name). The caller''s (issuer, subject) is trusted only because it is equality-gated against the verified claim projection above; the conjunction is coalesced to false so unproven ownership is not-owned. A key held by a DIFFERENT principal still raises 23505 and the message names no handle. Stable owner, actor, slug, kind, and scope inputs stay frozen for a STAMPED enrollment (unchanged check, message now names the handle); an enrollment predating the stamp has no frozen contract and resumes on the ownership evidence alone, returning its ORIGINAL granted_scopes. Rotating presentation claims are revalidated upstream but are not equality-gated. Fleet-bounded codes are rejected before writes. Takes the invite row and its board (invite -> board) BEFORE the auth.users owner lock so every federated write route agrees on board -> owner (#194); the resumption additions are read-only and acquire no lock. A serialization failure or deadlock during redemption propagates instead of being reported as invite_redeemed=false.';
748
+ revoke all on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) from public, anon, authenticated;
749
+ grant execute on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) to service_role;