@consiliency/agent-board-schema 1.35.0 → 1.40.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 +16 -0
- package/migrations/20260917000100_agent_board_invite_no_downgrade.sql +398 -0
- package/migrations/20260918000100_agent_board_signed_write_enforced_repoint.sql +139 -0
- package/migrations/20260919000100_agent_board_signed_write_model_key.sql +107 -0
- package/migrations/20260920000100_agent_board_realtime_credential_claim.sql +119 -0
- package/package.json +1 -1
- package/schema-manifest.json +39 -3
- package/src/deployment-drift-script.test.mjs +424 -0
- package/src/deployment-drift.test.mjs +301 -0
- package/src/tooling.test.mjs +49 -2
- package/tests/agent_board_bundle_endpoint_compat.test.sql +73 -18
- package/tests/agent_board_consent_substrate.test.sql +8 -1
- package/tests/agent_board_invite_no_downgrade.test.sql +689 -0
- package/tests/agent_board_realtime_credential_claim.test.sql +301 -0
- package/tests/agent_board_signed_write_enforced.test.sql +366 -0
- package/tests/agent_board_source_strip_helper.test.sql +100 -1
- package/tests/agent_board_write_enforcement_class.test.sql +1016 -0
- package/tests/fixtures/isolation-substrate.sql +1 -1
- package/tests/fixtures/test-prereqs.sql +22 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
-- #317 — an invite must not weaken an active membership.
|
|
2
|
+
--
|
|
3
|
+
-- The defect: `_upsert_board_membership_state` set `membership_state = p_membership_state`
|
|
4
|
+
-- unconditionally, so inviting an agent who was ALREADY joined moved them back to `invited`
|
|
5
|
+
-- and silently revoked board delivery. Observed in production (`invite_offer -> invited` on a
|
|
6
|
+
-- membership joined since 2026-08-05) and reproduced at this bundle before the fix.
|
|
7
|
+
--
|
|
8
|
+
-- Both directions are asserted. A guard that refuses everything would satisfy "no downgrade"
|
|
9
|
+
-- while breaking every legitimate transition, so the untouched paths are pinned as positively
|
|
10
|
+
-- as the blocked one.
|
|
11
|
+
|
|
12
|
+
begin;
|
|
13
|
+
select plan(53);
|
|
14
|
+
|
|
15
|
+
select agent_board.register_agent('agent', 'nd-owner', 'ND Owner');
|
|
16
|
+
select agent_board.register_agent('agent', 'nd-member', 'ND Member');
|
|
17
|
+
|
|
18
|
+
create temp table _nd on commit drop as
|
|
19
|
+
select (select id from agent_board.agents where handle = 'nd-owner') as owner_id,
|
|
20
|
+
(select id from agent_board.agents where handle = 'nd-member') as member_id;
|
|
21
|
+
|
|
22
|
+
select agent_board.create_board((select owner_id from _nd), 'nd-board', 'ND Board');
|
|
23
|
+
|
|
24
|
+
create temp table _ndb on commit drop as
|
|
25
|
+
select id as board_id from agent_board.boards where slug = 'nd-board';
|
|
26
|
+
|
|
27
|
+
-- ORDER BY is not decoration. An unordered single-row read over (board, agent) is
|
|
28
|
+
-- non-deterministic the moment two rows exist for the pair -- the DUPROW (#88) shape --
|
|
29
|
+
-- and would make every assertion below flip with physical row order rather than fail.
|
|
30
|
+
-- Same ordering the consent suite uses (agent_board_consent_gating.test.sql pg_temp.state).
|
|
31
|
+
create or replace function pg_temp.nd_state() returns text language sql as $$
|
|
32
|
+
select membership_state from agent_board.board_memberships
|
|
33
|
+
where board_id = (select board_id from _ndb) and agent_id = (select member_id from _nd)
|
|
34
|
+
order by case when membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
35
|
+
created_at desc, id desc
|
|
36
|
+
limit 1;
|
|
37
|
+
$$;
|
|
38
|
+
|
|
39
|
+
-- ---------------------------------------------------------------------------
|
|
40
|
+
-- THE DEFECT: an offer applied to an existing membership must not weaken it.
|
|
41
|
+
-- ---------------------------------------------------------------------------
|
|
42
|
+
select lives_ok(
|
|
43
|
+
$$ select agent_board._upsert_board_membership_state(
|
|
44
|
+
(select board_id from _ndb), (select member_id from _nd), 'joined', (select owner_id from _nd)) $$,
|
|
45
|
+
'a member joins');
|
|
46
|
+
select is(pg_temp.nd_state(), 'joined', 'the member is joined');
|
|
47
|
+
|
|
48
|
+
select lives_ok(
|
|
49
|
+
$$ select agent_board.invite_board_member(
|
|
50
|
+
(select board_id from _ndb), (select member_id from _nd), (select owner_id from _nd)) $$,
|
|
51
|
+
'inviting an already-joined member does not raise — invite stays idempotent');
|
|
52
|
+
select is(pg_temp.nd_state(), 'joined',
|
|
53
|
+
'#317: inviting a JOINED member leaves them joined — an offer never weakens active standing');
|
|
54
|
+
|
|
55
|
+
-- Re-join FIRST, so this arm is evidenced against a JOINED row independently of the
|
|
56
|
+
-- assertion above. Without it, a mutation that reverts only the `invited` arm leaves the
|
|
57
|
+
-- row already `invited` here, and this pair then tests requested-against-INVITED while
|
|
58
|
+
-- reading as though it tested requested-against-joined. Found by the review board: the
|
|
59
|
+
-- earlier PR text claimed one mutation evidenced both arms, and it could not.
|
|
60
|
+
select lives_ok(
|
|
61
|
+
$$ select agent_board._upsert_board_membership_state(
|
|
62
|
+
(select board_id from _ndb), (select member_id from _nd), 'joined', (select owner_id from _nd)) $$,
|
|
63
|
+
'the member is (re-)joined, so the `requested` arm below is evidenced against JOINED');
|
|
64
|
+
|
|
65
|
+
-- `requested` is the other offer state and must behave identically.
|
|
66
|
+
select lives_ok(
|
|
67
|
+
$$ select agent_board._upsert_board_membership_state(
|
|
68
|
+
(select board_id from _ndb), (select member_id from _nd), 'requested', (select member_id from _nd)) $$,
|
|
69
|
+
'a `requested` offer against a joined member does not raise');
|
|
70
|
+
select is(pg_temp.nd_state(), 'joined',
|
|
71
|
+
'#317: `requested` against a JOINED member also leaves them joined');
|
|
72
|
+
|
|
73
|
+
-- ---------------------------------------------------------------------------
|
|
74
|
+
-- THE LEDGER, on the no-op path. The row keeping `joined` is only half the claim:
|
|
75
|
+
-- `board_membership_events` is what the #317 investigation actually read to establish
|
|
76
|
+
-- what happened, it is APPEND-ONLY, and before this was asserted the sink emitted the
|
|
77
|
+
-- ARGUMENT rather than the resulting row -- appending `invite_offer -> invited` beside a
|
|
78
|
+
-- row that says `joined`. A ledger contradicting the table it describes is worse than the
|
|
79
|
+
-- demotion this migration removes, because it cannot be corrected afterwards.
|
|
80
|
+
--
|
|
81
|
+
-- Asserted on RESULT_STATE and not on event_kind, deliberately. The kind is derived from
|
|
82
|
+
-- the session: these calls run with no JWT, so the emitter records `operator_designation`,
|
|
83
|
+
-- while the production event that exposed #317 was an authenticated owner's `invite_offer`.
|
|
84
|
+
-- Pinning the kind here would pin the test harness's own principal, not the property.
|
|
85
|
+
-- ---------------------------------------------------------------------------
|
|
86
|
+
select is(
|
|
87
|
+
(select result_state from agent_board.board_membership_events
|
|
88
|
+
where board_id = (select board_id from _ndb) and agent_id = (select member_id from _nd)
|
|
89
|
+
order by seq desc limit 1),
|
|
90
|
+
pg_temp.nd_state(),
|
|
91
|
+
'#317: the latest ledger event records the state the ROW actually has — the ledger cannot contradict the table');
|
|
92
|
+
|
|
93
|
+
select is(
|
|
94
|
+
(select count(*)::text from agent_board.board_membership_events
|
|
95
|
+
where board_id = (select board_id from _ndb) and agent_id = (select member_id from _nd)
|
|
96
|
+
and result_state in ('invited', 'requested')),
|
|
97
|
+
'0',
|
|
98
|
+
'#317: NO event so far records an offer state — before the fix the no-op invite appended `-> invited` beside a joined row, append-only and uncorrectable');
|
|
99
|
+
|
|
100
|
+
-- ---------------------------------------------------------------------------
|
|
101
|
+
-- WHAT "NO-OP" ACTUALLY MEANS, both halves. The migration claims a suppressed offer leaves the
|
|
102
|
+
-- membership "exactly as strong as it was"; the row is nonetheless still written. Asserting only
|
|
103
|
+
-- the preserved half would leave the prose broader than the evidence, which is the failure this
|
|
104
|
+
-- file has been corrected for twice. So both are pinned: the authority-bearing columns do NOT
|
|
105
|
+
-- move, and the bookkeeping columns DO.
|
|
106
|
+
-- ---------------------------------------------------------------------------
|
|
107
|
+
create temp table _ndbefore on commit drop as
|
|
108
|
+
select membership_state, left_at, role, metadata, updated_at, created_by_agent_id
|
|
109
|
+
from agent_board.board_memberships
|
|
110
|
+
where board_id = (select board_id from _ndb) and agent_id = (select member_id from _nd);
|
|
111
|
+
|
|
112
|
+
select lives_ok(
|
|
113
|
+
$$ select agent_board._upsert_board_membership_state(
|
|
114
|
+
(select board_id from _ndb), (select member_id from _nd), 'invited', (select owner_id from _nd),
|
|
115
|
+
'{"nd_probe": true}'::jsonb) $$,
|
|
116
|
+
'a suppressed offer carrying metadata is applied to the active member');
|
|
117
|
+
|
|
118
|
+
select is(
|
|
119
|
+
(select (b.membership_state is not distinct from a.membership_state)
|
|
120
|
+
and (b.left_at is not distinct from a.left_at)
|
|
121
|
+
and (b.role is not distinct from a.role)
|
|
122
|
+
from _ndbefore b
|
|
123
|
+
cross join lateral (
|
|
124
|
+
select membership_state, left_at, role from agent_board.board_memberships
|
|
125
|
+
where board_id = (select board_id from _ndb) and agent_id = (select member_id from _nd)
|
|
126
|
+
order by case when membership_state in ('joined','subscribed') then 0 else 1 end,
|
|
127
|
+
created_at desc, id desc
|
|
128
|
+
limit 1) a)::text,
|
|
129
|
+
'true',
|
|
130
|
+
'#317: a suppressed offer moves NONE of the authority-bearing columns — membership_state, left_at, role');
|
|
131
|
+
|
|
132
|
+
select is(
|
|
133
|
+
(select (a.metadata ? 'nd_probe')::text
|
|
134
|
+
from agent_board.board_memberships a
|
|
135
|
+
where a.board_id = (select board_id from _ndb) and a.agent_id = (select member_id from _nd)
|
|
136
|
+
order by case when a.membership_state in ('joined','subscribed') then 0 else 1 end,
|
|
137
|
+
a.created_at desc, a.id desc
|
|
138
|
+
limit 1),
|
|
139
|
+
'true',
|
|
140
|
+
'#317 scope: the row IS still written — caller metadata merges on the suppressed path, which the migration states rather than implies');
|
|
141
|
+
|
|
142
|
+
-- ---------------------------------------------------------------------------
|
|
143
|
+
-- AN OFFER AGAINST THE OWNER'S OWN MEMBERSHIP. membership_state is written from the RESOLVED
|
|
144
|
+
-- next state while `role` and `left_at` key off the caller's REQUEST; this pins that an offer
|
|
145
|
+
-- costs the owner neither their standing nor their role.
|
|
146
|
+
--
|
|
147
|
+
-- Honest about what this does NOT cover, because the first version of this comment claimed more
|
|
148
|
+
-- and a mutation refuted it: re-keying the #79 clamp to the resolved state leaves these
|
|
149
|
+
-- assertions GREEN, since the clamp also requires a non-active prior state (or currently-removed
|
|
150
|
+
-- standing) and a suppressed offer always has an active prior. The two spellings diverge only in
|
|
151
|
+
-- the `v_currently_removed` corner, which this fixture does not build. Recorded rather than
|
|
152
|
+
-- papered over -- an assertion that cannot fail for the reason its comment gives is exactly the
|
|
153
|
+
-- shape this repo keeps finding.
|
|
154
|
+
-- ---------------------------------------------------------------------------
|
|
155
|
+
select is(
|
|
156
|
+
(select role from agent_board.board_memberships
|
|
157
|
+
where board_id = (select board_id from _ndb) and agent_id = (select owner_id from _nd)
|
|
158
|
+
order by case when membership_state in ('joined','subscribed') then 0 else 1 end, created_at desc, id desc
|
|
159
|
+
limit 1),
|
|
160
|
+
'owner',
|
|
161
|
+
'fixture: the board creator holds the owner role');
|
|
162
|
+
|
|
163
|
+
select lives_ok(
|
|
164
|
+
$$ select agent_board._upsert_board_membership_state(
|
|
165
|
+
(select board_id from _ndb), (select owner_id from _nd), 'invited', (select owner_id from _nd)) $$,
|
|
166
|
+
'an offer is applied to the OWNER''s own active membership');
|
|
167
|
+
|
|
168
|
+
select is(
|
|
169
|
+
(select role || '/' || membership_state from agent_board.board_memberships
|
|
170
|
+
where board_id = (select board_id from _ndb) and agent_id = (select owner_id from _nd)
|
|
171
|
+
order by case when membership_state in ('joined','subscribed') then 0 else 1 end, created_at desc, id desc
|
|
172
|
+
limit 1),
|
|
173
|
+
'owner/joined',
|
|
174
|
+
'#317: a suppressed offer leaves the owner at owner/joined — role and standing both survive an offer');
|
|
175
|
+
|
|
176
|
+
-- ---------------------------------------------------------------------------
|
|
177
|
+
-- THE MINTED-CONSENT TRAP, which is why the no-op emits NOTHING rather than the row's
|
|
178
|
+
-- own state. The second draft of this migration "fixed" the false ledger by emitting
|
|
179
|
+
-- v_membership.membership_state -- and that MINTS CONSENT: with no JWT the provenance is
|
|
180
|
+
-- `operator-designated`, and `_membership_consent_present` treats operator-designated +
|
|
181
|
+
-- joined/subscribed as an actual PLACEMENT. An operator merely OFFERING would have become
|
|
182
|
+
-- an admission grant, reopening the exact narrowing MBCONSENT shipped to make.
|
|
183
|
+
--
|
|
184
|
+
-- It needs its OWN fixture: the member in the main scenario joined through the sink, so
|
|
185
|
+
-- their ledger already carries a grant and consent reads true no matter what happens here.
|
|
186
|
+
-- The trap is only visible on a membership whose ledger is SILENT -- which is reachable,
|
|
187
|
+
-- because five shipped writers seat a row without routing through the sink.
|
|
188
|
+
-- ---------------------------------------------------------------------------
|
|
189
|
+
select agent_board.register_agent('agent', 'nd-silent', 'ND Silent');
|
|
190
|
+
|
|
191
|
+
create temp table _ndsil on commit drop as
|
|
192
|
+
select (select id from agent_board.agents where handle = 'nd-silent') as agent_id;
|
|
193
|
+
|
|
194
|
+
-- seated directly, so the consent ledger says nothing about this agent
|
|
195
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, role, created_by_agent_id)
|
|
196
|
+
select (select board_id from _ndb), (select agent_id from _ndsil), 'joined', 'member', (select owner_id from _nd);
|
|
197
|
+
|
|
198
|
+
select is(
|
|
199
|
+
agent_board._membership_consent_present((select board_id from _ndb), (select agent_id from _ndsil))::text,
|
|
200
|
+
'false',
|
|
201
|
+
'#317 fixture: the silently-seated member has NO consent on record — the precondition the trap needs');
|
|
202
|
+
|
|
203
|
+
select lives_ok(
|
|
204
|
+
$$ select agent_board.invite_board_member(
|
|
205
|
+
(select board_id from _ndb), (select agent_id from _ndsil), (select owner_id from _nd)) $$,
|
|
206
|
+
'an operator invites the silently-seated JOINED member');
|
|
207
|
+
|
|
208
|
+
select is(
|
|
209
|
+
(select count(*)::text from agent_board.board_membership_events
|
|
210
|
+
where board_id = (select board_id from _ndb) and agent_id = (select agent_id from _ndsil)),
|
|
211
|
+
'0',
|
|
212
|
+
'#317: a suppressed offer appends NO event at all — the ledger records state CHANGES, and nothing changed');
|
|
213
|
+
|
|
214
|
+
select is(
|
|
215
|
+
agent_board._membership_consent_present((select board_id from _ndb), (select agent_id from _ndsil))::text,
|
|
216
|
+
'false',
|
|
217
|
+
'#317: the offer did NOT mint consent — emitting the row state here would have turned an operator OFFER into an admission grant');
|
|
218
|
+
|
|
219
|
+
-- THE SECOND CONJUNCT of the suppression test, which nothing else reaches. `joined -> joined`
|
|
220
|
+
-- also does not move the state, but it IS the caller getting exactly what they asked for, and it
|
|
221
|
+
-- must still emit -- join_board and update_board_membership both permit it. Defining suppression
|
|
222
|
+
-- as "the state did not move" alone silently stops emitting for that call, and left the whole
|
|
223
|
+
-- suite green until this assertion existed.
|
|
224
|
+
select lives_ok(
|
|
225
|
+
$$ select agent_board._upsert_board_membership_state(
|
|
226
|
+
(select board_id from _ndb), (select agent_id from _ndsil), 'joined', (select owner_id from _nd)) $$,
|
|
227
|
+
'the silently-seated member is re-asserted as joined');
|
|
228
|
+
|
|
229
|
+
select is(
|
|
230
|
+
(select count(*)::text from agent_board.board_membership_events
|
|
231
|
+
where board_id = (select board_id from _ndb) and agent_id = (select agent_id from _ndsil)),
|
|
232
|
+
'1',
|
|
233
|
+
'#317: joined -> joined DOES emit — it moves no state but is not a suppressed offer, so the ledger records it');
|
|
234
|
+
|
|
235
|
+
-- ---------------------------------------------------------------------------
|
|
236
|
+
-- THE SUBSCRIBED ARM. The guard names two active states, and until this existed only
|
|
237
|
+
-- `joined` was exercised: narrowing the guard to `in ('joined')` -- restoring the whole
|
|
238
|
+
-- defect for every subscribed member -- left all 19 assertions green. Found by the review
|
|
239
|
+
-- board, and it is the sharpest kind of miss: the rule was stated correctly in the
|
|
240
|
+
-- migration and half of it was unverified.
|
|
241
|
+
-- ---------------------------------------------------------------------------
|
|
242
|
+
select lives_ok(
|
|
243
|
+
$$ select agent_board._upsert_board_membership_state(
|
|
244
|
+
(select board_id from _ndb), (select member_id from _nd), 'subscribed', (select member_id from _nd)) $$,
|
|
245
|
+
'the member self-toggles to subscribed');
|
|
246
|
+
select is(pg_temp.nd_state(), 'subscribed', 'the member is subscribed');
|
|
247
|
+
|
|
248
|
+
select lives_ok(
|
|
249
|
+
$$ select agent_board.invite_board_member(
|
|
250
|
+
(select board_id from _ndb), (select member_id from _nd), (select owner_id from _nd)) $$,
|
|
251
|
+
'inviting a SUBSCRIBED member does not raise');
|
|
252
|
+
select is(pg_temp.nd_state(), 'subscribed',
|
|
253
|
+
'#317: inviting a SUBSCRIBED member leaves them subscribed — the guard covers BOTH active states, not just joined');
|
|
254
|
+
|
|
255
|
+
-- Restore to subscribed FIRST, for the same reason the joined pair re-joins above: a mutation
|
|
256
|
+
-- that drops only the `invited` arm would have demoted this member on the assertion before
|
|
257
|
+
-- this one, and the pair below would then be testing requested-against-INVITED while reading
|
|
258
|
+
-- as though it tested requested-against-SUBSCRIBED. Round 1 fixed this on the joined pair and
|
|
259
|
+
-- left the subscribed pair contaminated; a review seat caught the other half.
|
|
260
|
+
select lives_ok(
|
|
261
|
+
$$ select agent_board._upsert_board_membership_state(
|
|
262
|
+
(select board_id from _ndb), (select member_id from _nd), 'subscribed', (select member_id from _nd)) $$,
|
|
263
|
+
'restored to subscribed, so the `requested` arm below is evidenced against SUBSCRIBED');
|
|
264
|
+
|
|
265
|
+
select lives_ok(
|
|
266
|
+
$$ select agent_board._upsert_board_membership_state(
|
|
267
|
+
(select board_id from _ndb), (select member_id from _nd), 'requested', (select member_id from _nd)) $$,
|
|
268
|
+
'a `requested` offer against a subscribed member does not raise');
|
|
269
|
+
select is(pg_temp.nd_state(), 'subscribed',
|
|
270
|
+
'#317: `requested` against a SUBSCRIBED member also leaves them subscribed');
|
|
271
|
+
|
|
272
|
+
-- Finding 1A: the ledger was pinned only at the JOINED stage, so the SUBSCRIBED arm of the
|
|
273
|
+
-- suppression could emit a contradicting event with nothing reading it. It can now be checked
|
|
274
|
+
-- here because nothing else writes this membership between the toggle and this point.
|
|
275
|
+
select is(
|
|
276
|
+
(select result_state from agent_board.board_membership_events
|
|
277
|
+
where board_id = (select board_id from _ndb) and agent_id = (select member_id from _nd)
|
|
278
|
+
order by seq desc limit 1),
|
|
279
|
+
pg_temp.nd_state(),
|
|
280
|
+
'#317: the latest event still matches the row at the SUBSCRIBED stage too — the suppression arms are pinned symmetrically');
|
|
281
|
+
|
|
282
|
+
-- restore to joined so the transitions below start from the state they describe
|
|
283
|
+
select lives_ok(
|
|
284
|
+
$$ select agent_board._upsert_board_membership_state(
|
|
285
|
+
(select board_id from _ndb), (select member_id from _nd), 'joined', (select member_id from _nd)) $$,
|
|
286
|
+
'back to joined for the transition checks');
|
|
287
|
+
|
|
288
|
+
-- ---------------------------------------------------------------------------
|
|
289
|
+
-- THE OTHER DIRECTION: everything legitimate still works. Without these, a guard
|
|
290
|
+
-- that simply refused all offers would pass the assertions above.
|
|
291
|
+
-- ---------------------------------------------------------------------------
|
|
292
|
+
select lives_ok(
|
|
293
|
+
$$ select agent_board._upsert_board_membership_state(
|
|
294
|
+
(select board_id from _ndb), (select member_id from _nd), 'subscribed', (select member_id from _nd)) $$,
|
|
295
|
+
'joined -> subscribed does not raise');
|
|
296
|
+
select is(pg_temp.nd_state(), 'subscribed', 'joined -> subscribed self-toggle still works');
|
|
297
|
+
|
|
298
|
+
select lives_ok(
|
|
299
|
+
$$ select agent_board._upsert_board_membership_state(
|
|
300
|
+
(select board_id from _ndb), (select member_id from _nd), 'removed', (select owner_id from _nd)) $$,
|
|
301
|
+
'active -> removed does not raise');
|
|
302
|
+
select is(pg_temp.nd_state(), 'removed', 'active -> removed still works');
|
|
303
|
+
|
|
304
|
+
-- Finding 1B: widening the suppression predicate to swallow a REAL transition left the suite
|
|
305
|
+
-- green, because nothing asserted that a genuine state change still reaches the ledger. An
|
|
306
|
+
-- append-only record that silently omits a removal is the same class of defect as one that
|
|
307
|
+
-- records a state the row does not have.
|
|
308
|
+
select is(
|
|
309
|
+
(select event_kind || ' -> ' || result_state from agent_board.board_membership_events
|
|
310
|
+
where board_id = (select board_id from _ndb) and agent_id = (select member_id from _nd)
|
|
311
|
+
order by seq desc limit 1),
|
|
312
|
+
'operator_removal -> removed',
|
|
313
|
+
'#317: a REAL transition still emits — suppression must not swallow an actual state change');
|
|
314
|
+
|
|
315
|
+
select lives_ok(
|
|
316
|
+
$$ select agent_board._upsert_board_membership_state(
|
|
317
|
+
(select board_id from _ndb), (select member_id from _nd), 'invited', (select owner_id from _nd)) $$,
|
|
318
|
+
're-inviting a REMOVED member does not raise — an OVER-BROAD guard trips board_memberships_state_left_at here, and a bare call would abort the file with NO `not ok` (verified)');
|
|
319
|
+
select is(pg_temp.nd_state(), 'invited',
|
|
320
|
+
're-inviting a REMOVED member still works — the guard is about weakening, not about offers');
|
|
321
|
+
|
|
322
|
+
-- ---------------------------------------------------------------------------
|
|
323
|
+
-- THE SECOND DOOR: invite-link redemption reaches an active membership by a
|
|
324
|
+
-- different statement shape (INSERT … ON CONFLICT DO UPDATE), so the guard in the
|
|
325
|
+
-- UPDATE path says nothing about it. Asserted behaviourally rather than by reading
|
|
326
|
+
-- the source: `_redeem_unbounded_invite_for_agent` lifts only `role`, and this is
|
|
327
|
+
-- what makes that claim evidence instead of a comment.
|
|
328
|
+
-- ---------------------------------------------------------------------------
|
|
329
|
+
select lives_ok(
|
|
330
|
+
$$ select agent_board._upsert_board_membership_state(
|
|
331
|
+
(select board_id from _ndb), (select member_id from _nd), 'joined', (select owner_id from _nd)) $$,
|
|
332
|
+
'the member is restored to joined for the invite-link probe');
|
|
333
|
+
|
|
334
|
+
-- The preset is `subscribed` while the member is `joined`, DELIBERATELY: a link presetting
|
|
335
|
+
-- the state the member already has cannot show whether redemption writes the column, so the
|
|
336
|
+
-- probe would pass no matter what the function did. (`invited` would be the sharper contrast
|
|
337
|
+
-- but create_invite_link rejects it -- "invalid preset membership state" -- so `subscribed`
|
|
338
|
+
-- is the strongest observable difference available.)
|
|
339
|
+
--
|
|
340
|
+
-- `select * from`, not a bare call: create_invite_link RETURNS TABLE, so a scalar
|
|
341
|
+
-- projection is a pseudo-type record and `create table as` errors -- which ABORTS the
|
|
342
|
+
-- transaction. The run then stops early and pgTAP emits NO `not ok` line at all, so the
|
|
343
|
+
-- failure is INVISIBLE rather than loud (message-board#113, and observed here).
|
|
344
|
+
--
|
|
345
|
+
-- Deliberately no assertion count in that sentence. It used to name one, and the number was
|
|
346
|
+
-- stale within a day of being written -- a hand-maintained count of a machine-derivable fact
|
|
347
|
+
-- is rule 1's own anti-pattern applied to prose. The invariant is that the failure prints
|
|
348
|
+
-- nothing; the exact stopping point moves every time an assertion is added.
|
|
349
|
+
-- lives_ok, not a bare statement. create_invite_link checks the creator's role against the
|
|
350
|
+
-- board authority ceiling, so ANY mutation that weakens the owner's own membership earlier in
|
|
351
|
+
-- this file makes it RAISE here -- and as a bare call that aborted the transaction at assertion
|
|
352
|
+
-- 38, hiding every catalog and structural assertion after it. Measured: three separate
|
|
353
|
+
-- behavioural mutations truncated the run to 38 of 52 that way, so a regression in the guard
|
|
354
|
+
-- could conceal a simultaneous regression in the class assertions. This is the file's own
|
|
355
|
+
-- subject matter applied to its own fixture.
|
|
356
|
+
create temp table _ndlink(invite_id uuid, board_id uuid, code text, preset_role text,
|
|
357
|
+
preset_membership_state text, max_uses int, uses_count int,
|
|
358
|
+
expires_at timestamptz, created_at timestamptz) on commit drop;
|
|
359
|
+
|
|
360
|
+
select lives_ok(
|
|
361
|
+
$$ insert into _ndlink select * from agent_board.create_invite_link(
|
|
362
|
+
(select board_id from _ndb), 'member', 'subscribed', null, null, (select owner_id from _nd)) $$,
|
|
363
|
+
'the owner can still create an invite link — a weakened owner membership fails HERE by name rather than aborting the file');
|
|
364
|
+
|
|
365
|
+
-- lives_ok, not a bare `is((select … from rpc(…)))`: redemption is a state-mutating
|
|
366
|
+
-- admission RPC, and a raise inside an assertion's value expression aborts the whole
|
|
367
|
+
-- file with no `not ok` (message-board#113).
|
|
368
|
+
select lives_ok(
|
|
369
|
+
$$ create temp table _ndcap as select * from agent_board._redeem_unbounded_invite_for_agent(
|
|
370
|
+
(select code from _ndlink), (select member_id from _nd)) $$,
|
|
371
|
+
'a JOINED member redeems a board invite link — redemption stays idempotent');
|
|
372
|
+
select is(pg_temp.nd_state(), 'joined',
|
|
373
|
+
'#317 second door: redeeming a `subscribed`-preset link leaves a JOINED member joined — the ON CONFLICT lifts role only');
|
|
374
|
+
|
|
375
|
+
-- ---------------------------------------------------------------------------
|
|
376
|
+
-- THE CLASS, derived from pg_proc rather than from a list written here.
|
|
377
|
+
--
|
|
378
|
+
-- The guard lives in the shared sink so every caller inherits it. A future function that
|
|
379
|
+
-- writes board_memberships.MEMBERSHIP_STATE directly would bypass it, and a hand-written list
|
|
380
|
+
-- of "the writers that matter" is what this repo's rule 1 forbids -- so the set is enumerated
|
|
381
|
+
-- from the catalog and these assertions NAME any new member.
|
|
382
|
+
--
|
|
383
|
+
-- WHY THE PREDICATE IS SHAPED THIS WAY -- each clause was forced by a mutation that
|
|
384
|
+
-- ESCAPED an earlier draft, not chosen for tidiness:
|
|
385
|
+
--
|
|
386
|
+
-- * `stripped_body`, not `pg_get_functiondef`. The generated header carries
|
|
387
|
+
-- `SET search_path = …` with no semicolon before the body, so `set[^;]*membership_state`
|
|
388
|
+
-- over the full definition matched functions that only READ the column (join_board,
|
|
389
|
+
-- onboard_federated_fleet_agent). The earlier draft hid that false positive behind an
|
|
390
|
+
-- `update agent_board.board_memberships` conjunct -- and that conjunct was itself the
|
|
391
|
+
-- hole below. stripped_body also strips comments and literals, so prose near a write
|
|
392
|
+
-- cannot satisfy a claim (message-board#196/#199), and it RAISES rather than returning
|
|
393
|
+
-- NULL for a body it cannot read, which a `where … ~ …` scan would drop silently.
|
|
394
|
+
--
|
|
395
|
+
-- * The statement alternation and the optional-qualifier spelling are TAKEN VERBATIM from
|
|
396
|
+
-- agent_board_receipt_subject_gate.test.sql rather than written fresh. That file already
|
|
397
|
+
-- paid for this: it records `merge into`, `update only`, `delete from only`,
|
|
398
|
+
-- `agent_board."board_memberships"` and `"agent_board" . "board_memberships"` as
|
|
399
|
+
-- VERIFIED-LEGAL spellings that were each invisible to a narrower pattern. A second pattern
|
|
400
|
+
-- written here would be a second list to drift -- rule 1 applied to the predicate itself.
|
|
401
|
+
-- The first draft of THIS file required `update agent_board.board_memberships` alone, and
|
|
402
|
+
-- FOUR separate decoys each left it 19/19 green: `insert ... on conflict ... do update set`,
|
|
403
|
+
-- `merge into`, an UNQUALIFIED `update board_memberships` under a search_path, and dynamic
|
|
404
|
+
-- SQL inside a string literal. The review board found three of the four; the fourth came
|
|
405
|
+
-- from testing its findings rather than accepting them.
|
|
406
|
+
--
|
|
407
|
+
-- * `stripped_body(p.oid, TRUE)` -- literals KEPT, deliberately, and this is the one place the
|
|
408
|
+
-- usual advice inverts. A writer assembled as dynamic SQL lives ENTIRELY inside a string
|
|
409
|
+
-- literal, so the default literal-stripping mode cannot see it. Comments are still stripped,
|
|
410
|
+
-- so prose naming the pattern still cannot satisfy the claim. Keeping literals can only
|
|
411
|
+
-- produce a LOUD false positive on an exact-set assertion, never a silent miss.
|
|
412
|
+
--
|
|
413
|
+
-- * `lower(...)` with `~` rather than `~*`: same effect, and it matches the neighbouring file
|
|
414
|
+
-- so the two cannot drift on case handling.
|
|
415
|
+
--
|
|
416
|
+
-- WHAT THIS SCAN CANNOT SEE, stated exactly rather than generously, because a scan that reports
|
|
417
|
+
-- clean on its own boundary forms is worth less than one that says which forms it declines to
|
|
418
|
+
-- judge. All of these were DEMONSTRATED against this file, not imagined:
|
|
419
|
+
--
|
|
420
|
+
-- * a body that ASSEMBLES the statement rather than containing it. Two demonstrated forms:
|
|
421
|
+
-- fragment concatenation (`'update agent_board.board_mem' || 'berships set ...'`) and
|
|
422
|
+
-- `EXECUTE format('update %I.%I set %I = %L', 'agent_board', 'board_memberships',
|
|
423
|
+
-- 'membership_state', 'invited')`, where the identifiers live in format ARGUMENTS so neither
|
|
424
|
+
-- the table nor the column is ever adjacent to the keyword. Keeping literals is what makes a
|
|
425
|
+
-- single COMPLETE literal statement visible; it does nothing for an assembled one, because no
|
|
426
|
+
-- regex can concatenate or interpolate. Neither can be closed by a better pattern -- only by
|
|
427
|
+
-- a parser -- so both are disclosed and then BOUNDED structurally by the privilege assertion
|
|
428
|
+
-- further down, which holds however the SQL is spelt.
|
|
429
|
+
-- * a writer in a schema other than `agent_board` (the `nspname` filter). Same bound applies.
|
|
430
|
+
--
|
|
431
|
+
-- Both are recorded rather than closed. The privilege assertion is the reason that is an
|
|
432
|
+
-- acceptable answer instead of a shrug: it does not identify such a writer, but it proves one
|
|
433
|
+
-- could only exist as a SECURITY DEFINER function, which is the population this scan reads.
|
|
434
|
+
--
|
|
435
|
+
-- * `prokind in ('f','p')`. A PROCEDURE writer is exempt under `prokind = 'f'`.
|
|
436
|
+
--
|
|
437
|
+
-- * `\m` word boundaries. Without them the role canary below silently matched nothing.
|
|
438
|
+
--
|
|
439
|
+
-- A `BEGIN ATOMIC` writer is covered too: stripped_body falls back to
|
|
440
|
+
-- pg_get_function_sqlbody, verified with a BEGIN ATOMIC decoy that this assertion named.
|
|
441
|
+
select is(
|
|
442
|
+
(select coalesce(array_agg(p.proname::text order by p.proname::text), array[]::text[])
|
|
443
|
+
from pg_proc p
|
|
444
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
445
|
+
where n.nspname = 'agent_board'
|
|
446
|
+
and p.prokind in ('f', 'p')
|
|
447
|
+
and p.prolang <> (select oid from pg_language where lanname = 'internal')
|
|
448
|
+
and lower(agent_board_test.stripped_body(p.oid, true))
|
|
449
|
+
~ '(insert\s+into|merge\s+into|update|delete\s+from|copy|truncate(\s+table)?)(\s+only)?\s+(("?agent_board"?\s*\.\s*)?board_memberships\M|("?agent_board"?\s*\.\s*)?"board_memberships")'),
|
|
450
|
+
array['_fedsession_autojoin_default_board', '_redeem_unbounded_invite_for_agent',
|
|
451
|
+
'_upsert_board_membership_state', 'bootstrap_board_owner_memberships',
|
|
452
|
+
'create_board', 'grant_board_role']::text[],
|
|
453
|
+
'#317: the set of functions that WRITE board_memberships is exactly these six — a new writer fails here NAMING itself');
|
|
454
|
+
|
|
455
|
+
-- The direction claim itself: of those writers, only the shared sink assigns
|
|
456
|
+
-- membership_state in a SET clause, so only the sink can move an EXISTING row between
|
|
457
|
+
-- states -- and it is the one carrying the guard. The other five are exempt because of
|
|
458
|
+
-- what they write, and that is asserted here as a set rather than by leaving names out;
|
|
459
|
+
-- an exemption claimed by absence is this repo's named soft spot.
|
|
460
|
+
select is(
|
|
461
|
+
(select coalesce(array_agg(p.proname::text order by p.proname::text), array[]::text[])
|
|
462
|
+
from pg_proc p
|
|
463
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
464
|
+
where n.nspname = 'agent_board'
|
|
465
|
+
and p.prokind in ('f', 'p')
|
|
466
|
+
and p.prolang <> (select oid from pg_language where lanname = 'internal')
|
|
467
|
+
and lower(agent_board_test.stripped_body(p.oid, true))
|
|
468
|
+
~ '(insert\s+into|merge\s+into|update|delete\s+from|copy|truncate(\s+table)?)(\s+only)?\s+(("?agent_board"?\s*\.\s*)?board_memberships\M|("?agent_board"?\s*\.\s*)?"board_memberships")'
|
|
469
|
+
and lower(agent_board_test.stripped_body(p.oid, true))
|
|
470
|
+
~ '(set\s[^;]*\mmembership_state"?\s*=|set\s*\([^;()]*\mmembership_state"?[^;()]*\)\s*=)'),
|
|
471
|
+
array['_upsert_board_membership_state']::text[],
|
|
472
|
+
'#317: the shared sink is the ONLY writer that assigns membership_state IN A SET CLAUSE — i.e. the only one that can MOVE an existing row between states');
|
|
473
|
+
|
|
474
|
+
-- POSITIVE CANARY for the SET-clause predicate the assertion above depends on. Without
|
|
475
|
+
-- this, a regex that stopped matching SET clauses at all would make that assertion green
|
|
476
|
+
-- and empty -- the exact fail-open this file exists to prevent. These five DO assign
|
|
477
|
+
-- `role` in a SET clause, so the predicate is proven to still see real SET clauses.
|
|
478
|
+
select is(
|
|
479
|
+
(select coalesce(array_agg(p.proname::text order by p.proname::text), array[]::text[])
|
|
480
|
+
from pg_proc p
|
|
481
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
482
|
+
where n.nspname = 'agent_board'
|
|
483
|
+
and p.prokind in ('f', 'p')
|
|
484
|
+
and p.prolang <> (select oid from pg_language where lanname = 'internal')
|
|
485
|
+
and lower(agent_board_test.stripped_body(p.oid, true))
|
|
486
|
+
~ '(insert\s+into|merge\s+into|update|delete\s+from|copy|truncate(\s+table)?)(\s+only)?\s+(("?agent_board"?\s*\.\s*)?board_memberships\M|("?agent_board"?\s*\.\s*)?"board_memberships")'
|
|
487
|
+
and lower(agent_board_test.stripped_body(p.oid, true)) ~ 'set\s[^;]*\mrole\s*='),
|
|
488
|
+
array['_redeem_unbounded_invite_for_agent', '_upsert_board_membership_state',
|
|
489
|
+
'bootstrap_board_owner_memberships', 'create_board', 'grant_board_role']::text[],
|
|
490
|
+
'#317 canary: the SET-clause predicate still matches real SET clauses (these five assign `role`) — so an empty membership_state result means empty, not blind');
|
|
491
|
+
|
|
492
|
+
-- THE DOORS THAT ARE NOT FUNCTION BODIES. Every predicate above reads function source, so
|
|
493
|
+
-- anything that writes membership_state WITHOUT a function body naming the table is invisible
|
|
494
|
+
-- to all of them. Three such doors exist and all three are asserted, because two of them were
|
|
495
|
+
-- found only by a review seat asking what was not a function:
|
|
496
|
+
--
|
|
497
|
+
-- * a TRIGGER assigning NEW.membership_state;
|
|
498
|
+
-- * a non-SELECT RULE rewriting a statement onto the table;
|
|
499
|
+
-- * an auto-updatable VIEW -- `update agent_board.<view> set membership_state = ...` is
|
|
500
|
+
-- rewritten by PostgreSQL onto the base table with NO trigger and NO mention of
|
|
501
|
+
-- `board_memberships` anywhere in the writer's body. Verified: a decoy view plus a writer
|
|
502
|
+
-- through it left the whole suite green before this assertion existed.
|
|
503
|
+
--
|
|
504
|
+
-- The view set is pinned by NAME rather than by "no views exist", because two legitimately do.
|
|
505
|
+
-- A new view over this table is then a loud event that forces its author to decide whether it
|
|
506
|
+
-- is writable, which is the decision that would otherwise be made silently.
|
|
507
|
+
select is(
|
|
508
|
+
(select coalesce(array_agg(t.tgname::text order by t.tgname::text), array[]::text[])
|
|
509
|
+
from pg_trigger t
|
|
510
|
+
join pg_class c on c.oid = t.tgrelid
|
|
511
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
512
|
+
where n.nspname = 'agent_board' and c.relname = 'board_memberships'
|
|
513
|
+
and not t.tgisinternal),
|
|
514
|
+
array[]::text[],
|
|
515
|
+
'#317: no user trigger on board_memberships — a trigger assigning NEW.membership_state would bypass every source predicate above');
|
|
516
|
+
|
|
517
|
+
select is(
|
|
518
|
+
(select coalesce(array_agg(r.rulename::text order by r.rulename::text), array[]::text[])
|
|
519
|
+
from pg_rewrite r
|
|
520
|
+
join pg_class c on c.oid = r.ev_class
|
|
521
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
522
|
+
where n.nspname = 'agent_board' and c.relname = 'board_memberships'
|
|
523
|
+
and r.ev_type <> '1'),
|
|
524
|
+
array[]::text[],
|
|
525
|
+
'#317: no INSERT/UPDATE/DELETE rule on board_memberships — a rule rewrites a statement onto the table with no function body naming it');
|
|
526
|
+
|
|
527
|
+
-- ---------------------------------------------------------------------------
|
|
528
|
+
-- THE STRUCTURAL BOUND, which is the one claim here that a regex cannot be talked out of.
|
|
529
|
+
--
|
|
530
|
+
-- Everything above enumerates writers by READING SOURCE, and a source scan has a floor: a
|
|
531
|
+
-- body that assembles `update agent_board.board_' || 'memberships set ...` from separate
|
|
532
|
+
-- string fragments and EXECUTEs the result is invisible to any pattern, because no regex can
|
|
533
|
+
-- concatenate. That escape was demonstrated against this very file (it stayed 35/35 green),
|
|
534
|
+
-- and it cannot be closed by a better regex -- only by a parser.
|
|
535
|
+
--
|
|
536
|
+
-- What CAN be stated without reading source: nobody but the table owner may write this table
|
|
537
|
+
-- at all. `authenticated`, `anon` and `service_role` hold no INSERT/UPDATE/DELETE, so every
|
|
538
|
+
-- write -- fragment-assembled or not -- must go through a SECURITY DEFINER function owned by
|
|
539
|
+
-- the owner. That does not identify the writer, but it bounds WHERE one can exist to the set
|
|
540
|
+
-- the scan above enumerates, and it holds regardless of how the statement is spelled.
|
|
541
|
+
--
|
|
542
|
+
-- has_table_privilege, not a pg_class.relacl or information_schema read: it resolves privileges
|
|
543
|
+
-- inherited through role membership and PUBLIC defaults, which a direct-grant read misses --
|
|
544
|
+
-- the fail-open shape #302 corrected and #118's drop/recreate class. Verified on a built
|
|
545
|
+
-- database that all three routes read false here, including the PUBLIC default, and that none
|
|
546
|
+
-- of these roles reaches the owner via pg_has_role.
|
|
547
|
+
--
|
|
548
|
+
-- SCOPE, because "no role may write it" would otherwise overstate: this asserts the three
|
|
549
|
+
-- APPLICATION principals. A SUPERUSER (supabase_admin) and the owner itself are outside any
|
|
550
|
+
-- in-database privilege check by construction -- no assertion written in SQL can bound them,
|
|
551
|
+
-- and saying so is more useful than a claim that quietly excludes them. `service_role` carries
|
|
552
|
+
-- BYPASSRLS, which is deliberately NOT the same thing: it defeats row-level policies and does
|
|
553
|
+
-- nothing about table privileges, so it still holds no UPDATE here.
|
|
554
|
+
-- ---------------------------------------------------------------------------
|
|
555
|
+
select is(
|
|
556
|
+
(select coalesce(array_agg(g.grant_found order by g.grant_found), array[]::text[])
|
|
557
|
+
from (
|
|
558
|
+
select r.rolname || ':' || t.tbl || ':' || t.priv as grant_found
|
|
559
|
+
from (values ('authenticated'), ('anon'), ('service_role')) as r(rolname)
|
|
560
|
+
cross join (values
|
|
561
|
+
-- direct writes to the table
|
|
562
|
+
('agent_board.board_memberships', 'INSERT'),
|
|
563
|
+
('agent_board.board_memberships', 'UPDATE'),
|
|
564
|
+
('agent_board.board_memberships', 'DELETE'),
|
|
565
|
+
-- TRUNCATE is a WRITE by this file's own definition: the statement scan
|
|
566
|
+
-- above counts `truncate` as a writer. Checking only INSERT/UPDATE/DELETE
|
|
567
|
+
-- made the privilege bound NARROWER than the scan it is supposed to
|
|
568
|
+
-- backstop -- a role holding only TRUNCATE could empty the table and fail
|
|
569
|
+
-- neither. Found by a review seat comparing the two lists against each other.
|
|
570
|
+
('agent_board.board_memberships', 'TRUNCATE'),
|
|
571
|
+
-- THE CASCADE PARENTS, and they are load-bearing rather than thorough.
|
|
572
|
+
-- board_id and agent_id are ON DELETE CASCADE, so deleting a board or an
|
|
573
|
+
-- agent DESTROYS membership rows through internal FK triggers -- with no
|
|
574
|
+
-- privilege on board_memberships and no SECURITY DEFINER function anywhere.
|
|
575
|
+
-- Measured: 2 membership rows -> 0 on one `delete from agent_board.boards`.
|
|
576
|
+
-- That is exactly the write class this assertion claimed could not exist,
|
|
577
|
+
-- reached through a table the assertion never mentioned. It is closed in
|
|
578
|
+
-- the shipped grant set, and now it is closed BY ASSERTION rather than by
|
|
579
|
+
-- the accident of nobody having granted it. (It also reproduces #317's
|
|
580
|
+
-- original symptom -- a member with no visible membership -- by a different
|
|
581
|
+
-- mechanism, which is worth knowing next time that symptom is reported.)
|
|
582
|
+
('agent_board.boards', 'DELETE'),
|
|
583
|
+
('agent_board.agents', 'DELETE')
|
|
584
|
+
) as t(tbl, priv)
|
|
585
|
+
where has_table_privilege(r.rolname, t.tbl, t.priv)
|
|
586
|
+
) g),
|
|
587
|
+
array[]::text[],
|
|
588
|
+
'#317 structural bound: no application role can write board_memberships directly, empty it, or destroy its rows through an ON DELETE CASCADE parent');
|
|
589
|
+
|
|
590
|
+
select is(
|
|
591
|
+
(select coalesce(array_agg(p.proname::text order by p.proname::text), array[]::text[])
|
|
592
|
+
from pg_proc p
|
|
593
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
594
|
+
where n.nspname = 'agent_board'
|
|
595
|
+
and p.proname in ('_fedsession_autojoin_default_board', '_redeem_unbounded_invite_for_agent',
|
|
596
|
+
'_upsert_board_membership_state', 'bootstrap_board_owner_memberships',
|
|
597
|
+
'create_board', 'grant_board_role')
|
|
598
|
+
and not p.prosecdef),
|
|
599
|
+
array[]::text[],
|
|
600
|
+
'#317: every one of the six FUNCTION writers is SECURITY DEFINER — a function writer that is not would need a direct table grant, which the assertion above forbids (FK cascade is covered there, not here)');
|
|
601
|
+
|
|
602
|
+
select is(
|
|
603
|
+
(select coalesce(array_agg(distinct c.relname::text order by c.relname::text), array[]::text[])
|
|
604
|
+
from pg_depend d
|
|
605
|
+
join pg_rewrite r on r.oid = d.objid
|
|
606
|
+
join pg_class c on c.oid = r.ev_class
|
|
607
|
+
join pg_class t on t.oid = d.refobjid
|
|
608
|
+
join pg_namespace tn on tn.oid = t.relnamespace
|
|
609
|
+
join pg_namespace cn on cn.oid = c.relnamespace
|
|
610
|
+
where tn.nspname = 'agent_board' and t.relname = 'board_memberships'
|
|
611
|
+
and cn.nspname = 'agent_board' and c.relkind in ('v', 'm')),
|
|
612
|
+
array['v_board_directory', 'v_board_memberships']::text[],
|
|
613
|
+
'#317: exactly these two views read board_memberships — a NEW view is auto-updatable by default, and an update through it reaches the table with nothing naming it');
|
|
614
|
+
|
|
615
|
+
-- ---------------------------------------------------------------------------
|
|
616
|
+
-- THE WHOLE INPUT SPACE, not a chosen path through it. Every assertion above walks ONE
|
|
617
|
+
-- scenario, and a scenario can only show that the cases someone thought of behave. This
|
|
618
|
+
-- drives all 6x6 (prior state, requested state) pairs through the sink and asserts two
|
|
619
|
+
-- things about the result set as a whole:
|
|
620
|
+
--
|
|
621
|
+
-- * the suppressed set is EXACTLY the four offer-against-active pairs, named. A guard
|
|
622
|
+
-- that is too narrow drops a pair; one that is too broad adds one; either way the
|
|
623
|
+
-- assertion prints the actual set, so it says what went wrong rather than just that
|
|
624
|
+
-- something did.
|
|
625
|
+
-- * no pair RAISES. The over-broad-guard mutation trips
|
|
626
|
+
-- `board_memberships_state_left_at` on a transition no scenario above visits, and a
|
|
627
|
+
-- raise inside a scenario is what truncates a pgTAP file silently.
|
|
628
|
+
--
|
|
629
|
+
-- The ledger is cleared between seating the prior state and the call under test, so the
|
|
630
|
+
-- count measures only that call.
|
|
631
|
+
-- ---------------------------------------------------------------------------
|
|
632
|
+
create temp table _ndmx(prior text, req text, after_state text, events int, err text) on commit drop;
|
|
633
|
+
|
|
634
|
+
select lives_ok($$
|
|
635
|
+
do $mx$
|
|
636
|
+
declare
|
|
637
|
+
st text[] := array['requested','invited','joined','subscribed','left','removed'];
|
|
638
|
+
p text; r text; v_b uuid; v_a uuid; v_o uuid; n int; after_s text; i int := 0;
|
|
639
|
+
begin
|
|
640
|
+
foreach p in array st loop
|
|
641
|
+
foreach r in array st loop
|
|
642
|
+
i := i + 1;
|
|
643
|
+
begin
|
|
644
|
+
perform agent_board.register_agent('agent','ndmx-o-'||i,'O');
|
|
645
|
+
perform agent_board.register_agent('agent','ndmx-m-'||i,'M');
|
|
646
|
+
select a.id into v_o from agent_board.agents a where a.handle='ndmx-o-'||i;
|
|
647
|
+
select a.id into v_a from agent_board.agents a where a.handle='ndmx-m-'||i;
|
|
648
|
+
perform agent_board.create_board(v_o,'ndmx-b-'||i,'B');
|
|
649
|
+
select b.id into v_b from agent_board.boards b where b.slug='ndmx-b-'||i;
|
|
650
|
+
perform agent_board._upsert_board_membership_state(v_b, v_a, p, v_o);
|
|
651
|
+
delete from agent_board.board_membership_events where board_id=v_b and agent_id=v_a;
|
|
652
|
+
perform agent_board._upsert_board_membership_state(v_b, v_a, r, v_o);
|
|
653
|
+
select membership_state into after_s from agent_board.board_memberships
|
|
654
|
+
where board_id=v_b and agent_id=v_a
|
|
655
|
+
order by case when membership_state in ('joined','subscribed') then 0 else 1 end,
|
|
656
|
+
created_at desc, id desc limit 1;
|
|
657
|
+
select count(*) into n from agent_board.board_membership_events
|
|
658
|
+
where board_id=v_b and agent_id=v_a;
|
|
659
|
+
insert into _ndmx values (p, r, after_s, n, null);
|
|
660
|
+
exception when others then
|
|
661
|
+
insert into _ndmx values (p, r, null, null, substr(sqlerrm,1,60));
|
|
662
|
+
end;
|
|
663
|
+
end loop;
|
|
664
|
+
end loop;
|
|
665
|
+
end $mx$;
|
|
666
|
+
$$, 'the 6x6 transition matrix runs to completion');
|
|
667
|
+
|
|
668
|
+
select is(
|
|
669
|
+
(select coalesce(array_agg(prior || '+' || req order by prior, req), array[]::text[])
|
|
670
|
+
from _ndmx where err is not null),
|
|
671
|
+
array[]::text[],
|
|
672
|
+
'#317 matrix: no (prior, requested) pair RAISES — a raise here is the silent-truncation failure mode, and no scenario above visits every pair');
|
|
673
|
+
|
|
674
|
+
select is(
|
|
675
|
+
(select coalesce(array_agg(prior || '+' || req order by prior, req), array[]::text[])
|
|
676
|
+
from _ndmx where err is null and events = 0),
|
|
677
|
+
array['joined+invited', 'joined+requested', 'subscribed+invited', 'subscribed+requested']::text[],
|
|
678
|
+
'#317 matrix: EXACTLY the four offer-against-active pairs are suppressed — too narrow drops one, too broad adds one, and this names which');
|
|
679
|
+
|
|
680
|
+
select is(
|
|
681
|
+
(select coalesce(array_agg(prior || '+' || req || '->' || coalesce(after_state,'?') order by prior, req), array[]::text[])
|
|
682
|
+
from _ndmx
|
|
683
|
+
where err is null
|
|
684
|
+
and events <> (case when after_state is not distinct from prior and req is distinct from after_state then 0 else 1 end)),
|
|
685
|
+
array[]::text[],
|
|
686
|
+
'#317 matrix: across all 36 pairs the ledger is written exactly when the state moved or the caller got what they asked for');
|
|
687
|
+
|
|
688
|
+
select * from finish();
|
|
689
|
+
rollback;
|