@consiliency/agent-board-schema 1.45.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 +24 -0
- package/migrations/20260926000100_agent_board_ownerless_quota_census.sql +268 -0
- package/migrations/20260927000100_agent_board_fleet_domain_no_downgrade.sql +393 -0
- package/migrations/20260928000100_agent_board_ownerless_quota_renew_convergence.sql +562 -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 +57 -3
- package/src/tooling.test.mjs +52 -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_fleet_machine_hijack.test.sql +270 -1
- package/tests/agent_board_fleet_machine_reclaim.test.sql +28 -2
- package/tests/agent_board_ownerless_quota_census.test.sql +406 -0
- package/tests/agent_board_ownerless_renew_quota.test.sql +580 -0
- package/tests/catalog/p_metadata_parameters.json +28 -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/expected/fleet_domain_no_downgrade_demote_race.out +447 -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/isolation/specs/fleet_domain_no_downgrade_demote_race.spec +292 -0
- package/tests/seeds/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql.pre.sql +99 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
-- #400 — a board owner can defeat ownerless-quota convergence indefinitely, and leave no trace.
|
|
2
|
+
--
|
|
3
|
+
-- THE DEFECT, AND IT NEEDS NO ACCOMPLICE. `create_board` seeds the creator's owner membership, and
|
|
4
|
+
-- that membership is exactly what `set_board_claim_policy`'s gate tests. So an agent that creates a
|
|
5
|
+
-- board holds the switch that exempts its own claims:
|
|
6
|
+
--
|
|
7
|
+
-- 1. flip the board to claim_policy = 'advisory';
|
|
8
|
+
-- 2. renew every ownerless claim to the full horizon — the advisory carve-out skips BOTH the
|
|
9
|
+
-- #373 TTL cap and the #388 convergence clamp;
|
|
10
|
+
-- 3. flip back to 'inherit';
|
|
11
|
+
-- 4. repeat before expiry.
|
|
12
|
+
--
|
|
13
|
+
-- And it is invisible. #393's census resolves board policy at QUERY time (OQ-29), so after step 3
|
|
14
|
+
-- `claims_on_advisory_boards` reports ZERO for those claims while the overage they bought is still
|
|
15
|
+
-- counted. An operator sees an agent over quota with no advisory share — the shape that reads as
|
|
16
|
+
-- plain squatting — and the column built to explain the exemption reports none of it. #399 shipped
|
|
17
|
+
-- with this pinned as a test (ORQ-11/12) rather than described in prose, precisely so that closing
|
|
18
|
+
-- it would have to change an assertion instead of a paragraph.
|
|
19
|
+
--
|
|
20
|
+
-- ===========================================================================
|
|
21
|
+
-- THE FIX: A CAP RESET ON REVERT. On an 'advisory' -> 'inherit' transition, re-apply the ownerless
|
|
22
|
+
-- TTL cap to that board's active ownerless claims. Clamp only — least(lease_expires_at, now() +
|
|
23
|
+
-- _claimgate_ownerless_ttl_cap()) — never extend.
|
|
24
|
+
-- ===========================================================================
|
|
25
|
+
--
|
|
26
|
+
-- THE FAIRNESS ARGUMENT, which is the whole justification: reverting to 'inherit' means accepting
|
|
27
|
+
-- inherit's rules, and the clamp only ever SHORTENS. A board that wants the long horizon its own
|
|
28
|
+
-- mutex granted may keep it — by staying advisory. What it may not do is bank a long lease under
|
|
29
|
+
-- the opt-out and then hand the opt-out back. The claim keeps exactly what an ordinary claim on an
|
|
30
|
+
-- ordinary inherit board is allowed to keep, which is what "inherit" means.
|
|
31
|
+
--
|
|
32
|
+
-- WHY NOT THE AUDIT LEDGER #400 ORIGINALLY PROPOSED. A ledger makes the escape VISIBLE; this
|
|
33
|
+
-- removes its BENEFIT, and the two are not equal. To hold a lease longer than the cap the board
|
|
34
|
+
-- must STAY advisory — and while it is advisory those claims are counted by the census's
|
|
35
|
+
-- `claims_on_advisory_boards`, which is exactly the column an operator reads. So above the cap the
|
|
36
|
+
-- escape becomes either ineffective or visible, with no new mechanism and no policy history to
|
|
37
|
+
-- maintain. The owner chose this over the ledger.
|
|
38
|
+
--
|
|
39
|
+
-- ===========================================================================
|
|
40
|
+
-- WHAT THIS DOES NOT CLOSE. BOUNDED, NOT CLOSED — and the difference is stated here rather than
|
|
41
|
+
-- discovered later, because a header that overclaims is the #399 failure being repeated.
|
|
42
|
+
-- ===========================================================================
|
|
43
|
+
--
|
|
44
|
+
-- 1. THE FLIP-FLOP STILL WORKS, up to the cap. Each advisory window still buys a lease of up to
|
|
45
|
+
-- `_claimgate_ownerless_ttl_cap()` (30 days) that survives the revert, so an owner who flips
|
|
46
|
+
-- every 29 days keeps an over-quota agent over quota forever. What is gone is the UNBOUNDED
|
|
47
|
+
-- horizon: the ceiling on what a cycle can bank drops from "whatever was asked for" to the
|
|
48
|
+
-- same cap every non-advisory ownerless lease already lives under. #388's convergence clamp
|
|
49
|
+
-- is therefore still defeatable by a board owner; it is no longer defeatable by ONE flip.
|
|
50
|
+
-- ORQ-11 asserts the bound (the +400 day lease does not survive the revert) and says so.
|
|
51
|
+
--
|
|
52
|
+
-- 2. IT IS STILL INVISIBLE BETWEEN FLIPS. Nothing here records that the exemption was used, and
|
|
53
|
+
-- the census still resolves policy at query time. ORQ-12 is unchanged and still pins that.
|
|
54
|
+
-- Recording acquire-time policy remains #393's own documented gap and is not invented here.
|
|
55
|
+
--
|
|
56
|
+
-- 3. AN IN-FLIGHT RENEW CAN STILL WIN THE RACE, AND THIS IS UNPINNED. `_claimgate_board_claim_
|
|
57
|
+
-- policy` takes no lock on `boards`, so a `renew_claim` whose snapshot predates the revert's
|
|
58
|
+
-- commit resolves 'advisory' and, under READ COMMITTED, re-evaluates and applies its full
|
|
59
|
+
-- horizon AFTER this function's row lock is released at commit. The attacker owns both
|
|
60
|
+
-- sessions, so a deliberate interleave banks a full-horizon lease past the revert. Closing it
|
|
61
|
+
-- means taking a `boards` lock on the renew path, which is a LOCK-ORDER decision (renew
|
|
62
|
+
-- already touches claims and, through the resolver, threads/boards) and belongs with the
|
|
63
|
+
-- two-session isolationtester harness, not with this change. Recorded as open rather than
|
|
64
|
+
-- quietly excluded from the claim.
|
|
65
|
+
--
|
|
66
|
+
-- 4. AN OPERATOR EXTENSION IS CLAMPED TOO. `service_role` is exempt from the guards at acquire
|
|
67
|
+
-- and at renew, so an operator may deliberately hand an agent a 400-day ownerless lease. If
|
|
68
|
+
-- that claim's board is later reverted from advisory to inherit, this reset shortens it —
|
|
69
|
+
-- nothing records who last extended a lease, and inventing provenance to protect it would be
|
|
70
|
+
-- a second unreviewed mechanism. The operator can re-extend; service_role's renew is exempt.
|
|
71
|
+
--
|
|
72
|
+
-- ===========================================================================
|
|
73
|
+
-- THE DECISIONS, EACH PINNED
|
|
74
|
+
-- ===========================================================================
|
|
75
|
+
--
|
|
76
|
+
-- TRIGGER CONDITION: 'advisory' -> 'inherit' ONLY. Not 'inherit' -> 'inherit' (a no-op re-set must
|
|
77
|
+
-- clamp nothing; BCP-15), not 'inherit' -> 'advisory' (BCP-16), not 'advisory' -> 'advisory'
|
|
78
|
+
-- (BCP-17 — the one the other three cannot see: dropping the `= 'inherit'` conjunct leaves
|
|
79
|
+
-- "previously advisory", which still passes the two inherit-first cases). The OUTGOING policy is
|
|
80
|
+
-- read under `for no key update` on the board row, so two concurrent flips serialize and only the
|
|
81
|
+
-- one that actually observed 'advisory' resets. `for no key update` is deliberate over `for
|
|
82
|
+
-- update`: it does not conflict with the FOR KEY SHARE that an FK reference to `boards` takes, so
|
|
83
|
+
-- inserting a thread on this board does not queue behind a policy flip.
|
|
84
|
+
--
|
|
85
|
+
-- CARVE-OUTS, and where they DIFFER from the guards this mirrors — stated because "consistency" is
|
|
86
|
+
-- the default expectation and one of the two is deliberately absent:
|
|
87
|
+
--
|
|
88
|
+
-- * `_claimgate_ownerless_guards_enabled()` GOVERNS THIS, exactly as it governs every other
|
|
89
|
+
-- ownerless guard on both write paths. With the flag off nothing enforces the TTL cap at
|
|
90
|
+
-- acquire or at renew, so re-applying it on a revert would enforce a cap that no other path
|
|
91
|
+
-- enforces — one flag, one meaning. BCP-18 pins it.
|
|
92
|
+
--
|
|
93
|
+
-- * THERE IS NO service_role CARVE-OUT, because there is nothing for it to carve out.
|
|
94
|
+
-- `set_board_claim_policy` REFUSES service_role outright (current_agent_id() is null, so the
|
|
95
|
+
-- owner gate raises 42501 — BCP-8) and the reset sits below that gate. An `and not
|
|
96
|
+
-- _is_service_role()` conjunct here would be dead code that reads like a policy. The carve-out
|
|
97
|
+
-- that DOES bite is the one recorded above as limitation 4: the reset governs the BOARD's
|
|
98
|
+
-- claims regardless of who last extended them.
|
|
99
|
+
--
|
|
100
|
+
-- SCOPE: active, unreleased, unexpired claims of the ownerless kinds whose thread resolves to this
|
|
101
|
+
-- board. The kind list is NOT hand-written a fourth time — `_claimgate_ownerless_kinds()` is
|
|
102
|
+
-- introduced below and `_claimgate_active_ownerless_claims()` (the helper both enforcement paths
|
|
103
|
+
-- count through) is re-created to consume it, so the reset and the guards share one definition. A
|
|
104
|
+
-- claim on ANOTHER board, or with no thread at all, is out of scope and untouched (BCP-14): this
|
|
105
|
+
-- board's revert is a statement about this board. So are the DEAD rows on this board — a released
|
|
106
|
+
-- claim and a lapsed one (BCP-21), scored on the lease for the released row and on `updated_at` for
|
|
107
|
+
-- the lapsed one, whose lease `least()` already protects on its own.
|
|
108
|
+
--
|
|
109
|
+
-- `least()`, AND IT IS THE ONLY CLAMP MECHANISM — deliberately not paired with a
|
|
110
|
+
-- `lease_expires_at > now() + cap` conjunct in the WHERE. The two would be the same guard written
|
|
111
|
+
-- twice, and only one of them can be mutation-visible: with the WHERE conjunct present, deleting
|
|
112
|
+
-- `least()` changes nothing observable. So `least()` stands alone and BCP-12 fails when it is
|
|
113
|
+
-- replaced by an assignment. The cost is that a claim already WITHIN the cap has its `updated_at`
|
|
114
|
+
-- bumped by a revert while its lease is preserved to the microsecond; that is a documented
|
|
115
|
+
-- consequence of keeping one mechanism rather than two, and BCP-12 asserts the lease only.
|
|
116
|
+
--
|
|
117
|
+
-- CLASS (rule 1): `set claim_policy` appears in exactly one function in `agent_board`, and no role
|
|
118
|
+
-- holds UPDATE on `agent_board.boards` — so this RPC is the only writer of the column and the only
|
|
119
|
+
-- place the hook can live. Both halves are asserted from the catalog in BCP-19/20, the second as a
|
|
120
|
+
-- privilege bound rather than as a longer regex.
|
|
121
|
+
|
|
122
|
+
-- ---------------------------------------------------------------------------
|
|
123
|
+
-- The ownerless kind list, once. Every other site still spells it inline; those are the two
|
|
124
|
+
-- enforcement paths (acquire_claim, renew_claim) and they are pinned BEHAVIOURALLY (ORT-9, ORQ-2)
|
|
125
|
+
-- because a kind that stops being clamped is only visible as a clamp that did not happen. They are
|
|
126
|
+
-- not re-emitted here: transcribing two 150-line function bodies for a cosmetic dedup is a
|
|
127
|
+
-- transcription risk taken for no behaviour change.
|
|
128
|
+
-- ---------------------------------------------------------------------------
|
|
129
|
+
create or replace function agent_board._claimgate_ownerless_kinds()
|
|
130
|
+
returns text[]
|
|
131
|
+
language sql
|
|
132
|
+
immutable
|
|
133
|
+
parallel safe
|
|
134
|
+
set search_path = ''
|
|
135
|
+
as $$
|
|
136
|
+
select array['file', 'tool', 'custom']::text[];
|
|
137
|
+
$$;
|
|
138
|
+
|
|
139
|
+
revoke all on function agent_board._claimgate_ownerless_kinds() from public, anon;
|
|
140
|
+
grant execute on function agent_board._claimgate_ownerless_kinds() to authenticated, service_role;
|
|
141
|
+
|
|
142
|
+
comment on function agent_board._claimgate_ownerless_kinds() is
|
|
143
|
+
'The OWNERLESS claim kinds — the ones governed by the per-agent quota and the TTL cap rather '
|
|
144
|
+
'than by the fleet-authority gate. Single definition shared by '
|
|
145
|
+
'_claimgate_active_ownerless_claims() (which both enforcement paths count through) and by '
|
|
146
|
+
'set_board_claim_policy''s advisory-revert cap reset (#400). Granted to authenticated like its '
|
|
147
|
+
'sibling _claimgate_ownerless_quota(): it returns a constant and tells a caller nothing about '
|
|
148
|
+
'any row.';
|
|
149
|
+
|
|
150
|
+
-- ---------------------------------------------------------------------------
|
|
151
|
+
-- The count, re-created to consume the kind helper. Body otherwise unchanged from 20260928000100.
|
|
152
|
+
-- ---------------------------------------------------------------------------
|
|
153
|
+
create or replace function agent_board._claimgate_active_ownerless_claims(p_agent_id uuid)
|
|
154
|
+
returns integer
|
|
155
|
+
language sql
|
|
156
|
+
stable
|
|
157
|
+
set search_path = ''
|
|
158
|
+
as $$
|
|
159
|
+
select count(*)::integer
|
|
160
|
+
from agent_board.claims c
|
|
161
|
+
where c.agent_id = p_agent_id
|
|
162
|
+
and c.resource_kind = any(agent_board._claimgate_ownerless_kinds())
|
|
163
|
+
and c.status = 'active'
|
|
164
|
+
and c.released_at is null
|
|
165
|
+
and c.lease_expires_at > now();
|
|
166
|
+
$$;
|
|
167
|
+
|
|
168
|
+
revoke all on function agent_board._claimgate_active_ownerless_claims(uuid) from public, anon;
|
|
169
|
+
grant execute on function agent_board._claimgate_active_ownerless_claims(uuid) to service_role;
|
|
170
|
+
|
|
171
|
+
-- Rule 5: the previous wording said a board owner may flip "indefinitely", which #400 falsifies in
|
|
172
|
+
-- its unbounded form. Corrected in the migration that changes the behaviour, not left for the next
|
|
173
|
+
-- reader to disbelieve.
|
|
174
|
+
comment on function agent_board._claimgate_active_ownerless_claims(uuid) is
|
|
175
|
+
'#388: THE single definition of an agent''s active ownerless claim count — acquire_claim''s quota '
|
|
176
|
+
'predicate (kind in _claimgate_ownerless_kinds(), status active, released_at null, lease '
|
|
177
|
+
'unexpired). Called by acquire_claim (which refuses at >= _claimgate_ownerless_quota()) and by '
|
|
178
|
+
'renew_claim (which stops EXTENDING at > quota, so an over-quota agent converges to the ceiling '
|
|
179
|
+
'UNLESS its board opts out). The opt-out escape is now BOUNDED rather than unbounded (#400): a '
|
|
180
|
+
'board owner may still flip claim_policy to advisory and back, but reverting to inherit '
|
|
181
|
+
're-applies the ownerless TTL cap, so a cycle banks at most _claimgate_ownerless_ttl_cap() '
|
|
182
|
+
'instead of an arbitrary horizon. NO board-policy filter here: the advisory opt-out gates whether '
|
|
183
|
+
'a guard RUNS, not whether the resulting rows COUNT. service_role only, and deliberately narrower '
|
|
184
|
+
'than _claimgate_ownerless_quota(): this reads claims under security invoker, so an authenticated '
|
|
185
|
+
'caller would receive its own RLS-filtered count under a fleet-wide name.';
|
|
186
|
+
|
|
187
|
+
-- ---------------------------------------------------------------------------
|
|
188
|
+
-- set_board_claim_policy. The 20260922000100 body with the outgoing-policy read and the cap reset
|
|
189
|
+
-- added; the validation, the owner gate, the boards UPDATE, the P0002 check and the fleet-revocation
|
|
190
|
+
-- assertion are unchanged and in the same order.
|
|
191
|
+
--
|
|
192
|
+
-- ORDER: boards -> claims -> the agents-side revocation assertion. The canonical boards-before-
|
|
193
|
+
-- agents order (#194) is preserved and the reset sits between them; the assertion still raises in
|
|
194
|
+
-- the same transaction, so a fleet-revoked owner's reset is rolled back with their policy change.
|
|
195
|
+
-- ---------------------------------------------------------------------------
|
|
196
|
+
create or replace function agent_board.set_board_claim_policy(
|
|
197
|
+
p_board_id uuid,
|
|
198
|
+
p_claim_policy text
|
|
199
|
+
)
|
|
200
|
+
returns table(
|
|
201
|
+
board_id uuid,
|
|
202
|
+
claim_policy text,
|
|
203
|
+
updated_at timestamptz
|
|
204
|
+
)
|
|
205
|
+
language plpgsql
|
|
206
|
+
security definer
|
|
207
|
+
set search_path = agent_board, public
|
|
208
|
+
as $$
|
|
209
|
+
declare
|
|
210
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
211
|
+
v_row agent_board.boards%rowtype;
|
|
212
|
+
v_prev_policy text;
|
|
213
|
+
begin
|
|
214
|
+
if p_claim_policy is null then
|
|
215
|
+
raise exception 'p_claim_policy must not be null' using errcode = '22023';
|
|
216
|
+
end if;
|
|
217
|
+
-- Validated HERE as well as by the column CHECK. The CHECK would reject an unknown value
|
|
218
|
+
-- anyway, but with a constraint-violation error naming an internal constraint; a caller asking
|
|
219
|
+
-- for 'off' deserves to be told the vocabulary, not handed 23514.
|
|
220
|
+
if p_claim_policy not in ('inherit', 'advisory') then
|
|
221
|
+
raise exception 'claim policy must be one of inherit, advisory (received %)', p_claim_policy
|
|
222
|
+
using errcode = '22023';
|
|
223
|
+
end if;
|
|
224
|
+
|
|
225
|
+
-- Owner gate: only an active owner-role member may set this. service_role
|
|
226
|
+
-- (current_agent_id() = null) is not an owner and is rejected here.
|
|
227
|
+
if v_actor is null
|
|
228
|
+
or not exists (
|
|
229
|
+
select 1
|
|
230
|
+
from agent_board.board_memberships bm
|
|
231
|
+
where bm.board_id = p_board_id
|
|
232
|
+
and bm.agent_id = v_actor
|
|
233
|
+
and bm.role = 'owner'
|
|
234
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
235
|
+
) then
|
|
236
|
+
raise exception 'only a board owner may set the claim policy' using errcode = '42501';
|
|
237
|
+
end if;
|
|
238
|
+
|
|
239
|
+
-- #400: the OUTGOING policy, read under the row lock the UPDATE below will take anyway. Placed
|
|
240
|
+
-- AFTER the owner gate so an unauthorized caller never locks the row. `for no key update` rather
|
|
241
|
+
-- than `for update`: it does not conflict with the FOR KEY SHARE an FK reference to boards takes,
|
|
242
|
+
-- so a concurrent thread insert on this board is not queued behind a policy flip. Two concurrent
|
|
243
|
+
-- flips serialize here, so only the one that genuinely observed 'advisory' resets.
|
|
244
|
+
--
|
|
245
|
+
-- A missing board leaves this null and the UPDATE below matches nothing, so the P0002 answer and
|
|
246
|
+
-- its ordering are unchanged.
|
|
247
|
+
select b.claim_policy into v_prev_policy
|
|
248
|
+
from agent_board.boards b
|
|
249
|
+
where b.id = p_board_id
|
|
250
|
+
for no key update;
|
|
251
|
+
|
|
252
|
+
update agent_board.boards b
|
|
253
|
+
set claim_policy = p_claim_policy,
|
|
254
|
+
updated_at = now()
|
|
255
|
+
where b.id = p_board_id
|
|
256
|
+
returning * into v_row;
|
|
257
|
+
|
|
258
|
+
if v_row.id is null then
|
|
259
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
260
|
+
end if;
|
|
261
|
+
|
|
262
|
+
-- #400 THE CAP RESET ON REVERT. Reverting to 'inherit' means accepting inherit's rules, so this
|
|
263
|
+
-- board's active ownerless claims get the ownerless TTL cap re-applied. The escape it closes:
|
|
264
|
+
-- flip to advisory, renew every claim to an arbitrary horizon (the advisory carve-out skips both
|
|
265
|
+
-- the cap and #388's convergence clamp), flip back, repeat. An agent that created the board is
|
|
266
|
+
-- its owner, so no accomplice is needed. Nothing took the granted horizon back — the convergence
|
|
267
|
+
-- clamp only refuses to EXTEND — so the horizon simply survived the revert.
|
|
268
|
+
--
|
|
269
|
+
-- CLAMP ONLY, and least() is the ONLY thing doing it. There is deliberately no `lease_expires_at
|
|
270
|
+
-- > now() + cap` conjunct in the WHERE: it would be the same guard written twice and would make
|
|
271
|
+
-- least() unmutatable. So a claim already within the cap keeps its lease to the microsecond
|
|
272
|
+
-- (BCP-12) while its updated_at is bumped — the documented cost of one mechanism over two.
|
|
273
|
+
--
|
|
274
|
+
-- 'advisory' -> 'inherit' ONLY. A no-op re-set (inherit -> inherit) must clamp nothing, and
|
|
275
|
+
-- neither may inherit -> advisory or advisory -> advisory (BCP-15/16/17).
|
|
276
|
+
--
|
|
277
|
+
-- Governed by the same operator flag as every other ownerless guard: with ownerless_guards off
|
|
278
|
+
-- nothing enforces this cap at acquire or renew, so re-applying it here would enforce a bound no
|
|
279
|
+
-- other path enforces (BCP-18). There is no service_role conjunct because service_role cannot
|
|
280
|
+
-- reach this line — the owner gate above refuses it (BCP-8).
|
|
281
|
+
--
|
|
282
|
+
-- Scoped to THIS board through the claim's thread, the same path _claimgate_board_claim_policy
|
|
283
|
+
-- resolves. A claim on another board, or with no thread, is untouched (BCP-14).
|
|
284
|
+
if v_prev_policy = 'advisory'
|
|
285
|
+
and p_claim_policy = 'inherit'
|
|
286
|
+
and agent_board._claimgate_ownerless_guards_enabled() then
|
|
287
|
+
update agent_board.claims c
|
|
288
|
+
set lease_expires_at = least(c.lease_expires_at,
|
|
289
|
+
now() + agent_board._claimgate_ownerless_ttl_cap()),
|
|
290
|
+
updated_at = now()
|
|
291
|
+
where c.resource_kind = any(agent_board._claimgate_ownerless_kinds())
|
|
292
|
+
and c.status = 'active'
|
|
293
|
+
and c.released_at is null
|
|
294
|
+
and c.lease_expires_at > now()
|
|
295
|
+
and c.thread_id in (select t.id
|
|
296
|
+
from agent_board.threads t
|
|
297
|
+
where t.board_id = p_board_id);
|
|
298
|
+
end if;
|
|
299
|
+
|
|
300
|
+
-- #276, AFTER the boards UPDATE so this function's order is boards -> agents, the canonical one
|
|
301
|
+
-- (#194). Fleet revocation deliberately LEAVES agents.status = 'active' -- the ledger row is the
|
|
302
|
+
-- only authority signal -- so the owner-membership check above passes for a revoked owner and
|
|
303
|
+
-- cannot stand alone. Disabling this board's claim enforcement is an authority write in the same
|
|
304
|
+
-- class as flipping the role gate, which is why the mirror has to include this call and not just
|
|
305
|
+
-- the membership test. Same transaction: the raise rolls the UPDATE back -- and, since #400, the
|
|
306
|
+
-- cap reset with it.
|
|
307
|
+
--
|
|
308
|
+
-- This was MISSING in the first version of this function, which claimed in its own commit
|
|
309
|
+
-- message to mirror set_board_role_gate "exactly". A review seat found it by reading both
|
|
310
|
+
-- functions side by side rather than taking that claim.
|
|
311
|
+
perform agent_board._assert_actor_not_fleet_revoked(v_actor, 'set_board_claim_policy');
|
|
312
|
+
|
|
313
|
+
return query select v_row.id, v_row.claim_policy, v_row.updated_at;
|
|
314
|
+
end;
|
|
315
|
+
$$;
|
|
316
|
+
|
|
317
|
+
revoke all on function agent_board.set_board_claim_policy(uuid, text) from public, anon;
|
|
318
|
+
grant execute on function agent_board.set_board_claim_policy(uuid, text) to authenticated, service_role;
|
|
319
|
+
|
|
320
|
+
comment on function agent_board.set_board_claim_policy(uuid, text) is
|
|
321
|
+
'Board-owner-only. Sets boards.claim_policy to ''inherit'' (follow the fleet-wide claimgate flags) '
|
|
322
|
+
'or ''advisory'' (this board coordinates through another locking mechanism, so our ownerless '
|
|
323
|
+
'file/tool/custom TTL cap and quota are recorded but not enforced). Never weakens the '
|
|
324
|
+
'fleet-authority gate on machine/repository/repo_file, the subject gate, or claim uniqueness. '
|
|
325
|
+
'Owner gate and service_role refusal mirror set_board_role_gate. #400: an advisory -> inherit '
|
|
326
|
+
'transition RE-APPLIES the ownerless TTL cap to this board''s active ownerless claims — '
|
|
327
|
+
'least(lease_expires_at, now() + _claimgate_ownerless_ttl_cap()), clamp only, never an extension '
|
|
328
|
+
'— so a long lease banked under the opt-out cannot be kept by handing the opt-out back. Governed '
|
|
329
|
+
'by ownerless_guards; no other transition resets anything.';
|
|
330
|
+
|
|
331
|
+
-- ---------------------------------------------------------------------------
|
|
332
|
+
-- Rule 5: three recorded facts that were true when written and are not now. #399 stated the escape
|
|
333
|
+
-- as UNBOUNDED ("indefinitely", "forever"); it is now bounded by the TTL cap per cycle. Corrected
|
|
334
|
+
-- in the migration that falsifies them. The 20260928000100 file itself cannot be edited — its
|
|
335
|
+
-- bytes are hashed in the manifest and in fixtures/already-applied.json — so the comments it set
|
|
336
|
+
-- are restated here, which is the pattern that migration used for the same reason.
|
|
337
|
+
-- ---------------------------------------------------------------------------
|
|
338
|
+
comment on column agent_board.boards.claim_policy is
|
|
339
|
+
'Board-settable claim enforcement. ''inherit'' follows the fleet-wide claimgate flags. ''advisory'' '
|
|
340
|
+
'means this board coordinates through some OTHER locking mechanism, so our ownerless '
|
|
341
|
+
'(file/tool/custom) TTL cap and per-agent quota are recorded but not enforced for its claims. It '
|
|
342
|
+
'does NOT weaken the fleet-authority gate on machine/repository/repo_file, the subject gate, or '
|
|
343
|
+
'claim uniqueness — a board may decline our lock, never grant itself another owner''s resource. '
|
|
344
|
+
'Set via agent_board.set_board_claim_policy (board owner only), which since #400 re-applies the '
|
|
345
|
+
'ownerless TTL cap to this board''s active ownerless claims when the policy reverts from '
|
|
346
|
+
'''advisory'' to ''inherit'': the opt-out may be given back, but not after banking a lease longer '
|
|
347
|
+
'than inherit allows.';
|
|
348
|
+
|
|
349
|
+
comment on function agent_board._claimgate_ownerless_quota() is
|
|
350
|
+
'CLAIMGATE/SHADOW (#133): max concurrent ACTIVE ownerless (file/tool/custom) claims per agent. '
|
|
351
|
+
'Single source for the migration logic and its tests. GENEROUS placeholder (50) pending prod '
|
|
352
|
+
'census. Enforced on BOTH write paths: acquire_claim REFUSES at count >= quota (54000), and '
|
|
353
|
+
'renew_claim stops EXTENDING at count > quota (#388) so an agent already over the ceiling '
|
|
354
|
+
'converges back to it instead of renewing its overage forever — SCOPED to agents whose boards do '
|
|
355
|
+
'not opt out. A board owner may flip claim_policy to advisory, let the agent renew, and flip '
|
|
356
|
+
'back; since #400 the revert re-applies the ownerless TTL cap, so each such cycle banks at most '
|
|
357
|
+
'_claimgate_ownerless_ttl_cap() rather than an arbitrary horizon. The escape is bounded, not '
|
|
358
|
+
'closed (ORQ-11/12). The two predicates differ deliberately — >= is what makes the quota the '
|
|
359
|
+
'largest reachable population, > is what makes the ceiling the convergence fixed point — and both '
|
|
360
|
+
'count through _claimgate_active_ownerless_claims(). Governed by '
|
|
361
|
+
'_claimgate_ownerless_guards_enabled() on both paths, service_role exempt, and skipped for a board '
|
|
362
|
+
'whose claim_policy is advisory.';
|
|
363
|
+
|
|
364
|
+
comment on table agent_board.claimgate_enforcement_flags is
|
|
365
|
+
'CLAIMGATE per-scope enforcement flags. Absent row => disabled (SHADOW default: blocks nothing). '
|
|
366
|
+
'scope_key ''ownerless_guards'' gates EVERY ownerless (file/tool/custom) guard on ALL THREE write '
|
|
367
|
+
'paths: the TTL cap + per-agent quota in acquire_claim, in renew_claim both the TTL clamp (#373) '
|
|
368
|
+
'and the quota-convergence clamp (#388), and in set_board_claim_policy the advisory -> inherit cap '
|
|
369
|
+
'reset (#400). The advisory opt-out is owner-toggleable and re-toggleable, so it remains an escape '
|
|
370
|
+
'from convergence — bounded since #400 by the TTL cap per cycle rather than unbounded. '
|
|
371
|
+
'Operator-managed (service_role); enabled only after the prod lease-length census.';
|