@consiliency/agent-board-schema 1.27.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/fixtures/already-applied.json +32 -0
  2. package/migrations/20260909000100_agent_board_ledger_revoked_authority.sql +1036 -0
  3. package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
  4. package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
  5. package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
  6. package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
  7. package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
  8. package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
  9. package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
  10. package/package.json +1 -1
  11. package/schema-manifest.json +75 -3
  12. package/src/tooling.test.mjs +47 -2
  13. package/tests/agent_board_atomic_fleet_onboarding.test.sql +161 -30
  14. package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
  15. package/tests/agent_board_enroll_resumption.test.sql +7 -1
  16. package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
  17. package/tests/agent_board_fedsession.test.sql +17 -2
  18. package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
  19. package/tests/agent_board_fk_lock_edges.test.sql +422 -0
  20. package/tests/agent_board_ledger_authority.test.sql +1081 -0
  21. package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
  22. package/tests/agent_board_signer_lifecycle.test.sql +29 -6
  23. package/tests/agent_board_source_strip_helper.test.sql +157 -1
  24. package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
  25. package/tests/catalog/p_metadata_parameters.json +20 -0
  26. package/tests/fixtures/test-prereqs.sql +420 -11
  27. package/tests/isolation/expected/ledger_authority_revoke.out +701 -0
  28. package/tests/isolation/expected/signer_lifecycle_revoke.out +1 -1
  29. package/tests/isolation/expected/task_thread_lock_order.out +203 -0
  30. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
  31. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
  32. package/tests/isolation/specs/ledger_authority_revoke.spec +405 -0
  33. package/tests/isolation/specs/signer_lifecycle_revoke.spec +10 -0
  34. package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
  35. package/tests/seeds/20260916000100_agent_board_federated_identity_injective_slug.sql.pre.sql +196 -0
@@ -0,0 +1,500 @@
1
+ -- Consiliency/message-board#291 — a claim is a LOCK, never a KEY. And the key it already
2
+ -- handed out is never taken back, which is now a RULING rather than an accident.
3
+ --
4
+ -- Sibling of #287 (20260912000100), which closed the identical shape on read_receipts. The
5
+ -- owner ruled on both halves of #291 on 2026-08-14; both the outcome and the REJECTED
6
+ -- alternatives are recorded here, because the second half is a decision to LEAVE a surprising
7
+ -- behaviour in place and a future reader must not be able to mistake it for an oversight.
8
+ --
9
+ -- ============================================================================
10
+ -- HALF 1 — THE MEASURED DEFECT (closed here)
11
+ -- ============================================================================
12
+ -- `agent_board.claims` is an access KEY: `_agent_can_access_thread` accepts "there is a claims
13
+ -- row for (thread, agent)" as one of the seven disjuncts that grant access to an
14
+ -- `access_mode = 'inherit'` thread. `acquire_claim` is the ONLY route to such a row —
15
+ -- service_role and authenticated hold SELECT and only SELECT on the table (no INSERT by any
16
+ -- route incl. PUBLIC), and it is the only agent_board function that INSERTs into it, so this
17
+ -- SECURITY DEFINER body is the whole surface.
18
+ --
19
+ -- The subject of the claim is `p_agent_id` and nothing gated it. The checks `acquire_claim`
20
+ -- did apply — `_current_agent_can_access_task(p_task_id)` and
21
+ -- `_current_agent_can_access_thread(v_thread_id)` — are evaluated for the SESSION, and
22
+ -- `_is_service_role()` short-circuits both; `_resolved_actor_agent_id` then returns
23
+ -- `p_agent_id` verbatim for a service_role caller. Measured on a live replay inside
24
+ -- BEGIN … ROLLBACK: an agent B with no relationship to an inherit thread T went
25
+ -- `_agent_can_access_thread(T,B) = false` -> service_role calls
26
+ -- `acquire_claim(B, 'file', k, now()+1h, null, T)` -> `true`, without B calling anything.
27
+ --
28
+ -- IT MINTED TASK ACCESS TOO, which the receipt route did not. `_current_agent_can_access_task`
29
+ -- accepts `_current_agent_can_access_thread(t.thread_id)` as a disjunct, so the same measured
30
+ -- mint flipped `_current_agent_can_access_task(task-on-T)` from false to true for B in the
31
+ -- same call. Closing the thread subject closes both; there is no separate task-key row to gate,
32
+ -- because no function writes a task-access row at all.
33
+ --
34
+ -- Under `authenticated` there was never a gap, and this is the fact that decides the shape of
35
+ -- the fix (see THE FIRST-ACCESS QUESTION). Measured, all three shapes, agent with no access:
36
+ -- acquire_claim(B,'file',k,…, p_thread_id => T) -> 42501 thread not accessible
37
+ -- acquire_claim(B,'task',<task uuid>,…) -> 42501 task not accessible
38
+ -- acquire_claim(B,'file',k,…, p_task_id => <task>) -> 42501 task not accessible
39
+ -- The asymmetry was service_role-only.
40
+ --
41
+ -- ============================================================================
42
+ -- THE FIRST-ACCESS QUESTION — asked before the fix was written, because a wrong answer here
43
+ -- would have made the fix a CAPABILITY REMOVAL rather than a hole closure
44
+ -- ============================================================================
45
+ -- A read receipt is a memo: gating it on prior access is obviously safe, because reading
46
+ -- presupposes access. A CLAIM need not be — if any legitimate flow uses `acquire_claim` to
47
+ -- grant FIRST access (claiming a task from a pool is how a worker comes to be allowed to see
48
+ -- it), then requiring prior access breaks the feature. Three legs, each checked rather than
49
+ -- assumed:
50
+ --
51
+ -- 1. BY CONSTRUCTION on the authenticated path. The session check already evaluates
52
+ -- `_agent_can_access_thread(v_thread_id, current_agent_id())` — that is what
53
+ -- `_current_agent_can_access_thread` reduces to for a non-service_role caller — and
54
+ -- `_resolved_actor_agent_id` forces `v_actor_agent_id = current_agent_id()`. So the new
55
+ -- gate re-evaluates an expression the function has just proved true. Confirmed by the
56
+ -- three measurements above: claim-as-first-access does not exist for authenticated, and
57
+ -- never did.
58
+ -- 2. NO IN-SCHEMA CALLER. A catalog scan of every `agent_board` function body for
59
+ -- `acquire_claim\s*\(` returns ZERO. Enrollment, delegation and task dispatch do not mint
60
+ -- claims on an agent's behalf; there is no server-side flow to break.
61
+ -- 3. NO THIRD-PARTY SHIPPED CALLER. client/CLI/MCP all default `p_agent_id` to the session's
62
+ -- own acting agent (`USER_SCOPED_ACTOR_DEFAULTS` in packages/agent-board-client/src/
63
+ -- client.ts), and a repo-wide search finds no service_role caller naming another agent.
64
+ --
65
+ -- The legitimate dispatch shape SURVIVES, and that is the point: a dispatcher that DELIVERS
66
+ -- first — `delegate_task`, `send_direct_message` — gives the subject an `inbox_items` row,
67
+ -- which is itself a disjunct, after which the claim passes the gate. Delivery grants access;
68
+ -- the claim then records a lock on something already visible. `scripts/client-smoke/
69
+ -- two-agent-claim.ts` is exactly this flow and is untouched: its second agent reaches the
70
+ -- thread through the direct-message inbox item, so its conflicting acquire still answers 23505
71
+ -- rather than 42501.
72
+ --
73
+ -- ============================================================================
74
+ -- THE FIX — gate the SUBJECT, reusing #287's predicate rather than a second copy
75
+ -- ============================================================================
76
+ -- `_agent_can_access_thread(p_thread_id, p_agent_id)` already exists (20260912000100). It is
77
+ -- subject-explicit and carries NO `_is_service_role()` short-circuit — that absence is what
78
+ -- makes a subject gate real, and reintroducing the short-circuit there would make BOTH this
79
+ -- gate and #287's vacuous while every allow-side test stayed green. It is not re-derived here;
80
+ -- there is exactly one definition of the disjunct set and the two gates cannot drift from it.
81
+ --
82
+ -- UNCONDITIONAL IN *WHO*. The gate is not `if _is_service_role()` and not
83
+ -- `if v_actor_agent_id is distinct from current_agent_id()`. A conditional gate is one
84
+ -- `is distinct from` away from being skipped by a caller that presents both a service_role
85
+ -- claim and a `sub`, and it would make this function's invariant depend on
86
+ -- `_resolved_actor_agent_id` continuing to raise on a mismatch — borrowing a property instead
87
+ -- of stating it. The redundancy on the authenticated path is the price of that.
88
+ --
89
+ -- CONDITIONAL ON *THREAD PRESENCE*, and that is not a loophole. `v_thread_id` is null for a
90
+ -- claim bound to neither a task nor a thread — every `machine` / `repository` / `repo_file` /
91
+ -- ownerless `file`|`tool`|`custom` claim in the fleet. Such a row stores `thread_id = null` and
92
+ -- is therefore NOT a disjunct of anything: it mints no access, so there is nothing to gate.
93
+ -- `_agent_can_access_thread(null, a)` is false, so gating unconditionally would refuse every
94
+ -- fleet claim in existence. The gate covers exactly the claims that are keys.
95
+ --
96
+ -- GATE PLACEMENT is deliberate. An earlier draft of this header said it "preserves every
97
+ -- existing error code". THAT WAS FALSE, and it is corrected below rather than softened -- see
98
+ -- ONE ERROR CODE DOES CHANGE. The ladder is:
99
+ -- 22023 empty resource key -> P0002 task not found -> 42501 task not accessible (session)
100
+ -- -> 42501 thread not accessible (session) -> [NEW] 42501 claim subject has no access
101
+ -- -> OBSGATE-4 task-resource gates -> CLAIMGATE shadow/enforce -> ownerless TTL/quota
102
+ -- -> sweep -> 23505 conflicting active claim -> insert
103
+ -- After the task lookup, so a nonexistent task still answers P0002 rather than 42501.
104
+ --
105
+ -- ONE ERROR CODE DOES CHANGE, and it is a decision rather than a side effect. For a
106
+ -- SERVICE_ROLE caller on a THREAD-BOUND claim, everything this gate now refuses used to run all
107
+ -- the way to the INSERT and surface as a foreign-key violation. Measured by restoring the
108
+ -- pre-fix definition (20260825000100) inside a rolled-back transaction and calling both:
109
+ --
110
+ -- PRE-fix POST-fix
111
+ -- nonexistent p_thread_id, valid subject 23503 claims_thread_id_fkey -> 42501
112
+ -- nonexistent subject, valid thread 23503 claims_agent_id_fkey -> 42501
113
+ -- nonexistent subject, THREAD-LESS claim 23503 claims_agent_id_fkey -> 23503 (unchanged)
114
+ --
115
+ -- ONE rule, two observable instances: the gate raises 42501 whenever the named subject fails
116
+ -- the predicate for the bound thread, and a nonexistent thread and a nonexistent agent both
117
+ -- make that predicate false. The third row is the control that the change is confined to
118
+ -- thread-BOUND claims. `authenticated` is unaffected: its session check already answered 42501
119
+ -- for a nonexistent thread.
120
+ --
121
+ -- 23503 IS NOT RESTORED, DELIBERATELY. Restoring it would mean probing thread existence BEFORE
122
+ -- the access gate, which tells a caller with no access whether a thread id exists. 42501 gives
123
+ -- the same answer to "no such thread" and "not your thread", which is the right answer for a
124
+ -- caller entitled to neither. All three rows are pinned (test assertions 61-63): 61/62 by the
125
+ -- gate-deletion mutation, 63 by the make-the-gate-unconditional mutation.
126
+ --
127
+ -- AND BEFORE THE SWEEP -- but NOT for the reason an earlier draft of this header gave. That
128
+ -- draft said "the sweep expires stale rows, so a refused caller must not reach it or the denial
129
+ -- leaks observable state". THAT IS FALSE, and its own mutation is what caught it: moving the
130
+ -- gate BELOW the sweep produced ran=60, notok=0 on the test file. `raise exception` unwinds the
131
+ -- whole function call, so every write made before the raise is rolled back wherever the check
132
+ -- sits. "A refused caller observes no state change" is therefore TRUE BY STATEMENT ATOMICITY
133
+ -- and is not a consequence of placement -- and because it is true either way, no test can pin
134
+ -- the placement through it. The gate is placed before the sweep because that matches the
135
+ -- convention CLAIMGATE/ENFORCE states two blocks below in this same function, not because a
136
+ -- test defends it. The distinction is recorded rather than quietly dropped, because a claim
137
+ -- that cites a reason no assertion supports is exactly what CLAUDE.md rule 2 exists to catch.
138
+ --
139
+ -- ============================================================================
140
+ -- HALF 2 — WHAT IS DELIBERATELY NOT CHANGED: the claim grandfather, RATIFIED
141
+ -- ============================================================================
142
+ -- The claims disjunct filters on `(thread_id, agent_id)` and NOTHING ELSE — no `status`, no
143
+ -- `lease_expires_at`. `release_claim` sets `status='released'` rather than deleting the row, so
144
+ -- A RELEASED OR LONG-EXPIRED CLAIM STILL GRANTS THREAD ACCESS, INDEFINITELY. Measured: after
145
+ -- `release_claim`, `_agent_can_access_thread(T,B)` is still true; deleting the row is what
146
+ -- flips it to false, and nothing deletes it.
147
+ --
148
+ -- THIS IS INTENDED. The owner ratified it on #291 (2026-08-14) as the #287 grandfather
149
+ -- principle extended: the agent legitimately held the thread, so it keeps what it saw. The
150
+ -- disjunct is deliberately NOT filtered on `status` or `lease_expires_at`, and a future
151
+ -- migration that adds such a filter is REVERSING A RULING, not fixing a bug.
152
+ --
153
+ -- Two alternatives were considered and EXPLICITLY REJECTED; they are recorded because "why not
154
+ -- the obvious fix" is the question a future reader will actually have:
155
+ --
156
+ -- (a) ACCESS ENDS WITH THE CLAIM — filter the disjunct on `status='active'` and an unexpired
157
+ -- lease. REJECTED: it reverses the #287 ruling for this one case, and it would remove
158
+ -- access from every principal holding a released or expired claim AT DEPLOY TIME, with
159
+ -- no notice and no way to tell which of them still needed it.
160
+ -- (b) KEEP THE ACCESS BUT RE-MECHANISE IT — record the access separately when the claim is
161
+ -- taken, so a stale claim ROW is not itself the key. ACKNOWLEDGED AS THE MORE HONEST
162
+ -- DESIGN and rejected for now on cost, and because agents holding historical released
163
+ -- claims would need backfilling or would silently lose access.
164
+ --
165
+ -- PERMANENCE IS BY CONSTRUCTION, not by policy, and the test asserts it rather than describing
166
+ -- it: no role holds DELETE or TRUNCATE on `agent_board.claims` by any route (the ACL is
167
+ -- SELECT-only outside the owner), and NO agent_board function deletes or truncates a claims
168
+ -- row. There is no reaper. The three non-minting writers — `release_claim`, `renew_claim`,
169
+ -- `reclaim_fleet_machine` — update `status` / `lease_expires_at` / `resource_key` / `metadata`
170
+ -- and never `agent_id` or `thread_id`, so none of them can re-key an existing row onto a
171
+ -- different subject or thread. Each is asserted POSITIVELY in the test, by call, rather than by
172
+ -- absence of a regex match.
173
+ --
174
+ -- THE GATE AND THE GRANDFATHER ARE COMPLEMENTARY, NOT IN TENSION. The gate decides WHO MAY BE
175
+ -- NAMED in a claim; the grandfather decides WHAT A CLAIM MEANS afterwards. Gating the subject
176
+ -- is what makes the grandfather safe to keep: a key can no longer be conjured for an agent that
177
+ -- never had access, so "permanent" now only ever describes access that was really held.
178
+ --
179
+ -- ============================================================================
180
+ -- WHY THIS DISJUNCTION IS THE WHOLE ACCESS STORY — `access_mode='restricted'` is unreachable
181
+ -- ============================================================================
182
+ -- Carried forward from #291 and RE-VERIFIED on a live replay of this bundle rather than
183
+ -- repeated. `agent_board.threads.access_mode` defaults to `'inherit'` and:
184
+ -- * NO agent_board function so much as mentions `access_mode` except `_agent_can_access_thread`
185
+ -- itself, which READS it (catalog scan over stripped function source against a full replay
186
+ -- of the bundle INCLUDING this migration, and re-asserted on every suite run by
187
+ -- agent_board_claim_subject_gate.test.sql — subject to the prosqlbody blind spot filed as
188
+ -- message-board#293);
189
+ -- * `aclexplode(pg_class.relacl)` on `agent_board.threads` gives `authenticated` and
190
+ -- `service_role` SELECT and nothing else; only the `postgres` owner can write it.
191
+ -- So no shipped path can ever set a thread to `'restricted'`, the `thread_memberships` branch
192
+ -- of the predicate is dead, and the seven-way `inherit` OR governs EVERY thread in production.
193
+ -- That is why this gate matters more than its blast radius suggests: it is not one branch of an
194
+ -- access model, it is the access model.
195
+ --
196
+ -- One nuance, stated rather than acted on: `service_role` DOES hold INSERT and UPDATE on
197
+ -- `agent_board.thread_memberships` (unlike every other table in this story), and no function
198
+ -- writes that table. The branch is dead anyway because `access_mode` itself is unwritable, so
199
+ -- those grants confer nothing today. They are input to the owner's open question about whether
200
+ -- the dormant per-thread ACL deserves a filing of its own; this migration does not touch them.
201
+ --
202
+ -- ============================================================================
203
+ -- MECHANICS
204
+ -- ============================================================================
205
+ -- `create or replace` with UNCHANGED signatures throughout. `drop function` would discard the
206
+ -- ACL **and** the COMMENT, and recreating a SECURITY DEFINER function grants EXECUTE to PUBLIC
207
+ -- by default. `acquire_claim` is reproduced VERBATIM from 20260825000100 (CLAIMGATE/ENFORCE)
208
+ -- except the single gate block; the applied result was diffed against the prior definition to
209
+ -- confirm the gate is the only delta. `acquire_claim` had NO comment before this migration —
210
+ -- the one added below is new, not a rewrite of an earlier record.
211
+ --
212
+ -- Definition-only: every apply-time statement is `create or replace function`, `revoke`,
213
+ -- `grant` or `comment on`. The DML in this file is `acquire_claim`'s own body, inside
214
+ -- `$function$ … $function$`, executed on call and never at apply time. `schema_version` stays 8.
215
+
216
+ -- 1. acquire_claim: gate the NAMED subject ---------------------------------
217
+ -- Reproduced VERBATIM from 20260825000100 (CLAIMGATE/ENFORCE) EXCEPT the single #291 gate
218
+ -- block, inserted after the SESSION thread check and BEFORE the sweep.
219
+ create or replace function agent_board.acquire_claim(
220
+ p_agent_id uuid,
221
+ p_resource_kind text,
222
+ p_resource_key text,
223
+ p_lease_expires_at timestamp with time zone,
224
+ p_task_id uuid default null,
225
+ p_thread_id uuid default null,
226
+ p_metadata jsonb default '{}'::jsonb
227
+ )
228
+ 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)
229
+ language plpgsql
230
+ security definer
231
+ set search_path to 'agent_board', 'public'
232
+ as $function$
233
+ declare
234
+ v_thread_id uuid;
235
+ v_claim_id uuid;
236
+ v_actor_agent_id uuid;
237
+ v_shadow_authorized boolean;
238
+ v_ownerless boolean;
239
+ v_active_ownerless int;
240
+ begin
241
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
242
+
243
+ if nullif(trim(p_resource_key), '') is null then
244
+ raise exception 'resource key is required' using errcode = '22023';
245
+ end if;
246
+
247
+ -- CLAIMKEY (#133): normalize the resource key to its canonical, fleet-fingerprint-carrying
248
+ -- form BEFORE any lookup/insert, so the row stored (and the uniqueness index contended)
249
+ -- carry canonical identity. NORMALIZE-ONLY — this never rejects (rejection is ENFORCE).
250
+ p_resource_key := agent_board._canonical_claim_key(p_resource_kind, p_resource_key);
251
+
252
+ if p_task_id is not null then
253
+ select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
254
+ if v_thread_id is null then
255
+ raise exception 'task not found' using errcode = 'P0002';
256
+ end if;
257
+ if not agent_board._current_agent_can_access_task(p_task_id) then
258
+ raise exception 'task not accessible to current agent' using errcode = '42501';
259
+ end if;
260
+ else
261
+ v_thread_id := p_thread_id;
262
+ end if;
263
+
264
+ if v_thread_id is not null and not agent_board._current_agent_can_access_thread(v_thread_id) then
265
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
266
+ end if;
267
+
268
+ -- message-board#291: the SUBJECT gate. The checks above answer "may this SESSION touch the
269
+ -- task/thread" and are short-circuited true for service_role; this one answers "does the
270
+ -- agent the claim is ABOUT already have access". Without it a service_role caller could name
271
+ -- any agent, and the resulting claims row -- itself an access disjunct, and a PERMANENT one
272
+ -- -- would MINT thread access (and, through the thread disjunct of
273
+ -- _current_agent_can_access_task, task access) rather than record a lock on something the
274
+ -- subject could already see.
275
+ --
276
+ -- UNCONDITIONAL IN *WHO*. DO NOT "optimize" this into `if _is_service_role() and not ...` or
277
+ -- `if v_actor_agent_id is distinct from agent_board.current_agent_id()`. For an authenticated
278
+ -- caller v_actor_agent_id IS current_agent_id() and the session check above already
279
+ -- established this same expression, so the redundancy is PROVABLE rather than suspected; it
280
+ -- is the price of an invariant this function states for itself instead of borrowing from
281
+ -- _resolved_actor_agent_id continuing to raise on a subject mismatch.
282
+ --
283
+ -- CONDITIONAL ON *THREAD PRESENCE*, which is not a loophole. A claim bound to neither a task
284
+ -- nor a thread stores thread_id = null, is a disjunct of nothing and mints no access.
285
+ -- _agent_can_access_thread answers false for a null thread, so gating unconditionally would
286
+ -- refuse every machine / repository / repo_file / ownerless claim in the fleet. This covers
287
+ -- exactly the claims that are keys.
288
+ --
289
+ -- PLACED AFTER THE TASK LOOKUP so a nonexistent task still answers P0002, not 42501 -- that
290
+ -- ordering IS test-pinned. Placed BEFORE the sweep only to match the predicate-before-side-
291
+ -- effects convention CLAIMGATE/ENFORCE states below; it is NOT what makes a denial free of
292
+ -- side effects. `raise exception` unwinds the whole call, so a refused caller changes no state
293
+ -- wherever this sits -- measured, by moving the gate below the sweep and observing the test
294
+ -- file stay entirely green.
295
+ if v_thread_id is not null
296
+ and not agent_board._agent_can_access_thread(v_thread_id, v_actor_agent_id) then
297
+ raise exception 'claim subject has no access to thread' using errcode = '42501';
298
+ end if;
299
+
300
+ -- OBSGATE-4 (#127): a claim on a TASK resource is authorized on the LOCKED task = the task
301
+ -- named by RESOURCE_KEY (resource_kind='task'). The active-resource uniqueness is on
302
+ -- (resource_kind, resource_key), so that is the task a squat contends for — gating resource_key
303
+ -- (NOT p_task_id, which only associates the claim/event) prevents locking task B's key while
304
+ -- authorizing on an unrelated task A passed as p_task_id. A non-uuid 'task' key cannot collide
305
+ -- with a uuid-keyed legitimate claim.
306
+ --
307
+ -- The composition MIRRORS append_task_event's: ACCESS then MUTATE. mutate alone is
308
+ -- insufficient — _current_agent_can_mutate_task admits any non-observer board member via
309
+ -- _current_agent_can_post_to_thread (board send-role), which is NOT thread access; on a
310
+ -- RESTRICTED thread a board member lacking thread access would pass mutate but must still be
311
+ -- denied (they cannot access the task). Requiring access too closes that gap and matches the
312
+ -- authorization a task-BOUND acquire already gets through append_task_event.
313
+ if p_resource_kind = 'task'
314
+ 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
315
+ if not agent_board._current_agent_can_access_task(p_resource_key::uuid) then
316
+ raise exception 'task not accessible to current agent' using errcode = '42501';
317
+ end if;
318
+ if not agent_board._current_agent_can_mutate_task(p_resource_key::uuid) then
319
+ raise exception 'claiming a task resource requires a non-observer board role, or task assignment/creation'
320
+ using errcode = '42501';
321
+ end if;
322
+ end if;
323
+
324
+ -- CLAIMGATE/SHADOW (#133): compute the fleet-graph authority verdict BEFORE the sweep (so
325
+ -- ENFORCE can flip this same point fail-closed with no observable side effect leaking to an
326
+ -- unauthorized caller). The verdict is stamped below regardless of enforcement.
327
+ v_shadow_authorized := agent_board._can_claim_resource(p_resource_kind, p_resource_key);
328
+
329
+ -- CLAIMGATE/ENFORCE (#133): per-kind GATED DENIAL, BEFORE the sweep (predicate-before-side-
330
+ -- effects). When this fleet-backed kind's flag is ON and the actor is UNAUTHORIZED, DENY —
331
+ -- no state has changed yet (sweep/insert are below). When OFF, fall through to the shadow-
332
+ -- stamp only (SHADOW behavior; blocks nothing). Non-fleet kinds never kind-enforce (resolver
333
+ -- returns FALSE). service_role is authorized by the predicate (wrapper short-circuit), so the
334
+ -- operator carve-out holds. Fail-closed-on-unknown is automatic: an unresolvable fleet resource
335
+ -- makes v_shadow_authorized FALSE, so an enforced acquire of it denies.
336
+ if p_resource_kind in ('machine', 'repository', 'repo_file')
337
+ and agent_board._claimgate_kind_enforced(p_resource_kind)
338
+ and not v_shadow_authorized then
339
+ raise exception 'not authorized to claim this % resource (fleet authority required)', p_resource_kind
340
+ using errcode = '42501';
341
+ end if;
342
+
343
+ -- CLAIMGATE/ENFORCE (#133): max-lease horizon. For an ENFORCED fleet-backed kind, CLAMP the
344
+ -- lease to now()+horizon (default 30d, operator-configurable) — a bound on lease term, NOT an
345
+ -- authority block. Clamp (not reject) so an over-long lease reconciles here / at its next renew.
346
+ -- service_role exempt (operator carve-out). Only reached by an AUTHORIZED acquire (unauthorized
347
+ -- was denied above), or when the flag is OFF (then the guard below is skipped entirely).
348
+ if p_resource_kind in ('machine', 'repository', 'repo_file')
349
+ and not agent_board._is_service_role()
350
+ and agent_board._claimgate_kind_enforced(p_resource_kind)
351
+ and p_lease_expires_at > now() + agent_board._claimgate_max_lease_horizon() then
352
+ p_lease_expires_at := now() + agent_board._claimgate_max_lease_horizon();
353
+ end if;
354
+
355
+ -- Ownerless tail (file/tool/custom): the ONE guard SHADOW carries — GATED OFF BY DEFAULT.
356
+ -- _claimgate_ownerless_guards_enabled() is false unless an operator flips it post-census, so in
357
+ -- SHADOW this block is skipped entirely and NOTHING is blocked (only the stamp above lands).
358
+ -- service_role is also exempt (operator carve-out, consistent with the predicate short-circuit).
359
+ v_ownerless := p_resource_kind in ('file', 'tool', 'custom');
360
+ if v_ownerless
361
+ and not agent_board._is_service_role()
362
+ and agent_board._claimgate_ownerless_guards_enabled() then
363
+ -- TTL CAP: an ownerless lease may not exceed the max horizon. Rejects an over-long acquire;
364
+ -- this is a lease-length bound, NOT an authority block.
365
+ if p_lease_expires_at > now() + agent_board._claimgate_ownerless_ttl_cap() then
366
+ raise exception 'ownerless claim lease horizon exceeds cap (%)', agent_board._claimgate_ownerless_ttl_cap()
367
+ using errcode = '22023';
368
+ end if;
369
+
370
+ -- QUOTA (transactionally serialized): an actor-scoped advisory XACT lock serializes this
371
+ -- actor's concurrent acquisitions (on DIFFERENT keys, which the row-level uniqueness index
372
+ -- does NOT), so a count-before-insert cannot race two claims past the cap. The lock releases
373
+ -- at commit; it does not block other actors (the key mixes in the actor id).
374
+ perform pg_advisory_xact_lock(hashtext('agent_board.claimgate_ownerless_quota'), hashtext(v_actor_agent_id::text));
375
+ select count(*) into v_active_ownerless
376
+ from agent_board.claims c
377
+ where c.agent_id = v_actor_agent_id
378
+ and c.resource_kind in ('file', 'tool', 'custom')
379
+ and c.status = 'active'
380
+ and c.released_at is null
381
+ and c.lease_expires_at > now();
382
+ if v_active_ownerless >= agent_board._claimgate_ownerless_quota() then
383
+ raise exception 'ownerless active-claim quota reached (% per agent)', agent_board._claimgate_ownerless_quota()
384
+ using errcode = '54000';
385
+ end if;
386
+ end if;
387
+
388
+ -- CLAIMKEY (#133) MUST-FIX 3: the sweep AND the conflict check are ALIAS-AWARE. p_resource_key
389
+ -- is already canonical (above); comparing against the CANONICAL form of each existing row's key
390
+ -- matches both a canonical row AND an un-healed LEGACY row for the same physical resource. Without
391
+ -- this, acquire('machine','machine:fpDH') would normalize to 'fpDH', see no exact-'fpDH' conflict
392
+ -- against a still-active legacy 'machine:fpDH' row, and INSERT a second live hold — breaking mutual
393
+ -- exclusion during the legacy window. Closing it here in code does NOT depend on the operator-owned
394
+ -- live census.
395
+ update agent_board.claims c
396
+ set status = 'expired',
397
+ updated_at = now()
398
+ where c.resource_kind = p_resource_kind
399
+ and agent_board._canonical_claim_key(c.resource_kind, c.resource_key) = p_resource_key
400
+ and c.status = 'active'
401
+ and c.released_at is null
402
+ and c.lease_expires_at <= now();
403
+
404
+ if exists (
405
+ select 1 from agent_board.claims
406
+ where claims.resource_kind = p_resource_kind
407
+ and agent_board._canonical_claim_key(claims.resource_kind, claims.resource_key) = p_resource_key
408
+ and claims.status = 'active'
409
+ and claims.released_at is null
410
+ and claims.lease_expires_at > now()
411
+ ) then
412
+ raise exception 'conflicting active claim' using errcode = '23505';
413
+ end if;
414
+
415
+ insert into agent_board.claims(task_id, thread_id, agent_id, resource_kind, resource_key, lease_expires_at, metadata)
416
+ values (
417
+ p_task_id, v_thread_id, v_actor_agent_id, p_resource_kind, p_resource_key, p_lease_expires_at,
418
+ -- SHADOW stamp: record the authority verdict alongside any caller metadata. Merged last so it
419
+ -- is authoritative over a caller-supplied claimgate_shadow key.
420
+ coalesce(p_metadata, '{}'::jsonb)
421
+ || jsonb_build_object(
422
+ 'claimgate_shadow',
423
+ jsonb_build_object('kind', p_resource_kind, 'authorized', v_shadow_authorized)
424
+ )
425
+ )
426
+ returning id into v_claim_id;
427
+
428
+ if p_task_id is not null then
429
+ perform agent_board.append_task_event(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));
430
+ end if;
431
+
432
+ return query
433
+ 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
434
+ from agent_board.claims c
435
+ where c.id = v_claim_id;
436
+ end;
437
+ $function$;
438
+ revoke all on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) from public, anon;
439
+ grant execute on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) to authenticated, service_role;
440
+
441
+ comment on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) is
442
+ 'Takes an exclusive lease on (resource_kind, resource_key), optionally bound to a task and/or '
443
+ 'a thread. The subject is _resolved_actor_agent_id(p_agent_id): authenticated callers are '
444
+ 'forced to current_agent_id() and fail closed on mismatch; service_role may claim on behalf '
445
+ 'of another agent. '
446
+ 'A CLAIM IS A LOCK, NEVER A KEY (message-board#291): because a claims row for '
447
+ '(thread_id, agent_id) is itself a disjunct of _agent_can_access_thread, a claim bound to a '
448
+ 'thread requires the NAMED agent to already pass that predicate, or the call raises '
449
+ '42501 ''claim subject has no access to thread''. This closes the only route to a claims row '
450
+ '(no role holds INSERT on agent_board.claims by any route, and acquire_claim is the only '
451
+ 'agent_board function that inserts one), and with it the only route by which service_role '
452
+ 'could hand an arbitrary agent durable access to an inherit thread -- and, through the thread '
453
+ 'disjunct of _current_agent_can_access_task, to its tasks. The gate is UNCONDITIONAL in who '
454
+ '(never branched on _is_service_role() or on the subject differing from the session agent) '
455
+ 'and applies only when the claim is bound to a thread: a task-less, thread-less claim stores '
456
+ 'thread_id = null, is a disjunct of nothing and mints no access, so gating it would refuse '
457
+ 'every machine/repository/repo_file/ownerless claim in the fleet. It sits after the task '
458
+ 'lookup, so a nonexistent task still answers P0002; it also sits before the expiry sweep, by '
459
+ 'CLAIMGATE convention rather than necessity (a raise unwinds the whole call, so a refused '
460
+ 'caller changes no state wherever the check sits). On the authenticated path it is provably a '
461
+ 'no-op: the '
462
+ 'session check just evaluated the same expression for the same agent. '
463
+ 'ONE ERROR CODE CHANGES, deliberately: for a service_role caller on a thread-bound claim, a '
464
+ 'nonexistent p_thread_id -- or a nonexistent named subject -- used to reach the insert and '
465
+ 'surface as 23503 on the claims foreign keys, and now answers 42501 here instead. That is one '
466
+ 'rule with two instances, not two changes, and 23503 is NOT restored: preserving it would '
467
+ 'require probing thread existence before the access gate, which leaks existence to a caller '
468
+ 'with no access. A THREAD-LESS claim for a nonexistent subject still answers 23503, which is '
469
+ 'the boundary. '
470
+ 'THE CLAIM IT MINTS IS PERMANENT, AND THAT IS RATIFIED, NOT AN OVERSIGHT -- see the comment '
471
+ 'on _current_agent_can_access_thread. Other gates on this function are unchanged: OBSGATE-4 '
472
+ 'task-resource authorization (#127), CLAIMKEY canonicalization and CLAIMGATE shadow/enforce '
473
+ 'fleet authority (#133).';
474
+
475
+ comment on function agent_board._current_agent_can_access_thread(uuid) is
476
+ 'Thread access for the CURRENT session: service_role short-circuits true, otherwise '
477
+ '_agent_can_access_thread(p_thread_id, current_agent_id()). The disjunct set lives in that '
478
+ 'helper and is not restated here. '
479
+ 'ACCESS ONCE HELD IS NEVER WITHDRAWN, AND THAT IS DELIBERATE (message-board#287, extended and '
480
+ 'RATIFIED for claims on message-board#291). The read_receipts, claims and inbox_items '
481
+ 'disjuncts are all PERMANENT: no role holds DELETE or TRUNCATE on any of those tables and no '
482
+ 'function body removes a row from them, so LEAVING A BOARD, LOSING A TASK ASSIGNMENT, OR '
483
+ 'RELEASING A CLAIM DOES NOT REVOKE ACCESS TO A THREAD THE AGENT ALREADY HELD. The rule is '
484
+ '"you keep what you saw". For claims this is sharper than it looks and was ruled on '
485
+ 'explicitly: the claims disjunct filters on (thread_id, agent_id) and NOT on status or '
486
+ 'lease_expires_at, so a RELEASED or LONG-EXPIRED claim still grants access indefinitely. '
487
+ 'Two alternatives were considered and REJECTED on #291: (a) filtering the disjunct on an '
488
+ 'active, unexpired lease -- rejected because it reverses the #287 ruling for this one case '
489
+ 'and would strip access at deploy time from every holder of a released or expired claim; '
490
+ '(b) recording the access separately when the claim is taken, so a stale row is not itself '
491
+ 'the key -- acknowledged as the more honest design, rejected for now on cost and because '
492
+ 'agents holding historical released claims would need backfilling or would silently lose '
493
+ 'access. A future migration that adds a status or lease filter here is REVERSING A RULING, '
494
+ 'not fixing a bug. What IS closed is the mint: mark_read (#287) and acquire_claim (#291) both '
495
+ 'gate the NAMED subject through _agent_can_access_thread, so a receipt or a claim can only '
496
+ 'ever record access already held. '
497
+ 'access_mode = ''restricted'' is UNREACHABLE through the shipped API -- no function writes '
498
+ 'threads.access_mode (it defaults to ''inherit'') and authenticated/service_role hold SELECT '
499
+ 'and only SELECT on agent_board.threads -- so the inherit disjunction governs every thread in '
500
+ 'production and the per-thread ACL added in #27 is dormant.';