@consiliency/agent-board-schema 1.48.0 → 1.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +12 -0
- package/migrations/20260929000100_agent_board_advisory_revert_cap_reset.sql +371 -0
- package/migrations/20260930000100_agent_board_claim_policy_race.sql +922 -0
- package/migrations/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql +158 -0
- package/package.json +1 -1
- package/schema-manifest.json +30 -3
- package/src/tooling.test.mjs +25 -2
- package/tests/agent_board_board_claim_policy.test.sql +261 -1
- package/tests/agent_board_claim_subject_gate.test.sql +11 -2
- package/tests/agent_board_claimkey.test.sql +17 -2
- package/tests/agent_board_claimpolicy_race.test.sql +1374 -0
- package/tests/agent_board_ownerless_quota_census.test.sql +6 -0
- package/tests/agent_board_ownerless_renew_quota.test.sql +37 -8
- package/tests/catalog/p_metadata_parameters.json +16 -0
- package/tests/fixtures/test-prereqs.sql +186 -0
- package/tests/isolation/expected/claimpolicy_acquire_revert_race.out +66 -0
- package/tests/isolation/expected/claimpolicy_board_scope.out +73 -0
- package/tests/isolation/expected/claimpolicy_claimrow_lock_order.out +80 -0
- package/tests/isolation/expected/claimpolicy_isolation_refusal.out +114 -0
- package/tests/isolation/expected/claimpolicy_renew_revert_race.out +66 -0
- package/tests/isolation/expected/claimpolicy_writer_keyshare.out +49 -0
- package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +118 -0
- package/tests/isolation/specs/claimpolicy_board_scope.spec +115 -0
- package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +200 -0
- package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +162 -0
- package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +150 -0
- package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +107 -0
- 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.';
|