@consiliency/agent-board-schema 1.22.0 → 1.23.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.
- package/fixtures/already-applied.json +36 -0
- package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
- package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
- package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
- package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
- package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
- package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
- package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
- package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
- package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
- package/package.json +1 -1
- package/schema-manifest.json +84 -3
- package/src/tooling.test.mjs +2 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
- package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
- package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
- package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
- package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
- package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
- package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
- package/tests/agent_board_fedsession.test.sql +46 -2
- package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
- package/tests/agent_board_inbox_reads.test.sql +16 -19
- package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
- package/tests/agent_board_portal_offline_invariant.test.sql +1 -1
- package/tests/agent_board_roomlink.test.sql +2 -2
- package/tests/agent_board_wake_recovery.test.sql +756 -0
- package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
- package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
- package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
- package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
- package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
- package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
- package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
- package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
- package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
- package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
- package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
- package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
- package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
- package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
- package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
- package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
- package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
- package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
- package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
- package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
- package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
- package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
- package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
- package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
- package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
- package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
- package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
- package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
- package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
- package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
- package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
- package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
- 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;
|