@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,624 @@
1
+ -- Consiliency/message-board#287 — a read receipt is a MEMO of access, never a SOURCE of it.
2
+ --
3
+ -- Two properties are pinned here and they pull in opposite directions, which is why they share
4
+ -- a file:
5
+ --
6
+ -- THE GATE a service_role caller may no longer name a THIRD PARTY in mark_read unless
7
+ -- that agent already passes the thread-access predicate. Before
8
+ -- 20260912000100, `mark_read(T, B, 0)` under service_role wrote a receipt for
9
+ -- an agent B with no relationship to T -- and because a receipt row is itself
10
+ -- an inherit-mode disjunct, B then PASSED the predicate. The memo minted a
11
+ -- grant.
12
+ --
13
+ -- THE GRANDFATHER the receipt disjunct STAYS, and it is permanent. Nothing deletes a
14
+ -- read_receipts row, so leaving a board or losing a task assignment does NOT
15
+ -- revoke access to a thread already read. "You keep what you saw." Two shipped
16
+ -- flows depend on it and are asserted POSITIVELY below, because they are what a
17
+ -- well-meaning cleanup of the disjunct would break.
18
+ --
19
+ -- The file runs as the postgres db role and simulates callers the way
20
+ -- agent_board_thread_acl.test.sql does: request.jwt.claim.role / .sub. Note that with NO role
21
+ -- claim, _is_service_role() is ALREADY true for session_user postgres, so the service_role
22
+ -- claim is set explicitly wherever it matters rather than relied on implicitly.
23
+ --
24
+ -- MUTATION EVIDENCE — measured, not predicted, and re-measured after each plan change.
25
+ -- All 36 assertions RAN in every row below (`ran=36`); a truncated plan is the failure mode
26
+ -- this table exists to rule out, and it is checked per row rather than inferred.
27
+ --
28
+ -- a delete the subject-gate `if` from mark_read
29
+ -- -> not ok 2, 3, 4 (the mint reopens), 24, 26 (the class assertion names it)
30
+ -- b re-add `_is_service_role() or` to _agent_can_access_thread
31
+ -- -> not ok 1, 2, 3, 4, 8, 24 -- the gate goes vacuous; assertion 1 exists for this
32
+ -- c revert the tasks disjunct to current_agent_id()
33
+ -- -> not ok 16, 17, 19 -- grandfather flow B collapses
34
+ -- d revert the board-delivery disjunct to _current_agent_has_board_access
35
+ -- -> not ok 8 -- the disjunct starts answering about the SESSION, not the subject
36
+ -- e remove the read_receipts disjunct entirely
37
+ -- -> not ok 13, 14, 15, 19 -- BOTH grandfather flows collapse
38
+ -- f1 a writer spelled `merge into agent_board.read_receipts`
39
+ -- -> not ok 25 (names it), 26, 27
40
+ -- f2 a writer spelled `update only agent_board.read_receipts`
41
+ -- -> not ok 25, 26
42
+ -- f3 a writer spelled `delete from only "agent_board" . "read_receipts"`
43
+ -- -> not ok 25, 26, 27
44
+ -- f4 the EXISTING member mark_read's own write respelled
45
+ -- `merge into only "agent_board"."read_receipts"` -- rule 2: mutate a member, do not only
46
+ -- add a new one
47
+ -- -> not ok 2, 3, 4, 24, 26, 27
48
+ -- g `grant insert on agent_board.read_receipts to public`
49
+ -- -> not ok 28, 30, 32, 33 -- both privilege arms
50
+ -- h add a receipt reaper (`delete from read_receipts`)
51
+ -- -> not ok 25, 26, 27
52
+ -- i `grant insert` to a BRAND-NEW role
53
+ -- -> not ok 33 only. has_table_privilege(service_role,INSERT) stayed FALSE, so the
54
+ -- effective arm alone would have shipped this green -- this is why the set form exists
55
+ -- j `grant insert` to `anon` (a live Supabase role the effective arm never names)
56
+ -- -> not ok 33 only
57
+ -- k `grant insert` to a role, then that role granted TO service_role (role MEMBERSHIP)
58
+ -- -> not ok 28, 33. The ACL names only the member role, so the set form reports the
59
+ -- wrong subject; has_table_privilege(service_role) = true is the one that answers
60
+ -- the real question -- this is why the effective arm exists
61
+ -- l a TRUNCATE-based receipt reaper (`truncate agent_board.read_receipts`)
62
+ -- -> not ok 27. Before `truncate` joined that alternation this was GREEN while the
63
+ -- assertion claimed permanence
64
+ -- l2 a TRUNCATE-based receipt reaper spelled `truncate table only agent_board.read_receipts`
65
+ -- -- ADDED under message-board#291, which found this hole in its own transplanted copy of
66
+ -- this pattern and fixed BOTH rather than leaving the known one on main
67
+ -- -> not ok 27. It was GREEN before that fix: the alternation was written
68
+ -- `truncate(\s+only)?\s+(table\s+)?`, i.e. ONLY before TABLE, but PostgreSQL's
69
+ -- syntax is `TRUNCATE [ TABLE ] [ ONLY ] name` -- TABLE FIRST. Measured on PG17:
70
+ -- `truncate agent_board.read_receipts` / `truncate only …` / `truncate table …` all
71
+ -- matched, and `truncate table only …` -- equally legal -- did NOT. A SECURITY
72
+ -- DEFINER reaper spelled that way shipped green past the assertion that claims the
73
+ -- receipt grandfather is permanent. The alternation is now
74
+ -- `(\s+table)?(\s+only)?`, which accepts both keywords in the legal order, either
75
+ -- alone, or neither.
76
+ -- m a writer that CALLS _agent_can_access_thread and IGNORES the result
77
+ -- -> not ok 25, 26. The previous assertion 26 predicate (a bare qualified call) MATCHED
78
+ -- this function, i.e. it would have read as gated and shipped GREEN
79
+ -- n `revoke select on read_receipts from service_role` (over-revoke guard)
80
+ -- -> not ok 29
81
+ -- o a writer on a DIFFERENT table named `read_receipts_archive`
82
+ -- -> ran=36, notok=0. A NEGATIVE pin: the guard must stay GREEN. Under the previous
83
+ -- unbounded pattern this function MATCHED, i.e. assertion 25 went red and named a
84
+ -- function that never touches read_receipts -- a false accusation.
85
+ -- o-control a REAL writer added in the same transaction as o
86
+ -- -> not ok 25, 26. Without this, o's green is indistinguishable from a pattern that
87
+ -- matches nothing at all; a negative pin needs a positive control beside it. -- permanence, hence the grandfather, would stop being true
88
+ --
89
+ -- TWO CONTRASTS THAT ARE THE POINT OF THE LAST REVISION, both measured directly:
90
+ -- * f1-f3 were each INVISIBLE to the previous regex. Evaluating the old and new patterns
91
+ -- over the same five spellings: the old matched only the plain
92
+ -- `insert into agent_board.read_receipts` control and missed all four others; the new
93
+ -- matched all five.
94
+ -- * under g, the direct-grantee query these assertions replaced still reported
95
+ -- service_role = 'SELECT' while has_table_privilege reported INSERT = true. The old
96
+ -- assertion 28 would have stayed GREEN while its own message was false.
97
+ --
98
+ -- Assertions 29 and 31 are the positive SELECT guards (mutation n), and 34 is the positive
99
+ -- owner-exemption guard: they red if the write-revoke is over-applied to the read path or if
100
+ -- the postgres exclusion above stops being an owner exemption.
101
+ --
102
+ -- i/j/k are the three-way proof that NEITHER privilege arm subsumes the other: i and j red
103
+ -- only the set form, k reds the effective form on the subject that matters. Dropping either
104
+ -- arm leaves a real route uncovered.
105
+
106
+ create extension if not exists pgtap with schema extensions;
107
+ set search_path = extensions, public, agent_board;
108
+
109
+ begin;
110
+
111
+ select plan(36);
112
+
113
+ create temp table _rsg (
114
+ thread_plain uuid,
115
+ thread_board uuid,
116
+ thread_task uuid,
117
+ board_id uuid,
118
+ agent_owner uuid,
119
+ agent_strange uuid,
120
+ agent_joiner uuid,
121
+ agent_reasgn uuid,
122
+ user_owner uuid,
123
+ user_strange uuid,
124
+ task_id uuid
125
+ ) on commit drop;
126
+
127
+ -- Receipt captures land in a table that EXISTS BEFORE any RPC runs. `create temp table _x as
128
+ -- select * from <rpc>(...)` inside lives_ok looks equivalent, but when the call raises, the
129
+ -- table is never created and the NEXT assertion -- a plain select from it -- errors and aborts
130
+ -- the whole file, so the remaining assertions never run (14 of 31, observed under mutation e).
131
+ -- lives_ok's subtransaction contains the raise; a missing row then reads as null and FAILS a
132
+ -- named assertion instead of killing the plan.
133
+ create temp table _rsg_receipt (
134
+ label text primary key,
135
+ thread_id uuid,
136
+ agent_id uuid,
137
+ last_read_message_sequence bigint,
138
+ last_read_at timestamptz
139
+ ) on commit drop;
140
+
141
+ do $$
142
+ declare
143
+ v_sfx text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
144
+ v_u_owner uuid;
145
+ v_u_stran uuid;
146
+ v_u_join uuid;
147
+ v_u_reasg uuid;
148
+ v_owner uuid := gen_random_uuid();
149
+ v_stranger uuid := gen_random_uuid();
150
+ v_joiner uuid := gen_random_uuid();
151
+ v_reasgn uuid := gen_random_uuid();
152
+ v_t_plain uuid := gen_random_uuid();
153
+ v_t_board uuid := gen_random_uuid();
154
+ v_t_task uuid := gen_random_uuid();
155
+ v_board uuid := gen_random_uuid();
156
+ v_memb uuid := gen_random_uuid();
157
+ v_msg uuid := gen_random_uuid();
158
+ v_task uuid := gen_random_uuid();
159
+ begin
160
+ v_u_owner := public.ensure_github_user('rsg-owner-' || v_sfx, 'rsg-owner-' || v_sfx || '@example.test', 'rsg-owner-' || v_sfx, 'RSG Owner');
161
+ v_u_stran := public.ensure_github_user('rsg-stran-' || v_sfx, 'rsg-stran-' || v_sfx || '@example.test', 'rsg-stran-' || v_sfx, 'RSG Stranger');
162
+ v_u_join := public.ensure_github_user('rsg-join-' || v_sfx, 'rsg-join-' || v_sfx || '@example.test', 'rsg-join-' || v_sfx, 'RSG Joiner');
163
+ v_u_reasg := public.ensure_github_user('rsg-reasg-' || v_sfx, 'rsg-reasg-' || v_sfx || '@example.test', 'rsg-reasg-' || v_sfx, 'RSG Reassigned');
164
+
165
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
166
+ values
167
+ (v_owner, 'agent', 'rsg-owner-' || v_sfx, 'RSG Owner', 'active', v_u_owner, v_u_owner),
168
+ (v_stranger, 'agent', 'rsg-stran-' || v_sfx, 'RSG Stranger', 'active', v_u_stran, v_u_stran),
169
+ (v_joiner, 'agent', 'rsg-join-' || v_sfx, 'RSG Joiner', 'active', v_u_join, v_u_join),
170
+ (v_reasgn, 'agent', 'rsg-reasg-' || v_sfx, 'RSG Reassigned', 'active', v_u_reasg, v_u_reasg);
171
+
172
+ -- Three inherit threads. last_message_sequence is set high so mark_read's
173
+ -- least(p_seq, last_message_sequence) never silently clamps a cursor assertion.
174
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, last_message_sequence)
175
+ values
176
+ (v_t_plain, 'coordination', v_owner, 'RSG plain thread', 9),
177
+ (v_t_board, 'coordination', v_owner, 'RSG board thread', 9),
178
+ (v_t_task, 'task', v_owner, 'RSG task thread', 9);
179
+
180
+ -- The board thread: a board-delivered message. The board-delivery disjunct keys on
181
+ -- delivery_recipients.board_id + the message's thread, NOT on recipient_agent_id, so the
182
+ -- late joiner reaches it through board membership alone -- which is the flow being pinned.
183
+ insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility)
184
+ values (v_board, 'rsg-board-' || v_sfx, 'RSG Board', v_owner, 'open');
185
+
186
+ insert into agent_board.board_memberships(id, board_id, agent_id, membership_state, role)
187
+ values (v_memb, v_board, v_owner, 'joined', 'owner');
188
+
189
+ insert into agent_board.messages(id, thread_id, author_agent_id, message_sequence, message_type, body)
190
+ values (v_msg, v_t_board, v_owner, 1, 'comment', 'board post');
191
+
192
+ insert into agent_board.delivery_recipients(delivery_mode, recipient_agent_id, source_kind, source_message_id, board_id, board_membership_id)
193
+ values ('board', v_owner, 'message', v_msg, v_board, v_memb);
194
+
195
+ -- The task thread: v_reasgn is the assignee (the task disjunct), and will be reassigned away.
196
+ insert into agent_board.tasks(id, thread_id, created_by_agent_id, assigned_agent_id, title, status, priority, orchestration_mode)
197
+ values (v_task, v_t_task, v_owner, v_reasgn, 'RSG task', 'pending', 'medium', 'distributed');
198
+
199
+ insert into _rsg values
200
+ (v_t_plain, v_t_board, v_t_task, v_board, v_owner, v_stranger, v_joiner, v_reasgn, v_u_owner, v_u_stran, v_task);
201
+ end
202
+ $$;
203
+
204
+ select set_config('request.jwt.claim.role', 'service_role', true);
205
+
206
+ -- ============================================================================
207
+ -- 1-4. THE GATE: service_role cannot mint a receipt for an agent with no access
208
+ -- ============================================================================
209
+ -- Assertion 1 is the SHORT-CIRCUIT INVARIANT and it is deliberately evaluated under a
210
+ -- service_role claim: _agent_can_access_thread must carry NO _is_service_role() bypass, or the
211
+ -- gate below is vacuous while every allow-side assertion in this file stays green.
212
+ select is(
213
+ agent_board._agent_can_access_thread(
214
+ (select thread_plain from _rsg), (select agent_strange from _rsg)),
215
+ false,
216
+ 'invariant: _agent_can_access_thread answers about the SUBJECT under a service_role claim (no short-circuit)');
217
+
218
+ select throws_ok(
219
+ $$ select * from agent_board.mark_read(
220
+ (select thread_plain from _rsg), (select agent_strange from _rsg), 0) $$,
221
+ '42501',
222
+ 'read receipt subject has no access to thread',
223
+ 'service_role CANNOT mint a receipt for an agent with no access to the thread');
224
+
225
+ select is(
226
+ (select count(*)::int from agent_board.read_receipts rr
227
+ where rr.thread_id = (select thread_plain from _rsg)
228
+ and rr.agent_id = (select agent_strange from _rsg)),
229
+ 0,
230
+ 'the refused call wrote NO read_receipts row for the named stranger');
231
+
232
+ select is(
233
+ agent_board._agent_can_access_thread(
234
+ (select thread_plain from _rsg), (select agent_strange from _rsg)),
235
+ false,
236
+ 'the stranger still has no thread access after the refused call (the false->true transition is closed)');
237
+
238
+ -- ============================================================================
239
+ -- 5-7. The allow side: an agent that DOES have access still gets its receipt
240
+ -- ============================================================================
241
+ -- #113: the call goes through lives_ok and the assertions read the captured row. A bare
242
+ -- is((select ... from mark_read(...))) would ABORT the whole file on a raise, emitting no
243
+ -- `not ok` -- an over-refusing gate would then look like a pass.
244
+ select lives_ok(
245
+ $$ insert into _rsg_receipt
246
+ select 'owner', * from agent_board.mark_read(
247
+ (select thread_plain from _rsg), (select agent_owner from _rsg), 1) $$,
248
+ 'service_role marks read for the thread creator [call succeeds]');
249
+
250
+ select is(
251
+ (select agent_id from _rsg_receipt where label = 'owner'),
252
+ (select agent_owner from _rsg),
253
+ 'the receipt subject is the named agent');
254
+
255
+ select is(
256
+ (select count(*)::int from agent_board.read_receipts rr
257
+ where rr.thread_id = (select thread_plain from _rsg)
258
+ and rr.agent_id = (select agent_owner from _rsg)),
259
+ 1,
260
+ 'a receipt row IS written for an agent that already has access');
261
+
262
+ -- ============================================================================
263
+ -- 8-15. GRANDFATHER FLOW A — the late board joiner
264
+ -- ============================================================================
265
+ -- Joins a board after the history was posted, reads a thread, then leaves. The board-delivery
266
+ -- disjunct evaporates on leaving; the receipt is what keeps the thread readable.
267
+ select is(
268
+ agent_board._agent_can_access_thread(
269
+ (select thread_board from _rsg), (select agent_joiner from _rsg)),
270
+ false,
271
+ 'flow A precondition: before joining the board, the joiner has no access to the board thread');
272
+
273
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, role)
274
+ select board_id, agent_joiner, 'joined', 'member' from _rsg;
275
+
276
+ select is(
277
+ agent_board._agent_can_access_thread(
278
+ (select thread_board from _rsg), (select agent_joiner from _rsg)),
279
+ true,
280
+ 'flow A: a late joiner reaches the board thread through the board-delivery disjunct');
281
+
282
+ select lives_ok(
283
+ $$ insert into _rsg_receipt
284
+ select 'joiner', * from agent_board.mark_read(
285
+ (select thread_board from _rsg), (select agent_joiner from _rsg), 2) $$,
286
+ 'flow A: service_role marks read on behalf of the late joiner [call succeeds]');
287
+
288
+ select is(
289
+ (select last_read_message_sequence from _rsg_receipt where label = 'joiner'),
290
+ 2::bigint,
291
+ 'flow A: the joiner receipt records the cursor it was given');
292
+
293
+ update agent_board.board_memberships
294
+ set membership_state = 'left', left_at = now()
295
+ where board_id = (select board_id from _rsg)
296
+ and agent_id = (select agent_joiner from _rsg);
297
+
298
+ select is(
299
+ agent_board._agent_has_board_access(
300
+ (select board_id from _rsg), (select agent_joiner from _rsg)),
301
+ false,
302
+ 'flow A: after leaving, the board disjunct no longer holds for the joiner');
303
+
304
+ select is(
305
+ agent_board._agent_can_access_thread(
306
+ (select thread_board from _rsg), (select agent_joiner from _rsg)),
307
+ true,
308
+ 'GRANDFATHER A: leaving the board does NOT revoke access to a thread already read');
309
+
310
+ select lives_ok(
311
+ $$ insert into _rsg_receipt
312
+ select 'joiner-after-leaving', * from agent_board.mark_read(
313
+ (select thread_board from _rsg), (select agent_joiner from _rsg), 5) $$,
314
+ 'GRANDFATHER A: the cursor still advances on grandfathered access [call succeeds]');
315
+
316
+ select is(
317
+ (select last_read_message_sequence from _rsg_receipt where label = 'joiner-after-leaving'),
318
+ 5::bigint,
319
+ 'GRANDFATHER A: the subject gate accepts the receipt disjunct as the subject''s access');
320
+
321
+ -- ============================================================================
322
+ -- 16-19. GRANDFATHER FLOW B — the reassigned task assignee
323
+ -- ============================================================================
324
+ select is(
325
+ agent_board._agent_can_access_thread(
326
+ (select thread_task from _rsg), (select agent_reasgn from _rsg)),
327
+ true,
328
+ 'flow B precondition: the assignee reaches the task thread through the task disjunct');
329
+
330
+ select lives_ok(
331
+ $$ insert into _rsg_receipt
332
+ select 'assignee', * from agent_board.mark_read(
333
+ (select thread_task from _rsg), (select agent_reasgn from _rsg), 3) $$,
334
+ 'flow B: service_role marks read on behalf of the assignee [call succeeds]');
335
+
336
+ update agent_board.tasks
337
+ set assigned_agent_id = (select agent_owner from _rsg)
338
+ where id = (select task_id from _rsg);
339
+
340
+ select is(
341
+ (select exists (
342
+ select 1 from agent_board.tasks t
343
+ where t.thread_id = (select thread_task from _rsg)
344
+ and (t.created_by_agent_id = (select agent_reasgn from _rsg)
345
+ or t.assigned_agent_id = (select agent_reasgn from _rsg)))),
346
+ false,
347
+ 'flow B: after reassignment the task disjunct no longer holds for the former assignee');
348
+
349
+ select is(
350
+ agent_board._agent_can_access_thread(
351
+ (select thread_task from _rsg), (select agent_reasgn from _rsg)),
352
+ true,
353
+ 'GRANDFATHER B: losing the task assignment does NOT revoke access to a thread already read');
354
+
355
+ -- ============================================================================
356
+ -- 20-23. The authenticated path is unchanged
357
+ -- ============================================================================
358
+ select set_config('request.jwt.claim.role', 'authenticated', true);
359
+ select set_config('request.jwt.claim.sub', (select user_owner from _rsg)::text, true);
360
+
361
+ select throws_ok(
362
+ $$ select * from agent_board.mark_read(
363
+ (select thread_plain from _rsg), (select agent_strange from _rsg), 1) $$,
364
+ '42501',
365
+ 'p_agent_id must match current_agent_id()',
366
+ 'authenticated still cannot name a third party at all -- refused before the subject gate');
367
+
368
+ select lives_ok(
369
+ $$ insert into _rsg_receipt
370
+ select 'authenticated-self', * from agent_board.mark_read(
371
+ (select thread_plain from _rsg), (select agent_owner from _rsg), 4) $$,
372
+ 'authenticated marks its OWN read unchanged [call succeeds]');
373
+
374
+ select is(
375
+ (select last_read_message_sequence from _rsg_receipt where label = 'authenticated-self'),
376
+ 4::bigint,
377
+ 'authenticated cursor advances exactly as before');
378
+
379
+ select is(
380
+ agent_board._current_agent_can_access_thread((select thread_plain from _rsg)),
381
+ true,
382
+ 'the recomposed _current_agent_can_access_thread still admits the creator under authenticated');
383
+
384
+ select set_config('request.jwt.claim.sub', (select user_strange from _rsg)::text, true);
385
+
386
+ select is(
387
+ agent_board._current_agent_can_access_thread((select thread_plain from _rsg)),
388
+ false,
389
+ 'the recomposed _current_agent_can_access_thread still denies an unrelated agent under authenticated');
390
+
391
+ -- ============================================================================
392
+ -- 25-36. Catalog-derived class + the invariants that keep the gate real
393
+ -- ============================================================================
394
+ select set_config('request.jwt.claim.role', 'service_role', true);
395
+ select set_config('request.jwt.claim.sub', '', true);
396
+
397
+ -- The completeness claim is "mark_read is the ONLY route to a read_receipts row", and it is
398
+ -- backed from BOTH sides: the catalog function scan below, and the effective-privilege
399
+ -- assertions after it (no role holds a write privilege, by ANY route, so a direct write is
400
+ -- impossible and the SECURITY DEFINER bodies are the whole surface).
401
+ --
402
+ -- WHAT THE SCAN COVERS, stated exactly rather than generously. It is a REGEX over stripped
403
+ -- function source, so its completeness is the completeness of the pattern, and a spelling the
404
+ -- pattern does not know is a SILENT miss -- not a loud one. An earlier version of this comment
405
+ -- claimed "a wider scan can only produce a LOUD false positive, never a silent miss"; that
406
+ -- argument is about WIDENING and says nothing about the NARROWING gaps the pattern had. It
407
+ -- did, and these are VERIFIED-LEGAL spellings on PG17, not hypotheticals: `update only
408
+ -- read_receipts`, `delete from only read_receipts`, `merge into read_receipts` and `merge into
409
+ -- only read_receipts` (MERGE was absent from the alternation entirely), `insert into
410
+ -- agent_board."read_receipts"`, `"agent_board" . "read_receipts"`. Each was invisible and left
411
+ -- the assertion green. (`insert into only` is NOT one of them -- ONLY is not accepted after
412
+ -- INSERT INTO. The pattern tolerates it anyway because refusing it would buy nothing; the
413
+ -- claim here is confined to spellings Postgres actually parses.) The pattern below now covers
414
+ -- ONLY, MERGE, double-quoted identifier parts and whitespace around the schema dot, and the
415
+ -- mutations exercise them against a REAL member as well as a new one.
416
+ --
417
+ -- ONE MORE WAS MISSING AND IS NOW COVERED (message-board#291). The permanence scan below spelled
418
+ -- its TRUNCATE arm `truncate(\s+only)?\s+(table\s+)?`, which has PostgreSQL's keyword order
419
+ -- BACKWARDS: the syntax is `TRUNCATE [ TABLE ] [ ONLY ] name`, TABLE first. So
420
+ -- `truncate table only agent_board.read_receipts` -- legal, verified on PG17 -- was invisible,
421
+ -- and a reaper spelled that way would have shipped green past the assertion claiming the
422
+ -- grandfather is permanent. Fixed here rather than on the branch that found it, because the
423
+ -- defect is identical in both files and leaving the known one on main is worse than the scope
424
+ -- mix. Mutation l2 pins it.
425
+ --
426
+ -- It still is not a parser, and it HAD one known blind spot, now closed by message-board#293.
427
+ -- A function declared with an SQL-standard `BEGIN ATOMIC` body keeps its body in
428
+ -- pg_proc.prosqlbody and leaves prosrc EMPTY -- verified directly on this cluster: a BEGIN
429
+ -- ATOMIC function reading read_receipts showed prosrc empty, prosqlbody non-null, and
430
+ -- stripped_body ''. A writer written that way was invisible to assertions 25, 26 and 27 alike.
431
+ --
432
+ -- It was fixed in the SHARED helper (tests/fixtures/test-prereqs.sql), not here: stripped_body
433
+ -- had a LIVE use in 22 test files including this one (counted from the catalog of files, not
434
+ -- estimated; #293's own text said 19 and an earlier draft of this comment guessed "roughly
435
+ -- ten"), so a local patch would have fixed one caller and left 21 open -- the partial-fix shape
436
+ -- CLAUDE.md rule 1 bans. stripped_body now falls back to the DEPARSED prosqlbody and RAISES on
437
+ -- a body it can read by neither route, so an unreadable body is loud instead of silently
438
+ -- matching nothing. Pinned by SQLBODY-1..-11 in agent_board_source_strip_helper.test.sql,
439
+ -- SQLBODY-11 being an exact-set writer scan of this file's own shape.
440
+ --
441
+ -- What that does NOT buy: for such a function these regexes read Postgres' DEPARSE of the
442
+ -- parse tree rather than the author's text, and no agent_board function is written that way
443
+ -- today, so the shape remains UNEXERCISED against real schema code -- a fact about today's
444
+ -- catalog, not a property of the scan.
445
+ --
446
+ -- What keeps the completeness claim honest despite all of the above is the SECOND LEG: no
447
+ -- grantee holds a write privilege on the table by ANY route (assertions 29, 31, 32, 33), so an
448
+ -- unmatched writer would have to be a SECURITY DEFINER function -- exactly the population this
449
+ -- scans -- and the exact-set shape means a widened pattern fails LOUDLY and names what it
450
+ -- caught rather than passing.
451
+ --
452
+ -- THE RIGHT-HAND BOUNDARY, and why it is an ALTERNATION rather than a trailing \M. Without a
453
+ -- boundary the pattern also matched `read_receipts_archive` -- a DIFFERENT table whose name
454
+ -- merely starts with this one. That direction is fail-CLOSED (a spurious member reds the
455
+ -- exact-set assertion and names a function, rather than hiding one), so it never threatened
456
+ -- the completeness claim. It is fixed anyway for a second-order reason: a future author who
457
+ -- adds a read_receipts_archive table would get a FALSE accusation from this guard, and the
458
+ -- natural response to a guard that cries wolf is to loosen it. That is how a real guard dies.
459
+ --
460
+ -- `_` is a word character in Postgres regex, so \M (end-of-word) correctly refuses
461
+ -- `read_receipts_archive`. The trap is the QUOTED form: with a MANDATORY closing quote,
462
+ -- `"read_receipts"\M` FAILS, because after `"` no word ends there -- verified directly.
463
+ -- Writing `"?read_receipts"?\M` happens to work, but only because the optional `"?`
464
+ -- BACKTRACKS to empty so \M lands between `s` and `"` -- i.e. on the quoted form the pattern
465
+ -- silently stops requiring the closing quote at all. Correct today, and far too subtle to
466
+ -- leave for the next editor. The alternation states the two cases separately: unquoted ends
467
+ -- at \M, quoted ends at its own mandatory closing quote, which IS the boundary.
468
+ --
469
+ -- Verified over 14 spellings, 8 that MUST match and 6 that MUST NOT (`read_receipts_archive`
470
+ -- bare / schema-qualified / quoted, `read_receipts2`, `old_read_receipts`, and a plain SELECT,
471
+ -- which is a read and not a write).
472
+ --
473
+ -- The scan keeps STRING LITERALS (stripped_body's second argument) on purpose: a writer
474
+ -- assembled as dynamic SQL lives in a literal. Comments are still stripped, so prose naming
475
+ -- the pattern cannot satisfy it.
476
+ select is(
477
+ (select coalesce(string_agg(p.proname, ',' order by p.proname), '(none)')
478
+ from pg_proc p
479
+ where p.pronamespace = 'agent_board'::regnamespace
480
+ and lower(agent_board_test.stripped_body(p.oid, true))
481
+ ~ '(insert\s+into|merge\s+into|update|delete\s+from)(\s+only)?\s+(("?agent_board"?\s*\.\s*)?read_receipts\M|("?agent_board"?\s*\.\s*)?"read_receipts")'),
482
+ 'mark_read',
483
+ 'catalog: mark_read is the ONLY agent_board function that writes read_receipts -- a new writer names itself here');
484
+
485
+ select is(
486
+ (select coalesce(string_agg(p.proname, ',' order by p.proname), '(none)')
487
+ from pg_proc p
488
+ where p.pronamespace = 'agent_board'::regnamespace
489
+ and lower(agent_board_test.stripped_body(p.oid, true))
490
+ ~ '(insert\s+into|merge\s+into|update|delete\s+from)(\s+only)?\s+(("?agent_board"?\s*\.\s*)?read_receipts\M|("?agent_board"?\s*\.\s*)?"read_receipts")'
491
+ and lower(agent_board_test.stripped_body(p.oid))
492
+ !~ 'not\s+agent_board\._agent_can_access_thread\s*\('),
493
+ '(none)',
494
+ 'catalog: every read_receipts writer contains a REFUSING reference to _agent_can_access_thread (source shape only -- assertion 2 is what proves it refuses)');
495
+
496
+ -- WHAT ASSERTION 26 DOES AND DOES NOT ESTABLISH. It matches `not
497
+ -- agent_board._agent_can_access_thread(` in the writer's body -- a source SHAPE, so it sees
498
+ -- the refusing form rather than a bare mention, but it still cannot see whether the branch
499
+ -- raises, or whether the result is discarded. Its message used to say every writer "gates its
500
+ -- named subject", which is more than a regex can know: that is the "comment claims what no
501
+ -- assertion does" tell. The message now says what it checks. The BEHAVIOUR -- that the gate
502
+ -- actually refuses -- is assertion 2, which calls mark_read for a subject with no access and
503
+ -- requires the 42501; mutation a (deleting the gate) reds 2 and 26 together, so the pair is
504
+ -- not redundant: 26 is the CLASS half (a NEW writer that skips the gate) and 2 is the
505
+ -- BEHAVIOURAL half (this writer really refuses).
506
+
507
+ -- The permanence half of the grandfather, asserted rather than asserted-about: if a future
508
+ -- migration adds a receipt reaper, "you keep what you saw" stops being true and this names it.
509
+ -- TRUNCATE is in the alternation because it removes every row without a DELETE: the assertion
510
+ -- claims permanence, so scanning only `delete from` would have claimed more than it checked.
511
+ select is(
512
+ (select coalesce(string_agg(p.proname, ',' order by p.proname), '(none)')
513
+ from pg_proc p
514
+ where p.pronamespace = 'agent_board'::regnamespace
515
+ and lower(agent_board_test.stripped_body(p.oid, true))
516
+ ~ '(delete\s+from|merge\s+into|truncate)(\s+table)?(\s+only)?\s+(("?agent_board"?\s*\.\s*)?read_receipts\M|("?agent_board"?\s*\.\s*)?"read_receipts")'),
517
+ '(none)',
518
+ 'catalog: no agent_board function deletes or truncates a read receipt -- the grandfather is permanent by construction');
519
+
520
+ -- EFFECTIVE privilege, not the direct grant. information_schema.role_table_grants filtered on
521
+ -- `grantee = '<role>'` shows only grants made TO THAT NAME, so `grant insert on
522
+ -- agent_board.read_receipts to public` hands service_role a working INSERT while a
523
+ -- direct-grantee query stays green -- and the assertion's own message ("SELECT and only
524
+ -- SELECT") would then be false. That is the same PUBLIC-privilege fail-open class as #118, and
525
+ -- the first version of these two assertions had it. has_table_privilege resolves PUBLIC and
526
+ -- role membership, so it answers the question the message actually asks. Mutation g
527
+ -- (`grant insert ... to public`) reds assertions 29 and 31 and would NOT have reded the
528
+ -- direct-grantee form -- that contrast is the whole reason for the change.
529
+ select is(
530
+ (select string_agg(priv, ',' order by priv)
531
+ from unnest(array['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES']) as priv
532
+ where has_table_privilege('service_role', 'agent_board.read_receipts', priv)),
533
+ null,
534
+ 'service_role holds NO effective write privilege on read_receipts, by any route incl. PUBLIC');
535
+
536
+ select ok(
537
+ has_table_privilege('service_role', 'agent_board.read_receipts', 'SELECT'),
538
+ 'service_role does hold SELECT on read_receipts (the read path is not over-revoked)');
539
+
540
+ select is(
541
+ (select string_agg(priv, ',' order by priv)
542
+ from unnest(array['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES']) as priv
543
+ where has_table_privilege('authenticated', 'agent_board.read_receipts', priv)),
544
+ null,
545
+ 'authenticated holds NO effective write privilege on read_receipts, by any route incl. PUBLIC');
546
+
547
+ select ok(
548
+ has_table_privilege('authenticated', 'agent_board.read_receipts', 'SELECT'),
549
+ 'authenticated does hold SELECT on read_receipts (the read path is not over-revoked)');
550
+
551
+ -- The PUBLIC pseudo-role directly: `=arwd` on the table would give EVERY role a write, present
552
+ -- and future, including roles this file does not name.
553
+ select is(
554
+ (select string_agg(priv, ',' order by priv)
555
+ from unnest(array['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'SELECT']) as priv
556
+ where has_table_privilege('public', 'agent_board.read_receipts', priv)),
557
+ null,
558
+ 'PUBLIC holds NO privilege at all on read_receipts -- no role gains a write through it');
559
+
560
+ -- THE SET FORM -- and why BOTH arms are needed, measured rather than assumed.
561
+ --
562
+ -- The has_table_privilege assertions above are a HAND LIST of two grantees (CLAUDE.md rule 1):
563
+ -- an INSERT granted to `anon`, or to a brand-new role, self-names NOWHERE in them. This arm is
564
+ -- the rule-1-correct shape -- the set of grantees holding any non-SELECT privilege on the
565
+ -- table, derived from the ACL, so a new grantee FAILS THIS AND NAMES ITSELF.
566
+ --
567
+ -- Neither arm subsumes the other. Measured on this cluster, granting INSERT to a new role
568
+ -- `rsg_newrole`:
569
+ -- set form -> rsg_newrole:INSERT (RED)
570
+ -- has_table_privilege(service_role,INSERT) -> false (still green)
571
+ -- and granting INSERT to PUBLIC:
572
+ -- set form -> PUBLIC:INSERT (RED)
573
+ -- has_table_privilege(service_role,INSERT) -> true (RED)
574
+ -- PUBLIC happens to appear in the ACL as its own grantee, so the set form catches that case
575
+ -- too; what it CANNOT catch is privilege reaching a role through ROLE MEMBERSHIP, where the
576
+ -- ACL names only the member role and never service_role:
577
+ -- grant insert to rsg_newrole; grant rsg_newrole to service_role;
578
+ -- set form -> rsg_newrole:INSERT (names the wrong thing)
579
+ -- has_table_privilege(service_role,INSERT) -> true (names the real answer)
580
+ -- So: the set form answers "has anyone been granted a write", the effective form answers "can
581
+ -- THESE two roles write". Both, or a route is uncovered.
582
+ --
583
+ -- The ACL is read through aclexplode(pg_class.relacl) rather than
584
+ -- information_schema.role_table_grants, because that view is filtered to roles the CURRENT
585
+ -- user can see -- a privilege-dependent visibility filter has no place inside a completeness
586
+ -- claim. `postgres` (the table owner, whose full ownership ACL is expected) is the one
587
+ -- exemption, and it is stated POSITIVELY below rather than by absence of a match.
588
+ select is(
589
+ (select coalesce(string_agg(distinct coalesce(g.rolname, 'PUBLIC') || ':' || a.privilege_type,
590
+ ', ' order by coalesce(g.rolname, 'PUBLIC') || ':' || a.privilege_type),
591
+ '(none)')
592
+ from pg_class c
593
+ cross join aclexplode(c.relacl) a
594
+ left join pg_roles g on g.oid = a.grantee
595
+ where c.oid = 'agent_board.read_receipts'::regclass
596
+ and a.privilege_type <> 'SELECT'
597
+ and coalesce(g.rolname, 'PUBLIC') <> 'postgres'),
598
+ '(none)',
599
+ 'catalog: NO grantee holds a non-SELECT privilege on read_receipts -- a new one names itself here');
600
+
601
+ -- The owner exemption, asserted positively: `postgres` is expected to hold the full ownership
602
+ -- ACL, and saying so is what keeps the exclusion above from being a place to hide a grantee.
603
+ select ok(
604
+ has_table_privilege('postgres', 'agent_board.read_receipts', 'INSERT')
605
+ and has_table_privilege('postgres', 'agent_board.read_receipts', 'DELETE'),
606
+ 'the one excluded grantee, postgres, is the table OWNER and does hold the full ACL as expected');
607
+
608
+ -- The board helper carries the same no-short-circuit invariant, for the same reason.
609
+ select is(
610
+ agent_board._agent_has_board_access(
611
+ (select board_id from _rsg), (select agent_strange from _rsg)),
612
+ false,
613
+ 'invariant: _agent_has_board_access answers about the SUBJECT under a service_role claim (no short-circuit)');
614
+
615
+ -- Over-revoke guard: the short-circuit belongs in the SESSION predicate and must stay there.
616
+ -- "Fixing" the invariant by deleting it everywhere would break every live service_role caller.
617
+ select is(
618
+ agent_board._current_agent_can_access_thread((select thread_plain from _rsg)),
619
+ true,
620
+ 'the _is_service_role() short-circuit is PRESERVED in the session predicate where it belongs');
621
+
622
+ select * from finish();
623
+
624
+ rollback;