@consiliency/agent-board-schema 1.54.0 → 1.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/fixtures/already-applied.json +8 -0
  2. package/migrations/20261005000100_agent_board_enroll_token_single_use.sql +886 -0
  3. package/migrations/20261006000100_agent_board_room_roster_active_first.sql +118 -0
  4. package/package.json +1 -1
  5. package/schema-manifest.json +21 -3
  6. package/src/tooling.test.mjs +7 -2
  7. package/tests/agent_board_atomic_fleet_onboarding.test.sql +35 -35
  8. package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +5 -5
  9. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +32 -32
  10. package/tests/agent_board_enroll_federated_identity.test.sql +11 -10
  11. package/tests/agent_board_enroll_pop_binding.test.sql +12 -12
  12. package/tests/agent_board_enroll_resumption.test.sql +1 -1
  13. package/tests/agent_board_enroll_token_single_use.test.sql +538 -0
  14. package/tests/agent_board_enrollment_audit_content.test.sql +3 -3
  15. package/tests/agent_board_federated_lock_order_class.test.sql +5 -5
  16. package/tests/agent_board_fedsession.test.sql +1 -1
  17. package/tests/agent_board_fk_lock_edges.test.sql +23 -0
  18. package/tests/agent_board_identity_providers.test.sql +3 -2
  19. package/tests/agent_board_membership_current_row_class.test.sql +460 -0
  20. package/tests/agent_board_portal_offline_invariant.test.sql +1 -1
  21. package/tests/agent_board_room_roster.test.sql +262 -24
  22. package/tests/agent_board_roomlink.test.sql +2 -2
  23. package/tests/agent_board_rowid.test.sql +15 -2
  24. package/tests/agent_board_write_enforcement_class.test.sql +5 -2
  25. package/tests/catalog/function_execute_privileges.json +12 -3
  26. package/tests/catalog/p_metadata_parameters.json +6 -2
  27. package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +1 -1
  28. package/tests/isolation/expected/atomic_fleet_authority_races.out +8 -8
  29. package/tests/isolation/expected/atomic_fleet_binding_revoke.out +2 -2
  30. package/tests/isolation/expected/atomic_fleet_board_archive.out +2 -2
  31. package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +2 -2
  32. package/tests/isolation/expected/atomic_fleet_identity_disposition.out +2 -2
  33. package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +22 -22
  34. package/tests/isolation/expected/atomic_fleet_onboarding.out +2 -2
  35. package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +1 -1
  36. package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +1 -1
  37. package/tests/isolation/expected/enroll_token_fleet_abort.out +81 -0
  38. package/tests/isolation/expected/enroll_token_pk_backstop.out +36 -0
  39. package/tests/isolation/expected/enroll_token_placement.out +86 -0
  40. package/tests/isolation/expected/enroll_token_rollback_unstrands.out +59 -0
  41. package/tests/isolation/expected/enroll_token_route_race_fleet_first.out +72 -0
  42. package/tests/isolation/expected/enroll_token_route_race_identity_first.out +72 -0
  43. package/tests/isolation/expected/enroll_token_same_key_fleet_then_identity.out +73 -0
  44. package/tests/isolation/expected/enroll_token_same_key_identity_then_fleet.out +73 -0
  45. package/tests/isolation/expected/enroll_token_same_key_resume.out +59 -0
  46. package/tests/isolation/expected/enroll_token_same_token_race.out +58 -0
  47. package/tests/isolation/expected/onboarding_ceiling_lock_order.out +2 -2
  48. package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +1 -1
  49. package/tests/isolation/specs/atomic_fleet_authority_races.spec +2 -2
  50. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +2 -2
  51. package/tests/isolation/specs/atomic_fleet_board_archive.spec +2 -2
  52. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +2 -2
  53. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +2 -2
  54. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +2 -2
  55. package/tests/isolation/specs/atomic_fleet_onboarding.spec +2 -2
  56. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +2 -2
  57. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +1 -1
  58. package/tests/isolation/specs/enroll_token_fleet_abort.spec +138 -0
  59. package/tests/isolation/specs/enroll_token_pk_backstop.spec +37 -0
  60. package/tests/isolation/specs/enroll_token_placement.spec +136 -0
  61. package/tests/isolation/specs/enroll_token_rollback_unstrands.spec +71 -0
  62. package/tests/isolation/specs/enroll_token_route_race_fleet_first.spec +119 -0
  63. package/tests/isolation/specs/enroll_token_route_race_identity_first.spec +120 -0
  64. package/tests/isolation/specs/enroll_token_same_key_fleet_then_identity.spec +112 -0
  65. package/tests/isolation/specs/enroll_token_same_key_identity_then_fleet.spec +113 -0
  66. package/tests/isolation/specs/enroll_token_same_key_resume.spec +70 -0
  67. package/tests/isolation/specs/enroll_token_same_token_race.spec +80 -0
  68. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +1 -1
@@ -0,0 +1,886 @@
1
+ -- Consiliency/message-board#521: a verified Portal access token enrolls AT MOST ONE key.
2
+ --
3
+ -- WHAT WAS WRONG. /enroll RS256-verifies a Portal access token and checks an Ed25519
4
+ -- proof-of-possession: the NEW key's signature over the token string. Nothing recorded
5
+ -- that a token had been used. The PoP does not stop reuse -- whoever holds the token can
6
+ -- generate a fresh keypair and sign the same token again -- and the existing replay
7
+ -- mechanism (enroll_federated_identity's #176 resumption) is keyed on the KEY, so a fresh
8
+ -- key per request walks straight past it. One held token therefore enrolled an unbounded
9
+ -- number of agents under the same human until it expired: an undesigned delegation path
10
+ -- with none of the bounds the designed one (mint_delegated_child, capped by
11
+ -- delegation_policies) carries.
12
+ --
13
+ -- THE INVARIANT. A verified token binds to at most one agent key, created OR resumed,
14
+ -- enforced atomically in the database:
15
+ --
16
+ -- * agent_board.enroll_token_uses records (token_sha256 -> key fingerprint). The
17
+ -- fingerprint is known before any write, so no reservation state is needed and the
18
+ -- agent is recoverable through agent_signing_keys.fingerprint. No FK: revoking or
19
+ -- deleting a key must never un-consume a token.
20
+ -- * agent_board._enroll_token_bind is the ONLY writer. Same token + same fingerprint
21
+ -- is a no-op and the caller continues into its UNCHANGED body; same token + a
22
+ -- different fingerprint raises SQLSTATE MBT01, which the Edge Function maps to
23
+ -- 409 token_already_used. MBT01 is outside every SQLSTATE this schema already uses
24
+ -- (23505 already means "key enrolled / not resumable / frozen contract"), and it is
25
+ -- not PTxxx, which PostgREST would turn into an HTTP status.
26
+ -- * It is called from EXACTLY ONE site: enroll_federated_identity, immediately after the
27
+ -- owner lock and the human-revocation gate and BEFORE the found/not-found split, so
28
+ -- it covers creation (-> _enroll_federated_identity_once) AND resumption. There is no
29
+ -- early return: a same-key retry falls through into the existing (a)/(b)/(c) gates
30
+ -- and redemption exactly as before. onboard_federated_fleet_agent reaches it through
31
+ -- its nested enroll_federated_identity call and only forwards the two arguments; a
32
+ -- second call site there would be a no-op under fall-through, and a mutation removing
33
+ -- either site could then redden nothing.
34
+ --
35
+ -- WHY NO NEW LOCK. Same token => same verified (issuer, subject) => same derived owner O,
36
+ -- and every path that reaches the bind already holds O FOR UPDATE (the identity route at
37
+ -- its owner lock; the fleet route at its own owner lock before the nested call, then
38
+ -- re-entrantly). The owner is coalesce(existing key's owner, derived owner): that is O for
39
+ -- a new key, a key the caller owns, and a legacy null-owner key. The only other case is a
40
+ -- key owned by another principal, which raises at resumption gate (a) in the same
41
+ -- transaction. Two same-token requests are therefore already serialised by an existing
42
+ -- row lock before the bind, and the second one's bind statement sees the first one's
43
+ -- committed row. The primary key is the backstop: were that argument ever broken, the
44
+ -- second insert waits on the first's uncommitted key and fails 23505 -- closed, loud.
45
+ -- Placing the bind ABOVE the owner lock would deadlock the identity route against the
46
+ -- fleet route (fleet holds O, then reaches the token; identity would hold the token, then
47
+ -- wait for O). Placement after the owner lock is what makes the loser a clean MBT01.
48
+ --
49
+ -- THE TOKEN IDENTITY is lowercase hex sha256 over the RS256 SIGNING INPUT
50
+ -- (header "." payload), computed by the Edge Function, which also refuses non-canonical
51
+ -- token encodings. RS256 is deterministic and the signing input is fixed by the
52
+ -- signature, so the hash is invariant under any re-encoding of the signature segment.
53
+ -- The expiry is the token's verified `exp`.
54
+ --
55
+ -- BOUNDED TABLE. Each bind prunes up to 50 rows whose token expired more than an hour
56
+ -- ago. Deleting a row is safe ONLY because an expired token can never pass again: the
57
+ -- Edge Function rejects exp <= now, and the bind itself refuses p_token_expires_at <=
58
+ -- clock_timestamp(). The hour absorbs Edge-Function/database clock skew.
59
+ --
60
+ -- BOTH SIDES READ clock_timestamp(), NEVER now(). now() is fixed at TRANSACTION START, and a
61
+ -- request can start, wait on the owner lock for as long as another transaction holds it, and
62
+ -- only then bind. With now() on the refusal, a request that started before exp and waited
63
+ -- past exp + 1 hour while another enrollment pruned the token's row would find the row gone,
64
+ -- pass its stale expiry check, and bind a SECOND key. The bind runs after the owner lock is
65
+ -- held, so clock_timestamp() there is the time the decision is actually made; and the prune
66
+ -- deletes only rows with exp < clock_timestamp() - 1 hour, each of which the refusal above
67
+ -- already rejects at any later clock_timestamp(). (Review finding, #524 round 1.)
68
+ --
69
+ -- SIGNATURES. enroll_federated_identity and onboard_federated_fleet_agent gain
70
+ -- (p_token_sha256 text, p_token_expires_at timestamptz) immediately after p_pop_verified,
71
+ -- with NO defaults, and the old signatures are DROPPED -- `create or replace` with a
72
+ -- changed argument list would leave the unguarded overload callable. An old Edge Function
73
+ -- calling with the old named arguments therefore resolves to no function (PGRST202) and
74
+ -- fails closed rather than falling through to an unguarded path.
75
+ -- _enroll_federated_identity_once is unchanged in signature and body; its service_role
76
+ -- EXECUTE grant is revoked. service_role has no direct INSERT on agent_signing_keys or
77
+ -- agents, so that grant was a real, unguarded creation path, and its only caller is a
78
+ -- postgres-owned definer that needs no grant.
79
+ --
80
+ -- DROP DISCARDS THE ACL AND THE COMMENT, and a recreated function grants EXECUTE to
81
+ -- PUBLIC by default, so every revoke/grant/comment is restated below in this file.
82
+ --
83
+ -- THIS MIGRATION MUST BE APPLIED INSIDE ONE TRANSACTION. The live apply path is one
84
+ -- Management-API request per migration, which is one implicit transaction, so the drop,
85
+ -- the create, the ACL and the comment commit together and no caller ever sees a missing
86
+ -- function. Applied statement-at-a-time (the local replay harness) there is a
87
+ -- drop-to-create gap on a scratch database; that gap is an AVAILABILITY gap only --
88
+ -- both recreated bodies raise 42501 on `not _is_service_role()` before anything else, so
89
+ -- the transient PUBLIC EXECUTE is not exploitable on its own.
90
+ --
91
+ -- The function bodies below are pg_get_functiondef of the live definers on a fresh bundle
92
+ -- built from this tree (20260916000100, written in UPPERCASE), NOT copies from a
93
+ -- migration file, plus exactly: the two parameters, the bind call (enroll_federated_identity
94
+ -- only), and onboard_federated_fleet_agent forwarding the two arguments to its nested call.
95
+
96
+ create table if not exists agent_board.enroll_token_uses (
97
+ token_sha256 text primary key check (token_sha256 ~ '^[0-9a-f]{64}$'),
98
+ agent_public_key_fingerprint text not null,
99
+ token_expires_at timestamptz not null,
100
+ created_at timestamptz not null default now()
101
+ );
102
+
103
+ create index if not exists enroll_token_uses_token_expires_at_idx
104
+ on agent_board.enroll_token_uses (token_expires_at);
105
+
106
+ alter table agent_board.enroll_token_uses enable row level security;
107
+ revoke all on table agent_board.enroll_token_uses from public, anon, authenticated, service_role;
108
+
109
+ comment on table agent_board.enroll_token_uses is
110
+ 'One row per verified Portal access token that has enrolled or resumed a key (#521): token_sha256 is lowercase hex sha256 of the RS256 signing input (header.payload), bound to exactly one agent_public_key_fingerprint. Written ONLY by _enroll_token_bind, called ONLY from enroll_federated_identity. No FK to keys or agents by design: revoking or deleting a key must not un-consume a token. RLS on with zero policies and no API-role grants. Rows are pruned by _enroll_token_bind once token_expires_at is more than an hour past, which is safe only because an expired token can never pass /enroll or the bind again.';
111
+
112
+ create or replace function agent_board._enroll_token_bind(
113
+ p_token_sha256 text,
114
+ p_token_expires_at timestamptz,
115
+ p_fingerprint text
116
+ )
117
+ returns void
118
+ language plpgsql
119
+ security definer
120
+ set search_path = agent_board, public
121
+ as $$
122
+ declare
123
+ v_bound_fingerprint text;
124
+ begin
125
+ -- Not reachable through the API once the revoke below applies. This guard covers the one
126
+ -- gap the revoke cannot: applied statement-at-a-time (the local replay harness), this
127
+ -- CREATE commits with PUBLIC EXECUTE before the next statement revokes it.
128
+ if not agent_board._is_service_role() then
129
+ raise exception 'service_role required for _enroll_token_bind' using errcode = '42501';
130
+ end if;
131
+ if p_token_sha256 is null or p_token_sha256 !~ '^[0-9a-f]{64}$' then
132
+ raise exception 'enroll token hash is missing or malformed' using errcode = '22023';
133
+ end if;
134
+ if p_token_expires_at is null then
135
+ raise exception 'enroll token expiry is required' using errcode = '22023';
136
+ end if;
137
+ -- Defence in depth, and what makes the prune below safe from the database side: a
138
+ -- token whose row could already have been pruned is refused here, not re-bound.
139
+ -- clock_timestamp(), not now(): this runs after the caller's owner-lock WAIT, and now()
140
+ -- is the transaction's START time (see the header).
141
+ if p_token_expires_at <= clock_timestamp() then
142
+ raise exception 'enroll token has expired' using errcode = '22023';
143
+ end if;
144
+ if p_fingerprint is null then
145
+ raise exception 'agent public key fingerprint is required' using errcode = '22023';
146
+ end if;
147
+
148
+ select u.agent_public_key_fingerprint into v_bound_fingerprint
149
+ from agent_board.enroll_token_uses u
150
+ where u.token_sha256 = p_token_sha256;
151
+
152
+ if found then
153
+ -- Same key: a no-op. The caller continues into its unchanged body, which re-runs
154
+ -- every existing gate; nothing returns early from here.
155
+ if v_bound_fingerprint is distinct from p_fingerprint then
156
+ raise exception 'enroll token already bound to a different key' using errcode = 'MBT01';
157
+ end if;
158
+ else
159
+ -- A plain insert, deliberately WITHOUT `on conflict`. The caller's owner lock
160
+ -- serialises same-token requests, so a conflict here needs two requests holding
161
+ -- DIFFERENT owner locks -- reachable only when one presents a key owned by another
162
+ -- principal, which resumption gate (a) refuses in the same transaction anyway. When
163
+ -- it does happen it must surface as a loud 23505, never pass silently. It is not
164
+ -- claimed to be impossible.
165
+ insert into agent_board.enroll_token_uses (token_sha256, agent_public_key_fingerprint, token_expires_at)
166
+ values (p_token_sha256, p_fingerprint, p_token_expires_at);
167
+ end if;
168
+
169
+ -- Bounded prune. DELETE has no LIMIT, so it is keyed through the primary key;
170
+ -- `skip locked` keeps concurrent enrollments from queueing on each other's cleanup.
171
+ delete from agent_board.enroll_token_uses d
172
+ where d.token_sha256 in (
173
+ select u.token_sha256
174
+ from agent_board.enroll_token_uses u
175
+ where u.token_expires_at < clock_timestamp() - interval '1 hour'
176
+ order by u.token_expires_at
177
+ limit 50
178
+ for update skip locked
179
+ );
180
+ end;
181
+ $$;
182
+
183
+ revoke all on function agent_board._enroll_token_bind(text, timestamptz, text) from public, anon, authenticated, service_role;
184
+
185
+ comment on function agent_board._enroll_token_bind(text, timestamptz, text) is
186
+ 'Binds a verified Portal access token (lowercase hex sha256 of its RS256 signing input) to exactly one agent key fingerprint (#521). Same token + same fingerprint: no-op, the caller continues into its unchanged body. Same token + a different fingerprint: raises SQLSTATE MBT01 (the Edge Function maps it to 409 token_already_used). Not found: a plain insert WITHOUT on conflict -- the caller''s owner lock serialises same-token requests, and the primary key is the fail-closed backstop (23505). Raises 22023 on a null or malformed hash, a null expiry, an expiry <= clock_timestamp() (never now(): the bind runs after an owner-lock wait), or a null fingerprint; raises 42501 unless the caller is service_role. Prunes up to 50 rows expired more than an hour before clock_timestamp(). Called ONLY from enroll_federated_identity, after its owner lock and revocation gate; not EXECUTE-able by any API role.';
187
+
188
+ -- ---------------------------------------------------------------------------
189
+ -- Drop the token-less signatures. Nothing may keep resolving to them.
190
+ -- ---------------------------------------------------------------------------
191
+ drop function agent_board.onboard_federated_fleet_agent(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text, uuid, uuid, text, text, text, text, text, text, text);
192
+ drop function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text);
193
+
194
+ 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_token_sha256 text, p_token_expires_at timestamp with time zone, 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)
195
+ 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)
196
+ LANGUAGE plpgsql
197
+ SECURITY DEFINER
198
+ SET search_path TO 'agent_board', 'public'
199
+ AS $function$
200
+ #variable_conflict use_column
201
+ declare
202
+ v_fingerprint text;
203
+ v_existing record;
204
+ v_created record;
205
+ v_replay jsonb;
206
+ v_invite_redeemed boolean := false;
207
+ v_slug text;
208
+ v_kind text := lower(btrim(coalesce(p_agent_kind, 'agent')));
209
+ v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
210
+ v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
211
+ v_provider agent_board.identity_providers%rowtype;
212
+ v_invite_board_id uuid;
213
+ v_claim_audience jsonb;
214
+ v_owner_user_id uuid;
215
+ -- #176 resumption. `v_derived_owner_user_id` is the auth user the CALLER's verified
216
+ -- (issuer, subject) derives to; `v_creation_federation` is the federation object the
217
+ -- ORIGINAL enrollment stamped on the agent; `v_owned_by_caller` is the conjunction of
218
+ -- the three stored-evidence tests, coalesced so missing evidence reads as NOT-owned.
219
+ v_derived_owner_user_id uuid;
220
+ v_creation_federation jsonb;
221
+ v_owned_by_caller boolean;
222
+ begin
223
+ if not agent_board._is_service_role() then
224
+ raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
225
+ end if;
226
+ if p_pop_verified is not true then
227
+ raise exception 'proof-of-possession was not verified' using errcode = '42501';
228
+ end if;
229
+ if p_subject is null then
230
+ raise exception 'verified subject is required' using errcode = '22023';
231
+ end if;
232
+ if p_human_identity_claim is null then
233
+ raise exception 'human identity claim is required' using errcode = '22023';
234
+ end if;
235
+
236
+ select * into v_provider from agent_board.identity_providers ip
237
+ where ip.issuer = p_issuer and ip.audience = p_audience limit 1;
238
+ if v_provider.id is null then
239
+ raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
240
+ end if;
241
+
242
+ v_claim_audience := agent_board._normalized_federation_audience(p_human_identity_claim);
243
+ if p_human_identity_claim ->> 'iss' is distinct from p_issuer
244
+ or p_human_identity_claim ->> v_provider.subject_claim is distinct from p_subject
245
+ or v_claim_audience is null
246
+ or not (v_claim_audience ? p_audience) then
247
+ raise exception 'verified stable claim projection does not match enrollment inputs' using errcode = '22023';
248
+ end if;
249
+ if p_agent_public_key_format is distinct from 'raw_ed25519_base64url'
250
+ or p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
251
+ raise exception 'malformed agent public key' using errcode = '22023';
252
+ end if;
253
+ v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
254
+ if p_agent_public_key_fingerprint is distinct from v_fingerprint then
255
+ raise exception 'agent public key fingerprint mismatch' using errcode = '22023';
256
+ end if;
257
+ v_slug := agent_board._normalize_provision_slug(p_slug);
258
+
259
+ if not v_provider.supported or not v_provider.policy_approved then
260
+ raise exception 'identity provider is not approved' using errcode = '42501';
261
+ end if;
262
+ if v_kind = 'system' then
263
+ raise exception 'the system kind cannot be federated' using errcode = '42501';
264
+ end if;
265
+ if v_kind not in ('human', 'agent') then
266
+ raise exception 'agent kind must be human or agent' using errcode = '22023';
267
+ end if;
268
+ if array_length(v_provider.allowed_agent_kinds, 1) is not null
269
+ and not (v_kind = any(v_provider.allowed_agent_kinds)) then
270
+ raise exception 'agent kind is outside the provider ceiling' using errcode = '42501';
271
+ end if;
272
+ if not (v_requested_scopes <@ v_provider.allowed_scopes)
273
+ or not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
274
+ raise exception 'requested scopes exceed the provider ceiling' using errcode = '42501';
275
+ end if;
276
+
277
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is not null and exists (
278
+ select 1 from agent_board.board_invite_tokens bit
279
+ join agent_board.fleet_onboarding_invite_bounds b on b.invite_id = bit.id
280
+ where bit.code = btrim(p_invite_code)
281
+ ) then
282
+ raise exception 'fleet invite requires atomic fleet onboarding' using errcode = '42501';
283
+ end if;
284
+
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 nullif(btrim(coalesce(p_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 = nullif(btrim(coalesce(p_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
+ perform pg_advisory_xact_lock(hashtextextended(v_fingerprint, 0));
312
+
313
+ select a.owner_user_id into v_owner_user_id
314
+ from agent_board.agent_signing_keys k
315
+ join agent_board.agents a on a.id = k.agent_id
316
+ where k.fingerprint = v_fingerprint;
317
+ v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
318
+ agent_board._federated_identity_slug(p_issuer, p_subject)
319
+ ));
320
+ perform 1 from auth.users u where u.id = v_owner_user_id for update;
321
+ if exists (
322
+ select 1 from agent_board.fleet_human_revocations r
323
+ where r.issuer = p_issuer and r.subject = p_subject
324
+ ) then
325
+ raise exception 'human owner has been revoked' using errcode = '42501';
326
+ end if;
327
+
328
+ -- #521 THE TOKEN BINDING -- one site, here, for BOTH branches below. The owner lock
329
+ -- above already serialises every request carrying this token (same token => same
330
+ -- verified (issuer, subject) => same owner), so the bind needs no lock of its own; and
331
+ -- it sits AFTER that lock deliberately -- above it, the identity route would hold the
332
+ -- token and wait for the owner while the fleet route holds the owner and waits for the
333
+ -- token. A different key under a used token raises MBT01 here. The same key falls
334
+ -- THROUGH: nothing returns early, so resumption gates (a)/(b)/(c) and redemption below
335
+ -- run exactly as they did before this call existed.
336
+ perform agent_board._enroll_token_bind(p_token_sha256, p_token_expires_at, v_fingerprint);
337
+
338
+ -- #176 (i): the auth user the CALLER's verified (issuer, subject) derives to, by the
339
+ -- SAME derivation _enroll_federated_identity_once step (7) used to mint the owner --
340
+ -- now literally the same function, agent_board._federated_identity_slug (#246), not a
341
+ -- second copy of the expression. Resolved by a PURE LOOKUP against the deterministic
342
+ -- email _ensure_agent_auth_user builds -- calling THAT function here would MINT an owner
343
+ -- row for whatever principal a squat attempt claimed. The slug helper is pure and mints
344
+ -- nothing, so computing the slug here is safe; only _ensure_agent_auth_user writes. Read-only: no FOR UPDATE, no row
345
+ -- lock, so the #194 board -> owner order above is untouched.
346
+ select u.id into v_derived_owner_user_id
347
+ from auth.users u
348
+ where lower(u.email) = format('agent+%s@agent-board.local',
349
+ agent_board._federated_identity_slug(p_issuer, p_subject))
350
+ limit 1;
351
+
352
+ select a.id as agent_id, a.auth_user_id, a.handle, a.agent_type, a.status,
353
+ a.metadata, a.owner_user_id, k.id as signing_key_id, k.key_id, k.key_version,
354
+ k.public_key, k.public_key_format, k.status as key_status, k.proof_state,
355
+ (select e.granted_scopes
356
+ from agent_board.enrollment_federation_audit e
357
+ where e.agent_id = a.id
358
+ and e.issuer = p_issuer
359
+ and e.subject = p_subject
360
+ and e.provider_id = v_provider.id) as granted_scopes
361
+ into v_existing
362
+ from agent_board.agent_signing_keys k
363
+ join agent_board.agents a on a.id = k.agent_id
364
+ where k.fingerprint = v_fingerprint
365
+ for update of a, k;
366
+
367
+ if found then
368
+ v_replay := v_existing.metadata #> '{federation,replay}';
369
+ v_creation_federation := v_existing.metadata #> '{federation}';
370
+
371
+ -- #176 OWNERSHIP, from evidence written at the ORIGINAL enrollment -- never from a
372
+ -- caller argument asserting who acted. (i) the one-way-derived owner, (ii) the
373
+ -- creation-time federation stamp, (iii) the original enrollment's audit row. All
374
+ -- three must agree, and the whole conjunction is coalesced to FALSE: any NULL here
375
+ -- (absent metadata, absent owner) means ownership is UNPROVEN, and unproven must
376
+ -- read as not-owned rather than leaving the `if not` below untaken.
377
+ v_owned_by_caller := coalesce(
378
+ v_creation_federation ->> 'issuer' = p_issuer
379
+ and v_creation_federation ->> 'subject' = p_subject
380
+ and v_creation_federation ->> 'providerId' = v_provider.id
381
+ and exists (
382
+ select 1
383
+ from agent_board.enrollment_federation_audit e
384
+ where e.agent_id = v_existing.agent_id
385
+ and e.issuer = p_issuer
386
+ and e.subject = p_subject
387
+ and e.provider_id = v_provider.id
388
+ )
389
+ -- (i) is REQUIRED WHENEVER IT EXISTS and is the one conjunct with an exemption,
390
+ -- so its claim is stated positively rather than left as "unless". A row whose
391
+ -- owner_user_id is NULL predates the owner model entirely (the atomic-fleet
392
+ -- backfill fixture is exactly that shape, and replaying such a row is released,
393
+ -- asserted behaviour): (i) is UNAVAILABLE there, not contradicted. It is also not
394
+ -- the binding that does the work. (ii) and (iii) already name the principal, and
395
+ -- reaching them at all requires a claim the edge function VERIFIED for that same
396
+ -- issuer and subject, so a null owner narrows corroboration without widening who
397
+ -- may resume. A NON-NULL owner that disagrees with the derived one is a hard
398
+ -- refusal, which is what keeps this from being a hole.
399
+ and (
400
+ v_existing.owner_user_id is null
401
+ or (v_derived_owner_user_id is not null
402
+ and v_existing.owner_user_id = v_derived_owner_user_id)
403
+ ), false);
404
+
405
+ -- (a) THE KEY-SQUAT this check exists to prevent, and the FIRST thing decided, so a
406
+ -- caller that does not own the key learns nothing else about it. No handle is named:
407
+ -- telling a squatter which agent holds the key it just probed is the leak this
408
+ -- ordering avoids. Everything below runs only for the proven owner.
409
+ if not v_owned_by_caller then
410
+ 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'
411
+ using errcode = '23505';
412
+ end if;
413
+
414
+ -- (b) the owner is asking to resume, but the identity is not in a resumable state.
415
+ -- Resumption returns an existing identity; it never revives a revoked, unproven or
416
+ -- drifted one, and it never invents an audit trail that is not there.
417
+ if v_existing.public_key is distinct from p_agent_public_key
418
+ or v_existing.public_key_format is distinct from p_agent_public_key_format
419
+ or v_existing.key_status <> 'active'
420
+ or v_existing.proof_state <> 'proven'
421
+ or v_existing.status <> 'active'
422
+ or v_existing.granted_scopes is null then
423
+ 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',
424
+ v_existing.handle using errcode = '23505';
425
+ end if;
426
+
427
+ -- (c) a STAMPED enrollment (20260901000100 and later) still holds its frozen identity
428
+ -- contract exactly as before -- the check is unchanged, only its message improves to
429
+ -- name the handle and say the enrollment already exists. An UNSTAMPED enrollment
430
+ -- (every identity created before 20260901000100, including the #176 victim) has no
431
+ -- frozen contract to check and resumes on the ownership evidence alone; that absence
432
+ -- used to be the raise that trapped it.
433
+ if v_replay is not null
434
+ and (v_replay ->> 'issuer' is distinct from p_issuer
435
+ or v_replay -> 'audience' is distinct from v_claim_audience
436
+ or v_replay ->> 'subject' is distinct from p_subject
437
+ or v_replay ->> 'requestedSlug' is distinct from v_slug
438
+ or v_replay ->> 'agentKind' is distinct from v_kind
439
+ or v_replay -> 'actorSub' is distinct from coalesce(to_jsonb(p_actor_sub), 'null'::jsonb)
440
+ or v_replay -> 'requestedScopes' is distinct from to_jsonb(v_requested_scopes)
441
+ or v_replay -> 'requestedBoardScopes' is distinct from to_jsonb(v_requested_board_scopes)
442
+ or v_replay ->> 'providerId' is distinct from v_provider.id) then
443
+ 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',
444
+ v_existing.handle using errcode = '23505';
445
+ end if;
446
+
447
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
448
+ begin
449
+ perform agent_board._redeem_invite_for_agent(btrim(p_invite_code), v_existing.agent_id);
450
+ v_invite_redeemed := true;
451
+ exception when others then
452
+ -- #194: a serialization failure is NOT "the invite was not redeemed".
453
+ -- Swallowing class 40 here reported enrollment SUCCESS to the caller with
454
+ -- invite_redeemed=false and no membership row, while the deadlock counter
455
+ -- ticked and no error reached the client. Re-raise the whole class
456
+ -- (40001 serialization_failure, 40P01 deadlock_detected, 40003, 40002)
457
+ -- and keep the soft-honor contract for every deterministic failure.
458
+ if left(sqlstate, 2) = '40' then
459
+ raise;
460
+ end if;
461
+ v_invite_redeemed := false;
462
+ end;
463
+ end if;
464
+
465
+ -- provider_id comes from STORED evidence, not the request: the replay stamp when
466
+ -- there is one, otherwise the creation-time federation object. The ownership gate
467
+ -- already pinned that value to v_provider.id, so the two agree by construction --
468
+ -- but reading it from the row keeps the returned receipt sourced where every other
469
+ -- returned field is.
470
+ return query select v_existing.agent_id, v_existing.auth_user_id,
471
+ coalesce(v_replay ->> 'providerId', v_creation_federation ->> 'providerId'), v_existing.signing_key_id,
472
+ v_existing.granted_scopes, v_existing.key_id, v_existing.key_version,
473
+ v_existing.handle, v_invite_redeemed;
474
+ return;
475
+ end if;
476
+
477
+ select * into v_created from agent_board._enroll_federated_identity_once(
478
+ p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
479
+ p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
480
+ p_pop_verified, p_actor_sub, p_agent_kind, v_requested_scopes,
481
+ v_requested_board_scopes, p_invite_code
482
+ );
483
+
484
+ update agent_board.agents a
485
+ set metadata = jsonb_set(a.metadata, '{federation,replay}', jsonb_build_object(
486
+ 'issuer', p_issuer, 'audience', v_claim_audience, 'subject', p_subject,
487
+ 'providerId', v_created.provider_id,
488
+ 'actorSub', coalesce(to_jsonb(p_actor_sub), 'null'::jsonb),
489
+ 'requestedSlug', v_slug, 'agentKind', v_kind,
490
+ 'requestedScopes', to_jsonb(v_requested_scopes),
491
+ 'requestedBoardScopes', to_jsonb(v_requested_board_scopes)
492
+ ), true)
493
+ where a.id = v_created.agent_id;
494
+
495
+ return query select v_created.agent_id, v_created.auth_user_id,
496
+ v_created.provider_id, v_created.signing_key_id, v_created.granted_scopes,
497
+ v_created.key_id, v_created.key_version, v_created.agent_handle,
498
+ v_created.invite_redeemed;
499
+ end;
500
+ $function$;
501
+
502
+ 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_token_sha256 text, p_token_expires_at timestamp with time zone, 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)
503
+ 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)
504
+ LANGUAGE plpgsql
505
+ SECURITY DEFINER
506
+ SET search_path TO 'agent_board', 'public'
507
+ AS $function$
508
+ #variable_conflict use_column
509
+ declare
510
+ v_inv agent_board.board_invite_tokens%rowtype;
511
+ v_bounds agent_board.fleet_onboarding_invite_bounds%rowtype;
512
+ v_board agent_board.boards%rowtype;
513
+ v_machine agent_board.fleet_machines%rowtype;
514
+ v_domain agent_board.fleet_trust_domains%rowtype;
515
+ v_enroll record;
516
+ v_membership record;
517
+ v_harness agent_board.fleet_harness_registrations%rowtype;
518
+ v_repo agent_board.fleet_repo_scopes%rowtype;
519
+ v_binding agent_board.fleet_machine_agents%rowtype;
520
+ v_kind text := lower(btrim(coalesce(p_harness_kind, '')));
521
+ v_label text := btrim(coalesce(p_harness_label, ''));
522
+ v_repo_slug text := btrim(coalesce(p_repo_slug, ''));
523
+ v_repo_fingerprint text := btrim(coalesce(p_repo_fingerprint, ''));
524
+ v_expected_handle text := btrim(coalesce(p_expected_agent_handle, ''));
525
+ v_existing_agent_id uuid;
526
+ v_owner_user_id uuid;
527
+ v_identity_disposition text;
528
+ v_membership_disposition text;
529
+ v_harness_disposition text;
530
+ v_repo_disposition text;
531
+ v_binding_disposition text;
532
+ v_completed_replay boolean := false;
533
+ begin
534
+ if not agent_board._is_service_role() then
535
+ raise exception 'service_role required for onboard_federated_fleet_agent' using errcode = '42501';
536
+ end if;
537
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is null then
538
+ raise exception 'bounded invite code is required' using errcode = '22023';
539
+ end if;
540
+
541
+ select bit.* into v_inv
542
+ from agent_board.board_invite_tokens bit
543
+ where bit.code = btrim(p_invite_code)
544
+ for update;
545
+ if not found then
546
+ raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
547
+ end if;
548
+ select * into v_bounds
549
+ from agent_board.fleet_onboarding_invite_bounds b
550
+ where b.invite_id = v_inv.id
551
+ for update;
552
+ if not found then
553
+ raise exception 'fleet onboarding requires a bounded invite' using errcode = '42501';
554
+ end if;
555
+
556
+ -- ADVISORY BEFORE BOARDS. set_board_authority_ceiling holds this lock and then
557
+ -- reaches this same boards row through board_authority_ceilings' FK (FOR KEY SHARE
558
+ -- on INSERT). Taking the boards lock first would invert the order against it and
559
+ -- deadlock -- it did, until this line was hoisted above the select below.
560
+ perform agent_board._lock_board_authority_ceiling(v_inv.board_id);
561
+
562
+ select * into v_board
563
+ from agent_board.boards b
564
+ where b.id = v_inv.board_id
565
+ for update;
566
+ if not found or v_board.status <> 'active' then
567
+ raise exception 'fleet onboarding board is not usable' using errcode = '42501';
568
+ end if;
569
+
570
+ if v_inv.board_id is distinct from p_board_id
571
+ or v_bounds.machine_id is distinct from p_machine_id
572
+ or v_bounds.harness_kind is distinct from v_kind
573
+ or v_bounds.harness_label is distinct from v_label
574
+ or v_bounds.repo_slug is distinct from v_repo_slug
575
+ or v_bounds.repo_fingerprint is distinct from v_repo_fingerprint
576
+ or v_bounds.repo_scope_role is distinct from p_repo_scope_role
577
+ or v_bounds.agent_role is distinct from p_agent_role
578
+ or v_bounds.expected_agent_handle is distinct from v_expected_handle then
579
+ raise exception 'fleet onboarding bounds mismatch' using errcode = '42501';
580
+ end if;
581
+ if not ((p_agent_role = 'phase_worker' and p_repo_scope_role = 'worker')
582
+ or (p_agent_role = 'reviewer' and p_repo_scope_role = 'reviewer')) then
583
+ raise exception 'fleet onboarding authority is elevated' using errcode = '42501';
584
+ end if;
585
+
586
+ select * into v_machine from agent_board.fleet_machines fm
587
+ where fm.id = p_machine_id for update;
588
+ if not found or v_machine.owner_agent_id <> v_inv.created_by_agent_id
589
+ or v_machine.lifecycle_state <> 'active' then
590
+ raise exception 'fleet onboarding machine is not usable' using errcode = '42501';
591
+ end if;
592
+ select * into v_domain from agent_board.fleet_trust_domains td
593
+ where td.id = v_machine.trust_domain_id for update;
594
+ if not found or v_domain.owner_agent_id <> v_inv.created_by_agent_id
595
+ or v_domain.lifecycle_state <> 'active' then
596
+ raise exception 'fleet onboarding trust domain is not usable' using errcode = '42501';
597
+ end if;
598
+
599
+ -- Serialize cross-route identity classification before locking principals. For
600
+ -- a replay, lock the creator and enrolled principal in UUID order so a human or
601
+ -- agent subtree revocation that visits the same pair cannot invert their order.
602
+ perform pg_advisory_xact_lock(hashtextextended(p_agent_public_key_fingerprint, 0));
603
+ select k.agent_id, a.owner_user_id into v_existing_agent_id, v_owner_user_id
604
+ from agent_board.agent_signing_keys k
605
+ join agent_board.agents a on a.id = k.agent_id
606
+ where k.fingerprint = p_agent_public_key_fingerprint;
607
+ v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
608
+ agent_board._federated_identity_slug(p_issuer, p_subject)
609
+ ));
610
+ perform 1 from auth.users u where u.id = v_owner_user_id for update;
611
+ if exists (
612
+ select 1 from agent_board.fleet_human_revocations r
613
+ where r.issuer = p_issuer and r.subject = p_subject
614
+ ) then
615
+ raise exception 'human owner has been revoked' using errcode = '42501';
616
+ end if;
617
+ perform 1 from agent_board.agents a
618
+ where a.id = any(array[v_inv.created_by_agent_id, v_existing_agent_id])
619
+ order by a.id for update;
620
+
621
+ -- Creator authority is part of the invite's live authorization, not merely a
622
+ -- snapshot from mint time. Lock every membership row and the current ceiling
623
+ -- before evaluating its role so revocation, eviction, or demotion must serialize
624
+ -- on one side of this transaction.
625
+ --
626
+ -- The ceiling ROW lock below covers demotion of an EXISTING ceiling only. No row
627
+ -- lock can cover the ABSENCE of a row, and absence is the normal default-owner
628
+ -- state, so a concurrent set_board_authority_ceiling inserting 'observer' used to
629
+ -- interleave: this transaction read "no ceiling", passed as owner, and committed a
630
+ -- 'member' membership after the ceiling committed. The board-keyed advisory lock
631
+ -- that serializes the ABSENCE is already held -- it is taken far above, BEFORE the
632
+ -- boards row lock, because the ceiling writer reaches boards through its FK and the
633
+ -- two must agree on order.
634
+ perform 1 from agent_board.board_memberships bm
635
+ where bm.board_id = v_inv.board_id
636
+ and bm.agent_id = v_inv.created_by_agent_id
637
+ order by bm.id for update;
638
+ perform 1 from agent_board.board_authority_ceilings c
639
+ where c.board_id = v_inv.board_id for update;
640
+
641
+ -- Concurrent legacy enrollment or onboarding on another machine has committed
642
+ -- before this snapshot decides whether the identity was created here.
643
+ if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = p_agent_public_key_fingerprint) then
644
+ v_identity_disposition := 'reused';
645
+ else
646
+ v_identity_disposition := 'created';
647
+ end if;
648
+
649
+ select * into v_enroll from agent_board.enroll_federated_identity(
650
+ p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
651
+ p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
652
+ p_pop_verified, p_token_sha256, p_token_expires_at, p_actor_sub, p_agent_kind, p_requested_scopes,
653
+ p_requested_board_scopes, null
654
+ );
655
+ if v_enroll.agent_handle is distinct from v_expected_handle then
656
+ raise exception 'server-assigned handle does not match the authorized handle' using errcode = '23505';
657
+ end if;
658
+
659
+ -- A complete replay may bypass expiry/exhaustion only while its redemption and
660
+ -- active membership remain true through commit. Lock both ledgers before the
661
+ -- proof; the invite row already serializes first redemption for an absent row.
662
+ perform 1 from agent_board.board_invite_redemptions red
663
+ where red.invite_id = v_inv.id and red.agent_id = v_enroll.agent_id
664
+ order by red.id for update;
665
+ perform 1 from agent_board.board_memberships bm
666
+ where bm.board_id = v_inv.board_id and bm.agent_id = v_enroll.agent_id
667
+ order by bm.id for update;
668
+
669
+ -- The released ROOMLINK core deliberately returns an existing redemption before
670
+ -- checking current token authority. Fleet onboarding has a narrower replay
671
+ -- contract: only an exact, fully active chain may ignore expiry/exhaustion. A
672
+ -- partial chain must pass every current invite gate before any missing suffix is
673
+ -- written, and revocation/current creator authority always apply.
674
+ select exists (
675
+ select 1
676
+ from agent_board.board_invite_redemptions red
677
+ join agent_board.board_memberships mem on mem.id = red.membership_id
678
+ join agent_board.fleet_harness_registrations h
679
+ on h.machine_id = p_machine_id
680
+ and h.harness_kind = v_kind
681
+ and h.harness_label = v_label
682
+ join agent_board.fleet_repo_scopes repo
683
+ on repo.harness_registration_id = h.id
684
+ and repo.repo_slug = v_repo_slug
685
+ and repo.repo_fingerprint = v_repo_fingerprint
686
+ and repo.scope_role = p_repo_scope_role
687
+ join agent_board.fleet_machine_agents binding
688
+ on binding.machine_id = p_machine_id
689
+ and binding.agent_id = v_enroll.agent_id
690
+ and binding.repo_scope_id = repo.id
691
+ and binding.signing_key_id = v_enroll.signing_key_id
692
+ and binding.agent_role = p_agent_role
693
+ join agent_board.agents principal on principal.id = v_enroll.agent_id
694
+ join agent_board.agent_signing_keys signing_key on signing_key.id = v_enroll.signing_key_id
695
+ where red.invite_id = v_inv.id
696
+ and red.agent_id = v_enroll.agent_id
697
+ and mem.board_id = v_inv.board_id
698
+ and mem.agent_id = v_enroll.agent_id
699
+ and mem.role = 'member'
700
+ and mem.membership_state in ('joined', 'subscribed')
701
+ and h.lifecycle_state = 'active'
702
+ and repo.lifecycle_state = 'active'
703
+ and binding.lifecycle_state = 'active'
704
+ and principal.status = 'active'
705
+ and signing_key.status = 'active'
706
+ and signing_key.proof_state = 'proven'
707
+ and signing_key.revoked_at is null
708
+ and not exists (
709
+ select 1 from agent_board.fleet_revocation_events e
710
+ where (e.subject_kind = 'agent' and e.subject_id = v_enroll.agent_id)
711
+ or (e.subject_kind = 'signing_key' and e.subject_id = v_enroll.signing_key_id)
712
+ or (e.subject_kind = 'harness' and e.subject_id = h.id)
713
+ or (e.subject_kind = 'repo_scope' and e.subject_id = repo.id)
714
+ )
715
+ ) into v_completed_replay;
716
+
717
+ if v_inv.revoked_at is not null
718
+ or not exists (
719
+ select 1 from agent_board.agents a
720
+ where a.id = v_inv.created_by_agent_id and a.status = 'active'
721
+ )
722
+ or not agent_board._grant_within_ceiling(
723
+ v_inv.created_by_agent_id, v_inv.board_id, v_inv.preset_role
724
+ )
725
+ or (
726
+ exists (
727
+ select 1 from agent_board.board_memberships bm
728
+ where bm.board_id = v_inv.board_id
729
+ and bm.agent_id = v_enroll.agent_id
730
+ and bm.membership_state = 'removed'
731
+ )
732
+ and not exists (
733
+ select 1 from agent_board.board_memberships bm
734
+ where bm.board_id = v_inv.board_id
735
+ and bm.agent_id = v_enroll.agent_id
736
+ and bm.membership_state in ('joined', 'subscribed')
737
+ )
738
+ ) then
739
+ raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
740
+ end if;
741
+ if not v_completed_replay
742
+ and (
743
+ v_inv.expires_at <= now()
744
+ or (v_inv.max_uses is not null and v_inv.uses_count >= v_inv.max_uses)
745
+ ) then
746
+ raise exception 'fleet onboarding invite is not usable' using errcode = '42501';
747
+ end if;
748
+
749
+ if exists (
750
+ select 1 from agent_board.board_invite_redemptions r
751
+ where r.invite_id = v_inv.id and r.agent_id = v_enroll.agent_id
752
+ ) then v_membership_disposition := 'reused'; else v_membership_disposition := 'created'; end if;
753
+
754
+ select * into v_membership
755
+ from agent_board._redeem_fleet_invite_for_agent(btrim(p_invite_code), v_enroll.agent_id);
756
+ if v_membership.role <> 'member' or v_membership.membership_state not in ('joined', 'subscribed') then
757
+ raise exception 'fleet onboarding requires an active member membership' using errcode = '42501';
758
+ end if;
759
+
760
+ perform 1 from agent_board.fleet_harness_registrations h
761
+ where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
762
+ order by h.id for update;
763
+ if exists (
764
+ select 1 from agent_board.fleet_harness_registrations h
765
+ where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
766
+ and h.lifecycle_state <> 'active'
767
+ ) then
768
+ raise exception 'revoked harness registration cannot be replaced' using errcode = '42501';
769
+ end if;
770
+ select * into v_harness
771
+ from agent_board.fleet_harness_registrations h
772
+ where h.machine_id = p_machine_id and h.harness_kind = v_kind and h.harness_label = v_label
773
+ and h.lifecycle_state = 'active'
774
+ order by h.id;
775
+ if found then
776
+ v_harness_disposition := 'reused';
777
+ else
778
+ insert into agent_board.fleet_harness_registrations(machine_id, harness_kind, harness_label)
779
+ values (p_machine_id, v_kind, v_label) returning * into v_harness;
780
+ v_harness_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
781
+ end if;
782
+
783
+ perform 1 from agent_board.fleet_repo_scopes r
784
+ where r.harness_registration_id = v_harness.id
785
+ and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
786
+ order by r.id for update;
787
+ if exists (
788
+ select 1 from agent_board.fleet_repo_scopes r
789
+ where r.harness_registration_id = v_harness.id
790
+ and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
791
+ and (r.lifecycle_state <> 'active' or r.repo_slug <> v_repo_slug)
792
+ ) then
793
+ raise exception 'conflicting or revoked repo scope cannot be replaced' using errcode = '42501';
794
+ end if;
795
+ select * into v_repo
796
+ from agent_board.fleet_repo_scopes r
797
+ where r.harness_registration_id = v_harness.id
798
+ and r.repo_fingerprint = v_repo_fingerprint and r.scope_role = p_repo_scope_role
799
+ and r.lifecycle_state = 'active' and r.repo_slug = v_repo_slug
800
+ order by r.id;
801
+ if found then
802
+ v_repo_disposition := 'reused';
803
+ else
804
+ insert into agent_board.fleet_repo_scopes(
805
+ harness_registration_id, repo_slug, repo_fingerprint, scope_role
806
+ ) values (v_harness.id, v_repo_slug, v_repo_fingerprint, p_repo_scope_role)
807
+ returning * into v_repo;
808
+ v_repo_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
809
+ end if;
810
+
811
+ -- Machine -> agent/key/binding lock order. Any tombstone for this natural key
812
+ -- blocks replacement authority even though the active unique index would allow it.
813
+ perform 1 from agent_board.agents a where a.id = v_enroll.agent_id for update;
814
+ perform 1 from agent_board.agent_signing_keys k where k.id = v_enroll.signing_key_id for update;
815
+ perform 1 from agent_board.fleet_machine_agents b
816
+ where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
817
+ and b.repo_scope_id = v_repo.id
818
+ order by b.id for update;
819
+ if exists (
820
+ select 1 from agent_board.fleet_machine_agents b
821
+ where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
822
+ and b.repo_scope_id = v_repo.id
823
+ and (
824
+ b.lifecycle_state <> 'active'
825
+ or b.signing_key_id is distinct from v_enroll.signing_key_id
826
+ or b.agent_role is distinct from p_agent_role
827
+ )
828
+ ) then
829
+ raise exception 'conflicting or revoked fleet binding cannot be replaced' using errcode = '42501';
830
+ end if;
831
+ select * into v_binding
832
+ from agent_board.fleet_machine_agents b
833
+ where b.machine_id = p_machine_id and b.agent_id = v_enroll.agent_id
834
+ and b.repo_scope_id = v_repo.id and b.lifecycle_state = 'active'
835
+ and b.signing_key_id = v_enroll.signing_key_id and b.agent_role = p_agent_role
836
+ order by b.id;
837
+ if found then
838
+ v_binding_disposition := 'reused';
839
+ else
840
+ insert into agent_board.fleet_machine_agents(
841
+ machine_id, agent_id, repo_scope_id, signing_key_id, agent_role
842
+ ) values (
843
+ p_machine_id, v_enroll.agent_id, v_repo.id, v_enroll.signing_key_id, p_agent_role
844
+ ) returning * into v_binding;
845
+ v_binding_disposition := case when v_identity_disposition = 'reused' then 'recovered' else 'created' end;
846
+ end if;
847
+
848
+ if exists (
849
+ select 1 from agent_board.fleet_revocation_events e
850
+ where (e.subject_kind = 'agent' and e.subject_id = v_enroll.agent_id)
851
+ or (e.subject_kind = 'signing_key' and e.subject_id = v_enroll.signing_key_id)
852
+ or (e.subject_kind = 'harness' and e.subject_id = v_harness.id)
853
+ or (e.subject_kind = 'repo_scope' and e.subject_id = v_repo.id)
854
+ ) then
855
+ raise exception 'fleet onboarding authority has effective revocation evidence' using errcode = '42501';
856
+ end if;
857
+
858
+ return query select
859
+ 'fleet_onboarding.v1'::text, v_enroll.agent_id, v_enroll.auth_user_id,
860
+ v_enroll.provider_id, v_enroll.signing_key_id, v_enroll.granted_scopes,
861
+ v_enroll.key_id, v_enroll.key_version, v_enroll.agent_handle,
862
+ p_agent_public_key_fingerprint, v_membership.board_id, v_membership.role,
863
+ v_membership.membership_state, p_machine_id, v_harness.id, v_repo.id,
864
+ v_binding.id, v_binding.signing_key_id, v_kind, v_label, v_repo_slug, v_repo_fingerprint,
865
+ p_repo_scope_role, p_agent_role, v_expected_handle,
866
+ v_identity_disposition, v_membership_disposition, v_harness_disposition,
867
+ v_repo_disposition, v_binding_disposition;
868
+ end;
869
+ $function$;
870
+
871
+ revoke all on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, timestamptz, text, text, text[], text[], text) from public, anon, authenticated;
872
+ grant execute on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, timestamptz, text, text, text[], text[], text) to service_role;
873
+ comment on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, timestamptz, text, text, text[], text[], text) is
874
+ '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. #521: p_token_sha256 (lowercase hex sha256 of the verified Portal token''s RS256 signing input) and p_token_expires_at (its verified exp) are REQUIRED and have no default; immediately after the owner lock and the revocation gate, and before the found/not-found split, _enroll_token_bind binds the token to this key''s fingerprint, so a token enrolls or resumes AT MOST ONE key. A different key under a used token raises SQLSTATE MBT01 and nothing is created; the same key falls through into the unchanged resumption gates. This is the ONLY call site of _enroll_token_bind and the only caller of _enroll_federated_identity_once.';
875
+
876
+ revoke all on function agent_board.onboard_federated_fleet_agent(text, text, text, jsonb, text, text, text, text, boolean, text, timestamptz, text, text, text[], text[], text, uuid, uuid, text, text, text, text, text, text, text) from public, anon, authenticated;
877
+ grant execute on function agent_board.onboard_federated_fleet_agent(text, text, text, jsonb, text, text, text, text, boolean, text, timestamptz, text, text, text[], text[], text, uuid, uuid, text, text, text, text, text, text, text) to service_role;
878
+ comment on function agent_board.onboard_federated_fleet_agent(text, text, text, jsonb, text, text, text, text, boolean, text, timestamptz, text, text, text[], text[], text, uuid, uuid, text, text, text, text, text, text, text) is
879
+ 'Service-role-only atomic fleet onboarding. Locks invite, machine, domain, principal, key, and binding in canonical order; takes the board-keyed _lock_board_authority_ceiling advisory lock so the ceiling''s ABSENCE serializes against a concurrent set_board_authority_ceiling before creator authority is evaluated; validates immutable creator bounds; enrolls/replays identity; redeems member ROOMLINK; and creates/reuses the exact harness, repo, and key-bound machine-agent chain in one transaction. #521: p_token_sha256 and p_token_expires_at are REQUIRED (no default) and are only forwarded to the nested enroll_federated_identity call, which binds the token to the key after this function''s owner lock; a used token presented with a different key raises MBT01 there and the whole onboarding rolls back.';
880
+
881
+ -- _enroll_federated_identity_once: signature and body unchanged. Its only caller is the
882
+ -- postgres-owned definer above, so the service_role grant was a bypass of the token bind
883
+ -- and nothing else.
884
+ revoke execute on function agent_board._enroll_federated_identity_once(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) from service_role;
885
+ comment on function agent_board._enroll_federated_identity_once(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) is
886
+ '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. #521: not EXECUTE-able by service_role (or any API role). The Portal-token binding lives in its only caller, enroll_federated_identity, and a direct service_role call would have been an unguarded creation path, since service_role holds no direct INSERT on agent_signing_keys or agents.';