@consiliency/agent-board-schema 1.22.0 → 1.24.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 (65) hide show
  1. package/README.md +75 -1
  2. package/fixtures/already-applied.json +40 -0
  3. package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
  4. package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
  5. package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
  6. package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
  7. package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
  8. package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
  9. package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
  10. package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
  11. package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
  12. package/migrations/20260905000100_agent_board_consent_gating_reconcile.sql +151 -0
  13. package/package.json +1 -1
  14. package/schema-manifest.json +93 -3
  15. package/src/check.js +23 -0
  16. package/src/index.js +20 -0
  17. package/src/plan.js +79 -5
  18. package/src/tooling.test.mjs +237 -4
  19. package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
  20. package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
  21. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
  22. package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
  23. package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
  24. package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
  25. package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
  26. package/tests/agent_board_fedsession.test.sql +46 -2
  27. package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
  28. package/tests/agent_board_inbox_reads.test.sql +16 -19
  29. package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
  30. package/tests/agent_board_portal_offline_invariant.test.sql +1 -1
  31. package/tests/agent_board_roomlink.test.sql +2 -2
  32. package/tests/agent_board_wake_recovery.test.sql +756 -0
  33. package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
  34. package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
  35. package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
  36. package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
  37. package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
  38. package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
  39. package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
  40. package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
  41. package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
  42. package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
  43. package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
  44. package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
  45. package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
  46. package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
  47. package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
  48. package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
  49. package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
  50. package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
  51. package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
  52. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
  53. package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
  54. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
  55. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
  56. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
  57. package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
  58. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
  59. package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
  60. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
  61. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
  62. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
  63. package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
  64. package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
  65. package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
@@ -0,0 +1,756 @@
1
+ -- WAKERECOVERY / message-board#191 — the gap-safe wake recovery surface.
2
+ --
3
+ -- WHAT THIS FILE PINS, and why each assertion is shaped the way it is:
4
+ --
5
+ -- * WR-1 EXISTENCE — the view exists and the exact string the runtime's fetch
6
+ -- descriptor names (`agent_board.v_agent_wake_recovery`) resolves to a VIEW.
7
+ -- Renaming the view in the migration without renaming it in the descriptor
8
+ -- fails here.
9
+ -- * WR-2 INVOKER + SHAPE -- `security_invoker = true` read from `reloptions`,
10
+ -- not inferred from the DDL text. WR-2 pins the DECLARATION and NOTHING MORE,
11
+ -- and the earlier claim here that it also bounded the blast radius was WRONG.
12
+ -- The full four-cell matrix, measured with the scoping predicate deleted and
13
+ -- read as agent A (co-member / stranger rows returned):
14
+ --
15
+ -- wake flag v_agent_inbox flag co-member stranger
16
+ -- invoker invoker 1 0
17
+ -- reset invoker 1 0
18
+ -- invoker reset 1 1
19
+ -- reset reset 1 1
20
+ --
21
+ -- THIS view's flag is inert in every cell. The bound is carried entirely by
22
+ -- `v_agent_inbox`'s flag, because an inner `security_invoker` view re-anchors
23
+ -- base-table RLS to the real querying user through any outer definer view.
24
+ -- My earlier note said "on this view OR on v_agent_inbox" and attributed the
25
+ -- bound to a flag that does not carry it -- corrected from the measurement,
26
+ -- which is the third time this paragraph has been rewritten by a probe.
27
+ -- The flag is still declared and still asserted, because it becomes
28
+ -- load-bearing the moment this view is rebuilt directly on `inbox_items`.
29
+ -- The flag that DOES carry the bound is pinned in
30
+ -- agent_board_inbox_reads.test.sql, now as a catalog-derived class over every
31
+ -- agent_board view rather than a nine-name list.
32
+ -- The column list is compared to `v_agent_inbox`'s FROM THE CATALOG on both
33
+ -- sides, so the recovery surface cannot silently diverge from its source.
34
+ -- * WR-3 GRANTS — asserted as a per-role privilege EQUALITY against
35
+ -- `v_agent_inbox` (has_table_privilege for each of anon/authenticated/
36
+ -- service_role, on each of SELECT/INSERT/UPDATE/DELETE), never a hand-copied
37
+ -- grant list. `v_agent_inbox`'s own grants are split across TWO migrations
38
+ -- (authenticated in 20260507000000, service_role in 20260508000001); an
39
+ -- equality against the catalog absorbs that automatically. PUBLIC is checked
40
+ -- separately through the ACL, because there is no role named `public` to ask.
41
+ -- * WR-4 NO CURSOR — the view definition contains no comparison between a
42
+ -- pre-commit-assigned column (`seq`, `created_at`, `updated_at`) and anything,
43
+ -- and no ORDER BY. `pg_get_viewdef` is regenerated from the parse tree, so
44
+ -- comments and formatting are gone by construction: unlike a `prosrc` scan,
45
+ -- this cannot be defeated by prose.
46
+ -- * WR-5 INDEX AGREEMENT — the view's `action_state` set equals the predicate
47
+ -- set of the existing partial index `idx_agent_board_inbox_items_agent_pending`,
48
+ -- both extracted from the catalog. Editing either alone fails. Both sides are
49
+ -- coalesced to DISTINCT sentinels so a regex that stops matching cannot pass
50
+ -- as NULL = NULL.
51
+ -- * WR-6 BEHAVIOUR + SCOPING — the view returns exactly the unhandled items and
52
+ -- nothing else (including inheriting `visible_in_inbox`), and under a real
53
+ -- authenticated session a CO-MEMBER's board-delivered item is absent while a
54
+ -- companion assertion proves that same row IS visible through `v_agent_inbox`.
55
+ -- The pair is what makes the exclusion a measurement rather than an empty
56
+ -- fixture. Deleting the scoping predicate turns them red.
57
+ -- * WR-7 CLASS — catalog-derived, per repo rule #1. The member set comes from
58
+ -- the transitive view closure over `inbox_items` / `callback_outbox_events`
59
+ -- (pg_depend/pg_rewrite) plus every non-internal `agent_board` function whose
60
+ -- definition reaches those tables — NOT from a list written here. A new object
61
+ -- that gates a returned batch on a pre-commit-assigned value fails this and
62
+ -- NAMES ITSELF. The enumeration is separately asserted to REACH the members it
63
+ -- must, so the class cannot pass by enumerating nothing.
64
+ -- * WR-8 / WR-9 POSITIVE — the one member the class scan names
65
+ -- (`mark_retention_metadata`) is asserted POSITIVELY to be what it claims:
66
+ -- a retention age sweep that returns a count and no batch, unreachable by
67
+ -- authenticated. And the two recovery-shaped members are positively shown to
68
+ -- partition on STATUS. Exemptions asserted by absence are where the hole hides.
69
+ --
70
+ -- WHAT THIS FILE DELIBERATELY DOES NOT ASSERT: that a late-committing writer is
71
+ -- recovered. That is a CROSS-SESSION fact — a single rolled-back transaction
72
+ -- cannot produce the interleaving at all — and it is pinned in
73
+ -- tests/isolation/specs/wake_recovery_late_commit.spec. Saying which probe owns
74
+ -- which property stops each from being assumed to cover the other.
75
+ --
76
+ -- The one-pass literal/comment strip used by WR-7 is a SINGLE alternation
77
+ -- (`'…'` | `--…` | `/*…*/`) applied in one regexp_replace. Stripping literals and
78
+ -- then comments in two passes desynchronises on a dollar quote or an odd
79
+ -- apostrophe count; leftmost matching over one alternation cannot.
80
+ --
81
+ -- Allow-side calls are wrapped in lives_ok(...) with a SEPARATE state check
82
+ -- (message-board#113): a bare `is((select ... from rpc(...)), ...)` aborts the
83
+ -- transaction on a raise, pgTAP emits NO `not ok`, and every later assertion in
84
+ -- the file silently does not run.
85
+
86
+ create extension if not exists pgtap with schema extensions;
87
+ set search_path = extensions, public, agent_board;
88
+
89
+ begin;
90
+
91
+ select plan(35);
92
+
93
+ -- ---------------------------------------------------------------------------
94
+ -- Shared helpers. Both are temp objects and vanish with the rollback.
95
+ -- ---------------------------------------------------------------------------
96
+
97
+ -- ONE-PASS strip of string literals, line comments, and block comments.
98
+ create or replace function pg_temp._wr_strip(p_text text) returns text
99
+ language sql immutable as $fn$
100
+ select regexp_replace(
101
+ coalesce($1, ''),
102
+ $re$'(?:[^']|'')*'|--[^\n]*|/\*(?:[^*]|\*+[^*/])*\*+/$re$,
103
+ ' ', 'g')
104
+ $fn$;
105
+
106
+ -- A "cursor gate" is a range test against a PRE-COMMIT-ASSIGNED column.
107
+ --
108
+ -- BOTH INPUTS ARE NOW DERIVED FROM THE CATALOG, not written here. The previous
109
+ -- version hard-coded the column names AND the relation names; a name filter is a
110
+ -- hand list wearing SQL, in the file whose whole job is to assert a class. Its
111
+ -- concrete cost, named by the round-2 board: a function reading `v_board_feed` and
112
+ -- gating on `first_delivered_at` -- which is `min(ii.created_at) OVER (...)`, i.e.
113
+ -- the same pre-commit value under another name -- was outside the class entirely,
114
+ -- on BOTH axes at once.
115
+ --
116
+ -- * the RELATION set is the dependency closure over the two base tables
117
+ -- (pg_depend/pg_rewrite), so a new view over inbox_items joins the class the
118
+ -- day it is created;
119
+ -- * the COLUMN set is every `%_at` timestamptz column of every relation in that
120
+ -- closure, plus `seq` -- so a derived alias like `first_delivered_at` or
121
+ -- `latest_inbox_updated_at` is covered without being listed.
122
+ --
123
+ -- MATCHING IS CASE-INSENSITIVE, and that is load-bearing rather than tidiness.
124
+ -- `pg_get_viewdef` is regenerated and lowercases everything, but
125
+ -- `pg_get_functiondef` returns `prosrc` VERBATIM, so a PL/pgSQL body written
126
+ -- `WHERE created_at BETWEEN p_cursor AND 'infinity'` kept its capitals and evaded
127
+ -- a case-sensitive scan entirely. Measured: `'... BETWEEN ...' ~ '\mbetween\M'`
128
+ -- was false. This is the actual mechanism behind the round-2 finding.
129
+ --
130
+ -- Shapes verified by EXECUTING the probe matrix, not by reading the regex:
131
+ -- `created_at::timestamp > x`, `extract(epoch from created_at) > 5`,
132
+ -- `date_trunc('day', created_at) >= x`, `coalesce(created_at, now()) > x`,
133
+ -- `(co.seq) > p_after`, `co.seq::bigint > p_after`, reversed-with-alias
134
+ -- `x > vi.created_at`, `created_at::timestamp > now() - interval '30 days'`,
135
+ -- `BETWEEN` / `NOT BETWEEN` in either case, and `first_delivered_at > p_after`
136
+ -- through `v_board_feed`. False-positive controls that must NOT match:
137
+ -- `message_sequence >`, `event_sequence >=` (the \m boundary stops `seq` matching
138
+ -- inside them). A detector that catches everything would make the class vacuous.
139
+ --
140
+ -- THE CEILING. WHAT THIS DETECTOR DOES *NOT* SEE -- measured, not guessed, and
141
+ -- deliberately NOT widened a fourth time:
142
+ -- * ALIAS LAUNDERING. A view projecting `created_at AS ts` and a gate on `s.ts`
143
+ -- is inside the membership closure and returns EMPTY from this scan, because
144
+ -- `ts` is not a `%_at` column. Renaming defeats a name-based column set by
145
+ -- construction.
146
+ -- * A WIDE WINDOW. The column and the operator are matched across a bounded
147
+ -- window; two literal-free padding terms (~44 chars) between them evades.
148
+ -- Raising the bound moves the threshold, it does not remove it.
149
+ -- * `message_sequence` is excluded ON PURPOSE by the \m boundary (so `seq`
150
+ -- cannot match inside it) -- but it is itself pre-commit-assigned, so a
151
+ -- recovery gate on it would be this same defect class and invisible BY DESIGN
152
+ -- CHOICE, not by oversight.
153
+ -- So the narrower true claim, which is what the assertion name should be read as:
154
+ -- this recognises LEXICAL gate shapes on KNOWN column names within a bounded
155
+ -- window. The real guarantees in this file are the MEMBERSHIP closure (which is
156
+ -- exhaustive and parse-tree-derived for views) and the BEHAVIOURAL WR-6
157
+ -- assertions. WR-7 is a tripwire over the common shapes, not a proof of absence.
158
+ --
159
+ -- THE OTHER CEILING, ON THE FUNCTION HALF SPECIFICALLY. The function half is a TEXT scan
160
+ -- over `prosrc`, and it cannot be anything better: PostgreSQL records no
161
+ -- dependency from a string-bodied function to the relations it reads
162
+ -- (`pg_depend` = 0 rows for `_upsert_inbox_item` -> `inbox_items`, measured) and
163
+ -- exposes no parse tree for it (`prosqlbody IS NULL`). So the VIEW half is
164
+ -- parse-tree-derived and exhaustive; the FUNCTION half is best-effort and can be
165
+ -- evaded by dynamic SQL or by indirection through a helper. WR-13 is a tripwire on
166
+ -- exactly that boundary: it goes red the day a function moves to a SQL-standard
167
+ -- body, because at that point the parse tree this file could not use becomes
168
+ -- available and the function half can be made exhaustive too.
169
+ create or replace function pg_temp._wr_strip(p_text text) returns text
170
+ language sql immutable as $fn$
171
+ select regexp_replace(
172
+ coalesce($1, ''),
173
+ $re$'(?:[^']|'')*'|--[^\n]*|/\*(?:[^*]|\*+[^*/])*\*+/$re$,
174
+ ' ', 'g')
175
+ $fn$;
176
+
177
+ -- The closure, and the two derived patterns, materialised once.
178
+ create temp table _wr_class(kind text, pattern text) on commit drop;
179
+
180
+ with recursive closure as (
181
+ select 'agent_board.inbox_items'::regclass as oid
182
+ union all
183
+ select 'agent_board.callback_outbox_events'::regclass
184
+ union
185
+ select r.ev_class
186
+ from pg_depend d
187
+ join pg_rewrite r on r.oid = d.objid
188
+ join closure c on c.oid = d.refobjid
189
+ where d.classid = 'pg_rewrite'::regclass
190
+ and d.refclassid = 'pg_class'::regclass
191
+ and r.ev_class <> d.refobjid
192
+ ),
193
+ rels as (
194
+ select c.oid, c.relname, c.relkind
195
+ from closure cl
196
+ join pg_class c on c.oid = cl.oid
197
+ join pg_namespace n on n.oid = c.relnamespace
198
+ where n.nspname = 'agent_board'
199
+ ),
200
+ cols as (
201
+ select distinct a.attname
202
+ from rels r
203
+ join pg_attribute a on a.attrelid = r.oid
204
+ where a.attnum > 0 and not a.attisdropped
205
+ and a.atttypid = 'timestamptz'::regtype
206
+ and a.attname ~ '_at$'
207
+ union
208
+ select 'seq'
209
+ )
210
+ insert into _wr_class(kind, pattern)
211
+ select 'relations', string_agg(relname, '|' order by relname) from rels
212
+ union all
213
+ select 'columns', string_agg(attname, '|' order by length(attname) desc, attname) from cols;
214
+
215
+ create or replace function pg_temp._wr_cursor_gates(p_text text) returns text
216
+ language sql stable as $fn$
217
+ select coalesce(
218
+ (select string_agg(distinct regexp_replace(m[1], '[[:space:]]+', ' ', 'g'), ' | ')
219
+ from _wr_class c,
220
+ regexp_matches(
221
+ pg_temp._wr_strip($1),
222
+ '(\m(' || c.pattern || ')\M[^<>=]{0,40}(?:<=|>=|<|>)'
223
+ || '|(?:<=|>=|<|>)[^<>=]{0,40}\m(' || c.pattern || ')\M'
224
+ || '|\m(' || c.pattern || ')\M[^<>=]{0,20}\mbetween\M'
225
+ || '|\mbetween\M[^<>=]{0,40}\m(' || c.pattern || ')\M)',
226
+ 'gi') m
227
+ where c.kind = 'columns'),
228
+ '')
229
+ $fn$;
230
+
231
+ create temp table _wr_members(name text, kind text, src text) on commit drop;
232
+
233
+ with recursive closure as (
234
+ select 'agent_board.inbox_items'::regclass as oid
235
+ union all
236
+ select 'agent_board.callback_outbox_events'::regclass
237
+ union
238
+ select r.ev_class
239
+ from pg_depend d
240
+ join pg_rewrite r on r.oid = d.objid
241
+ join closure c on c.oid = d.refobjid
242
+ where d.classid = 'pg_rewrite'::regclass
243
+ and d.refclassid = 'pg_class'::regclass
244
+ and r.ev_class <> d.refobjid
245
+ )
246
+ insert into _wr_members(name, kind, src)
247
+ select c.relname, 'view', pg_get_viewdef(c.oid)
248
+ from closure cl
249
+ join pg_class c on c.oid = cl.oid
250
+ join pg_namespace n on n.oid = c.relnamespace
251
+ where c.relkind in ('v', 'm')
252
+ and n.nspname = 'agent_board';
253
+
254
+ insert into _wr_members(name, kind, src)
255
+ select p.proname, 'function', pg_get_functiondef(p.oid)
256
+ from pg_proc p
257
+ join pg_namespace n on n.oid = p.pronamespace
258
+ where n.nspname = 'agent_board'
259
+ and p.prolang <> (select oid from pg_language where lanname = 'internal')
260
+ and pg_get_functiondef(p.oid) ~* ('(' || (select pattern from _wr_class where kind = 'relations') || ')');
261
+
262
+ -- ---------------------------------------------------------------------------
263
+ -- WR-1 EXISTENCE
264
+ -- ---------------------------------------------------------------------------
265
+ select has_view('agent_board', 'v_agent_wake_recovery',
266
+ 'WR-1 v_agent_wake_recovery exists');
267
+
268
+ -- The literal string the runtime fetch descriptor names. If the migration renames
269
+ -- the view and the descriptor is not renamed with it, this is where it surfaces.
270
+ select is(
271
+ (select c.relkind::text
272
+ from pg_class c
273
+ where c.oid = to_regclass('agent_board.v_agent_wake_recovery')),
274
+ 'v',
275
+ 'WR-1 the descriptor string agent_board.v_agent_wake_recovery resolves to a VIEW');
276
+
277
+ -- ---------------------------------------------------------------------------
278
+ -- WR-2 INVOKER + SHAPE
279
+ -- ---------------------------------------------------------------------------
280
+ select ok(
281
+ (select 'security_invoker=true' = any(coalesce(c.reloptions, '{}'::text[]))
282
+ from pg_class c
283
+ where c.oid = to_regclass('agent_board.v_agent_wake_recovery')),
284
+ 'WR-2 v_agent_wake_recovery is security_invoker (reloptions, from the catalog) -- the DECLARATION only: measured inert in all four cells of the invoker matrix, load-bearing only if this view is ever rebuilt directly on inbox_items');
285
+
286
+ select is(
287
+ (select string_agg(a.attname, ',' order by a.attnum)
288
+ from pg_attribute a
289
+ where a.attrelid = to_regclass('agent_board.v_agent_wake_recovery')
290
+ and a.attnum > 0 and not a.attisdropped),
291
+ (select string_agg(a.attname, ',' order by a.attnum)
292
+ from pg_attribute a
293
+ where a.attrelid = to_regclass('agent_board.v_agent_inbox')
294
+ and a.attnum > 0 and not a.attisdropped),
295
+ 'WR-2 the recovery view projects EXACTLY v_agent_inbox''s column list, in order -- both sides read from pg_attribute');
296
+
297
+ -- ---------------------------------------------------------------------------
298
+ -- WR-3 GRANTS
299
+ -- ---------------------------------------------------------------------------
300
+ -- The grants are deliberately NOT a mirror of v_agent_inbox's: service_role is
301
+ -- DENIED. It holds BYPASSRLS and current_agent_id() returns NULL for it, so a
302
+ -- scoped view would hand it a silently EMPTY recovery set -- the very silent-drop
303
+ -- shape this file exists to close. A denied SELECT is loud; an empty result is not.
304
+ select is(
305
+ (select string_agg(r.rolname || ':' ||
306
+ (select string_agg(case when has_table_privilege(r.rolname, to_regclass('agent_board.v_agent_wake_recovery'), pr) then pr else '-' end, ',')
307
+ from unnest(array['SELECT', 'INSERT', 'UPDATE', 'DELETE']) pr),
308
+ ' ' order by r.rolname)
309
+ from pg_roles r
310
+ where r.rolname in ('anon', 'authenticated', 'service_role')),
311
+ 'anon:-,-,-,- authenticated:SELECT,-,-,- service_role:-,-,-,-',
312
+ 'WR-3 exactly authenticated holds SELECT; anon and service_role hold nothing -- service_role is denied ON PURPOSE so an identity-less read fails loudly instead of returning an empty recovery set');
313
+
314
+ -- The half of the old mirror that still holds: whatever authenticated may do on
315
+ -- v_agent_inbox, it may do here and no more. Catalog-derived on both sides.
316
+ select is(
317
+ (select string_agg(case when has_table_privilege('authenticated', to_regclass('agent_board.v_agent_wake_recovery'), pr) then pr else '-' end, ','
318
+ order by pr)
319
+ from unnest(array['SELECT', 'INSERT', 'UPDATE', 'DELETE']) pr),
320
+ (select string_agg(case when has_table_privilege('authenticated', to_regclass('agent_board.v_agent_inbox'), pr) then pr else '-' end, ','
321
+ order by pr)
322
+ from unnest(array['SELECT', 'INSERT', 'UPDATE', 'DELETE']) pr),
323
+ 'WR-3 authenticated''s privilege set matches v_agent_inbox''s exactly -- the recovery surface grants no more than the surface it derives from');
324
+
325
+ select is(
326
+ (select count(*)::integer
327
+ from pg_class c
328
+ where c.oid = to_regclass('agent_board.v_agent_wake_recovery')
329
+ and array_to_string(coalesce(c.relacl, '{}'::aclitem[]), ',') ~ '(^|,)=[a-zA-Z*]+/'),
330
+ 0,
331
+ 'WR-3 PUBLIC holds no privilege entry in the wake recovery ACL');
332
+
333
+ -- ---------------------------------------------------------------------------
334
+ -- WR-4 NO CURSOR ON THE RECOVERY SURFACE ITSELF
335
+ -- ---------------------------------------------------------------------------
336
+ select is(
337
+ (select pg_temp._wr_cursor_gates(pg_get_viewdef(to_regclass('agent_board.v_agent_wake_recovery')))),
338
+ '',
339
+ 'WR-4 the wake recovery view definition references no pre-commit ordering column in a comparison -- a cursor filter in view clothing is NAMED here');
340
+
341
+ select ok(
342
+ (select pg_get_viewdef(to_regclass('agent_board.v_agent_wake_recovery')) !~* '\morder[[:space:]]+by\M'),
343
+ 'WR-4 the wake recovery view carries no ORDER BY -- the unhandled set is a SET, not a cursor stream');
344
+
345
+ -- ---------------------------------------------------------------------------
346
+ -- WR-5 THE VIEW AND THE PARTIAL INDEX AGREE
347
+ -- ---------------------------------------------------------------------------
348
+ -- Both blobs are anchored on `action_state = ANY (ARRAY[...])`, which is how
349
+ -- Postgres normalises an IN-list of constants, so this reads the WHERE clause and
350
+ -- never the projection. Distinct sentinels on each side mean a regex that stops
351
+ -- matching cannot pass as NULL = NULL.
352
+ select is(
353
+ coalesce(
354
+ (select array_agg(btrim(btrim(split_part(e, '::', 1)), '''') order by btrim(btrim(split_part(e, '::', 1)), ''''))
355
+ from unnest(string_to_array(
356
+ substring(pg_get_viewdef(to_regclass('agent_board.v_agent_wake_recovery'))
357
+ from 'action_state = ANY \(ARRAY\[([^]]*)\]'), ',')) e),
358
+ array['VIEW-STATE-SET-NOT-FOUND']),
359
+ coalesce(
360
+ (select array_agg(btrim(btrim(split_part(e, '::', 1)), '''') order by btrim(btrim(split_part(e, '::', 1)), ''''))
361
+ from unnest(string_to_array(
362
+ substring((select pg_get_expr(i.indpred, i.indrelid)
363
+ from pg_index i
364
+ where i.indexrelid = to_regclass('agent_board.idx_agent_board_inbox_items_agent_pending'))
365
+ from 'action_state = ANY \(ARRAY\[([^]]*)\]'), ',')) e),
366
+ array['INDEX-STATE-SET-NOT-FOUND']),
367
+ 'WR-5 the view''s action_state set equals idx_agent_board_inbox_items_agent_pending''s predicate set -- editing either one alone fails here');
368
+
369
+ select isnt(
370
+ (select obj_description(to_regclass('agent_board.v_agent_wake_recovery'), 'pg_class')),
371
+ null,
372
+ 'WR-5 the view carries a COMMENT stating that it is status-partitioned and carries no cursor');
373
+
374
+ -- ---------------------------------------------------------------------------
375
+ -- Fixture: two authenticated agents, one board, seven inbox items.
376
+ -- Everything is scoped to these two agent ids; a repo-wide count would be
377
+ -- polluted by earlier fixtures and would fail looking like a real defect.
378
+ -- ---------------------------------------------------------------------------
379
+ create temp table _wr_fixture(user_a uuid, user_b uuid, agent_a uuid, agent_b uuid) on commit drop;
380
+ grant select on table _wr_fixture to authenticated;
381
+
382
+ create temp table _wr_shape(label text, val text) on commit drop;
383
+ grant insert, select on table _wr_shape to authenticated;
384
+
385
+ do $$
386
+ declare
387
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
388
+ v_user_a uuid;
389
+ v_user_b uuid;
390
+ v_agent_a uuid := '19100000-0000-4000-8000-00000000000a';
391
+ v_agent_b uuid := '19100000-0000-4000-8000-00000000000b';
392
+ v_board uuid := '19101000-0000-4000-8000-000000000001';
393
+ v_member uuid := '19102000-0000-4000-8000-000000000001';
394
+ v_member_b uuid := '19102000-0000-4000-8000-000000000002';
395
+ v_thread uuid := '19103000-0000-4000-8000-000000000001';
396
+ v_states text[] := array['new', 'restored', 'deferred', 'read', 'acknowledged', 'dismissed'];
397
+ v_state text;
398
+ v_n integer;
399
+ begin
400
+ v_user_a := public.ensure_github_user(
401
+ 'agent-board-wr191-a-' || v_suffix,
402
+ 'agent-board-wr191-a-' || v_suffix || '@example.test',
403
+ 'agent-board-wr191-a-' || v_suffix,
404
+ 'Wake Recovery A ' || v_suffix);
405
+ v_user_b := public.ensure_github_user(
406
+ 'agent-board-wr191-b-' || v_suffix,
407
+ 'agent-board-wr191-b-' || v_suffix || '@example.test',
408
+ 'agent-board-wr191-b-' || v_suffix,
409
+ 'Wake Recovery B ' || v_suffix);
410
+
411
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
412
+ values (v_agent_a, 'agent', 'agent', 'wr191-a-' || v_suffix, 'Wake Recovery Agent A', 'active', v_user_a, v_user_a),
413
+ (v_agent_b, 'agent', 'agent', 'wr191-b-' || v_suffix, 'Wake Recovery Agent B', 'active', v_user_b, v_user_b);
414
+
415
+ insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
416
+ values (v_board, 'wr191-board-' || v_suffix, 'Wake Recovery Board', v_agent_a, 'open', 'active');
417
+
418
+ insert into agent_board.board_memberships(id, board_id, agent_id, role, membership_state, created_by_agent_id)
419
+ values (v_member, v_board, v_agent_a, 'member', 'joined', v_agent_a);
420
+
421
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
422
+ values (v_thread, 'coordination', v_agent_a, 'Wake Recovery thread', v_board);
423
+
424
+ -- One message per inbox item: delivery_recipients is UNIQUE on
425
+ -- (recipient_agent_id, canonical_source_key), so one recipient cannot hold two
426
+ -- rows for the same message.
427
+ insert into agent_board.messages(id, thread_id, author_agent_id, message_sequence, message_type, body)
428
+ select ('19104000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid, v_thread, v_agent_a, n, 'comment', 'wr191 message ' || n
429
+ from generate_series(1, 8) n;
430
+
431
+ -- Agent A: one item in each of the six action_states, all visible in inbox.
432
+ v_n := 0;
433
+ foreach v_state in array v_states loop
434
+ v_n := v_n + 1;
435
+ insert into agent_board.delivery_recipients(id, delivery_mode, recipient_agent_id, source_kind, source_message_id)
436
+ values (('19105000-0000-4000-8000-' || lpad(v_n::text, 12, '0'))::uuid, 'direct', v_agent_a, 'message',
437
+ ('19104000-0000-4000-8000-' || lpad(v_n::text, 12, '0'))::uuid);
438
+ insert into agent_board.inbox_items(
439
+ id, delivery_recipient_id, recipient_agent_id, delivery_mode, source_kind, source_message_id,
440
+ subject, preview, priority, action_state, visible_in_inbox, visible_in_board_feed, fetch_descriptor)
441
+ values (('19106000-0000-4000-8000-' || lpad(v_n::text, 12, '0'))::uuid,
442
+ ('19105000-0000-4000-8000-' || lpad(v_n::text, 12, '0'))::uuid,
443
+ v_agent_a, 'direct', 'message', ('19104000-0000-4000-8000-' || lpad(v_n::text, 12, '0'))::uuid,
444
+ 'WR191 ' || v_state, 'wr191 preview', 'high', v_state, true, false, '{}'::jsonb);
445
+ end loop;
446
+
447
+ -- Agent A, seventh item: action_state 'new' but NOT visible in the inbox (board
448
+ -- feed only). v_agent_inbox filters it out; the recovery view must INHERIT that
449
+ -- rather than restate it.
450
+ insert into agent_board.delivery_recipients(id, delivery_mode, recipient_agent_id, source_kind, source_message_id, board_id, board_membership_id)
451
+ values ('19105000-0000-4000-8000-000000000007', 'board', v_agent_a, 'message',
452
+ '19104000-0000-4000-8000-000000000007', v_board, v_member);
453
+ insert into agent_board.inbox_items(
454
+ id, delivery_recipient_id, recipient_agent_id, board_id, delivery_mode, source_kind, source_message_id,
455
+ subject, preview, priority, action_state, visible_in_inbox, visible_in_board_feed, fetch_descriptor)
456
+ values ('19106000-0000-4000-8000-000000000007', '19105000-0000-4000-8000-000000000007',
457
+ v_agent_a, v_board, 'board', 'message', '19104000-0000-4000-8000-000000000007',
458
+ 'WR191 board-feed only', 'wr191 preview', 'high', 'new', false, true, '{}'::jsonb);
459
+
460
+ -- Agent B is a CO-MEMBER of the same board, and its unhandled item is a BOARD
461
+ -- delivery with BOTH visibility flags true -- byte-for-byte the shape
462
+ -- post_board_message writes (it passes true, true to _upsert_inbox_item). This
463
+ -- is the shape the inbox_items RLS policy admits to co-members via its second
464
+ -- disjunct, and therefore the ONLY shape that can expose the defect. The
465
+ -- previous fixture used a direct, non-board row -- the one shape that IS
466
+ -- self-scoped -- which is why WR-6 was green while the surface leaked.
467
+ insert into agent_board.board_memberships(id, board_id, agent_id, role, membership_state, created_by_agent_id)
468
+ values (v_member_b, v_board, v_agent_b, 'member', 'joined', v_agent_a);
469
+
470
+ insert into agent_board.delivery_recipients(id, delivery_mode, recipient_agent_id, source_kind, source_message_id, board_id, board_membership_id)
471
+ values ('19105000-0000-4000-8000-000000000008', 'board', v_agent_b, 'message',
472
+ '19104000-0000-4000-8000-000000000008', v_board, v_member_b);
473
+ insert into agent_board.inbox_items(
474
+ id, delivery_recipient_id, recipient_agent_id, board_id, delivery_mode, source_kind, source_message_id,
475
+ subject, preview, priority, action_state, visible_in_inbox, visible_in_board_feed, fetch_descriptor)
476
+ values ('19106000-0000-4000-8000-000000000008', '19105000-0000-4000-8000-000000000008',
477
+ v_agent_b, v_board, 'board', 'message', '19104000-0000-4000-8000-000000000008',
478
+ 'WR191 agent B board item', 'wr191 preview', 'high', 'new', true, true, '{}'::jsonb);
479
+
480
+ insert into _wr_fixture(user_a, user_b, agent_a, agent_b) values (v_user_a, v_user_b, v_agent_a, v_agent_b);
481
+ end
482
+ $$;
483
+
484
+ -- ---------------------------------------------------------------------------
485
+ -- WR-6 BEHAVIOUR + RLS, all under a REAL authenticated session.
486
+ -- ---------------------------------------------------------------------------
487
+ -- Every read below runs as agent A through an authenticated JWT, because the view
488
+ -- is now scoped on current_agent_id() and a superuser read would legitimately see
489
+ -- nothing. Captured under lives_ok FIRST, then asserted: read directly, a renamed
490
+ -- or dropped view raises, aborts the transaction, and pgTAP emits NO `not ok` for
491
+ -- this or any later assertion (#113) -- verified, the rename mutation did exactly
492
+ -- that before this block was captured.
493
+ select lives_ok($$
494
+ do $inner$
495
+ declare
496
+ v_fixture record;
497
+ begin
498
+ select * into v_fixture from _wr_fixture;
499
+ perform set_config('request.jwt.claim.role', 'authenticated', true);
500
+ perform set_config('request.jwt.claim.sub', v_fixture.user_a::text, true);
501
+ set local role authenticated;
502
+
503
+ insert into _wr_shape(label, val)
504
+ select 'states', (select string_agg(w.action_state, ',' order by w.action_state)
505
+ from agent_board.v_agent_wake_recovery w)
506
+ union all
507
+ select 'handled', (select count(*)::text from agent_board.v_agent_wake_recovery w
508
+ where w.action_state in ('read', 'acknowledged', 'dismissed'))
509
+ union all
510
+ select 'board_feed_only', (select count(*)::text from agent_board.v_agent_wake_recovery w
511
+ where w.inbox_item_id = '19106000-0000-4000-8000-000000000007')
512
+ union all
513
+ select 'foreign_rows', (select count(*)::text from agent_board.v_agent_wake_recovery w
514
+ where w.recipient_agent_id = v_fixture.agent_b)
515
+ union all
516
+ select 'own_rows', (select count(*)::text from agent_board.v_agent_wake_recovery w
517
+ where w.recipient_agent_id = v_fixture.agent_a)
518
+ union all
519
+ -- The co-member's row IS readable through the unscoped surface this view is
520
+ -- derived from. Asserting that positively is what proves the next assertion
521
+ -- measures the scoping and not merely an empty board.
522
+ select 'foreign_rows_via_v_agent_inbox',
523
+ (select count(*)::text from agent_board.v_agent_inbox i
524
+ where i.recipient_agent_id = v_fixture.agent_b
525
+ and i.action_state in ('new', 'restored', 'deferred'));
526
+ reset role;
527
+ end
528
+ $inner$;
529
+ $$, 'WR-6 an authenticated session can read the recovery view [call succeeds]');
530
+
531
+ select is(
532
+ (select val from _wr_shape where label = 'states'),
533
+ 'deferred,new,restored',
534
+ 'WR-6 the view returns EXACTLY the caller''s unhandled states');
535
+
536
+ select is(
537
+ (select val from _wr_shape where label = 'handled'),
538
+ '0',
539
+ 'WR-6 handled items (read / acknowledged / dismissed) are NOT in the recovery set');
540
+
541
+ select is(
542
+ (select val from _wr_shape where label = 'board_feed_only'),
543
+ '0',
544
+ 'WR-6 a board-feed-only item (visible_in_inbox = false) is excluded -- the rule is INHERITED from v_agent_inbox, not restated');
545
+
546
+ select is(
547
+ (select val from _wr_shape where label = 'own_rows'),
548
+ '3',
549
+ 'WR-6 the authenticated agent sees its own three unhandled items');
550
+
551
+ -- The defect this pins (message-board#191 board review): RLS on inbox_items is NOT
552
+ -- self-scoped, so without the current_agent_id() predicate this returns the
553
+ -- co-member's board item. Measured at 1 before the fix.
554
+ select is(
555
+ (select val from _wr_shape where label = 'foreign_rows'),
556
+ '0',
557
+ 'WR-6 a CO-MEMBER''s board-delivered unhandled item is NOT in the caller''s recovery set -- inbox_items RLS admits it, the view''s current_agent_id() scoping excludes it');
558
+
559
+ select is(
560
+ (select val from _wr_shape where label = 'foreign_rows_via_v_agent_inbox'),
561
+ '1',
562
+ 'WR-6 that same co-member row IS visible through v_agent_inbox -- so the assertion above measures the scoping, not an empty fixture');
563
+
564
+ -- The scoping is a catalog fact, not only a behavioural one: a future edit that
565
+ -- drops the predicate but leaves the fixture passing for another reason fails here.
566
+ select ok(
567
+ (select pg_get_viewdef(to_regclass('agent_board.v_agent_wake_recovery')) ~ 'current_agent_id\(\)'),
568
+ 'WR-6 the view definition itself scopes on current_agent_id() -- recipient scoping is server-side, never a caller''s filter');
569
+
570
+ -- ---------------------------------------------------------------------------
571
+ -- WR-7 CLASS (catalog-derived, and asserted to be non-vacuous first)
572
+ -- ---------------------------------------------------------------------------
573
+ -- Stated BEFORE the class assertion: if the enumeration silently stopped reaching
574
+ -- these members, the class below would pass by finding nothing.
575
+ select is(
576
+ (select string_agg(name, ', ' order by name)
577
+ from _wr_members
578
+ where name in ('claim_callback_outbox_events', 'mark_retention_metadata',
579
+ 'v_agent_inbox', 'v_agent_wake_recovery')),
580
+ 'claim_callback_outbox_events, mark_retention_metadata, v_agent_inbox, v_agent_wake_recovery',
581
+ 'WR-7 the class enumeration REACHES the members it must -- including v_agent_wake_recovery, which names inbox_items only transitively');
582
+
583
+ select is(
584
+ (select coalesce(string_agg(distinct name, ', ' order by name), '')
585
+ from _wr_members
586
+ where pg_temp._wr_cursor_gates(src) <> ''),
587
+ 'claim_callback_outbox_events, mark_retention_metadata',
588
+ 'WR-7 no member of the derived class gates on a derived pre-commit column, except the two asserted positively below -- an offender NAMES ITSELF here');
589
+
590
+ -- ---------------------------------------------------------------------------
591
+ -- WR-8 POSITIVE: the one named member is what it claims to be.
592
+ -- ---------------------------------------------------------------------------
593
+ select is(
594
+ (select p.prorettype::regtype::text || '/' || p.proretset::text
595
+ from pg_proc p
596
+ where p.pronamespace = 'agent_board'::regnamespace
597
+ and p.proname = 'mark_retention_metadata'),
598
+ 'bigint/false',
599
+ 'WR-8 mark_retention_metadata returns a COUNT and is not set-returning -- it hands no batch to a consumer, so its comparison cannot be a recovery cursor');
600
+
601
+ select is(
602
+ (select pg_temp._wr_cursor_gates(src) from _wr_members where name = 'mark_retention_metadata'),
603
+ 'created_at <',
604
+ 'WR-8 mark_retention_metadata''s ONLY pre-commit comparison is `created_at <` (an age cutoff) -- it never compares seq, and never gates on a lower bound');
605
+
606
+ -- Read from the RAW definition on purpose: `ops_retention_reason` is a jsonb KEY,
607
+ -- i.e. a string literal, and the one-pass strip removes it. The companion half of
608
+ -- this assertion is read from the STRIPPED definition, where the UPDATE survives
609
+ -- because it lives inside a dollar-quoted body rather than a single-quoted one.
610
+ select ok(
611
+ (select src ~ 'ops_retention_reason'
612
+ and pg_temp._wr_strip(src) ~ 'update agent_board'
613
+ from _wr_members where name = 'mark_retention_metadata'),
614
+ 'WR-8 mark_retention_metadata stamps retention metadata through an UPDATE sweep -- the age cutoff drives retention, not a fetch');
615
+
616
+ select ok(
617
+ (select not has_function_privilege('authenticated', p.oid, 'EXECUTE')
618
+ and not has_function_privilege('anon', p.oid, 'EXECUTE')
619
+ and not has_function_privilege('public', p.oid, 'EXECUTE')
620
+ from pg_proc p
621
+ where p.pronamespace = 'agent_board'::regnamespace
622
+ and p.proname = 'mark_retention_metadata'),
623
+ 'WR-8 mark_retention_metadata is not executable by authenticated / anon / PUBLIC -- it is not reachable as an agent read path');
624
+
625
+ -- The second exemption, asserted positively rather than granted. `available_at`
626
+ -- became a member of the DERIVED column set (it is a `%_at` timestamptz on a
627
+ -- closure relation), and the claim RPC does gate on it -- legitimately: it is a
628
+ -- SCHEDULING predicate against now(), not a caller-supplied cursor, and the batch
629
+ -- it hands back is bounded by STATUS (WR-9 below). Both halves are stated.
630
+ select is(
631
+ (select pg_temp._wr_cursor_gates(src) from _wr_members where name = 'claim_callback_outbox_events'),
632
+ 'available_at <=',
633
+ 'WR-8 claim_callback_outbox_events'' ONLY pre-commit gate is `available_at <=` -- a scheduling predicate, never a lower bound against a caller cursor');
634
+
635
+ select ok(
636
+ (select pg_temp._wr_strip(src) !~* '\mavailable_at\M[^<>=]{0,40}(?:>=|>)'
637
+ from _wr_members where name = 'claim_callback_outbox_events'),
638
+ 'WR-8 that gate is an UPPER bound (<=) and never a lower bound -- a `available_at >` would be a cursor, and is what this distinguishes');
639
+
640
+ -- ---------------------------------------------------------------------------
641
+ -- WR-13 TRIPWIRE ON THE CEILING
642
+ -- ---------------------------------------------------------------------------
643
+ -- The function half of WR-7 is a TEXT scan because PostgreSQL gives it nothing
644
+ -- better: a string-bodied function records no pg_depend edge to the relations it
645
+ -- reads and exposes no parse tree. This asserts that boundary still holds. It goes
646
+ -- RED the day any function in the class moves to a SQL-standard body (BEGIN
647
+ -- ATOMIC) -- which is good news, not a regression: at that point pg_depend and
648
+ -- prosqlbody become available and the function half can be made exhaustive like
649
+ -- the view half already is. A boundary a future change trips over, rather than a
650
+ -- paragraph it can ignore.
651
+ select is(
652
+ (select coalesce(string_agg(p.proname, ', ' order by p.proname), '')
653
+ from pg_proc p
654
+ where p.pronamespace = 'agent_board'::regnamespace
655
+ and p.prolang <> (select oid from pg_language where lanname = 'internal')
656
+ and p.prosqlbody is not null),
657
+ '',
658
+ 'WR-13 CEILING: every agent_board function still has a STRING body (no prosqlbody), so no parse tree exists for the function half of WR-7 -- when this goes red, that half can stop being a text scan');
659
+
660
+ -- ---------------------------------------------------------------------------
661
+ -- WR-9 POSITIVE: the recovery-shaped members partition on STATUS.
662
+ -- ---------------------------------------------------------------------------
663
+ select ok(
664
+ (select pg_temp._wr_strip(src) ~ 'outbox_status[[:space:]]*(=|in|<>)'
665
+ from _wr_members where name = 'claim_callback_outbox_events'),
666
+ 'WR-9 claim_callback_outbox_events partitions the queue on outbox_status -- the lossless property #187 relies on, restated positively here');
667
+
668
+ select ok(
669
+ (select src ~ 'action_state = ANY'
670
+ from _wr_members where name = 'v_agent_wake_recovery'),
671
+ 'WR-9 v_agent_wake_recovery partitions the inbox on action_state -- status, never an ordinal');
672
+
673
+ -- ---------------------------------------------------------------------------
674
+ -- WR-10 THE EXIT PATHS THE COMMENT CLAIMS ARE THE ONES THAT EXIST
675
+ -- ---------------------------------------------------------------------------
676
+ -- The migration's COMMENT carries the whole losslessness argument ("an item leaves
677
+ -- the set only through an ack write"). Its first draft named three exit paths;
678
+ -- pg_catalog says there is ONE. That is this repo's named trap -- a safety comment
679
+ -- arguing from a premise the schema contradicts -- so the claim is pinned here,
680
+ -- derived from the catalog, and a new or removed writer NAMES ITSELF.
681
+ select is(
682
+ (select coalesce(string_agg(p.proname, ', ' order by p.proname), '')
683
+ from pg_proc p
684
+ where p.pronamespace = 'agent_board'::regnamespace
685
+ and p.prolang <> (select oid from pg_language where lanname = 'internal')
686
+ and pg_temp._wr_strip(pg_get_functiondef(p.oid)) ~ 'action_state[[:space:]]*=[[:space:]]*[^=]'
687
+ -- The schema qualification is OPTIONAL on purpose (board review F3): a writer
688
+ -- using `set search_path` plus a bare `update inbox_items` was invisible to
689
+ -- the qualified form, and WR-10 stayed green naming only one writer. Same
690
+ -- class as the WR-7 name filter -- two name-based enumerators in one file.
691
+ and pg_temp._wr_strip(pg_get_functiondef(p.oid)) ~* 'update[[:space:]]+(agent_board\.)?inbox_items\M'),
692
+ 'update_inbox_item_state',
693
+ 'WR-10 exactly ONE function writes inbox_items.action_state -- the only way out of the unhandled set');
694
+
695
+ -- Stated positively rather than by absence, because "mark_read acks the item" is
696
+ -- the intuition the comment encoded and it is false: mark_read writes read
697
+ -- receipts, so a READ item is still unhandled and still woken for.
698
+ select ok(
699
+ (select pg_temp._wr_strip(pg_get_functiondef(p.oid)) !~ 'action_state'
700
+ and pg_temp._wr_strip(pg_get_functiondef(p.oid)) ~ 'read_receipts'
701
+ from pg_proc p
702
+ where p.pronamespace = 'agent_board'::regnamespace and p.proname = 'mark_read'),
703
+ 'WR-10 mark_read writes read_receipts and NEVER action_state -- marking an item read does not remove it from the recovery set');
704
+
705
+ -- ---------------------------------------------------------------------------
706
+ -- WR-11 WHY THE EXIT PATH IS STRUCTURAL, NOT JUST CONVENTIONAL
707
+ -- ---------------------------------------------------------------------------
708
+ -- WR-10 says one FUNCTION writes action_state. That is only half an invariant:
709
+ -- a trigger, or a direct UPDATE grant, would be a second way out of the set that
710
+ -- names no function at all. Both are closed, and asserted here so they stay that
711
+ -- way. (Found by the Fable review seat; verified against this catalog before use.)
712
+ select is(
713
+ (select count(*)::integer from pg_trigger t
714
+ where t.tgrelid = 'agent_board.inbox_items'::regclass and not t.tgisinternal),
715
+ 0,
716
+ 'WR-11 inbox_items carries NO user trigger -- nothing can move an item out of the unhandled set behind the writer''s back');
717
+
718
+ select is(
719
+ (select coalesce(string_agg(g.grantee || '=' || g.privilege_type, ', ' order by g.grantee, g.privilege_type), '')
720
+ from information_schema.role_table_grants g
721
+ where g.table_schema = 'agent_board' and g.table_name = 'inbox_items'
722
+ and g.grantee in ('anon', 'authenticated', 'service_role')),
723
+ 'authenticated=SELECT, service_role=SELECT',
724
+ 'WR-11 authenticated and service_role hold SELECT and nothing else on inbox_items -- no direct UPDATE, so the RLS write policies cannot be reached without a grant and the ONLY way out of the set is the definer RPC');
725
+
726
+ -- ---------------------------------------------------------------------------
727
+ -- WR-12 THE ONE UNPINNED WAY OUT, NAMED RATHER THAN HIDDEN
728
+ -- ---------------------------------------------------------------------------
729
+ -- `_upsert_inbox_item` is the second DML writer. Its ON CONFLICT overwrites
730
+ -- `visible_in_inbox` from the excluded row while leaving `action_state` alone, so
731
+ -- a caller passing false on a conflicting upsert would drop an item out of
732
+ -- `v_agent_inbox` -- and therefore out of the recovery set -- with NO ack write.
733
+ --
734
+ -- What is asserted: that its conflict path does not touch `action_state`, which is
735
+ -- what keeps WR-10's "one writer" claim true.
736
+ --
737
+ -- What is NOT asserted, stated plainly rather than papered over: that no caller
738
+ -- ever passes `visible_in_inbox => false`. All four current callers pass literal
739
+ -- true (checked), but the arguments are POSITIONAL and three of them legitimately
740
+ -- pass `false` for `visible_in_board_feed`, so a "no false in the call" scan would
741
+ -- assert a falsehood. Pinning this properly needs named arguments at the call
742
+ -- sites; until then this is a known, unpinned second exit.
743
+ select ok(
744
+ (select regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g')
745
+ ~ 'on conflict[^;]*do update'
746
+ and (select m[1] !~ 'action_state'
747
+ from regexp_matches(regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g'),
748
+ 'on conflict[^;]*do update([^;]*)', 'g') m
749
+ limit 1)
750
+ from pg_proc p
751
+ where p.pronamespace = 'agent_board'::regnamespace and p.proname = '_upsert_inbox_item'),
752
+ 'WR-12 _upsert_inbox_item''s ON CONFLICT never writes action_state -- so the upsert path cannot silently ack an item (it CAN still hide one via visible_in_inbox; see the note above)');
753
+
754
+ select * from finish();
755
+
756
+ rollback;