@consiliency/agent-board-schema 1.41.0 → 1.43.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.
@@ -556,6 +556,14 @@
556
556
  {
557
557
  "id": "20260921000100_agent_board_task_event_origin_posture",
558
558
  "sha256": "c515e9cd08b73c118de64125da94735979add5d1128d3ff070ff904af77fef81"
559
+ },
560
+ {
561
+ "id": "20260922000100_agent_board_board_claim_policy",
562
+ "sha256": "1363a194f50342feb613a1aa1e01321a2a81783d88dfebe2c8f2002170cdc25c"
563
+ },
564
+ {
565
+ "id": "20260923000100_agent_board_heartbeat_preserve",
566
+ "sha256": "22713b970564bc59433026f70934758982b4cf7832be9c06197faa2d4ecc0040"
559
567
  }
560
568
  ]
561
569
  }
@@ -0,0 +1,376 @@
1
+ -- Board-settable claim policy: let a board opt OUT of our ownerless claim enforcement.
2
+ --
3
+ -- THE ASK. A board's work may be coordinated by somebody else's locking — a repository lock, a
4
+ -- scheduler lease, an external mutex — and our claim guards must not interfere with it. If their
5
+ -- lock already granted the work, our TTL cap or quota rejecting the acquire is us breaking a
6
+ -- system that was working. So enforcement becomes opt-out, and the board creator owns the switch.
7
+ --
8
+ -- WHAT THIS DOES NOT TOUCH, WHICH IS THE WHOLE SECURITY POINT. `advisory` disables the OWNERLESS
9
+ -- guards only — the TTL cap and per-agent quota on 'file'/'tool'/'custom'. It does NOT and MUST
10
+ -- NOT weaken:
11
+ --
12
+ -- * the fleet-authority gate on 'machine' / 'repository' / 'repo_file' (_can_claim_resource),
13
+ -- which answers "may this agent claim this resource AT ALL" and is an authorization boundary,
14
+ -- not a locking convenience;
15
+ -- * the claim SUBJECT gate (#291), the task/thread access checks, or the uniqueness index that
16
+ -- makes a claim exclusive in the first place.
17
+ --
18
+ -- The distinction is: a board may decline OUR LOCK. A board may not grant itself SOMEONE ELSE'S
19
+ -- RESOURCE. Structurally this holds because the opt-out is inside the `v_ownerless` branch, which
20
+ -- only the three ownerless kinds enter — and the fleet kinds are gated far above it. BCP-6 asserts
21
+ -- it anyway rather than trusting the structure, because "it is in the right branch" is exactly the
22
+ -- kind of reasoning that stops being true when someone moves a line.
23
+ --
24
+ -- WHY THE THREAD IS THE RESOLUTION PATH. `claims` has no board_id — a claim is scoped to an agent
25
+ -- plus an optional task/thread. So the board is reached as claim -> thread -> threads.board_id.
26
+ -- Three cases, all deliberate:
27
+ -- * thread has a board, board says 'advisory' -> guards skipped
28
+ -- * thread has a board, board says 'inherit' -> fleet-wide flags govern (the default)
29
+ -- * no thread, or thread has no board -> NO BOARD TO ASK, fleet-wide flags govern
30
+ -- The last is the important one: a bare ownerless claim cannot opt itself out by omitting a
31
+ -- thread. Opting out requires a board that a human owner deliberately configured. BCP-5 pins it.
32
+ --
33
+ -- DEFAULT IS 'inherit', so every existing board behaves exactly as it does today. This ships no
34
+ -- behaviour change until a board owner asks for one.
35
+
36
+ -- 1. The column.
37
+ alter table agent_board.boards
38
+ add column if not exists claim_policy text not null default 'inherit';
39
+
40
+ alter table agent_board.boards drop constraint if exists boards_claim_policy_check;
41
+ alter table agent_board.boards add constraint boards_claim_policy_check
42
+ check (claim_policy in (
43
+ 'inherit', -- follow the fleet-wide claimgate flags (default; today's behaviour)
44
+ 'advisory' -- record claims, never enforce them: this board's locking lives elsewhere
45
+ ));
46
+
47
+ comment on column agent_board.boards.claim_policy is
48
+ 'Board-settable claim enforcement. ''inherit'' follows the fleet-wide claimgate flags. ''advisory'' means this board coordinates through some OTHER locking mechanism, so our ownerless (file/tool/custom) TTL cap and per-agent quota are recorded but not enforced for its claims. It does NOT weaken the fleet-authority gate on machine/repository/repo_file, the subject gate, or claim uniqueness — a board may decline our lock, never grant itself another owner''s resource. Set via agent_board.set_board_claim_policy (board owner only).';
49
+
50
+ -- 2. Resolve a claim's board policy from its thread. STABLE and SECURITY DEFINER so the read is
51
+ -- not subject to the caller's RLS — the policy is a property of the board, not of who is asking,
52
+ -- and a caller who cannot see the board must still be governed by it.
53
+ create or replace function agent_board._claimgate_board_claim_policy(p_thread_id uuid)
54
+ returns text
55
+ language sql
56
+ stable
57
+ security definer
58
+ set search_path = agent_board, public
59
+ as $$
60
+ select coalesce(
61
+ (select b.claim_policy
62
+ from agent_board.threads t
63
+ join agent_board.boards b on b.id = t.board_id
64
+ where t.id = p_thread_id),
65
+ 'inherit')
66
+ $$;
67
+
68
+ revoke all on function agent_board._claimgate_board_claim_policy(uuid) from public, anon;
69
+ grant execute on function agent_board._claimgate_board_claim_policy(uuid) to authenticated, service_role;
70
+
71
+ comment on function agent_board._claimgate_board_claim_policy(uuid) is
72
+ 'Resolves a claim''s governing board policy through its thread. Returns ''inherit'' when the thread is null, unknown, or not board-linked — so a claim with no board cannot opt itself out by omission; opting out requires a board an owner deliberately configured.';
73
+ -- 3. acquire_claim, with the board opt-out folded into the ownerless condition.
74
+ -- CREATE OR REPLACE with an unchanged signature, so the ACL and COMMENT survive.
75
+ 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)
76
+ 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)
77
+ LANGUAGE plpgsql
78
+ SECURITY DEFINER
79
+ SET search_path TO 'agent_board', 'public'
80
+ AS $function$
81
+ declare
82
+ v_thread_id uuid;
83
+ v_claim_id uuid;
84
+ v_actor_agent_id uuid;
85
+ v_shadow_authorized boolean;
86
+ v_ownerless boolean;
87
+ v_active_ownerless int;
88
+ begin
89
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
90
+
91
+ if nullif(trim(p_resource_key), '') is null then
92
+ raise exception 'resource key is required' using errcode = '22023';
93
+ end if;
94
+
95
+ -- CLAIMKEY (#133): normalize the resource key to its canonical, fleet-fingerprint-carrying
96
+ -- form BEFORE any lookup/insert, so the row stored (and the uniqueness index contended)
97
+ -- carry canonical identity. NORMALIZE-ONLY — this never rejects (rejection is ENFORCE).
98
+ p_resource_key := agent_board._canonical_claim_key(p_resource_kind, p_resource_key);
99
+
100
+ if p_task_id is not null then
101
+ select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
102
+ if v_thread_id is null then
103
+ raise exception 'task not found' using errcode = 'P0002';
104
+ end if;
105
+ if not agent_board._current_agent_can_access_task(p_task_id) then
106
+ raise exception 'task not accessible to current agent' using errcode = '42501';
107
+ end if;
108
+ else
109
+ v_thread_id := p_thread_id;
110
+ end if;
111
+
112
+ if v_thread_id is not null and not agent_board._current_agent_can_access_thread(v_thread_id) then
113
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
114
+ end if;
115
+
116
+ -- message-board#291: the SUBJECT gate. The checks above answer "may this SESSION touch the
117
+ -- task/thread" and are short-circuited true for service_role; this one answers "does the
118
+ -- agent the claim is ABOUT already have access". Without it a service_role caller could name
119
+ -- any agent, and the resulting claims row -- itself an access disjunct, and a PERMANENT one
120
+ -- -- would MINT thread access (and, through the thread disjunct of
121
+ -- _current_agent_can_access_task, task access) rather than record a lock on something the
122
+ -- subject could already see.
123
+ --
124
+ -- UNCONDITIONAL IN *WHO*. DO NOT "optimize" this into `if _is_service_role() and not ...` or
125
+ -- `if v_actor_agent_id is distinct from agent_board.current_agent_id()`. For an authenticated
126
+ -- caller v_actor_agent_id IS current_agent_id() and the session check above already
127
+ -- established this same expression, so the redundancy is PROVABLE rather than suspected; it
128
+ -- is the price of an invariant this function states for itself instead of borrowing from
129
+ -- _resolved_actor_agent_id continuing to raise on a subject mismatch.
130
+ --
131
+ -- CONDITIONAL ON *THREAD PRESENCE*, which is not a loophole. A claim bound to neither a task
132
+ -- nor a thread stores thread_id = null, is a disjunct of nothing and mints no access.
133
+ -- _agent_can_access_thread answers false for a null thread, so gating unconditionally would
134
+ -- refuse every machine / repository / repo_file / ownerless claim in the fleet. This covers
135
+ -- exactly the claims that are keys.
136
+ --
137
+ -- PLACED AFTER THE TASK LOOKUP so a nonexistent task still answers P0002, not 42501 -- that
138
+ -- ordering IS test-pinned. Placed BEFORE the sweep only to match the predicate-before-side-
139
+ -- effects convention CLAIMGATE/ENFORCE states below; it is NOT what makes a denial free of
140
+ -- side effects. `raise exception` unwinds the whole call, so a refused caller changes no state
141
+ -- wherever this sits -- measured, by moving the gate below the sweep and observing the test
142
+ -- file stay entirely green.
143
+ if v_thread_id is not null
144
+ and not agent_board._agent_can_access_thread(v_thread_id, v_actor_agent_id) then
145
+ raise exception 'claim subject has no access to thread' using errcode = '42501';
146
+ end if;
147
+
148
+ -- OBSGATE-4 (#127): a claim on a TASK resource is authorized on the LOCKED task = the task
149
+ -- named by RESOURCE_KEY (resource_kind='task'). The active-resource uniqueness is on
150
+ -- (resource_kind, resource_key), so that is the task a squat contends for — gating resource_key
151
+ -- (NOT p_task_id, which only associates the claim/event) prevents locking task B's key while
152
+ -- authorizing on an unrelated task A passed as p_task_id. A non-uuid 'task' key cannot collide
153
+ -- with a uuid-keyed legitimate claim.
154
+ --
155
+ -- The composition MIRRORS append_task_event's: ACCESS then MUTATE. mutate alone is
156
+ -- insufficient — _current_agent_can_mutate_task admits any non-observer board member via
157
+ -- _current_agent_can_post_to_thread (board send-role), which is NOT thread access; on a
158
+ -- RESTRICTED thread a board member lacking thread access would pass mutate but must still be
159
+ -- denied (they cannot access the task). Requiring access too closes that gap and matches the
160
+ -- authorization a task-BOUND acquire already gets through append_task_event.
161
+ if p_resource_kind = 'task'
162
+ 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
163
+ if not agent_board._current_agent_can_access_task(p_resource_key::uuid) then
164
+ raise exception 'task not accessible to current agent' using errcode = '42501';
165
+ end if;
166
+ if not agent_board._current_agent_can_mutate_task(p_resource_key::uuid) then
167
+ raise exception 'claiming a task resource requires a non-observer board role, or task assignment/creation'
168
+ using errcode = '42501';
169
+ end if;
170
+ end if;
171
+
172
+ -- CLAIMGATE/SHADOW (#133): compute the fleet-graph authority verdict BEFORE the sweep (so
173
+ -- ENFORCE can flip this same point fail-closed with no observable side effect leaking to an
174
+ -- unauthorized caller). The verdict is stamped below regardless of enforcement.
175
+ v_shadow_authorized := agent_board._can_claim_resource(p_resource_kind, p_resource_key);
176
+
177
+ -- CLAIMGATE/ENFORCE (#133): per-kind GATED DENIAL, BEFORE the sweep (predicate-before-side-
178
+ -- effects). When this fleet-backed kind's flag is ON and the actor is UNAUTHORIZED, DENY —
179
+ -- no state has changed yet (sweep/insert are below). When OFF, fall through to the shadow-
180
+ -- stamp only (SHADOW behavior; blocks nothing). Non-fleet kinds never kind-enforce (resolver
181
+ -- returns FALSE). service_role is authorized by the predicate (wrapper short-circuit), so the
182
+ -- operator carve-out holds. Fail-closed-on-unknown is automatic: an unresolvable fleet resource
183
+ -- makes v_shadow_authorized FALSE, so an enforced acquire of it denies.
184
+ if p_resource_kind in ('machine', 'repository', 'repo_file')
185
+ and agent_board._claimgate_kind_enforced(p_resource_kind)
186
+ and not v_shadow_authorized then
187
+ raise exception 'not authorized to claim this % resource (fleet authority required)', p_resource_kind
188
+ using errcode = '42501';
189
+ end if;
190
+
191
+ -- CLAIMGATE/ENFORCE (#133): max-lease horizon. For an ENFORCED fleet-backed kind, CLAMP the
192
+ -- lease to now()+horizon (default 30d, operator-configurable) — a bound on lease term, NOT an
193
+ -- authority block. Clamp (not reject) so an over-long lease reconciles here / at its next renew.
194
+ -- service_role exempt (operator carve-out). Only reached by an AUTHORIZED acquire (unauthorized
195
+ -- was denied above), or when the flag is OFF (then the guard below is skipped entirely).
196
+ if p_resource_kind in ('machine', 'repository', 'repo_file')
197
+ and not agent_board._is_service_role()
198
+ and agent_board._claimgate_kind_enforced(p_resource_kind)
199
+ and p_lease_expires_at > now() + agent_board._claimgate_max_lease_horizon() then
200
+ p_lease_expires_at := now() + agent_board._claimgate_max_lease_horizon();
201
+ end if;
202
+
203
+ -- Ownerless tail (file/tool/custom): the ONE guard SHADOW carries — GATED OFF BY DEFAULT.
204
+ -- _claimgate_ownerless_guards_enabled() is false unless an operator flips it post-census, so in
205
+ -- SHADOW this block is skipped entirely and NOTHING is blocked (only the stamp above lands).
206
+ -- service_role is also exempt (operator carve-out, consistent with the predicate short-circuit).
207
+ v_ownerless := p_resource_kind in ('file', 'tool', 'custom');
208
+ if v_ownerless
209
+ and not agent_board._is_service_role()
210
+ and agent_board._claimgate_ownerless_guards_enabled()
211
+ -- BOARD OPT-OUT: a board whose work is coordinated by SOMEONE ELSE'S locking (a repo lock, a
212
+ -- scheduler lease, an external mutex) can set claim_policy='advisory'. Our claims are then
213
+ -- RECORDED for visibility and never ENFORCED, so we cannot reject an acquire that their own
214
+ -- lock already permitted. Resolved from the claim's thread; a claim with no thread, or a
215
+ -- thread with no board, has no board to ask and falls back to the fleet-wide flags.
216
+ and agent_board._claimgate_board_claim_policy(v_thread_id) <> 'advisory' then
217
+ -- TTL CAP: an ownerless lease may not exceed the max horizon. Rejects an over-long acquire;
218
+ -- this is a lease-length bound, NOT an authority block.
219
+ if p_lease_expires_at > now() + agent_board._claimgate_ownerless_ttl_cap() then
220
+ raise exception 'ownerless claim lease horizon exceeds cap (%)', agent_board._claimgate_ownerless_ttl_cap()
221
+ using errcode = '22023';
222
+ end if;
223
+
224
+ -- QUOTA (transactionally serialized): an actor-scoped advisory XACT lock serializes this
225
+ -- actor's concurrent acquisitions (on DIFFERENT keys, which the row-level uniqueness index
226
+ -- does NOT), so a count-before-insert cannot race two claims past the cap. The lock releases
227
+ -- at commit; it does not block other actors (the key mixes in the actor id).
228
+ perform pg_advisory_xact_lock(hashtext('agent_board.claimgate_ownerless_quota'), hashtext(v_actor_agent_id::text));
229
+ select count(*) into v_active_ownerless
230
+ from agent_board.claims c
231
+ where c.agent_id = v_actor_agent_id
232
+ and c.resource_kind in ('file', 'tool', 'custom')
233
+ and c.status = 'active'
234
+ and c.released_at is null
235
+ and c.lease_expires_at > now();
236
+ if v_active_ownerless >= agent_board._claimgate_ownerless_quota() then
237
+ raise exception 'ownerless active-claim quota reached (% per agent)', agent_board._claimgate_ownerless_quota()
238
+ using errcode = '54000';
239
+ end if;
240
+ end if;
241
+
242
+ -- CLAIMKEY (#133) MUST-FIX 3: the sweep AND the conflict check are ALIAS-AWARE. p_resource_key
243
+ -- is already canonical (above); comparing against the CANONICAL form of each existing row's key
244
+ -- matches both a canonical row AND an un-healed LEGACY row for the same physical resource. Without
245
+ -- this, acquire('machine','machine:fpDH') would normalize to 'fpDH', see no exact-'fpDH' conflict
246
+ -- against a still-active legacy 'machine:fpDH' row, and INSERT a second live hold — breaking mutual
247
+ -- exclusion during the legacy window. Closing it here in code does NOT depend on the operator-owned
248
+ -- live census.
249
+ update agent_board.claims c
250
+ set status = 'expired',
251
+ updated_at = now()
252
+ where c.resource_kind = p_resource_kind
253
+ and agent_board._canonical_claim_key(c.resource_kind, c.resource_key) = p_resource_key
254
+ and c.status = 'active'
255
+ and c.released_at is null
256
+ and c.lease_expires_at <= now();
257
+
258
+ if exists (
259
+ select 1 from agent_board.claims
260
+ where claims.resource_kind = p_resource_kind
261
+ and agent_board._canonical_claim_key(claims.resource_kind, claims.resource_key) = p_resource_key
262
+ and claims.status = 'active'
263
+ and claims.released_at is null
264
+ and claims.lease_expires_at > now()
265
+ ) then
266
+ raise exception 'conflicting active claim' using errcode = '23505';
267
+ end if;
268
+
269
+ insert into agent_board.claims(task_id, thread_id, agent_id, resource_kind, resource_key, lease_expires_at, metadata)
270
+ values (
271
+ p_task_id, v_thread_id, v_actor_agent_id, p_resource_kind, p_resource_key, p_lease_expires_at,
272
+ -- SHADOW stamp: record the authority verdict alongside any caller metadata. Merged last so it
273
+ -- is authoritative over a caller-supplied claimgate_shadow key.
274
+ coalesce(p_metadata, '{}'::jsonb)
275
+ || jsonb_build_object(
276
+ 'claimgate_shadow',
277
+ jsonb_build_object('kind', p_resource_kind, 'authorized', v_shadow_authorized)
278
+ )
279
+ )
280
+ returning id into v_claim_id;
281
+
282
+ if p_task_id is not null then
283
+ 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));
284
+ end if;
285
+
286
+ return query
287
+ 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
288
+ from agent_board.claims c
289
+ where c.id = v_claim_id;
290
+ end;
291
+ $function$;
292
+ -- 4. The setter. Owner-gated, mirroring set_board_role_gate rather than inventing a second
293
+ -- authorization shape: the same membership test, the same service_role refusal, and the same
294
+ -- post-UPDATE fleet-revocation assertion.
295
+ --
296
+ -- THE REFUSAL IS A REAL BOUNDARY, not a formality. An earlier draft of this comment said an
297
+ -- operator "can write the column directly with the grant it already holds" — inherited wording,
298
+ -- and it overstates the operator's reach in THIS repo: service_role holds SELECT on boards and
299
+ -- an RLS FOR ALL policy, but NO GRANT UPDATE, so that policy does not write. Changing the
300
+ -- column outside this RPC needs the table owner. Flagged by a review seat that checked the
301
+ -- grants instead of reading the sentence.
302
+ --
303
+ -- The point of the RPC is that a BOARD CREATOR can set this without an operator at all.
304
+ create or replace function agent_board.set_board_claim_policy(
305
+ p_board_id uuid,
306
+ p_claim_policy text
307
+ )
308
+ returns table(
309
+ board_id uuid,
310
+ claim_policy text,
311
+ updated_at timestamptz
312
+ )
313
+ language plpgsql
314
+ security definer
315
+ set search_path = agent_board, public
316
+ as $$
317
+ declare
318
+ v_actor uuid := agent_board.current_agent_id();
319
+ v_row agent_board.boards%rowtype;
320
+ begin
321
+ if p_claim_policy is null then
322
+ raise exception 'p_claim_policy must not be null' using errcode = '22023';
323
+ end if;
324
+ -- Validated HERE as well as by the column CHECK. The CHECK would reject an unknown value
325
+ -- anyway, but with a constraint-violation error naming an internal constraint; a caller asking
326
+ -- for 'off' deserves to be told the vocabulary, not handed 23514.
327
+ if p_claim_policy not in ('inherit', 'advisory') then
328
+ raise exception 'claim policy must be one of inherit, advisory (received %)', p_claim_policy
329
+ using errcode = '22023';
330
+ end if;
331
+
332
+ -- Owner gate: only an active owner-role member may set this. service_role
333
+ -- (current_agent_id() = null) is not an owner and is rejected here.
334
+ if v_actor is null
335
+ or not exists (
336
+ select 1
337
+ from agent_board.board_memberships bm
338
+ where bm.board_id = p_board_id
339
+ and bm.agent_id = v_actor
340
+ and bm.role = 'owner'
341
+ and bm.membership_state in ('joined', 'subscribed')
342
+ ) then
343
+ raise exception 'only a board owner may set the claim policy' using errcode = '42501';
344
+ end if;
345
+
346
+ update agent_board.boards b
347
+ set claim_policy = p_claim_policy,
348
+ updated_at = now()
349
+ where b.id = p_board_id
350
+ returning * into v_row;
351
+
352
+ if v_row.id is null then
353
+ raise exception 'board not found' using errcode = 'P0002';
354
+ end if;
355
+
356
+ -- #276, AFTER the boards UPDATE so this function's order is boards -> agents, the canonical one
357
+ -- (#194). Fleet revocation deliberately LEAVES agents.status = 'active' -- the ledger row is the
358
+ -- only authority signal -- so the owner-membership check above passes for a revoked owner and
359
+ -- cannot stand alone. Disabling this board's claim enforcement is an authority write in the same
360
+ -- class as flipping the role gate, which is why the mirror has to include this call and not just
361
+ -- the membership test. Same transaction: the raise rolls the UPDATE back.
362
+ --
363
+ -- This was MISSING in the first version of this function, which claimed in its own commit
364
+ -- message to mirror set_board_role_gate "exactly". A review seat found it by reading both
365
+ -- functions side by side rather than taking that claim.
366
+ perform agent_board._assert_actor_not_fleet_revoked(v_actor, 'set_board_claim_policy');
367
+
368
+ return query select v_row.id, v_row.claim_policy, v_row.updated_at;
369
+ end;
370
+ $$;
371
+
372
+ revoke all on function agent_board.set_board_claim_policy(uuid, text) from public, anon;
373
+ grant execute on function agent_board.set_board_claim_policy(uuid, text) to authenticated, service_role;
374
+
375
+ comment on function agent_board.set_board_claim_policy(uuid, text) is
376
+ 'Board-owner-only. Sets boards.claim_policy to ''inherit'' (follow the fleet-wide claimgate flags) or ''advisory'' (this board coordinates through another locking mechanism, so our ownerless file/tool/custom TTL cap and quota are recorded but not enforced). Never weakens the fleet-authority gate on machine/repository/repo_file, the subject gate, or claim uniqueness. Owner gate and service_role refusal mirror set_board_role_gate.';
@@ -0,0 +1,121 @@
1
+ -- #366/F4 — heartbeat must be able to say "I am alive" WITHOUT also saying everything else.
2
+ --
3
+ -- THE DEFECT. The upsert overwrote every field it was not told, and the signature made that
4
+ -- impossible to avoid from a caller:
5
+ --
6
+ -- p_availability text default 'ready'
7
+ --
8
+ -- An OMITTED argument arrives as 'ready', so the function cannot distinguish "not supplied" from
9
+ -- "supplied as ready". A caller that only wants to refresh the lease therefore also resets
10
+ -- availability to 'ready' and clears thread_id, activity_summary and metadata — every time.
11
+ --
12
+ -- That is fatal to an implicit, activity-coupled heartbeat (#329): reporting liveness on every
13
+ -- board tool call would silently wipe an agent's own declared state on every call. Three views
14
+ -- read this table — v_agent_dashboard, v_ops_presence_heartbeats, v_ops_table_growth — so the
15
+ -- damage would be visible as an agent that is permanently 'ready' and never in a thread.
16
+ --
17
+ -- WHY THIS COULD NOT BE FIXED IN THE CLIENT, which is what #329 assumed. That issue said "the fix
18
+ -- is client-side and there is no schema change here at all". It was right about the WRITER — the
19
+ -- reason presence was empty is that nothing called heartbeat — and wrong about the WRITE. No
20
+ -- argument a client can send distinguishes absent from 'ready', because the default is applied
21
+ -- before the function sees it. The distinction has to exist in the signature.
22
+ --
23
+ -- THE CHANGE. Every optional field defaults to NULL, and NULL now means LEAVE UNCHANGED:
24
+ --
25
+ -- p_availability 'ready' -> null
26
+ -- p_metadata '{}' -> null
27
+ -- (p_thread_id and p_activity_summary already defaulted null; their CLEARING behaviour changes)
28
+ --
29
+ -- On INSERT a null still lands the documented starting value ('ready', '{}'). On CONFLICT a null
30
+ -- preserves what is already there.
31
+ --
32
+ -- THE TRADE-OFF, stated rather than buried: there is now NO WAY to clear thread_id or
33
+ -- activity_summary through heartbeat, because null is how you say "don't touch it". Clearing was
34
+ -- never a meaningful operation here — an agent moves to another thread or its row expires — and
35
+ -- the alternative is the defect above. `presence` holds ZERO rows on the canonical project
36
+ -- (measured 2026-08-29, unchanged in four weeks), so no caller relies on the old clearing shape.
37
+ --
38
+ --
39
+ -- #366/F1 IS FIXED HERE TOO, and by a better mechanism than the one first proposed. The client
40
+ -- could not call heartbeat at all: `p_agent_id uuid` had NO DEFAULT, so a call omitting it did not
41
+ -- match the function and was rejected before the body ran. The obvious fix was to add heartbeat to
42
+ -- the client's USER_SCOPED_ACTOR_DEFAULTS map so the client fills the id in.
43
+ --
44
+ -- That would have worked and it is the wrong layer. heartbeat ALREADY resolves its actor from the
45
+ -- session:
46
+ --
47
+ -- v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
48
+ --
49
+ -- and that resolver, for a non-service_role caller, IGNORES a supplied id beyond checking it
50
+ -- matches `current_agent_id()`. Having the client send an id the server then re-derives is the
51
+ -- shape this repo has a rule against: provenance is derived from the session, never from a caller
52
+ -- argument. The id was required by the SIGNATURE, not by the semantics.
53
+ --
54
+ -- So `p_agent_id` gains `default null`. An authenticated caller omits it and the session answers;
55
+ -- a service_role caller must still supply it, because `_resolved_actor_agent_id` raises 22023 for
56
+ -- a null id under service_role — that behaviour is untouched and HBP-12 pins it.
57
+ -- SIGNATURE IS UNCHANGED. Parameter DEFAULTS are not part of a function's identity in PostgreSQL,
58
+ -- so this is a true CREATE OR REPLACE: the ACL and the COMMENT survive, and nothing is dropped.
59
+ -- That matters here specifically — heartbeat is granted to authenticated, and a DROP would hand
60
+ -- EXECUTE back to PUBLIC by default.
61
+ create or replace function agent_board.heartbeat(
62
+ p_agent_id uuid default null,
63
+ p_thread_id uuid default null,
64
+ p_availability text default null,
65
+ p_activity_summary text default null,
66
+ p_expires_in interval default interval '5 minutes',
67
+ p_metadata jsonb default null
68
+ )
69
+ returns table(agent_id uuid, availability text, thread_id uuid, last_heartbeat_at timestamptz, expires_at timestamptz, updated_at timestamptz)
70
+ language plpgsql
71
+ security definer
72
+ set search_path = agent_board, public
73
+ as $$
74
+ declare
75
+ v_actor_agent_id uuid;
76
+ begin
77
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
78
+
79
+ -- Unchanged: a supplied thread must exist and be reachable by the caller. A NULL thread now
80
+ -- means "leave my thread as it is", so it is not a claim about any thread and needs no check.
81
+ if p_thread_id is not null then
82
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
83
+ raise exception 'thread not found' using errcode = 'P0002';
84
+ end if;
85
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
86
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
87
+ end if;
88
+ end if;
89
+
90
+ return query
91
+ insert into agent_board.presence(agent_id, thread_id, availability, activity_summary, last_heartbeat_at, expires_at, metadata, updated_at)
92
+ values (
93
+ v_actor_agent_id,
94
+ p_thread_id,
95
+ -- INSERT: a null still lands the documented starting value, so a first heartbeat behaves
96
+ -- exactly as it did before this migration.
97
+ coalesce(p_availability, 'ready'),
98
+ p_activity_summary,
99
+ now(),
100
+ now() + coalesce(p_expires_in, interval '5 minutes'),
101
+ coalesce(p_metadata, '{}'::jsonb),
102
+ now()
103
+ )
104
+ on conflict on constraint presence_pkey do update
105
+ -- UPDATE: null preserves. `presence.<col>` is the EXISTING row; `excluded.<col>` is what this
106
+ -- call proposed. Coalescing onto the existing value is the whole fix -- it is what lets a
107
+ -- lease refresh say nothing about availability.
108
+ set thread_id = coalesce(excluded.thread_id, presence.thread_id),
109
+ availability = coalesce(p_availability, presence.availability),
110
+ activity_summary = coalesce(excluded.activity_summary, presence.activity_summary),
111
+ metadata = coalesce(p_metadata, presence.metadata),
112
+ -- These three are the POINT of a heartbeat and always advance.
113
+ last_heartbeat_at = excluded.last_heartbeat_at,
114
+ expires_at = excluded.expires_at,
115
+ updated_at = excluded.updated_at
116
+ returning presence.agent_id, presence.availability, presence.thread_id, presence.last_heartbeat_at, presence.expires_at, presence.updated_at;
117
+ end;
118
+ $$;
119
+
120
+ comment on function agent_board.heartbeat(uuid, uuid, text, text, interval, jsonb) is
121
+ '#366/F1+F4: asserts liveness for the acting agent. Every optional field defaults to NULL and NULL means LEAVE UNCHANGED on an existing row -- so a caller refreshing only the lease does not reset availability to ''ready'' or clear thread_id/activity_summary/metadata. On a FIRST heartbeat a null still lands the documented starting value (''ready'', ''{}''). Consequence, deliberate: thread_id and activity_summary cannot be CLEARED through this RPC, because null is how a caller declines to change them. Before this migration p_availability defaulted to ''ready'', which made "not supplied" indistinguishable from "supplied as ready" and made an implicit activity-coupled heartbeat destructive. p_agent_id also gains a null default (#366/F1): it was required by the SIGNATURE though the body already resolves the actor from the session via _resolved_actor_agent_id, so an authenticated caller may now omit it entirely. service_role must still supply it -- that resolver raises 22023 for a null id under service_role, unchanged.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-schema",
3
- "version": "1.41.0",
3
+ "version": "1.43.0",
4
4
  "description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
5
5
  "repository": "https://github.com/Consiliency/message-board.git",
6
6
  "type": "module",
@@ -2,7 +2,7 @@
2
2
  "schema": "agent_board_schema_manifest.v1",
3
3
  "interface": "IF-0-MBPLANE-BUNDLE-1",
4
4
  "package": "@consiliency/agent-board-schema",
5
- "packageVersion": "1.41.0",
5
+ "packageVersion": "1.43.0",
6
6
  "schemaVersion": 8,
7
7
  "minClientVersion": "1.1.0",
8
8
  "source": {
@@ -46,8 +46,8 @@
46
46
  "usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
47
47
  }
48
48
  ],
49
- "migrationCount": 139,
50
- "aggregateSha256": "0c0daf769baedbae5243a7345d44df09613494c5ea2495b342222a7248dde6d0",
49
+ "migrationCount": 141,
50
+ "aggregateSha256": "d97c281cbc43a2c031dc769731473c6c9d1aa4facb0f72d07095cd1b9301a163",
51
51
  "migrations": [
52
52
  {
53
53
  "order": 0,
@@ -1299,6 +1299,24 @@
1299
1299
  "sourceRef": "message-board",
1300
1300
  "bytes": 48237,
1301
1301
  "sha256": "c515e9cd08b73c118de64125da94735979add5d1128d3ff070ff904af77fef81"
1302
+ },
1303
+ {
1304
+ "order": 139,
1305
+ "id": "20260922000100_agent_board_board_claim_policy",
1306
+ "filename": "20260922000100_agent_board_board_claim_policy.sql",
1307
+ "origin": "message-board-forward",
1308
+ "sourceRef": "message-board",
1309
+ "bytes": 22285,
1310
+ "sha256": "1363a194f50342feb613a1aa1e01321a2a81783d88dfebe2c8f2002170cdc25c"
1311
+ },
1312
+ {
1313
+ "order": 140,
1314
+ "id": "20260923000100_agent_board_heartbeat_preserve",
1315
+ "filename": "20260923000100_agent_board_heartbeat_preserve.sql",
1316
+ "origin": "message-board-forward",
1317
+ "sourceRef": "message-board",
1318
+ "bytes": 7731,
1319
+ "sha256": "22713b970564bc59433026f70934758982b4cf7832be9c06197faa2d4ecc0040"
1302
1320
  }
1303
1321
  ],
1304
1322
  "excludedBridge": {
@@ -38,9 +38,9 @@ describe('manifest integrity', () => {
38
38
  expect(manifest.schemaVersion).toBe(8);
39
39
  expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
40
40
  });
41
- it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 139)', () => {
41
+ it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE + one BOARDCLAIMPOLICY + one HEARTBEATPRESERVE forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 141)', () => {
42
42
  expect(manifest.migrationCount).toBe(manifest.migrations.length);
43
- expect(manifest.migrationCount).toBe(139);
43
+ expect(manifest.migrationCount).toBe(141);
44
44
  // MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
45
45
  // schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
46
46
  // enrollment-only additive surface (identity_providers registry, enrollment audit,
@@ -245,6 +245,23 @@ describe('manifest integrity', () => {
245
245
  // PostgreSQL refuses a default before non-defaulted parameters. Nothing is DROPped: both
246
246
  // public signatures stay byte-identical, so ACLs and COMMENTs survive.
247
247
  '20260921000100_agent_board_task_event_origin_posture.sql',
248
+ // BOARDCLAIMPOLICY. A board's work may be coordinated by SOMEONE ELSE'S locking -- a repo
249
+ // lock, a scheduler lease, an external mutex -- and our claim guards must not break a system
250
+ // that was already working. boards.claim_policy lets the board OWNER opt out: 'inherit'
251
+ // (default, today's behaviour) or 'advisory' (record claims, never enforce them). It
252
+ // disables the OWNERLESS file/tool/custom lease cap and quota ONLY, and cannot reach the
253
+ // fleet-authority gate on machine/repository/repo_file -- a board may decline our lock,
254
+ // never grant itself another owner's resource (BCP-6 asserts that from the catalog).
255
+ '20260922000100_agent_board_board_claim_policy.sql',
256
+ // #366/F4 HEARTBEATPRESERVE. heartbeat's upsert overwrote every field it was not told, and
257
+ // p_availability defaulted to 'ready' -- so the function could not distinguish "not supplied"
258
+ // from "supplied as ready", and a caller refreshing only its lease also reset availability
259
+ // and cleared thread_id/activity_summary/metadata. Fatal to an implicit activity-coupled
260
+ // heartbeat (#329), and unfixable from the client because the default is applied before the
261
+ // function sees it. Optional fields now default NULL and NULL means LEAVE UNCHANGED on an
262
+ // existing row; a first heartbeat still lands 'ready'/'{}'. Signature unchanged (parameter
263
+ // defaults are not part of function identity), so the ACL and COMMENT survive.
264
+ '20260923000100_agent_board_heartbeat_preserve.sql',
248
265
  ]) {
249
266
  expect(names.has(f)).toBe(true);
250
267
  }
@@ -0,0 +1,228 @@
1
+ -- Board-settable claim policy: a board may decline OUR lock, never grant itself ANOTHER OWNER'S
2
+ -- resource.
3
+ --
4
+ -- THE ASK. A board's work may be coordinated by somebody else's locking — a repository lock, a
5
+ -- scheduler lease, an external mutex. If their lock already granted the work, our TTL cap or quota
6
+ -- rejecting the acquire is us breaking a system that was working. So the enforcement is opt-out
7
+ -- and the board owner holds the switch.
8
+ --
9
+ -- BCP-6 IS THE ASSERTION THIS FILE EXISTS FOR. Everything else here checks that the opt-out
10
+ -- works; BCP-6 checks that it does not work on the things it must never reach. `advisory` disables
11
+ -- the OWNERLESS guards (file/tool/custom lease cap + per-agent quota) and must leave the
12
+ -- fleet-authority gate on machine/repository/repo_file untouched — that gate answers "may this
13
+ -- agent claim this resource at all", which is authorization, not a locking convenience.
14
+ --
15
+ -- Structurally that holds because the opt-out sits inside the `v_ownerless` branch and the fleet
16
+ -- kinds are gated far above it. BCP-6 asserts it anyway: "it is in the right branch" is exactly
17
+ -- the kind of reasoning that stops being true when someone moves a line, and this file would
18
+ -- otherwise be a set of tests that only ever prove the permissive direction.
19
+ --
20
+ -- BCP-5 IS THE SECOND ONE THAT MATTERS. A claim reaches its board through its thread. So the
21
+ -- obvious hole is a claim that simply omits the thread — if that inherited "no board, therefore no
22
+ -- enforcement", any agent could opt itself out by passing null. It resolves to 'inherit' instead,
23
+ -- and BCP-5 pins that the fleet-wide flags still govern.
24
+ create extension if not exists pgtap with schema extensions;
25
+ set search_path = extensions, public, agent_board;
26
+
27
+ begin;
28
+
29
+ select plan(13);
30
+
31
+ -- ---------------------------------------------------------------------------
32
+ -- Fixture: one board with an owner and a non-owner member, a board-linked thread, and a
33
+ -- thread with NO board (for BCP-5).
34
+ -- ---------------------------------------------------------------------------
35
+ create temp table _bcp (
36
+ board uuid, owner_agent uuid, owner_user uuid,
37
+ member_agent uuid, member_user uuid,
38
+ thread uuid, free_thread uuid
39
+ ) on commit drop;
40
+
41
+ do $$
42
+ declare
43
+ v_sfx text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
44
+ v_owner uuid := gen_random_uuid();
45
+ v_member uuid := gen_random_uuid();
46
+ v_owner_user uuid; v_member_user uuid;
47
+ v_board uuid; v_thread uuid := gen_random_uuid(); v_free uuid := gen_random_uuid();
48
+ begin
49
+ v_owner_user := public.ensure_github_user('bcp-own-' || v_sfx, 'bcp-own-' || v_sfx || '@example.test', 'bcp-own-' || v_sfx, 'BCP Owner');
50
+ v_member_user := public.ensure_github_user('bcp-mem-' || v_sfx, 'bcp-mem-' || v_sfx || '@example.test', 'bcp-mem-' || v_sfx, 'BCP Member');
51
+
52
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
53
+ values (v_owner, 'agent', 'bcp-own-' || v_sfx, 'BCP Owner', 'active', v_owner_user, v_owner_user),
54
+ (v_member, 'agent', 'bcp-mem-' || v_sfx, 'BCP Member', 'active', v_member_user, v_member_user);
55
+
56
+ -- create_board seeds the creator's owner membership (ROOMOWN).
57
+ perform set_config('request.jwt.claim.role', 'authenticated', true);
58
+ perform set_config('request.jwt.claim.sub', v_owner_user::text, true);
59
+ select board_id into v_board
60
+ from agent_board.create_board(v_owner, 'bcp-' || v_sfx, 'BCP Board', 'claim policy', '{}'::jsonb, 'private');
61
+
62
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
63
+ values (v_board, v_member, 'joined', v_owner, 'member');
64
+
65
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
66
+ values (v_thread, 'coordination', v_owner, 'BCP board thread', v_board),
67
+ (v_free, 'coordination', v_owner, 'BCP boardless thread', null);
68
+
69
+ insert into _bcp values (v_board, v_owner, v_owner_user, v_member, v_member_user, v_thread, v_free);
70
+ end $$;
71
+
72
+ -- The fleet-wide guards must be ON for any of the opt-out assertions to mean anything: with them
73
+ -- off, everything succeeds and every permissive assertion below would pass vacuously.
74
+ insert into agent_board.claimgate_enforcement_flags(scope_key, enabled)
75
+ values ('ownerless_guards', true)
76
+ on conflict (scope_key) do update set enabled = true;
77
+
78
+ -- ---------------------------------------------------------------------------
79
+ -- BCP-1..2: the column.
80
+ -- ---------------------------------------------------------------------------
81
+ select is(
82
+ (select claim_policy from agent_board.boards where id = (select board from _bcp)),
83
+ 'inherit',
84
+ 'BCP-1: a newly created board defaults to inherit, so this migration changes no existing behaviour');
85
+
86
+ -- Scored by extracting the constraint's OWN literal list, not by checking that two known words
87
+ -- appear in it. The weaker spelling passes against a vocabulary that also admits a third value --
88
+ -- a review seat pointed out the first version of this assertion did exactly that.
89
+ select is(
90
+ (select string_agg(m[1], ',' order by m[1])
91
+ from pg_constraint c,
92
+ lateral regexp_matches(pg_get_constraintdef(c.oid), '''([a-z-]+)''', 'g') m
93
+ where c.conrelid = 'agent_board.boards'::regclass
94
+ and c.conname = 'boards_claim_policy_check'),
95
+ 'advisory,inherit',
96
+ 'BCP-2: the vocabulary is EXACTLY inherit and advisory -- a third admitted value fails here');
97
+
98
+ -- ---------------------------------------------------------------------------
99
+ -- BCP-3..4: the opt-out, in both directions. Both use an over-cap lease, so the ONLY difference
100
+ -- between them is the board policy.
101
+ -- ---------------------------------------------------------------------------
102
+ select lives_ok(
103
+ $$ select agent_board.set_board_claim_policy((select board from _bcp), 'advisory') $$,
104
+ 'BCP-3a: a board owner may set advisory (lives_ok, so a raise fails HERE rather than aborting)');
105
+
106
+ select lives_ok(
107
+ $$ select * from agent_board.acquire_claim(
108
+ (select owner_agent from _bcp), 'file', 'bcp-over-cap-advisory',
109
+ now() + interval '400 days', null, (select thread from _bcp)) $$,
110
+ 'BCP-3b: with advisory, an OVER-CAP ownerless lease is allowed — their lock governs, not ours');
111
+
112
+ do $$
113
+ begin
114
+ perform agent_board.set_board_claim_policy((select board from _bcp), 'inherit');
115
+ end $$;
116
+
117
+ select throws_ok(
118
+ $$ select * from agent_board.acquire_claim(
119
+ (select owner_agent from _bcp), 'file', 'bcp-over-cap-inherit',
120
+ now() + interval '400 days', null, (select thread from _bcp)) $$,
121
+ '22023',
122
+ null,
123
+ 'BCP-4: with inherit, the SAME over-cap lease is rejected — so BCP-3b is the policy working, not the guard being absent');
124
+
125
+ -- ---------------------------------------------------------------------------
126
+ -- BCP-5: no board means no opt-out. A claim that omits the thread must not inherit permissiveness.
127
+ -- ---------------------------------------------------------------------------
128
+ select throws_ok(
129
+ $$ select * from agent_board.acquire_claim(
130
+ (select owner_agent from _bcp), 'file', 'bcp-no-thread',
131
+ now() + interval '400 days', null, null) $$,
132
+ '22023',
133
+ null,
134
+ 'BCP-5: a claim with NO thread cannot opt itself out by omission — it resolves to inherit and the fleet flags govern');
135
+
136
+ select is(
137
+ agent_board._claimgate_board_claim_policy(null),
138
+ 'inherit',
139
+ 'BCP-5b: the resolver itself answers inherit for a null thread rather than NULL, so the comparison in acquire_claim can never be NULL-swallowed');
140
+
141
+ select is(
142
+ agent_board._claimgate_board_claim_policy((select free_thread from _bcp)),
143
+ 'inherit',
144
+ 'BCP-5c: and inherit for a real thread that is not board-linked');
145
+
146
+ -- ---------------------------------------------------------------------------
147
+ -- BCP-6: THE SECURITY BOUNDARY. advisory must NOT reach the fleet-authority gate.
148
+ --
149
+ -- Scored from the catalog rather than by attempting a machine claim, because a behavioural probe
150
+ -- here would need a full fleet-registration fixture and would prove only that ONE machine claim
151
+ -- was refused. This asks the structural question instead: does the board policy appear anywhere
152
+ -- the fleet kinds are decided? It must appear ONLY inside the ownerless branch.
153
+ -- ---------------------------------------------------------------------------
154
+ select is(
155
+ (select coalesce(string_agg(p.proname, ', ' order by p.proname), '')
156
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
157
+ where n.nspname = 'agent_board'
158
+ and p.proname in ('_can_claim_resource', '_claimgate_kind_enforced', '_claimgate_promotion_blockers')
159
+ and agent_board_test.stripped_body(p.oid, true) ~ '_claimgate_board_claim_policy'),
160
+ '',
161
+ 'BCP-6 SECURITY: the board policy is reachable from NO fleet-authority function — a board may decline our lock, never grant itself another owner''s resource');
162
+
163
+ -- ---------------------------------------------------------------------------
164
+ -- BCP-7..8: who may set it. Mirrors set_board_role_gate's gate exactly.
165
+ -- ---------------------------------------------------------------------------
166
+ select throws_ok(
167
+ format($$ select set_config('request.jwt.claim.sub', %L, true);
168
+ select agent_board.set_board_claim_policy(%L, 'advisory') $$,
169
+ (select member_user from _bcp)::text, (select board from _bcp)::text),
170
+ '42501',
171
+ null,
172
+ 'BCP-7: a non-owner MEMBER may not set the claim policy');
173
+
174
+ select throws_ok(
175
+ format($$ select set_config('request.jwt.claims', '', true);
176
+ select set_config('request.jwt.claim.sub', '', true);
177
+ select set_config('request.jwt.claim.role', '', true);
178
+ select agent_board.set_board_claim_policy(%L, 'advisory') $$, (select board from _bcp)::text),
179
+ '42501',
180
+ null,
181
+ 'BCP-8: an unresolved principal (service_role) is not an owner and is refused — same carve-out as set_board_role_gate');
182
+
183
+ -- ---------------------------------------------------------------------------
184
+ -- BCP-9: an unknown value is refused by the RPC with the vocabulary, not by the column CHECK with
185
+ -- a constraint name. A caller asking for 'off' should be told what the options are.
186
+ -- ---------------------------------------------------------------------------
187
+ select throws_ok(
188
+ format($$ select set_config('request.jwt.claim.role', 'authenticated', true);
189
+ select set_config('request.jwt.claim.sub', %L, true);
190
+ select agent_board.set_board_claim_policy(%L, 'off') $$,
191
+ (select owner_user from _bcp)::text, (select board from _bcp)::text),
192
+ '22023',
193
+ null,
194
+ 'BCP-9: an unknown policy is refused as a bad argument, naming the vocabulary — not as a raw CHECK violation');
195
+
196
+ -- ---------------------------------------------------------------------------
197
+ -- BCP-10: A FLEET-REVOKED OWNER IS STILL AN OWNER BY MEMBERSHIP. This function shipped its first
198
+ -- version WITHOUT this check while its own commit message claimed to mirror set_board_role_gate
199
+ -- "exactly" -- a review seat found it by reading both functions side by side instead of taking
200
+ -- the claim. Fleet revocation deliberately leaves agents.status = 'active' (the ledger row is the
201
+ -- only authority signal), so the membership test in BCP-7/8 passes for a revoked owner and cannot
202
+ -- stand alone.
203
+ --
204
+ -- Disabling a board's claim enforcement is an authority write in the same class as flipping the
205
+ -- role gate, so it owes the same post-UPDATE revocation assertion.
206
+ -- ---------------------------------------------------------------------------
207
+ do $$
208
+ begin
209
+ -- revoke as an operator (service_role: no resolved actor)
210
+ perform set_config('request.jwt.claims', '', true);
211
+ perform set_config('request.jwt.claim.sub', '', true);
212
+ perform set_config('request.jwt.claim.role', '', true);
213
+ perform agent_board.revoke_fleet_subject(
214
+ 'agent', (select owner_agent from _bcp), (select owner_agent from _bcp),
215
+ 'BCP-10 revocation', '{}'::jsonb);
216
+ end $$;
217
+
218
+ select throws_ok(
219
+ format($$ select set_config('request.jwt.claim.role', 'authenticated', true);
220
+ select set_config('request.jwt.claim.sub', %L, true);
221
+ select agent_board.set_board_claim_policy(%L, 'advisory') $$,
222
+ (select owner_user from _bcp)::text, (select board from _bcp)::text),
223
+ '42501',
224
+ null,
225
+ 'BCP-10: a FLEET-REVOKED owner may not disable claim enforcement, even though the board still lists them as owner and their agent row is still active');
226
+
227
+ select * from finish();
228
+ rollback;
@@ -0,0 +1,240 @@
1
+ -- #366/F4 pgTAP: a heartbeat can say "I am alive" WITHOUT also saying everything else.
2
+ --
3
+ -- THE DEFECT THIS PINS. heartbeat's upsert overwrote every field it was not told, and the
4
+ -- signature made that unavoidable from a caller: `p_availability text default 'ready'` means an
5
+ -- OMITTED argument arrives as 'ready', so the function could not distinguish "not supplied" from
6
+ -- "supplied as ready". A caller refreshing only its lease also reset availability and cleared
7
+ -- thread_id, activity_summary and metadata — every single call.
8
+ --
9
+ -- That is fatal to the implicit, activity-coupled heartbeat #329 wants: reporting liveness on
10
+ -- every board tool call would wipe the agent's own declared state on every call.
11
+ --
12
+ -- HBP-2 IS THE ONE THIS FILE EXISTS FOR. Everything else checks that the change did not break the
13
+ -- documented behaviour; HBP-2 checks the behaviour the change was made for. HBP-6 is its
14
+ -- necessary twin: "preserve on null" must not decay into "ignore the argument", and a file that
15
+ -- only asserted preservation would pass against a function that ignored availability entirely.
16
+ --
17
+ -- WHAT THIS FILE STRUCTURALLY CANNOT SEE, named here because a review seat walked through it.
18
+ -- Every assertion below calls the RPC with `p_metadata` OMITTED. The production client did not:
19
+ -- `heartbeat` was classified `signed-metadata`, and the client's attachSigningMetadata ALWAYS
20
+ -- writes a non-null `p_metadata`, so for every signing-enabled agent the server saw a supplied
21
+ -- argument and HBP-5's coalesce replaced the declared metadata with the signing envelope. HBP-2/3/4
22
+ -- were true and HBP-5 was false in production, and no assertion here could have told the
23
+ -- difference, because pgTAP cannot see what the client puts on the wire. The fix was to
24
+ -- reclassify the RPC as `unsigned-deliberate`; the pin lives in client.test.ts, "leaves heartbeat
25
+ -- unsigned, so a lease refresh cannot clobber declared presence (#366)". A server-side test file
26
+ -- naming its own blind spot is cheaper than a reader assuming it has none.
27
+ --
28
+ -- HBP-9 ASSERTS THE TRADE-OFF RATHER THAN HIDING IT. Because null now means "leave unchanged",
29
+ -- there is no way to CLEAR thread_id through this RPC. That is a real capability loss, it was
30
+ -- chosen deliberately over the defect above, and it is asserted here so it is a documented
31
+ -- property rather than something a future reader discovers and treats as a bug.
32
+ create extension if not exists pgtap with schema extensions;
33
+ set search_path = extensions, public, agent_board;
34
+
35
+ begin;
36
+
37
+ select plan(14);
38
+
39
+ -- ---------------------------------------------------------------------------
40
+ -- Fixture: one agent, and two threads it can reach.
41
+ -- ---------------------------------------------------------------------------
42
+ create temp table _hb (agent uuid, auth_user uuid, thread_a uuid, thread_b uuid) on commit drop;
43
+
44
+ do $$
45
+ declare
46
+ v_sfx text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
47
+ v_agent uuid := gen_random_uuid();
48
+ v_user uuid;
49
+ v_a uuid := gen_random_uuid();
50
+ v_b uuid := gen_random_uuid();
51
+ begin
52
+ v_user := public.ensure_github_user('hb-' || v_sfx, 'hb-' || v_sfx || '@example.test', 'hb-' || v_sfx, 'HB Agent');
53
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
54
+ values (v_agent, 'agent', 'hb-' || v_sfx, 'HB Agent', 'active', v_user, v_user);
55
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
56
+ values (v_a, 'coordination', v_agent, 'HB thread A'),
57
+ (v_b, 'coordination', v_agent, 'HB thread B');
58
+ perform set_config('request.jwt.claim.role', 'authenticated', true);
59
+ perform set_config('request.jwt.claim.sub', v_user::text, true);
60
+ insert into _hb values (v_agent, v_user, v_a, v_b);
61
+ end $$;
62
+
63
+ -- ---------------------------------------------------------------------------
64
+ -- HBP-1: the INSERT path is unchanged. A first heartbeat with nothing supplied still lands the
65
+ -- documented starting values, so this migration is not a behaviour change for a new agent.
66
+ -- ---------------------------------------------------------------------------
67
+ do $$
68
+ begin
69
+ perform agent_board.heartbeat((select agent from _hb));
70
+ end $$;
71
+
72
+ select is(
73
+ (select availability || '|' || coalesce(thread_id::text, 'NULL') || '|' || coalesce(activity_summary, 'NULL') || '|' || metadata::text
74
+ from agent_board.presence where agent_id = (select agent from _hb)),
75
+ 'ready|NULL|NULL|{}',
76
+ 'HBP-1: a FIRST heartbeat with nothing supplied still lands ready / no thread / no summary / {}');
77
+
78
+ -- Now the agent declares real state, the way a caller that means it would.
79
+ do $$
80
+ begin
81
+ perform agent_board.heartbeat(
82
+ (select agent from _hb),
83
+ (select thread_a from _hb),
84
+ 'busy',
85
+ 'reviewing the panel findings',
86
+ interval '10 minutes',
87
+ '{"lane":"review"}'::jsonb);
88
+ end $$;
89
+
90
+ select is(
91
+ (select availability || '|' || thread_id::text || '|' || activity_summary || '|' || (metadata->>'lane')
92
+ from agent_board.presence where agent_id = (select agent from _hb)),
93
+ 'busy|' || (select thread_a from _hb)::text || '|reviewing the panel findings|review',
94
+ 'HBP-2a: an explicit heartbeat sets every field it supplies');
95
+
96
+ -- ---------------------------------------------------------------------------
97
+ -- HBP-2..5: THE FIX. A lease-only refresh — exactly what an implicit activity-coupled heartbeat
98
+ -- sends — must leave all four declared fields alone.
99
+ -- ---------------------------------------------------------------------------
100
+ do $$
101
+ begin
102
+ perform agent_board.heartbeat((select agent from _hb), p_expires_in => interval '30 minutes');
103
+ end $$;
104
+
105
+ select is(
106
+ (select availability from agent_board.presence where agent_id = (select agent from _hb)),
107
+ 'busy',
108
+ 'HBP-2: a lease-only refresh PRESERVES availability — before this fix it silently reset to ready on every call');
109
+
110
+ select is(
111
+ (select thread_id from agent_board.presence where agent_id = (select agent from _hb)),
112
+ (select thread_a from _hb),
113
+ 'HBP-3: and preserves thread_id');
114
+
115
+ select is(
116
+ (select activity_summary from agent_board.presence where agent_id = (select agent from _hb)),
117
+ 'reviewing the panel findings',
118
+ 'HBP-4: and preserves activity_summary');
119
+
120
+ select is(
121
+ (select metadata->>'lane' from agent_board.presence where agent_id = (select agent from _hb)),
122
+ 'review',
123
+ 'HBP-5: and preserves metadata');
124
+
125
+ -- ---------------------------------------------------------------------------
126
+ -- HBP-6: the necessary twin. "Preserve on null" must not decay into "ignore the argument" — a
127
+ -- function that ignored availability entirely would pass HBP-2 through HBP-5 and be useless.
128
+ -- ---------------------------------------------------------------------------
129
+ do $$
130
+ begin
131
+ perform agent_board.heartbeat((select agent from _hb), p_availability => 'away');
132
+ end $$;
133
+
134
+ select is(
135
+ (select availability from agent_board.presence where agent_id = (select agent from _hb)),
136
+ 'away',
137
+ 'HBP-6: an EXPLICIT availability still sets it — preserve-on-null is not ignore-the-argument');
138
+
139
+ -- ---------------------------------------------------------------------------
140
+ -- HBP-7: the fields a heartbeat exists to MOVE must always move, however little else is supplied.
141
+ -- A preserve-everything bug would freeze the lease and make every agent look permanently stale --
142
+ -- the exact opposite failure to the one HBP-2 fixes, and just as invisible.
143
+ --
144
+ -- WHY THE OBVIOUS SPELLING DOES NOT WORK: now() is fixed for an entire TRANSACTION in PostgreSQL,
145
+ -- and this file runs in one. Two heartbeats here therefore produce the SAME last_heartbeat_at, and
146
+ -- pg_sleep does not change that. The first version of this assertion compared two in-transaction
147
+ -- heartbeats and failed for that reason -- a broken probe, not a broken function.
148
+ --
149
+ -- So the row is pushed into the past first. If last_heartbeat_at were preserved rather than
150
+ -- written, it would STAY an hour old, which is a difference this transaction can actually see.
151
+ -- ---------------------------------------------------------------------------
152
+ update agent_board.presence
153
+ set last_heartbeat_at = now() - interval '1 hour',
154
+ expires_at = now() - interval '30 minutes'
155
+ where agent_id = (select agent from _hb);
156
+
157
+ do $$
158
+ begin
159
+ perform agent_board.heartbeat((select agent from _hb), p_expires_in => interval '90 minutes');
160
+ end $$;
161
+
162
+ select ok(
163
+ (select last_heartbeat_at = now() and expires_at > now() + interval '89 minutes'
164
+ from agent_board.presence where agent_id = (select agent from _hb)),
165
+ 'HBP-7: last_heartbeat_at and expires_at ALWAYS advance -- pushed an hour into the past, a heartbeat brings them back');
166
+
167
+ -- ---------------------------------------------------------------------------
168
+ -- HBP-8: an explicitly supplied thread still moves the agent, so preservation does not pin it.
169
+ -- ---------------------------------------------------------------------------
170
+ do $$
171
+ begin
172
+ perform agent_board.heartbeat((select agent from _hb), (select thread_b from _hb));
173
+ end $$;
174
+
175
+ select is(
176
+ (select thread_id from agent_board.presence where agent_id = (select agent from _hb)),
177
+ (select thread_b from _hb),
178
+ 'HBP-8: an explicit thread still moves the agent between threads');
179
+
180
+ -- ---------------------------------------------------------------------------
181
+ -- HBP-9: THE TRADE-OFF, asserted rather than hidden. Null means "leave unchanged", so thread_id
182
+ -- cannot be CLEARED through this RPC. Chosen deliberately over the destructive alternative, and
183
+ -- pinned here so a future reader finds a documented property instead of what looks like a bug.
184
+ -- ---------------------------------------------------------------------------
185
+ do $$
186
+ begin
187
+ perform agent_board.heartbeat((select agent from _hb), null);
188
+ end $$;
189
+
190
+ select is(
191
+ (select thread_id from agent_board.presence where agent_id = (select agent from _hb)),
192
+ (select thread_b from _hb),
193
+ 'HBP-9 TRADE-OFF: a null thread does NOT clear it — null is how a caller declines to change a field, so clearing is not expressible here');
194
+
195
+ -- ---------------------------------------------------------------------------
196
+ -- HBP-10: CREATE OR REPLACE kept the ACL. Parameter defaults are not part of a function's
197
+ -- identity, so this was a true replace — but DROP FUNCTION would have handed EXECUTE back to
198
+ -- PUBLIC, and heartbeat is granted to authenticated.
199
+ -- ---------------------------------------------------------------------------
200
+ select is(
201
+ (select coalesce(string_agg(r, ', ' order by r), '')
202
+ from unnest(array['anon','authenticated','service_role']) r
203
+ where has_function_privilege(r, 'agent_board.heartbeat(uuid,uuid,text,text,interval,jsonb)', 'execute')),
204
+ 'authenticated, service_role',
205
+ 'HBP-10: the ACL survived the replace — authenticated and service_role, and NOT anon or public');
206
+
207
+ -- ---------------------------------------------------------------------------
208
+ -- HBP-11/12: #366/F1 — the call that could not be made.
209
+ --
210
+ -- p_agent_id had no default, so `heartbeat()` did not match the function and was rejected before
211
+ -- the body ran. That is why the implicit heartbeat in #366 was a silent no-op: the RPC rejected,
212
+ -- and the fire-and-forget reporter swallowed the error by design.
213
+ --
214
+ -- The fix is the SIGNATURE, not the client. The body already resolves the actor from the session,
215
+ -- and _resolved_actor_agent_id ignores a supplied id for a non-service_role caller beyond checking
216
+ -- it matches. Having the client send an id the server re-derives is the shape this repo has a rule
217
+ -- against.
218
+ -- ---------------------------------------------------------------------------
219
+ select lives_ok(
220
+ $$ select agent_board.heartbeat() $$,
221
+ 'HBP-11: an authenticated caller can heartbeat with NO arguments at all — the session answers for the actor');
222
+
223
+ select is(
224
+ (select count(*)::int from agent_board.presence where agent_id = (select agent from _hb)),
225
+ 1,
226
+ 'HBP-11b: and it wrote the ACTING agent''s row, not some other one');
227
+
228
+ -- service_role has no session identity, so the resolver must still demand an explicit id. If this
229
+ -- ever passed, an operator call with no actor would silently write somebody's presence.
230
+ select throws_ok(
231
+ $$ select set_config('request.jwt.claims', '', true);
232
+ select set_config('request.jwt.claim.sub', '', true);
233
+ select set_config('request.jwt.claim.role', '', true);
234
+ select agent_board.heartbeat() $$,
235
+ '22023',
236
+ null,
237
+ 'HBP-12: service_role must STILL supply p_agent_id — the null default relaxes the signature, not the resolver');
238
+
239
+ select * from finish();
240
+ rollback;
@@ -539,12 +539,19 @@ select is(
539
539
  or agent_board_test.stripped_body(p.oid) ~* 'update\s+agent_board\.board_memberships\M[^;]*\mset\M[^;]*\mrole\s*='
540
540
  or agent_board_test.stripped_body(p.oid) ~* '(insert\s+into|update)\s+agent_board\.board_authority_ceilings\M'
541
541
  or agent_board_test.stripped_body(p.oid) ~* 'update\s+agent_board\.boards\M[^;]*\mset\M[^;]*\mrole_gate_enforced\s*='
542
+ -- BOARDCLAIMPOLICY: boards.claim_policy is the same SHAPE of authority as
543
+ -- role_gate_enforced above -- a board-level switch deciding what a later gate does
544
+ -- (there the creator-identity exemption, here whether our ownerless claim guards apply
545
+ -- at all). Counting it as an authority write is what makes the pairing assertion below
546
+ -- hold, and is why set_board_claim_policy carries the ledger check.
547
+ or agent_board_test.stripped_body(p.oid) ~* 'update\s+agent_board\.boards\M[^;]*\mset\M[^;]*\mclaim_policy\s*='
542
548
  or agent_board_test.stripped_body(p.oid) ~* 'insert\s+into\s+agent_board\.board_invite_tokens\M')),
543
549
  '_fedsession_autojoin_default_board,_redeem_unbounded_invite_for_agent,'
544
550
  || '_upsert_board_membership_state,bootstrap_board_owner_memberships,create_board,'
545
- || 'create_invite_link,grant_board_role,set_board_authority_ceiling,set_board_role_gate',
551
+ || 'create_invite_link,grant_board_role,set_board_authority_ceiling,set_board_claim_policy,'
552
+ || 'set_board_role_gate',
546
553
  'the functions that WRITE a board-authority relation — in ANY shape, including a mintable '
547
- 'invite preset_role — are exactly these nine');
554
+ 'invite preset_role — are exactly these ten');
548
555
 
549
556
  -- EITHER helper counts as consulting the ledger: the invite redeem path calls the UNLOCKED
550
557
  -- predicate (see the migration header), so a side that matched only the share-locking assert
@@ -558,8 +565,8 @@ select is(
558
565
  and p.proname not in ('_assert_actor_not_fleet_revoked', '_actor_fleet_revoked')
559
566
  and agent_board_test.stripped_body(p.oid) ~ '\m(_assert_actor_not_fleet_revoked|_actor_fleet_revoked)\s*\('),
560
567
  '_redeem_unbounded_invite_for_agent,create_board,create_invite_link,grant_board_role,'
561
- || 'set_board_authority_ceiling,set_board_role_gate',
562
- 'the functions that consult the fleet revocation ledger are exactly these six (#280 added '
568
+ || 'set_board_authority_ceiling,set_board_claim_policy,set_board_role_gate',
569
+ 'the functions that consult the fleet revocation ledger are exactly these seven (#280 added '
563
570
  'create_board)');
564
571
 
565
572
  -- The gate proper: the two sets must differ by EXACTLY the three asserted exemptions, and any
@@ -588,6 +595,7 @@ select is(
588
595
  or agent_board_test.stripped_body(p.oid) ~* 'update\s+agent_board\.board_memberships\M[^;]*\mset\M[^;]*\mrole\s*='
589
596
  or agent_board_test.stripped_body(p.oid) ~* '(insert\s+into|update)\s+agent_board\.board_authority_ceilings\M'
590
597
  or agent_board_test.stripped_body(p.oid) ~* 'update\s+agent_board\.boards\M[^;]*\mset\M[^;]*\mrole_gate_enforced\s*='
598
+ or agent_board_test.stripped_body(p.oid) ~* 'update\s+agent_board\.boards\M[^;]*\mset\M[^;]*\mclaim_policy\s*='
591
599
  or agent_board_test.stripped_body(p.oid) ~* 'insert\s+into\s+agent_board\.board_invite_tokens\M')
592
600
  <> (agent_board_test.stripped_body(p.oid) ~ '\m(_assert_actor_not_fleet_revoked|_actor_fleet_revoked)\s*\('))
593
601
  ) x),
@@ -528,6 +528,7 @@ select is( -- pgtap-allow-raise-checked
528
528
  'agent_board.send_multicast_message(uuid,uuid,jsonb,text,text,text,text,text,text,jsonb)',
529
529
  'agent_board.send_multicast_message(uuid,uuid,uuid[],text,text,text,text,text,jsonb)',
530
530
  'agent_board.set_board_authority_ceiling(uuid,text,uuid)',
531
+ 'agent_board.set_board_claim_policy(uuid,text)',
531
532
  'agent_board.set_board_role_gate(uuid,boolean)',
532
533
  'agent_board.set_fleet_receive_policy(uuid,text,text,boolean,uuid,uuid,jsonb)',
533
534
  'agent_board.subscribe_callback_endpoint(uuid,uuid,text,text,uuid,jsonb)',
@@ -538,7 +539,7 @@ select is( -- pgtap-allow-raise-checked
538
539
  'agent_board.upsert_delegation_policy(uuid,text,jsonb,integer,interval,boolean)',
539
540
  'agent_board.verify_callback_endpoint(uuid,uuid,jsonb)'
540
541
  ]::text[],
541
- '#325: the effect-derived class is exactly these 59 authenticated-executable writer OVERLOADS — per-OID, and TRANSITIVE, so a delegating wrapper whose own body holds no DML is a member rather than a blind spot');
542
+ '#325: the effect-derived class is exactly these 60 authenticated-executable writer OVERLOADS — per-OID, and TRANSITIVE, so a delegating wrapper whose own body holds no DML is a member rather than a blind spot');
542
543
 
543
544
  -- WEC-4. THE DELEGATION-CHAIN LIVENESS PIN, and the mitigation for the fail-open inversion the
544
545
  -- header describes. Everything above depends on `fn_call_closure()` actually resolving edges; a
@@ -65,6 +65,10 @@
65
65
  "signature": "_check_and_bump_child_mint_rate(p_root_agent_id uuid, p_limit integer, p_window interval)",
66
66
  "declares_p_metadata": false
67
67
  },
68
+ {
69
+ "signature": "_claimgate_board_claim_policy(p_thread_id uuid)",
70
+ "declares_p_metadata": false
71
+ },
68
72
  {
69
73
  "signature": "_claimgate_kind_enforced(p_resource_kind text)",
70
74
  "declares_p_metadata": false
@@ -645,6 +649,10 @@
645
649
  "signature": "set_board_authority_ceiling(p_board_id uuid, p_max_grantable_role text, p_actor_agent_id uuid)",
646
650
  "declares_p_metadata": false
647
651
  },
652
+ {
653
+ "signature": "set_board_claim_policy(p_board_id uuid, p_claim_policy text)",
654
+ "declares_p_metadata": false
655
+ },
648
656
  {
649
657
  "signature": "set_board_role_gate(p_board_id uuid, p_enforced boolean)",
650
658
  "declares_p_metadata": false