@consiliency/agent-board-schema 1.48.0 → 1.50.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 (28) hide show
  1. package/fixtures/already-applied.json +12 -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/package.json +1 -1
  6. package/schema-manifest.json +30 -3
  7. package/src/tooling.test.mjs +25 -2
  8. package/tests/agent_board_board_claim_policy.test.sql +261 -1
  9. package/tests/agent_board_claim_subject_gate.test.sql +11 -2
  10. package/tests/agent_board_claimkey.test.sql +17 -2
  11. package/tests/agent_board_claimpolicy_race.test.sql +1374 -0
  12. package/tests/agent_board_ownerless_quota_census.test.sql +6 -0
  13. package/tests/agent_board_ownerless_renew_quota.test.sql +37 -8
  14. package/tests/catalog/p_metadata_parameters.json +16 -0
  15. package/tests/fixtures/test-prereqs.sql +186 -0
  16. package/tests/isolation/expected/claimpolicy_acquire_revert_race.out +66 -0
  17. package/tests/isolation/expected/claimpolicy_board_scope.out +73 -0
  18. package/tests/isolation/expected/claimpolicy_claimrow_lock_order.out +80 -0
  19. package/tests/isolation/expected/claimpolicy_isolation_refusal.out +114 -0
  20. package/tests/isolation/expected/claimpolicy_renew_revert_race.out +66 -0
  21. package/tests/isolation/expected/claimpolicy_writer_keyshare.out +49 -0
  22. package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +118 -0
  23. package/tests/isolation/specs/claimpolicy_board_scope.spec +115 -0
  24. package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +200 -0
  25. package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +162 -0
  26. package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +150 -0
  27. package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +107 -0
  28. package/tests/seeds/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql.pre.sql +99 -0
@@ -0,0 +1,158 @@
1
+ -- Consiliency/message-board#405 — the board policy lock helper is granted to NOBODY.
2
+ --
3
+ -- WHAT THIS CLOSES, measured rather than argued. 20260930000100 revoked
4
+ -- `_claimgate_lock_board_policy` from public/anon/authenticated and GRANTED it to `service_role`,
5
+ -- on the reasoning (stated in its own comment) that the serialisation "cannot be reached from a
6
+ -- session". `service_role` IS a session role. With that grant in place a single service_role
7
+ -- transaction assembles the exact inversion claimpolicy_claimrow_lock_order.spec constructs by
8
+ -- hand:
9
+ --
10
+ -- BEGIN; select agent_board.renew_claim(<token>, …); -- claims ROW LOCK, no board lock
11
+ -- select agent_board._claimgate_lock_board_policy(<board>); -- board lock, AFTER it
12
+ --
13
+ -- and a concurrent `set_board_claim_policy` on that board (board lock, then #400's cap-reset
14
+ -- UPDATE onto the row session 1 holds) closes the cycle. Reproduced against this bundle as
15
+ -- `ERROR: deadlock detected` before this migration existed.
16
+ --
17
+ -- WHY renew_claim STILL TAKES THE ROW LOCK FOR service_role, which is the part that makes the
18
+ -- composition work: its `update agent_board.claims` sits OUTSIDE the ownerless block, so
19
+ -- `_is_service_role()` short-circuits the BOARD lock while the ROW lock is taken regardless. The
20
+ -- caller therefore holds a claims row lock and no board lock — precisely the state the inversion
21
+ -- needs — and only needs one more granted call to want the board lock.
22
+ --
23
+ -- WHY REVOKING IS SAFE, derived from the catalog rather than assumed. The helper is NOT
24
+ -- `SECURITY DEFINER`; its three callers (`acquire_claim`, `renew_claim`, `set_board_claim_policy`)
25
+ -- ARE, and are owned by the same role that owns the helper. A SECURITY DEFINER function executes
26
+ -- as its owner, and an owner needs no grant on its own function, so the definer path is unaffected
27
+ -- by any grant at all. CPR-22 asserts the empty ACL; CPR-22a asserts the definer path still
28
+ -- reaches the lock AND that the advisory lock is genuinely held afterwards, so the negative cannot
29
+ -- pass by the enforcement having been taken offline.
30
+ --
31
+ -- NOTHING CALLS IT DIRECTLY. Derived from the tree on this branch, not from a live database: the
32
+ -- only references to `_claimgate_lock_board_policy` outside its defining migration are in this
33
+ -- package's pgTAP file, the isolation-spec COMMENTS, and the p_metadata catalog snapshot. No
34
+ -- TypeScript package (client/cli/mcp/runtime) and no Edge Function names it, in any casing.
35
+ --
36
+ -- WHY A NEW MIGRATION RATHER THAN EDITING 20260930000100's GRANT LINE. That migration is this
37
+ -- PR's own and still unmerged, so amending it would be tidier history — but this repository cannot
38
+ -- prove that no database has already applied it (the canary project tracks this branch), and a
39
+ -- bundle is replayed by digest rather than re-derived. A forward revoke is correct whether or not
40
+ -- the grant was ever applied, and `revoke` on an absent grant is a no-op. Replay-safe either way.
41
+ --
42
+ -- NOT `DROP FUNCTION`: that would discard the ACL *and* the COMMENT and re-grant EXECUTE to PUBLIC
43
+ -- by default (AGENTS.md, "Schema-change mechanics that bite"). The function body is unchanged here;
44
+ -- only its ACL and its comment move.
45
+
46
+ -- THE REVOKE IS DERIVED, ACROSS EVERY OVERLOAD, AND CASCADES. Three separate holes closed here,
47
+ -- each of which would leave this migration's own comment asserting something false:
48
+ --
49
+ -- 1. NAMED ROLES ARE A HAND LIST. `revoke ... from public, anon, authenticated, service_role`
50
+ -- leaves a grant to any fifth role standing. That is not hypothetical bookkeeping — this bundle
51
+ -- is REPLAYED ONTO ADOPTER DATABASES we do not control, where an operator may have granted this
52
+ -- helper to a role we have never heard of.
53
+ --
54
+ -- 2. ONE SIGNATURE IS A HAND LIST TOO. Keying the sweep to `(uuid)` lets a `(text)` overload carry
55
+ -- a grant straight through it. The property wanted is about the NAME: nothing in agent_board
56
+ -- called _claimgate_lock_board_policy should be executable by a non-owner, whatever its
57
+ -- argument types. Every overload is swept.
58
+ --
59
+ -- 3. `REVOKE` DEFAULTS TO `RESTRICT`, AND RESTRICT FAILS ON A DEPENDENT GRANT. If a grantee holds
60
+ -- EXECUTE **WITH GRANT OPTION** and has granted it onward, a plain REVOKE ERRORS ("dependent
61
+ -- privileges exist") — and on an adopter database that is a FAILED MIGRATION, not a tidier ACL,
62
+ -- with the empty ACL not achieved either. `CASCADE` is chosen deliberately over enumerating
63
+ -- dependencies: the whole claim of this migration is that NOTHING non-owner holds EXECUTE, so a
64
+ -- downstream grant is exactly as unwanted as the grant it hangs from, and handling dependencies
65
+ -- individually would only reach the same end state by a longer route. Stated rather than
66
+ -- slipped in, because CASCADE silently removes grants a third party made: an operator who
67
+ -- deliberately delegated this helper onward will find that delegation gone after upgrading.
68
+ -- That is the intended outcome — the helper is not meant to be reachable from any session — but
69
+ -- it is a real effect on someone else's database and belongs in the record.
70
+ --
71
+ -- 4. `ON FUNCTION` IS THE WRONG VERB FOR A ROUTINE, AND RAISES. The scan above selects by NAME,
72
+ -- which admits any pg_proc row — a legal same-name PROCEDURE or AGGREGATE included. `REVOKE …
73
+ -- ON FUNCTION` against a procedure fails with `is not a function`, and it would fail at the
74
+ -- MATERIALISATION statement, before the sweep runs at all: a second adopter-upgrade abort from
75
+ -- the same source as the RESTRICT one, a query written for functions applied to routines.
76
+ -- Measured on this bundle: `on function` against a procedure errors, `on routine` succeeds
77
+ -- against a procedure AND an aggregate. So both statements say ROUTINE and there is no prokind
78
+ -- filter — operating on routines consistently is the choice, rather than narrowing the scan to
79
+ -- prokind='f' and leaving a same-name procedure's grant standing. Window functions ('w') cannot
80
+ -- be created without C-level support and are untested here; ROUTINE is the widest verb available
81
+ -- and no narrower verb would cover more.
82
+ --
83
+ -- The OWNER is excluded throughout: EXECUTE is implicit in ownership, cannot be revoked, and is
84
+ -- exactly the path the three SECURITY DEFINER callers use.
85
+ --
86
+ -- A missing function RAISES rather than silently sweeping nothing: this migration exists to
87
+ -- establish an ACL, and doing that to a function that is not there is a failure, not a success.
88
+ -- Likewise an unresolvable grantee OID. Both differ from CPR-22, which RENDERS those cases instead
89
+ -- — a raise inside a pgTAP assertion aborts the file and emits no `not ok`, whereas here failing
90
+ -- closed is right, because skipping silently would leave a live grant standing.
91
+ do $$
92
+ declare
93
+ v_overloads int := 0;
94
+ v_who text;
95
+ f record;
96
+ r record;
97
+ begin
98
+ for f in
99
+ select p.oid, p.oid::regprocedure::text as sig
100
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
101
+ where n.nspname = 'agent_board'
102
+ and p.proname = '_claimgate_lock_board_policy'
103
+ loop
104
+ v_overloads := v_overloads + 1;
105
+
106
+ -- MATERIALISE THE ACL FIRST. While `proacl` is NULL the function carries DEFAULT privileges,
107
+ -- and the default for a function is EXECUTE to PUBLIC — but aclexplode(NULL) yields no rows,
108
+ -- so the sweep below would find nothing to revoke on a database where no grant was ever
109
+ -- written explicitly. This statement both removes that implicit PUBLIC grant and populates
110
+ -- proacl so the sweep can see the rest.
111
+ execute format('revoke all on routine %s from public cascade', f.sig);
112
+
113
+ for r in
114
+ select distinct a.grantee
115
+ from pg_proc p, aclexplode(p.proacl) a
116
+ where p.oid = f.oid
117
+ and a.privilege_type = 'EXECUTE'
118
+ and a.grantee <> p.proowner
119
+ loop
120
+ if r.grantee = 0 then
121
+ v_who := 'public';
122
+ else
123
+ select quote_ident(rolname) into v_who from pg_roles where oid = r.grantee;
124
+ end if;
125
+ if v_who is null then
126
+ raise exception
127
+ 'EXECUTE grantee % on % has no pg_roles row — cannot revoke it, and reporting an empty ACL would be false',
128
+ r.grantee, f.sig
129
+ using errcode = '42704';
130
+ end if;
131
+ execute format('revoke all on routine %s from %s cascade', f.sig, v_who);
132
+ end loop;
133
+ end loop;
134
+
135
+ if v_overloads = 0 then
136
+ raise exception
137
+ 'no function named agent_board._claimgate_lock_board_policy — refusing to report an empty ACL for a function that does not exist'
138
+ using errcode = '42883';
139
+ end if;
140
+ end $$;
141
+
142
+ -- The comment 20260930000100 attached asserts the service_role grant this migration removes, and
143
+ -- cites CPR-14 for the ACL — CPR-14 is the claims row-lock class assertion; CPR-22 is the ACL one.
144
+ -- Both corrected here, in the same migration that makes the first sentence true.
145
+ comment on function agent_board._claimgate_lock_board_policy(uuid) is
146
+ '#405: the board-scoped serialisation point for claim policy. Takes a pg_advisory_xact_lock on '
147
+ 'hashtextextended(''agent_board.board_claim_policy:''||board_id) so set_board_claim_policy''s '
148
+ 'flip and every ownerless enforcement decision on that board are totally ordered, and RAISES '
149
+ '0A000 unless transaction_isolation is ''read committed'' — under repeatable read or '
150
+ 'serializable the wait does not advance the caller''s snapshot, so the lock would be silently '
151
+ 'ineffective; under read uncommitted the lock still works (PostgreSQL runs it as read '
152
+ 'committed) and the refusal is a deliberate one-accepted-spelling contract rather than a '
153
+ 'correctness requirement. A null board is isolation-checked and takes no lock. '
154
+ 'EXECUTE is granted to NO role (#405 follow-up): the helper is not SECURITY DEFINER, its three '
155
+ 'callers are and share its owner, so they reach it by ownership. The grant that existed briefly '
156
+ 'let a service_role session compose renew_claim''s claims row lock with a direct call to this '
157
+ 'helper and deadlock against the policy writer. CPR-22 pins the empty ACL; CPR-22a pins that '
158
+ 'the definer path still works.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-schema",
3
- "version": "1.48.0",
3
+ "version": "1.50.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.48.0",
5
+ "packageVersion": "1.50.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": 146,
50
- "aggregateSha256": "cbb24bed446f3334f4c0593716b51ee00e5b6faf57bfd7b48a8767f019551551",
49
+ "migrationCount": 149,
50
+ "aggregateSha256": "c15239411cdaff38ce6a52294d6586ae6fe711cdea40938785c55e7e38e13c0b",
51
51
  "migrations": [
52
52
  {
53
53
  "order": 0,
@@ -1362,6 +1362,33 @@
1362
1362
  "sourceRef": "message-board",
1363
1363
  "bytes": 36921,
1364
1364
  "sha256": "9982efff25378703a744187b15081b5cd53ab38fa6ac7d2950092a471780169e"
1365
+ },
1366
+ {
1367
+ "order": 146,
1368
+ "id": "20260929000100_agent_board_advisory_revert_cap_reset",
1369
+ "filename": "20260929000100_agent_board_advisory_revert_cap_reset.sql",
1370
+ "origin": "message-board-forward",
1371
+ "sourceRef": "message-board",
1372
+ "bytes": 24052,
1373
+ "sha256": "f2bd4e9e06f5c348aba379a004a010d8dba29a84f66b1b4569313fad4a0046ba"
1374
+ },
1375
+ {
1376
+ "order": 147,
1377
+ "id": "20260930000100_agent_board_claim_policy_race",
1378
+ "filename": "20260930000100_agent_board_claim_policy_race.sql",
1379
+ "origin": "message-board-forward",
1380
+ "sourceRef": "message-board",
1381
+ "bytes": 58999,
1382
+ "sha256": "1893b57ccb643a8293c0dc801d2d66d20fe1d71a2b7dc95cd93f64ced39b472d"
1383
+ },
1384
+ {
1385
+ "order": 148,
1386
+ "id": "20261001000100_agent_board_claimpolicy_lock_grant_revoke",
1387
+ "filename": "20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql",
1388
+ "origin": "message-board-forward",
1389
+ "sourceRef": "message-board",
1390
+ "bytes": 10193,
1391
+ "sha256": "db73a9941d4601dd6db0c767fdc718534fe2856598a305f4747496cadc722afd"
1365
1392
  }
1366
1393
  ],
1367
1394
  "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 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 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)', () => {
42
42
  expect(manifest.migrationCount).toBe(manifest.migrations.length);
43
- expect(manifest.migrationCount).toBe(146);
43
+ expect(manifest.migrationCount).toBe(149);
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,29 @@ 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',
307
330
  ]) {
308
331
  expect(names.has(f)).toBe(true);
309
332
  }
@@ -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