@consiliency/agent-board-schema 1.48.0 → 1.50.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 +12 -0
- package/migrations/20260929000100_agent_board_advisory_revert_cap_reset.sql +371 -0
- package/migrations/20260930000100_agent_board_claim_policy_race.sql +922 -0
- package/migrations/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql +158 -0
- package/package.json +1 -1
- package/schema-manifest.json +30 -3
- package/src/tooling.test.mjs +25 -2
- package/tests/agent_board_board_claim_policy.test.sql +261 -1
- package/tests/agent_board_claim_subject_gate.test.sql +11 -2
- package/tests/agent_board_claimkey.test.sql +17 -2
- package/tests/agent_board_claimpolicy_race.test.sql +1374 -0
- package/tests/agent_board_ownerless_quota_census.test.sql +6 -0
- package/tests/agent_board_ownerless_renew_quota.test.sql +37 -8
- package/tests/catalog/p_metadata_parameters.json +16 -0
- package/tests/fixtures/test-prereqs.sql +186 -0
- package/tests/isolation/expected/claimpolicy_acquire_revert_race.out +66 -0
- package/tests/isolation/expected/claimpolicy_board_scope.out +73 -0
- package/tests/isolation/expected/claimpolicy_claimrow_lock_order.out +80 -0
- package/tests/isolation/expected/claimpolicy_isolation_refusal.out +114 -0
- package/tests/isolation/expected/claimpolicy_renew_revert_race.out +66 -0
- package/tests/isolation/expected/claimpolicy_writer_keyshare.out +49 -0
- package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +118 -0
- package/tests/isolation/specs/claimpolicy_board_scope.spec +115 -0
- package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +200 -0
- package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +162 -0
- package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +150 -0
- package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +107 -0
- package/tests/seeds/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql.pre.sql +99 -0
|
@@ -0,0 +1,1374 @@
|
|
|
1
|
+
-- #405 — the stale-policy race family: the CATALOG half.
|
|
2
|
+
--
|
|
3
|
+
-- WHAT THIS FILE CAN AND CANNOT SEE, said first because the division is the whole point. A pgTAP
|
|
4
|
+
-- file runs in ONE rolled-back transaction, so it cannot produce the interleaving this issue is
|
|
5
|
+
-- about, and it cannot change its own isolation level (that must be the transaction's first
|
|
6
|
+
-- statement). Every BEHAVIOURAL claim about the race — that a reader waits, that it re-reads the
|
|
7
|
+
-- committed policy, that a REPEATABLE READ caller is refused, that two boards do not serialise
|
|
8
|
+
-- against each other — lives in the isolation specs:
|
|
9
|
+
--
|
|
10
|
+
-- claimpolicy_acquire_revert_race.spec the advisory-SKIP path, the one a "lock before the
|
|
11
|
+
-- agent lock" placement never reaches
|
|
12
|
+
-- claimpolicy_renew_revert_race.spec the re-read AND the claim-state revalidation
|
|
13
|
+
-- claimpolicy_board_scope.spec the twins: a different board does not serialise
|
|
14
|
+
-- claimpolicy_isolation_refusal.spec REPEATABLE READ is refused, by name
|
|
15
|
+
-- claimpolicy_writer_keyshare.spec the writer-side KEY SHARE cycle, measured both ways
|
|
16
|
+
-- claimpolicy_claimrow_lock_order.spec reclaim_fleet_machine against the policy writer, and
|
|
17
|
+
-- the claim-row -> board-lock inversion CONSTRUCTED by
|
|
18
|
+
-- hand, so the quiet permutation is evidence about the
|
|
19
|
+
-- code rather than about the probe
|
|
20
|
+
--
|
|
21
|
+
-- WHAT THIS FILE PINS is the part a spec cannot: that the CLASS is complete and comes from the
|
|
22
|
+
-- catalog, that each exemption is asserted positively rather than by absence, and that the two
|
|
23
|
+
-- premises the advisory lock rests on — `set_board_claim_policy` is the sole writer of
|
|
24
|
+
-- `boards.claim_policy`, and `threads.board_id` never moves once set — are still true.
|
|
25
|
+
--
|
|
26
|
+
-- WHAT AN ALL-GREEN RUN OF THIS FILE ESTABLISHES, AND WHAT IT DOES NOT. Read this before citing a
|
|
27
|
+
-- green as evidence. Most assertions here are TRIPWIRES over source text and catalog state, and a
|
|
28
|
+
-- tripwire that fails OPEN is green for the same reason a correct one is: 35/35 here, 124/124 for
|
|
29
|
+
-- the suite and 34/34 for the isolation goldens are all consistent with a pattern that silently
|
|
30
|
+
-- matches nothing. Six review rounds found exactly that six times — a window too small for a real
|
|
31
|
+
-- upsert, an alternation with no path to `for key share`, a complement keyed by name so an overload
|
|
32
|
+
-- escaped it. Each was green until someone injected the shape and watched. So: a green run means no
|
|
33
|
+
-- assertion's expected value changed; it does NOT mean the properties are held. Only a MUTATION
|
|
34
|
+
-- that produces a NAMED failure establishes that a given property is pinned at all.
|
|
35
|
+
--
|
|
36
|
+
-- WHAT LIVES ONLY IN THE ISOLATION SUITE, so nobody reads this file as the whole gate: the race
|
|
37
|
+
-- itself (a reader waits and then observes the COMMITTED policy, including acquire's advisory-skip
|
|
38
|
+
-- path), renew's claim revalidation after the wait, the 0A000 refusal by name at three isolation
|
|
39
|
+
-- levels, cross-board non-serialisation, and the writer-side KEY SHARE vs FOR UPDATE
|
|
40
|
+
-- discrimination. Nothing here replaces claimpolicy_board_scope.spec. ISOLATION IS NOT OPTIONAL CI
|
|
41
|
+
-- FOR THIS FEATURE — a green pgTAP run with the isolation suite skipped establishes none of those.
|
|
42
|
+
--
|
|
43
|
+
-- THE MATCHERS' BEHAVIOUR TABLE lives in tests/fixtures/test-prereqs.sql beside the functions, and
|
|
44
|
+
-- its ACCEPT rows are the cheap half. Four defects shipped in a matcher whose accept rows all
|
|
45
|
+
-- passed: a literal space inside a multiword verb (so `INSERT\nINTO` evaded), a raw lower-case
|
|
46
|
+
-- column token (so `SET CLAIM_POLICY` evaded), `$` missing from the identifier class (so a DISTINCT
|
|
47
|
+
-- function `x$_claimgate_lock_board_policy` was credited as the protected helper), and a caller
|
|
48
|
+
-- passing `'i'` (so `"AGENT_BOARD"."claims"` folded onto `agent_board.claims`). Every one is a
|
|
49
|
+
-- REJECT row, and none was derivable from the spellings this file happens to contain — the corpus
|
|
50
|
+
-- uses single-space `INSERT INTO` and unquoted `agent_board`, so a green suite said nothing about
|
|
51
|
+
-- any of them. Reject rows must come from PostgreSQL's identifier and keyword rules, not from the
|
|
52
|
+
-- current bodies; a matcher's whole job is discrimination, so most of its table is what it refuses.
|
|
53
|
+
--
|
|
54
|
+
-- THREE LITERAL `agent_board\.` SPELLINGS SURVIVE ON PURPOSE, named here so "every site uses the
|
|
55
|
+
-- shared matcher" is not read as "no literal exists" — it is 67 matcher invocations across 41 lines
|
|
56
|
+
-- PLUS these three. They are PROBES OF ONE KNOWN BODY rather than population derivations, which is
|
|
57
|
+
-- a different instrument: a population matcher that misses a spelling drops a member SILENTLY,
|
|
58
|
+
-- while a probe that misses its target returns 0 or false and takes its assertion RED. Both were
|
|
59
|
+
-- mutation-checked rather than argued:
|
|
60
|
+
-- * CPR-12's first-board-wins guard — rewriting the guarded writer as `update "agent_board"."threads"`
|
|
61
|
+
-- makes the label fall to UNGUARDED and CPR-12 fails.
|
|
62
|
+
-- * CPR-3c's two position probes — rewriting renew's post-lock re-read as unqualified `from claims`
|
|
63
|
+
-- makes the probe return 0 and CPR-3c fails.
|
|
64
|
+
-- Converting them would be harmless but would not buy anything, and the exemption is cheaper to
|
|
65
|
+
-- audit written down than rediscovered.
|
|
66
|
+
--
|
|
67
|
+
-- ONE AXIS THAT HAS BEEN WRONG IN BOTH DIRECTIONS, called out because it keeps recurring: whether an
|
|
68
|
+
-- assertion should key on proname or on OID/signature depends on the question it asks, and there is
|
|
69
|
+
-- no safe default. A property of the NAME ("nothing called X is executable by a non-owner", CPR-22)
|
|
70
|
+
-- needs the name scan, and pinning a signature there lets an overload carry the grant. A property of
|
|
71
|
+
-- a SPECIFIC function (CPR-25a's guard shape, CPR-23/24's ACLs) needs to_regprocedure, and scanning
|
|
72
|
+
-- by name there returns multiple rows — which for a scalar subquery ABORTS the file and emits no
|
|
73
|
+
-- `not ok` at all. A set-membership test (CPR-14b) must compare OIDs, or one censused overload
|
|
74
|
+
-- excludes a same-named sibling from the complement. All three were live defects, not hypotheticals.
|
|
75
|
+
--
|
|
76
|
+
-- CPR-1 IS THE ONE THIS FILE EXISTS FOR. An advisory lock only excludes callers that also take it.
|
|
77
|
+
-- So "which functions resolve a board's claim policy" is not a documentation question here, it is
|
|
78
|
+
-- the soundness question, and a list written by hand is exactly what this repo's rule 1 forbids.
|
|
79
|
+
-- The set is derived from `pg_proc` + `pg_get_viewdef` on every run; a fifth resolver fails CPR-1
|
|
80
|
+
-- and NAMES ITSELF instead of silently inheriting an exemption nobody decided.
|
|
81
|
+
create extension if not exists pgtap with schema extensions;
|
|
82
|
+
set search_path = extensions, public, agent_board;
|
|
83
|
+
|
|
84
|
+
begin;
|
|
85
|
+
|
|
86
|
+
select plan(41);
|
|
87
|
+
|
|
88
|
+
-- ---------------------------------------------------------------------------
|
|
89
|
+
-- Fixture: one board, one thread on it, one thread with NO board (the constitutive-'inherit'
|
|
90
|
+
-- case that CPR-19/20 pin the composition against).
|
|
91
|
+
-- ---------------------------------------------------------------------------
|
|
92
|
+
create temp table _cpr (board uuid, thread uuid, free_thread uuid, board_b uuid) on commit drop;
|
|
93
|
+
|
|
94
|
+
do $$
|
|
95
|
+
declare
|
|
96
|
+
v_sfx text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
97
|
+
v_owner uuid := gen_random_uuid();
|
|
98
|
+
v_owner_user uuid;
|
|
99
|
+
v_board uuid;
|
|
100
|
+
v_thread uuid := gen_random_uuid();
|
|
101
|
+
v_free uuid := gen_random_uuid();
|
|
102
|
+
-- A SECOND board, existing only for CPR-22a. It must not be the board CPR-25 locks: the whole
|
|
103
|
+
-- pgTAP file is ONE transaction, `pg_advisory_xact_lock` holds to the end of it, and re-taking a
|
|
104
|
+
-- key already held adds no pg_locks row. Sharing a board would make CPR-22a's state check pass on
|
|
105
|
+
-- CPR-25's lock no matter what CPR-22a itself did — measured, not feared: with the helper call
|
|
106
|
+
-- deleted from set_board_claim_policy, the shared-board version of CPR-22a and its state check
|
|
107
|
+
-- both stayed GREEN.
|
|
108
|
+
v_board_b uuid;
|
|
109
|
+
begin
|
|
110
|
+
v_owner_user := public.ensure_github_user('cpr-own-' || v_sfx, 'cpr-own-' || v_sfx || '@example.test',
|
|
111
|
+
'cpr-own-' || v_sfx, 'CPR Owner');
|
|
112
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
|
|
113
|
+
values (v_owner, 'agent', 'cpr-own-' || v_sfx, 'CPR Owner', 'active', v_owner_user, v_owner_user);
|
|
114
|
+
|
|
115
|
+
-- create_board seeds the creator's owner membership (ROOMOWN), which is what
|
|
116
|
+
-- set_board_claim_policy's gate tests.
|
|
117
|
+
perform set_config('request.jwt.claim.role', 'authenticated', true);
|
|
118
|
+
perform set_config('request.jwt.claim.sub', v_owner_user::text, true);
|
|
119
|
+
select board_id into v_board
|
|
120
|
+
from agent_board.create_board(v_owner, 'cpr-' || v_sfx, 'CPR Board', 'claim policy race', '{}'::jsonb, 'private');
|
|
121
|
+
-- Opted out by direct UPDATE rather than through the RPC. Deliberate: the RPC now takes the
|
|
122
|
+
-- lock helper, so a MUTATION of that helper would abort this file in its FIXTURE and emit no
|
|
123
|
+
-- `not ok` at all -- rule 3's exact failure shape. Set the column directly and CPR-25 fails
|
|
124
|
+
-- BY NAME instead. The RPC's own transitions are BCP-11..21's subject, not this file's.
|
|
125
|
+
update agent_board.boards set claim_policy = 'advisory' where id = v_board;
|
|
126
|
+
|
|
127
|
+
insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
|
|
128
|
+
values (v_thread, 'coordination', v_owner, 'CPR board thread', v_board),
|
|
129
|
+
(v_free, 'coordination', v_owner, 'CPR boardless thread', null);
|
|
130
|
+
|
|
131
|
+
select board_id into v_board_b
|
|
132
|
+
from agent_board.create_board(v_owner, 'cpr-b-' || v_sfx, 'CPR Board B', 'claim policy race (CPR-22a)', '{}'::jsonb, 'private');
|
|
133
|
+
update agent_board.boards set claim_policy = 'advisory' where id = v_board_b;
|
|
134
|
+
|
|
135
|
+
insert into _cpr(board, thread, free_thread, board_b) values (v_board, v_thread, v_free, v_board_b);
|
|
136
|
+
end $$;
|
|
137
|
+
|
|
138
|
+
-- ===========================================================================
|
|
139
|
+
-- THE CLASS (rule 1). Catalog-derived on every run.
|
|
140
|
+
-- ===========================================================================
|
|
141
|
+
|
|
142
|
+
-- CPR-1. Everything in `agent_board` whose COMMENT-STRIPPED body (or view definition) mentions
|
|
143
|
+
-- `claim_policy` at all. Seven names, and the split between them is CPR-2/CPR-5's job — this
|
|
144
|
+
-- assertion's only claim is that there are no OTHERS.
|
|
145
|
+
--
|
|
146
|
+
-- STRIPPED, and that is not a stylistic preference: run the same query over RAW `prosrc` on this
|
|
147
|
+
-- schema and `set_board_claim_policy` also appears as a caller of `_claimgate_board_claim_policy`,
|
|
148
|
+
-- because its PROSE mentions the helper. A scan that counts a comment as code has already shipped
|
|
149
|
+
-- in this repository once.
|
|
150
|
+
select is(
|
|
151
|
+
(select string_agg(nm, ', ' order by nm) from (
|
|
152
|
+
select p.proname as nm
|
|
153
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
154
|
+
where n.nspname = 'agent_board'
|
|
155
|
+
and agent_board_test.stripped_body(p.oid, true) ~ 'claim_policy'
|
|
156
|
+
union
|
|
157
|
+
select c.relname
|
|
158
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
159
|
+
where n.nspname = 'agent_board' and c.relkind in ('v', 'm')
|
|
160
|
+
and agent_board_test.stripped_viewdef(c.oid, true) ~ 'claim_policy'
|
|
161
|
+
) s),
|
|
162
|
+
'_claimgate_board_claim_policy, _claimgate_board_claim_policy_of, _claimgate_lock_board_policy, '
|
|
163
|
+
'acquire_claim, renew_claim, set_board_claim_policy, v_ownerless_claim_quota',
|
|
164
|
+
'CPR-1 CLASS: these are all the ROUTINE BODIES and VIEW/MATVIEW DEFINITIONS in agent_board that mention claim_policy — the range this query actually covers; a check constraint, column default or index mentioning the column is outside it, and safely so, since none of those can resolve a policy for a caller or yield a lease. A fifth resolver fails here and names itself');
|
|
165
|
+
|
|
166
|
+
-- CPR-2. Of those, the DECIDERS — the ones that act on the answer — must serialise. Asserted as
|
|
167
|
+
-- the exact set of callers of the lock helper, so a decider added without the lock is absent here
|
|
168
|
+
-- and a non-decider that takes it is present unexpectedly. Either direction fails.
|
|
169
|
+
select is(
|
|
170
|
+
(select coalesce(string_agg(p.proname, ', ' order by p.proname), '')
|
|
171
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
172
|
+
where n.nspname = 'agent_board'
|
|
173
|
+
and p.proname <> '_claimgate_lock_board_policy'
|
|
174
|
+
and agent_board_test.stripped_body(p.oid, true) ~ '_claimgate_lock_board_policy'),
|
|
175
|
+
'acquire_claim, renew_claim, set_board_claim_policy',
|
|
176
|
+
'CPR-2 CLASS: these are the deciders that take the board policy lock — the write paths and the writer itself');
|
|
177
|
+
|
|
178
|
+
-- CPR-3. AND TAKE IT BEFORE THE DECISION, which is the correction that makes this fix different
|
|
179
|
+
-- from the one that was falsified in review. The first design said "before the agent-scoped lock";
|
|
180
|
+
-- in acquire_claim that lock lives INSIDE the branch a stale 'advisory' read SKIPS, so on the
|
|
181
|
+
-- dangerous path it is never reached. Source ORDER is asserted per function against the marker
|
|
182
|
+
-- that IS that function's decision.
|
|
183
|
+
select is(
|
|
184
|
+
(select string_agg(x.proname || '=' ||
|
|
185
|
+
case when regexp_instr(b.body, agent_board_test.call_re('_claimgate_lock_board_policy'), 1, 1, 0) = 0 then 'NO-LOCK'
|
|
186
|
+
when regexp_instr(b.body, x.decision, 1, 1, 0, 'i') = 0 then 'NO-DECISION'
|
|
187
|
+
when regexp_instr(b.body, agent_board_test.call_re('_claimgate_lock_board_policy'), 1, 1, 0)
|
|
188
|
+
< regexp_instr(b.body, x.decision, 1, 1, 0, 'i')
|
|
189
|
+
then 'lock-first'
|
|
190
|
+
else 'DECISION-FIRST' end, ', ' order by x.proname)
|
|
191
|
+
from (values
|
|
192
|
+
('acquire_claim', agent_board_test.call_re('_claimgate_board_claim_policy_of')),
|
|
193
|
+
('renew_claim', agent_board_test.call_re('_claimgate_board_claim_policy_of')),
|
|
194
|
+
('set_board_claim_policy', 'into\s+v_prev_policy')
|
|
195
|
+
) as x(proname, decision)
|
|
196
|
+
join pg_proc p on p.proname = x.proname
|
|
197
|
+
join pg_namespace n on n.oid = p.pronamespace and n.nspname = 'agent_board'
|
|
198
|
+
cross join lateral (select agent_board_test.stripped_body(p.oid, true) as body) b),
|
|
199
|
+
'acquire_claim=lock-first, renew_claim=lock-first, set_board_claim_policy=lock-first',
|
|
200
|
+
'CPR-3 ORDER: in each decider the board lock precedes the policy decision in source order — the population is a restatement of CPR-2''s catalog-derived set with a per-function decision marker, so a FOURTH decider fails CPR-2 before this list can go stale; the behavioural half is claimpolicy_acquire_revert_race.spec');
|
|
201
|
+
|
|
202
|
+
-- CPR-4. acquire_claim takes TWO advisory locks. Board first, always: it is the only function that
|
|
203
|
+
-- takes the actor-scoped quota lock, so one consistent order here is the whole cycle argument.
|
|
204
|
+
-- CPR-4 REMOVED (#405 completeness review). It read acquire_claim alone; CPR-4a derives the same
|
|
205
|
+
-- ordering over every function taking the actor-scoped quota lock and so strictly contains it.
|
|
206
|
+
-- Confirmed by mutation before removal: inverting acquire_claim's two locks trips CPR-4a (it tripped
|
|
207
|
+
-- CPR-4 and CPR-4a together). The universal clause CPR-4's description used to carry is CPR-4a's.
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
-- CPR-4a. THE CLAUSE CPR-4'S SENTENCE USED TO CARRY. It ended "and nothing in the schema takes them
|
|
211
|
+
-- the other way" — a claim about every function, backed by a query that read acquire_claim and
|
|
212
|
+
-- nothing else. The population is derived instead: every function taking the actor-scoped quota
|
|
213
|
+
-- lock, each labelled with the order of its two lock events.
|
|
214
|
+
--
|
|
215
|
+
-- The marker is `pg_advisory_xact_lock(hashtext(` and it does NOT match the board helper, which
|
|
216
|
+
-- calls `hashtextextended` — the character after `hashtext` differs, so the two stay distinguishable
|
|
217
|
+
-- without a negative lookahead. `\s*` between tokens rather than a plain substring, so a caller
|
|
218
|
+
-- spelling the call across a line break is still seen.
|
|
219
|
+
--
|
|
220
|
+
-- upsert_delegation_policy is in the population and takes NO board lock, which is why it cannot
|
|
221
|
+
-- invert anything; it is asserted positively here rather than filtered out, so it having to change
|
|
222
|
+
-- is a decision someone makes rather than an exemption that erodes.
|
|
223
|
+
select is(
|
|
224
|
+
(with q as (
|
|
225
|
+
select p.proname,
|
|
226
|
+
regexp_instr(agent_board_test.stripped_body(p.oid, true),
|
|
227
|
+
agent_board_test.call_re('_claimgate_lock_board_policy'), 1, 1, 0) as board_pos,
|
|
228
|
+
regexp_instr(agent_board_test.stripped_body(p.oid, true),
|
|
229
|
+
'pg_(try_)?advisory_xact_lock\s*\(\s*hashtext\s*\(', 1, 1, 0, 'i') as quota_pos
|
|
230
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
231
|
+
where n.nspname = 'agent_board'
|
|
232
|
+
and agent_board_test.stripped_body(p.oid, true) ~* 'pg_(try_)?advisory_xact_lock\s*\(\s*hashtext\s*\('
|
|
233
|
+
)
|
|
234
|
+
select coalesce(string_agg(proname || '=' ||
|
|
235
|
+
(case when board_pos = 0 then 'no-board-lock'
|
|
236
|
+
when board_pos < quota_pos then 'board-then-agent'
|
|
237
|
+
else 'AGENT-THEN-BOARD' end), ', ' order by proname), '<none>')
|
|
238
|
+
from q),
|
|
239
|
+
'acquire_claim=board-then-agent, upsert_delegation_policy=no-board-lock',
|
|
240
|
+
'CPR-4a ORDER (schema-wide): every function taking the actor-scoped quota lock either never takes the board lock or takes the board lock FIRST — the universal half of CPR-4, derived from the catalog rather than asserted about one function');
|
|
241
|
+
|
|
242
|
+
-- CPR-5. THE EXEMPTION, ASSERTED POSITIVELY. `v_ownerless_claim_quota` resolves board policy at
|
|
243
|
+
-- query time (#393's OQ-29) and is deliberately NOT serialised. The claim that makes that safe is
|
|
244
|
+
-- "it cannot produce a lease", and this asserts exactly that rather than asserting it is absent
|
|
245
|
+
-- from CPR-2: its ONLY rewrite rule is the view's own `_RETURN` SELECT (ev_type 1). An
|
|
246
|
+
-- `ON UPDATE ... DO INSTEAD` rule added to it would fail here.
|
|
247
|
+
select is(
|
|
248
|
+
(select format('rules=%s, user_triggers=%s',
|
|
249
|
+
(select string_agg(r.rulename || ':' || r.ev_type::text, ',' order by r.rulename)
|
|
250
|
+
from pg_rewrite r where r.ev_class = 'agent_board.v_ownerless_claim_quota'::regclass),
|
|
251
|
+
(select count(*) from pg_trigger t
|
|
252
|
+
where t.tgrelid = 'agent_board.v_ownerless_claim_quota'::regclass
|
|
253
|
+
and not t.tgisinternal)::text)),
|
|
254
|
+
'rules=_RETURN:1, user_triggers=0',
|
|
255
|
+
'CPR-5 EXEMPTION (positive): the census resolver is SELECT-only — its only rewrite rule is _RETURN AND it carries no INSTEAD OF trigger, the other way a view accepts writes; so a stale policy read there yields a report line and can never yield a lease');
|
|
256
|
+
|
|
257
|
+
-- ===========================================================================
|
|
258
|
+
-- THE SOLE-WRITER PROOF. #406 argued it from has_table_privilege + zero triggers, which does not
|
|
259
|
+
-- exclude column-level grants, rules, other definer routines, or dynamic SQL. Four bounds, each
|
|
260
|
+
-- asserted, because an advisory lock only excludes callers that also take it.
|
|
261
|
+
-- ===========================================================================
|
|
262
|
+
|
|
263
|
+
-- CPR-6. Source bound.
|
|
264
|
+
select is(
|
|
265
|
+
(select coalesce(string_agg(p.proname, ', ' order by p.proname), '')
|
|
266
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
267
|
+
where n.nspname not in ('pg_catalog', 'information_schema', 'agent_board_test')
|
|
268
|
+
and n.nspname !~ '^pg_'
|
|
269
|
+
and p.prolang <> (select oid from pg_language where lanname = 'internal')
|
|
270
|
+
and agent_board_test.stripped_body(p.oid, true)
|
|
271
|
+
~ (agent_board_test.stmt_re('update|merge into|insert into', 'boards')
|
|
272
|
+
|| '[^;]*' || agent_board_test.col_frag('claim_policy')
|
|
273
|
+
-- COPY, parsed by POSITION rather than by token. Three things this gets right that a
|
|
274
|
+
-- verb-plus-token test did not:
|
|
275
|
+
-- * `copy boards from …` with NO column list writes EVERY column, claim_policy
|
|
276
|
+
-- included, and contains no claim_policy token — the widest form of the verb.
|
|
277
|
+
-- * the direction word must follow the relation and its optional column list, so
|
|
278
|
+
-- `copy boards to '/tmp/from.csv'` cannot match on a PATH containing "from".
|
|
279
|
+
-- (Literals are kept by this scan, so the path really is in the text.)
|
|
280
|
+
-- * a column list that does NOT name claim_policy (`copy boards (slug) from …`)
|
|
281
|
+
-- writes only that column and is correctly NOT a member: the optional group fails
|
|
282
|
+
-- to match it, and the direction word is then not where the pattern requires.
|
|
283
|
+
|| '|' || agent_board_test.ci_re('copy') || '\s+(?:' || agent_board_test.ci_re('only') || '\s+)?'
|
|
284
|
+
|| agent_board_test.qname_frag('boards')
|
|
285
|
+
|| '\s*(?:\([^)]*' || agent_board_test.col_frag('claim_policy') || '[^)]*\)\s*)?'
|
|
286
|
+
|| agent_board_test.ci_re('from') || '(?![A-Za-z0-9_])'
|
|
287
|
+
-- INSERT WITH NO COLUMN LIST, the same shape as COPY without one and a PRE-EXISTING
|
|
288
|
+
-- hole rather than one this change introduced: `insert into boards values (…)` and
|
|
289
|
+
-- `insert into boards select …` write EVERY column, claim_policy included, and carry
|
|
290
|
+
-- no claim_policy token. COPY was special-cased and INSERT was not.
|
|
291
|
+
-- The INSERT-without-column-list rule is a NAMED OBJECT, embedded VERBATIM — the frag,
|
|
292
|
+
-- not `replace(re, '(?c)', '')`. CPR-0's rows compile this same frag, so row and
|
|
293
|
+
-- production are one object rather than two strings that agree today.
|
|
294
|
+
|| '|' || agent_board_test.insert_all_columns_frag('boards')
|
|
295
|
+
|| '|\m' || agent_board_test.ci_re('select') || '(?![A-Za-z0-9_])[^;]*\m'
|
|
296
|
+
|| agent_board_test.ci_re('into') || '(?![A-Za-z0-9_])[^;]*'
|
|
297
|
+
|| agent_board_test.qname_frag('boards') || '[^;]*' || agent_board_test.col_frag('claim_policy'))),
|
|
298
|
+
'set_board_claim_policy',
|
|
299
|
+
'CPR-6 SOLE WRITER (source): across EVERY non-system schema, exactly one function has a statement targeting boards that mentions claim_policy — a STATEMENT test rather than an enumeration of assignment spellings, so the multi-column form `set (slug, claim_policy) = (…)` lands here too, and a SECURITY DEFINER writer in another schema cannot bypass the premise by living outside agent_board');
|
|
300
|
+
|
|
301
|
+
-- CPR-7. Column-privilege bound — the one #406's table-level check cannot give. A grant of UPDATE
|
|
302
|
+
-- on `claim_policy` alone to `authenticated` passes `has_table_privilege(..., 'UPDATE')=false` at
|
|
303
|
+
-- table level in neither direction reliably, and would be invisible to CPR-6 entirely.
|
|
304
|
+
select is(
|
|
305
|
+
(select coalesce(string_agg(distinct grantee, ','), '<none>')
|
|
306
|
+
from information_schema.column_privileges
|
|
307
|
+
where table_schema = 'agent_board' and table_name = 'boards' and privilege_type = 'UPDATE'
|
|
308
|
+
and grantee <> (select relowner::regrole::text from pg_class where oid = 'agent_board.boards'::regclass)),
|
|
309
|
+
'<none>',
|
|
310
|
+
'CPR-7 SOLE WRITER (column privileges): no non-owner grantee holds UPDATE on ANY column of agent_board.boards, claim_policy included');
|
|
311
|
+
|
|
312
|
+
-- CPR-8. Rule and trigger bound.
|
|
313
|
+
select is(
|
|
314
|
+
format('rules=%s, user_triggers=%s, anon=%s, authenticated=%s, service_role=%s',
|
|
315
|
+
(select count(*) from pg_rewrite where ev_class = 'agent_board.boards'::regclass)::text,
|
|
316
|
+
(select count(*) from pg_trigger where tgrelid = 'agent_board.boards'::regclass and not tgisinternal)::text,
|
|
317
|
+
has_table_privilege('anon', 'agent_board.boards', 'update')::text,
|
|
318
|
+
has_table_privilege('authenticated', 'agent_board.boards', 'update')::text,
|
|
319
|
+
has_table_privilege('service_role', 'agent_board.boards', 'update')::text),
|
|
320
|
+
'rules=0, user_triggers=0, anon=false, authenticated=false, service_role=false',
|
|
321
|
+
'CPR-8 SOLE WRITER (rules/triggers/table privileges): boards carries no rewrite rule and no user trigger, and none of the three PostgREST roles holds table-level UPDATE — the general bound over ALL grantees is CPR-7, whose information_schema source expands a table-level grant per column and so sees a grant to any role, not just these three');
|
|
322
|
+
|
|
323
|
+
-- CPR-9. Dynamic-SQL bound. A `set claim_policy` assembled at run time is invisible to CPR-6, so
|
|
324
|
+
-- the population that COULD assemble one is bounded instead, and each member is cleared
|
|
325
|
+
-- positively: get_bundle_compatibility contains no write at all, and mark_retention_metadata's
|
|
326
|
+
-- table allowlist is a literal that does not contain `boards` (it only ever assigns
|
|
327
|
+
-- metadata/payload). Literals are KEPT by this scan, which is what makes the allowlist visible.
|
|
328
|
+
select is(
|
|
329
|
+
(select string_agg(p.proname || ':mentions_boards=' ||
|
|
330
|
+
(agent_board_test.stripped_body(p.oid, true) ~ agent_board_test.qname_re('boards'))::text ||
|
|
331
|
+
'/mentions_threads=' ||
|
|
332
|
+
(agent_board_test.stripped_body(p.oid, true) ~ agent_board_test.qname_re('threads'))::text ||
|
|
333
|
+
'/mentions_claims=' ||
|
|
334
|
+
(agent_board_test.stripped_body(p.oid, true) ~ agent_board_test.qname_re('claims'))::text, ', ' order by p.proname)
|
|
335
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
336
|
+
where n.nspname = 'agent_board'
|
|
337
|
+
and agent_board_test.stripped_body(p.oid, true) ~* '\mexecute\M'),
|
|
338
|
+
'get_bundle_compatibility:mentions_boards=false/mentions_threads=false/mentions_claims=false, mark_retention_metadata:mentions_boards=false/mentions_threads=false/mentions_claims=false',
|
|
339
|
+
'CPR-9 SOLE WRITER (EXECUTE token): the functions in agent_board that contain an `execute` token are these and neither names boards, threads or claims — a third, or either one gaining any of the three, fails here. The range is that token, not dynamic SQL in general: a statement assembled and run some other way is outside it. The claims field is what makes a dynamic claims writer visible, since CPR-14a strips literals and cannot see one');
|
|
340
|
+
|
|
341
|
+
-- CPR-10. The writer's ROW lock is still `for no key update` and not `for update`. This is not
|
|
342
|
+
-- cosmetic: `for update` conflicts with the FOR KEY SHARE an FK reference to boards takes, which
|
|
343
|
+
-- turns "hold KEY SHARE, then want the advisory lock" into a real cycle.
|
|
344
|
+
-- claimpolicy_writer_keyshare.spec MEASURES both directions; this makes the source change visible
|
|
345
|
+
-- too, so the two fail together rather than one silently drifting.
|
|
346
|
+
select is(
|
|
347
|
+
(select format('for_no_key_update=%s, for_update=%s',
|
|
348
|
+
(agent_board_test.stripped_body(p.oid, true) ~ 'for\s+no\s+key\s+update')::text,
|
|
349
|
+
(agent_board_test.stripped_body(p.oid, true) ~ 'for\s+update')::text)
|
|
350
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
351
|
+
where p.oid = to_regprocedure('agent_board.set_board_claim_policy(uuid,text)')),
|
|
352
|
+
'for_no_key_update=true, for_update=false',
|
|
353
|
+
'CPR-10 LOCK ORDER: set_board_claim_policy row-locks boards with FOR NO KEY UPDATE, which does not conflict with the FK''s FOR KEY SHARE — measured both ways in claimpolicy_writer_keyshare.spec');
|
|
354
|
+
|
|
355
|
+
-- ===========================================================================
|
|
356
|
+
-- THE OTHER PREMISE: WHICH BOARD. Both write paths lock the board they derived from the claim's
|
|
357
|
+
-- thread, so `threads.board_id` must not move under them.
|
|
358
|
+
-- ===========================================================================
|
|
359
|
+
|
|
360
|
+
-- CPR-11. The writer set for UPDATEs of agent_board.threads.
|
|
361
|
+
--
|
|
362
|
+
-- THE SCHEMA QUALIFIER IS OPTIONAL IN THE PATTERN, DELIBERATELY. This scan used to require the
|
|
363
|
+
-- literal `agent_board.threads`, which made it blind to `update threads` resolved through a
|
|
364
|
+
-- function's own `search_path` — the identical write, spelled one way the regex did not admit.
|
|
365
|
+
-- Measured, not theorised: injecting a function with `set search_path to agent_board` and a bare
|
|
366
|
+
-- `update threads set board_id = …` left this assertion GREEN. The qualifier is now an optional
|
|
367
|
+
-- group, so both spellings land in the same population.
|
|
368
|
+
select is(
|
|
369
|
+
(select coalesce(string_agg(p.proname, ', ' order by p.proname), '')
|
|
370
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
371
|
+
where n.nspname = 'agent_board'
|
|
372
|
+
and agent_board_test.stripped_body(p.oid, true) ~ agent_board_test.stmt_re('update', 'threads')),
|
|
373
|
+
'_next_thread_message_sequence, _upsert_delivery_recipient',
|
|
374
|
+
'CPR-11 BOARD IMMUTABILITY: these are the functions that UPDATE agent_board.threads, qualified or not — a third has to be decided against this premise rather than inheriting it');
|
|
375
|
+
|
|
376
|
+
-- CPR-12. THE POPULATION IS A STATEMENT TEST, NOT A SPELLING LIST. It matched `board_id =` after an
|
|
377
|
+
-- UPDATE or INSERT, which enumerated two spellings of assignment and called that the class. Three
|
|
378
|
+
-- valid forms were unmatched: the multi-column form `set (board_id, …) = (…)`, a
|
|
379
|
+
-- `MERGE … WHEN MATCHED THEN UPDATE SET board_id`, and a plain INSERT naming board_id in its COLUMN
|
|
380
|
+
-- LIST rather than an assignment. The population is now "a statement targeting threads that mentions
|
|
381
|
+
-- board_id at all", which admits every spelling of assignment including ones nobody has thought of;
|
|
382
|
+
-- it is deliberately WIDER than assignment (an UPDATE merely reading board_id in its WHERE lands
|
|
383
|
+
-- here too), because a false member is labelled and must be decided about, while a missed one is
|
|
384
|
+
-- invisible. Measured: the widening leaves today's population unchanged.
|
|
385
|
+
--
|
|
386
|
+
-- CPR-12. And the only one that assigns `board_id` does it FIRST-BOARD-WINS. That guard is what
|
|
387
|
+
-- makes the transition null -> board and never board -> board, which is why locking the board
|
|
388
|
+
-- derived before the wait is locking the right board. The one drift it leaves — a null board
|
|
389
|
+
-- STAMPED during the wait — is handled by resolving policy from the locked id rather than by
|
|
390
|
+
-- re-walking the thread; CPR-20 pins the constitutive 'inherit' that stamp would have to displace.
|
|
391
|
+
select is(
|
|
392
|
+
(select coalesce(string_agg(p.proname || '[' ||
|
|
393
|
+
(case when agent_board_test.stripped_body(p.oid, true)
|
|
394
|
+
~ 'update\s+agent_board\.threads\s+t\s+set\s+board_id\s*=\s*p_board_id\s+where\s+t\.board_id\s+is\s+null'
|
|
395
|
+
then 'first-board-wins' else 'UNGUARDED' end) || ']', ', ' order by p.proname), '<none>')
|
|
396
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
397
|
+
where n.nspname not in ('pg_catalog', 'information_schema', 'agent_board_test')
|
|
398
|
+
and n.nspname !~ '^pg_'
|
|
399
|
+
and p.prolang <> (select oid from pg_language where lanname = 'internal')
|
|
400
|
+
and agent_board_test.stripped_body(p.oid, true)
|
|
401
|
+
~ (agent_board_test.stmt_re('update|merge into|insert into', 'threads') || '[^;]*' || agent_board_test.col_frag('board_id'))),
|
|
402
|
+
'_upsert_delivery_recipient[first-board-wins]',
|
|
403
|
+
'CPR-12 BOARD IMMUTABILITY: the sole writer of threads.board_id stamps only where it is null (OBSGATE first-board-wins), so a board can be gained but never moved');
|
|
404
|
+
|
|
405
|
+
-- CPR-13. And no session can do it directly.
|
|
406
|
+
select is(
|
|
407
|
+
format('anon=%s, authenticated=%s, service_role=%s',
|
|
408
|
+
has_table_privilege('anon', 'agent_board.threads', 'update')::text,
|
|
409
|
+
has_table_privilege('authenticated', 'agent_board.threads', 'update')::text,
|
|
410
|
+
has_table_privilege('service_role', 'agent_board.threads', 'update')::text),
|
|
411
|
+
'anon=false, authenticated=false, service_role=false',
|
|
412
|
+
'CPR-13 BOARD IMMUTABILITY: none of the PostgREST roles holds table-level UPDATE on agent_board.threads — the bound over ALL grantees is CPR-13a, so board_id is reachable only through the definer function CPR-12 clears');
|
|
413
|
+
|
|
414
|
+
-- ---------------------------------------------------------------------------
|
|
415
|
+
-- CPR-13a/b/c. THE AXIS PARITY THIS PREMISE WAS MISSING.
|
|
416
|
+
--
|
|
417
|
+
-- `boards.claim_policy` is bounded on five axes (CPR-6 source, CPR-7 column privileges, CPR-8
|
|
418
|
+
-- rules/triggers/table privileges, CPR-9 dynamic SQL, CPR-10 lock strength). `threads.board_id`
|
|
419
|
+
-- had three, and the two premises carry the SAME weight: the fix locks the board id observed
|
|
420
|
+
-- before the wait, so `null -> board` is safe (a mid-wait stamp reads as the enforcing 'inherit')
|
|
421
|
+
-- but `board -> board` would let a caller lock board X and skip as 'advisory' while the claim now
|
|
422
|
+
-- sits on enforcing board Y.
|
|
423
|
+
--
|
|
424
|
+
-- The gap was MEASURED, not inferred. Against the pre-#408 file all three of these violations left
|
|
425
|
+
-- the suite 26/26 GREEN: a column-level `grant update (board_id) on threads to authenticated`, a
|
|
426
|
+
-- new unqualified unguarded `update threads set board_id` writer, and a trigger on threads. The
|
|
427
|
+
-- identical column grant on `boards` went red on CPR-7 — that control is what shows the asymmetry
|
|
428
|
+
-- was in the assertions rather than in the code, which measured clean on every axis.
|
|
429
|
+
-- ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
-- CPR-13a. The column-privilege bound. THIS IS THE ONE CPR-13 CANNOT GIVE: after
|
|
432
|
+
-- `grant update (board_id) on agent_board.threads to authenticated`,
|
|
433
|
+
-- `has_table_privilege(...,'UPDATE')` is still FALSE while `has_column_privilege(...)` is TRUE, so
|
|
434
|
+
-- CPR-13 stays green while a session really can move the column. Mirrors CPR-7 for boards.
|
|
435
|
+
select is(
|
|
436
|
+
(select coalesce(string_agg(distinct grantee, ','), '<none>')
|
|
437
|
+
from information_schema.column_privileges
|
|
438
|
+
where table_schema = 'agent_board' and table_name = 'threads' and privilege_type = 'UPDATE'
|
|
439
|
+
and grantee <> (select relowner::regrole::text from pg_class where oid = 'agent_board.threads'::regclass)),
|
|
440
|
+
'<none>',
|
|
441
|
+
'CPR-13a BOARD IMMUTABILITY (column privileges): no non-owner grantee holds UPDATE on ANY column of agent_board.threads, board_id included — the bound CPR-13''s table-level test cannot express');
|
|
442
|
+
|
|
443
|
+
-- CPR-13b. The rule and trigger bound. A `before update` trigger on threads rewrites board_id
|
|
444
|
+
-- underneath the definer function CPR-12 clears, and an `on update do instead` rule redirects the
|
|
445
|
+
-- write entirely — neither is visible to a source scan of the function bodies. Mirrors CPR-8.
|
|
446
|
+
select is(
|
|
447
|
+
format('rules=%s, user_triggers=%s',
|
|
448
|
+
(select count(*) from pg_rewrite where ev_class = 'agent_board.threads'::regclass)::text,
|
|
449
|
+
(select count(*) from pg_trigger where tgrelid = 'agent_board.threads'::regclass and not tgisinternal)::text),
|
|
450
|
+
'rules=0, user_triggers=0',
|
|
451
|
+
'CPR-13b BOARD IMMUTABILITY (rules/triggers): threads carries no rewrite rule and no user trigger, so nothing rewrites board_id beneath the first-board-wins guard CPR-12 pins');
|
|
452
|
+
|
|
453
|
+
-- CPR-13c. The dynamic-SQL bound is CPR-9's, EXTENDED IN PLACE rather than duplicated here: its
|
|
454
|
+
-- format string now carries `mentions_threads` beside `mentions_boards` for each of the two
|
|
455
|
+
-- EXECUTE-bearing functions. A sibling assertion would have restated CPR-9's "exactly two
|
|
456
|
+
-- functions contain an EXECUTE" population claim in a second place, and two copies of one
|
|
457
|
+
-- exact-set claim drift. What remains for this slot is the bound CPR-9 does not give: that
|
|
458
|
+
-- `threads` is not reachable from a session by any privilege path at all, column grants included,
|
|
459
|
+
-- which CPR-13 + CPR-13a give jointly — asserted here as one positive statement rather than left
|
|
460
|
+
-- to be composed by a reader.
|
|
461
|
+
-- CPR-13c REMOVED (#405 completeness review). It asserted the CONJUNCTION of CPR-13 (table-level
|
|
462
|
+
-- UPDATE for the three PostgREST roles) and CPR-13a (non-owner column grants) and detected nothing
|
|
463
|
+
-- either of them missed. Confirmed by mutation before removal, both directions: a TABLE grant trips
|
|
464
|
+
-- CPR-13 and CPR-13a; a COLUMN grant trips CPR-13a. Two assertions pinning one property is how a
|
|
465
|
+
-- later editor deletes the wrong one, so the conjunction went and the two bounds stayed.
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
-- ===========================================================================
|
|
469
|
+
-- LOCK ORDER (b): the claim-row -> board-lock inversion, bounded by PRIVILEGE rather than by
|
|
470
|
+
-- convention. Every path here takes the board lock before touching a claims row; the inverse
|
|
471
|
+
-- holder would be a session sitting on a claims row lock and then entering a policy path.
|
|
472
|
+
-- ===========================================================================
|
|
473
|
+
|
|
474
|
+
-- CPR-14. No function takes one WITH AN EXPLICIT LOCKING CLAUSE — which is what this query asks,
|
|
475
|
+
-- and all it asks. The description used to say "no function takes a row lock on claims", and that
|
|
476
|
+
-- sentence is FALSE: `update agent_board.claims` takes row locks too, and five functions do it
|
|
477
|
+
-- (acquire_claim, renew_claim, set_board_claim_policy, reclaim_fleet_machine, release_claim). The
|
|
478
|
+
-- premise that actually holds is the ORDERED one, and it is CPR-14a's, not this one's: of those
|
|
479
|
+
-- five, only the three that also take the board lock reach it at all, and all three take the board
|
|
480
|
+
-- lock FIRST. This assertion is still worth having — an explicit `for update` on claims is the one
|
|
481
|
+
-- spelling that could appear anywhere in a body, including before the board lock — but a
|
|
482
|
+
-- description claiming the whole class was quoted as a premise by
|
|
483
|
+
-- claimpolicy_claimrow_lock_order.spec, which is how one false sentence became two.
|
|
484
|
+
select is(
|
|
485
|
+
(select coalesce(string_agg(p.proname, ', ' order by p.proname), '<none>')
|
|
486
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
487
|
+
where n.nspname = 'agent_board'
|
|
488
|
+
and agent_board_test.stripped_body(p.oid, true) ~ (agent_board_test.qname_re('claims') || '[^;]*\m' || agent_board_test.ci_re('for') || '\s+(?:' || agent_board_test.ci_re('no key') || '\s+)?(?:' || agent_board_test.ci_re('update') || '|' || agent_board_test.ci_re('share') || '|' || agent_board_test.ci_re('key share') || ')(?![A-Za-z0-9_])')),
|
|
489
|
+
'<none>',
|
|
490
|
+
'CPR-14 LOCK ORDER: no function in agent_board takes a claims row lock through an EXPLICIT locking clause (for update / for no key update / for share / for key share) — the spelling that could sit anywhere in a body; the UPDATE-shaped row locks, which CPR-14a enumerates and orders, are not this assertion''s subject');
|
|
491
|
+
|
|
492
|
+
-- CPR-14a. THE PREMISE CPR-14'S DESCRIPTION USED TO CLAIM, ASSERTED AS FAR AS A SOURCE SCAN CAN.
|
|
493
|
+
-- A row lock on claims is taken by `update agent_board.claims` just as surely as by `for update`,
|
|
494
|
+
-- so the question that decides the cycle is not "does anything take one" but "does anything take
|
|
495
|
+
-- one and THEN want the board lock".
|
|
496
|
+
--
|
|
497
|
+
-- WHAT THIS IS: A REGRESSION TRIPWIRE, not an execution-order proof. It compares FIRST-OCCURRENCE
|
|
498
|
+
-- SOURCE POSITIONS in the stripped body. That is genuinely weaker than execution order and the
|
|
499
|
+
-- gaps are real: a branch that takes the row lock only on one path, a loop, or a CALLEE that
|
|
500
|
+
-- performs the UPDATE would all evade it. A source scan has a floor and this is it. What the
|
|
501
|
+
-- tripwire does deliver is worth having on its own: it fails CLOSED on a new claims writer (the
|
|
502
|
+
-- population is catalog-derived, so an addition changes the string and names itself) and it
|
|
503
|
+
-- relabels a same-body swap INVERTED — verified by moving renew_claim's own lock call after its
|
|
504
|
+
-- UPDATE, which produced `renew_claim[INVERTED]`.
|
|
505
|
+
--
|
|
506
|
+
-- THE ACTUAL EXECUTION-ORDER EVIDENCE IS ELSEWHERE, and this comment exists so nobody mistakes
|
|
507
|
+
-- this assertion for it: a pg_proc call-closure measurement over the applied bundle established
|
|
508
|
+
-- the two sets and their intersection — every function that row-locks claims, every function whose
|
|
509
|
+
-- CALL CLOSURE (not just direct body) reaches _claimgate_lock_board_policy, and the order in each
|
|
510
|
+
-- of the three members of the intersection. reclaim_fleet_machine and release_claim row-lock claims
|
|
511
|
+
-- and never reach the helper by any path; acquire_claim, renew_claim and set_board_claim_policy
|
|
512
|
+
-- take the board lock first. That measurement also confirmed there are no triggers on claims and no
|
|
513
|
+
-- FK referencing it, so no lock event is added by a path the call graph cannot see.
|
|
514
|
+
--
|
|
515
|
+
-- THE POPULATION AND THE COMPARISON ARE THE SAME EXPRESSION, which they were not at first: the
|
|
516
|
+
-- population admitted DELETE, MERGE and explicit locking clauses while the ordering comparison
|
|
517
|
+
-- searched only for UPDATE, so a DELETE-shaped row lock followed by the board lock was labelled
|
|
518
|
+
-- `board-lock-first`. Both now come from `row_pos`, and membership IS `row_pos` having been found.
|
|
519
|
+
--
|
|
520
|
+
-- UNIFYING THEM MOVED THE FAILURE MODE, which is why the shape list then had to be re-derived
|
|
521
|
+
-- rather than trusted. While the two disagreed, an unmatched shape was MISLABELLED; now an
|
|
522
|
+
-- unmatched shape falls out of the census ENTIRELY and is not flagged at all. `INSERT … ON
|
|
523
|
+
-- CONFLICT … DO UPDATE` locks and updates an existing row and matched NEITHER regex, so a function
|
|
524
|
+
-- using it and then taking the board lock would simply have vanished from the population while the
|
|
525
|
+
-- description claimed every new writer names itself. It is the third arm of `row_pos` now.
|
|
526
|
+
--
|
|
527
|
+
-- THE ARMS ARE A LIST, AND CALLING THEM "DERIVED" WAS ITSELF AN OVERSTATEMENT. Three regexes and
|
|
528
|
+
-- a five-name expected string are a hand list however carefully chosen. What IS asserted is the
|
|
529
|
+
-- COMPLEMENT, by CPR-14b below: everything whose body mentions claims either lands in one of these
|
|
530
|
+
-- arms' census or carries no write verb at all. So an unlisted write shape does not vanish — it
|
|
531
|
+
-- fails CPR-14b and names itself. That is the property the word "derived" was reaching for, and it
|
|
532
|
+
-- needed an assertion rather than a sentence.
|
|
533
|
+
--
|
|
534
|
+
-- THE PLAIN-INSERT ARM IS DELIBERATELY WIDER THAN "ROW LOCK". An `INSERT` into claims takes no lock
|
|
535
|
+
-- on an EXISTING row, so on a strict reading it does not belong. It is here because the property
|
|
536
|
+
-- that matters is participation in a LOCK CYCLE, not the lock's taxonomy: every insert form can
|
|
537
|
+
-- block on `claims_active_resource_unique` against a concurrent inserter, and acquire_claim (board
|
|
538
|
+
-- lock, then that unique insert) is the other half of exactly that cycle. Excluding it was
|
|
539
|
+
-- justified last round on the grounds that acquire_claim is in the census anyway through its
|
|
540
|
+
-- UPDATE — true of TODAY's body only, asserted nowhere, and worthless against a NEW insert-only
|
|
541
|
+
-- writer that takes the board lock afterwards. Including the arm asserts it instead. It also
|
|
542
|
+
-- subsumes the upsert arm that replaced it: `least()` finds the INSERT at or before any
|
|
543
|
+
-- `ON CONFLICT`, so `DO UPDATE` and `DO NOTHING` both enter the census with no window to get wrong.
|
|
544
|
+
--
|
|
545
|
+
-- ONE SHAPE REMAINS OUT OF MODEL: a row lock taken by a CALLEE rather than in this body. The
|
|
546
|
+
-- tripwire is per-body by construction; the call-closure measurement recorded above is what covers
|
|
547
|
+
-- that direction.
|
|
548
|
+
--
|
|
549
|
+
-- NO CHARACTER-BOUNDED WINDOWS, and that is a correction rather than a preference. The upsert arm
|
|
550
|
+
-- was written as `claims … [^;]{0,250} … on conflict`, and the gap between them spans the column
|
|
551
|
+
-- list AND the VALUES list: measured at 337 characters on a realistic claims upsert, so the arm did
|
|
552
|
+
-- not match the shape it was named for and such a writer dropped out of the census silently — the
|
|
553
|
+
-- exact fail-open the arm was added to close. `[^;]` alone already confines a match to a single
|
|
554
|
+
-- statement (literals are stripped here, so no `;` can appear inside one), which is the containment
|
|
555
|
+
-- the bound was reaching for. The bound added nothing but a number to get wrong.
|
|
556
|
+
select is(
|
|
557
|
+
(with bodies as (
|
|
558
|
+
select p.proname, agent_board_test.stripped_body(p.oid) as body
|
|
559
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
560
|
+
where n.nspname = 'agent_board'
|
|
561
|
+
and p.prolang <> (select oid from pg_language where lanname = 'internal')
|
|
562
|
+
), posed as (
|
|
563
|
+
select proname,
|
|
564
|
+
regexp_instr(body, agent_board_test.call_re('_claimgate_lock_board_policy'), 1, 1, 0) as lock_pos,
|
|
565
|
+
least(
|
|
566
|
+
coalesce(nullif(regexp_instr(body,
|
|
567
|
+
agent_board_test.stmt_re('update|delete from|merge into', 'claims'),
|
|
568
|
+
1, 1, 0, 'i'), 0), 2147483647),
|
|
569
|
+
coalesce(nullif(regexp_instr(body,
|
|
570
|
+
agent_board_test.qname_re('claims') || '[^;]*\m' || agent_board_test.ci_re('for') || '\s+(?:' || agent_board_test.ci_re('no key') || '\s+)?(?:' || agent_board_test.ci_re('update') || '|' || agent_board_test.ci_re('share') || '|' || agent_board_test.ci_re('key share') || ')(?![A-Za-z0-9_])',
|
|
571
|
+
1, 1, 0, 'i'), 0), 2147483647),
|
|
572
|
+
coalesce(nullif(regexp_instr(body,
|
|
573
|
+
agent_board_test.stmt_re('insert into', 'claims'),
|
|
574
|
+
1, 1, 0, 'i'), 0), 2147483647)
|
|
575
|
+
) as row_pos
|
|
576
|
+
from bodies
|
|
577
|
+
)
|
|
578
|
+
select coalesce(string_agg(proname || '[' ||
|
|
579
|
+
(case when lock_pos = 0 then 'no-board-lock'
|
|
580
|
+
when lock_pos < row_pos then 'board-lock-first'
|
|
581
|
+
else 'INVERTED' end) || ']', ', ' order by proname), '<none>')
|
|
582
|
+
from posed
|
|
583
|
+
where row_pos < 2147483647),
|
|
584
|
+
'acquire_claim[board-lock-first], reclaim_fleet_machine[no-board-lock], release_claim[no-board-lock], renew_claim[board-lock-first], set_board_claim_policy[board-lock-first]',
|
|
585
|
+
'CPR-14a LOCK ORDER (source-position tripwire): for every function whose body takes a claims row lock by one of the modelled shapes — UPDATE, DELETE, MERGE, ANY INSERT (upsert or plain), or an explicit locking clause — the board-lock helper is either absent from that body or appears EARLIER IN THE SOURCE TEXT than the shape; a regression tripwire on source position, not a proof of execution order, with the out-of-model shapes named in the comment above');
|
|
586
|
+
|
|
587
|
+
-- CPR-14b. THE COMPLEMENT OF CPR-14a'S CENSUS, which is what turns three hand-written regexes into
|
|
588
|
+
-- a bounded claim. CPR-14a can only order the functions its arms MATCH; a claims writer using a
|
|
589
|
+
-- shape no arm knows falls out of the census entirely and is ordered by nothing. This asserts that
|
|
590
|
+
-- the leftovers are all READERS: every function whose body mentions claims but which CPR-14a did
|
|
591
|
+
-- not census carries no write verb anywhere in its body.
|
|
592
|
+
--
|
|
593
|
+
-- Deliberately BLUNT — the verb scan is whole-body, not claims-scoped, so a leftover that writes
|
|
594
|
+
-- some OTHER table fails this too. That is the intended behaviour and not a rough edge: such a
|
|
595
|
+
-- function has to be looked at and decided about, rather than inheriting an exemption nobody chose.
|
|
596
|
+
--
|
|
597
|
+
-- KEYED BY OID, NOT BY proname, and that was a real hole rather than a tidy-up. The complement was
|
|
598
|
+
-- computed as `proname not in (censused)`, so if ONE overload of a name was censused, ANOTHER
|
|
599
|
+
-- overload of the same name using an unmodelled write shape was excluded from the complement —
|
|
600
|
+
-- ignored by CPR-14a because its shape is unmodelled, and by CPR-14b because its NAME was already
|
|
601
|
+
-- censused. It vanished from both, which is exactly what this assertion exists to prevent. The
|
|
602
|
+
-- members are rendered as full signatures so the failure message distinguishes overloads too.
|
|
603
|
+
--
|
|
604
|
+
-- WHAT THIS COMPLEMENT BOUNDS, and what it does not — because "complement of a hand list" reads
|
|
605
|
+
-- like a total bound and is not one. It bounds: any agent_board routine whose stripped body
|
|
606
|
+
-- contains the whole word `claims`, qualified or unqualified, that CPR-14a's arms did not census.
|
|
607
|
+
-- It does NOT bound:
|
|
608
|
+
-- * a table whose name merely CONTAINS claims — a partition like `claims_p1` is not `\mclaims\M`.
|
|
609
|
+
-- Checked rather than assumed: agent_board.claims is not partitioned and has no children today,
|
|
610
|
+
-- so this is a bound on future schema shape, not a live hole.
|
|
611
|
+
-- * a writer in ANOTHER SCHEMA — outside this file's subject entirely.
|
|
612
|
+
-- * dynamic SQL, whose claims mention lives in a string literal `stripped_body` removes here;
|
|
613
|
+
-- CPR-9's `mentions_claims` field is what covers that direction.
|
|
614
|
+
-- * verbs outside the scanned set (`COPY … TO/FROM`, `SELECT … INTO`), which are write paths this
|
|
615
|
+
-- regex does not name.
|
|
616
|
+
select is(
|
|
617
|
+
(with bodies as (
|
|
618
|
+
select p.oid, p.proname, agent_board_test.stripped_body(p.oid) as body
|
|
619
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
620
|
+
where n.nspname = 'agent_board'
|
|
621
|
+
and p.prolang <> (select oid from pg_language where lanname = 'internal')
|
|
622
|
+
), mentioners as (
|
|
623
|
+
select oid, proname, body from bodies where body ~ agent_board_test.qname_re('claims')
|
|
624
|
+
), censused as (
|
|
625
|
+
select oid from mentioners
|
|
626
|
+
where body ~ agent_board_test.stmt_re('update|delete from|merge into', 'claims')
|
|
627
|
+
or body ~ (agent_board_test.qname_re('claims') || '[^;]*\m' || agent_board_test.ci_re('for') || '\s+(?:' || agent_board_test.ci_re('no key') || '\s+)?(?:' || agent_board_test.ci_re('update') || '|' || agent_board_test.ci_re('share') || '|' || agent_board_test.ci_re('key share') || ')(?![A-Za-z0-9_])')
|
|
628
|
+
or body ~ agent_board_test.stmt_re('insert into', 'claims')
|
|
629
|
+
)
|
|
630
|
+
select coalesce(string_agg(m.oid::regprocedure::text || '[' ||
|
|
631
|
+
(case when m.body ~* '\m(update|delete|insert|merge|truncate|copy)\M|\mselect\M[^;]*\minto\M'
|
|
632
|
+
then 'HAS-WRITE-VERB' else 'no-write-verb' end) || ']', ', ' order by m.oid::regprocedure::text), '<none>')
|
|
633
|
+
from mentioners m
|
|
634
|
+
where m.oid not in (select oid from censused)),
|
|
635
|
+
'_agent_can_access_thread(uuid,uuid)[no-write-verb], _claimgate_active_ownerless_claims(uuid)[no-write-verb], _claimgate_promotion_blockers(text)[no-write-verb]',
|
|
636
|
+
'CPR-14b LOCK ORDER (census complement): every function mentioning claims that CPR-14a did NOT census is a pure reader — so a claims writer using a shape no arm matches fails HERE instead of vanishing from both assertions');
|
|
637
|
+
|
|
638
|
+
-- ---------------------------------------------------------------------------
|
|
639
|
+
-- CPR-3a/3b/3c. THE PREMISE EVERYTHING ELSE RESTS ON, WHICH NOTHING PINNED.
|
|
640
|
+
--
|
|
641
|
+
-- "Policy is resolved from the board id that was LOCKED" is the load-bearing claim of this whole
|
|
642
|
+
-- change, and no assertion coupled the two. CPR-3 checks only that the lock call appears BEFORE the
|
|
643
|
+
-- resolver in source order — so `policy_of(board_of_thread(p_thread_id))` written after
|
|
644
|
+
-- `lock(v_board_id)` passes the entire inventory while re-walking the thread and reopening exactly
|
|
645
|
+
-- the null->board drift the lock exists to exclude. Position is not coupling.
|
|
646
|
+
--
|
|
647
|
+
-- WHY THERE IS NO ISOLATION PERMUTATION FOR THE null->board CASE, measured rather than assumed: the
|
|
648
|
+
-- helper takes NO lock when the board is null (`if p_board_id is not null`). On a boardless thread
|
|
649
|
+
-- there is therefore no wait point, nothing blocks, and no interleaving can be constructed at that
|
|
650
|
+
-- position — a permutation there would be a probe pointed at nothing. What actually guards the case
|
|
651
|
+
-- is structural: the resolver is called with the SAME identifier that was locked (CPR-3a), so a
|
|
652
|
+
-- board stamped mid-flight cannot be observed by this path at all, and CPR-18 pins that the absent
|
|
653
|
+
-- board resolves to the ENFORCING answer. Those two together are the guarantee; the spec suite
|
|
654
|
+
-- carries the cases that DO have a wait point.
|
|
655
|
+
-- ---------------------------------------------------------------------------
|
|
656
|
+
|
|
657
|
+
-- CPR-3a. The resolver's argument is the identifier that was locked — captured from each body and
|
|
658
|
+
-- compared, not inferred from ordering.
|
|
659
|
+
select is(
|
|
660
|
+
(select string_agg(x.proname || '=' ||
|
|
661
|
+
(case when x.locked is null then 'NO-LOCK-ARG'
|
|
662
|
+
when x.resolved is null then 'NO-RESOLVER-ARG'
|
|
663
|
+
when x.locked = x.resolved then 'same-var(' || x.locked || ')'
|
|
664
|
+
else 'DIFFERENT-VAR(' || x.locked || ' vs ' || x.resolved || ')' end),
|
|
665
|
+
', ' order by x.proname)
|
|
666
|
+
from (select p.proname,
|
|
667
|
+
substring(agent_board_test.stripped_body(p.oid)
|
|
668
|
+
from '"?_claimgate_lock_board_policy"?\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)') as locked,
|
|
669
|
+
substring(agent_board_test.stripped_body(p.oid)
|
|
670
|
+
from '"?_claimgate_board_claim_policy_of"?\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)') as resolved
|
|
671
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
672
|
+
where n.nspname = 'agent_board'
|
|
673
|
+
and p.prolang <> (select oid from pg_language where lanname = 'internal')
|
|
674
|
+
and agent_board_test.stripped_body(p.oid) ~ agent_board_test.call_re('_claimgate_board_claim_policy_of')
|
|
675
|
+
and p.proname <> '_claimgate_board_claim_policy') x),
|
|
676
|
+
'acquire_claim=same-var(v_board_id), renew_claim=same-var(v_board_id)',
|
|
677
|
+
'CPR-3a COUPLING: each path resolves the policy from the SAME identifier it locked — a resolver called on a freshly re-walked board (policy_of(board_of_thread(…))) is named DIFFERENT-VAR here, which CPR-3''s source-order check cannot see');
|
|
678
|
+
|
|
679
|
+
-- CPR-3b. EXACTLY ONE OF EACH CALL, which is what makes CPR-3a's first-match capture SOUND rather
|
|
680
|
+
-- than merely usually right. `substring` returns the FIRST match, so a body with a valid first pair
|
|
681
|
+
-- followed by a second resolver call on a different variable —
|
|
682
|
+
-- `policy_of(v_fresh_board_id)` in a later branch — satisfies CPR-3, CPR-3a and a mere
|
|
683
|
+
-- "at least one bare identifier" check, while that later variable can observe exactly the stamp the
|
|
684
|
+
-- captured-id reasoning excludes. Counting closes it by construction: a second call of either kind
|
|
685
|
+
-- is a failure that names itself, and the fix is then to decide about the second call rather than to
|
|
686
|
+
-- out-regex it.
|
|
687
|
+
--
|
|
688
|
+
-- Measured before choosing this bound: both legitimate bodies contain exactly one of each, so the
|
|
689
|
+
-- assertion costs nothing today. A body that genuinely needs two must fail here and be argued for.
|
|
690
|
+
--
|
|
691
|
+
-- TWO LIMITS THIS CANNOT REACH, stated rather than implied, because a source scan has a floor:
|
|
692
|
+
-- * SHADOWING. Equal captured NAMES are not equal BINDINGS — a nested PL/pgSQL block may declare
|
|
693
|
+
-- its own `v_board_id`, and this comparison would read the two as the same variable. Nothing
|
|
694
|
+
-- here resolves scopes; what bounds it is that both bodies are single-block and CPR-3b's count
|
|
695
|
+
-- makes a second declaration-plus-call pattern fail.
|
|
696
|
+
-- * DOMINANCE. CPR-3c pins that the re-read appears between the lock and the decision in SOURCE
|
|
697
|
+
-- ORDER; it does not prove the re-read executes on every path that reaches the decision. A
|
|
698
|
+
-- conditional re-read would pass. That is an execution-order claim, and the isolation specs are
|
|
699
|
+
-- where execution order is demonstrated.
|
|
700
|
+
select is(
|
|
701
|
+
(select string_agg(x.proname || '=locks:' || x.locks || '/resolvers:' || x.resolvers
|
|
702
|
+
|| '/bare:' || x.bare::text, ', ' order by x.proname)
|
|
703
|
+
from (select p.proname,
|
|
704
|
+
(select count(*) from regexp_matches(agent_board_test.stripped_body(p.oid),
|
|
705
|
+
agent_board_test.call_re('_claimgate_lock_board_policy'), 'g')) as locks,
|
|
706
|
+
(select count(*) from regexp_matches(agent_board_test.stripped_body(p.oid),
|
|
707
|
+
agent_board_test.call_re('_claimgate_board_claim_policy_of'), 'g')) as resolvers,
|
|
708
|
+
agent_board_test.stripped_body(p.oid)
|
|
709
|
+
~* '"?_claimgate_board_claim_policy_of"?\s*\(\s*[A-Za-z_][A-Za-z0-9_]*\s*\)' as bare
|
|
710
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
711
|
+
where n.nspname = 'agent_board'
|
|
712
|
+
and p.prolang <> (select oid from pg_language where lanname = 'internal')
|
|
713
|
+
and agent_board_test.stripped_body(p.oid) ~ agent_board_test.call_re('_claimgate_board_claim_policy_of')
|
|
714
|
+
and p.proname <> '_claimgate_board_claim_policy') x),
|
|
715
|
+
'acquire_claim=locks:1/resolvers:1/bare:true, renew_claim=locks:1/resolvers:1/bare:true',
|
|
716
|
+
'CPR-3b COUPLING (capture soundness): each path takes the board lock ONCE and resolves the policy ONCE, with a bare identifier — so CPR-3a''s first-match capture cannot be defeated by a second resolver call on a different variable in a later branch');
|
|
717
|
+
|
|
718
|
+
-- CPR-3c. renew re-reads the CLAIM after the lock and before the decision. The re-read is what makes
|
|
719
|
+
-- renew's answer reflect the writer's committed state rather than its pre-wait snapshot; it is
|
|
720
|
+
-- cheaply visible in source and was pinned only by prose.
|
|
721
|
+
select ok(
|
|
722
|
+
(select regexp_instr(b.body, agent_board_test.call_re('_claimgate_lock_board_policy'), 1, 1, 0) > 0
|
|
723
|
+
-- SEARCHED FROM THE LOCK, not from the top: renew reads the claim BEFORE the lock too (to
|
|
724
|
+
-- derive the board from the token), so a first-occurrence search finds that earlier read and
|
|
725
|
+
-- would report the property false while it holds. Measured on this bundle: lock@1863,
|
|
726
|
+
-- pre-lock read@367, re-read@1940, resolver@2309.
|
|
727
|
+
and regexp_instr(b.body, 'select[^;]*from\s+agent_board\.claims\M',
|
|
728
|
+
regexp_instr(b.body, agent_board_test.call_re('_claimgate_lock_board_policy'), 1, 1, 0), 1, 0, 'i') > 0
|
|
729
|
+
and regexp_instr(b.body, 'select[^;]*from\s+agent_board\.claims\M',
|
|
730
|
+
regexp_instr(b.body, agent_board_test.call_re('_claimgate_lock_board_policy'), 1, 1, 0), 1, 0, 'i')
|
|
731
|
+
< regexp_instr(b.body, agent_board_test.call_re('_claimgate_board_claim_policy_of'), 1, 1, 0)
|
|
732
|
+
from pg_proc p
|
|
733
|
+
cross join lateral (select agent_board_test.stripped_body(p.oid) as body) b
|
|
734
|
+
where p.oid = to_regprocedure('agent_board.renew_claim(uuid,timestamptz)')),
|
|
735
|
+
'CPR-3c RE-READ: renew_claim re-SELECTs the claim AFTER taking the board lock and BEFORE resolving the policy — the ordering that makes its decision reflect the committed state rather than its pre-wait snapshot');
|
|
736
|
+
|
|
737
|
+
-- ---------------------------------------------------------------------------
|
|
738
|
+
-- CPR-14c. THE CALLEE DIRECTION, WHICH BOTH PER-BODY ASSERTIONS MISS BY CONSTRUCTION.
|
|
739
|
+
--
|
|
740
|
+
-- CPR-14a and CPR-14b each read ONE body. A function that delegates its claims row lock to a callee
|
|
741
|
+
-- and then takes the board lock itself is invisible to both: its own body contains no row-lock
|
|
742
|
+
-- shape, so CPR-14a never censuses it and CPR-14b classifies it as a reader. That direction was
|
|
743
|
+
-- covered by a one-time call-closure measurement recorded in a comment — which is rule 5's exact
|
|
744
|
+
-- shape, a fact about the moment it was written with nothing re-checking it.
|
|
745
|
+
--
|
|
746
|
+
-- IT IS ONE HALF OF A UNION, and the reason is a regression the rework caused. The membership
|
|
747
|
+
-- version it replaced would have named a NON-row-locking root that delegates; this order version
|
|
748
|
+
-- excludes such a root only if its own body never mentions the board lock, so replacing rather than
|
|
749
|
+
-- adding closed one case and opened another. CPR-14d below is the membership half, kept alongside
|
|
750
|
+
-- with today's members enumerated so a WRAPPER appears there and has to be decided about VISIBLY
|
|
751
|
+
-- rather than being carved out by a predicate nobody re-reads.
|
|
752
|
+
--
|
|
753
|
+
-- AND FIRST SOURCE POSITION IS NOT EXECUTION ORDER, stated because this file rejects that inference
|
|
754
|
+
-- elsewhere and must not rely on it here: a body with an early lock in one branch and
|
|
755
|
+
-- `rowlock_callee(); lock();` in another passes, since the first textual lock precedes the callee
|
|
756
|
+
-- while that second branch executes claims-lock first. Branch semantics are beyond a source scan.
|
|
757
|
+
-- What this assertion covers is a body whose FIRST lock mention follows a row-locking call site;
|
|
758
|
+
-- what it does not cover is which of several mentions a given path reaches.
|
|
759
|
+
--
|
|
760
|
+
-- IT COMPARES ORDER, NOT MEMBERSHIP, and the first version did not — which was unsound in BOTH
|
|
761
|
+
-- directions. That version excluded any root that itself row-locks claims, so a function taking the
|
|
762
|
+
-- board lock, row-locking claims safely AFTER it, and DELEGATING another claims row lock BEFORE it
|
|
763
|
+
-- was omitted — the exact composed inversion this assertion exists to catch. And it admitted every
|
|
764
|
+
-- ordinary WRAPPER of acquire_claim, because membership in two closures says nothing about order,
|
|
765
|
+
-- so the exemption-resistance the narrowing was justified by was never bought.
|
|
766
|
+
--
|
|
767
|
+
-- The predicate is now: the caller's own body references the board lock, and it CALLS a callee whose
|
|
768
|
+
-- closure reaches a claims row-locker, and that call site precedes the lock. A wrapper has no board
|
|
769
|
+
-- lock in its own body and is excluded on that ground rather than by a membership carve-out; a
|
|
770
|
+
-- self-row-locking root is no longer excluded at all.
|
|
771
|
+
--
|
|
772
|
+
-- ITS OWN LIMIT, stated: fn_call_closure()'s edges are name matches over stripped bodies, so a
|
|
773
|
+
-- callee invoked through dynamic SQL is invisible to it. CPR-9's EXECUTE-token bound is what covers
|
|
774
|
+
-- that direction.
|
|
775
|
+
select is(
|
|
776
|
+
(with fns as (
|
|
777
|
+
select p.oid, p.proname, agent_board_test.stripped_body(p.oid) as body
|
|
778
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
779
|
+
where n.nspname = 'agent_board'
|
|
780
|
+
and p.prolang <> (select oid from pg_language where lanname = 'internal')
|
|
781
|
+
), rowlockers as (
|
|
782
|
+
select oid from fns
|
|
783
|
+
where body ~ agent_board_test.stmt_re('update|delete from|merge into', 'claims')
|
|
784
|
+
or body ~ agent_board_test.stmt_re('insert into', 'claims')
|
|
785
|
+
or body ~ (agent_board_test.qname_re('claims') || '[^;]*\m' || agent_board_test.ci_re('for') || '\s+(?:' || agent_board_test.ci_re('no key') || '\s+)?(?:' || agent_board_test.ci_re('update') || '|' || agent_board_test.ci_re('share') || '|' || agent_board_test.ci_re('key share') || ')(?![A-Za-z0-9_])')
|
|
786
|
+
), reaches_rowlock as (
|
|
787
|
+
select distinct c.root_oid as oid
|
|
788
|
+
from agent_board_test.fn_call_closure() c
|
|
789
|
+
where c.reached_oid in (select oid from rowlockers)
|
|
790
|
+
), edges as (
|
|
791
|
+
select r.proname as caller, e.proname as callee,
|
|
792
|
+
regexp_instr(r.body, '\m' || e.proname || '\M\s*\(', 1, 1, 0, 'i') as call_pos,
|
|
793
|
+
regexp_instr(r.body, agent_board_test.call_re('_claimgate_lock_board_policy'), 1, 1, 0) as lock_pos
|
|
794
|
+
from fns r join fns e on e.oid <> r.oid
|
|
795
|
+
where e.oid in (select oid from reaches_rowlock)
|
|
796
|
+
and r.body ~ agent_board_test.call_re(e.proname)
|
|
797
|
+
and regexp_instr(r.body, agent_board_test.call_re('_claimgate_lock_board_policy'), 1, 1, 0) > 0
|
|
798
|
+
)
|
|
799
|
+
select coalesce(string_agg(distinct caller || '->' || callee, ', '
|
|
800
|
+
order by caller || '->' || callee), '<none>')
|
|
801
|
+
from edges
|
|
802
|
+
where call_pos > 0 and call_pos < lock_pos),
|
|
803
|
+
'<none>',
|
|
804
|
+
'CPR-14c LOCK ORDER (callee direction): no function calls a claims row-locking callee BEFORE it takes the board lock — compared by the position of the CALL SITE against the lock in the caller''s own body, so the composed inversion neither per-body assertion can see is named here');
|
|
805
|
+
|
|
806
|
+
-- CPR-14d. THE MEMBERSHIP HALF OF THE UNION, restored rather than replaced. CPR-14c asks about
|
|
807
|
+
-- ORDER and therefore says nothing about a root whose own body never mentions the board lock; this
|
|
808
|
+
-- one asks only WHO reaches both, and enumerates them. A legitimate wrapper of acquire_claim will
|
|
809
|
+
-- appear here and fail — that is intended: the exemption is then a visible edit to this expected
|
|
810
|
+
-- string with a reason beside it, rather than a predicate carve-out that silently covers shapes
|
|
811
|
+
-- nobody considered. The two together catch both the delegated inversion and the composed one.
|
|
812
|
+
select is(
|
|
813
|
+
(with fns as (
|
|
814
|
+
select p.oid, p.proname, agent_board_test.stripped_body(p.oid) as body
|
|
815
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
816
|
+
where n.nspname = 'agent_board'
|
|
817
|
+
and p.prolang <> (select oid from pg_language where lanname = 'internal')
|
|
818
|
+
), rowlockers as (
|
|
819
|
+
select oid from fns
|
|
820
|
+
where body ~ agent_board_test.stmt_re('update|delete from|merge into', 'claims')
|
|
821
|
+
or body ~ agent_board_test.stmt_re('insert into', 'claims')
|
|
822
|
+
or body ~ (agent_board_test.qname_re('claims') || '[^;]*\m' || agent_board_test.ci_re('for') || '\s+(?:' || agent_board_test.ci_re('no key') || '\s+)?(?:' || agent_board_test.ci_re('update') || '|' || agent_board_test.ci_re('share') || '|' || agent_board_test.ci_re('key share') || ')(?![A-Za-z0-9_])')
|
|
823
|
+
)
|
|
824
|
+
select coalesce(string_agg(distinct c.root_name, ', ' order by c.root_name), '<none>')
|
|
825
|
+
from agent_board_test.fn_call_closure() c
|
|
826
|
+
where c.reached_name = '_claimgate_lock_board_policy'
|
|
827
|
+
and c.root_oid in (select root_oid from agent_board_test.fn_call_closure()
|
|
828
|
+
where reached_oid in (select oid from rowlockers))),
|
|
829
|
+
'acquire_claim, renew_claim, set_board_claim_policy',
|
|
830
|
+
'CPR-14d LOCK ORDER (callee direction, membership half): these are the functions that reach BOTH a claims row-locker and the board lock through their call closures — a fourth, wrapper or composer alike, is named here and must be decided about rather than exempted by a predicate');
|
|
831
|
+
|
|
832
|
+
-- ---------------------------------------------------------------------------
|
|
833
|
+
-- CPR-0 / CPR-0a. THE MATCHERS' BEHAVIOUR TABLE, AND A GATE ON THE TABLE ITSELF.
|
|
834
|
+
--
|
|
835
|
+
-- WHY BOTH. The identifier matchers drifted four times across three review rounds, and every
|
|
836
|
+
-- version passed its accept cases. So a table was added. Then the SAME defect appeared INSIDE the
|
|
837
|
+
-- table three times: rows that pass for a reason unrelated to the rule they are named for — first
|
|
838
|
+
-- r06 (tested `$` through an adjacency that rejected it earlier), then a12/r13 (compiled a
|
|
839
|
+
-- reconstruction rather than the production object), then a13/a14/a15 (keyword branches that the
|
|
840
|
+
-- subject text could never exercise). Each was found by a different reviewer, after the previous
|
|
841
|
+
-- fix, by the same procedure: revert a rule and see which rows move.
|
|
842
|
+
--
|
|
843
|
+
-- THREE OCCURRENCES IS ENOUGH EVIDENCE THAT A HAND-COMPILED REVERT MAP CANNOT BE TRUSTED, because
|
|
844
|
+
-- compiling it is the same act as writing the rows. So the map is mechanical now: CPR-0a reverts
|
|
845
|
+
-- each rule in turn, re-evaluates every row, and requires at least one row to flip. A rule with no
|
|
846
|
+
-- flipping row FAILS AND NAMES ITSELF — which is what would have caught all three, and none of
|
|
847
|
+
-- which a human reading the table did.
|
|
848
|
+
--
|
|
849
|
+
-- The rows are DATA rather than inline expressions so both assertions evaluate the same list: a
|
|
850
|
+
-- row CPR-0 checks and CPR-0a cannot re-evaluate would be back to two lists that agree today.
|
|
851
|
+
-- ---------------------------------------------------------------------------
|
|
852
|
+
create temp table _mrow (label text primary key, expr text, want boolean) on commit drop;
|
|
853
|
+
|
|
854
|
+
insert into _mrow(label, expr, want) values
|
|
855
|
+
-- ACCEPTS: spellings PostgreSQL resolves to OUR object
|
|
856
|
+
('a01-qualified', $x$'update agent_board.claims set x' ~ agent_board_test.stmt_re('update','claims')$x$, true),
|
|
857
|
+
('a02-unqualified', $x$'update claims set x' ~ agent_board_test.stmt_re('update','claims')$x$, true),
|
|
858
|
+
('a03-upper-unquoted', $x$'UPDATE AGENT_BOARD.CLAIMS SET x' ~ agent_board_test.stmt_re('update','claims')$x$, true),
|
|
859
|
+
('a04-quoted-lower', $x$'update "agent_board"."claims" set x' ~ agent_board_test.stmt_re('update','claims')$x$, true),
|
|
860
|
+
('a05-verb-newline', $x$'insert' || chr(10) || 'into claims v' ~ agent_board_test.stmt_re('insert into','claims')$x$, true),
|
|
861
|
+
('a06-verb-2spaces', $x$'delete from claims where x' ~ agent_board_test.stmt_re('delete from','claims')$x$, true),
|
|
862
|
+
('a07-verb-tab', $x$'merge' || chr(9) || 'into claims m' ~ agent_board_test.stmt_re('merge into','claims')$x$, true),
|
|
863
|
+
-- a08 gates ci_re's OWN space rule, which a05-a07 do NOT: they exercise whitespace inside a verb
|
|
864
|
+
-- through stmt_re, which would still work if ci_re emitted a literal space and stmt_re rewrote it.
|
|
865
|
+
('a08-cire-phrase', $x$'no' || chr(10) || 'key' ~ ('(?c)' || agent_board_test.ci_re('no key'))$x$, true),
|
|
866
|
+
('a09-inheritance', $x$'update agent_board.boards* set x' ~ agent_board_test.stmt_re('update','boards')$x$, true),
|
|
867
|
+
('a10-call-space', $x$'perform _claimgate_lock_board_policy (v)' ~ agent_board_test.call_re('_claimgate_lock_board_policy')$x$, true),
|
|
868
|
+
('a11-call-quoted', $x$'perform agent_board."_claimgate_lock_board_policy"(v)' ~ agent_board_test.call_re('_claimgate_lock_board_policy')$x$, true),
|
|
869
|
+
('a12-col-upper', $x$'set CLAIM_POLICY = x' ~ agent_board_test.col_frag('claim_policy')$x$, true),
|
|
870
|
+
('a13-ins-novals', $x$'insert into agent_board.boards values (1,2)' ~ agent_board_test.insert_all_columns_frag('boards')$x$, true),
|
|
871
|
+
('a14-ins-select', $x$'insert into agent_board.boards select (1)' ~ agent_board_test.insert_all_columns_frag('boards')$x$, true),
|
|
872
|
+
-- a15/a16 cover two INSERT FORMS the rule must census. They do NOT gate a keyword exclusion, and
|
|
873
|
+
-- that is stated rather than implied: DEFAULT is only ever followed by VALUES and OVERRIDING by
|
|
874
|
+
-- SYSTEM/USER, so neither can be mistaken for an alias-before-`(`. The exclusions for them were
|
|
875
|
+
-- therefore DEAD CODE and have been removed rather than kept with rows that cannot gate them.
|
|
876
|
+
('a15-ins-default', $x$'insert into agent_board.boards default values' ~ agent_board_test.insert_all_columns_frag('boards')$x$, true),
|
|
877
|
+
('a16-ins-overriding', $x$'insert into agent_board.boards overriding user value values (1)' ~ agent_board_test.insert_all_columns_frag('boards')$x$, true),
|
|
878
|
+
('a17-ins-re-eq-frag', $x$agent_board_test.insert_all_columns_re('boards') = '(?c)' || agent_board_test.insert_all_columns_frag('boards')$x$, true),
|
|
879
|
+
-- REJECTS: spellings PostgreSQL resolves ELSEWHERE
|
|
880
|
+
('a18-verb-rel-spaces', $x$'update agent_board.claims set x' ~ agent_board_test.stmt_re('update','claims')$x$, true),
|
|
881
|
+
('r01-other-schema', $x$'update other.claims set x' ~ agent_board_test.stmt_re('update','claims')$x$, false),
|
|
882
|
+
('r02-dot-space', $x$'perform other . _claimgate_lock_board_policy(v)' ~ agent_board_test.call_re('_claimgate_lock_board_policy')$x$, false),
|
|
883
|
+
('r03-quoted-upper', $x$'update "AGENT_BOARD"."claims" set x' ~ agent_board_test.stmt_re('update','claims')$x$, false),
|
|
884
|
+
('r04-prefix-word', $x$'update dashboards set x' ~ agent_board_test.stmt_re('update','boards')$x$, false),
|
|
885
|
+
('r05-dollar-lead', $x$'perform x$_claimgate_lock_board_policy(v)' ~ agent_board_test.call_re('_claimgate_lock_board_policy')$x$, false),
|
|
886
|
+
('r06-dollar-rel', $x$'from x$claims c' ~ agent_board_test.qname_re('claims')$x$, false),
|
|
887
|
+
('r07-nonascii-lead', $x$'perform ' || chr(233) || '_claimgate_lock_board_policy(v)' ~ agent_board_test.call_re('_claimgate_lock_board_policy')$x$, false),
|
|
888
|
+
('r08-nonascii-trail', $x$'from claims' || chr(233) || ' c' ~ agent_board_test.qname_re('claims')$x$, false),
|
|
889
|
+
('r09-col-nonascii', $x$'set claim_policy' || chr(233) || ' = x' ~ agent_board_test.col_frag('claim_policy')$x$, false),
|
|
890
|
+
('r10-col-dollar', $x$'set x$claim_policy = x' ~ agent_board_test.col_frag('claim_policy')$x$, false),
|
|
891
|
+
('r11-i-flag', $x$regexp_instr('x "AGENT_BOARD"."claims" y', agent_board_test.qname_re('claims'), 1, 1, 0, 'i') > 0$x$, false),
|
|
892
|
+
('r12-ci-operator', $x$'x "AGENT_BOARD"."claims" y' ~* agent_board_test.qname_re('claims')$x$, false),
|
|
893
|
+
('r13-ins-collist', $x$'insert into agent_board.boards (slug) values (1)' ~ agent_board_test.insert_all_columns_frag('boards')$x$, false),
|
|
894
|
+
('r14-ins-alias', $x$'insert into agent_board.boards as b(slug) values (1)' ~ agent_board_test.insert_all_columns_frag('boards')$x$, false),
|
|
895
|
+
('r16-call-i-flag', $x$regexp_instr('perform "AGENT_BOARD"."_claimgate_lock_board_policy"(v)', agent_board_test.call_re('_claimgate_lock_board_policy'), 1, 1, 0, 'i') > 0$x$, false),
|
|
896
|
+
('r15-ins-qalias', $x$'insert into agent_board.boards as "b"(slug) values (1)' ~ agent_board_test.insert_all_columns_frag('boards')$x$, false);
|
|
897
|
+
|
|
898
|
+
create function pg_temp.mverdict(p_expr text) returns boolean
|
|
899
|
+
language plpgsql as $f$
|
|
900
|
+
declare v boolean;
|
|
901
|
+
begin
|
|
902
|
+
execute format('select (%s)', p_expr) into v;
|
|
903
|
+
return coalesce(v, false);
|
|
904
|
+
exception when others then
|
|
905
|
+
-- A row whose expression cannot compile is a FAILED row, not a skipped one: returning null here
|
|
906
|
+
-- would let a broken pattern read as agreement with a `false` expectation.
|
|
907
|
+
return not (select want from _mrow where expr = p_expr);
|
|
908
|
+
end $f$;
|
|
909
|
+
|
|
910
|
+
select is(
|
|
911
|
+
(select coalesce(string_agg(label || '=' || pg_temp.mverdict(expr)::text, ', ' order by label), '<none>')
|
|
912
|
+
from _mrow),
|
|
913
|
+
(select coalesce(string_agg(label || '=' || want::text, ', ' order by label), '<none>') from _mrow),
|
|
914
|
+
'CPR-0 MATCHER BEHAVIOUR: over PLAIN and DOUBLE-QUOTED identifiers, the matchers accept the spellings PostgreSQL resolves to our object and reject those it resolves elsewhere — Unicode-escape identifiers (U&"...") are OUT OF SCOPE and unhandled, so this is not a claim about every spelling');
|
|
915
|
+
|
|
916
|
+
-- CPR-0a. EVERY RULE HAS A ROW THAT FAILS WITHOUT IT — compiled by the database, not by hand.
|
|
917
|
+
-- ONE BEHAVIOUR PER ENTRY, AND EACH NAMES THE ROW THAT MUST FLIP. Both are corrections.
|
|
918
|
+
--
|
|
919
|
+
-- THE SPLIT PAID FOR ITSELF ON ITS FIRST RUN, which is the answer to "why are these so finely
|
|
920
|
+
-- divided": separating the behaviours immediately exposed FOUR matcher rules the coarser hand list
|
|
921
|
+
-- had never reverted — ident_re's quoted alternative, qname_frag's OPTIONAL qualifier, call_re's own
|
|
922
|
+
-- director, and stmt_re's `\s+` between verb and relation. Two of them had no row at all (a18, r16
|
|
923
|
+
-- were added for them). A reviewer tempted to recombine entries should read that as the cost.
|
|
924
|
+
--
|
|
925
|
+
-- NO DESCRIPTION CARRIES A COUNT, and the rule that replaced is worth recording because it was
|
|
926
|
+
-- WRONG in a way that took a reviewer to see. It said a count is "safe" when it sits beside its own
|
|
927
|
+
-- assertion's expected value, since the two would fail together. They do not. PROXIMITY IS NOT
|
|
928
|
+
-- DERIVATION: nothing computes the count from the expected value, so the assertion fails on drift
|
|
929
|
+
-- only until a maintainer updates the expected value — which is the normal maintenance path — and
|
|
930
|
+
-- from that moment the count is silently wrong with nothing failing.
|
|
931
|
+
--
|
|
932
|
+
-- Measured rather than argued: add a function to agent_board_test, and CPR-0b fails; update its
|
|
933
|
+
-- expected value the way a maintainer would, and it passes again while any count in its prose is now
|
|
934
|
+
-- false. So the "safe" category did not exist. There are only counts that are easier to notice and
|
|
935
|
+
-- counts that are harder, and the durable move is the one this package already made when its runner
|
|
936
|
+
-- dropped "28 tests" and "42-migration union": delete them. The expected values enumerate every
|
|
937
|
+
-- member by name; a reader who wants a number can count one that cannot drift.
|
|
938
|
+
--
|
|
939
|
+
-- THE RECURSION IS THE POINT, not an aside: the rule written to prevent stale counts made a claim
|
|
940
|
+
-- broader than its own mechanism supported — the same shape as every finding in this review, in the
|
|
941
|
+
-- sentence codifying the lesson from the previous one.
|
|
942
|
+
--
|
|
943
|
+
-- IN THESE TWO ASSERTIONS the expected values enumerate every entry by name, so no count appears in
|
|
944
|
+
-- either description: it would add nothing a reader cannot see and one more thing that can go stale.
|
|
945
|
+
--
|
|
946
|
+
-- COMPOUND ENTRIES LET A COVERED BRANCH CONCEAL AN UNCOVERED SIBLING: `keyword exclusions` reverted
|
|
947
|
+
-- VALUES and SELECT together, and `lead_guard` reverted its dot-whitespace and non-ASCII guards
|
|
948
|
+
-- together, so deleting one row still left the rule reporting as gated through the other. That is
|
|
949
|
+
-- the r06 defect at the RULE level — the map certifying a rule it only half tests.
|
|
950
|
+
--
|
|
951
|
+
-- AND "SOME ROW FLIPPED" IS NOT "THE ROW FOR THIS RULE FLIPPED". The gate now requires the NAMED
|
|
952
|
+
-- row, so a rule cannot borrow coverage from an unrelated one.
|
|
953
|
+
create temp table _mrule (rule_name text primary key, fn text, expect_row text, revert_sql text) on commit drop;
|
|
954
|
+
|
|
955
|
+
insert into _mrule(rule_name, fn, expect_row, revert_sql) values
|
|
956
|
+
('ci_re: a space becomes \s+', 'agent_board_test.ci_re(text)', 'a08-cire-phrase', $r$
|
|
957
|
+
create or replace function agent_board_test.ci_re(p_text text) returns text
|
|
958
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
959
|
+
select coalesce(string_agg(case when c ~ '[A-Za-z]' then '[' || upper(c) || lower(c) || ']' else c end, ''), '')
|
|
960
|
+
from regexp_split_to_table(p_text, '') as c $b$; $r$),
|
|
961
|
+
('ident_chars: $ is an identifier character', 'agent_board_test.ident_chars()', 'r05-dollar-lead', $r$
|
|
962
|
+
create or replace function agent_board_test.ident_chars() returns text
|
|
963
|
+
language sql immutable set search_path to pg_catalog as $b$ select 'A-Za-z0-9_' $b$; $r$),
|
|
964
|
+
('ident_re: the quoted alternative', 'agent_board_test.ident_re(text)', 'a04-quoted-lower', $r$
|
|
965
|
+
create or replace function agent_board_test.ident_re(p_name text) returns text
|
|
966
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
967
|
+
select '(?:' || agent_board_test.ci_re(p_name) || ')' $b$; $r$),
|
|
968
|
+
('lead_guard: reject a dot with whitespace', 'agent_board_test.lead_guard()', 'r02-dot-space', $r$
|
|
969
|
+
create or replace function agent_board_test.lead_guard() returns text
|
|
970
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
971
|
+
select '(?<![' || agent_board_test.ident_chars() || '."])(?<![^\x00-\x7F])' $b$; $r$),
|
|
972
|
+
('lead_guard: reject a leading non-ASCII letter', 'agent_board_test.lead_guard()', 'r07-nonascii-lead', $r$
|
|
973
|
+
create or replace function agent_board_test.lead_guard() returns text
|
|
974
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
975
|
+
select '(?<![' || agent_board_test.ident_chars() || '."])(?<!\.\s*)' $b$; $r$),
|
|
976
|
+
('trail_guard: reject a trailing non-ASCII letter', 'agent_board_test.trail_guard()', 'r08-nonascii-trail', $r$
|
|
977
|
+
create or replace function agent_board_test.trail_guard() returns text
|
|
978
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
979
|
+
select '(?![' || agent_board_test.ident_chars() || '])' $b$; $r$),
|
|
980
|
+
('qname_frag: the qualifier is OPTIONAL', 'agent_board_test.qname_frag(text)', 'a02-unqualified', $r$
|
|
981
|
+
create or replace function agent_board_test.qname_frag(p_name text) returns text
|
|
982
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
983
|
+
select agent_board_test.lead_guard() || agent_board_test.ident_re('agent_board')
|
|
984
|
+
|| '\s*\.\s*' || agent_board_test.ident_re(p_name) || agent_board_test.trail_guard() $b$; $r$),
|
|
985
|
+
('qname_re: the (?c) director', 'agent_board_test.qname_re(text)', 'r11-i-flag', $r$
|
|
986
|
+
create or replace function agent_board_test.qname_re(p_name text) returns text
|
|
987
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
988
|
+
select agent_board_test.qname_frag(p_name) $b$; $r$),
|
|
989
|
+
('call_frag: whitespace may precede the paren', 'agent_board_test.call_frag(text)', 'a10-call-space', $r$
|
|
990
|
+
create or replace function agent_board_test.call_frag(p_name text) returns text
|
|
991
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
992
|
+
select agent_board_test.lead_guard() || '(?:' || agent_board_test.ident_re('agent_board')
|
|
993
|
+
|| '\s*\.\s*)?' || agent_board_test.ident_re(p_name) || '\(' $b$; $r$),
|
|
994
|
+
('call_re: the (?c) director', 'agent_board_test.call_re(text)', 'r16-call-i-flag', $r$
|
|
995
|
+
create or replace function agent_board_test.call_re(p_name text) returns text
|
|
996
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
997
|
+
select agent_board_test.call_frag(p_name) $b$; $r$),
|
|
998
|
+
('col_frag: the column token carries identifier rules', 'agent_board_test.col_frag(text)', 'a12-col-upper', $r$
|
|
999
|
+
create or replace function agent_board_test.col_frag(p_name text) returns text
|
|
1000
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
1001
|
+
select agent_board_test.lead_guard() || p_name || agent_board_test.trail_guard() $b$; $r$),
|
|
1002
|
+
('stmt_re: \s+ between the verb and the relation', 'agent_board_test.stmt_re(text,text)', 'a18-verb-rel-spaces', $r$
|
|
1003
|
+
create or replace function agent_board_test.stmt_re(p_verbs text, p_rel text) returns text
|
|
1004
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
1005
|
+
select '(?c)(?:' || (select string_agg(agent_board_test.ci_re(v), '|')
|
|
1006
|
+
from regexp_split_to_table(p_verbs, '\|') as v)
|
|
1007
|
+
|| ') (?:' || agent_board_test.ci_re('only') || '\s+)?' || agent_board_test.qname_frag(p_rel) $b$; $r$),
|
|
1008
|
+
('insert rule: the VALUES exclusion', 'agent_board_test.insert_all_columns_frag(text)', 'a13-ins-novals', $r$
|
|
1009
|
+
create or replace function agent_board_test.insert_all_columns_frag(p_rel text) returns text
|
|
1010
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
1011
|
+
select agent_board_test.ci_re('insert into') || '\s+(?:' || agent_board_test.ci_re('only') || '\s+)?'
|
|
1012
|
+
|| agent_board_test.qname_frag(p_rel)
|
|
1013
|
+
|| '(?!(?:\s+(?:' || agent_board_test.ci_re('as') || '\s+)?(?!'
|
|
1014
|
+
|| agent_board_test.ci_re('select') || '(?![A-Za-z0-9_$]))'
|
|
1015
|
+
|| '(?:"[^"]*"|[A-Za-z_][A-Za-z0-9_$]*))?\s*\()' $b$; $r$),
|
|
1016
|
+
('insert rule: the SELECT exclusion', 'agent_board_test.insert_all_columns_frag(text)', 'a14-ins-select', $r$
|
|
1017
|
+
create or replace function agent_board_test.insert_all_columns_frag(p_rel text) returns text
|
|
1018
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
1019
|
+
select agent_board_test.ci_re('insert into') || '\s+(?:' || agent_board_test.ci_re('only') || '\s+)?'
|
|
1020
|
+
|| agent_board_test.qname_frag(p_rel)
|
|
1021
|
+
|| '(?!(?:\s+(?:' || agent_board_test.ci_re('as') || '\s+)?(?!'
|
|
1022
|
+
|| agent_board_test.ci_re('values') || '(?![A-Za-z0-9_$]))'
|
|
1023
|
+
|| '(?:"[^"]*"|[A-Za-z_][A-Za-z0-9_$]*))?\s*\()' $b$; $r$),
|
|
1024
|
+
('insert rule: no backtracking before the paren', 'agent_board_test.insert_all_columns_frag(text)', 'r13-ins-collist', $r$
|
|
1025
|
+
create or replace function agent_board_test.insert_all_columns_frag(p_rel text) returns text
|
|
1026
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
1027
|
+
select agent_board_test.ci_re('insert into') || '\s+(?:' || agent_board_test.ci_re('only') || '\s+)?'
|
|
1028
|
+
|| agent_board_test.qname_frag(p_rel) || '\s*(?!\()' $b$; $r$),
|
|
1029
|
+
('insert rule: the quoted-alias alternative', 'agent_board_test.insert_all_columns_frag(text)', 'r15-ins-qalias', $r$
|
|
1030
|
+
create or replace function agent_board_test.insert_all_columns_frag(p_rel text) returns text
|
|
1031
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
1032
|
+
select agent_board_test.ci_re('insert into') || '\s+(?:' || agent_board_test.ci_re('only') || '\s+)?'
|
|
1033
|
+
|| agent_board_test.qname_frag(p_rel)
|
|
1034
|
+
|| '(?!(?:\s+(?:' || agent_board_test.ci_re('as') || '\s+)?(?!'
|
|
1035
|
+
|| agent_board_test.ci_re('values') || '(?![A-Za-z0-9_$])|'
|
|
1036
|
+
|| agent_board_test.ci_re('select') || '(?![A-Za-z0-9_$]))'
|
|
1037
|
+
|| '[A-Za-z_][A-Za-z0-9_$]*)?\s*\()' $b$; $r$),
|
|
1038
|
+
('insert re: composed from the frag', 'agent_board_test.insert_all_columns_re(text)', 'a17-ins-re-eq-frag', $r$
|
|
1039
|
+
create or replace function agent_board_test.insert_all_columns_re(p_rel text) returns text
|
|
1040
|
+
language sql immutable set search_path to pg_catalog as $b$
|
|
1041
|
+
select agent_board_test.insert_all_columns_frag(p_rel) $b$; $r$);
|
|
1042
|
+
|
|
1043
|
+
create temp table _mflip (rule_name text, expect_row text, flipped text, named_row_flipped boolean) on commit drop;
|
|
1044
|
+
|
|
1045
|
+
do $mut$
|
|
1046
|
+
declare
|
|
1047
|
+
r record;
|
|
1048
|
+
v_save text;
|
|
1049
|
+
v_hits text;
|
|
1050
|
+
begin
|
|
1051
|
+
for r in select * from _mrule order by rule_name loop
|
|
1052
|
+
v_save := pg_get_functiondef(r.fn::regprocedure);
|
|
1053
|
+
execute r.revert_sql;
|
|
1054
|
+
select coalesce(string_agg(m.label, ' ' order by m.label), '<NONE>')
|
|
1055
|
+
into v_hits
|
|
1056
|
+
from _mrow m
|
|
1057
|
+
where pg_temp.mverdict(m.expr) <> m.want;
|
|
1058
|
+
execute v_save; -- restore before the next rule
|
|
1059
|
+
insert into _mflip
|
|
1060
|
+
values (r.rule_name, r.expect_row, v_hits,
|
|
1061
|
+
v_hits ~ ('(^| )' || r.expect_row || '( |$)'));
|
|
1062
|
+
end loop;
|
|
1063
|
+
end $mut$;
|
|
1064
|
+
|
|
1065
|
+
select is(
|
|
1066
|
+
(select coalesce(string_agg(rule_name || ' (wanted ' || expect_row || ', got ' || flipped || ')',
|
|
1067
|
+
'; ' order by rule_name), '<none>')
|
|
1068
|
+
from _mflip where not named_row_flipped),
|
|
1069
|
+
'<none>',
|
|
1070
|
+
'CPR-0a REVERT MAP (mechanical): reverting each rule makes THE ROW NAMED FOR THAT RULE fail — one behaviour per entry and a named row per entry, so a compound revert cannot let a covered branch conceal an uncovered sibling and no rule can borrow coverage from an unrelated row — the map is compiled by reverting each rule and re-evaluating the rows, not by hand, because three separate rows have now certified axes they did not test and a human reading the table caught none of them');
|
|
1071
|
+
|
|
1072
|
+
-- CPR-0b. AND THE RULE INVENTORY IS RECONCILED AGAINST THE CATALOG, because _mrule is still written
|
|
1073
|
+
-- by hand and a matcher added without an entry would be SILENTLY UNGATED — which contradicts
|
|
1074
|
+
-- CPR-0a's own sentence. Deriving "every rule a regex-building function implements" from its source
|
|
1075
|
+
-- is not mechanical; deriving "every FUNCTION in this schema is either gated or positively exempted"
|
|
1076
|
+
-- is, and it closes the exposure that matters: a new matcher cannot arrive unnoticed.
|
|
1077
|
+
--
|
|
1078
|
+
-- Every function in agent_board_test is classified. A new matcher appears as `NOT-GATED` and fails;
|
|
1079
|
+
-- a new helper appears too and must be exempted deliberately. That is rule 1 applied to the gate's
|
|
1080
|
+
-- own inventory: the classification comes from pg_proc, and the exemptions are asserted positively
|
|
1081
|
+
-- rather than by absence.
|
|
1082
|
+
select is(
|
|
1083
|
+
(select string_agg(p.proname || '=' ||
|
|
1084
|
+
(case when exists (select 1 from _mrule r where r.fn like '%.' || p.proname || '(%')
|
|
1085
|
+
then 'gated' else 'NOT-GATED' end), ', ' order by p.proname)
|
|
1086
|
+
from pg_proc p where p.pronamespace = 'agent_board_test'::regnamespace),
|
|
1087
|
+
'call_frag=gated, call_re=gated, ci_re=gated, col_frag=gated, fn_call_closure=NOT-GATED, '
|
|
1088
|
+
'ident_chars=gated, ident_re=gated, insert_all_columns_frag=gated, insert_all_columns_re=gated, '
|
|
1089
|
+
'lead_guard=gated, lock_edges=NOT-GATED, qname_frag=gated, qname_re=gated, '
|
|
1090
|
+
'row_lock_rank=NOT-GATED, row_locks_conflict=NOT-GATED, stmt_re=gated, strip_sql=NOT-GATED, '
|
|
1091
|
+
'stripped_body=NOT-GATED, stripped_def=NOT-GATED, stripped_viewdef=NOT-GATED, trail_guard=gated',
|
|
1092
|
+
'CPR-0b RULE INVENTORY: every function in agent_board_test is either covered by a CPR-0a rule or positively listed as not a pattern matcher — the NOT-GATED entries are body readers and lock helpers, which build no regex, and the expected value names every one of them; a new matcher added without a rule appears here as NOT-GATED and fails by name');
|
|
1093
|
+
|
|
1094
|
+
-- CPR-15/16. And no session can take one either — the OPERATION, not a privilege proxy for it.
|
|
1095
|
+
-- `select ... for update` on claims is refused with 42501 to both roles that can read the table,
|
|
1096
|
+
-- because a row lock needs UPDATE/DELETE privilege and neither holds it at table or column level.
|
|
1097
|
+
-- anon cannot even SELECT. Run inside a DO block so the SET LOCAL role is unwound with the
|
|
1098
|
+
-- subtransaction throws_ok opens.
|
|
1099
|
+
select throws_ok(
|
|
1100
|
+
$$ do $cpr$ begin
|
|
1101
|
+
perform set_config('role', 'authenticated', true);
|
|
1102
|
+
perform 1 from agent_board.claims limit 1 for update;
|
|
1103
|
+
end $cpr$ $$,
|
|
1104
|
+
'42501', NULL,
|
|
1105
|
+
'CPR-15 LOCK ORDER: an authenticated session cannot row-lock a claim DIRECTLY — `select ... for update` is refused 42501, so the hand-written inversion claimpolicy_claimrow_lock_order.spec constructs cannot be spelled that way; composition through granted definer RPCs is a separate path, narrowed in that spec''s header');
|
|
1106
|
+
|
|
1107
|
+
select throws_ok(
|
|
1108
|
+
$$ do $cpr$ begin
|
|
1109
|
+
perform set_config('role', 'service_role', true);
|
|
1110
|
+
perform 1 from agent_board.claims limit 1 for update;
|
|
1111
|
+
end $cpr$ $$,
|
|
1112
|
+
'42501', NULL,
|
|
1113
|
+
'CPR-16 LOCK ORDER: nor can service_role spell it directly — the operator carve-out exempts it from the guards, not from the table grants; what service_role could do is compose it across two RPCs, which is why CPR-22 now pins the lock helper as grantable to nobody');
|
|
1114
|
+
|
|
1115
|
+
-- ===========================================================================
|
|
1116
|
+
-- THE HELPERS: contract and authorization.
|
|
1117
|
+
-- ===========================================================================
|
|
1118
|
+
|
|
1119
|
+
-- CPR-17/18. The null fallbacks, which are what make the "board stamped mid-wait" drift
|
|
1120
|
+
-- CONSERVATIVE rather than a hole: an absent board resolves to the ENFORCING policy.
|
|
1121
|
+
select is(
|
|
1122
|
+
(select agent_board._claimgate_board_of_thread(null)),
|
|
1123
|
+
null::uuid,
|
|
1124
|
+
'CPR-17 NULL FALLBACK: a null thread has no board — so nothing is locked and nothing can go stale');
|
|
1125
|
+
|
|
1126
|
+
select is(
|
|
1127
|
+
(select agent_board._claimgate_board_claim_policy_of(null)),
|
|
1128
|
+
'inherit',
|
|
1129
|
+
'CPR-18 NULL FALLBACK: an absent board resolves to inherit, the ENFORCING answer — a board stamped onto a null-board thread mid-wait can therefore only make this path stricter, never permissive');
|
|
1130
|
+
|
|
1131
|
+
-- CPR-19/20. The composition is the SAME function it replaced. `_claimgate_board_claim_policy` is
|
|
1132
|
+
-- what the census view resolves through and what BCP-5 pins; decomposing it into the two halves
|
|
1133
|
+
-- the enforcement paths call separately must not have moved its answer on either case.
|
|
1134
|
+
select is(
|
|
1135
|
+
(select agent_board._claimgate_board_claim_policy(c.thread) from _cpr c),
|
|
1136
|
+
(select agent_board._claimgate_board_claim_policy_of(agent_board._claimgate_board_of_thread(c.thread)) from _cpr c),
|
|
1137
|
+
'CPR-19 COMPOSITION: for a board-linked thread the thread-keyed resolver and the two halves agree — one definition of the walk, not two');
|
|
1138
|
+
|
|
1139
|
+
select is(
|
|
1140
|
+
(select agent_board._claimgate_board_claim_policy(c.free_thread) from _cpr c),
|
|
1141
|
+
'inherit',
|
|
1142
|
+
'CPR-20 COMPOSITION: a thread with NO board still resolves inherit — a claim cannot opt itself out by omitting the board (BCP-5''s rule, unchanged by the decomposition)');
|
|
1143
|
+
|
|
1144
|
+
-- CPR-21. And the board-linked fixture really is advisory, so CPR-19 is comparing something.
|
|
1145
|
+
-- Without this CPR-19 could agree on 'inherit' twice and prove nothing.
|
|
1146
|
+
select is(
|
|
1147
|
+
(select agent_board._claimgate_board_claim_policy(c.thread) from _cpr c),
|
|
1148
|
+
'advisory',
|
|
1149
|
+
'CPR-21 DISCRIMINATION: the fixture board is advisory, so CPR-19 compares a non-default answer rather than agreeing on the fallback twice');
|
|
1150
|
+
|
|
1151
|
+
-- CPR-22..24. AUTHORIZATION. The serialisation is only worth something if a session cannot take
|
|
1152
|
+
-- the lock on a key of its own choosing, or skip it; and the two read helpers must stay narrower
|
|
1153
|
+
-- than the thread-keyed resolver, which KEEPS its authenticated grant because the census view
|
|
1154
|
+
-- resolves through it.
|
|
1155
|
+
-- CPR-22. THE GRANT THIS PR REMOVED, AND WHY. The description here used to say the helper was
|
|
1156
|
+
-- "unreachable from a session" while its own expected string asserted `service_role=true` — and a
|
|
1157
|
+
-- service_role SESSION could indeed call it. That is not academic: with the grant in place, a
|
|
1158
|
+
-- single service_role transaction could call `renew_claim` (whose `update agent_board.claims` sits
|
|
1159
|
+
-- OUTSIDE the ownerless block, so the row lock is taken even though `_is_service_role()`
|
|
1160
|
+
-- short-circuits the board lock) and then call this helper DIRECTLY — assembling claim-row-lock
|
|
1161
|
+
-- then board-lock, the very inversion claimpolicy_claimrow_lock_order.spec constructs by hand. It
|
|
1162
|
+
-- was reproduced as a real `deadlock detected` before the grant was removed.
|
|
1163
|
+
--
|
|
1164
|
+
-- The grant was dead weight: the helper is NOT SECURITY DEFINER and its three callers ARE, owned
|
|
1165
|
+
-- by the same owner, so they reach it by ownership. Nothing in the schema, the TypeScript packages
|
|
1166
|
+
-- (client/cli/mcp/runtime), or the Edge Functions names it — derived from the tree, not assumed.
|
|
1167
|
+
-- ENUMERATED FROM THE ACL, ACROSS EVERY OVERLOAD OF THE NAME. Two hand lists have been removed
|
|
1168
|
+
-- from this assertion in successive rounds, and the second was introduced by the fix for the first.
|
|
1169
|
+
--
|
|
1170
|
+
-- Round 3 replaced four has_function_privilege() calls (public/anon/authenticated/service_role)
|
|
1171
|
+
-- with aclexplode, because a grant to any fifth role was invisible. Measured: with EXECUTE
|
|
1172
|
+
-- granted to a fifth role the old form returned all-false and PASSED.
|
|
1173
|
+
--
|
|
1174
|
+
-- Round 4 removed the SIGNATURE filter. That fix keyed the query to
|
|
1175
|
+
-- `to_regprocedure('…(uuid)')` while its own description claimed "an overload carrying one is
|
|
1176
|
+
-- named here" — a claim one `create function …(text)` away from false. The property actually
|
|
1177
|
+
-- wanted is about the NAME: nothing in agent_board called _claimgate_lock_board_policy should be
|
|
1178
|
+
-- executable by a non-owner, whatever its argument types. Enumerating overloads is strictly
|
|
1179
|
+
-- stronger than asserting none exists, and does not require that second fact to be kept true.
|
|
1180
|
+
--
|
|
1181
|
+
-- Round 5 corrected the WORD: the scan takes any pg_proc row, so it covers ROUTINES — procedures
|
|
1182
|
+
-- and aggregates as well as functions — while the description said "function overloads". The
|
|
1183
|
+
-- scan was right and the sentence was narrow; both now say routine. 20261001000100 has the same
|
|
1184
|
+
-- scope and had to change its REVOKE verb to match, because `on function` RAISES against a
|
|
1185
|
+
-- procedure.
|
|
1186
|
+
--
|
|
1187
|
+
-- Four fail-open modes closed here, each deliberately:
|
|
1188
|
+
-- * `proacl IS NULL` means DEFAULT privileges, and the default for a function is EXECUTE to
|
|
1189
|
+
-- PUBLIC. aclexplode(NULL) returns ZERO ROWS, so a plain enumeration would report '<none>' for
|
|
1190
|
+
-- the one state where PUBLIC really can call it. Reported as its own row per overload.
|
|
1191
|
+
-- * ZERO overloads would also aggregate to '<none>' and PASS — an absent function reported as a
|
|
1192
|
+
-- clean ACL. The count is checked first and says so instead.
|
|
1193
|
+
-- * grantee 0 is PUBLIC and renders as '-' through regrole; spelled out so a PUBLIC grant is
|
|
1194
|
+
-- legible in the failure message rather than looking like a dash.
|
|
1195
|
+
-- * a grantee OID with no pg_roles row is RENDERED, not raised on. `a.grantee::regrole` raises
|
|
1196
|
+
-- for a dangling OID, and a raise inside a pgTAP assertion aborts the file and emits NO
|
|
1197
|
+
-- `not ok` at all — rule 3's exact shape, and the same trap to_regprocedure avoided before.
|
|
1198
|
+
-- The migration's matching `raise` is correct precisely because it is NOT an assertion: there,
|
|
1199
|
+
-- skipping silently would leave a live grant standing.
|
|
1200
|
+
select is(
|
|
1201
|
+
(with fns as (
|
|
1202
|
+
select p.oid, p.proacl, p.proowner, p.oid::regprocedure::text as sig
|
|
1203
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
1204
|
+
where n.nspname = 'agent_board' and p.proname = '_claimgate_lock_board_policy'
|
|
1205
|
+
), findings as (
|
|
1206
|
+
select f.sig || ' -> DEFAULT ACL (public holds EXECUTE implicitly)' as entry
|
|
1207
|
+
from fns f where f.proacl is null
|
|
1208
|
+
union all
|
|
1209
|
+
select f.sig || ' -> ' ||
|
|
1210
|
+
case when a.grantee = 0 then 'PUBLIC'
|
|
1211
|
+
else coalesce((select quote_ident(r.rolname) from pg_roles r where r.oid = a.grantee),
|
|
1212
|
+
'OID ' || a.grantee::text) end
|
|
1213
|
+
from fns f cross join lateral aclexplode(f.proacl) a
|
|
1214
|
+
where a.privilege_type = 'EXECUTE' and a.grantee <> f.proowner
|
|
1215
|
+
)
|
|
1216
|
+
select case when (select count(*) from fns) = 0
|
|
1217
|
+
then 'NO FUNCTION NAMED agent_board._claimgate_lock_board_policy'
|
|
1218
|
+
else coalesce((select string_agg(entry, '; ' order by entry) from findings), '<none>')
|
|
1219
|
+
end),
|
|
1220
|
+
'<none>',
|
|
1221
|
+
'CPR-22 AUTHORIZATION: no non-owner grantee holds EXECUTE on ANY ROUTINE named agent_board._claimgate_lock_board_policy — functions, procedures and aggregates alike, enumerated from each one''s ACL, so a grant to any role on any signature or kind is named here rather than slipping past a list of four roles or a filter on one signature');
|
|
1222
|
+
|
|
1223
|
+
-- ENUMERATED FROM THE ACL, like CPR-22 and for the same reason: three named roles are a hand list,
|
|
1224
|
+
-- and a grant to any fourth was invisible to the old form. Pinned to the two exact signatures with
|
|
1225
|
+
-- to_regprocedure — correct HERE (this is a property of these two specific functions, not of a
|
|
1226
|
+
-- name) and the opposite of CPR-22's name scan, which asks a property of the name. A missing
|
|
1227
|
+
-- function yields no row and fails by comparison rather than raising.
|
|
1228
|
+
select is(
|
|
1229
|
+
(select string_agg(x.nm || ':' || x.grantees, ', ' order by x.nm)
|
|
1230
|
+
from (select p.proname as nm,
|
|
1231
|
+
(select case
|
|
1232
|
+
when p.proacl is null then 'DEFAULT-ACL(public holds EXECUTE)'
|
|
1233
|
+
else coalesce((select string_agg(distinct g.who, ',' order by g.who)
|
|
1234
|
+
from (select case when a.grantee = 0 then 'PUBLIC'
|
|
1235
|
+
else coalesce((select r.rolname from pg_roles r where r.oid = a.grantee),
|
|
1236
|
+
'OID ' || a.grantee::text) end as who
|
|
1237
|
+
from aclexplode(p.proacl) a
|
|
1238
|
+
where a.privilege_type = 'EXECUTE' and a.grantee <> p.proowner) g),
|
|
1239
|
+
'<none>') end) as grantees
|
|
1240
|
+
from pg_proc p
|
|
1241
|
+
where p.oid in (to_regprocedure('agent_board._claimgate_board_of_thread(uuid)'),
|
|
1242
|
+
to_regprocedure('agent_board._claimgate_board_claim_policy_of(uuid)'))) x),
|
|
1243
|
+
'_claimgate_board_claim_policy_of:service_role, _claimgate_board_of_thread:service_role',
|
|
1244
|
+
'CPR-23 AUTHORIZATION: the two halves are granted to service_role and to NOBODY ELSE — enumerated from each ACL rather than asked of three named roles, so a grant to any other role is named here; they read threads/boards under SECURITY DEFINER, so an authenticated grant would expose any thread''s board past RLS');
|
|
1245
|
+
|
|
1246
|
+
-- ENUMERATED, because has_function_privilege COULD NOT DECIDE THIS ASSERTION'S OWN SENTENCE. It
|
|
1247
|
+
-- claims the ACL was preserved "rather than silently re-granting to PUBLIC" — but
|
|
1248
|
+
-- has_function_privilege('authenticated', …) returns TRUE when PUBLIC holds the grant, so the exact
|
|
1249
|
+
-- state the sentence rules out was indistinguishable from the one it asserts. The grantee list is
|
|
1250
|
+
-- read from the ACL instead, where PUBLIC appears by name.
|
|
1251
|
+
select is(
|
|
1252
|
+
(select (select case
|
|
1253
|
+
when p.proacl is null then 'DEFAULT-ACL(public holds EXECUTE)'
|
|
1254
|
+
else coalesce((select string_agg(distinct g.who, ',' order by g.who)
|
|
1255
|
+
from (select case when a.grantee = 0 then 'PUBLIC'
|
|
1256
|
+
else coalesce((select r.rolname from pg_roles r where r.oid = a.grantee),
|
|
1257
|
+
'OID ' || a.grantee::text) end as who
|
|
1258
|
+
from aclexplode(p.proacl) a
|
|
1259
|
+
where a.privilege_type = 'EXECUTE' and a.grantee <> p.proowner) g),
|
|
1260
|
+
'<none>') end)
|
|
1261
|
+
from pg_proc p
|
|
1262
|
+
where p.oid = to_regprocedure('agent_board._claimgate_board_claim_policy(uuid)')),
|
|
1263
|
+
'authenticated,service_role',
|
|
1264
|
+
'CPR-24 AUTHORIZATION: the thread-keyed resolver''s non-owner grantees are exactly authenticated and service_role — the census view resolves through it, and a PUBLIC grant is now DISTINGUISHABLE from the authenticated one rather than reading as the same true');
|
|
1265
|
+
|
|
1266
|
+
-- CPR-25. The lock helper works on the level it demands. The REFUSAL is the interesting half and
|
|
1267
|
+
-- cannot be asserted here — `set transaction isolation level` must be a transaction's first
|
|
1268
|
+
-- statement and this file is already inside one — so it is pinned by name in
|
|
1269
|
+
-- claimpolicy_isolation_refusal.spec. This asserts the allow side, which is what would break if
|
|
1270
|
+
-- the guard's comparison were inverted.
|
|
1271
|
+
select lives_ok(
|
|
1272
|
+
$$ select agent_board._claimgate_lock_board_policy(
|
|
1273
|
+
(select board from _cpr)) $$,
|
|
1274
|
+
'CPR-25 ISOLATION GUARD (allow side): under READ COMMITTED the lock is taken and returns — the refusal under REPEATABLE READ is pinned in claimpolicy_isolation_refusal.spec, which is the only place it can be');
|
|
1275
|
+
|
|
1276
|
+
-- CPR-26. The NULL is CAST, because an untyped one is ambiguous the moment a same-name overload
|
|
1277
|
+
-- exists: `_claimgate_lock_board_policy(null)` resolves against every overload and raises
|
|
1278
|
+
-- `function is not unique`. That fails closed rather than aborting, so it was not a hole — but it
|
|
1279
|
+
-- reported a defect in the WRONG assertion (this one) for a condition CPR-22 is the one to name.
|
|
1280
|
+
-- Found while probing a same-name procedure for CPR-22's routine scope.
|
|
1281
|
+
--
|
|
1282
|
+
-- CPR-26. And a null board is isolation-checked but locks nothing — the deliberate absence of a
|
|
1283
|
+
-- carve-out. Asserted so the branch is exercised rather than assumed unreachable.
|
|
1284
|
+
select lives_ok(
|
|
1285
|
+
$$ select agent_board._claimgate_lock_board_policy(null::uuid) $$,
|
|
1286
|
+
'CPR-26 ISOLATION GUARD: a null board takes no lock (there is no board row to serialise on) and is still admitted under READ COMMITTED');
|
|
1287
|
+
|
|
1288
|
+
-- CPR-22a. AND THE REVOKE DID NOT BREAK THE DEFINER PATH. CPR-22 is a negative — it would stay
|
|
1289
|
+
-- green if the helper became uncallable by EVERYONE including its own callers, which would take
|
|
1290
|
+
-- the enforcement offline rather than securing it. So the positive half is asserted by RUNNING the
|
|
1291
|
+
-- path: `set_board_claim_policy` is SECURITY DEFINER, reaches the helper by ownership, and its
|
|
1292
|
+
-- advisory lock must actually be HELD afterwards.
|
|
1293
|
+
--
|
|
1294
|
+
-- NOT VACUOUS, and that is the whole design of this assertion. `lives_ok` alone would pass if the
|
|
1295
|
+
-- helper were never entered, so the state check is separate (rule 3): the advisory lock the helper
|
|
1296
|
+
-- takes is looked for in pg_locks for THIS backend. The board is flipped advisory -> inherit, the
|
|
1297
|
+
-- transition that reaches the lock; the fixture board is already 'advisory' (CPR-21 pins that), so
|
|
1298
|
+
-- the call is a real transition rather than a no-op.
|
|
1299
|
+
select lives_ok(
|
|
1300
|
+
$$ select agent_board.set_board_claim_policy((select board_b from _cpr), 'inherit') $$,
|
|
1301
|
+
'CPR-22a AUTHORIZATION (positive half): with the helper granted to nobody, the SECURITY DEFINER writer still reaches it by ownership — the revoke closed the session path without taking enforcement offline');
|
|
1302
|
+
|
|
1303
|
+
-- The state half, keyed to BOARD B SPECIFICALLY. `count(*) > 0` over all advisory locks would be
|
|
1304
|
+
-- satisfied by the lock CPR-25 already holds on the other board and would prove nothing about this
|
|
1305
|
+
-- call; the key is recomputed here the same way the helper computes it, and split into the
|
|
1306
|
+
-- (classid, objid) pair pg_locks records for a bigint advisory key.
|
|
1307
|
+
select is(
|
|
1308
|
+
(select count(*)
|
|
1309
|
+
from pg_locks l, (select hashtextextended('agent_board.board_claim_policy:' ||
|
|
1310
|
+
(select board_b from _cpr)::text, 0) as k) x
|
|
1311
|
+
where l.locktype = 'advisory'
|
|
1312
|
+
and l.pid = pg_backend_pid()
|
|
1313
|
+
and l.classid = ((x.k >> 32) & 4294967295)::oid
|
|
1314
|
+
and l.objid = (x.k & 4294967295)::oid),
|
|
1315
|
+
1::bigint,
|
|
1316
|
+
'CPR-22a-state AUTHORIZATION: and the advisory lock for THAT board is genuinely held by this backend afterwards — so CPR-22a exercised the helper rather than passing on a lock some earlier assertion took');
|
|
1317
|
+
|
|
1318
|
+
-- CPR-25a. THE GUARD'S COMPARISON TEXT — and text is all it is. Read the scope note before
|
|
1319
|
+
-- treating a green here as a refusal guarantee.
|
|
1320
|
+
--
|
|
1321
|
+
-- WHAT IT ASSERTS: that the body contains an inequality against the single ACCEPTED level, spelled
|
|
1322
|
+
-- `v_isolation <> 'read committed'`. WHAT IT DOES NOT ASSERT: that this comparison is the one
|
|
1323
|
+
-- CONTROLLING the raise. It is a regex over the stripped body, so a dead or unrelated inequality
|
|
1324
|
+
-- elsewhere would satisfy it while a narrower operative guard admitted another level. Proving
|
|
1325
|
+
-- control flow is beyond what a source scan can do, and a longer regex would not change that — it
|
|
1326
|
+
-- would only make the same claim harder to audit.
|
|
1327
|
+
--
|
|
1328
|
+
-- SO THE BEHAVIOURAL PIN IS THE SPEC, NOT THIS: claimpolicy_isolation_refusal.spec's `sz` and `ru`
|
|
1329
|
+
-- permutations RUN a transaction at serializable and at read uncommitted and observe the 0A000 by
|
|
1330
|
+
-- name. This assertion earns its place beside them because a permutation only pins the levels it
|
|
1331
|
+
-- ENUMERATES: the guard's inequality form is what extends the refusal to a level no permutation
|
|
1332
|
+
-- names, and that form is textual, so a text assertion is the right instrument for it.
|
|
1333
|
+
--
|
|
1334
|
+
-- THE INDEPENDENCE, MEASURED RATHER THAN REASONED — and the first version of this comment got it
|
|
1335
|
+
-- wrong, which is worth leaving on the record given what this file is for. It claimed that
|
|
1336
|
+
-- inverting to `= 'read committed'` "fails only this one, because every permutation's level is
|
|
1337
|
+
-- still refused". Measured: that inversion refuses READ COMMITTED and ADMITS the other three, so
|
|
1338
|
+
-- it fails `sz`, `ru`, CPR-25, CPR-26 and both halves of CPR-22a as well. It is not an isolating
|
|
1339
|
+
-- mutation at all.
|
|
1340
|
+
--
|
|
1341
|
+
-- What actually separates the two instruments is a BEHAVIOUR-PRESERVING reformulation:
|
|
1342
|
+
-- `not (v_isolation = 'read committed')` is semantically identical — measured refusing repeatable
|
|
1343
|
+
-- read, serializable and read uncommitted while admitting read committed, exactly as before — so
|
|
1344
|
+
-- every permutation and every other assertion stays green, and ONLY this one goes red. That is the
|
|
1345
|
+
-- honest statement of what this assertion adds: it pins the FORM while the permutations pin the
|
|
1346
|
+
-- behaviour, and a change that alters the form without altering today's behaviour is caught here
|
|
1347
|
+
-- alone. Narrowing to `= 'repeatable read'` alters both, and fails `sz`, `ru` and this together.
|
|
1348
|
+
--
|
|
1349
|
+
-- `stripped_body(oid, true)` KEEPS literals, which is required and not incidental here: the marker
|
|
1350
|
+
-- IS the literal 'read committed' inside the comparison. Comments are still stripped, so prose
|
|
1351
|
+
-- mentioning the level cannot satisfy it.
|
|
1352
|
+
--
|
|
1353
|
+
-- PINNED TO THE (uuid) SIGNATURE, and here that is CORRECT — the opposite of CPR-22, deliberately.
|
|
1354
|
+
-- The two ask different questions. CPR-22 asks a property of the NAME ("no overload is executable
|
|
1355
|
+
-- by a non-owner"), so filtering to one signature would let another carry a grant. This asks a
|
|
1356
|
+
-- property of THE GUARD the three deciders actually call, which is the (uuid) one; an unrelated
|
|
1357
|
+
-- overload's body has no bearing on it.
|
|
1358
|
+
--
|
|
1359
|
+
-- to_regprocedure() rather than `p.proname = …`, and that is not stylistic. Selecting by NAME made
|
|
1360
|
+
-- this a scalar subquery over a SET: adding one overload made it return two rows, and
|
|
1361
|
+
-- `more than one row returned by a subquery used as an expression` ABORTED the whole file — 32 of
|
|
1362
|
+
-- 33 assertions ran and the missing one emitted no `not ok` at all. Rule 3's exact shape, found by
|
|
1363
|
+
-- running the overload mutation rather than by reading. Four scalar subqueries in this file carried
|
|
1364
|
+
-- the same latent abort and are all pinned now.
|
|
1365
|
+
select is(
|
|
1366
|
+
(select substring(agent_board_test.stripped_body(p.oid, true)
|
|
1367
|
+
from '(v_isolation\s*(?:<>|!=|=)\s*''[a-z ]+'')')
|
|
1368
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
1369
|
+
where p.oid = to_regprocedure('agent_board._claimgate_lock_board_policy(uuid)')),
|
|
1370
|
+
'v_isolation <> ''read committed''',
|
|
1371
|
+
'CPR-25a ISOLATION GUARD (comparison TEXT, not control flow): the guard''s body spells an inequality against the one accepted level rather than an equality against one refused level — this pins the FORM that extends refusal to levels no permutation enumerates; the refusal itself is demonstrated behaviourally by the sz and ru permutations in claimpolicy_isolation_refusal.spec');
|
|
1372
|
+
|
|
1373
|
+
select * from finish();
|
|
1374
|
+
rollback;
|