@consiliency/agent-board-schema 1.45.0 → 1.48.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.
@@ -0,0 +1,562 @@
1
+ -- #388 — the ownerless quota bounds NEW ACQUISITIONS but never CONVERGES an agent already over it.
2
+ --
3
+ -- THE PROPERTY. `acquire_claim` refuses at `count >= _claimgate_ownerless_quota()` (50) concurrent
4
+ -- active ownerless (file/tool/custom) claims. `renew_claim` has never asked the question at all. So
5
+ -- the ceiling binds GROWTH and not PERSISTENCE: an agent already over it renews every one of its
6
+ -- claims indefinitely and stays over, forever. #393 shipped the census that REPORTS those agents
7
+ -- (`v_ownerless_claim_quota`, `ownerless_quota_census()`); nothing CONVERGES them. This does.
8
+ --
9
+ -- ===========================================================================
10
+ -- THE DESIGN: A MONOTONE, NON-EXTENDING CLAMP. "While over quota, a renew may not postpone a
11
+ -- deadline." The lease the agent already holds is untouched to the microsecond; the renew succeeds
12
+ -- and returns the row; the only thing withheld is MORE TIME.
13
+ -- ===========================================================================
14
+ --
15
+ -- WHY NOT A RAISE, and this is the option a reader reaches for first. #387's test file states the
16
+ -- rationale and ORT-3 asserts it by checking a resulting TIMESTAMP rather than an error: acquire
17
+ -- RAISES because nothing is lost by asking again, but a renew that raised would KILL A LEASE THE
18
+ -- AGENT IS ACTIVELY HOLDING, converting a quantity problem into an availability incident. That
19
+ -- reasoning is undisturbed by #388 — being over quota is exactly the state in which the agent has
20
+ -- the most in-flight work to lose — so no existing assertion changes contract here.
21
+ --
22
+ -- WHY NOT THE OBVIOUS CLAMP — a short grace horizon (renew still succeeds, but only to
23
+ -- `now() + 1 hour`, so leases lapse quickly). IT DOES NOT CONVERGE, and the counterexample is
24
+ -- one line: an agent at 60 simply renews all 60 every hour and stays at 60 forever. Convergence
25
+ -- under that design is contingent on the over-quota agent CHOOSING to abandon claims, which is
26
+ -- the one party whose cooperation the guard exists not to require. It also behaves worst for the
27
+ -- careful agent (a 29-day lease is cut to an hour) and not at all for the diligent one. Rejected
28
+ -- on the counterexample, recorded here so the next reader does not re-propose it as "simpler".
29
+ --
30
+ -- WHY THE MONOTONE CLAMP CONVERGES, AND AGAINST WHOM. Every deadline is FIXED at the moment the
31
+ -- agent goes over. No renew can move one later, so each claim lapses at its own already-set expiry,
32
+ -- the count falls monotonically, and the guard releases the instant the count is back at the
33
+ -- ceiling. The agent keeps every second it already had — up to the full 30-day ownerless cap —
34
+ -- which is a longer and fairer grace than any fixed window, and it is per claim rather than uniform.
35
+ --
36
+ -- THAT SENTENCE IS SCOPED, and an earlier version of this header stated it without the scope, which
37
+ -- was WRONG. It holds against an agent whose boards do not opt out. It does NOT hold against an
38
+ -- agent with a cooperating board owner, and #399's panel is right that this is the material
39
+ -- limitation:
40
+ --
41
+ -- THE OWNER-TOGGLE ESCAPE. The advisory carve-out below is resolved at RENEW TIME against the
42
+ -- board's CURRENT policy, and `set_board_claim_policy` is owner-settable. So an owner can flip
43
+ -- the board to 'advisory', let the over-quota agent renew every claim to the full horizon, flip
44
+ -- back to 'inherit', and repeat before the next expiry. Nothing here takes the granted horizon
45
+ -- back — the clamp only refuses to EXTEND, it never shortens — so the agent stays over quota for
46
+ -- as long as the owner keeps flipping. NO ACCOMPLICE IS REQUIRED: `create_board` seeds the
47
+ -- creator's owner membership and that membership is exactly what the policy gate tests, so an
48
+ -- agent that created a board holds the switch that exempts its own claims. ORQ-11 performs the
49
+ -- flip-flop through the real RPCs and pins it.
50
+ --
51
+ -- AND IT IS INVISIBLE TO THE CENSUS, which is the half worth recording. #393 resolves board
52
+ -- policy at QUERY time, so once the board is back to 'inherit' the escaped claim contributes ZERO
53
+ -- to `claims_on_advisory_boards` while the overage it bought is still counted. An operator sees
54
+ -- an agent over quota with no advisory share — the shape that reads as plain squatting — and the
55
+ -- column built to explain the exemption reports none of it. ORQ-12 pins that too.
56
+ --
57
+ -- THE CARVE-OUT IS NOT REMOVED IN RESPONSE, and that is a decision rather than an oversight. A board
58
+ -- coordinating through someone else's mutex asked us not to enforce; refusing to extend a lease that
59
+ -- mutex already granted would break the promise the opt-out makes, which is the whole reason the
60
+ -- opt-out exists. Making the exemption ACQUIRE-time was also rejected: nothing records the policy in
61
+ -- force when a claim was taken — that absence is #393's own documented gap — and inventing a policy
62
+ -- history here would be a second, unreviewed mechanism. What was defective was the CLAIM, not the
63
+ -- code, so the claim is scoped and the limitation is pinned by a test rather than left in prose.
64
+ --
65
+ -- THE REST OF THE CONVERGENCE BOUND, STATED HONESTLY rather than as "unconditional". The horizon is
66
+ -- THE LONGEST LEASE THE AGENT CURRENTLY HOLDS. A 400-day lease grandfathered from before the flag
67
+ -- flip (#369) lapses at ITS OWN expiry and not sooner — a renew-side guard cannot touch a claim
68
+ -- whose holder never renews it, and this one does not pretend to. `service_role` is outside it too,
69
+ -- by the operator carve-out. An agent whose entire overage sits on advisory boards never converges,
70
+ -- which is why the census reports `claims_on_advisory_boards` as its own column: it is precisely
71
+ -- the share this guard cannot reach while the board stays advisory.
72
+ --
73
+ -- `> quota`, NOT `>= quota`, AND THE DIFFERENCE IS A REAL AGENT. `>=` is ACQUIRE's refusal
74
+ -- predicate, and acquire refusing at 50 is what makes 50 the largest population reachable through
75
+ -- it. Reusing `>=` here would freeze an agent sitting legitimately AT the ceiling and walk it down
76
+ -- to 49 — shrinking a legal population rather than converging an illegal one. The predicate used is
77
+ -- the census's own `overage > 0` / `agents_over_quota`, so the fixed point is exactly the ceiling
78
+ -- and the two surfaces answer with one arithmetic.
79
+ --
80
+ -- WHICH ASSERTIONS PIN THAT, corrected by MEASURING rather than by reasoning about the fixtures.
81
+ -- An earlier draft of this header said "ORQ-2 is the assertion that fails on `>=`, and it is the
82
+ -- only one that can: it needs an agent at EXACTLY 50, which no other fixture has." Both halves
83
+ -- were wrong, and the mutation says so:
84
+ --
85
+ -- `>` -> `>=` ran=31, not ok on ORQ-5 AND ORQ-13
86
+ -- remove the least() clamp ran=31, not ok on ORQ-1, ORQ-2, ORQ-3
87
+ --
88
+ -- So ORQ-2 pins the CLAMP, not the boundary, and the boundary is pinned TWICE. The "only one that
89
+ -- can" reasoning was self-refuting: ORQ-13 is the fixed point, and its own text says it "puts the
90
+ -- agent AT the ceiling" — precisely the fixture the header claimed no other case had. Being pinned
91
+ -- by two assertions is stronger than the claim, but a header that names the wrong assertion sends
92
+ -- the next reader to the wrong test when this breaks, so it is corrected rather than left as a
93
+ -- harmless understatement.
94
+ --
95
+ -- NO `pg_advisory_xact_lock`, AND THIS DEVIATES FROM #388 STEP 2 ON PURPOSE. That step prescribes
96
+ -- acquire's agent-scoped lock plus a two-session isolationtester case "so two concurrent renews
97
+ -- cannot both observe room". That requirement belongs to an ADMIT design — one where a renew
98
+ -- CONSUMES a slot and two concurrent renews could consume the same one. This design admits
99
+ -- nothing. The row being renewed is required by the UPDATE's own WHERE (`status = 'active' and
100
+ -- released_at is null and lease_expires_at > now()`) to be one of the counted rows, so the count is
101
+ -- INVARIANT under renew: renew inserts nothing and can only shorten. Two concurrent renews by the
102
+ -- same agent therefore either both freeze or both pass, and either way the population is unchanged.
103
+ -- The only stale read available is "saw 51 while a concurrent release made it 50", whose entire
104
+ -- consequence is one renew that did not extend and which the next renew extends. Acquire keeps its
105
+ -- lock, because acquire COUNTS THEN INSERTS and that genuinely races.
106
+ --
107
+ -- THE CARVE-OUTS ARE ACQUIRE'S FOUR, VERBATIM, so one operator flag governs every ownerless guard
108
+ -- on both write paths and a board coordinating its own locking is not converged by ours:
109
+ -- the kind is ownerless · not service_role · _claimgate_ownerless_guards_enabled()
110
+ -- · the claim's board is not claim_policy = 'advisory'
111
+ -- The agent id and the thread come from the LIVE CLAIM, never from a caller argument — a
112
+ -- caller-supplied thread would let an agent choose which board's policy is consulted.
113
+ --
114
+ -- ONE DEFINITION OF THE COUNT, NOT THREE. `_claimgate_active_ownerless_claims()` below is extracted
115
+ -- from acquire's inline predicate and acquire is re-created to call it, so the two ENFORCEMENT
116
+ -- paths cannot drift. The census view keeps its own copy DELIBERATELY: OQ-6's four near-miss rows
117
+ -- are what make each conjunct of the reader's predicate individually mutation-visible, and routing
118
+ -- the view's headline through the helper would make that mutation invisible. The two are pinned
119
+ -- EQUAL behaviourally instead (ORQ-13), which is the property that actually matters.
120
+
121
+ -- ---------------------------------------------------------------------------
122
+ -- The count. Acquire's quota predicate, moved here unchanged, and now the single definition both
123
+ -- enforcement paths ask.
124
+ -- ---------------------------------------------------------------------------
125
+ create or replace function agent_board._claimgate_active_ownerless_claims(p_agent_id uuid)
126
+ returns integer
127
+ language sql
128
+ stable
129
+ set search_path = ''
130
+ as $$
131
+ select count(*)::integer
132
+ from agent_board.claims c
133
+ where c.agent_id = p_agent_id
134
+ and c.resource_kind in ('file', 'tool', 'custom')
135
+ and c.status = 'active'
136
+ and c.released_at is null
137
+ and c.lease_expires_at > now();
138
+ $$;
139
+
140
+ -- SERVICE_ROLE ONLY, and NARROWER than the sibling `_claimgate_ownerless_quota()` (which is granted
141
+ -- to `authenticated`) on purpose. That one returns a constant and tells a caller nothing. This one
142
+ -- reads `agent_board.claims`, and it is `security invoker` — so an `authenticated` caller would get
143
+ -- its OWN RLS-filtered count back, wearing the name of the fleet-wide quota count. That is the
144
+ -- census's own #367-shape trap one layer down: a confident, plausible, wrong number. The two
145
+ -- SECURITY DEFINER callers do not need the grant (a definer executes as its owner), so withholding
146
+ -- it costs the enforcement paths nothing.
147
+ revoke all on function agent_board._claimgate_active_ownerless_claims(uuid) from public, anon;
148
+ grant execute on function agent_board._claimgate_active_ownerless_claims(uuid) to service_role;
149
+
150
+ comment on function agent_board._claimgate_active_ownerless_claims(uuid) is
151
+ '#388: THE single definition of an agent''s active ownerless (file/tool/custom) claim count — '
152
+ 'acquire_claim''s quota predicate, extracted verbatim (kind in file/tool/custom, status active, '
153
+ 'released_at null, lease unexpired). Called by acquire_claim (which refuses at >= '
154
+ '_claimgate_ownerless_quota()) and by renew_claim (which stops EXTENDING at > quota, so an '
155
+ 'over-quota agent converges to the ceiling UNLESS its board opts out — a board owner can flip '
156
+ 'claim_policy to advisory and back indefinitely, and an agent that created a board is its owner, '
157
+ 'so convergence is guaranteed only against an agent whose boards do not opt out, #399). NO '
158
+ 'board-policy filter here: the advisory opt-out gates '
159
+ 'whether a guard RUNS, not whether the resulting rows COUNT. service_role only, and deliberately '
160
+ 'narrower than _claimgate_ownerless_quota(): this reads claims under security invoker, so an '
161
+ 'authenticated caller would receive its own RLS-filtered count under a fleet-wide name.';
162
+
163
+ -- ---------------------------------------------------------------------------
164
+ -- acquire_claim, re-created from the 20260922000100 pg_get_functiondef dump with exactly one hunk
165
+ -- changed: the inline count is now the helper call. CREATE OR REPLACE with an unchanged signature,
166
+ -- so the ACL and the COMMENT survive.
167
+ -- ---------------------------------------------------------------------------
168
+ 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)
169
+ 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)
170
+ LANGUAGE plpgsql
171
+ SECURITY DEFINER
172
+ SET search_path TO 'agent_board', 'public'
173
+ AS $function$
174
+ declare
175
+ v_thread_id uuid;
176
+ v_claim_id uuid;
177
+ v_actor_agent_id uuid;
178
+ v_shadow_authorized boolean;
179
+ v_ownerless boolean;
180
+ v_active_ownerless int;
181
+ begin
182
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
183
+
184
+ if nullif(trim(p_resource_key), '') is null then
185
+ raise exception 'resource key is required' using errcode = '22023';
186
+ end if;
187
+
188
+ -- CLAIMKEY (#133): normalize the resource key to its canonical, fleet-fingerprint-carrying
189
+ -- form BEFORE any lookup/insert, so the row stored (and the uniqueness index contended)
190
+ -- carry canonical identity. NORMALIZE-ONLY — this never rejects (rejection is ENFORCE).
191
+ p_resource_key := agent_board._canonical_claim_key(p_resource_kind, p_resource_key);
192
+
193
+ if p_task_id is not null then
194
+ select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
195
+ if v_thread_id is null then
196
+ raise exception 'task not found' using errcode = 'P0002';
197
+ end if;
198
+ if not agent_board._current_agent_can_access_task(p_task_id) then
199
+ raise exception 'task not accessible to current agent' using errcode = '42501';
200
+ end if;
201
+ else
202
+ v_thread_id := p_thread_id;
203
+ end if;
204
+
205
+ if v_thread_id is not null and not agent_board._current_agent_can_access_thread(v_thread_id) then
206
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
207
+ end if;
208
+
209
+ -- message-board#291: the SUBJECT gate. The checks above answer "may this SESSION touch the
210
+ -- task/thread" and are short-circuited true for service_role; this one answers "does the
211
+ -- agent the claim is ABOUT already have access". Without it a service_role caller could name
212
+ -- any agent, and the resulting claims row -- itself an access disjunct, and a PERMANENT one
213
+ -- -- would MINT thread access (and, through the thread disjunct of
214
+ -- _current_agent_can_access_task, task access) rather than record a lock on something the
215
+ -- subject could already see.
216
+ --
217
+ -- UNCONDITIONAL IN *WHO*. DO NOT "optimize" this into `if _is_service_role() and not ...` or
218
+ -- `if v_actor_agent_id is distinct from agent_board.current_agent_id()`. For an authenticated
219
+ -- caller v_actor_agent_id IS current_agent_id() and the session check above already
220
+ -- established this same expression, so the redundancy is PROVABLE rather than suspected; it
221
+ -- is the price of an invariant this function states for itself instead of borrowing from
222
+ -- _resolved_actor_agent_id continuing to raise on a subject mismatch.
223
+ --
224
+ -- CONDITIONAL ON *THREAD PRESENCE*, which is not a loophole. A claim bound to neither a task
225
+ -- nor a thread stores thread_id = null, is a disjunct of nothing and mints no access.
226
+ -- _agent_can_access_thread answers false for a null thread, so gating unconditionally would
227
+ -- refuse every machine / repository / repo_file / ownerless claim in the fleet. This covers
228
+ -- exactly the claims that are keys.
229
+ --
230
+ -- PLACED AFTER THE TASK LOOKUP so a nonexistent task still answers P0002, not 42501 -- that
231
+ -- ordering IS test-pinned. Placed BEFORE the sweep only to match the predicate-before-side-
232
+ -- effects convention CLAIMGATE/ENFORCE states below; it is NOT what makes a denial free of
233
+ -- side effects. `raise exception` unwinds the whole call, so a refused caller changes no state
234
+ -- wherever this sits -- measured, by moving the gate below the sweep and observing the test
235
+ -- file stay entirely green.
236
+ if v_thread_id is not null
237
+ and not agent_board._agent_can_access_thread(v_thread_id, v_actor_agent_id) then
238
+ raise exception 'claim subject has no access to thread' using errcode = '42501';
239
+ end if;
240
+
241
+ -- OBSGATE-4 (#127): a claim on a TASK resource is authorized on the LOCKED task = the task
242
+ -- named by RESOURCE_KEY (resource_kind='task'). The active-resource uniqueness is on
243
+ -- (resource_kind, resource_key), so that is the task a squat contends for — gating resource_key
244
+ -- (NOT p_task_id, which only associates the claim/event) prevents locking task B's key while
245
+ -- authorizing on an unrelated task A passed as p_task_id. A non-uuid 'task' key cannot collide
246
+ -- with a uuid-keyed legitimate claim.
247
+ --
248
+ -- The composition MIRRORS append_task_event's: ACCESS then MUTATE. mutate alone is
249
+ -- insufficient — _current_agent_can_mutate_task admits any non-observer board member via
250
+ -- _current_agent_can_post_to_thread (board send-role), which is NOT thread access; on a
251
+ -- RESTRICTED thread a board member lacking thread access would pass mutate but must still be
252
+ -- denied (they cannot access the task). Requiring access too closes that gap and matches the
253
+ -- authorization a task-BOUND acquire already gets through append_task_event.
254
+ if p_resource_kind = 'task'
255
+ 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
256
+ if not agent_board._current_agent_can_access_task(p_resource_key::uuid) then
257
+ raise exception 'task not accessible to current agent' using errcode = '42501';
258
+ end if;
259
+ if not agent_board._current_agent_can_mutate_task(p_resource_key::uuid) then
260
+ raise exception 'claiming a task resource requires a non-observer board role, or task assignment/creation'
261
+ using errcode = '42501';
262
+ end if;
263
+ end if;
264
+
265
+ -- CLAIMGATE/SHADOW (#133): compute the fleet-graph authority verdict BEFORE the sweep (so
266
+ -- ENFORCE can flip this same point fail-closed with no observable side effect leaking to an
267
+ -- unauthorized caller). The verdict is stamped below regardless of enforcement.
268
+ v_shadow_authorized := agent_board._can_claim_resource(p_resource_kind, p_resource_key);
269
+
270
+ -- CLAIMGATE/ENFORCE (#133): per-kind GATED DENIAL, BEFORE the sweep (predicate-before-side-
271
+ -- effects). When this fleet-backed kind's flag is ON and the actor is UNAUTHORIZED, DENY —
272
+ -- no state has changed yet (sweep/insert are below). When OFF, fall through to the shadow-
273
+ -- stamp only (SHADOW behavior; blocks nothing). Non-fleet kinds never kind-enforce (resolver
274
+ -- returns FALSE). service_role is authorized by the predicate (wrapper short-circuit), so the
275
+ -- operator carve-out holds. Fail-closed-on-unknown is automatic: an unresolvable fleet resource
276
+ -- makes v_shadow_authorized FALSE, so an enforced acquire of it denies.
277
+ if p_resource_kind in ('machine', 'repository', 'repo_file')
278
+ and agent_board._claimgate_kind_enforced(p_resource_kind)
279
+ and not v_shadow_authorized then
280
+ raise exception 'not authorized to claim this % resource (fleet authority required)', p_resource_kind
281
+ using errcode = '42501';
282
+ end if;
283
+
284
+ -- CLAIMGATE/ENFORCE (#133): max-lease horizon. For an ENFORCED fleet-backed kind, CLAMP the
285
+ -- lease to now()+horizon (default 30d, operator-configurable) — a bound on lease term, NOT an
286
+ -- authority block. Clamp (not reject) so an over-long lease reconciles here / at its next renew.
287
+ -- service_role exempt (operator carve-out). Only reached by an AUTHORIZED acquire (unauthorized
288
+ -- was denied above), or when the flag is OFF (then the guard below is skipped entirely).
289
+ if p_resource_kind in ('machine', 'repository', 'repo_file')
290
+ and not agent_board._is_service_role()
291
+ and agent_board._claimgate_kind_enforced(p_resource_kind)
292
+ and p_lease_expires_at > now() + agent_board._claimgate_max_lease_horizon() then
293
+ p_lease_expires_at := now() + agent_board._claimgate_max_lease_horizon();
294
+ end if;
295
+
296
+ -- Ownerless tail (file/tool/custom): the ONE guard SHADOW carries — GATED OFF BY DEFAULT.
297
+ -- _claimgate_ownerless_guards_enabled() is false unless an operator flips it post-census, so in
298
+ -- SHADOW this block is skipped entirely and NOTHING is blocked (only the stamp above lands).
299
+ -- service_role is also exempt (operator carve-out, consistent with the predicate short-circuit).
300
+ v_ownerless := p_resource_kind in ('file', 'tool', 'custom');
301
+ if v_ownerless
302
+ and not agent_board._is_service_role()
303
+ and agent_board._claimgate_ownerless_guards_enabled()
304
+ -- BOARD OPT-OUT: a board whose work is coordinated by SOMEONE ELSE'S locking (a repo lock, a
305
+ -- scheduler lease, an external mutex) can set claim_policy='advisory'. Our claims are then
306
+ -- RECORDED for visibility and never ENFORCED, so we cannot reject an acquire that their own
307
+ -- lock already permitted. Resolved from the claim's thread; a claim with no thread, or a
308
+ -- thread with no board, has no board to ask and falls back to the fleet-wide flags.
309
+ and agent_board._claimgate_board_claim_policy(v_thread_id) <> 'advisory' then
310
+ -- TTL CAP: an ownerless lease may not exceed the max horizon. Rejects an over-long acquire;
311
+ -- this is a lease-length bound, NOT an authority block.
312
+ if p_lease_expires_at > now() + agent_board._claimgate_ownerless_ttl_cap() then
313
+ raise exception 'ownerless claim lease horizon exceeds cap (%)', agent_board._claimgate_ownerless_ttl_cap()
314
+ using errcode = '22023';
315
+ end if;
316
+
317
+ -- QUOTA (transactionally serialized): an actor-scoped advisory XACT lock serializes this
318
+ -- actor's concurrent acquisitions (on DIFFERENT keys, which the row-level uniqueness index
319
+ -- does NOT), so a count-before-insert cannot race two claims past the cap. The lock releases
320
+ -- at commit; it does not block other actors (the key mixes in the actor id).
321
+ perform pg_advisory_xact_lock(hashtext('agent_board.claimgate_ownerless_quota'), hashtext(v_actor_agent_id::text));
322
+ -- #388: THE COUNT IS THE SHARED HELPER NOW, not an inline predicate. renew_claim asks the
323
+ -- same question one line of arithmetic away, and two hand-copies of a predicate across two
324
+ -- enforcement paths is rule 1's own failure shape. The ADVISORY LOCK ABOVE STAYS HERE AND IS
325
+ -- NOT MIRRORED INTO RENEW, deliberately: it exists because acquire COUNTS THEN INSERTS, and a
326
+ -- renew inserts nothing (see this migration's header).
327
+ v_active_ownerless := agent_board._claimgate_active_ownerless_claims(v_actor_agent_id);
328
+ if v_active_ownerless >= agent_board._claimgate_ownerless_quota() then
329
+ raise exception 'ownerless active-claim quota reached (% per agent)', agent_board._claimgate_ownerless_quota()
330
+ using errcode = '54000';
331
+ end if;
332
+ end if;
333
+
334
+ -- CLAIMKEY (#133) MUST-FIX 3: the sweep AND the conflict check are ALIAS-AWARE. p_resource_key
335
+ -- is already canonical (above); comparing against the CANONICAL form of each existing row's key
336
+ -- matches both a canonical row AND an un-healed LEGACY row for the same physical resource. Without
337
+ -- this, acquire('machine','machine:fpDH') would normalize to 'fpDH', see no exact-'fpDH' conflict
338
+ -- against a still-active legacy 'machine:fpDH' row, and INSERT a second live hold — breaking mutual
339
+ -- exclusion during the legacy window. Closing it here in code does NOT depend on the operator-owned
340
+ -- live census.
341
+ update agent_board.claims c
342
+ set status = 'expired',
343
+ updated_at = now()
344
+ where c.resource_kind = p_resource_kind
345
+ and agent_board._canonical_claim_key(c.resource_kind, c.resource_key) = p_resource_key
346
+ and c.status = 'active'
347
+ and c.released_at is null
348
+ and c.lease_expires_at <= now();
349
+
350
+ if exists (
351
+ select 1 from agent_board.claims
352
+ where claims.resource_kind = p_resource_kind
353
+ and agent_board._canonical_claim_key(claims.resource_kind, claims.resource_key) = p_resource_key
354
+ and claims.status = 'active'
355
+ and claims.released_at is null
356
+ and claims.lease_expires_at > now()
357
+ ) then
358
+ raise exception 'conflicting active claim' using errcode = '23505';
359
+ end if;
360
+
361
+ insert into agent_board.claims(task_id, thread_id, agent_id, resource_kind, resource_key, lease_expires_at, metadata)
362
+ values (
363
+ p_task_id, v_thread_id, v_actor_agent_id, p_resource_kind, p_resource_key, p_lease_expires_at,
364
+ -- SHADOW stamp: record the authority verdict alongside any caller metadata. Merged last so it
365
+ -- is authoritative over a caller-supplied claimgate_shadow key.
366
+ coalesce(p_metadata, '{}'::jsonb)
367
+ || jsonb_build_object(
368
+ 'claimgate_shadow',
369
+ jsonb_build_object('kind', p_resource_kind, 'authorized', v_shadow_authorized)
370
+ )
371
+ )
372
+ returning id into v_claim_id;
373
+
374
+ if p_task_id is not null then
375
+ 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));
376
+ end if;
377
+
378
+ return query
379
+ 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
380
+ from agent_board.claims c
381
+ where c.id = v_claim_id;
382
+ end;
383
+ $function$;
384
+
385
+ -- ---------------------------------------------------------------------------
386
+ -- renew_claim. The #373 body (20260925000100) with the convergence clamp added after the TTL clamp,
387
+ -- and the live claim's agent id and CURRENT expiry read alongside the kind it already reads.
388
+ -- ---------------------------------------------------------------------------
389
+ create or replace function agent_board.renew_claim(p_lease_token uuid, p_lease_expires_at timestamptz)
390
+ 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 timestamptz, released_at timestamptz, created_at timestamptz, updated_at timestamptz)
391
+ language plpgsql
392
+ security definer
393
+ set search_path = agent_board, public
394
+ as $$
395
+ declare
396
+ v_actor_agent_id uuid;
397
+ v_kind text;
398
+ v_key text;
399
+ v_thread_id uuid;
400
+ v_claim_agent_id uuid;
401
+ v_current_expires timestamptz;
402
+ v_effective_expires timestamptz;
403
+ begin
404
+ if not agent_board._is_service_role() then
405
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(null, 'current_agent_id');
406
+ if not exists (
407
+ select 1
408
+ from agent_board.claims c
409
+ where c.lease_token = p_lease_token
410
+ and c.agent_id = v_actor_agent_id
411
+ ) then
412
+ raise exception 'claim not accessible to current agent' using errcode = '42501';
413
+ end if;
414
+ end if;
415
+
416
+ -- CLAIMGATE/ENFORCE (#133): re-authorize + horizon for the predicate-gated kinds. Read the
417
+ -- live lease's resource identity (SECURITY DEFINER, so RLS does not hide it). A dead/expired
418
+ -- lease yields a null kind and the UPDATE below matches nothing (preserves prior behavior).
419
+ --
420
+ -- #388 adds the HOLDER and the CURRENT EXPIRY to this same read. Both come from the live row and
421
+ -- never from a caller argument: a caller-supplied agent id is an assertion about who holds the
422
+ -- claim, and a caller-supplied expiry would let the convergence clamp be handed its own baseline.
423
+ select c.resource_kind, c.resource_key, c.thread_id, c.agent_id, c.lease_expires_at
424
+ into v_kind, v_key, v_thread_id, v_claim_agent_id, v_current_expires
425
+ from agent_board.claims c
426
+ where c.lease_token = p_lease_token
427
+ and c.status = 'active'
428
+ and c.released_at is null
429
+ and c.lease_expires_at > now();
430
+
431
+ -- RE-AUTH: bound revocation latency to one lease term. Uses the SAME session wrapper acquire
432
+ -- consults (_can_claim_resource -> _can_claim_resource_for(current_agent_id())), so service_role
433
+ -- keeps its carve-out and acquire+renew share ONE per-kind flag (a mutation splitting the flags
434
+ -- fails a test). Task-renew is EXCLUDED (ownership-only, ratified supersession).
435
+ if v_kind in ('machine', 'repository', 'repo_file')
436
+ and agent_board._claimgate_kind_enforced(v_kind) then
437
+ if not agent_board._can_claim_resource(v_kind, agent_board._canonical_claim_key(v_kind, v_key)) then
438
+ raise exception 'not authorized to renew this % claim (fleet authority revoked)', v_kind
439
+ using errcode = '42501';
440
+ end if;
441
+ end if;
442
+
443
+ -- HORIZON: clamp the renewed lease to now()+horizon for an ENFORCED fleet-gated kind — this is
444
+ -- where an existing overlong lease reconciles (no bulk-clamp migration; the horizon clamps it
445
+ -- at its next renew). service_role exempt. Same per-kind flag as acquire.
446
+ v_effective_expires := p_lease_expires_at;
447
+ if v_kind in ('machine', 'repository', 'repo_file')
448
+ and not agent_board._is_service_role()
449
+ and agent_board._claimgate_kind_enforced(v_kind)
450
+ and p_lease_expires_at > now() + agent_board._claimgate_max_lease_horizon() then
451
+ v_effective_expires := now() + agent_board._claimgate_max_lease_horizon();
452
+ end if;
453
+
454
+ -- #373 OWNERLESS TTL CAP AT RENEW. The half that was missing: without this an agent renews a
455
+ -- file/tool/custom lease past the 30-day cap indefinitely and the bound is decorative.
456
+ --
457
+ -- Clamps rather than raises, unlike acquire. A raise here would kill a lease the agent is
458
+ -- actively holding; shortening it costs one more renew. Same resolution the fleet-kind horizon
459
+ -- clamp above already applies, and the same place an over-long legacy lease reconciles.
460
+ --
461
+ -- The four conditions are acquire's, verbatim, so ONE operator flag governs both paths and a
462
+ -- board that declined our locking (claim_policy='advisory') is not clamped by it either. The
463
+ -- thread comes from the LIVE CLAIM, never from a caller argument -- a caller-supplied thread
464
+ -- would let an agent choose which board's policy is consulted.
465
+ if v_kind in ('file', 'tool', 'custom')
466
+ and not agent_board._is_service_role()
467
+ and agent_board._claimgate_ownerless_guards_enabled()
468
+ and agent_board._claimgate_board_claim_policy(v_thread_id) <> 'advisory'
469
+ and v_effective_expires > now() + agent_board._claimgate_ownerless_ttl_cap() then
470
+ v_effective_expires := now() + agent_board._claimgate_ownerless_ttl_cap();
471
+ end if;
472
+
473
+ -- #388 OWNERLESS QUOTA CONVERGENCE AT RENEW. The cap above bounds how LONG a lease may be; this
474
+ -- bounds how long an agent may stay OVER THE CEILING, which nothing did before. While the holder
475
+ -- is over quota a renew may not POSTPONE a deadline: it still succeeds, the lease it already
476
+ -- holds is preserved to the microsecond, and the only thing withheld is more time. Each claim
477
+ -- then lapses at its own already-set expiry, the count falls monotonically, and this block stops
478
+ -- firing the instant the count is back at the ceiling.
479
+ --
480
+ -- least(), not an assignment. An over-quota agent asking for a SHORTER lease must still get the
481
+ -- shorter one — that is the agent converging voluntarily, and refusing it would be perverse.
482
+ --
483
+ -- `>` and not `>=`. `>=` is ACQUIRE's refusal predicate; reusing it here would freeze an agent
484
+ -- sitting legitimately AT the ceiling and walk it down to 49. The predicate is the census's own
485
+ -- `overage > 0`, so the fixed point is exactly the ceiling.
486
+ --
487
+ -- Same four carve-outs as the cap above, evaluated against the LIVE claim's holder and thread.
488
+ -- No advisory lock: the row being renewed is required by the UPDATE's own WHERE to be one of the
489
+ -- counted rows, so the count is invariant under renew and there is no slot to race for. See the
490
+ -- migration header for why that departs from #388 step 2.
491
+ if v_kind in ('file', 'tool', 'custom')
492
+ and not agent_board._is_service_role()
493
+ and agent_board._claimgate_ownerless_guards_enabled()
494
+ and agent_board._claimgate_board_claim_policy(v_thread_id) <> 'advisory'
495
+ and agent_board._claimgate_active_ownerless_claims(v_claim_agent_id)
496
+ > agent_board._claimgate_ownerless_quota() then
497
+ v_effective_expires := least(v_effective_expires, v_current_expires);
498
+ end if;
499
+
500
+ -- CLAIMKEY (#133) MUST-FIX 1: re-canonicalize the STORED key as the lease is renewed, but
501
+ -- NEVER propagate a uniqueness collision. A check-then-act guard is NOT race-safe: two aliased
502
+ -- legacy rows renewed concurrently at READ COMMITTED can both observe the canonical key free
503
+ -- and one loses on claims_active_resource_unique (23505). So attempt the canonicalization and,
504
+ -- on unique_violation (deterministic alias OR concurrent race), retry ONCE keeping the CURRENT
505
+ -- key. That makes renew non-raising UNCONDITIONALLY (true normalize-only); the surviving legacy
506
+ -- key is the deferred dual-key alias the ENFORCE phase closes.
507
+ begin
508
+ return query
509
+ update agent_board.claims c
510
+ set lease_expires_at = v_effective_expires,
511
+ resource_key = agent_board._canonical_claim_key(c.resource_kind, c.resource_key),
512
+ updated_at = now()
513
+ where c.lease_token = p_lease_token
514
+ and c.status = 'active'
515
+ and c.released_at is null
516
+ and c.lease_expires_at > now()
517
+ 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;
518
+ exception when unique_violation then
519
+ -- the canonical key is already held (by a committed twin or a concurrent renew/acquire):
520
+ -- renew the lease keeping the current key, aliasing deferred to ENFORCE. Only the key change
521
+ -- can raise unique_violation (the lease/timestamp columns are unconstrained), so the retry
522
+ -- cannot itself collide.
523
+ return query
524
+ update agent_board.claims c
525
+ set lease_expires_at = v_effective_expires,
526
+ updated_at = now()
527
+ where c.lease_token = p_lease_token
528
+ and c.status = 'active'
529
+ and c.released_at is null
530
+ and c.lease_expires_at > now()
531
+ 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;
532
+ end;
533
+ end;
534
+ $$;
535
+
536
+ -- ---------------------------------------------------------------------------
537
+ -- Rule 5: two comments that were true when written and are not now. Corrected in the same
538
+ -- migration that falsifies them rather than left for the next reader to disbelieve.
539
+ -- ---------------------------------------------------------------------------
540
+ comment on function agent_board._claimgate_ownerless_quota() is
541
+ 'CLAIMGATE/SHADOW (#133): max concurrent ACTIVE ownerless (file/tool/custom) claims per agent. '
542
+ 'Single source for the migration logic and its tests. GENEROUS placeholder (50) pending prod '
543
+ 'census. Enforced on BOTH write paths: acquire_claim REFUSES at count >= quota (54000), and '
544
+ 'renew_claim stops EXTENDING at count > quota (#388) so an agent already over the ceiling '
545
+ 'converges back to it instead of renewing its overage forever — GUARANTEED ONLY against an agent '
546
+ 'whose boards do not opt out: a board owner may flip claim_policy to advisory, let the agent '
547
+ 'renew to the full horizon, and flip back, indefinitely (#399; pinned by ORQ-11/12). The two '
548
+ 'predicates differ '
549
+ 'deliberately — >= is what makes the quota the largest reachable population, > is what makes the '
550
+ 'ceiling the convergence fixed point — and both count through '
551
+ '_claimgate_active_ownerless_claims(). Governed by _claimgate_ownerless_guards_enabled() on both '
552
+ 'paths, service_role exempt, and skipped for a board whose claim_policy is advisory.';
553
+
554
+ comment on table agent_board.claimgate_enforcement_flags is
555
+ 'CLAIMGATE per-scope enforcement flags. Absent row => disabled (SHADOW default: blocks nothing). '
556
+ 'scope_key ''ownerless_guards'' gates EVERY ownerless (file/tool/custom) guard on BOTH write '
557
+ 'paths: the TTL cap + per-agent quota in acquire_claim, and in renew_claim both the TTL clamp '
558
+ '(#373) and the quota-convergence clamp (#388, which stops an over-quota holder from extending '
559
+ 'any lease until it is back at the ceiling — on a board that has not opted out; the advisory '
560
+ 'opt-out is owner-toggleable and re-toggleable, so it is also an escape from convergence, #399). '
561
+ 'Operator-managed (service_role); enabled only after '
562
+ 'the prod lease-length census.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-schema",
3
- "version": "1.45.0",
3
+ "version": "1.48.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.45.0",
5
+ "packageVersion": "1.48.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": 143,
50
- "aggregateSha256": "e5f248a69031759fbd220b5504268c4ca3159fc7cf641404badf2f2ae855e4ae",
49
+ "migrationCount": 146,
50
+ "aggregateSha256": "cbb24bed446f3334f4c0593716b51ee00e5b6faf57bfd7b48a8767f019551551",
51
51
  "migrations": [
52
52
  {
53
53
  "order": 0,
@@ -1335,6 +1335,33 @@
1335
1335
  "sourceRef": "message-board",
1336
1336
  "bytes": 9981,
1337
1337
  "sha256": "da312f43cabf07c6dfd10dbe2a119fb46f49f24155e21adce4f38e291a38103c"
1338
+ },
1339
+ {
1340
+ "order": 143,
1341
+ "id": "20260926000100_agent_board_ownerless_quota_census",
1342
+ "filename": "20260926000100_agent_board_ownerless_quota_census.sql",
1343
+ "origin": "message-board-forward",
1344
+ "sourceRef": "message-board",
1345
+ "bytes": 17894,
1346
+ "sha256": "bbfabcb2d45cbcebd1e6e121032a1045e8406ea8763a784762a92d210e1e1043"
1347
+ },
1348
+ {
1349
+ "order": 144,
1350
+ "id": "20260927000100_agent_board_fleet_domain_no_downgrade",
1351
+ "filename": "20260927000100_agent_board_fleet_domain_no_downgrade.sql",
1352
+ "origin": "message-board-forward",
1353
+ "sourceRef": "message-board",
1354
+ "bytes": 25689,
1355
+ "sha256": "ec5c4d89aff2d6d77d44cb3d00c1096b39010c6064c23d72eb3c5c354f8a5e74"
1356
+ },
1357
+ {
1358
+ "order": 145,
1359
+ "id": "20260928000100_agent_board_ownerless_quota_renew_convergence",
1360
+ "filename": "20260928000100_agent_board_ownerless_quota_renew_convergence.sql",
1361
+ "origin": "message-board-forward",
1362
+ "sourceRef": "message-board",
1363
+ "bytes": 36921,
1364
+ "sha256": "9982efff25378703a744187b15081b5cd53ab38fa6ac7d2950092a471780169e"
1338
1365
  }
1339
1366
  ],
1340
1367
  "excludedBridge": {