@consiliency/agent-board-schema 1.50.0 → 1.51.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.
@@ -596,6 +596,10 @@
596
596
  {
597
597
  "id": "20261001000100_agent_board_claimpolicy_lock_grant_revoke",
598
598
  "sha256": "db73a9941d4601dd6db0c767fdc718534fe2856598a305f4747496cadc722afd"
599
+ },
600
+ {
601
+ "id": "20261002000100_agent_board_session_challenge_supersede",
602
+ "sha256": "f10d8f539538fcf63d5b3538650160e7a4f9e345ccb7476b21756675f2b6ab80"
599
603
  }
600
604
  ]
601
605
  }
@@ -0,0 +1,383 @@
1
+ -- F1 (code-review-2026-09-01): the per-key pending-challenge cap was a lockout lever.
2
+ --
3
+ -- THE DEFECT. `/session` step "challenge" is verify_jwt=false: it needs only an agent
4
+ -- handle and a key id, both directory-visible, and each request inserts a challenge row
5
+ -- through service_role. `issue_signing_key_challenge` capped pending challenges at 16
6
+ -- per KEY and RAISED `challenge_pending_cap` at the cap. So any caller who could reach
7
+ -- the endpoint could hold a victim's sixteen slots with one request every five minutes;
8
+ -- every legitimate challenge for that key then failed. The client turned the 403 into a
9
+ -- non-retryable SessionError and STOPPED its background refresh loop, so the outage
10
+ -- outlived the flood and needed a restart to clear. The same shape occurred by accident
11
+ -- after sixteen crashed mints inside one five-minute challenge lifetime.
12
+ --
13
+ -- THE CHANGE. Forward-only `create or replace` of two functions, both reproducing their
14
+ -- current body verbatim with one edit each:
15
+ --
16
+ -- 1. issue_signing_key_challenge — at the cap, SUPERSEDE the oldest pending challenge
17
+ -- and issue, instead of refusing. The resource bound is unchanged (at most 16
18
+ -- pending rows per key, still); what changes is that arrival is never refused.
19
+ -- 2. mint_fleet_agent_session — report a superseded challenge as `challenge_superseded`
20
+ -- rather than `nonce_replay`, so an eviction is retryable without teaching a client
21
+ -- to retry through a genuine replay.
22
+ --
23
+ -- WHY SUPERSEDE RATHER THAN NARROW THE CAP. The cap's purpose is to bound rows per key;
24
+ -- refusing was how it was spelled, not what it was for. Making the cap per-source instead
25
+ -- does NOT close this: sixteen requests over five minutes fit under any per-source limit
26
+ -- a real fleet can use, and a caller with several sources is not bounded per key at all.
27
+ -- The per-source rate cap added to the handler in this same change bounds the COST of
28
+ -- driving the queue; superseding is what removes the denial.
29
+ --
30
+ -- WHAT IS CLOSED, AND THE RESIDUAL THAT IS NOT. What this closes is the HOLDABLE lockout:
31
+ -- fill-and-walk-away no longer denies anything, and an outage can no longer outlive the
32
+ -- attack. What it does NOT close is eviction under a SUSTAINED flood. A challenge is the
33
+ -- NEWEST of its key when issued, so sixteen later issues rotate it out and its mint then
34
+ -- raises challenge_superseded. Per source that is bounded by the `session:` rate cap;
35
+ -- IN AGGREGATE IT IS UNBOUNDED, because a per-key aggregate bound is precisely the
36
+ -- holdable lever this migration removes -- the two cannot both exist. N sources rotate the
37
+ -- queue in 1/N of the per-source time, so a minter loses its challenge once its
38
+ -- issue->mint gap exceeds ~32/N seconds at the default 30/min.
39
+ --
40
+ -- That residual is DoS-class, not stored state: it costs the attacker continuous traffic and
41
+ -- stops when the traffic stops. NOTE WHAT IS NOT CLAIMED: "the victim's recovery is one
42
+ -- retry" is true of a ONE-SHOT evictor (driven end-to-end by F1-F in
43
+ -- session.integration.test.ts) and NOT of a sustained flood, where the retry re-enters on
44
+ -- the same odds and the retry count is geometric in the ratio of the client's issue->mint
45
+ -- window W to the flood's rotation period. W is asserted NOWHERE here and is dominated by
46
+ -- the signer -- an OS-keychain prompt makes it seconds, at which point a handful of sources
47
+ -- suffices. An earlier version of this header stated the one-retry recovery flatly.
48
+ -- It is pinned by assertions 19-20 of
49
+ -- agent_board_signing_challenge_pending_cap.test.sql rather than left as prose, because a
50
+ -- sentence is not re-checked (rule 5). ACCEPTED FOR THIS RELEASE AND TRACKED BY
51
+ -- message-board#423, which owns whether an eventual-progress mechanism is wanted; this
52
+ -- migration deliberately does not attempt one. An earlier version of this header called the race
53
+ -- "rate-bounded in attempts": true per source, FALSE in aggregate. Deleted rather than
54
+ -- softened -- it was the sentence that was wrong.
55
+ --
56
+ -- `create or replace` preserves both functions' ACLs; both are restated below anyway,
57
+ -- with their COMMENTs, because a SECURITY DEFINER function grants EXECUTE to PUBLIC by
58
+ -- default and this file must be self-contained if either is ever dropped and re-run.
59
+
60
+ create or replace function agent_board.issue_signing_key_challenge(
61
+ p_agent_id uuid,
62
+ p_key_id text,
63
+ p_expires_in interval default interval '5 minutes'
64
+ )
65
+ returns table (
66
+ challenge_id uuid,
67
+ agent_id uuid,
68
+ key_id text,
69
+ key_version integer,
70
+ fingerprint text,
71
+ challenge text,
72
+ expires_at timestamptz
73
+ )
74
+ language plpgsql
75
+ security definer
76
+ set search_path = agent_board, public
77
+ as $$
78
+ declare
79
+ v_actor uuid;
80
+ v_key agent_board.agent_signing_keys%rowtype;
81
+ v_challenge_id uuid := gen_random_uuid();
82
+ v_nonce text := encode(extensions.gen_random_bytes(24), 'base64');
83
+ v_expires_at timestamptz;
84
+ v_pending integer;
85
+ v_evicted uuid;
86
+ -- Frozen pending-challenge cap per signing key (IF-0-MBHARDEN-1a). N=16.
87
+ c_pending_cap constant integer := 16;
88
+ begin
89
+ if p_expires_in is null or p_expires_in <= interval '0 seconds' or p_expires_in > interval '15 minutes' then
90
+ raise exception 'challenge expiry must be between 0 and 15 minutes' using errcode = '22023';
91
+ end if;
92
+
93
+ v_actor := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
94
+
95
+ select *
96
+ into v_key
97
+ from agent_board.agent_signing_keys ask
98
+ where ask.agent_id = v_actor
99
+ and ask.key_id = p_key_id
100
+ for update;
101
+
102
+ if not found then
103
+ raise exception 'signing key not found' using errcode = 'P0002';
104
+ end if;
105
+ if v_key.status in ('revoked', 'compromised') then
106
+ raise exception 'key_revoked: signing key is revoked' using errcode = '22023';
107
+ end if;
108
+ if v_key.expires_at is not null and v_key.expires_at <= now() then
109
+ update agent_board.agent_signing_keys
110
+ set status = 'expired',
111
+ proof_state = 'expired',
112
+ updated_at = now()
113
+ where id = v_key.id;
114
+ raise exception 'key_expired: signing key is expired' using errcode = '22023';
115
+ end if;
116
+
117
+ -- Pending-challenge cap: at most N=16 unexpired, unconsumed, still-pending
118
+ -- challenges per key. Serialized by the `for update` above, so the count is
119
+ -- race-free and the eviction below cannot double-fire.
120
+ --
121
+ -- AT THE CAP THIS SUPERSEDES, IT DOES NOT REFUSE (review finding F1). The
122
+ -- previous behaviour raised challenge_pending_cap, which made a per-key RESOURCE
123
+ -- BOUND into a per-key LOCKOUT LEVER: /session step "challenge" is
124
+ -- verify_jwt=false and needs only a directory-visible handle and key id, so any
125
+ -- caller could hold all sixteen slots with one request every five minutes and the
126
+ -- key's legitimate owner could never obtain a challenge again. The client turned
127
+ -- the resulting 403 into a non-retryable error and stopped its refresh loop, so
128
+ -- the outage outlived the flood.
129
+ --
130
+ -- WHAT SUPERSEDING COSTS. It creates an eviction the refusal did not have: sixteen
131
+ -- later issues rotate any pending challenge out, so a sustained flood evicts a slow
132
+ -- minter for as long as it runs. Bounded per source by the `session:` rate cap,
133
+ -- UNBOUNDED in aggregate -- an aggregate per-key bound is the holdable lever this
134
+ -- removes. DoS-class rather than stored state: it stops when the traffic stops, and
135
+ -- the consequence is one retry rather than a stopped client. Pinned by assertions
136
+ -- 19-20 of agent_board_signing_challenge_pending_cap.test.sql.
137
+ --
138
+ -- The eviction is deliberately made LOUD instead of
139
+ -- cheap: the row is marked (below) and mint_fleet_agent_session reports
140
+ -- challenge_superseded, distinct from the nonce_replay a genuine replay raises, so
141
+ -- the client can retry the one without learning to ignore the other.
142
+ --
143
+ -- WHY OLDEST. The oldest pending challenge is the one most likely to be abandoned,
144
+ -- and taking it puts a freshly-issued challenge LAST in the eviction order — a
145
+ -- challenge is minted within milliseconds of issue, so the legitimate flow is the
146
+ -- one this ordering protects. `order by created_at` with `id` as the tiebreak keeps
147
+ -- the choice deterministic when two rows share a timestamp.
148
+ --
149
+ -- WHY `SKIP LOCKED`, WHICH IS NOT OPTIONAL. This function holds a FOR UPDATE on the
150
+ -- agent_signing_keys row and then writes an agent_signing_key_challenges row;
151
+ -- mint_fleet_agent_session takes those two in the OPPOSITE order (challenge row FOR
152
+ -- UPDATE at 20260718000600:73-76, then key row FOR UPDATE at :95-100). A plain
153
+ -- eviction would therefore deadlock against a concurrent mint of the very row being
154
+ -- evicted — an ABBA this change would have INTRODUCED, and exactly the traffic it
155
+ -- makes common: floods driving evictions while legitimate agents mint. SKIP LOCKED
156
+ -- removes the cycle by never waiting: a challenge row a mint already holds is passed
157
+ -- over for the next-oldest. It also happens to be the kinder choice, since the row a
158
+ -- mint is holding is the one row we most want not to evict. The pair is pinned as a
159
+ -- class by agent_board_fk_lock_edges.test.sql FKL-10, which named this inversion when
160
+ -- the first version of this migration omitted the clause.
161
+ select count(*)
162
+ into v_pending
163
+ from agent_board.agent_signing_key_challenges c
164
+ where c.signing_key_id = v_key.id
165
+ and c.proof_state = 'pending'
166
+ and c.consumed_at is null
167
+ and c.expires_at > now();
168
+
169
+ if v_pending >= c_pending_cap then
170
+ update agent_board.agent_signing_key_challenges
171
+ set proof_state = 'expired',
172
+ consumed_at = now(),
173
+ updated_at = now(),
174
+ metadata = metadata || jsonb_build_object(
175
+ 'superseded_at', now(),
176
+ 'superseded_by', v_challenge_id
177
+ )
178
+ where id = (
179
+ select c.id
180
+ from agent_board.agent_signing_key_challenges c
181
+ where c.signing_key_id = v_key.id
182
+ and c.proof_state = 'pending'
183
+ and c.consumed_at is null
184
+ and c.expires_at > now()
185
+ order by c.created_at, c.id
186
+ for update skip locked
187
+ limit 1
188
+ )
189
+ returning id into v_evicted;
190
+
191
+ -- No evictable row. Under SKIP LOCKED this is reachable without the bound being
192
+ -- wrong: every one of the sixteen pending rows can be row-locked by a concurrent
193
+ -- mint of that same key. Issuing anyway would grow the pending set past the cap,
194
+ -- so refuse — the pre-change behaviour, now confined to a window in which sixteen
195
+ -- mints for one key are in flight at once, and retryable at the client rather than
196
+ -- terminal. ('expired'/consumed_at is the same closed-out shape the consumed-shape
197
+ -- CHECK requires, and reuses the existing proof_state vocabulary rather than adding
198
+ -- a value; the metadata marker is what distinguishes an eviction from a lapse.)
199
+ if v_evicted is null then
200
+ raise exception 'challenge_pending_cap: too many pending signing-key challenges for this key (max %)', c_pending_cap
201
+ using errcode = '22023';
202
+ end if;
203
+ end if;
204
+
205
+ v_expires_at := now() + p_expires_in;
206
+
207
+ insert into agent_board.agent_signing_key_challenges(
208
+ id,
209
+ signing_key_id,
210
+ agent_id,
211
+ challenge_nonce,
212
+ challenge_bytes,
213
+ expires_at,
214
+ metadata
215
+ )
216
+ values (
217
+ v_challenge_id,
218
+ v_key.id,
219
+ v_actor,
220
+ v_nonce,
221
+ 'agent-signing-key-proof.v1:' || v_challenge_id::text || ':' || v_nonce,
222
+ v_expires_at,
223
+ jsonb_build_object('keyId', v_key.key_id, 'keyVersion', v_key.key_version)
224
+ );
225
+
226
+ update agent_board.agent_signing_keys
227
+ set proof_state = 'challenge_issued',
228
+ updated_at = now()
229
+ where id = v_key.id
230
+ and proof_state <> 'proven';
231
+
232
+ return query
233
+ select
234
+ v_challenge_id,
235
+ v_actor,
236
+ v_key.key_id,
237
+ v_key.key_version,
238
+ v_key.fingerprint,
239
+ 'agent-signing-key-proof.v1:' || v_challenge_id::text || ':' || v_nonce,
240
+ v_expires_at;
241
+ end;
242
+ $$;
243
+ comment on function agent_board.issue_signing_key_challenge(uuid, text, interval) is
244
+ 'Issues a one-time PoP challenge for an agent signing key. Bounds the key to at most 16 concurrently-pending challenges; AT the bound it supersedes the OLDEST pending challenge (proof_state=''expired'', consumed_at set, metadata.superseded_by naming the new challenge) and issues, rather than refusing — refusing made the bound a lockout lever on the unauthenticated /session surface (F1). Serialized by the FOR UPDATE on the key row. authenticated + service_role.';
245
+
246
+ revoke all on function agent_board.issue_signing_key_challenge(uuid, text, interval) from public, anon;
247
+ grant execute on function agent_board.issue_signing_key_challenge(uuid, text, interval) to authenticated, service_role;
248
+
249
+ create or replace function agent_board.mint_fleet_agent_session(
250
+ p_challenge_id uuid,
251
+ p_nonce text,
252
+ p_signing_key_id uuid,
253
+ p_agent_id uuid,
254
+ p_max_ttl_seconds integer default 900
255
+ )
256
+ returns table(session_id text, expires_at timestamptz)
257
+ language plpgsql
258
+ security definer
259
+ set search_path = agent_board, public
260
+ as $$
261
+ declare
262
+ v_challenge agent_board.agent_signing_key_challenges%rowtype;
263
+ v_key agent_board.agent_signing_keys%rowtype;
264
+ v_auth_user_id uuid;
265
+ v_parent_agent_id uuid;
266
+ v_ttl integer := least(coalesce(p_max_ttl_seconds, 900), 900);
267
+ v_session_id text := 'fas_' || replace(gen_random_uuid()::text, '-', '');
268
+ v_expires_at timestamptz;
269
+ begin
270
+ if v_ttl <= 0 then
271
+ raise exception 'max_ttl_seconds must be positive' using errcode = '22023';
272
+ end if;
273
+
274
+ -- (i) challenge: exists, bound to this agent + signing key, unconsumed, unexpired.
275
+ select * into v_challenge
276
+ from agent_board.agent_signing_key_challenges c
277
+ where c.id = p_challenge_id
278
+ for update;
279
+ if not found then
280
+ raise exception 'signing key challenge not found' using errcode = 'P0002';
281
+ end if;
282
+ if v_challenge.agent_id <> p_agent_id or v_challenge.signing_key_id <> p_signing_key_id then
283
+ raise exception 'actor_mismatch: challenge is not bound to this agent and signing key' using errcode = '42501';
284
+ end if;
285
+ -- An EVICTED challenge is not a replayed one. issue_signing_key_challenge closes
286
+ -- the oldest pending row out ('expired' + consumed_at) when a new challenge arrives
287
+ -- at the per-key cap; without this branch that row would arrive here and be reported
288
+ -- as nonce_replay, which is a security signal the client must never learn to retry
289
+ -- through. Named separately so the client can retry exactly this one. Checked BEFORE
290
+ -- the generic branch because the evicted row satisfies it.
291
+ if v_challenge.metadata ? 'superseded_by' then
292
+ raise exception 'challenge_superseded: this challenge was superseded by a newer one for the same key; request a fresh challenge'
293
+ using errcode = '22023';
294
+ end if;
295
+ if v_challenge.proof_state <> 'pending' or v_challenge.consumed_at is not null then
296
+ raise exception 'nonce_replay: signing key challenge already consumed' using errcode = '23505';
297
+ end if;
298
+ if v_challenge.expires_at <= now() then
299
+ raise exception 'timestamp_out_of_window: signing key challenge expired' using errcode = '22023';
300
+ end if;
301
+ -- #10 nonce binding (belt-and-suspenders on top of the challenge_id binding): the
302
+ -- caller must present the exact stored challenge_nonce for this challenge row.
303
+ if p_nonce is null or p_nonce <> v_challenge.challenge_nonce then
304
+ raise exception 'nonce_mismatch: presented nonce does not match the bound challenge' using errcode = '42501';
305
+ end if;
306
+
307
+ -- (ii) signing key: bound to this agent, active, unrevoked.
308
+ select * into v_key
309
+ from agent_board.agent_signing_keys ask
310
+ where ask.id = p_signing_key_id
311
+ and ask.agent_id = p_agent_id
312
+ for update;
313
+ if not found then
314
+ raise exception 'signing key not found for agent' using errcode = 'P0002';
315
+ end if;
316
+ if v_key.status <> 'active' or v_key.revoked_at is not null then
317
+ raise exception 'key_revoked: signing key is not active' using errcode = '42501';
318
+ end if;
319
+ -- #12 key expiry: today only the challenge RPC checks agent_signing_keys.expires_at;
320
+ -- the mint must reject an expired key as well.
321
+ if v_key.expires_at is not null and v_key.expires_at <= now() then
322
+ raise exception 'key_expired: signing key is past its expires_at' using errcode = '42501';
323
+ end if;
324
+
325
+ -- (iii) whole principal active (self + every ancestor up to lineage root).
326
+ if not agent_board._agent_principal_active(p_agent_id) then
327
+ raise exception 'principal_inactive: agent principal is not fully active' using errcode = '42501';
328
+ end if;
329
+
330
+ -- (iv) #4 governing delegation-grant liveness for root_minted children. A child
331
+ -- that holds a delegation_grants row may mint ONLY while that grant is unrevoked
332
+ -- and unexpired (grant_expires_at > now()). independent_auth principals hold no
333
+ -- grant row and skip this gate. This binds the hours-scale grant clock to the mint
334
+ -- so an expired/revoked grant cannot keep re-minting <=900s tokens indefinitely.
335
+ if exists (select 1 from agent_board.delegation_grants g where g.child_agent_id = p_agent_id) then
336
+ if not exists (
337
+ select 1
338
+ from agent_board.delegation_grants g
339
+ where g.child_agent_id = p_agent_id
340
+ and g.revoked_at is null
341
+ and g.grant_expires_at > now()
342
+ ) then
343
+ raise exception 'grant_expired: the governing delegation grant is revoked or expired' using errcode = '42501';
344
+ end if;
345
+ end if;
346
+
347
+ -- Scope to the agent's authenticated principal. A fleet_agent_session must bind to
348
+ -- an auth-bound agent (no auth_user_id column on nonfleet_sessions; this is a mint
349
+ -- invariant flowed to the EF token sub, not a stored value).
350
+ select a.auth_user_id, a.parent_agent_id
351
+ into v_auth_user_id, v_parent_agent_id
352
+ from agent_board.agents a
353
+ where a.id = p_agent_id;
354
+ if v_auth_user_id is null then
355
+ raise exception 'agent has no auth_user_id principal to scope the session' using errcode = '42501';
356
+ end if;
357
+
358
+ -- Consume the challenge (one-time).
359
+ update agent_board.agent_signing_key_challenges
360
+ set proof_state = 'consumed',
361
+ consumed_at = now(),
362
+ updated_at = now()
363
+ where id = p_challenge_id;
364
+
365
+ v_expires_at := now() + make_interval(secs => v_ttl);
366
+
367
+ insert into agent_board.nonfleet_sessions(
368
+ id, session_id, identity_class, agent_id, parent_agent_id,
369
+ max_ttl_seconds, issued_at, expires_at
370
+ )
371
+ values (
372
+ v_session_id, v_session_id, 'fleet_agent_session', p_agent_id, v_parent_agent_id,
373
+ v_ttl, now(), v_expires_at
374
+ );
375
+
376
+ return query select v_session_id, v_expires_at;
377
+ end;
378
+ $$;
379
+ comment on function agent_board.mint_fleet_agent_session(uuid, text, uuid, uuid, integer) is
380
+ 'Whole-principal PoP session mint: re-validates challenge (incl. bound nonce) + active/unrevoked/unexpired signing key + full-lineage liveness + governing delegation-grant liveness, consumes the challenge, and inserts a <=900s fleet_agent_session. A challenge superseded by issue_signing_key_challenge raises challenge_superseded (retryable: request a fresh challenge), distinct from the nonce_replay a genuine replay raises. service_role-only.';
381
+
382
+ revoke all on function agent_board.mint_fleet_agent_session(uuid, text, uuid, uuid, integer) from public, anon, authenticated;
383
+ grant execute on function agent_board.mint_fleet_agent_session(uuid, text, uuid, uuid, integer) to service_role;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-schema",
3
- "version": "1.50.0",
3
+ "version": "1.51.0",
4
4
  "description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
5
5
  "repository": "https://github.com/Consiliency/message-board.git",
6
6
  "type": "module",
@@ -2,7 +2,7 @@
2
2
  "schema": "agent_board_schema_manifest.v1",
3
3
  "interface": "IF-0-MBPLANE-BUNDLE-1",
4
4
  "package": "@consiliency/agent-board-schema",
5
- "packageVersion": "1.50.0",
5
+ "packageVersion": "1.51.0",
6
6
  "schemaVersion": 8,
7
7
  "minClientVersion": "1.1.0",
8
8
  "source": {
@@ -46,8 +46,8 @@
46
46
  "usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
47
47
  }
48
48
  ],
49
- "migrationCount": 149,
50
- "aggregateSha256": "c15239411cdaff38ce6a52294d6586ae6fe711cdea40938785c55e7e38e13c0b",
49
+ "migrationCount": 150,
50
+ "aggregateSha256": "94b8945ab506a02a31ffd0ed6daad37d5655f209214558ca868dbb22693fb57d",
51
51
  "migrations": [
52
52
  {
53
53
  "order": 0,
@@ -1389,6 +1389,15 @@
1389
1389
  "sourceRef": "message-board",
1390
1390
  "bytes": 10193,
1391
1391
  "sha256": "db73a9941d4601dd6db0c767fdc718534fe2856598a305f4747496cadc722afd"
1392
+ },
1393
+ {
1394
+ "order": 149,
1395
+ "id": "20261002000100_agent_board_session_challenge_supersede",
1396
+ "filename": "20261002000100_agent_board_session_challenge_supersede.sql",
1397
+ "origin": "message-board-forward",
1398
+ "sourceRef": "message-board",
1399
+ "bytes": 19273,
1400
+ "sha256": "f10d8f539538fcf63d5b3538650160e7a4f9e345ccb7476b21756675f2b6ab80"
1392
1401
  }
1393
1402
  ],
1394
1403
  "excludedBridge": {
@@ -38,9 +38,9 @@ describe('manifest integrity', () => {
38
38
  expect(manifest.schemaVersion).toBe(8);
39
39
  expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
40
40
  });
41
- it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE + one BOARDCLAIMPOLICY + one HEARTBEATPRESERVE + one LIVENESSDENOM + one OWNERLESSRENEWTTL + one OWNERLESSQUOTACENSUS + one FLEETDOMAINNODOWNGRADE + one OWNERLESSQUOTACONVERGE + one ADVISORYREVERTRESET + one CLAIMPOLICYRACE + one CLAIMPOLICYLOCKGRANT forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 149)', () => {
41
+ it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE + one BOARDCLAIMPOLICY + one HEARTBEATPRESERVE + one LIVENESSDENOM + one OWNERLESSRENEWTTL + one OWNERLESSQUOTACENSUS + one FLEETDOMAINNODOWNGRADE + one OWNERLESSQUOTACONVERGE + one ADVISORYREVERTRESET + one CLAIMPOLICYRACE + one CLAIMPOLICYLOCKGRANT + one SESSIONCHALLENGESUPERSEDE forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 150)', () => {
42
42
  expect(manifest.migrationCount).toBe(manifest.migrations.length);
43
- expect(manifest.migrationCount).toBe(149);
43
+ expect(manifest.migrationCount).toBe(150);
44
44
  // MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
45
45
  // schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
46
46
  // enrollment-only additive surface (identity_providers registry, enrollment audit,
@@ -327,6 +327,14 @@ describe('manifest integrity', () => {
327
327
  // wait would not advance the snapshot and the lock would be silently inert.
328
328
  '20260930000100_agent_board_claim_policy_race.sql',
329
329
  '20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql',
330
+ // F1: the per-key pending-challenge cap SUPERSEDES the oldest pending challenge
331
+ // instead of raising challenge_pending_cap. Refusing at the cap turned a resource
332
+ // bound into a lockout lever on the unauthenticated /session surface -- sixteen
333
+ // requests naming a directory-visible handle and key id denied that key every
334
+ // further challenge, and the client stopped its refresh loop on the 403. The bound
335
+ // is unchanged; mint_fleet_agent_session now names an eviction challenge_superseded
336
+ // so it is retryable without making nonce_replay retryable with it.
337
+ '20261002000100_agent_board_session_challenge_supersede.sql',
330
338
  ]) {
331
339
  expect(names.has(f)).toBe(true);
332
340
  }
@@ -259,12 +259,32 @@ select is(
259
259
  -- pinned as a class in agent_board_task_thread_lock_order.test.sql (TT-1), which
260
260
  -- is what makes a REGRESSION fail there and name the function, rather than only
261
261
  -- failing here as an opaque re-appearance of the pair.
262
+ --
263
+ -- ONE PAIR HAS BEEN ADDED, and it is added with its own positive pin rather than as a
264
+ -- widened exemption. F1's supersede-at-the-cap makes issue_signing_key_challenge write
265
+ -- an agent_signing_key_challenges row while holding the agent_signing_keys row, which
266
+ -- mint_fleet_agent_session takes in the opposite order -- so this detector named
267
+ -- `agent_signing_key_challenges <-> agent_signing_keys` the first time that migration
268
+ -- was written. The cycle is closed at RUNTIME, by `for update skip locked` on the
269
+ -- eviction's row selection: a transaction that never waits on a challenge row cannot
270
+ -- close a cycle. This detector is a static regex scan and cannot see a SKIP LOCKED, so
271
+ -- it reports the pair and always will.
272
+ --
273
+ -- The clause is therefore pinned TWICE, POSITIVELY, so that deleting it fails a NAMED
274
+ -- assertion rather than merely re-adding a member to a list that already contains it
275
+ -- (which would be silent):
276
+ -- * agent_board_signing_challenge_pending_cap.test.sql asserts the DEPLOYED body
277
+ -- carries it, over stripped_body() so a comment cannot satisfy the match;
278
+ -- * tests/isolation/specs/challenge_supersede_lock_order.spec proves the absence of
279
+ -- the wait across two real sessions -- removing the clause turns its golden from a
280
+ -- clean completion into `<waiting ...>` and then a deadlock.
262
281
  -- ===========================================================================
263
282
  select is(
264
283
  (select array_agg(p order by p) from (
265
284
  select distinct least(rel_a, rel_b) || ' <-> ' || greatest(rel_a, rel_b) as p
266
285
  from _abba where not both_advisory) z),
267
286
  array[
287
+ 'agent_board.agent_signing_key_challenges <-> agent_board.agent_signing_keys',
268
288
  'agent_board.agent_signing_keys <-> agent_board.agents',
269
289
  'agent_board.agent_signing_keys <-> agent_board.board_memberships',
270
290
  'agent_board.agent_signing_keys <-> agent_board.fleet_machine_agents',