@consiliency/agent-board-schema 1.28.0 → 1.38.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 (43) hide show
  1. package/fixtures/already-applied.json +40 -0
  2. package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
  3. package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
  4. package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
  5. package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
  6. package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
  7. package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
  8. package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
  9. package/migrations/20260917000100_agent_board_invite_no_downgrade.sql +398 -0
  10. package/migrations/20260918000100_agent_board_signed_write_enforced_repoint.sql +139 -0
  11. package/migrations/20260919000100_agent_board_signed_write_model_key.sql +107 -0
  12. package/package.json +1 -1
  13. package/schema-manifest.json +93 -3
  14. package/src/deployment-drift-script.test.mjs +424 -0
  15. package/src/deployment-drift.test.mjs +301 -0
  16. package/src/tooling.test.mjs +83 -2
  17. package/tests/agent_board_atomic_fleet_onboarding.test.sql +144 -29
  18. package/tests/agent_board_bundle_endpoint_compat.test.sql +73 -18
  19. package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
  20. package/tests/agent_board_consent_substrate.test.sql +8 -1
  21. package/tests/agent_board_enroll_resumption.test.sql +7 -1
  22. package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
  23. package/tests/agent_board_fedsession.test.sql +17 -2
  24. package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
  25. package/tests/agent_board_fk_lock_edges.test.sql +422 -0
  26. package/tests/agent_board_invite_no_downgrade.test.sql +689 -0
  27. package/tests/agent_board_ledger_authority.test.sql +142 -71
  28. package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
  29. package/tests/agent_board_signed_write_enforced.test.sql +366 -0
  30. package/tests/agent_board_signer_lifecycle.test.sql +23 -6
  31. package/tests/agent_board_source_strip_helper.test.sql +256 -1
  32. package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
  33. package/tests/agent_board_write_enforcement_class.test.sql +1016 -0
  34. package/tests/catalog/p_metadata_parameters.json +12 -0
  35. package/tests/fixtures/isolation-substrate.sql +1 -1
  36. package/tests/fixtures/test-prereqs.sql +442 -11
  37. package/tests/isolation/expected/ledger_authority_revoke.out +219 -1
  38. package/tests/isolation/expected/task_thread_lock_order.out +203 -0
  39. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
  40. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
  41. package/tests/isolation/specs/ledger_authority_revoke.spec +115 -0
  42. package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
  43. package/tests/seeds/20260916000100_agent_board_federated_identity_injective_slug.sql.pre.sql +196 -0
@@ -0,0 +1,1025 @@
1
+ -- Consiliency/message-board#291 — a claim is a LOCK, never a KEY; and the key it hands out is
2
+ -- never taken back, which is a RULING rather than an accident.
3
+ --
4
+ -- Sibling of agent_board_receipt_subject_gate.test.sql (#287). Three properties share this file
5
+ -- because two of them pull against each other and the third is what makes the pair safe:
6
+ --
7
+ -- THE GATE a service_role caller may no longer name a THIRD PARTY in a thread-bound
8
+ -- acquire_claim unless that agent already passes the thread-access predicate.
9
+ -- Before 20260914000100, `acquire_claim(B,'file',k,…, p_thread_id => T)` under
10
+ -- service_role wrote a claims row for an agent B with no relationship to T --
11
+ -- and because a claims row IS an inherit-mode disjunct, B then passed the
12
+ -- predicate. Measured false -> true, for thread access AND (through the thread
13
+ -- disjunct of _current_agent_can_access_task) for task access.
14
+ --
15
+ -- THE GRANDFATHER the claims disjunct STAYS, unfiltered. It keys on (thread_id, agent_id)
16
+ -- and NOT on `status` or `lease_expires_at`, so a RELEASED or LONG-EXPIRED
17
+ -- claim still grants access, permanently. The owner ratified this on #291 and
18
+ -- REJECTED both alternatives (filter the disjunct; re-mechanise the access).
19
+ -- Three flows assert it POSITIVELY -- released by the holder, expired by time,
20
+ -- and released by an OPERATOR through reclaim_fleet_machine -- because a
21
+ -- well-meaning "cleanup" of the disjunct is what they exist to stop.
22
+ --
23
+ -- THE SCOPE the gate applies ONLY when the claim is bound to a thread. A machine /
24
+ -- repository / repo_file / ownerless claim stores thread_id = null, is a
25
+ -- disjunct of nothing, and mints no access; _agent_can_access_thread answers
26
+ -- false for a null thread, so an unconditional gate would refuse every fleet
27
+ -- claim in existence. That capability is pinned positively (16-18), because
28
+ -- the failure mode of this fix is not "the hole stays open", it is "the hole
29
+ -- closes and takes a working feature with it".
30
+ --
31
+ -- HOW THE GRANDFATHER FLOWS ARE BUILT, and why the obvious version of them is VACUOUS. The
32
+ -- claim holder must ALREADY have access to acquire the claim -- that is the gate. So a flow
33
+ -- that acquires a claim and then releases it proves nothing: the route that let it acquire is
34
+ -- still there and still grants access, and a mutation that filters the disjunct on `status`
35
+ -- stays GREEN. (Measured: the first draft of this file had exactly that shape, and mutations f
36
+ -- and g produced ran=50, notok=0.) Each flow therefore REMOVES the other route first -- the
37
+ -- agent is a task ASSIGNEE, and the task is reassigned away, exactly as #287 flow B does -- and
38
+ -- asserts in between that the claim is now the ONLY thing granting access. Only then does
39
+ -- releasing/expiring it test anything.
40
+ --
41
+ -- THE FIRST-ACCESS QUESTION, settled before the gate was written. If claiming were how an agent
42
+ -- came to SEE a task, gating on prior access would break the product. It is not, and the proof
43
+ -- is structural rather than a survey: for a non-service_role caller
44
+ -- `_current_agent_can_access_thread(T)` REDUCES to `_agent_can_access_thread(T, current_agent_id())`
45
+ -- and `_resolved_actor_agent_id` forces the subject to equal `current_agent_id()`, so the gate
46
+ -- re-evaluates an expression the function has just proved true. Assertions 24-27 measure that on
47
+ -- the shapes. The legitimate dispatch pattern -- DELIVER first (inbox item), then claim --
48
+ -- survives and is asserted at 19-23.
49
+ --
50
+ -- The file runs as the postgres db role and simulates callers the way
51
+ -- agent_board_receipt_subject_gate.test.sql does: request.jwt.claim.role / .sub. With NO role
52
+ -- claim `_is_service_role()` is ALREADY true for session_user postgres, so the service_role
53
+ -- claim is set explicitly wherever it matters rather than relied on implicitly.
54
+ --
55
+ -- ============================================================================
56
+ -- ONE PROPERTY THIS FILE DELIBERATELY DOES NOT CLAIM
57
+ -- ============================================================================
58
+ -- The migration places the gate BEFORE the expiry sweep, and an earlier draft of both the
59
+ -- migration header and this file called that "predicate before side effects: a refused caller
60
+ -- must not reach the sweep or the denial leaks observable state". THAT CLAIM IS FALSE, and it
61
+ -- was caught by its own mutation rather than by reading: moving the gate to sit BELOW the sweep
62
+ -- (mutation e) produced ran=60, notok=0.
63
+ --
64
+ -- The reason is statement atomicity. `raise exception` unwinds the entire function call, so
65
+ -- EVERY write the function made before raising is rolled back regardless of where the check
66
+ -- sits. A refused caller therefore observes no state change no matter what -- which means the
67
+ -- property is real but is NOT a consequence of gate placement, and no test can pin placement
68
+ -- through it. The placement is kept because it matches the convention CLAIMGATE/ENFORCE states
69
+ -- two blocks below in the same function, not because a test defends it.
70
+ --
71
+ -- What IS asserted here is the pair 12-15: a refused acquire leaves a stale sibling row
72
+ -- untouched (13, true by atomicity), AND an ALLOWED acquire on the same key really does sweep
73
+ -- it (15). 15 is the positive control without which 13 is indistinguishable from a fixture that
74
+ -- never reaches the sweep at all.
75
+ --
76
+ -- ============================================================================
77
+ -- MUTATION EVIDENCE — measured, not predicted, and re-measured after each plan change.
78
+ -- All 63 assertions RAN in every row below (`ran=63`); a truncated plan is the failure mode
79
+ -- this table exists to rule out, and it is checked per row rather than inferred.
80
+ -- ============================================================================
81
+ -- a delete the subject-gate `if` from acquire_claim
82
+ -- -> not ok 2, 3, 4, 5, 6 (the mint reopens), 51 (the class assertion names it),
83
+ -- 61, 62 (the changed codes fall back to 23503), and 13, 14, 18, 25, 26, 27 as CASCADE -- once the stranger holds a claim it has access,
84
+ -- so the later refusals stop refusing. The cascade is recorded, not filtered.
85
+ -- b re-add `_is_service_role() or` to _agent_can_access_thread
86
+ -- -> not ok 1, 2, 3, 4, 5, 6, 13, 14, 18, 19, 25, 26, 27, 61, 62 -- the gate goes vacuous;
87
+ -- assertion 1 is the one that exists for this and reds FIRST
88
+ -- c drop `v_thread_id is not null` from the gate (unconditional in THREAD PRESENCE)
89
+ -- -> not ok 16, 17 (every fleet/ownerless claim refused -- the capability pin), 26, and
90
+ -- 63, which is the assertion that pins the changed-code boundary: with the gate
91
+ -- unconditional, a THREAD-LESS claim for a nonexistent subject answers 42501 instead
92
+ -- of the 23503 it still answers today
93
+ -- d hoist the gate ABOVE the session thread check
94
+ -- -> not ok 25 -- an authenticated caller now hears the SUBJECT message instead of the
95
+ -- session one; the two 42501s stop being ordered
96
+ -- e move the gate BELOW the expiry sweep
97
+ -- -> ran=63, notok=0. THE PROPERTY IS NOT PINNED AND THE HEADER CLAIM WAS WRONG -- see
98
+ -- "ONE PROPERTY THIS FILE DELIBERATELY DOES NOT CLAIM" above. Recorded as a mutation
99
+ -- that produced NO failure rather than dropped, because that is the finding.
100
+ -- f add `and c.status = 'active'` to the claims disjunct of _agent_can_access_thread
101
+ -- -> not ok 37, 49 -- the RATIFIED released-claim permanence collapses in flow R and in
102
+ -- the operator-release flow M
103
+ -- g add `and c.lease_expires_at > now()` to the claims disjunct
104
+ -- -> not ok 43 -- the RATIFIED expired-claim permanence collapses in flow E
105
+ -- x remove the claims disjunct from _agent_can_access_thread entirely
106
+ -- -> not ok 34, 37, 40, 43, 46, 49 -- all three flows lose access the moment the task
107
+ -- assignment is taken away. This is what proves 34/40/46 are load-bearing rather than
108
+ -- decorative: without them f and g would have nothing to collapse.
109
+ -- h release_claim also `set agent_id = <another real agent>` (re-key an existing row)
110
+ -- -> not ok 36 (the positive exemption assertion), 37 (cascade: the row is no longer the
111
+ -- holder's). The re-key uses a REAL agent id so the call still succeeds -- an invalid
112
+ -- uuid would have reded 35 on the FK and told us nothing about the exemption.
113
+ -- i renew_claim also `set thread_id = <another real thread>`
114
+ -- -> not ok 42 (the positive exemption assertion), 43 (cascade)
115
+ -- j reclaim_fleet_machine also `set agent_id = p_new_owner_agent_id`
116
+ -- -> not ok 48 (the positive exemption assertion), 49 (cascade)
117
+ -- y remove the expiry sweep from acquire_claim
118
+ -- -> not ok 14, 15 -- the positive CONTROL for the sweep. Without y, assertion 13 is
119
+ -- indistinguishable from a fixture that never reaches the sweep at all.
120
+ -- s remove the `_is_service_role() or` from _current_agent_can_access_thread (over-revoke:
121
+ -- "fixing" the no-short-circuit invariant by deleting it everywhere)
122
+ -- -> not ok 60 (the named over-revoke guard) + 25 cascades: every service_role caller in
123
+ -- the file loses its session check, which is exactly the damage the guard predicts
124
+ -- k1 a NEW writer spelled `merge into agent_board.claims`
125
+ -- -> not ok 50 (names it), 51, 52
126
+ -- k2 a NEW writer spelled `update only agent_board.claims`
127
+ -- -> not ok 50
128
+ -- k3 a NEW writer spelled `delete from only "agent_board" . "claims"`
129
+ -- -> not ok 50, 52
130
+ -- k4 the EXISTING member acquire_claim's own insert respelled
131
+ -- `insert into "agent_board"."claims"` -- rule 2: mutate a member, do not only add one
132
+ -- -> ran=63, notok=0, and that is the CORRECT outcome: the scan must still see the
133
+ -- member. The green is only evidence because the contrast was measured directly --
134
+ -- the shipped creator pattern matches that spelling (true) while the same pattern
135
+ -- without its quoted-identifier arm does not (false), i.e. under the narrower pattern
136
+ -- acquire_claim would have DROPPED OUT of assertion 50's set and reded it.
137
+ -- l a claims reaper (`delete from agent_board.claims`)
138
+ -- -> not ok 50, 52
139
+ -- m a TRUNCATE-based claims reaper (`truncate table agent_board.claims`)
140
+ -- -> not ok 50, 52. Before `truncate` joined assertion 50's alternation this reded only
141
+ -- 52, while 50's own message claimed to name every WRITER -- a claim wider than its
142
+ -- query, caught by this mutation.
143
+ -- z1 a TRUNCATE-based claims reaper spelled `truncate table only agent_board.claims`
144
+ -- -> not ok 50, 52. It was GREEN until the keyword order was fixed (panel CR, codex
145
+ -- finding). The alternation was `truncate(\s+only)?\s+(table\s+)?`, i.e. ONLY
146
+ -- before TABLE; PostgreSQL's syntax is `TRUNCATE [ TABLE ] [ ONLY ] name` -- TABLE
147
+ -- FIRST. Measured on PG17 against the OLD pattern: `truncate agent_board.claims`
148
+ -- true, `truncate only …` true, `truncate table …` true, `truncate table only …`
149
+ -- FALSE -- an equally legal spelling, silently invisible, defeating the permanence
150
+ -- guarantee the ruling depends on. Now `(\s+table)?(\s+only)?`, which takes both
151
+ -- keywords in the legal order, either alone, or neither. The IDENTICAL defect was
152
+ -- already merged on main in agent_board_receipt_subject_gate.test.sql (transplanted
153
+ -- from #287 with the rest of the pattern); both are fixed here, and its mutation l2
154
+ -- pins the twin.
155
+ -- n a NEW inserter that CALLS _agent_can_access_thread and IGNORES the result
156
+ -- -> not ok 50, 51. 51 matches the REFUSING form, so a bare call does not satisfy it.
157
+ -- o `grant insert on agent_board.claims to public`
158
+ -- -> not ok 53, 55, 57, 58 -- both privilege arms
159
+ -- p `grant insert` to a BRAND-NEW role
160
+ -- -> not ok 58 only. has_table_privilege(service_role,INSERT) stayed FALSE, so the
161
+ -- effective arm alone would have shipped this green -- this is why the set form exists
162
+ -- q `grant insert` to a role, then that role granted TO service_role (role MEMBERSHIP)
163
+ -- -> not ok 53, 58. The ACL names only the member role, so the set form reports the
164
+ -- wrong subject; has_table_privilege(service_role) is the one that answers the real
165
+ -- question -- this is why the effective arm exists
166
+ -- r `revoke select on claims from service_role` (over-revoke guard)
167
+ -- -> not ok 54
168
+ -- t a function that WRITES threads.access_mode
169
+ -- -> not ok 29 -- the restricted-unreachable finding stops being true
170
+ -- u `grant update on agent_board.threads to service_role`
171
+ -- -> not ok 30 -- the other leg of the same finding
172
+ -- v a NEW writer on a DIFFERENT table named `claims_archive`
173
+ -- -> ran=60, notok=0. A NEGATIVE pin: the guard must stay GREEN, or a future author who
174
+ -- adds a claims_archive table gets a FALSE accusation, and the natural response to a
175
+ -- guard that cries wolf is to loosen it.
176
+ -- v-control a REAL claims writer added in the same transaction as v
177
+ -- -> not ok 50. Without this, v's green is indistinguishable from a pattern that matches
178
+ -- nothing at all; a negative pin needs a positive control beside it.
179
+ --
180
+ -- THE PATTERN MATRIX, measured on this cluster rather than reasoned about: 22 claims-specific
181
+ -- spellings, 11 that MUST match (plain insert, merge, `merge into only`, `update only`,
182
+ -- `delete from only` with a quoted+spaced schema dot, quoted schema.table, quoted table alone,
183
+ -- `truncate table`, `truncate only`, `truncate table only`, and a newline between the table
184
+ -- name and the next clause) and 11 that MUST NOT (`claims_archive` bare / schema-qualified /
185
+ -- quoted / truncated-table-only, `claims_archive` unqualified truncate, `claims2`,
186
+ -- `old_claims`, `reclaims`, `agent_board.reclaims`, `delete from reclaims`, and a plain SELECT,
187
+ -- which is a read and not a write). 22/22 correct. The three TRUNCATE rows are the ones the
188
+ -- first version got wrong (see z1).
189
+ -- The `reclaims` cases are the LEFT-boundary check and they are not hypothetical: the mandatory
190
+ -- `\s+` after the verb is the only thing refusing them, and `reclaim_fleet_machine` already
191
+ -- lives in this schema.
192
+ --
193
+ -- ============================================================================
194
+ -- WHAT THE CATALOG SCANS COVER, stated exactly rather than generously
195
+ -- ============================================================================
196
+ -- They are REGEXes over stripped function source, so their completeness is the completeness of
197
+ -- the pattern and a spelling the pattern does not know is a SILENT miss. The pattern is
198
+ -- transplanted from #287's FINAL form with `read_receipts` -> `claims`, so it covers ONLY,
199
+ -- MERGE, double-quoted identifier parts and whitespace around the schema dot -- all
200
+ -- VERIFIED-LEGAL spellings on PG17, each of which was invisible to that pattern's earlier
201
+ -- drafts.
202
+ --
203
+ -- The right-hand boundary is an ALTERNATION, not a trailing \M, and the reason is subtle enough
204
+ -- to restate: `_` is a word character in Postgres regex, so `claims\M` correctly refuses
205
+ -- `claims_archive`; but `"claims"\M` FAILS, because after `"` no word ends there. Writing
206
+ -- `"?claims"?\M` happens to work only because the optional `"?` backtracks to empty, i.e. on
207
+ -- the quoted form it silently stops requiring the closing quote. The alternation states the two
208
+ -- cases separately: unquoted ends at \M, quoted ends at its own mandatory closing quote.
209
+ --
210
+ -- The LEFT boundary is the mandatory `\s+` after the verb, and it is what refuses `reclaims`,
211
+ -- `old_claims` and `agent_board.reclaims` -- re-verified over claims-specific spellings rather
212
+ -- than inherited from the receipts file, because `reclaims` is a real English word that a
213
+ -- future table could plausibly be called and `reclaim_fleet_machine` already lives in this
214
+ -- schema.
215
+ --
216
+ -- ONE KNOWN BLIND SPOT, NOW CLOSED (message-board#293). A function declared with an
217
+ -- SQL-standard `BEGIN ATOMIC` body keeps its body in pg_proc.prosqlbody and leaves prosrc
218
+ -- EMPTY. agent_board_test.stripped_body used to read prosrc only, so it returned '' for such a
219
+ -- function and NOTHING matched -- a writer written that way was invisible to assertions 29, 50,
220
+ -- 51 and 52 alike. It was fixed suite-wide rather than here, because a local patch would have
221
+ -- fixed one caller and left the other 21 open. stripped_body now falls back to the deparsed
222
+ -- prosqlbody and RAISES on a body it cannot read at all; the fallback is pinned by SQLBODY-1..-11
223
+ -- in agent_board_source_strip_helper.test.sql, including SQLBODY-11, which is this file's own
224
+ -- exact-set scan shape. Note what that does NOT say: the scans below are still regexes, and the
225
+ -- text they now read for such a function is Postgres' DEPARSE of it, not the author's source.
226
+ --
227
+ -- What keeps the completeness claim honest despite that is the SECOND LEG: no grantee holds a
228
+ -- write privilege on agent_board.claims by ANY route (53-59), so an unmatched writer would have
229
+ -- to be a SECURITY DEFINER function -- exactly the population these scan -- and the exact-set
230
+ -- shape means a widened pattern fails LOUDLY and names what it caught rather than passing.
231
+ --
232
+ -- THE EXEMPTIONS ARE ASSERTED POSITIVELY (CLAUDE.md rule 1). Three of the four claims writers
233
+ -- are exempt from carrying the subject gate, and the exemption is not "they look harmless": it
234
+ -- is the claim that NONE of them creates or re-keys a (thread_id, agent_id) pair. Each is
235
+ -- called and its resulting row inspected -- release_claim (36), renew_claim (42),
236
+ -- reclaim_fleet_machine (48) -- rather than argued from the absence of a regex match.
237
+
238
+ create extension if not exists pgtap with schema extensions;
239
+ set search_path = extensions, public, agent_board;
240
+
241
+ begin;
242
+
243
+ select plan(63);
244
+
245
+ create temp table _csg (
246
+ thread_plain uuid,
247
+ thread_task uuid,
248
+ thread_rel uuid,
249
+ thread_exp uuid,
250
+ thread_mach uuid,
251
+ agent_owner uuid,
252
+ agent_strange uuid,
253
+ agent_inbox uuid,
254
+ agent_rel uuid,
255
+ agent_exp uuid,
256
+ agent_mowner uuid,
257
+ agent_mnew uuid,
258
+ user_owner uuid,
259
+ user_strange uuid,
260
+ task_id uuid,
261
+ task_rel uuid,
262
+ task_exp uuid,
263
+ task_mach uuid,
264
+ message_id uuid,
265
+ machine_id uuid,
266
+ machine_fp text,
267
+ stale_claim uuid,
268
+ sfx text
269
+ ) on commit drop;
270
+
271
+ -- Claim captures land in a table that EXISTS BEFORE any RPC runs. `create temp table _x as
272
+ -- select * from <rpc>(...)` inside lives_ok looks equivalent, but when the call raises the table
273
+ -- is never created and the NEXT assertion -- a plain select from it -- errors and aborts the
274
+ -- whole file, so the remaining assertions never run (#113, observed on #287). lives_ok's
275
+ -- subtransaction contains the raise; a missing row then reads as null and FAILS a named
276
+ -- assertion instead of killing the plan.
277
+ --
278
+ -- Assertions that read a captured row must be written so that a MISSING row is a FAILURE. An
279
+ -- assertion of the form is(<col>, null, …) passes on a missing row and is therefore blind to a
280
+ -- refused call -- assertion 17 was that shape in the first draft, and mutation c (which refuses
281
+ -- the very call it captures) left it GREEN. It now asserts a compound that is null when the row
282
+ -- is missing and true only when the row exists AND has the shape claimed.
283
+ create temp table _csg_claim (
284
+ label text primary key,
285
+ claim_id uuid,
286
+ task_id uuid,
287
+ thread_id uuid,
288
+ agent_id uuid,
289
+ resource_kind text,
290
+ resource_key text,
291
+ status text,
292
+ lease_token uuid,
293
+ lease_expires_at timestamptz,
294
+ released_at timestamptz,
295
+ created_at timestamptz,
296
+ updated_at timestamptz
297
+ ) on commit drop;
298
+
299
+ do $$
300
+ declare
301
+ v_sfx text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
302
+ v_u_owner uuid;
303
+ v_u_stran uuid;
304
+ v_u_inbox uuid;
305
+ v_u_rel uuid;
306
+ v_u_exp uuid;
307
+ v_u_mown uuid;
308
+ v_u_mnew uuid;
309
+ v_owner uuid := gen_random_uuid();
310
+ v_strange uuid := gen_random_uuid();
311
+ v_inbox uuid := gen_random_uuid();
312
+ v_rel uuid := gen_random_uuid();
313
+ v_exp uuid := gen_random_uuid();
314
+ v_mowner uuid := gen_random_uuid();
315
+ v_mnew uuid := gen_random_uuid();
316
+ v_t_plain uuid := gen_random_uuid();
317
+ v_t_task uuid := gen_random_uuid();
318
+ v_t_rel uuid := gen_random_uuid();
319
+ v_t_exp uuid := gen_random_uuid();
320
+ v_t_mach uuid := gen_random_uuid();
321
+ v_task uuid := gen_random_uuid();
322
+ v_tk_rel uuid := gen_random_uuid();
323
+ v_tk_exp uuid := gen_random_uuid();
324
+ v_tk_mach uuid := gen_random_uuid();
325
+ v_msg uuid := gen_random_uuid();
326
+ v_dom uuid;
327
+ v_mach uuid;
328
+ v_fp text;
329
+ v_stale uuid := gen_random_uuid();
330
+ begin
331
+ v_u_owner := public.ensure_github_user('csg-owner-' || v_sfx, 'csg-owner-' || v_sfx || '@example.test', 'csg-owner-' || v_sfx, 'CSG Owner');
332
+ v_u_stran := public.ensure_github_user('csg-stran-' || v_sfx, 'csg-stran-' || v_sfx || '@example.test', 'csg-stran-' || v_sfx, 'CSG Stranger');
333
+ v_u_inbox := public.ensure_github_user('csg-inbox-' || v_sfx, 'csg-inbox-' || v_sfx || '@example.test', 'csg-inbox-' || v_sfx, 'CSG Inbox');
334
+ v_u_rel := public.ensure_github_user('csg-rel-' || v_sfx, 'csg-rel-' || v_sfx || '@example.test', 'csg-rel-' || v_sfx, 'CSG Release Flow');
335
+ v_u_exp := public.ensure_github_user('csg-exp-' || v_sfx, 'csg-exp-' || v_sfx || '@example.test', 'csg-exp-' || v_sfx, 'CSG Expiry Flow');
336
+ v_u_mown := public.ensure_github_user('csg-mown-' || v_sfx, 'csg-mown-' || v_sfx || '@example.test', 'csg-mown-' || v_sfx, 'CSG Machine Owner');
337
+ v_u_mnew := public.ensure_github_user('csg-mnew-' || v_sfx, 'csg-mnew-' || v_sfx || '@example.test', 'csg-mnew-' || v_sfx, 'CSG Machine New Owner');
338
+
339
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
340
+ values
341
+ (v_owner, 'agent', 'csg-owner-' || v_sfx, 'CSG Owner', 'active', v_u_owner, v_u_owner),
342
+ (v_strange, 'agent', 'csg-stran-' || v_sfx, 'CSG Stranger', 'active', v_u_stran, v_u_stran),
343
+ (v_inbox, 'agent', 'csg-inbox-' || v_sfx, 'CSG Inbox', 'active', v_u_inbox, v_u_inbox),
344
+ (v_rel, 'agent', 'csg-rel-' || v_sfx, 'CSG RelFlow', 'active', v_u_rel, v_u_rel),
345
+ (v_exp, 'agent', 'csg-exp-' || v_sfx, 'CSG ExpFlow', 'active', v_u_exp, v_u_exp),
346
+ (v_mowner, 'agent', 'csg-mown-' || v_sfx, 'CSG MachOwner', 'active', v_u_mown, v_u_mown),
347
+ (v_mnew, 'agent', 'csg-mnew-' || v_sfx, 'CSG MachNew', 'active', v_u_mnew, v_u_mnew);
348
+
349
+ -- Five inherit threads (access_mode defaults to 'inherit'; see 29-31 for why there is no
350
+ -- 'restricted' thread here -- one cannot be created through any shipped path). Every thread
351
+ -- is created by v_owner, so no grandfather-flow agent gets the CREATOR disjunct for free.
352
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, last_message_sequence)
353
+ values
354
+ (v_t_plain, 'coordination', v_owner, 'CSG plain thread', 9),
355
+ (v_t_task, 'task', v_owner, 'CSG task thread', 9),
356
+ (v_t_rel, 'task', v_owner, 'CSG release thread', 9),
357
+ (v_t_exp, 'task', v_owner, 'CSG expiry thread', 9),
358
+ (v_t_mach, 'task', v_owner, 'CSG machine thread', 9);
359
+
360
+ -- The grandfather flows lean on the TASK disjunct as the route that will be taken away, so
361
+ -- each flow agent is an ASSIGNEE and never a creator.
362
+ insert into agent_board.tasks(id, thread_id, created_by_agent_id, assigned_agent_id, title, status, priority, orchestration_mode)
363
+ values
364
+ (v_task, v_t_task, v_owner, v_owner, 'CSG task', 'pending', 'medium', 'distributed'),
365
+ (v_tk_rel, v_t_rel, v_owner, v_rel, 'CSG release task', 'pending', 'medium', 'distributed'),
366
+ (v_tk_exp, v_t_exp, v_owner, v_exp, 'CSG expiry task', 'pending', 'medium', 'distributed'),
367
+ (v_tk_mach, v_t_mach, v_owner, v_mowner, 'CSG machine task', 'pending', 'medium', 'distributed');
368
+
369
+ -- The message the delivery-first dispatch flow (19-23) is delivered from.
370
+ insert into agent_board.messages(id, thread_id, author_agent_id, message_sequence, message_type, body)
371
+ values (v_msg, v_t_plain, v_owner, 1, 'comment', 'csg plain post');
372
+
373
+ -- A STALE ACTIVE claim on the (kind, key) the sweep assertions contend for.
374
+ insert into agent_board.claims(id, agent_id, resource_kind, resource_key, status, lease_expires_at)
375
+ values (v_stale, v_owner, 'file', 'csg-swept-' || v_sfx, 'active', now() - interval '1 hour');
376
+
377
+ -- Fleet fixture for the reclaim_fleet_machine exemption + the operator-release flow (44-49).
378
+ insert into agent_board.fleet_trust_domains(slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
379
+ values ('csg-domain-' || v_sfx, 'CSG Domain', 'tailnet', v_mowner, 'active', now())
380
+ returning id into v_dom;
381
+
382
+ select machine_id into v_mach
383
+ from agent_board.register_fleet_machine(v_mowner, v_dom, 'csg-m1-' || v_sfx, 'sha256:csg-m1-' || v_sfx, 'portal', '{"redacted":true}'::jsonb);
384
+ select machine_fingerprint into v_fp from agent_board.fleet_machines where id = v_mach;
385
+
386
+ insert into _csg values
387
+ (v_t_plain, v_t_task, v_t_rel, v_t_exp, v_t_mach,
388
+ v_owner, v_strange, v_inbox, v_rel, v_exp, v_mowner, v_mnew,
389
+ v_u_owner, v_u_stran,
390
+ v_task, v_tk_rel, v_tk_exp, v_tk_mach,
391
+ v_msg, v_mach, v_fp, v_stale, v_sfx);
392
+ end
393
+ $$;
394
+
395
+ select set_config('request.jwt.claim.role', 'service_role', true);
396
+ select set_config('request.jwt.claim.sub', '', true);
397
+
398
+ -- ============================================================================
399
+ -- 1-6. THE GATE: service_role cannot mint a claim for an agent with no access
400
+ -- ============================================================================
401
+ -- Assertion 1 is the VACUITY GUARD and it is deliberately evaluated UNDER a service_role claim:
402
+ -- _agent_can_access_thread must carry NO _is_service_role() bypass, or the gate below is a
403
+ -- no-op while every allow-side assertion in this file stays green.
404
+ select is(
405
+ agent_board._agent_can_access_thread(
406
+ (select thread_plain from _csg), (select agent_strange from _csg)),
407
+ false,
408
+ 'invariant: _agent_can_access_thread answers about the SUBJECT under a service_role claim (no short-circuit)');
409
+
410
+ select throws_ok(
411
+ $$ select * from agent_board.acquire_claim(
412
+ (select agent_strange from _csg), 'file', 'csg-mint-1',
413
+ now() + interval '1 hour', null, (select thread_plain from _csg), '{}'::jsonb) $$,
414
+ '42501',
415
+ 'claim subject has no access to thread',
416
+ 'service_role CANNOT mint a thread-bound claim for an agent with no access to the thread');
417
+
418
+ select is(
419
+ (select count(*)::int from agent_board.claims c
420
+ where c.thread_id = (select thread_plain from _csg)
421
+ and c.agent_id = (select agent_strange from _csg)),
422
+ 0,
423
+ 'the refused call wrote NO claims row for the named stranger');
424
+
425
+ select is(
426
+ agent_board._agent_can_access_thread(
427
+ (select thread_plain from _csg), (select agent_strange from _csg)),
428
+ false,
429
+ 'the stranger still has no thread access after the refused call (the false->true transition is closed)');
430
+
431
+ -- The task-BOUND shape reaches the same gate: v_thread_id is resolved from the task, so naming
432
+ -- a task rather than a thread is not a way round it.
433
+ select throws_ok(
434
+ $$ select * from agent_board.acquire_claim(
435
+ (select agent_strange from _csg), 'file', 'csg-mint-2',
436
+ now() + interval '1 hour', (select task_id from _csg), null, '{}'::jsonb) $$,
437
+ '42501',
438
+ 'claim subject has no access to thread',
439
+ 'the p_task_id shape reaches the same gate -- naming a task instead of a thread is not a bypass');
440
+
441
+ select is(
442
+ (select count(*)::int from agent_board.claims c
443
+ where c.task_id = (select task_id from _csg)
444
+ and c.agent_id = (select agent_strange from _csg)),
445
+ 0,
446
+ 'the refused task-bound call wrote NO claims row either');
447
+
448
+ -- ============================================================================
449
+ -- 7-11. The allow side, and the error codes the gate must not usurp
450
+ -- ============================================================================
451
+ -- NOT the whole error-code story, and this pointer is here so a reader does not conclude that
452
+ -- it is: the gate DOES change one code, deliberately, and that change is pinned at 61-63.
453
+ -- #113: every call goes through lives_ok and the assertions read the captured row. A bare
454
+ -- is((select ... from acquire_claim(...))) would ABORT the whole file on a raise, emitting no
455
+ -- `not ok` -- an over-refusing gate would then look like a pass.
456
+ select lives_ok(
457
+ $$ insert into _csg_claim
458
+ select 'owner-plain', * from agent_board.acquire_claim(
459
+ (select agent_owner from _csg), 'file', 'csg-allow-1',
460
+ now() + interval '1 hour', null, (select thread_plain from _csg), '{}'::jsonb) $$,
461
+ 'service_role claims on behalf of the thread creator [call succeeds]');
462
+
463
+ select is(
464
+ (select agent_id from _csg_claim where label = 'owner-plain'),
465
+ (select agent_owner from _csg),
466
+ 'the claim subject is the named agent');
467
+
468
+ select is(
469
+ (select thread_id from _csg_claim where label = 'owner-plain'),
470
+ (select thread_plain from _csg),
471
+ 'the claim is bound to the named thread -- this row IS the access disjunct the gate governs');
472
+
473
+ -- ORDERING, leg 1: the gate sits AFTER the task lookup, so a nonexistent task still answers
474
+ -- P0002 rather than being turned into a 42501.
475
+ select throws_ok(
476
+ $$ select * from agent_board.acquire_claim(
477
+ (select agent_owner from _csg), 'file', 'csg-allow-2',
478
+ now() + interval '1 hour', '00000000-0000-0000-0000-0000000000ff'::uuid, null, '{}'::jsonb) $$,
479
+ 'P0002',
480
+ 'task not found',
481
+ 'a nonexistent task still answers P0002 -- the subject gate did not usurp the lookup error');
482
+
483
+ -- ORDERING, leg 2: 23505 is still reachable. This is the shape scripts/client-smoke/
484
+ -- two-agent-claim.ts asserts, so a gate that turned a conflict into a 42501 would break it.
485
+ select throws_ok(
486
+ $$ select * from agent_board.acquire_claim(
487
+ (select agent_owner from _csg), 'file', 'csg-allow-1',
488
+ now() + interval '1 hour', null, (select thread_plain from _csg), '{}'::jsonb) $$,
489
+ '23505',
490
+ 'conflicting active claim',
491
+ 'a conflicting active claim still answers 23505 -- the two-agent-claim smoke keeps its shape');
492
+
493
+ -- ============================================================================
494
+ -- 12-15. The expiry sweep, both ways
495
+ -- ============================================================================
496
+ -- 13 is a statement about ATOMICITY, not about gate placement -- see "ONE PROPERTY THIS FILE
497
+ -- DELIBERATELY DOES NOT CLAIM" in the header. 15 is the positive control that makes 13 mean
498
+ -- anything: without it, "the stale row is still active" is equally satisfied by a fixture that
499
+ -- never reaches the sweep at all.
500
+ select lives_ok(
501
+ $$ do $do$
502
+ begin
503
+ begin
504
+ perform * from agent_board.acquire_claim(
505
+ (select agent_strange from _csg), 'file',
506
+ (select 'csg-swept-' || sfx from _csg),
507
+ now() + interval '1 hour', null, (select thread_plain from _csg), '{}'::jsonb);
508
+ exception when sqlstate '42501' then null;
509
+ end;
510
+ end $do$ $$,
511
+ 'a REFUSED mint contends for the same (kind, key) as a stale ACTIVE claim [call refused, contained]');
512
+
513
+ select is(
514
+ (select status from agent_board.claims where id = (select stale_claim from _csg)),
515
+ 'active',
516
+ 'the refused mint changed NO state -- the stale sibling row is untouched (statement atomicity: the raise unwinds the call)');
517
+
518
+ select lives_ok(
519
+ $$ insert into _csg_claim
520
+ select 'sweeper', * from agent_board.acquire_claim(
521
+ (select agent_owner from _csg), 'file',
522
+ (select 'csg-swept-' || sfx from _csg),
523
+ now() + interval '1 hour', null, (select thread_plain from _csg), '{}'::jsonb) $$,
524
+ 'CONTROL: an ALLOWED mint for the same (kind, key) [call succeeds]');
525
+
526
+ select is(
527
+ (select status from agent_board.claims where id = (select stale_claim from _csg)),
528
+ 'expired',
529
+ 'CONTROL: the allowed mint DID sweep the stale row -- so 13 is atomicity, not a fixture that misses the sweep');
530
+
531
+ -- ============================================================================
532
+ -- 16-18. THE SCOPE: a thread-less claim mints nothing, so it is NOT gated
533
+ -- ============================================================================
534
+ -- This is the capability pin. `machine` / `repository` / `repo_file` / ownerless claims carry
535
+ -- thread_id = null, are a disjunct of nothing, and must stay acquirable for ANY named agent.
536
+ -- _agent_can_access_thread(null, a) is false, so a gate that dropped its `v_thread_id is not
537
+ -- null` guard would refuse every fleet claim in existence while every assertion above stayed
538
+ -- green.
539
+ select lives_ok(
540
+ $$ insert into _csg_claim
541
+ select 'strange-machine', * from agent_board.acquire_claim(
542
+ (select agent_strange from _csg), 'machine',
543
+ (select 'csg-fp-' || sfx from _csg),
544
+ now() + interval '1 hour', null, null, '{}'::jsonb) $$,
545
+ 'a THREAD-LESS fleet claim is still acquirable for an agent with no thread access [call succeeds]');
546
+
547
+ -- Compound, not is(thread_id, null): a MISSING capture row would satisfy the null form and hide
548
+ -- a refused call. This is null when the row is absent and true only when it exists AND is
549
+ -- thread-less.
550
+ select is(
551
+ (select claim_id is not null and thread_id is null from _csg_claim where label = 'strange-machine'),
552
+ true,
553
+ 'the thread-less claim EXISTS and stores thread_id = null -- it is a disjunct of nothing');
554
+
555
+ select is(
556
+ agent_board._agent_can_access_thread(
557
+ (select thread_plain from _csg), (select agent_strange from _csg)),
558
+ false,
559
+ 'and it mints no access: the stranger still cannot reach the thread');
560
+
561
+ -- ============================================================================
562
+ -- 19-23. DELIVERY-FIRST DISPATCH SURVIVES
563
+ -- ============================================================================
564
+ -- The pattern the gate could plausibly have broken: an orchestrator claiming on behalf of a
565
+ -- worker. It survives because delivery is what grants access -- an inbox_items row is itself a
566
+ -- disjunct -- and the claim then records a lock on something the subject can already see.
567
+ select is(
568
+ agent_board._agent_can_access_thread(
569
+ (select thread_plain from _csg), (select agent_inbox from _csg)),
570
+ false,
571
+ 'dispatch precondition: before delivery, the worker has no access to the thread');
572
+
573
+ with dr as (
574
+ insert into agent_board.delivery_recipients(delivery_mode, recipient_agent_id, source_kind, source_message_id)
575
+ select 'direct', (select agent_inbox from _csg), 'message', (select message_id from _csg)
576
+ returning id
577
+ )
578
+ insert into agent_board.inbox_items(delivery_recipient_id, recipient_agent_id, delivery_mode, source_kind, source_message_id, subject)
579
+ select dr.id, (select agent_inbox from _csg), 'direct', 'message', (select message_id from _csg), 'csg delivery' from dr;
580
+
581
+ select is(
582
+ agent_board._agent_can_access_thread(
583
+ (select thread_plain from _csg), (select agent_inbox from _csg)),
584
+ true,
585
+ 'delivery grants access: the inbox_items row is itself an inherit-mode disjunct');
586
+
587
+ select lives_ok(
588
+ $$ insert into _csg_claim
589
+ select 'dispatch', * from agent_board.acquire_claim(
590
+ (select agent_inbox from _csg), 'file', 'csg-dispatch-1',
591
+ now() + interval '1 hour', null, (select thread_plain from _csg), '{}'::jsonb) $$,
592
+ 'DISPATCH: service_role claims on behalf of a worker that was DELIVERED to first [call succeeds]');
593
+
594
+ select is(
595
+ (select agent_id from _csg_claim where label = 'dispatch'),
596
+ (select agent_inbox from _csg),
597
+ 'DISPATCH: the claim subject is the worker, not the caller');
598
+
599
+ select is(
600
+ (select thread_id from _csg_claim where label = 'dispatch'),
601
+ (select thread_plain from _csg),
602
+ 'DISPATCH: and it is bound to the delivered thread');
603
+
604
+ -- ============================================================================
605
+ -- 24-28. The authenticated path is unchanged, and never offered first access
606
+ -- ============================================================================
607
+ select set_config('request.jwt.claim.role', 'authenticated', true);
608
+ select set_config('request.jwt.claim.sub', (select user_strange from _csg)::text, true);
609
+
610
+ select throws_ok(
611
+ $$ select * from agent_board.acquire_claim(
612
+ (select agent_owner from _csg), 'file', 'csg-auth-1',
613
+ now() + interval '1 hour', null, (select thread_plain from _csg), '{}'::jsonb) $$,
614
+ '42501',
615
+ 'p_agent_id must match current_agent_id()',
616
+ 'authenticated still cannot name a third party at all -- refused before the subject gate');
617
+
618
+ -- ORDERING, leg 3: the SESSION check still fires before the SUBJECT gate, so an authenticated
619
+ -- caller with no access hears the session message. Hoisting the gate above it changes this.
620
+ select throws_ok(
621
+ $$ select * from agent_board.acquire_claim(
622
+ (select agent_strange from _csg), 'file', 'csg-auth-2',
623
+ now() + interval '1 hour', null, (select thread_plain from _csg), '{}'::jsonb) $$,
624
+ '42501',
625
+ 'thread not accessible to current agent',
626
+ 'an authenticated agent claiming its OWN name on an inaccessible thread still hears the SESSION message');
627
+
628
+ -- The claim-as-first-access question, measured on the task-key shape too: acquiring is not how
629
+ -- an authenticated agent comes to see a task, and never was.
630
+ select throws_ok(
631
+ $$ select * from agent_board.acquire_claim(
632
+ (select agent_strange from _csg), 'task', (select task_id from _csg)::text,
633
+ now() + interval '1 hour', null, null, '{}'::jsonb) $$,
634
+ '42501',
635
+ 'task not accessible to current agent',
636
+ 'claiming a task resource is NOT a first-access route for authenticated -- it never was');
637
+
638
+ -- The task half of what the mint used to hand out: _current_agent_can_access_task accepts
639
+ -- thread access as a disjunct, so the closed mint closes task access too.
640
+ select is(
641
+ agent_board._current_agent_can_access_task((select task_id from _csg)),
642
+ false,
643
+ 'the stranger has no TASK access either -- the mint used to confer this too, through the thread disjunct');
644
+
645
+ select set_config('request.jwt.claim.sub', (select user_owner from _csg)::text, true);
646
+
647
+ select lives_ok(
648
+ $$ insert into _csg_claim
649
+ select 'authenticated-self', * from agent_board.acquire_claim(
650
+ (select agent_owner from _csg), 'file', 'csg-auth-3',
651
+ now() + interval '1 hour', null, (select thread_plain from _csg), '{}'::jsonb) $$,
652
+ 'authenticated claims for ITSELF on a thread it can reach, unchanged [call succeeds]');
653
+
654
+ -- ============================================================================
655
+ -- 29-31. WHY THIS DISJUNCTION IS THE WHOLE ACCESS STORY (carried forward from #291)
656
+ -- ============================================================================
657
+ -- `access_mode = 'restricted'` is unreachable through the shipped API, so the thread_memberships
658
+ -- branch of the predicate is dead and the inherit seven-way OR governs EVERY thread. Three legs,
659
+ -- because no one of them is sufficient: nothing WRITES the column, nothing may write the TABLE,
660
+ -- and the column's default is the other value.
661
+ select set_config('request.jwt.claim.role', 'service_role', true);
662
+ select set_config('request.jwt.claim.sub', '', true);
663
+
664
+ select is(
665
+ (select coalesce(string_agg(p.proname, ',' order by p.proname), '(none)')
666
+ from pg_proc p
667
+ where p.pronamespace = 'agent_board'::regnamespace
668
+ and lower(agent_board_test.stripped_body(p.oid, true)) ~ 'access_mode'),
669
+ '_agent_can_access_thread',
670
+ 'catalog: the ONLY agent_board function that so much as mentions threads.access_mode is the predicate that READS it');
671
+
672
+ select is(
673
+ (select coalesce(string_agg(distinct coalesce(g.rolname, 'PUBLIC') || ':' || a.privilege_type,
674
+ ', ' order by coalesce(g.rolname, 'PUBLIC') || ':' || a.privilege_type),
675
+ '(none)')
676
+ from pg_class c
677
+ cross join aclexplode(c.relacl) a
678
+ left join pg_roles g on g.oid = a.grantee
679
+ where c.oid = 'agent_board.threads'::regclass
680
+ and a.privilege_type <> 'SELECT'
681
+ and coalesce(g.rolname, 'PUBLIC') <> 'postgres'),
682
+ '(none)',
683
+ 'catalog: NO grantee may write agent_board.threads directly either -- so access_mode cannot be set by any route');
684
+
685
+ select is(
686
+ (select column_default from information_schema.columns
687
+ where table_schema = 'agent_board' and table_name = 'threads' and column_name = 'access_mode'),
688
+ '''inherit''::text',
689
+ 'and every thread is created as inherit -- the per-thread ACL added in #27 is dormant, so this gate is the access model, not one branch of it');
690
+
691
+ -- ============================================================================
692
+ -- 32-37. GRANDFATHER FLOW R — released by its holder
693
+ -- ============================================================================
694
+ -- The route that will be taken away is the TASK ASSIGNMENT. Assertion 34 is what makes 37 mean
695
+ -- something: it establishes that once the assignment is gone, the CLAIM is the only thing left
696
+ -- granting access. Without 34, releasing the claim would prove nothing because the assignment
697
+ -- would still be there.
698
+ select is(
699
+ agent_board._agent_can_access_thread(
700
+ (select thread_rel from _csg), (select agent_rel from _csg)),
701
+ true,
702
+ 'flow R precondition: the assignee reaches its task thread through the task disjunct');
703
+
704
+ select lives_ok(
705
+ $$ insert into _csg_claim
706
+ select 'rel', * from agent_board.acquire_claim(
707
+ (select agent_rel from _csg), 'file', 'csg-rel-1',
708
+ now() + interval '1 hour', null, (select thread_rel from _csg), '{}'::jsonb) $$,
709
+ 'flow R: the assignee takes a thread-bound claim [call succeeds]');
710
+
711
+ update agent_board.tasks
712
+ set assigned_agent_id = (select agent_owner from _csg)
713
+ where id = (select task_rel from _csg);
714
+
715
+ select is(
716
+ (select agent_board._agent_can_access_thread((select thread_rel from _csg), (select agent_rel from _csg))
717
+ and not exists (
718
+ select 1 from agent_board.tasks t
719
+ where t.thread_id = (select thread_rel from _csg)
720
+ and (t.created_by_agent_id = (select agent_rel from _csg)
721
+ or t.assigned_agent_id = (select agent_rel from _csg)))),
722
+ true,
723
+ 'flow R: after reassignment the task disjunct is GONE and the ACTIVE claim is the only thing granting access');
724
+
725
+ select lives_ok(
726
+ $$ insert into _csg_claim
727
+ select 'rel-released', * from agent_board.release_claim(
728
+ (select lease_token from _csg_claim where label = 'rel')) $$,
729
+ 'flow R: release_claim releases it [call succeeds]');
730
+
731
+ select is(
732
+ (select agent_id::text || '|' || thread_id::text || '|' || status
733
+ from _csg_claim where label = 'rel-released'),
734
+ (select agent_rel::text || '|' || thread_rel::text || '|released' from _csg),
735
+ 'EXEMPTION release_claim: it flips status and NEVER re-keys (thread_id, agent_id)');
736
+
737
+ select is(
738
+ agent_board._agent_can_access_thread(
739
+ (select thread_rel from _csg), (select agent_rel from _csg)),
740
+ true,
741
+ 'GRANDFATHER (RATIFIED #291): a RELEASED claim still grants thread access -- the disjunct is deliberately not filtered on status');
742
+
743
+ -- ============================================================================
744
+ -- 38-43. GRANDFATHER FLOW E — expired by time
745
+ -- ============================================================================
746
+ select is(
747
+ agent_board._agent_can_access_thread(
748
+ (select thread_exp from _csg), (select agent_exp from _csg)),
749
+ true,
750
+ 'flow E precondition: the assignee reaches its task thread through the task disjunct');
751
+
752
+ select lives_ok(
753
+ $$ insert into _csg_claim
754
+ select 'exp', * from agent_board.acquire_claim(
755
+ (select agent_exp from _csg), 'file', 'csg-exp-1',
756
+ now() + interval '1 hour', null, (select thread_exp from _csg), '{}'::jsonb) $$,
757
+ 'flow E: the assignee takes a thread-bound claim [call succeeds]');
758
+
759
+ update agent_board.tasks
760
+ set assigned_agent_id = (select agent_owner from _csg)
761
+ where id = (select task_exp from _csg);
762
+
763
+ select is(
764
+ (select agent_board._agent_can_access_thread((select thread_exp from _csg), (select agent_exp from _csg))
765
+ and not exists (
766
+ select 1 from agent_board.tasks t
767
+ where t.thread_id = (select thread_exp from _csg)
768
+ and (t.created_by_agent_id = (select agent_exp from _csg)
769
+ or t.assigned_agent_id = (select agent_exp from _csg)))),
770
+ true,
771
+ 'flow E: after reassignment the task disjunct is GONE and the ACTIVE claim is the only thing granting access');
772
+
773
+ select lives_ok(
774
+ $$ insert into _csg_claim
775
+ select 'exp-renewed', * from agent_board.renew_claim(
776
+ (select lease_token from _csg_claim where label = 'exp'),
777
+ now() + interval '2 hours') $$,
778
+ 'flow E: renew_claim extends the lease [call succeeds]');
779
+
780
+ select is(
781
+ (select agent_id::text || '|' || thread_id::text
782
+ from _csg_claim where label = 'exp-renewed'),
783
+ (select agent_exp::text || '|' || thread_exp::text from _csg),
784
+ 'EXEMPTION renew_claim: it extends the lease and NEVER re-keys (thread_id, agent_id)');
785
+
786
+ update agent_board.claims
787
+ set lease_expires_at = now() - interval '30 days'
788
+ where id = (select claim_id from _csg_claim where label = 'exp');
789
+
790
+ select is(
791
+ agent_board._agent_can_access_thread(
792
+ (select thread_exp from _csg), (select agent_exp from _csg)),
793
+ true,
794
+ 'GRANDFATHER (RATIFIED #291): a LONG-EXPIRED claim still grants thread access -- the disjunct is deliberately not filtered on lease_expires_at');
795
+
796
+ -- ============================================================================
797
+ -- 44-49. GRANDFATHER FLOW M — released by an OPERATOR, without the holder asking
798
+ -- ============================================================================
799
+ select is(
800
+ agent_board._agent_can_access_thread(
801
+ (select thread_mach from _csg), (select agent_mowner from _csg)),
802
+ true,
803
+ 'flow M precondition: the machine owner reaches its task thread through the task disjunct');
804
+
805
+ select lives_ok(
806
+ $$ insert into _csg_claim
807
+ select 'mach', * from agent_board.acquire_claim(
808
+ (select agent_mowner from _csg), 'machine', (select machine_fp from _csg),
809
+ now() + interval '1 hour', null, (select thread_mach from _csg), '{}'::jsonb) $$,
810
+ 'flow M: the machine owner takes a THREAD-BOUND machine claim [call succeeds]');
811
+
812
+ update agent_board.tasks
813
+ set assigned_agent_id = (select agent_owner from _csg)
814
+ where id = (select task_mach from _csg);
815
+
816
+ select is(
817
+ (select agent_board._agent_can_access_thread((select thread_mach from _csg), (select agent_mowner from _csg))
818
+ and not exists (
819
+ select 1 from agent_board.tasks t
820
+ where t.thread_id = (select thread_mach from _csg)
821
+ and (t.created_by_agent_id = (select agent_mowner from _csg)
822
+ or t.assigned_agent_id = (select agent_mowner from _csg)))),
823
+ true,
824
+ 'flow M: after reassignment the task disjunct is GONE and the ACTIVE claim is the only thing granting access');
825
+
826
+ select lives_ok(
827
+ $$ create temp table _csg_reclaim on commit drop as
828
+ select * from agent_board.reclaim_fleet_machine(
829
+ (select machine_id from _csg), (select agent_mnew from _csg), (select agent_mnew from _csg),
830
+ 'csg reclaim', null) $$,
831
+ 'flow M: an operator reclaims the machine for a new owner [call succeeds]');
832
+
833
+ select is(
834
+ (select c.agent_id::text || '|' || c.thread_id::text || '|' || c.status
835
+ from agent_board.claims c
836
+ where c.id = (select claim_id from _csg_claim where label = 'mach')),
837
+ (select agent_mowner::text || '|' || thread_mach::text || '|released' from _csg),
838
+ 'EXEMPTION reclaim_fleet_machine: it releases the prior holder''s claim and NEVER re-keys (thread_id, agent_id)');
839
+
840
+ select is(
841
+ agent_board._agent_can_access_thread(
842
+ (select thread_mach from _csg), (select agent_mowner from _csg)),
843
+ true,
844
+ 'GRANDFATHER (RATIFIED #291): a claim released BY THE OPERATOR still grants the prior holder thread access');
845
+
846
+ -- ============================================================================
847
+ -- 50-59. Catalog-derived class + the privilege legs
848
+ -- ============================================================================
849
+ -- The completeness claim is "acquire_claim is the ONLY route to a claims row", backed from BOTH
850
+ -- sides: the catalog function scan, and the effective-privilege assertions after it (no role
851
+ -- holds a write privilege by ANY route, so a direct write is impossible and the SECURITY
852
+ -- DEFINER bodies are the whole surface).
853
+ select is(
854
+ (select coalesce(string_agg(p.proname, ',' order by p.proname), '(none)')
855
+ from pg_proc p
856
+ where p.pronamespace = 'agent_board'::regnamespace
857
+ and lower(agent_board_test.stripped_body(p.oid, true))
858
+ ~ '(insert\s+into|merge\s+into|update|delete\s+from|truncate)(\s+table)?(\s+only)?\s+(("?agent_board"?\s*\.\s*)?claims\M|("?agent_board"?\s*\.\s*)?"claims")'),
859
+ 'acquire_claim,reclaim_fleet_machine,release_claim,renew_claim',
860
+ 'catalog: these four are the ONLY agent_board functions that write claims (insert/merge/update/delete/TRUNCATE) -- a new writer names itself here');
861
+
862
+ select is(
863
+ (select coalesce(string_agg(p.proname, ',' order by p.proname), '(none)')
864
+ from pg_proc p
865
+ where p.pronamespace = 'agent_board'::regnamespace
866
+ and lower(agent_board_test.stripped_body(p.oid, true))
867
+ ~ '(insert\s+into|merge\s+into)(\s+only)?\s+(("?agent_board"?\s*\.\s*)?claims\M|("?agent_board"?\s*\.\s*)?"claims")'
868
+ and lower(agent_board_test.stripped_body(p.oid))
869
+ !~ 'not\s+agent_board\._agent_can_access_thread\s*\('),
870
+ '(none)',
871
+ 'catalog: every function that CREATES a claims row contains a REFUSING reference to _agent_can_access_thread (source shape only -- assertion 2 is what proves it refuses)');
872
+
873
+ -- WHAT 51 DOES AND DOES NOT ESTABLISH. It matches `not agent_board._agent_can_access_thread(`
874
+ -- in the creator's body -- a source SHAPE, so it sees the refusing form rather than a bare
875
+ -- mention, but it cannot see whether the branch raises or whether the result is discarded. The
876
+ -- BEHAVIOUR is assertion 2. Mutation a reds both, so the pair is class-half plus
877
+ -- behavioural-half rather than redundant.
878
+
879
+ -- The permanence half of the grandfather, asserted rather than asserted-about: if a future
880
+ -- migration adds a claim reaper, "the access is permanent" stops being true and this names it.
881
+ -- TRUNCATE is in the alternation because it removes every row without a DELETE: the assertion
882
+ -- claims permanence, so scanning only `delete from` would have claimed more than it checked.
883
+ select is(
884
+ (select coalesce(string_agg(p.proname, ',' order by p.proname), '(none)')
885
+ from pg_proc p
886
+ where p.pronamespace = 'agent_board'::regnamespace
887
+ and lower(agent_board_test.stripped_body(p.oid, true))
888
+ ~ '(delete\s+from|merge\s+into|truncate)(\s+table)?(\s+only)?\s+(("?agent_board"?\s*\.\s*)?claims\M|("?agent_board"?\s*\.\s*)?"claims")'),
889
+ '(none)',
890
+ 'catalog: NOTHING REAPS -- no agent_board function deletes or truncates a claims row, which is what makes the ratified permanence true by construction');
891
+
892
+ -- EFFECTIVE privilege, not the direct grant. information_schema.role_table_grants filtered on
893
+ -- `grantee = '<role>'` shows only grants made TO THAT NAME, so `grant insert on
894
+ -- agent_board.claims to public` hands service_role a working INSERT while a direct-grantee
895
+ -- query stays green -- the #118 PUBLIC fail-open class. has_table_privilege resolves PUBLIC and
896
+ -- role membership, so it answers the question the message actually asks.
897
+ select is(
898
+ (select string_agg(priv, ',' order by priv)
899
+ from unnest(array['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES']) as priv
900
+ where has_table_privilege('service_role', 'agent_board.claims', priv)),
901
+ null,
902
+ 'service_role holds NO effective write privilege on claims, by any route incl. PUBLIC');
903
+
904
+ select ok(
905
+ has_table_privilege('service_role', 'agent_board.claims', 'SELECT'),
906
+ 'service_role does hold SELECT on claims (the read path is not over-revoked)');
907
+
908
+ select is(
909
+ (select string_agg(priv, ',' order by priv)
910
+ from unnest(array['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES']) as priv
911
+ where has_table_privilege('authenticated', 'agent_board.claims', priv)),
912
+ null,
913
+ 'authenticated holds NO effective write privilege on claims, by any route incl. PUBLIC');
914
+
915
+ select ok(
916
+ has_table_privilege('authenticated', 'agent_board.claims', 'SELECT'),
917
+ 'authenticated does hold SELECT on claims (the read path is not over-revoked)');
918
+
919
+ select is(
920
+ (select string_agg(priv, ',' order by priv)
921
+ from unnest(array['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'SELECT']) as priv
922
+ where has_table_privilege('public', 'agent_board.claims', priv)),
923
+ null,
924
+ 'PUBLIC holds NO privilege at all on claims -- no role gains a write through it');
925
+
926
+ -- THE SET FORM -- and why BOTH arms are needed, measured rather than assumed. The
927
+ -- has_table_privilege assertions above are a HAND LIST of two grantees (CLAUDE.md rule 1): an
928
+ -- INSERT granted to `anon`, or to a brand-new role, self-names NOWHERE in them. This arm is the
929
+ -- rule-1-correct shape -- the set of grantees holding any non-SELECT privilege, derived from the
930
+ -- ACL, so a new grantee FAILS THIS AND NAMES ITSELF. Neither subsumes the other: a grant to a
931
+ -- new role reds only this arm (mutation p), and a grant reaching a role through role MEMBERSHIP
932
+ -- reds only the effective arm on the subject that matters (mutation q).
933
+ --
934
+ -- The ACL is read through aclexplode(pg_class.relacl) rather than
935
+ -- information_schema.role_table_grants, because that view is filtered to roles the CURRENT user
936
+ -- can see -- a privilege-dependent visibility filter has no place inside a completeness claim.
937
+ -- `postgres` (the table owner) is the one exemption and it is stated POSITIVELY below.
938
+ select is(
939
+ (select coalesce(string_agg(distinct coalesce(g.rolname, 'PUBLIC') || ':' || a.privilege_type,
940
+ ', ' order by coalesce(g.rolname, 'PUBLIC') || ':' || a.privilege_type),
941
+ '(none)')
942
+ from pg_class c
943
+ cross join aclexplode(c.relacl) a
944
+ left join pg_roles g on g.oid = a.grantee
945
+ where c.oid = 'agent_board.claims'::regclass
946
+ and a.privilege_type <> 'SELECT'
947
+ and coalesce(g.rolname, 'PUBLIC') <> 'postgres'),
948
+ '(none)',
949
+ 'catalog: NO grantee holds a non-SELECT privilege on claims -- a new one names itself here');
950
+
951
+ select ok(
952
+ has_table_privilege('postgres', 'agent_board.claims', 'INSERT')
953
+ and has_table_privilege('postgres', 'agent_board.claims', 'DELETE'),
954
+ 'the one excluded grantee, postgres, is the table OWNER and does hold the full ACL as expected');
955
+
956
+ -- ============================================================================
957
+ -- 60. The over-revoke guard on the invariant itself
958
+ -- ============================================================================
959
+ -- The no-short-circuit rule belongs to the SUBJECT predicate (assertion 1). "Fixing" it by
960
+ -- deleting the short-circuit everywhere would break every live service_role caller, so the
961
+ -- session predicate must KEEP its own.
962
+ select is(
963
+ agent_board._current_agent_can_access_thread((select thread_plain from _csg)),
964
+ true,
965
+ 'the _is_service_role() short-circuit is PRESERVED in the session predicate where it belongs');
966
+
967
+ -- ============================================================================
968
+ -- 61-63. THE ONE ERROR CODE THIS CHANGE DOES ALTER -- stated as a decision, not hidden
969
+ -- ============================================================================
970
+ -- An earlier draft of the migration header claimed the gate "preserves every existing error
971
+ -- code". THAT IS FALSE. For a SERVICE_ROLE caller on a THREAD-BOUND claim, everything the gate
972
+ -- refuses used to run all the way to the INSERT and surface as a foreign-key violation.
973
+ -- Measured, by restoring the pre-fix definition (20260825000100) inside a rolled-back
974
+ -- transaction and calling both versions:
975
+ --
976
+ -- PRE-fix POST-fix
977
+ -- nonexistent p_thread_id, valid subject 23503 claims_thread_id_fkey -> 42501
978
+ -- nonexistent subject, valid thread 23503 claims_agent_id_fkey -> 42501
979
+ -- nonexistent subject, THREAD-LESS claim 23503 claims_agent_id_fkey -> 23503 (unchanged)
980
+ --
981
+ -- It is ONE rule with two observable instances, not two changes: the gate raises 42501 whenever
982
+ -- the named subject fails the predicate for the bound thread, and a nonexistent thread and a
983
+ -- nonexistent agent both make that predicate false. The third row is the negative control that
984
+ -- the change is confined to thread-BOUND claims -- the gate does not swallow the FK generally.
985
+ --
986
+ -- 23503 IS NOT RESTORED, DELIBERATELY. Preserving it would require probing thread existence
987
+ -- BEFORE the access gate, which tells a caller with no access whether a thread id exists.
988
+ -- 42501 is the better posture: it is the same answer for "no such thread" and "not your thread",
989
+ -- which is what a caller with no access should hear. `authenticated` is unaffected -- its
990
+ -- session check already answered 42501 for a nonexistent thread, because
991
+ -- _agent_can_access_thread is false for one.
992
+ --
993
+ -- MUTATION COVERAGE, measured rather than predicted -- an earlier draft of this paragraph said
994
+ -- mutation a reds all three, and it does NOT. `a` (delete the gate) reds 61 and 62, which fall
995
+ -- back to 23503. It leaves 63 GREEN, correctly: 63 is a CONTROL, and a thread-less claim
996
+ -- answers 23503 with or without the gate. What reds 63 is mutation `c` (make the gate
997
+ -- unconditional in thread presence), which drags the thread-less path into the gate and turns
998
+ -- its 23503 into a 42501. So 61/62 are pinned by a and 63 is pinned by c.
999
+ select throws_ok(
1000
+ $$ select * from agent_board.acquire_claim(
1001
+ (select agent_owner from _csg), 'file', 'csg-code-1',
1002
+ now() + interval '1 hour', null, '00000000-0000-0000-0000-0000000000ee'::uuid, '{}'::jsonb) $$,
1003
+ '42501',
1004
+ 'claim subject has no access to thread',
1005
+ 'CHANGED CODE: a nonexistent p_thread_id answers 42501 for service_role, where it used to reach the insert and answer 23503');
1006
+
1007
+ select throws_ok(
1008
+ $$ select * from agent_board.acquire_claim(
1009
+ '00000000-0000-0000-0000-0000000000dd'::uuid, 'file', 'csg-code-2',
1010
+ now() + interval '1 hour', null, (select thread_plain from _csg), '{}'::jsonb) $$,
1011
+ '42501',
1012
+ 'claim subject has no access to thread',
1013
+ 'CHANGED CODE: a nonexistent named SUBJECT answers 42501 too -- same rule, second instance');
1014
+
1015
+ select throws_ok(
1016
+ $$ select * from agent_board.acquire_claim(
1017
+ '00000000-0000-0000-0000-0000000000dd'::uuid, 'file', 'csg-code-3',
1018
+ now() + interval '1 hour', null, null, '{}'::jsonb) $$,
1019
+ '23503',
1020
+ 'insert or update on table "claims" violates foreign key constraint "claims_agent_id_fkey"',
1021
+ 'CONTROL: a THREAD-LESS claim for a nonexistent subject still answers 23503 -- the change is confined to thread-bound claims');
1022
+
1023
+ select * from finish();
1024
+
1025
+ rollback;