@consiliency/agent-board-schema 1.48.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.
Files changed (33) hide show
  1. package/fixtures/already-applied.json +16 -0
  2. package/migrations/20260929000100_agent_board_advisory_revert_cap_reset.sql +371 -0
  3. package/migrations/20260930000100_agent_board_claim_policy_race.sql +922 -0
  4. package/migrations/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql +158 -0
  5. package/migrations/20261002000100_agent_board_session_challenge_supersede.sql +383 -0
  6. package/package.json +1 -1
  7. package/schema-manifest.json +39 -3
  8. package/src/tooling.test.mjs +33 -2
  9. package/tests/agent_board_board_claim_policy.test.sql +261 -1
  10. package/tests/agent_board_claim_subject_gate.test.sql +11 -2
  11. package/tests/agent_board_claimkey.test.sql +17 -2
  12. package/tests/agent_board_claimpolicy_race.test.sql +1374 -0
  13. package/tests/agent_board_fk_lock_edges.test.sql +20 -0
  14. package/tests/agent_board_ownerless_quota_census.test.sql +6 -0
  15. package/tests/agent_board_ownerless_renew_quota.test.sql +37 -8
  16. package/tests/agent_board_signing_challenge_pending_cap.test.sql +391 -36
  17. package/tests/catalog/p_metadata_parameters.json +16 -0
  18. package/tests/fixtures/test-prereqs.sql +186 -0
  19. package/tests/isolation/expected/challenge_supersede_lock_order.out +213 -0
  20. package/tests/isolation/expected/claimpolicy_acquire_revert_race.out +66 -0
  21. package/tests/isolation/expected/claimpolicy_board_scope.out +73 -0
  22. package/tests/isolation/expected/claimpolicy_claimrow_lock_order.out +80 -0
  23. package/tests/isolation/expected/claimpolicy_isolation_refusal.out +114 -0
  24. package/tests/isolation/expected/claimpolicy_renew_revert_race.out +66 -0
  25. package/tests/isolation/expected/claimpolicy_writer_keyshare.out +49 -0
  26. package/tests/isolation/specs/challenge_supersede_lock_order.spec +183 -0
  27. package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +118 -0
  28. package/tests/isolation/specs/claimpolicy_board_scope.spec +115 -0
  29. package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +200 -0
  30. package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +162 -0
  31. package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +150 -0
  32. package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +107 -0
  33. package/tests/seeds/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql.pre.sql +99 -0
@@ -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 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 = 146)', () => {
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(146);
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,
@@ -304,6 +304,37 @@ describe('manifest integrity', () => {
304
304
  // ceiling is untouched. acquire's inline count is extracted into
305
305
  // _claimgate_active_ownerless_claims() so the two enforcement paths cannot drift.
306
306
  '20260928000100_agent_board_ownerless_quota_renew_convergence.sql',
307
+ // #400 ADVISORYREVERTRESET. claim_policy='advisory' skips both the ownerless TTL cap and
308
+ // #388's convergence clamp, and set_board_claim_policy is gated on the owner membership
309
+ // create_board seeds for the creator -- so an agent that creates a board can flip to
310
+ // advisory, renew its ownerless claims to an arbitrary horizon, flip back to inherit, and
311
+ // repeat, staying over quota forever while the census (which resolves policy at query time)
312
+ // reports no advisory share at all. The revert now RE-APPLIES the ownerless TTL cap to that
313
+ // board's active ownerless claims -- least(lease_expires_at, now()+cap), clamp only, never an
314
+ // extension -- so a horizon banked under the opt-out cannot be kept by handing the opt-out
315
+ // back. advisory->inherit only, governed by ownerless_guards. Bounded, not closed: a cycle
316
+ // still banks up to the cap.
317
+ '20260929000100_agent_board_advisory_revert_cap_reset.sql',
318
+ // #405 CLAIMPOLICYRACE. _claimgate_board_claim_policy took no lock on boards, so any
319
+ // operation whose snapshot predated a committed policy change acted on policy that was
320
+ // already gone -- renew_claim (read stale), acquire_claim (stale AND no row for #406's
321
+ // reset to clamp), and set_board_claim_policy itself (readers serialised on nothing).
322
+ // All three now take a board-scoped advisory xact lock BEFORE the policy decision --
323
+ // including acquire's 'advisory' path, which skips the block the agent-scoped lock lives
324
+ // in -- and re-read the policy as a separate statement; renew re-reads the whole CLAIM,
325
+ // because #400's reset clamps the very row whose pre-wait expiry is the convergence
326
+ // baseline. The lock helper REFUSES any isolation level but read committed, where the
327
+ // wait would not advance the snapshot and the lock would be silently inert.
328
+ '20260930000100_agent_board_claim_policy_race.sql',
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',
307
338
  ]) {
308
339
  expect(names.has(f)).toBe(true);
309
340
  }
@@ -17,6 +17,13 @@
17
17
  -- the kind of reasoning that stops being true when someone moves a line, and this file would
18
18
  -- otherwise be a set of tests that only ever prove the permissive direction.
19
19
  --
20
+ -- BCP-11..21 ARE #400: THE ADVISORY-REVERT CAP RESET. The opt-out is a TOGGLE, and that made it an
21
+ -- escape from every ownerless bound: flip to advisory, take an arbitrary horizon, flip back, repeat
22
+ -- — self-service, because create_board seeds the creator's owner membership and this RPC is gated
23
+ -- on exactly that. Reverting to inherit now re-applies the ownerless TTL cap to the board's active
24
+ -- ownerless claims, clamp only. The section pins all four transitions, both bounds (kind and board),
25
+ -- the operator flag, and the catalog class check that this RPC is the only writer of the column.
26
+ --
20
27
  -- BCP-5 IS THE SECOND ONE THAT MATTERS. A claim reaches its board through its thread. So the
21
28
  -- obvious hole is a claim that simply omits the thread — if that inherited "no board, therefore no
22
29
  -- enforcement", any agent could opt itself out by passing null. It resolves to 'inherit' instead,
@@ -26,7 +33,7 @@ set search_path = extensions, public, agent_board;
26
33
 
27
34
  begin;
28
35
 
29
- select plan(13);
36
+ select plan(30);
30
37
 
31
38
  -- ---------------------------------------------------------------------------
32
39
  -- Fixture: one board with an owner and a non-owner member, a board-linked thread, and a
@@ -193,6 +200,259 @@ select throws_ok(
193
200
  null,
194
201
  'BCP-9: an unknown policy is refused as a bad argument, naming the vocabulary — not as a raw CHECK violation');
195
202
 
203
+ -- ===========================================================================
204
+ -- BCP-11..21 (#400): THE ADVISORY-REVERT CAP RESET.
205
+ --
206
+ -- THE DEFECT. The advisory carve-out is resolved at ENFORCEMENT time against the board's CURRENT
207
+ -- policy, so an owner could flip to 'advisory', let an ownerless claim take an arbitrary horizon
208
+ -- (the carve-out skips both the #373 TTL cap and #388's convergence clamp), flip back to 'inherit',
209
+ -- and repeat before expiry. `create_board` seeds the creator's owner membership and this RPC is
210
+ -- gated on exactly that, so an agent that created a board holds the switch that exempts its own
211
+ -- claims: no accomplice. And #393's census resolves policy at QUERY time (OQ-29), so after the
212
+ -- revert the escaped claim contributes zero to `claims_on_advisory_boards` while its overage still
213
+ -- counts.
214
+ --
215
+ -- THE FIX. Reverting to 'inherit' means accepting inherit's rules: the ownerless TTL cap is
216
+ -- re-applied to this board's active ownerless claims, clamp only. The opt-out may be given back —
217
+ -- but not after banking a lease longer than inherit allows.
218
+ --
219
+ -- BOUNDED, NOT CLOSED, and this file does not pretend otherwise: each advisory window still banks
220
+ -- up to the cap, so an owner flipping every 29 days still defeats convergence. What is gone is the
221
+ -- UNBOUNDED horizon. ORQ-11 pins that bound end-to-end through the real RPCs.
222
+ --
223
+ -- NUMBERED 11+ AND PLACED HERE, BEFORE BCP-10, because BCP-10 fleet-revokes the owner and every
224
+ -- assertion below needs that owner to be able to call this RPC. Renumbering BCP-10 would break its
225
+ -- citations for no gain.
226
+ -- ===========================================================================
227
+ do $$
228
+ declare
229
+ v_b uuid := gen_random_uuid();
230
+ v_t uuid := gen_random_uuid();
231
+ begin
232
+ -- Session: the owner. BCP-7/8/9 set their GUCs INSIDE throws_ok, whose exception block rolls
233
+ -- them back with the raise, so the state here is the fixture's — but inferring that is exactly
234
+ -- the reading rule 4 exists for, so it is set explicitly instead.
235
+ perform set_config('request.jwt.claim.role', 'authenticated', true);
236
+ perform set_config('request.jwt.claim.sub', (select owner_user from _bcp)::text, true);
237
+
238
+ -- A SECOND BOARD, and it stays 'advisory' for the whole section. It is the scope witness for
239
+ -- BCP-14: reverting board A must not reach a board that never reverted. Advisory is also the
240
+ -- realistic shape — a board still coordinating through its own mutex, legitimately holding a
241
+ -- lease longer than our cap.
242
+ insert into agent_board.boards(id, slug, name, created_by_agent_id, claim_policy)
243
+ values (v_b, 'bcp400-' || substr(v_b::text, 1, 8), 'BCP 400 Other Board', (select owner_agent from _bcp), 'advisory');
244
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
245
+ values (v_t, 'coordination', (select owner_agent from _bcp), 'BCP 400 other-board thread', v_b);
246
+
247
+ create temp table _bcp400 (other_board uuid, other_thread uuid) on commit drop;
248
+ insert into _bcp400 values (v_b, v_t);
249
+ end $$;
250
+
251
+ select lives_ok(
252
+ $$ select agent_board.set_board_claim_policy((select board from _bcp), 'advisory') $$,
253
+ 'BCP-11a: the owner opts the board out again — this is the flip an over-quota holder makes to buy an unbounded horizon');
254
+
255
+ -- The claims the advisory window buys. Inserted directly rather than acquired, because three of
256
+ -- them are shapes acquire cannot produce in one call (a 'machine' kind needs a fleet fixture, one
257
+ -- belongs to another board, one has no thread) — but they are all shapes the DATABASE can hold:
258
+ -- an over-cap ownerless lease exists exactly where an advisory board, a grandfathered pre-flag
259
+ -- claim, or a service_role extension put it. BCP-11 scores a fourth claim that WAS acquired
260
+ -- through the real RPC under advisory, so the property is not proven only on synthetic rows.
261
+ insert into agent_board.claims(agent_id, thread_id, resource_kind, resource_key, lease_expires_at)
262
+ values
263
+ ((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-a-file', now() + interval '400 days'),
264
+ ((select owner_agent from _bcp), (select thread from _bcp), 'tool', 'bcp400-a-tool', now() + interval '400 days'),
265
+ ((select owner_agent from _bcp), (select thread from _bcp), 'custom', 'bcp400-a-custom', now() + interval '400 days'),
266
+ -- an OWNED kind on the same board: governed by the fleet-authority gate, never by this cap
267
+ ((select owner_agent from _bcp), (select thread from _bcp), 'machine', 'bcp400-a-machine', now() + interval '400 days'),
268
+ -- already WITHIN the cap: the twin. A reset that assigned now()+cap would LENGTHEN this one
269
+ ((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-a-under', now() + interval '5 days'),
270
+ -- another board, still advisory
271
+ ((select owner_agent from _bcp), (select other_thread from _bcp400), 'file', 'bcp400-b-file', now() + interval '400 days'),
272
+ -- no thread at all, so no board to revert
273
+ ((select owner_agent from _bcp), null, 'file', 'bcp400-free-file', now() + interval '400 days');
274
+
275
+ -- Two DEAD rows on the reverting board, for BCP-21. They are inserted separately because they need
276
+ -- columns the list above does not set.
277
+ insert into agent_board.claims(agent_id, thread_id, resource_kind, resource_key, status, released_at, lease_expires_at, updated_at)
278
+ values
279
+ -- released: least() would still shorten it, so this conjunct IS observable through the lease
280
+ ((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-a-released', 'released', now(), now() + interval '400 days', now() - interval '3 days'),
281
+ -- active but LAPSED: least(now()-1h, now()+cap) is the lease it already has, so this conjunct is
282
+ -- observable ONLY through updated_at — which is why the row carries a distinctive one
283
+ ((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-a-lapsed', 'active', null, now() - interval '1 hour', now() - interval '3 days');
284
+
285
+ select lives_ok(
286
+ $$ select agent_board.set_board_claim_policy((select board from _bcp), 'inherit') $$,
287
+ 'BCP-11b: and hands the opt-out back — the step that used to cost the escaping agent nothing');
288
+
289
+ -- ---------------------------------------------------------------------------
290
+ -- BCP-11: THE POINT. Every active ownerless claim on the reverting board is clamped to the TTL cap.
291
+ -- All three ownerless kinds are scored: every other assertion here drives `file`, and dropping
292
+ -- 'tool' or 'custom' from the kind list is the mutation that passed ORT's entire battery (ORT-9).
293
+ -- `rpc-acquired` is 'bcp-over-cap-advisory' from BCP-3b — taken through acquire_claim under
294
+ -- advisory and clamped by this file's own earlier revert, so the property is pinned on a row a
295
+ -- deployment can actually produce and not only on inserted fixtures.
296
+ -- ---------------------------------------------------------------------------
297
+ select is(
298
+ (select string_agg(l.label || '=' ||
299
+ (c.lease_expires_at = now() + agent_board._claimgate_ownerless_ttl_cap())::text, ', ' order by l.label)
300
+ from agent_board.claims c
301
+ join (values ('bcp400-a-file', 'file'),
302
+ ('bcp400-a-tool', 'tool'),
303
+ ('bcp400-a-custom', 'custom'),
304
+ ('bcp-over-cap-advisory', 'rpc-acquired')) as l(key, label) on l.key = c.resource_key),
305
+ 'custom=true, file=true, rpc-acquired=true, tool=true',
306
+ 'BCP-11 THE POINT: reverting advisory -> inherit re-applies the ownerless TTL cap to this board''s active claims, all three kinds — so a horizon banked under the opt-out cannot be kept by handing the opt-out back');
307
+
308
+ -- ---------------------------------------------------------------------------
309
+ -- BCP-12: THE TWIN, and the reason the clamp is least(). A claim already within the cap must come
310
+ -- back untouched: an assignment to now()+cap would satisfy BCP-11 and silently LENGTHEN this lease
311
+ -- from five days to thirty. Asserts the lease only — the row's updated_at IS bumped, which is the
312
+ -- documented cost of least() being the single mutation-visible clamp (see the migration header).
313
+ -- ---------------------------------------------------------------------------
314
+ select is(
315
+ (select lease_expires_at from agent_board.claims where resource_key = 'bcp400-a-under'),
316
+ now() + interval '5 days',
317
+ 'BCP-12 THE TWIN: a lease already within the cap is preserved to the microsecond — the reset is least(held, now()+cap), not a rewrite to now()+cap, which would hand back time the claim never had');
318
+
319
+ -- ---------------------------------------------------------------------------
320
+ -- BCP-13: the kind bound. 'machine' is an OWNED kind, governed by the fleet-authority gate and by
321
+ -- no ownerless cap at all, so the reset must not touch it — a reset that clamped every claim on the
322
+ -- board would satisfy BCP-11 and quietly shorten fleet leases.
323
+ -- ---------------------------------------------------------------------------
324
+ select is(
325
+ (select lease_expires_at from agent_board.claims where resource_key = 'bcp400-a-machine'),
326
+ now() + interval '400 days',
327
+ 'BCP-13: a machine claim on the reverting board is untouched — the reset is scoped to the ownerless kinds, which are the only ones the TTL cap ever governed');
328
+
329
+ -- ---------------------------------------------------------------------------
330
+ -- BCP-14: the board bound. This board's revert is a statement about THIS board. A claim on another
331
+ -- board — still advisory, legitimately holding a long lease — and a claim with no thread at all
332
+ -- must both survive it. Dropping the board join would clamp every ownerless claim in the fleet on
333
+ -- any revert anywhere, and every assertion above would still pass.
334
+ -- ---------------------------------------------------------------------------
335
+ select is(
336
+ (select string_agg(l.label || '=' || (c.lease_expires_at = now() + interval '400 days')::text, ', ' order by l.label)
337
+ from agent_board.claims c
338
+ join (values ('bcp400-b-file', 'other-board'),
339
+ ('bcp400-free-file', 'no-thread')) as l(key, label) on l.key = c.resource_key),
340
+ 'no-thread=true, other-board=true',
341
+ 'BCP-14 SCOPE: a claim on a DIFFERENT board and a claim with no thread are both untouched by this board''s revert — the reset resolves through the claim''s thread to this board, the same path the policy resolver uses');
342
+
343
+ -- ---------------------------------------------------------------------------
344
+ -- BCP-21: THE DEAD ROWS. The reset is scoped to ACTIVE, unreleased, unexpired claims — the same
345
+ -- three conjuncts the shared count predicate uses — so a revert does not rewrite history.
346
+ --
347
+ -- The two halves are scored differently on purpose. A RELEASED row still holds a future timestamp,
348
+ -- so dropping `status = 'active' and released_at is null` shortens it and the LEASE moves. A LAPSED
349
+ -- row's lease is already in the past, and least(past, now()+cap) is that same past value — so
350
+ -- dropping `lease_expires_at > now()` is invisible in the lease and visible ONLY as a touched row.
351
+ -- Both rows therefore carry a distinctive updated_at, and this assertion reads it.
352
+ -- ---------------------------------------------------------------------------
353
+ select is(
354
+ (select string_agg(l.label || '=' ||
355
+ (c.lease_expires_at = l.expected and c.updated_at = now() - interval '3 days')::text, ', ' order by l.label)
356
+ from agent_board.claims c
357
+ join (values ('bcp400-a-released', 'released', now() + interval '400 days'),
358
+ ('bcp400-a-lapsed', 'lapsed', now() - interval '1 hour')) as l(key, label, expected) on l.key = c.resource_key),
359
+ 'lapsed=true, released=true',
360
+ 'BCP-21: a RELEASED claim and a LAPSED one on the reverting board are not touched at all — same lease, same updated_at. The reset governs live leases, not the ledger of dead ones');
361
+
362
+ -- ---------------------------------------------------------------------------
363
+ -- BCP-15..17: THE TRANSITION CONDITION, all three non-triggering transitions. The rows below are
364
+ -- inserted once, while the board is 'inherit', and each is read after its own transition.
365
+ --
366
+ -- BCP-17 (advisory -> advisory) is the one the other two cannot see: dropping the `= 'inherit'`
367
+ -- conjunct leaves "was previously advisory", which still clamps nothing on either inherit-first
368
+ -- case. Without it that mutation survives BCP-15 and BCP-16 untouched.
369
+ -- ---------------------------------------------------------------------------
370
+ insert into agent_board.claims(agent_id, thread_id, resource_kind, resource_key, lease_expires_at)
371
+ values
372
+ ((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-noop-ii', now() + interval '400 days'),
373
+ ((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-noop-ia', now() + interval '400 days'),
374
+ ((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-noop-aa', now() + interval '400 days'),
375
+ ((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-flag-off', now() + interval '400 days');
376
+
377
+ select lives_ok(
378
+ $$ select agent_board.set_board_claim_policy((select board from _bcp), 'inherit') $$,
379
+ 'BCP-15a: the owner re-sets inherit on a board that is already inherit');
380
+
381
+ select is(
382
+ (select lease_expires_at from agent_board.claims where resource_key = 'bcp400-noop-ii'),
383
+ now() + interval '400 days',
384
+ 'BCP-15: inherit -> inherit clamps NOTHING — a no-op re-set is not a policy change, and treating it as one would let any owner shorten their own board''s leases by calling the RPC twice');
385
+
386
+ select lives_ok(
387
+ $$ select agent_board.set_board_claim_policy((select board from _bcp), 'advisory') $$,
388
+ 'BCP-16a: the owner opts out');
389
+
390
+ select is(
391
+ (select lease_expires_at from agent_board.claims where resource_key = 'bcp400-noop-ia'),
392
+ now() + interval '400 days',
393
+ 'BCP-16: inherit -> advisory clamps nothing — the reset belongs to the REVERT, and clamping while opting OUT would be the opposite of what the opt-out promises');
394
+
395
+ select lives_ok(
396
+ $$ select agent_board.set_board_claim_policy((select board from _bcp), 'advisory') $$,
397
+ 'BCP-17a: and re-sets advisory on a board that is already advisory');
398
+
399
+ select is(
400
+ (select lease_expires_at from agent_board.claims where resource_key = 'bcp400-noop-aa'),
401
+ now() + interval '400 days',
402
+ 'BCP-17: advisory -> advisory clamps nothing — the discriminating case, because a guard that asked only "was it advisory?" passes BCP-15 and BCP-16 and would clamp a board that never came back');
403
+
404
+ -- ---------------------------------------------------------------------------
405
+ -- BCP-18: ONE OPERATOR FLAG. With ownerless_guards off, nothing enforces the TTL cap at acquire or
406
+ -- at renew, so a revert must not enforce it either — the flag means the same thing on all three
407
+ -- write paths or it means nothing.
408
+ -- ---------------------------------------------------------------------------
409
+ do $$
410
+ begin
411
+ update agent_board.claimgate_enforcement_flags set enabled = false where scope_key = 'ownerless_guards';
412
+ end $$;
413
+
414
+ select lives_ok(
415
+ $$ select agent_board.set_board_claim_policy((select board from _bcp), 'inherit') $$,
416
+ 'BCP-18a: with the guards off the owner reverts to inherit');
417
+
418
+ select is(
419
+ (select lease_expires_at from agent_board.claims where resource_key = 'bcp400-flag-off'),
420
+ now() + interval '400 days',
421
+ 'BCP-18: with ownerless_guards OFF an advisory -> inherit revert clamps nothing — one flag governs the cap at acquire, at renew and at the revert alike');
422
+
423
+ do $$
424
+ begin
425
+ update agent_board.claimgate_enforcement_flags set enabled = true where scope_key = 'ownerless_guards';
426
+ end $$;
427
+
428
+ -- ---------------------------------------------------------------------------
429
+ -- BCP-19/20: CLASS (rule 1). The hook can only close the escape if this RPC is the ONLY way
430
+ -- boards.claim_policy changes. Derived from the catalog, not from a list: a second writer names
431
+ -- itself here rather than silently inheriting a policy change that resets nothing.
432
+ --
433
+ -- Two independent bounds, because neither is sufficient alone. BCP-19 is a source scan and a source
434
+ -- scan has a floor — it sees `set claim_policy` and not, say, a dynamic EXECUTE. BCP-20 is the
435
+ -- privilege bound underneath it: NO role holds UPDATE on agent_board.boards, so the column cannot
436
+ -- be written from a session at all, only from a SECURITY DEFINER function (which BCP-19 enumerates)
437
+ -- or by the table owner. `boards` also carries no user triggers, so there is no third path.
438
+ -- ---------------------------------------------------------------------------
439
+ select is(
440
+ (select coalesce(string_agg(p.proname, ', ' order by p.proname), '')
441
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
442
+ where n.nspname = 'agent_board'
443
+ and agent_board_test.stripped_body(p.oid, true) ~ 'set\s+claim_policy'),
444
+ 'set_board_claim_policy',
445
+ 'BCP-19 CLASS: set_board_claim_policy is the ONLY function that writes boards.claim_policy — a second writer would need the same advisory-revert reset and fails here instead of being remembered');
446
+
447
+ select is(
448
+ format('anon=%s, authenticated=%s, service_role=%s, user_triggers=%s',
449
+ has_table_privilege('anon', 'agent_board.boards', 'update')::text,
450
+ has_table_privilege('authenticated', 'agent_board.boards', 'update')::text,
451
+ has_table_privilege('service_role', 'agent_board.boards', 'update')::text,
452
+ (select count(*) from pg_trigger where tgrelid = 'agent_board.boards'::regclass and not tgisinternal)::text),
453
+ 'anon=false, authenticated=false, service_role=false, user_triggers=0',
454
+ 'BCP-20 CLASS: no role holds UPDATE on agent_board.boards and the table carries no user triggers — so the column is unreachable except through the definer function BCP-19 enumerates, which is the bound a source scan cannot give');
455
+
196
456
  -- ---------------------------------------------------------------------------
197
457
  -- BCP-10: A FLEET-REVOKED OWNER IS STILL AN OWNER BY MEMBERSHIP. This function shipped its first
198
458
  -- version WITHOUT this check while its own commit message claimed to mirror set_board_role_gate
@@ -850,14 +850,23 @@ select is(
850
850
  -- sides: the catalog function scan, and the effective-privilege assertions after it (no role
851
851
  -- holds a write privilege by ANY route, so a direct write is impossible and the SECURITY
852
852
  -- DEFINER bodies are the whole surface).
853
+ --
854
+ -- set_board_claim_policy (#400) is the FIFTH member, and it named itself here rather than being
855
+ -- remembered -- which is what this assertion is for. It creates no claims row and re-keys nothing:
856
+ -- its only claims write is the advisory -> inherit cap reset, `set lease_expires_at =
857
+ -- least(lease_expires_at, now() + cap), updated_at = now()`, on the reverting board's active
858
+ -- ownerless claims. That EXEMPTION IS ASSERTED POSITIVELY, not by absence: BCP-11/13/14 look those
859
+ -- claims up BY resource_key and score the resulting timestamps, so a reset that re-keyed or
860
+ -- inserted a row fails them by name, and assertion 51 below (every function that CREATES a claims
861
+ -- row refuses without thread access) still holds because this one creates none.
853
862
  select is(
854
863
  (select coalesce(string_agg(p.proname, ',' order by p.proname), '(none)')
855
864
  from pg_proc p
856
865
  where p.pronamespace = 'agent_board'::regnamespace
857
866
  and lower(agent_board_test.stripped_body(p.oid, true))
858
867
  ~ '(insert\s+into|merge\s+into|update|delete\s+from|truncate)(\s+table)?(\s+only)?\s+(("?agent_board"?\s*\.\s*)?claims\M|("?agent_board"?\s*\.\s*)?"claims")'),
859
- 'acquire_claim,reclaim_fleet_machine,release_claim,renew_claim',
860
- 'catalog: these four are the ONLY agent_board functions that write claims (insert/merge/update/delete/TRUNCATE) -- a new writer names itself here');
868
+ 'acquire_claim,reclaim_fleet_machine,release_claim,renew_claim,set_board_claim_policy',
869
+ 'catalog: these five are the ONLY agent_board functions that write claims (insert/merge/update/delete/TRUNCATE) -- a new writer names itself here');
861
870
 
862
871
  select is(
863
872
  (select coalesce(string_agg(p.proname, ',' order by p.proname), '(none)')
@@ -211,8 +211,14 @@ select set_eq(
211
211
  -- active machine claim(s) on the reclaimed fingerprint so the new owner can acquire. It routes the
212
212
  -- key through _canonical_claim_key (assertion 25 stays 0); its positive state claim (status set to
213
213
  -- 'released') is pinned in agent_board_fleet_machine_reclaim.test.sql.
214
- array['acquire_claim', 'renew_claim', 'release_claim', 'reclaim_fleet_machine'],
215
- 'exactly acquire/renew/release/reclaim write agent_board.claims (catalog-enumerated writer class)');
214
+ -- set_board_claim_policy (#400) is a fifth writer: an advisory -> inherit revert re-applies the
215
+ -- ownerless TTL cap to that board's active ownerless claims. It is EXEMPT FROM ASSERTION 25 by a
216
+ -- DERIVED property and not by name -- its claims UPDATE sets only lease_expires_at/updated_at and
217
+ -- its body never mentions resource_key at all, so there is no key for the normalizer to canonicalize.
218
+ -- The exemption's positive claim (the reset moves lease timestamps and nothing else) is scored in
219
+ -- agent_board_board_claim_policy.test.sql BCP-11/13/14, which look the claims up BY resource_key.
220
+ array['acquire_claim', 'renew_claim', 'release_claim', 'reclaim_fleet_machine', 'set_board_claim_policy'],
221
+ 'exactly acquire/renew/release/reclaim/set_board_claim_policy write agent_board.claims (catalog-enumerated writer class)');
216
222
  select is(
217
223
  (select count(*)::int
218
224
  from pg_proc p
@@ -220,6 +226,15 @@ select is(
220
226
  where n.nspname = 'agent_board'
221
227
  and p.prokind = 'f'
222
228
  and agent_board_test.stripped_body(p.oid) ~* '(insert\s+into|update)\s+(agent_board\.)?claims\M'
229
+ -- #400: SCOPED TO WRITERS THAT HAVE A KEY TO NORMALIZE. A claims writer whose body never
230
+ -- mentions resource_key cannot set one -- an UPDATE must name the column in its SET list and
231
+ -- an INSERT must name it in its column list (resource_key is NOT NULL) -- so requiring it to
232
+ -- call the normalizer would be dead code added to satisfy a check, which is the anti-pattern
233
+ -- rule 2 names. DERIVED, not a name exemption: a new writer that touches resource_key at all
234
+ -- is still in the population and still fails closed. STATED HOLE: `insert into claims select *
235
+ -- from ...` names no column and would slip past, which is the same shape assertion 24's regex
236
+ -- already cannot see.
237
+ and agent_board_test.stripped_body(p.oid) ~* '\mresource_key\M'
223
238
  -- message-board#196 Bucket B: read RAW, one comment naming _canonical_claim_key
224
239
  -- dropped a bypass writer out of this set and the count went to 0 green.
225
240
  -- anchored to the CALL shape: a bare-substring `!~` is satisfied by any