@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
@@ -0,0 +1,922 @@
1
+ -- #405 — the stale-policy race family. `_claimgate_board_claim_policy` took NO lock on `boards`,
2
+ -- so any operation whose snapshot predates a committed policy change acted on policy that was
3
+ -- already gone.
4
+ --
5
+ -- WHAT #406 LEFT OPEN, IN ITS OWN WORDS. That migration's header records limitation 3: "an
6
+ -- in-flight renew can still win the race, and this is unpinned ... closing it means taking a
7
+ -- `boards` lock on the renew path, which is a LOCK-ORDER decision and belongs with the two-session
8
+ -- isolationtester harness, not with this change." This is that change. The wording is corrected at
9
+ -- the bottom of this file rather than left to be disbelieved (rule 5).
10
+ --
11
+ -- THREE ENTRY POINTS, AND THE THIRD IS THE ONE A LIST WOULD MISS:
12
+ --
13
+ -- 1. `renew_claim` — a renew whose snapshot predates the revert resolves 'advisory', skips both
14
+ -- the #373 cap and the #388 convergence clamp, and writes a full horizon AFTER the revert's
15
+ -- row lock is released at commit. The attacker owns both sessions, so the interleave is
16
+ -- deliberate, not lucky.
17
+ -- 2. `acquire_claim` — the SAME read, and worse: there is no claim row yet, so #406's cap reset
18
+ -- has nothing to clamp. A stale 'advisory' read here banks a full-horizon lease that the
19
+ -- revert cannot reach even in principle.
20
+ -- 3. `set_board_claim_policy` itself — two concurrent flips serialised on the boards ROW, which
21
+ -- is what makes the outgoing-policy read correct, but a READER took no lock at all and so
22
+ -- serialised against neither.
23
+ --
24
+ -- ===========================================================================
25
+ -- THE MECHANISM: A BOARD-SCOPED ADVISORY XACT LOCK, TAKEN BEFORE THE POLICY DECISION, WITH THE
26
+ -- POLICY READ AFTER IT AS A SEPARATE STATEMENT.
27
+ -- ===========================================================================
28
+ --
29
+ -- WHERE "BEFORE THE POLICY DECISION" IS LOAD-BEARING, and where the first draft of this fix was
30
+ -- wrong. It said "before the agent-scoped lock". In `acquire_claim` that agent lock lives INSIDE
31
+ -- the branch a stale 'advisory' read SKIPS — so on the dangerous path it is never reached and
32
+ -- ordering against it means nothing. The board opt-out was a CONJUNCT of the `if`; it is hoisted
33
+ -- out, and the lock now precedes the decision on every path through the guarded region, including
34
+ -- the one that observes 'advisory' and does nothing else.
35
+ --
36
+ -- WHY A SEPARATE STATEMENT. Under READ COMMITTED each statement takes a fresh snapshot, so a
37
+ -- policy read issued AFTER the lock was granted observes the writer's committed value. Fused into
38
+ -- one statement it would not: that statement's snapshot is taken before the lock is granted.
39
+ --
40
+ -- ===========================================================================
41
+ -- ISOLATION LEVEL: DETECTED AND REFUSED, NOT ASSUMED.
42
+ -- ===========================================================================
43
+ --
44
+ -- Under REPEATABLE READ and SERIALIZABLE the transaction's snapshot is fixed at first use. Waiting
45
+ -- on the writer's lock does NOT advance it, so the re-read returns the SAME stale policy and the
46
+ -- advisory lock does not fail closed — it fails silently, which is worse than not being there.
47
+ -- `_claimgate_lock_board_policy` therefore RAISES (0A000) when
48
+ -- `current_setting('transaction_isolation')` is anything but `read committed`.
49
+ --
50
+ -- 'read uncommitted' is refused too, even though PostgreSQL implements it AS read committed. A
51
+ -- fail-closed guard with one accepted spelling stays honest; one carrying an equivalence argument
52
+ -- has to be re-argued every time someone reads it, and the refusal costs a caller nothing it
53
+ -- cannot fix with one `set transaction isolation level`.
54
+ --
55
+ -- NOTHING IN THIS TREE RUNS AT ANOTHER LEVEL — measured, not assumed: a repo-wide search for
56
+ -- `repeatable read` / `serializable` / `isolation level` / `default_transaction_isolation` finds
57
+ -- only two PROSE mentions (a plan and 20260901000110's header, both recording SERIALIZABLE as an
58
+ -- out-of-scope posture change) and no code that sets one. So the refusal breaks no caller that
59
+ -- exists. The refusal is pinned BEHAVIOURALLY by claimpolicy_isolation_refusal.spec, which now
60
+ -- enumerates repeatable read, serializable AND read uncommitted, and STRUCTURALLY by CPR-25a,
61
+ -- which asserts this guard is an inequality against the one ACCEPTED level rather than an
62
+ -- equality against one refused level. (It used to cite CPR-13, which is the threads.board_id
63
+ -- table-privilege assertion and has nothing to do with isolation.)
64
+ --
65
+ -- ===========================================================================
66
+ -- THE SOLE-WRITER PROOF, RE-DERIVED AND DELIBERATELY WIDER THAN #406's.
67
+ -- ===========================================================================
68
+ --
69
+ -- An advisory lock only excludes callers that also take it. So "this RPC is the only writer of
70
+ -- boards.claim_policy" is not a nicety here, it is what makes the lock a lock. #406 proved it with
71
+ -- `has_table_privilege(..., 'UPDATE') = false` plus zero user triggers, which does not exclude
72
+ -- COLUMN-level UPDATE grants, rules, other SECURITY DEFINER routines, or dynamic SQL. Each of
73
+ -- those was checked against the catalog and each is asserted positively (CPR-4..CPR-8):
74
+ --
75
+ -- * column-level UPDATE grants on `agent_board.boards` — the only grantee in
76
+ -- `information_schema.column_privileges` is the table OWNER (postgres). No non-owner grantee
77
+ -- holds UPDATE on any column, `claim_policy` included.
78
+ -- * rules — `pg_rewrite` carries nothing on `boards` but the views' own `_RETURN` SELECT rules;
79
+ -- there is no `ON UPDATE ... DO INSTEAD` path.
80
+ -- * other definer routines — the catalog scan for `set claim_policy` over COMMENT-STRIPPED
81
+ -- bodies returns exactly `set_board_claim_policy`. Stripped, because an unstripped scan of the
82
+ -- same catalog on this schema returns THREE names: `set_board_claim_policy`'s own prose
83
+ -- mentions `_claimgate_board_claim_policy`, and a scan that counts a comment as code is the
84
+ -- failure mode this repo has already shipped once.
85
+ -- * dynamic SQL — exactly two functions in `agent_board` contain an `execute`:
86
+ -- `get_bundle_compatibility` (which writes nothing at all) and `mark_retention_metadata`
87
+ -- (whose table allowlist is a literal that does not contain `boards`, and which only ever
88
+ -- assigns `metadata`/`payload`). Both asserted, not described.
89
+ --
90
+ -- ===========================================================================
91
+ -- THE OTHER PREMISE THE LOCK RESTS ON: WHICH BOARD.
92
+ -- ===========================================================================
93
+ --
94
+ -- Both write paths derive the board from the claim's thread, so `threads.board_id` had better not
95
+ -- move under them. It does not, and the reason is mechanical rather than incidental: the only
96
+ -- writer is `_upsert_delivery_recipient`, whose UPDATE carries `where t.board_id is null`
97
+ -- (OBSGATE's first-board-wins stamp). So the transition is null -> board and never board -> board.
98
+ --
99
+ -- That leaves exactly one drift: a claim whose thread had NO board when we looked can gain one
100
+ -- while we wait. We hold no lock on that board, so we must not consult it — and we do not: the
101
+ -- policy is resolved from the BOARD ID WE LOCKED, not by re-resolving through the thread. A stamp
102
+ -- arriving mid-wait therefore resolves to 'inherit', the ENFORCING answer, so the only reachable
103
+ -- drift is conservative. CPR-10/CPR-11 assert the writer set and the null-guard.
104
+ --
105
+ -- ===========================================================================
106
+ -- LOCK ORDER — MEASURED, NOT ARGUED (PR #397's standard).
107
+ -- ===========================================================================
108
+ --
109
+ -- (a) WRITER-SIDE CYCLE. `set_board_claim_policy` takes the advisory lock and then wants a
110
+ -- `boards` row lock, while a transaction that inserted a thread on that board holds FOR KEY
111
+ -- SHARE on the same row and then wants the advisory lock. That is a cycle IF the writer's
112
+ -- row lock conflicts with KEY SHARE. It does not: the read is `for no key update`, chosen in
113
+ -- #400 for precisely this reason. `claimpolicy_writer_keyshare.spec` measures BOTH
114
+ -- directions — the shipped `for no key update` completes, and the same permutation with
115
+ -- `for update` injected deadlocks with 40P01. The probe discriminates.
116
+ --
117
+ -- (b) CLAIM-ROW -> BOARD-LOCK INVERSION. Every path here takes the board lock BEFORE it touches
118
+ -- a claims row (acquire's sweep, renew's UPDATE, the #400 cap reset are all after it), and
119
+ -- renew's preliminary read is a plain SELECT that takes no row lock at all. The inverse
120
+ -- holder — a session sitting on a claims row lock and then entering a policy path — is
121
+ -- UNREACHABLE, and by privilege rather than by convention: `select ... for update` and
122
+ -- `for key share` on `agent_board.claims` are refused to `authenticated` and `service_role`
123
+ -- (neither holds UPDATE on the table, at table OR column level), and no function in the
124
+ -- schema takes a claims row lock. CPR-12 asserts the privilege bound; the catalog scan for
125
+ -- `from agent_board.claims ... for update` returns nothing.
126
+ --
127
+ -- (c) BOARD-THEN-AGENT. `acquire_claim` takes two advisory locks. The board lock is first on
128
+ -- every path; the actor-scoped quota lock is only ever taken inside the non-advisory branch,
129
+ -- after it. No function takes them in the other order (there is only one function that takes
130
+ -- the agent lock at all), so there is no cycle to close.
131
+ --
132
+ -- ===========================================================================
133
+ -- COMPLETENESS — FROM THE CATALOG (rule 1).
134
+ -- ===========================================================================
135
+ --
136
+ -- The class is "resolvers of a board's claim policy": callers of
137
+ -- `_claimgate_board_claim_policy` in `pg_proc` and `pg_get_viewdef`, UNION direct readers of the
138
+ -- `claim_policy` column. On this tree that is exactly four, and the fourth is the one a
139
+ -- hand-written list of "three entry points" missed:
140
+ --
141
+ -- acquire_claim SERIALISE (this migration)
142
+ -- renew_claim SERIALISE (this migration)
143
+ -- set_board_claim_policy SERIALISE (this migration; also the writer)
144
+ -- v_ownerless_claim_quota EXEMPT — and asserted POSITIVELY, never as an absence: it is a
145
+ -- SELECT-only view (its only rewrite rule is `_RETURN`, ev_type 1), so
146
+ -- a stale policy read there produces a report LINE and can never
147
+ -- produce a lease. That the census resolves policy at query time is
148
+ -- #393's own documented behaviour (OQ-29), not a hole opened here.
149
+ --
150
+ -- CPR-1/2/3 derive that set from the catalog on every run, so a fifth resolver fails them and
151
+ -- NAMES ITSELF instead of inheriting an exemption nobody decided.
152
+
153
+ -- ---------------------------------------------------------------------------
154
+ -- The board of a thread, once. Both write paths need the board ID (to lock it) and the policy (to
155
+ -- decide), and `_claimgate_board_claim_policy` already resolved the same join internally — so it
156
+ -- is decomposed rather than copied. `_claimgate_board_claim_policy(thread)` is re-created below as
157
+ -- the composition of these two, which keeps ONE definition of the resolution and leaves its
158
+ -- contract byte-identical (BCP-5's null/unknown/board-less cases all still answer 'inherit').
159
+ --
160
+ -- service_role only, and no grant to `authenticated`: this reads `agent_board.threads` under
161
+ -- SECURITY DEFINER, so a grant would tell any authenticated caller which board any thread belongs
162
+ -- to, past RLS. The two definer callers need no grant (a definer executes as its owner), and
163
+ -- `_claimgate_board_claim_policy` — which KEEPS its `authenticated` grant because the census view
164
+ -- resolves through it — reaches these as postgres for the same reason.
165
+ -- ---------------------------------------------------------------------------
166
+ create or replace function agent_board._claimgate_board_of_thread(p_thread_id uuid)
167
+ returns uuid
168
+ language sql
169
+ stable
170
+ security definer
171
+ set search_path = ''
172
+ as $$
173
+ select t.board_id from agent_board.threads t where t.id = p_thread_id;
174
+ $$;
175
+
176
+ revoke all on function agent_board._claimgate_board_of_thread(uuid) from public, anon, authenticated;
177
+ grant execute on function agent_board._claimgate_board_of_thread(uuid) to service_role;
178
+
179
+ comment on function agent_board._claimgate_board_of_thread(uuid) is
180
+ '#405: THE single definition of "which board governs this claim" — the thread''s board_id, or '
181
+ 'null for a null/unknown/board-less thread. Split out of _claimgate_board_claim_policy so the '
182
+ 'enforcement paths can LOCK the board they are about to resolve policy from, and resolve that '
183
+ 'policy from the id they locked rather than by re-walking the thread (a null board_id can be '
184
+ 'stamped by _upsert_delivery_recipient mid-wait; re-walking would consult a board whose lock is '
185
+ 'not held). service_role only, and deliberately narrower than _claimgate_board_claim_policy: it '
186
+ 'reads threads under SECURITY DEFINER, so an authenticated grant would expose board membership '
187
+ 'of any thread past RLS.';
188
+
189
+ -- ---------------------------------------------------------------------------
190
+ -- The policy of a board, once. Same coalesce-to-'inherit' fallback the thread-keyed resolver has
191
+ -- always had, now stated where the board id is already known.
192
+ -- ---------------------------------------------------------------------------
193
+ create or replace function agent_board._claimgate_board_claim_policy_of(p_board_id uuid)
194
+ returns text
195
+ language sql
196
+ stable
197
+ security definer
198
+ set search_path = ''
199
+ as $$
200
+ select coalesce(
201
+ (select b.claim_policy from agent_board.boards b where b.id = p_board_id),
202
+ 'inherit');
203
+ $$;
204
+
205
+ revoke all on function agent_board._claimgate_board_claim_policy_of(uuid) from public, anon, authenticated;
206
+ grant execute on function agent_board._claimgate_board_claim_policy_of(uuid) to service_role;
207
+
208
+ comment on function agent_board._claimgate_board_claim_policy_of(uuid) is
209
+ '#405: a board''s claim policy, keyed by BOARD id. Returns ''inherit'' for a null or unknown '
210
+ 'board — so an absent board cannot opt anything out by omission, the same rule '
211
+ '_claimgate_board_claim_policy has always had for an absent thread. Called by acquire_claim and '
212
+ 'renew_claim AFTER _claimgate_lock_board_policy on the same id, as its own statement, so under '
213
+ 'READ COMMITTED it observes a policy change that committed while the lock was being waited for. '
214
+ 'service_role only; the definer callers execute as the owner and need no grant.';
215
+
216
+ -- ---------------------------------------------------------------------------
217
+ -- The lock, and the isolation-level refusal that makes it mean anything.
218
+ --
219
+ -- NOT SECURITY DEFINER: it reads nothing and writes nothing. It is nonetheless revoked from
220
+ -- public/anon/authenticated and granted only to service_role, so the serialisation cannot be
221
+ -- reached — or spoofed on a different key — from a session; the three definer callers execute as
222
+ -- the owner. CPR-22 asserts that ACL -- not CPR-14, which is the claims row-lock class
223
+ -- assertion. NOTE: the grant below is REMOVED by 20261001000100. It was not sufficient for the
224
+ -- claim this paragraph makes: service_role is itself a session role, so `granted only to
225
+ -- service_role` did not mean `unreachable from a session`, and a service_role transaction could
226
+ -- compose renew_claim's claims row lock with a direct call here into a real deadlock.
227
+ --
228
+ -- A NULL BOARD TAKES NO LOCK, because there is no board row to serialise on and nothing whose
229
+ -- policy could go stale (an absent board resolves 'inherit' constitutively). The ISOLATION CHECK
230
+ -- still runs — it is not carved out for the null case. A carve-out there would be one more branch
231
+ -- that has to stay correct, and the uniform rule ("every guarded ownerless decision and every
232
+ -- policy flip refuses a non-READ-COMMITTED transaction") is the one that can be asserted whole.
233
+ -- ---------------------------------------------------------------------------
234
+ create or replace function agent_board._claimgate_lock_board_policy(p_board_id uuid)
235
+ returns void
236
+ language plpgsql
237
+ set search_path = ''
238
+ as $$
239
+ declare
240
+ v_isolation text := current_setting('transaction_isolation');
241
+ begin
242
+ -- READ COMMITTED IS A PRECONDITION, NOT A PREFERENCE. This lock exists so that the policy read
243
+ -- issued after it sees the writer's COMMITTED value; that only happens where each statement
244
+ -- takes a fresh snapshot. Under repeatable read / serializable the snapshot is fixed at first
245
+ -- use, the wait does not advance it, and the caller would read the stale policy anyway — a guard
246
+ -- that is silently wrong under a setting any caller can choose. Refuse instead.
247
+ if v_isolation <> 'read committed' then
248
+ raise exception
249
+ 'claim policy enforcement requires transaction isolation level read committed (got %)',
250
+ v_isolation
251
+ using errcode = '0A000',
252
+ hint = 'The board policy lock only closes the stale-read race under READ COMMITTED, '
253
+ 'where each statement takes a fresh snapshot. Retry this call in a READ '
254
+ 'COMMITTED transaction.';
255
+ end if;
256
+
257
+ if p_board_id is not null then
258
+ perform pg_catalog.pg_advisory_xact_lock(
259
+ pg_catalog.hashtextextended('agent_board.board_claim_policy:' || p_board_id::text, 0));
260
+ end if;
261
+ end;
262
+ $$;
263
+
264
+ revoke all on function agent_board._claimgate_lock_board_policy(uuid) from public, anon, authenticated;
265
+ grant execute on function agent_board._claimgate_lock_board_policy(uuid) to service_role;
266
+
267
+ comment on function agent_board._claimgate_lock_board_policy(uuid) is
268
+ '#405: the board-scoped serialisation point for claim policy. Takes a pg_advisory_xact_lock on '
269
+ 'hashtextextended(''agent_board.board_claim_policy:''||board_id) so set_board_claim_policy''s '
270
+ 'flip and every ownerless enforcement decision on that board are totally ordered, and RAISES '
271
+ '0A000 unless transaction_isolation is ''read committed'' — under repeatable read or '
272
+ 'serializable the wait does not advance the caller''s snapshot, so the lock would be silently '
273
+ 'inert rather than fail closed. A null board takes no lock (nothing to serialise; an absent '
274
+ 'board resolves ''inherit''), but is still isolation-checked. Advisory rather than a second '
275
+ 'boards row lock because #159 is open on lock ordering; that only excludes callers that also '
276
+ 'take it, which is why this migration re-derives the sole-writer proof for boards.claim_policy '
277
+ '(CPR-4..CPR-8). Board-then-agent is the order in acquire_claim; nothing takes them the other '
278
+ 'way. service_role only — the three definer callers execute as the owner.';
279
+
280
+ -- ---------------------------------------------------------------------------
281
+ -- The thread-keyed resolver, re-created as the composition of the two helpers above. Same
282
+ -- contract, one definition of the walk. `create or replace` with an unchanged signature, so the
283
+ -- ACL (authenticated + service_role, for the census view) and the COMMENT survive.
284
+ -- ---------------------------------------------------------------------------
285
+ create or replace function agent_board._claimgate_board_claim_policy(p_thread_id uuid)
286
+ returns text
287
+ language sql
288
+ stable
289
+ security definer
290
+ set search_path = ''
291
+ as $$
292
+ select agent_board._claimgate_board_claim_policy_of(
293
+ agent_board._claimgate_board_of_thread(p_thread_id));
294
+ $$;
295
+
296
+ -- ---------------------------------------------------------------------------
297
+ -- acquire_claim, re-created from the 20260928000100 pg_get_functiondef dump. The board opt-out is
298
+ -- hoisted out of the `if` condition into a lock / read / decide sequence; nothing else moves, and
299
+ -- the previously-nested statements are re-indented one level.
300
+ -- ---------------------------------------------------------------------------
301
+ CREATE OR REPLACE FUNCTION agent_board.acquire_claim(p_agent_id uuid, p_resource_kind text, p_resource_key text, p_lease_expires_at timestamp with time zone, p_task_id uuid DEFAULT NULL::uuid, p_thread_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
302
+ RETURNS TABLE(claim_id uuid, task_id uuid, thread_id uuid, agent_id uuid, resource_kind text, resource_key text, status text, lease_token uuid, lease_expires_at timestamp with time zone, released_at timestamp with time zone, created_at timestamp with time zone, updated_at timestamp with time zone)
303
+ LANGUAGE plpgsql
304
+ SECURITY DEFINER
305
+ SET search_path TO 'agent_board', 'public'
306
+ AS $function$
307
+ declare
308
+ v_thread_id uuid;
309
+ v_claim_id uuid;
310
+ v_actor_agent_id uuid;
311
+ v_shadow_authorized boolean;
312
+ v_ownerless boolean;
313
+ v_active_ownerless int;
314
+ v_board_id uuid;
315
+ v_board_policy text;
316
+ begin
317
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
318
+
319
+ if nullif(trim(p_resource_key), '') is null then
320
+ raise exception 'resource key is required' using errcode = '22023';
321
+ end if;
322
+
323
+ -- CLAIMKEY (#133): normalize the resource key to its canonical, fleet-fingerprint-carrying
324
+ -- form BEFORE any lookup/insert, so the row stored (and the uniqueness index contended)
325
+ -- carry canonical identity. NORMALIZE-ONLY — this never rejects (rejection is ENFORCE).
326
+ p_resource_key := agent_board._canonical_claim_key(p_resource_kind, p_resource_key);
327
+
328
+ if p_task_id is not null then
329
+ select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
330
+ if v_thread_id is null then
331
+ raise exception 'task not found' using errcode = 'P0002';
332
+ end if;
333
+ if not agent_board._current_agent_can_access_task(p_task_id) then
334
+ raise exception 'task not accessible to current agent' using errcode = '42501';
335
+ end if;
336
+ else
337
+ v_thread_id := p_thread_id;
338
+ end if;
339
+
340
+ if v_thread_id is not null and not agent_board._current_agent_can_access_thread(v_thread_id) then
341
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
342
+ end if;
343
+
344
+ -- message-board#291: the SUBJECT gate. The checks above answer "may this SESSION touch the
345
+ -- task/thread" and are short-circuited true for service_role; this one answers "does the
346
+ -- agent the claim is ABOUT already have access". Without it a service_role caller could name
347
+ -- any agent, and the resulting claims row -- itself an access disjunct, and a PERMANENT one
348
+ -- -- would MINT thread access (and, through the thread disjunct of
349
+ -- _current_agent_can_access_task, task access) rather than record a lock on something the
350
+ -- subject could already see.
351
+ --
352
+ -- UNCONDITIONAL IN *WHO*. DO NOT "optimize" this into `if _is_service_role() and not ...` or
353
+ -- `if v_actor_agent_id is distinct from agent_board.current_agent_id()`. For an authenticated
354
+ -- caller v_actor_agent_id IS current_agent_id() and the session check above already
355
+ -- established this same expression, so the redundancy is PROVABLE rather than suspected; it
356
+ -- is the price of an invariant this function states for itself instead of borrowing from
357
+ -- _resolved_actor_agent_id continuing to raise on a subject mismatch.
358
+ --
359
+ -- CONDITIONAL ON *THREAD PRESENCE*, which is not a loophole. A claim bound to neither a task
360
+ -- nor a thread stores thread_id = null, is a disjunct of nothing and mints no access.
361
+ -- _agent_can_access_thread answers false for a null thread, so gating unconditionally would
362
+ -- refuse every machine / repository / repo_file / ownerless claim in the fleet. This covers
363
+ -- exactly the claims that are keys.
364
+ --
365
+ -- PLACED AFTER THE TASK LOOKUP so a nonexistent task still answers P0002, not 42501 -- that
366
+ -- ordering IS test-pinned. Placed BEFORE the sweep only to match the predicate-before-side-
367
+ -- effects convention CLAIMGATE/ENFORCE states below; it is NOT what makes a denial free of
368
+ -- side effects. `raise exception` unwinds the whole call, so a refused caller changes no state
369
+ -- wherever this sits -- measured, by moving the gate below the sweep and observing the test
370
+ -- file stay entirely green.
371
+ if v_thread_id is not null
372
+ and not agent_board._agent_can_access_thread(v_thread_id, v_actor_agent_id) then
373
+ raise exception 'claim subject has no access to thread' using errcode = '42501';
374
+ end if;
375
+
376
+ -- OBSGATE-4 (#127): a claim on a TASK resource is authorized on the LOCKED task = the task
377
+ -- named by RESOURCE_KEY (resource_kind='task'). The active-resource uniqueness is on
378
+ -- (resource_kind, resource_key), so that is the task a squat contends for — gating resource_key
379
+ -- (NOT p_task_id, which only associates the claim/event) prevents locking task B's key while
380
+ -- authorizing on an unrelated task A passed as p_task_id. A non-uuid 'task' key cannot collide
381
+ -- with a uuid-keyed legitimate claim.
382
+ --
383
+ -- The composition MIRRORS append_task_event's: ACCESS then MUTATE. mutate alone is
384
+ -- insufficient — _current_agent_can_mutate_task admits any non-observer board member via
385
+ -- _current_agent_can_post_to_thread (board send-role), which is NOT thread access; on a
386
+ -- RESTRICTED thread a board member lacking thread access would pass mutate but must still be
387
+ -- denied (they cannot access the task). Requiring access too closes that gap and matches the
388
+ -- authorization a task-BOUND acquire already gets through append_task_event.
389
+ if p_resource_kind = 'task'
390
+ and p_resource_key ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' then
391
+ if not agent_board._current_agent_can_access_task(p_resource_key::uuid) then
392
+ raise exception 'task not accessible to current agent' using errcode = '42501';
393
+ end if;
394
+ if not agent_board._current_agent_can_mutate_task(p_resource_key::uuid) then
395
+ raise exception 'claiming a task resource requires a non-observer board role, or task assignment/creation'
396
+ using errcode = '42501';
397
+ end if;
398
+ end if;
399
+
400
+ -- CLAIMGATE/SHADOW (#133): compute the fleet-graph authority verdict BEFORE the sweep (so
401
+ -- ENFORCE can flip this same point fail-closed with no observable side effect leaking to an
402
+ -- unauthorized caller). The verdict is stamped below regardless of enforcement.
403
+ v_shadow_authorized := agent_board._can_claim_resource(p_resource_kind, p_resource_key);
404
+
405
+ -- CLAIMGATE/ENFORCE (#133): per-kind GATED DENIAL, BEFORE the sweep (predicate-before-side-
406
+ -- effects). When this fleet-backed kind's flag is ON and the actor is UNAUTHORIZED, DENY —
407
+ -- no state has changed yet (sweep/insert are below). When OFF, fall through to the shadow-
408
+ -- stamp only (SHADOW behavior; blocks nothing). Non-fleet kinds never kind-enforce (resolver
409
+ -- returns FALSE). service_role is authorized by the predicate (wrapper short-circuit), so the
410
+ -- operator carve-out holds. Fail-closed-on-unknown is automatic: an unresolvable fleet resource
411
+ -- makes v_shadow_authorized FALSE, so an enforced acquire of it denies.
412
+ if p_resource_kind in ('machine', 'repository', 'repo_file')
413
+ and agent_board._claimgate_kind_enforced(p_resource_kind)
414
+ and not v_shadow_authorized then
415
+ raise exception 'not authorized to claim this % resource (fleet authority required)', p_resource_kind
416
+ using errcode = '42501';
417
+ end if;
418
+
419
+ -- CLAIMGATE/ENFORCE (#133): max-lease horizon. For an ENFORCED fleet-backed kind, CLAMP the
420
+ -- lease to now()+horizon (default 30d, operator-configurable) — a bound on lease term, NOT an
421
+ -- authority block. Clamp (not reject) so an over-long lease reconciles here / at its next renew.
422
+ -- service_role exempt (operator carve-out). Only reached by an AUTHORIZED acquire (unauthorized
423
+ -- was denied above), or when the flag is OFF (then the guard below is skipped entirely).
424
+ if p_resource_kind in ('machine', 'repository', 'repo_file')
425
+ and not agent_board._is_service_role()
426
+ and agent_board._claimgate_kind_enforced(p_resource_kind)
427
+ and p_lease_expires_at > now() + agent_board._claimgate_max_lease_horizon() then
428
+ p_lease_expires_at := now() + agent_board._claimgate_max_lease_horizon();
429
+ end if;
430
+
431
+ -- Ownerless tail (file/tool/custom): the ONE guard SHADOW carries — GATED OFF BY DEFAULT.
432
+ -- _claimgate_ownerless_guards_enabled() is false unless an operator flips it post-census, so in
433
+ -- SHADOW this block is skipped entirely and NOTHING is blocked (only the stamp above lands).
434
+ -- service_role is also exempt (operator carve-out, consistent with the predicate short-circuit).
435
+ v_ownerless := p_resource_kind in ('file', 'tool', 'custom');
436
+ if v_ownerless
437
+ and not agent_board._is_service_role()
438
+ and agent_board._claimgate_ownerless_guards_enabled() then
439
+ -- #405 THE BOARD LOCK, BEFORE THE POLICY DECISION AND ON THE PATH THAT SKIPS EVERYTHING.
440
+ -- The board opt-out USED TO BE a conjunct of the `if` above, which put the policy read before
441
+ -- any lock and — worse — before the agent-scoped quota lock that lives INSIDE this block. An
442
+ -- acquire that read a stale 'advisory' skipped the whole block and therefore took no lock at
443
+ -- all, so ordering against the agent lock was meaningless. The lock is hoisted OUT of the
444
+ -- decision and taken first, on every path through it.
445
+ --
446
+ -- THREE STATEMENTS, DELIBERATELY. Under READ COMMITTED each of these takes a FRESH SNAPSHOT,
447
+ -- so the policy read below observes a revert that committed while the lock was being waited
448
+ -- for. Fused into one statement it would not: a single statement's snapshot is taken before
449
+ -- the lock is granted. `_claimgate_lock_board_policy` REFUSES any other isolation level for
450
+ -- exactly this reason (see its comment) rather than silently reading stale policy.
451
+ v_board_id := agent_board._claimgate_board_of_thread(v_thread_id);
452
+ perform agent_board._claimgate_lock_board_policy(v_board_id);
453
+ -- RESOLVED FROM THE BOARD WE LOCKED, not from the thread again. `threads.board_id` is
454
+ -- first-board-wins (_upsert_delivery_recipient stamps it only `where board_id is null`), so a
455
+ -- board can never MOVE — but a null board can be STAMPED during the wait. Re-resolving through
456
+ -- the thread would then read a board whose lock we do not hold, which is the same race one
457
+ -- level down. Resolving from v_board_id makes a stamp arriving mid-wait resolve to 'inherit',
458
+ -- the ENFORCING answer, so the drift is conservative by construction.
459
+ v_board_policy := agent_board._claimgate_board_claim_policy_of(v_board_id);
460
+
461
+ -- BOARD OPT-OUT: a board whose work is coordinated by SOMEONE ELSE'S locking (a repo lock, a
462
+ -- scheduler lease, an external mutex) can set claim_policy='advisory'. Our claims are then
463
+ -- RECORDED for visibility and never ENFORCED, so we cannot reject an acquire that their own
464
+ -- lock already permitted. Resolved from the claim's thread; a claim with no thread, or a
465
+ -- thread with no board, has no board to ask and falls back to the fleet-wide flags.
466
+ if v_board_policy <> 'advisory' then
467
+ -- TTL CAP: an ownerless lease may not exceed the max horizon. Rejects an over-long acquire;
468
+ -- this is a lease-length bound, NOT an authority block.
469
+ if p_lease_expires_at > now() + agent_board._claimgate_ownerless_ttl_cap() then
470
+ raise exception 'ownerless claim lease horizon exceeds cap (%)', agent_board._claimgate_ownerless_ttl_cap()
471
+ using errcode = '22023';
472
+ end if;
473
+
474
+ -- QUOTA (transactionally serialized): an actor-scoped advisory XACT lock serializes this
475
+ -- actor's concurrent acquisitions (on DIFFERENT keys, which the row-level uniqueness index
476
+ -- does NOT), so a count-before-insert cannot race two claims past the cap. The lock releases
477
+ -- at commit; it does not block other actors (the key mixes in the actor id). TAKEN AFTER THE
478
+ -- BOARD LOCK, never before it: board-then-agent is the one order every path here uses.
479
+ perform pg_advisory_xact_lock(hashtext('agent_board.claimgate_ownerless_quota'), hashtext(v_actor_agent_id::text));
480
+ -- #388: THE COUNT IS THE SHARED HELPER NOW, not an inline predicate. renew_claim asks the
481
+ -- same question one line of arithmetic away, and two hand-copies of a predicate across two
482
+ -- enforcement paths is rule 1's own failure shape. The ADVISORY LOCK ABOVE STAYS HERE AND IS
483
+ -- NOT MIRRORED INTO RENEW, deliberately: it exists because acquire COUNTS THEN INSERTS, and a
484
+ -- renew inserts nothing (see 20260928000100's header).
485
+ v_active_ownerless := agent_board._claimgate_active_ownerless_claims(v_actor_agent_id);
486
+ if v_active_ownerless >= agent_board._claimgate_ownerless_quota() then
487
+ raise exception 'ownerless active-claim quota reached (% per agent)', agent_board._claimgate_ownerless_quota()
488
+ using errcode = '54000';
489
+ end if;
490
+ end if;
491
+ end if;
492
+
493
+ -- CLAIMKEY (#133) MUST-FIX 3: the sweep AND the conflict check are ALIAS-AWARE. p_resource_key
494
+ -- is already canonical (above); comparing against the CANONICAL form of each existing row's key
495
+ -- matches both a canonical row AND an un-healed LEGACY row for the same physical resource. Without
496
+ -- this, acquire('machine','machine:fpDH') would normalize to 'fpDH', see no exact-'fpDH' conflict
497
+ -- against a still-active legacy 'machine:fpDH' row, and INSERT a second live hold — breaking mutual
498
+ -- exclusion during the legacy window. Closing it here in code does NOT depend on the operator-owned
499
+ -- live census.
500
+ update agent_board.claims c
501
+ set status = 'expired',
502
+ updated_at = now()
503
+ where c.resource_kind = p_resource_kind
504
+ and agent_board._canonical_claim_key(c.resource_kind, c.resource_key) = p_resource_key
505
+ and c.status = 'active'
506
+ and c.released_at is null
507
+ and c.lease_expires_at <= now();
508
+
509
+ if exists (
510
+ select 1 from agent_board.claims
511
+ where claims.resource_kind = p_resource_kind
512
+ and agent_board._canonical_claim_key(claims.resource_kind, claims.resource_key) = p_resource_key
513
+ and claims.status = 'active'
514
+ and claims.released_at is null
515
+ and claims.lease_expires_at > now()
516
+ ) then
517
+ raise exception 'conflicting active claim' using errcode = '23505';
518
+ end if;
519
+
520
+ insert into agent_board.claims(task_id, thread_id, agent_id, resource_kind, resource_key, lease_expires_at, metadata)
521
+ values (
522
+ p_task_id, v_thread_id, v_actor_agent_id, p_resource_kind, p_resource_key, p_lease_expires_at,
523
+ -- SHADOW stamp: record the authority verdict alongside any caller metadata. Merged last so it
524
+ -- is authoritative over a caller-supplied claimgate_shadow key.
525
+ coalesce(p_metadata, '{}'::jsonb)
526
+ || jsonb_build_object(
527
+ 'claimgate_shadow',
528
+ jsonb_build_object('kind', p_resource_kind, 'authorized', v_shadow_authorized)
529
+ )
530
+ )
531
+ returning id into v_claim_id;
532
+
533
+ if p_task_id is not null then
534
+ perform agent_board._append_task_event('unattested:signed-metadata-rpc', p_task_id, v_actor_agent_id, 'claimed', jsonb_build_object('claim_id', v_claim_id, 'resource_kind', p_resource_kind, 'resource_key', p_resource_key));
535
+ end if;
536
+
537
+ return query
538
+ select c.id, c.task_id, c.thread_id, c.agent_id, c.resource_kind, c.resource_key, c.status, c.lease_token, c.lease_expires_at, c.released_at, c.created_at, c.updated_at
539
+ from agent_board.claims c
540
+ where c.id = v_claim_id;
541
+ end;
542
+ $function$;
543
+
544
+ -- ---------------------------------------------------------------------------
545
+ -- renew_claim, re-created from the 20260928000100 dump. The two ownerless blocks become one
546
+ -- guarded region: lock, revalidate the CLAIM (not merely the policy), resolve the policy from the
547
+ -- locked board, then the same cap and convergence decisions in the same order.
548
+ -- ---------------------------------------------------------------------------
549
+ CREATE OR REPLACE FUNCTION agent_board.renew_claim(p_lease_token uuid, p_lease_expires_at timestamp with time zone)
550
+ RETURNS TABLE(claim_id uuid, task_id uuid, thread_id uuid, agent_id uuid, resource_kind text, resource_key text, status text, lease_token uuid, lease_expires_at timestamp with time zone, released_at timestamp with time zone, created_at timestamp with time zone, updated_at timestamp with time zone)
551
+ LANGUAGE plpgsql
552
+ SECURITY DEFINER
553
+ SET search_path TO 'agent_board', 'public'
554
+ AS $function$
555
+ declare
556
+ v_actor_agent_id uuid;
557
+ v_kind text;
558
+ v_key text;
559
+ v_thread_id uuid;
560
+ v_claim_agent_id uuid;
561
+ v_current_expires timestamptz;
562
+ v_effective_expires timestamptz;
563
+ v_board_id uuid;
564
+ v_board_policy text;
565
+ begin
566
+ if not agent_board._is_service_role() then
567
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(null, 'current_agent_id');
568
+ if not exists (
569
+ select 1
570
+ from agent_board.claims c
571
+ where c.lease_token = p_lease_token
572
+ and c.agent_id = v_actor_agent_id
573
+ ) then
574
+ raise exception 'claim not accessible to current agent' using errcode = '42501';
575
+ end if;
576
+ end if;
577
+
578
+ -- CLAIMGATE/ENFORCE (#133): re-authorize + horizon for the predicate-gated kinds. Read the
579
+ -- live lease's resource identity (SECURITY DEFINER, so RLS does not hide it). A dead/expired
580
+ -- lease yields a null kind and the UPDATE below matches nothing (preserves prior behavior).
581
+ --
582
+ -- #388 adds the HOLDER and the CURRENT EXPIRY to this same read. Both come from the live row and
583
+ -- never from a caller argument: a caller-supplied agent id is an assertion about who holds the
584
+ -- claim, and a caller-supplied expiry would let the convergence clamp be handed its own baseline.
585
+ select c.resource_kind, c.resource_key, c.thread_id, c.agent_id, c.lease_expires_at
586
+ into v_kind, v_key, v_thread_id, v_claim_agent_id, v_current_expires
587
+ from agent_board.claims c
588
+ where c.lease_token = p_lease_token
589
+ and c.status = 'active'
590
+ and c.released_at is null
591
+ and c.lease_expires_at > now();
592
+
593
+ -- RE-AUTH: bound revocation latency to one lease term. Uses the SAME session wrapper acquire
594
+ -- consults (_can_claim_resource -> _can_claim_resource_for(current_agent_id())), so service_role
595
+ -- keeps its carve-out and acquire+renew share ONE per-kind flag (a mutation splitting the flags
596
+ -- fails a test). Task-renew is EXCLUDED (ownership-only, ratified supersession).
597
+ if v_kind in ('machine', 'repository', 'repo_file')
598
+ and agent_board._claimgate_kind_enforced(v_kind) then
599
+ if not agent_board._can_claim_resource(v_kind, agent_board._canonical_claim_key(v_kind, v_key)) then
600
+ raise exception 'not authorized to renew this % claim (fleet authority revoked)', v_kind
601
+ using errcode = '42501';
602
+ end if;
603
+ end if;
604
+
605
+ -- HORIZON: clamp the renewed lease to now()+horizon for an ENFORCED fleet-gated kind — this is
606
+ -- where an existing overlong lease reconciles (no bulk-clamp migration; the horizon clamps it
607
+ -- at its next renew). service_role exempt. Same per-kind flag as acquire.
608
+ v_effective_expires := p_lease_expires_at;
609
+ if v_kind in ('machine', 'repository', 'repo_file')
610
+ and not agent_board._is_service_role()
611
+ and agent_board._claimgate_kind_enforced(v_kind)
612
+ and p_lease_expires_at > now() + agent_board._claimgate_max_lease_horizon() then
613
+ v_effective_expires := now() + agent_board._claimgate_max_lease_horizon();
614
+ end if;
615
+
616
+ -- #405 THE BOARD LOCK AND THE REVALIDATION, BEFORE EITHER OWNERLESS DECISION.
617
+ --
618
+ -- ORDER, AND WHY IT IS THIS ONE. The board id is derived FROM the claim (token -> claim ->
619
+ -- thread -> board), so "lock the board before any claims access" is not achievable here. The
620
+ -- achievable order is: read the claim, derive the board, LOCK the board, RE-READ, decide.
621
+ --
622
+ -- RE-READING THE POLICY ALONE WOULD MOVE THE RACE, NOT CLOSE IT. The writer this waits on is
623
+ -- set_board_claim_policy, and since #400 that same call CLAMPS this board's ownerless leases on
624
+ -- an advisory -> inherit revert. So the row this function read BEFORE the wait is exactly the row
625
+ -- the writer changed DURING it, and v_current_expires -- the baseline the convergence clamp
626
+ -- least()s against -- would still hold the pre-clamp horizon and hand it straight back. The whole
627
+ -- claim read is re-issued, not just the policy.
628
+ --
629
+ -- A CLAIM THAT DIED DURING THE WAIT (released, expired, swept) re-reads as null and every guard
630
+ -- below is skipped, exactly as a dead lease behaves today; the UPDATE at the end then matches
631
+ -- nothing and the caller gets the empty result it already gets for a dead token.
632
+ --
633
+ -- ONE GUARDED REGION, not two copies of the same three conjuncts. The TTL cap and the
634
+ -- convergence clamp previously each re-evaluated `kind / not service_role / guards_enabled`
635
+ -- inline; hoisting them makes the lock a STRUCTURAL precondition of both decisions rather than a
636
+ -- line that has to be remembered twice.
637
+ if v_kind in ('file', 'tool', 'custom')
638
+ and not agent_board._is_service_role()
639
+ and agent_board._claimgate_ownerless_guards_enabled() then
640
+ v_board_id := agent_board._claimgate_board_of_thread(v_thread_id);
641
+ perform agent_board._claimgate_lock_board_policy(v_board_id);
642
+
643
+ -- REVALIDATION. Its own statement, so under READ COMMITTED it takes a snapshot taken AFTER the
644
+ -- lock was granted. Same projection and same predicate as the read above -- deliberately, so
645
+ -- there is one definition of "the live claim" and not a second, narrower one here.
646
+ select c.resource_kind, c.resource_key, c.thread_id, c.agent_id, c.lease_expires_at
647
+ into v_kind, v_key, v_thread_id, v_claim_agent_id, v_current_expires
648
+ from agent_board.claims c
649
+ where c.lease_token = p_lease_token
650
+ and c.status = 'active'
651
+ and c.released_at is null
652
+ and c.lease_expires_at > now();
653
+
654
+ -- Resolved from the board we LOCKED, not from the thread again -- see acquire_claim's note:
655
+ -- threads.board_id is first-board-wins, so a board never moves, but a null one can be stamped
656
+ -- mid-wait and re-resolving would consult a board whose lock we do not hold.
657
+ v_board_policy := agent_board._claimgate_board_claim_policy_of(v_board_id);
658
+
659
+ if v_kind in ('file', 'tool', 'custom')
660
+ and v_board_policy <> 'advisory' then
661
+ -- #373 OWNERLESS TTL CAP AT RENEW. The half that was missing: without this an agent renews a
662
+ -- file/tool/custom lease past the 30-day cap indefinitely and the bound is decorative.
663
+ --
664
+ -- Clamps rather than raises, unlike acquire. A raise here would kill a lease the agent is
665
+ -- actively holding; shortening it costs one more renew. Same resolution the fleet-kind
666
+ -- horizon clamp above already applies, and the same place an over-long legacy lease
667
+ -- reconciles.
668
+ --
669
+ -- The carve-outs are acquire's, so ONE operator flag governs both paths and a board that
670
+ -- declined our locking (claim_policy='advisory') is not clamped by it either. The thread
671
+ -- comes from the LIVE CLAIM, never from a caller argument -- a caller-supplied thread would
672
+ -- let an agent choose which board's policy is consulted.
673
+ if v_effective_expires > now() + agent_board._claimgate_ownerless_ttl_cap() then
674
+ v_effective_expires := now() + agent_board._claimgate_ownerless_ttl_cap();
675
+ end if;
676
+
677
+ -- #388 OWNERLESS QUOTA CONVERGENCE AT RENEW. The cap above bounds how LONG a lease may be;
678
+ -- this bounds how long an agent may stay OVER THE CEILING, which nothing did before. While
679
+ -- the holder is over quota a renew may not POSTPONE a deadline: it still succeeds, the lease
680
+ -- it already holds is preserved to the microsecond, and the only thing withheld is more time.
681
+ -- Each claim then lapses at its own already-set expiry, the count falls monotonically, and
682
+ -- this block stops firing the instant the count is back at the ceiling.
683
+ --
684
+ -- least(), not an assignment. An over-quota agent asking for a SHORTER lease must still get
685
+ -- the shorter one -- that is the agent converging voluntarily, and refusing it would be
686
+ -- perverse. v_current_expires is the REVALIDATED baseline (#405): before this migration it
687
+ -- was read before the policy was resolved and could not see a concurrent #400 clamp at all.
688
+ --
689
+ -- `>` and not `>=`. `>=` is ACQUIRE's refusal predicate; reusing it here would freeze an
690
+ -- agent sitting legitimately AT the ceiling and walk it down to 49. The predicate is the
691
+ -- census's own `overage > 0`, so the fixed point is exactly the ceiling.
692
+ --
693
+ -- Still no ACTOR-scoped advisory lock: the row being renewed is required by the UPDATE's own
694
+ -- WHERE to be one of the counted rows, so the count is invariant under renew and there is no
695
+ -- slot to race for (20260928000100's header). The BOARD lock above answers a different
696
+ -- question -- which policy governs -- and is not a substitute for it in acquire.
697
+ if agent_board._claimgate_active_ownerless_claims(v_claim_agent_id)
698
+ > agent_board._claimgate_ownerless_quota() then
699
+ v_effective_expires := least(v_effective_expires, v_current_expires);
700
+ end if;
701
+ end if;
702
+ end if;
703
+
704
+ -- CLAIMKEY (#133) MUST-FIX 1: re-canonicalize the STORED key as the lease is renewed, but
705
+ -- NEVER propagate a uniqueness collision. A check-then-act guard is NOT race-safe: two aliased
706
+ -- legacy rows renewed concurrently at READ COMMITTED can both observe the canonical key free
707
+ -- and one loses on claims_active_resource_unique (23505). So attempt the canonicalization and,
708
+ -- on unique_violation (deterministic alias OR concurrent race), retry ONCE keeping the CURRENT
709
+ -- key. That makes renew non-raising UNCONDITIONALLY (true normalize-only); the surviving legacy
710
+ -- key is the deferred dual-key alias the ENFORCE phase closes.
711
+ begin
712
+ return query
713
+ update agent_board.claims c
714
+ set lease_expires_at = v_effective_expires,
715
+ resource_key = agent_board._canonical_claim_key(c.resource_kind, c.resource_key),
716
+ updated_at = now()
717
+ where c.lease_token = p_lease_token
718
+ and c.status = 'active'
719
+ and c.released_at is null
720
+ and c.lease_expires_at > now()
721
+ returning c.id, c.task_id, c.thread_id, c.agent_id, c.resource_kind, c.resource_key, c.status, c.lease_token, c.lease_expires_at, c.released_at, c.created_at, c.updated_at;
722
+ exception when unique_violation then
723
+ -- the canonical key is already held (by a committed twin or a concurrent renew/acquire):
724
+ -- renew the lease keeping the current key, aliasing deferred to ENFORCE. Only the key change
725
+ -- can raise unique_violation (the lease/timestamp columns are unconstrained), so the retry
726
+ -- cannot itself collide.
727
+ return query
728
+ update agent_board.claims c
729
+ set lease_expires_at = v_effective_expires,
730
+ updated_at = now()
731
+ where c.lease_token = p_lease_token
732
+ and c.status = 'active'
733
+ and c.released_at is null
734
+ and c.lease_expires_at > now()
735
+ returning c.id, c.task_id, c.thread_id, c.agent_id, c.resource_kind, c.resource_key, c.status, c.lease_token, c.lease_expires_at, c.released_at, c.created_at, c.updated_at;
736
+ end;
737
+ end;
738
+ $function$;
739
+
740
+ -- ---------------------------------------------------------------------------
741
+ -- set_board_claim_policy, re-created from the 20260929000100 dump with one statement added: the
742
+ -- advisory lock, after the owner gate and before the outgoing-policy read.
743
+ -- ---------------------------------------------------------------------------
744
+ CREATE OR REPLACE FUNCTION agent_board.set_board_claim_policy(p_board_id uuid, p_claim_policy text)
745
+ RETURNS TABLE(board_id uuid, claim_policy text, updated_at timestamp with time zone)
746
+ LANGUAGE plpgsql
747
+ SECURITY DEFINER
748
+ SET search_path TO 'agent_board', 'public'
749
+ AS $function$
750
+ declare
751
+ v_actor uuid := agent_board.current_agent_id();
752
+ v_row agent_board.boards%rowtype;
753
+ v_prev_policy text;
754
+ begin
755
+ if p_claim_policy is null then
756
+ raise exception 'p_claim_policy must not be null' using errcode = '22023';
757
+ end if;
758
+ -- Validated HERE as well as by the column CHECK. The CHECK would reject an unknown value
759
+ -- anyway, but with a constraint-violation error naming an internal constraint; a caller asking
760
+ -- for 'off' deserves to be told the vocabulary, not handed 23514.
761
+ if p_claim_policy not in ('inherit', 'advisory') then
762
+ raise exception 'claim policy must be one of inherit, advisory (received %)', p_claim_policy
763
+ using errcode = '22023';
764
+ end if;
765
+
766
+ -- Owner gate: only an active owner-role member may set this. service_role
767
+ -- (current_agent_id() = null) is not an owner and is rejected here.
768
+ if v_actor is null
769
+ or not exists (
770
+ select 1
771
+ from agent_board.board_memberships bm
772
+ where bm.board_id = p_board_id
773
+ and bm.agent_id = v_actor
774
+ and bm.role = 'owner'
775
+ and bm.membership_state in ('joined', 'subscribed')
776
+ ) then
777
+ raise exception 'only a board owner may set the claim policy' using errcode = '42501';
778
+ end if;
779
+
780
+ -- #405 THE BOARD POLICY LOCK. Taken AFTER the owner gate (an unauthorized caller must not be
781
+ -- able to make anyone wait, the same reason the row lock below sits here) and BEFORE the
782
+ -- outgoing-policy read, so every reader that serialises on this key -- acquire_claim and
783
+ -- renew_claim -- is either entirely before this flip or entirely after it. It is an ADVISORY
784
+ -- lock rather than a second row lock because #159 is open on lock ordering and a row lock on a
785
+ -- comparable path is what ab38805 reverted for a 40P01; an advisory lock only excludes callers
786
+ -- that also take it, which is why this migration re-derives the sole-writer proof for
787
+ -- boards.claim_policy from the catalog instead of inheriting #406's.
788
+ --
789
+ -- IT DOES NOT REPLACE the `for no key update` below. That row lock is what makes two concurrent
790
+ -- FLIPS see each other's outgoing value; this one is what makes a concurrent READER see the
791
+ -- committed one. Deleting either is separately observable (CPR-9, BCP-17).
792
+ perform agent_board._claimgate_lock_board_policy(p_board_id);
793
+
794
+ -- #400: the OUTGOING policy, read under the row lock the UPDATE below will take anyway. Placed
795
+ -- AFTER the owner gate so an unauthorized caller never locks the row. `for no key update` rather
796
+ -- than `for update`: it does not conflict with the FOR KEY SHARE an FK reference to boards takes,
797
+ -- so a concurrent thread insert on this board is not queued behind a policy flip. Two concurrent
798
+ -- flips serialize here, so only the one that genuinely observed 'advisory' resets.
799
+ --
800
+ -- A missing board leaves this null and the UPDATE below matches nothing, so the P0002 answer and
801
+ -- its ordering are unchanged.
802
+ select b.claim_policy into v_prev_policy
803
+ from agent_board.boards b
804
+ where b.id = p_board_id
805
+ for no key update;
806
+
807
+ update agent_board.boards b
808
+ set claim_policy = p_claim_policy,
809
+ updated_at = now()
810
+ where b.id = p_board_id
811
+ returning * into v_row;
812
+
813
+ if v_row.id is null then
814
+ raise exception 'board not found' using errcode = 'P0002';
815
+ end if;
816
+
817
+ -- #400 THE CAP RESET ON REVERT. Reverting to 'inherit' means accepting inherit's rules, so this
818
+ -- board's active ownerless claims get the ownerless TTL cap re-applied. The escape it closes:
819
+ -- flip to advisory, renew every claim to an arbitrary horizon (the advisory carve-out skips both
820
+ -- the cap and #388's convergence clamp), flip back, repeat. An agent that created the board is
821
+ -- its owner, so no accomplice is needed. Nothing took the granted horizon back — the convergence
822
+ -- clamp only refuses to EXTEND — so the horizon simply survived the revert.
823
+ --
824
+ -- CLAMP ONLY, and least() is the ONLY thing doing it. There is deliberately no `lease_expires_at
825
+ -- > now() + cap` conjunct in the WHERE: it would be the same guard written twice and would make
826
+ -- least() unmutatable. So a claim already within the cap keeps its lease to the microsecond
827
+ -- (BCP-12) while its updated_at is bumped — the documented cost of one mechanism over two.
828
+ --
829
+ -- 'advisory' -> 'inherit' ONLY. A no-op re-set (inherit -> inherit) must clamp nothing, and
830
+ -- neither may inherit -> advisory or advisory -> advisory (BCP-15/16/17).
831
+ --
832
+ -- Governed by the same operator flag as every other ownerless guard: with ownerless_guards off
833
+ -- nothing enforces this cap at acquire or renew, so re-applying it here would enforce a bound no
834
+ -- other path enforces (BCP-18). There is no service_role conjunct because service_role cannot
835
+ -- reach this line — the owner gate above refuses it (BCP-8).
836
+ --
837
+ -- Scoped to THIS board through the claim's thread, the same path _claimgate_board_claim_policy
838
+ -- resolves. A claim on another board, or with no thread, is untouched (BCP-14).
839
+ if v_prev_policy = 'advisory'
840
+ and p_claim_policy = 'inherit'
841
+ and agent_board._claimgate_ownerless_guards_enabled() then
842
+ update agent_board.claims c
843
+ set lease_expires_at = least(c.lease_expires_at,
844
+ now() + agent_board._claimgate_ownerless_ttl_cap()),
845
+ updated_at = now()
846
+ where c.resource_kind = any(agent_board._claimgate_ownerless_kinds())
847
+ and c.status = 'active'
848
+ and c.released_at is null
849
+ and c.lease_expires_at > now()
850
+ and c.thread_id in (select t.id
851
+ from agent_board.threads t
852
+ where t.board_id = p_board_id);
853
+ end if;
854
+
855
+ -- #276, AFTER the boards UPDATE so this function's order is boards -> agents, the canonical one
856
+ -- (#194). Fleet revocation deliberately LEAVES agents.status = 'active' -- the ledger row is the
857
+ -- only authority signal -- so the owner-membership check above passes for a revoked owner and
858
+ -- cannot stand alone. Disabling this board's claim enforcement is an authority write in the same
859
+ -- class as flipping the role gate, which is why the mirror has to include this call and not just
860
+ -- the membership test. Same transaction: the raise rolls the UPDATE back -- and, since #400, the
861
+ -- cap reset with it.
862
+ --
863
+ -- This was MISSING in the first version of this function, which claimed in its own commit
864
+ -- message to mirror set_board_role_gate "exactly". A review seat found it by reading both
865
+ -- functions side by side rather than taking that claim.
866
+ perform agent_board._assert_actor_not_fleet_revoked(v_actor, 'set_board_claim_policy');
867
+
868
+ return query select v_row.id, v_row.claim_policy, v_row.updated_at;
869
+ end;
870
+ $function$;
871
+
872
+ -- ---------------------------------------------------------------------------
873
+ -- Rule 5: recorded facts that were true when written and are not now. 20260929000100's bytes are
874
+ -- hashed in the manifest and in fixtures/already-applied.json, so its file cannot be edited — the
875
+ -- comments it set are restated here, the pattern that migration itself used for the same reason.
876
+ --
877
+ -- WHAT CHANGED. #406 recorded its limitation 3 as "an in-flight renew can still win the race, and
878
+ -- this is unpinned". It is now closed on all three paths and pinned by four isolation specs. Its
879
+ -- limitation 1 — the flip-every-29-days loophole — is NOT closed and is not claimed to be: that is
880
+ -- not a race, it is inherent to a self-service opt-out, and the owner has decided to leave it
881
+ -- open and documented. "Bounded, not closed" therefore still applies to the QUOTA escape, and no
882
+ -- longer applies to the stale-policy race.
883
+ -- ---------------------------------------------------------------------------
884
+ comment on table agent_board.claimgate_enforcement_flags is
885
+ 'CLAIMGATE per-scope enforcement flags. Absent row => disabled (SHADOW default: blocks nothing). '
886
+ 'scope_key ''ownerless_guards'' gates EVERY ownerless (file/tool/custom) guard on ALL THREE write '
887
+ 'paths: the TTL cap + per-agent quota in acquire_claim, in renew_claim both the TTL clamp (#373) '
888
+ 'and the quota-convergence clamp (#388), and in set_board_claim_policy the advisory -> inherit cap '
889
+ 'reset (#400). The advisory opt-out is owner-toggleable and re-toggleable, so it remains an escape '
890
+ 'from convergence — bounded since #400 by the TTL cap per cycle rather than unbounded. Since #405 '
891
+ 'all three paths serialise on a board-scoped advisory lock before resolving policy, so the escape '
892
+ 'can no longer be widened by RACING a revert: a flip-flop banks at most one capped window, and '
893
+ 'never a full horizon taken from policy that was already gone. Operator-managed (service_role); '
894
+ 'enabled only after the prod lease-length census.';
895
+
896
+ comment on function agent_board._claimgate_ownerless_quota() is
897
+ 'CLAIMGATE/SHADOW (#133): max concurrent ACTIVE ownerless (file/tool/custom) claims per agent. '
898
+ 'Single source for the migration logic and its tests. GENEROUS placeholder (50) pending prod '
899
+ 'census. Enforced on BOTH write paths: acquire_claim REFUSES at count >= quota (54000), and '
900
+ 'renew_claim stops EXTENDING at count > quota (#388) so an agent already over the ceiling '
901
+ 'converges back to it instead of renewing its overage forever — SCOPED to agents whose boards do '
902
+ 'not opt out. A board owner may flip claim_policy to advisory, let the agent renew, and flip '
903
+ 'back; since #400 the revert re-applies the ownerless TTL cap, so each such cycle banks at most '
904
+ '_claimgate_ownerless_ttl_cap() rather than an arbitrary horizon, and since #405 a concurrent '
905
+ 'acquire or renew cannot bank MORE than that by winning a race with the revert — all three paths '
906
+ 'take a board-scoped advisory lock before resolving policy and re-read it afterwards. The '
907
+ 'per-cycle escape is bounded, not closed (ORQ-11/12); the RACE is closed (CPR-15..CPR-18). The '
908
+ 'two predicates differ deliberately — >= is what makes the quota the largest reachable '
909
+ 'population, > is what makes the ceiling the convergence fixed point — and both count through '
910
+ '_claimgate_active_ownerless_claims(). Governed by _claimgate_ownerless_guards_enabled() on both '
911
+ 'paths, service_role exempt, and skipped for a board whose claim_policy is advisory.';
912
+
913
+ comment on function agent_board._claimgate_board_claim_policy(uuid) is
914
+ 'Resolves a claim''s governing board policy through its thread. Returns ''inherit'' when the '
915
+ 'thread is null, unknown, or not board-linked — so a claim with no board cannot opt itself out '
916
+ 'by omission; opting out requires a board an owner deliberately configured. Since #405 this is '
917
+ 'the COMPOSITION of _claimgate_board_of_thread() and _claimgate_board_claim_policy_of(), so '
918
+ 'there is one definition of the walk; the enforcement paths call the two halves separately in '
919
+ 'order to LOCK the board before resolving its policy, and this thread-keyed form is what the '
920
+ 'census view v_ownerless_claim_quota resolves through (read-only, deliberately unserialised — a '
921
+ 'stale read there is a report line, never a lease). Keeps its authenticated grant for exactly '
922
+ 'that reason; the two halves are service_role only.';