@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,162 @@
|
|
|
1
|
+
# Consiliency/message-board#405 — the board policy lock REFUSES a transaction
|
|
2
|
+
# that is not READ COMMITTED, rather than being silently inert in one.
|
|
3
|
+
#
|
|
4
|
+
# THE DEPENDENCY THIS PINS. The whole mechanism is "take the lock, then re-read
|
|
5
|
+
# the policy in a NEW statement". That works because under READ COMMITTED each
|
|
6
|
+
# statement takes a fresh snapshot, so the re-read sees what the writer
|
|
7
|
+
# committed while we were waiting. Under REPEATABLE READ and SERIALIZABLE the
|
|
8
|
+
# snapshot is fixed at first use: the wait does not advance it, the re-read
|
|
9
|
+
# returns the SAME stale policy, and the lock is not a guard at all — it is a
|
|
10
|
+
# delay. That failure mode is invisible; nothing errors, nothing waits
|
|
11
|
+
# incorrectly, the caller simply gets the answer the race was supposed to
|
|
12
|
+
# prevent.
|
|
13
|
+
#
|
|
14
|
+
# SO THE DECISION IS REFUSE, NOT DOCUMENT. `_claimgate_lock_board_policy` reads
|
|
15
|
+
# `current_setting('transaction_isolation')` and raises 0A000 naming the level.
|
|
16
|
+
# Nothing in this repository sets another level (a repo-wide search finds only
|
|
17
|
+
# two prose mentions and no code), so this refuses no caller that exists — and
|
|
18
|
+
# if one appears it fails loudly at its first guarded claim instead of quietly
|
|
19
|
+
# banking a horizon.
|
|
20
|
+
#
|
|
21
|
+
# THIS CANNOT BE A pgTAP ASSERTION. `SET TRANSACTION ISOLATION LEVEL` must be a
|
|
22
|
+
# transaction's first statement, and the pgTAP suite is already inside one. An
|
|
23
|
+
# isolation spec is the only place the level can be chosen.
|
|
24
|
+
#
|
|
25
|
+
# THREE PERMUTATIONS, because a refusal that fires everywhere is a different bug
|
|
26
|
+
# from one that fires nowhere:
|
|
27
|
+
#
|
|
28
|
+
# 1. REPEATABLE READ, ownerless claim -> REFUSED, by name.
|
|
29
|
+
# 2. READ COMMITTED, same call -> SUCCEEDS (the allow side).
|
|
30
|
+
# 3. REPEATABLE READ, 'machine' kind -> NOT refused. The guard is scoped to
|
|
31
|
+
# the ownerless region it protects; a fleet-kind acquire never resolves
|
|
32
|
+
# board policy and must not be collateral damage.
|
|
33
|
+
#
|
|
34
|
+
# MUTATION SIGNAL (verified in the PR): invert the comparison in
|
|
35
|
+
# `_claimgate_lock_board_policy` (`= 'read committed'`) -> permutation 1 succeeds
|
|
36
|
+
# and permutation 2 raises; both halves of the golden move.
|
|
37
|
+
|
|
38
|
+
setup
|
|
39
|
+
{
|
|
40
|
+
insert into agent_board.claimgate_enforcement_flags(scope_key, enabled) values ('ownerless_guards', true)
|
|
41
|
+
on conflict (scope_key) do update set enabled = true;
|
|
42
|
+
insert into auth.users (id) values ('40500000-0000-4000-8000-0000000000d1');
|
|
43
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
|
|
44
|
+
values ('40500000-0000-4000-8000-000000000004', 'agent', 'agent',
|
|
45
|
+
'cpr-iso-owner', 'CPR Isolation Owner', 'active',
|
|
46
|
+
'40500000-0000-4000-8000-0000000000d1', '40500000-0000-4000-8000-0000000000d1');
|
|
47
|
+
select count(*) from agent_board.create_board(
|
|
48
|
+
'40500000-0000-4000-8000-000000000004', 'cpr-iso-board', 'CPR Isolation Board',
|
|
49
|
+
'message-board#405 isolation-level refusal probe', jsonb_build_object(), 'private');
|
|
50
|
+
insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
|
|
51
|
+
values ('40500000-0000-4000-8000-000000000050', 'coordination',
|
|
52
|
+
'40500000-0000-4000-8000-000000000004', 'CPR isolation thread',
|
|
53
|
+
(select id from agent_board.boards where slug = 'cpr-iso-board'));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
teardown
|
|
57
|
+
{
|
|
58
|
+
delete from agent_board.claims where agent_id = '40500000-0000-4000-8000-000000000004';
|
|
59
|
+
delete from agent_board.threads where id = '40500000-0000-4000-8000-000000000050';
|
|
60
|
+
delete from agent_board.board_membership_events where board_id in (
|
|
61
|
+
select id from agent_board.boards where slug = 'cpr-iso-board');
|
|
62
|
+
delete from agent_board.board_memberships where board_id in (
|
|
63
|
+
select id from agent_board.boards where slug = 'cpr-iso-board');
|
|
64
|
+
delete from agent_board.boards where slug = 'cpr-iso-board';
|
|
65
|
+
delete from agent_board.agents where id = '40500000-0000-4000-8000-000000000004';
|
|
66
|
+
delete from auth.users where id = '40500000-0000-4000-8000-0000000000d1';
|
|
67
|
+
delete from agent_board.claimgate_enforcement_flags where scope_key = 'ownerless_guards';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# rr: a REPEATABLE READ caller. The lease it asks for is WITHIN the cap, so nothing else in the
|
|
71
|
+
# ownerless block would reject it -- the only reason this can fail is the isolation guard.
|
|
72
|
+
session "rr"
|
|
73
|
+
step "rr_begin" { BEGIN ISOLATION LEVEL REPEATABLE READ; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000d1'; }
|
|
74
|
+
step "rr_acq" {
|
|
75
|
+
select resource_key
|
|
76
|
+
from agent_board.acquire_claim(null, 'file', 'cpr-iso-key', now() + interval '1 hour',
|
|
77
|
+
null, '40500000-0000-4000-8000-000000000050');
|
|
78
|
+
}
|
|
79
|
+
# The fleet-kind path never resolves board policy, so it never reaches the guard. A claim with no
|
|
80
|
+
# thread and no board: kind 'machine' is not ownerless, so the guarded region is skipped entirely.
|
|
81
|
+
step "rr_machine" {
|
|
82
|
+
select resource_key
|
|
83
|
+
from agent_board.acquire_claim(null, 'machine', 'cpr-iso-machine', now() + interval '1 hour',
|
|
84
|
+
null, null);
|
|
85
|
+
}
|
|
86
|
+
step "rr_rollback" { ROLLBACK; }
|
|
87
|
+
|
|
88
|
+
# rc: the same call under the level the guard demands.
|
|
89
|
+
session "rc"
|
|
90
|
+
step "rc_begin" { BEGIN; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000d1'; }
|
|
91
|
+
step "rc_acq" {
|
|
92
|
+
select resource_key
|
|
93
|
+
from agent_board.acquire_claim(null, 'file', 'cpr-iso-key', now() + interval '1 hour',
|
|
94
|
+
null, '40500000-0000-4000-8000-000000000050');
|
|
95
|
+
}
|
|
96
|
+
step "rc_commit" { COMMIT; }
|
|
97
|
+
|
|
98
|
+
# sz: SERIALIZABLE. THE LEVEL THE OLD SPEC LEFT UNPINNED, and the one that matters most. Like
|
|
99
|
+
# REPEATABLE READ, a serializable transaction fixes its snapshot at first use and waiting on the
|
|
100
|
+
# writer's lock does NOT advance it -- so the re-read after the lock returns the SAME stale policy
|
|
101
|
+
# and the advisory lock is silently ineffective rather than failing closed. Admitting this level
|
|
102
|
+
# REOPENS THE RACE this whole issue exists to close.
|
|
103
|
+
#
|
|
104
|
+
# WHY IT NEEDED ITS OWN PERMUTATION. The guard is `<> 'read committed'`, so one permutation under
|
|
105
|
+
# REPEATABLE READ appears to cover its siblings -- but only the levels a permutation NAMES are
|
|
106
|
+
# pinned. Measured: narrowing the guard to `= 'repeatable read'` kept permutation 1 passing and the
|
|
107
|
+
# entire pgTAP suite green while serializable sailed through. That mutation now fails HERE by name,
|
|
108
|
+
# and structurally at CPR-25a.
|
|
109
|
+
session "sz"
|
|
110
|
+
step "sz_begin" { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000d1'; }
|
|
111
|
+
step "sz_acq" {
|
|
112
|
+
select resource_key
|
|
113
|
+
from agent_board.acquire_claim(null, 'file', 'cpr-iso-key', now() + interval '1 hour',
|
|
114
|
+
null, '40500000-0000-4000-8000-000000000050');
|
|
115
|
+
}
|
|
116
|
+
step "sz_rollback" { ROLLBACK; }
|
|
117
|
+
|
|
118
|
+
# ru: READ UNCOMMITTED. A DIFFERENT KIND OF CLAIM, and the difference is deliberate rather than an
|
|
119
|
+
# oversight -- do not collapse this permutation into the one above. PostgreSQL EXECUTES read
|
|
120
|
+
# uncommitted AS read committed (fresh snapshot per statement), so the lock would in fact work at
|
|
121
|
+
# this level: admitting it would NOT reopen the race. What the refusal defends is the guard's
|
|
122
|
+
# one-accepted-spelling CONTRACT, which the migration argues for on the grounds that a guard
|
|
123
|
+
# carrying an equivalence argument has to be re-argued by every reader.
|
|
124
|
+
#
|
|
125
|
+
# So this permutation pins a CONTRACT regression, not a correctness one. It is here because
|
|
126
|
+
# `current_setting('transaction_isolation')` really does report `read uncommitted` distinctly --
|
|
127
|
+
# the GUC preserves the requested spelling even though the engine does not distinguish the
|
|
128
|
+
# behaviour -- which is the only reason a `<> 'read committed'` guard can see it at all.
|
|
129
|
+
session "ru"
|
|
130
|
+
step "ru_begin" { BEGIN ISOLATION LEVEL READ UNCOMMITTED; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000d1'; }
|
|
131
|
+
step "ru_acq" {
|
|
132
|
+
select resource_key
|
|
133
|
+
from agent_board.acquire_claim(null, 'file', 'cpr-iso-key', now() + interval '1 hour',
|
|
134
|
+
null, '40500000-0000-4000-8000-000000000050');
|
|
135
|
+
}
|
|
136
|
+
step "ru_rollback" { ROLLBACK; }
|
|
137
|
+
|
|
138
|
+
session "chk"
|
|
139
|
+
step "chk" {
|
|
140
|
+
select coalesce(string_agg(resource_key, ',' order by resource_key), '<none>') as claims
|
|
141
|
+
from agent_board.claims
|
|
142
|
+
where agent_id = '40500000-0000-4000-8000-000000000004'
|
|
143
|
+
and status = 'active' and released_at is null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# 1. REPEATABLE READ is refused by name, and nothing is written.
|
|
147
|
+
permutation "rr_begin" "rr_acq" "rr_rollback" "chk"
|
|
148
|
+
|
|
149
|
+
# 2. THE ALLOW SIDE: the identical call under READ COMMITTED succeeds.
|
|
150
|
+
permutation "rc_begin" "rc_acq" "rc_commit" "chk"
|
|
151
|
+
|
|
152
|
+
# 3. SCOPE: a fleet-kind acquire under REPEATABLE READ never resolves board policy, so it is not
|
|
153
|
+
# refused. A guard that raised on entry to acquire_claim would fail here.
|
|
154
|
+
permutation "rr_begin" "rr_machine" "rr_rollback" "chk"
|
|
155
|
+
|
|
156
|
+
# 4. SERIALIZABLE is refused by name, and nothing is written. The race-reopening level.
|
|
157
|
+
permutation "sz_begin" "sz_acq" "sz_rollback" "chk"
|
|
158
|
+
|
|
159
|
+
# 5. READ UNCOMMITTED is refused by name too -- the contract half. PostgreSQL runs this level AS
|
|
160
|
+
# read committed, so this permutation pins the guard's single-accepted-spelling rule rather than a
|
|
161
|
+
# correctness property; see session "ru" for why the two are not the same claim.
|
|
162
|
+
permutation "ru_begin" "ru_acq" "ru_rollback" "chk"
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Consiliency/message-board#405 — a renew whose snapshot predates a committed
|
|
2
|
+
# advisory -> inherit revert must neither resolve the stale policy NOR hand back
|
|
3
|
+
# the horizon the revert just clamped.
|
|
4
|
+
#
|
|
5
|
+
# THIS IS #406's OWN RECORDED LIMITATION 3, closed. That header says: "a
|
|
6
|
+
# renew_claim whose snapshot predates the revert's commit resolves 'advisory'
|
|
7
|
+
# and, under READ COMMITTED, re-evaluates and applies its full horizon AFTER
|
|
8
|
+
# this function's row lock is released at commit ... Recorded as open rather
|
|
9
|
+
# than quietly excluded from the claim."
|
|
10
|
+
#
|
|
11
|
+
# TWO INDEPENDENT PROPERTIES, AND THE SECOND IS THE ONE THAT IS EASY TO GET
|
|
12
|
+
# WRONG.
|
|
13
|
+
#
|
|
14
|
+
# (1) THE POLICY RE-READ. Without the board lock, r resolves 'advisory', skips
|
|
15
|
+
# the #373 TTL cap and the #388 convergence clamp, and writes the full
|
|
16
|
+
# 400-day horizon after the revert has committed. `over_cap_lease` reads
|
|
17
|
+
# that out directly.
|
|
18
|
+
#
|
|
19
|
+
# (2) THE CLAIM-STATE REVALIDATION. Re-reading only the POLICY moves the race
|
|
20
|
+
# instead of closing it, because the writer this waits on is
|
|
21
|
+
# set_board_claim_policy and since #400 that same call CLAMPS this board's
|
|
22
|
+
# ownerless leases on the revert. The row r read BEFORE the wait is
|
|
23
|
+
# exactly the row w changed DURING it, and `v_current_expires` — the
|
|
24
|
+
# baseline the convergence clamp least()s against — would still hold the
|
|
25
|
+
# pre-clamp horizon. w records the clamped lease in `cpr_renew_mark` while
|
|
26
|
+
# still uncommitted, and the checker asserts the renew left it EXACTLY
|
|
27
|
+
# there.
|
|
28
|
+
#
|
|
29
|
+
# WHY THE SECOND SIGNAL IS AN EQUALITY AND NOT A MAGNITUDE. `now()` is
|
|
30
|
+
# transaction time. The reset writes w's `now() + 30 days`; the TTL cap computes
|
|
31
|
+
# r's `now() + 30 days`, and r's transaction starts later, so a stale baseline
|
|
32
|
+
# lets `least()` pick r's clock instead of w's and the lease creeps forward by
|
|
33
|
+
# the gap between the two BEGINs. That gap is real but small, so the checker
|
|
34
|
+
# scores `lease_expires_at = the recorded value` rather than a duration — a
|
|
35
|
+
# boolean is deterministic where a difference of milliseconds is not. The
|
|
36
|
+
# honest reading of that: this property's blast radius is one inter-transaction
|
|
37
|
+
# gap per revert, not a full horizon. Property (1) is the large one; (2) is
|
|
38
|
+
# pinned because "re-read the policy" and "revalidate the claim" are different
|
|
39
|
+
# fixes and only one of them is complete.
|
|
40
|
+
#
|
|
41
|
+
# THE AGENT IS DELIBERATELY OVER QUOTA (quota + 1 active ownerless claims), because
|
|
42
|
+
# the convergence clamp is the only consumer of `v_current_expires`. With the agent
|
|
43
|
+
# at or under the ceiling that clamp never runs and signal (2) cannot exist.
|
|
44
|
+
# The 50 filler claims carry NO thread, so #400's board-scoped reset does not touch
|
|
45
|
+
# them (BCP-14) and the only claim whose lease moves is the one under test.
|
|
46
|
+
#
|
|
47
|
+
# MUTATION SIGNALS, MEASURED — and the first one is not what it was predicted to be:
|
|
48
|
+
# * delete `perform agent_board._claimgate_lock_board_policy(v_board_id)` from
|
|
49
|
+
# renew_claim -> over_cap_lease = t, lease_kept_by_revert = f. THE `<waiting ...>`
|
|
50
|
+
# LINE DOES NOT MOVE. Without the board lock the renew still blocks — later, on the
|
|
51
|
+
# claims ROW lock #400's cap reset is holding — but it has already resolved the stale
|
|
52
|
+
# 'advisory' by then. A probe that scored the wait rather than the outcome would have
|
|
53
|
+
# passed this mutation clean, which is why the checker reports state and not timing.
|
|
54
|
+
# * delete the REVALIDATION select (keeping the lock and the policy re-read)
|
|
55
|
+
# -> over_cap_lease stays f, lease_kept_by_revert = f. Caught 10 times out of 10, so
|
|
56
|
+
# the inter-transaction gap this reads out is small but reliably nonzero.
|
|
57
|
+
#
|
|
58
|
+
# THE TWIN: the same renew with no revert at all. The board is advisory, so the
|
|
59
|
+
# opt-out must still work and the 400-day renew must be GRANTED. A guard that
|
|
60
|
+
# over-serialises, or one that resolved every board to 'inherit', passes the race
|
|
61
|
+
# permutation and fails here.
|
|
62
|
+
|
|
63
|
+
setup
|
|
64
|
+
{
|
|
65
|
+
insert into agent_board.claimgate_enforcement_flags(scope_key, enabled) values ('ownerless_guards', true)
|
|
66
|
+
on conflict (scope_key) do update set enabled = true;
|
|
67
|
+
create table if not exists public.cpr_renew_mark(lease timestamptz);
|
|
68
|
+
grant select, insert on public.cpr_renew_mark to authenticated;
|
|
69
|
+
delete from public.cpr_renew_mark;
|
|
70
|
+
insert into auth.users (id) values ('40500000-0000-4000-8000-0000000000b1');
|
|
71
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
|
|
72
|
+
values ('40500000-0000-4000-8000-000000000002', 'agent', 'agent',
|
|
73
|
+
'cpr-ren-owner', 'CPR Renew Owner', 'active',
|
|
74
|
+
'40500000-0000-4000-8000-0000000000b1', '40500000-0000-4000-8000-0000000000b1');
|
|
75
|
+
select count(*) from agent_board.create_board(
|
|
76
|
+
'40500000-0000-4000-8000-000000000002',
|
|
77
|
+
'cpr-ren-board', 'CPR Renew Board',
|
|
78
|
+
'message-board#405 renew stale-policy probe', jsonb_build_object(), 'private');
|
|
79
|
+
insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
|
|
80
|
+
values ('40500000-0000-4000-8000-000000000020', 'coordination',
|
|
81
|
+
'40500000-0000-4000-8000-000000000002', 'CPR renew thread',
|
|
82
|
+
(select id from agent_board.boards where slug = 'cpr-ren-board'));
|
|
83
|
+
update agent_board.boards set claim_policy = 'advisory' where slug = 'cpr-ren-board';
|
|
84
|
+
-- The claim under test: on the board, holding a horizon only the opt-out could have granted.
|
|
85
|
+
insert into agent_board.claims(id, agent_id, thread_id, resource_kind, resource_key, lease_token, lease_expires_at)
|
|
86
|
+
values ('40500000-0000-4000-8000-000000000030', '40500000-0000-4000-8000-000000000002',
|
|
87
|
+
'40500000-0000-4000-8000-000000000020', 'file', 'cpr-ren-key',
|
|
88
|
+
'40500000-0000-4000-8000-0000000000f1', now() + interval '400 days');
|
|
89
|
+
-- Filler that puts the holder OVER quota so the convergence clamp runs. No thread, so the
|
|
90
|
+
-- board-scoped reset leaves them alone and the only moving lease is the one under test.
|
|
91
|
+
insert into agent_board.claims(agent_id, resource_kind, resource_key, lease_expires_at)
|
|
92
|
+
select '40500000-0000-4000-8000-000000000002', 'file', 'cpr-ren-filler-' || g, now() + interval '1 hour'
|
|
93
|
+
from generate_series(1, agent_board._claimgate_ownerless_quota()) g;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
teardown
|
|
97
|
+
{
|
|
98
|
+
delete from agent_board.claims where agent_id = '40500000-0000-4000-8000-000000000002';
|
|
99
|
+
delete from agent_board.threads where id = '40500000-0000-4000-8000-000000000020';
|
|
100
|
+
delete from agent_board.board_membership_events where board_id in (
|
|
101
|
+
select id from agent_board.boards where slug = 'cpr-ren-board');
|
|
102
|
+
delete from agent_board.board_memberships where board_id in (
|
|
103
|
+
select id from agent_board.boards where slug = 'cpr-ren-board');
|
|
104
|
+
delete from agent_board.boards where slug = 'cpr-ren-board';
|
|
105
|
+
delete from agent_board.agents where id = '40500000-0000-4000-8000-000000000002';
|
|
106
|
+
delete from auth.users where id = '40500000-0000-4000-8000-0000000000b1';
|
|
107
|
+
delete from agent_board.claimgate_enforcement_flags where scope_key = 'ownerless_guards';
|
|
108
|
+
drop table if exists public.cpr_renew_mark;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# w: the owner reverting the opt-out. w_mark records what #400's reset actually wrote, from inside
|
|
112
|
+
# the still-uncommitted transaction — the value the renew must not move.
|
|
113
|
+
session "w"
|
|
114
|
+
step "w_begin" { BEGIN; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000b1'; }
|
|
115
|
+
step "w_flip" {
|
|
116
|
+
select claim_policy
|
|
117
|
+
from agent_board.set_board_claim_policy(
|
|
118
|
+
(select id from agent_board.boards where slug = 'cpr-ren-board'), 'inherit');
|
|
119
|
+
}
|
|
120
|
+
step "w_mark" {
|
|
121
|
+
insert into public.cpr_renew_mark(lease)
|
|
122
|
+
select lease_expires_at from agent_board.claims where id = '40500000-0000-4000-8000-000000000030';
|
|
123
|
+
}
|
|
124
|
+
step "w_commit" { COMMIT; }
|
|
125
|
+
|
|
126
|
+
# r: the in-flight renew, asking for the full horizon again.
|
|
127
|
+
session "r"
|
|
128
|
+
step "r_begin" { BEGIN; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000b1'; }
|
|
129
|
+
step "r_renew" {
|
|
130
|
+
select resource_key
|
|
131
|
+
from agent_board.renew_claim('40500000-0000-4000-8000-0000000000f1', now() + interval '400 days');
|
|
132
|
+
}
|
|
133
|
+
step "r_commit" { COMMIT; }
|
|
134
|
+
|
|
135
|
+
# checker: booleans only. `lease_kept_by_revert` is null in the twin (nothing was marked), which is
|
|
136
|
+
# itself part of the golden.
|
|
137
|
+
session "chk"
|
|
138
|
+
step "chk" {
|
|
139
|
+
select (c.lease_expires_at > now() + interval '31 days') as over_cap_lease,
|
|
140
|
+
(c.lease_expires_at = (select m.lease from public.cpr_renew_mark m)) as lease_kept_by_revert
|
|
141
|
+
from agent_board.claims c
|
|
142
|
+
where c.id = '40500000-0000-4000-8000-000000000030';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# THE RACE: the renew is issued while the revert is uncommitted.
|
|
146
|
+
permutation "w_begin" "w_flip" "w_mark" "r_begin" "r_renew" "w_commit" "r_commit" "chk"
|
|
147
|
+
|
|
148
|
+
# THE TWIN: no revert. The board is advisory, the opt-out still works, the 400-day renew is granted
|
|
149
|
+
# and nothing waits.
|
|
150
|
+
permutation "r_begin" "r_renew" "r_commit" "chk"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Consiliency/message-board#405 — the WRITER-SIDE lock-order question, MEASURED
|
|
2
|
+
# rather than argued (PR #397's standard).
|
|
3
|
+
#
|
|
4
|
+
# THE SHAPE OF THE CYCLE THAT WOULD EXIST. `set_board_claim_policy` now takes the
|
|
5
|
+
# board policy advisory lock and THEN wants a row lock on `agent_board.boards`.
|
|
6
|
+
# Any transaction that references that board row -- inserting a thread, a
|
|
7
|
+
# membership, an invite, an inbox item: twelve tables carry an FK to `boards` --
|
|
8
|
+
# holds `FOR KEY SHARE` on it, and if that same transaction then enters an
|
|
9
|
+
# ownerless claim path it wants the advisory lock. advisory -> row versus row ->
|
|
10
|
+
# advisory is a cycle, and #159 is open on lock ordering in this schema, so this
|
|
11
|
+
# is exactly the class that has already produced a revert here (ab38805, a 40P01
|
|
12
|
+
# on a comparable path).
|
|
13
|
+
#
|
|
14
|
+
# WHY IT IS NOT A CYCLE, AND WHY THAT IS AN OBSERVATION AND NOT A CLAIM. #400
|
|
15
|
+
# reads the outgoing policy `for no key update`, which does NOT conflict with
|
|
16
|
+
# `FOR KEY SHARE` -- so the writer never waits on the FK holder, and the wait
|
|
17
|
+
# graph has one edge, not two. This spec drives the arrangement and records that
|
|
18
|
+
# the writer COMPLETES: `t` holds KEY SHARE on the board row, `w` flips the
|
|
19
|
+
# policy anyway, `t`'s acquire then queues on `w`'s advisory lock and proceeds
|
|
20
|
+
# once `w` commits.
|
|
21
|
+
#
|
|
22
|
+
# THE PROBE IS DISCRIMINATING, which is the part that makes it evidence.
|
|
23
|
+
# MUTATION SIGNAL (verified in the PR): change `for no key update` to
|
|
24
|
+
# `for update` in set_board_claim_policy -> `w_flip` prints `<waiting ...>` on
|
|
25
|
+
# t's KEY SHARE, `t_acq` then queues on w's advisory lock, and PostgreSQL
|
|
26
|
+
# reports `ERROR: deadlock detected` (40P01). The same permutation, the same
|
|
27
|
+
# fixture, one lock mode apart. CPR-10 pins the source spelling so the two fail
|
|
28
|
+
# together.
|
|
29
|
+
#
|
|
30
|
+
# THE LEASE IS DELIBERATELY WITHIN THE CAP. This spec is about the wait graph,
|
|
31
|
+
# not about which policy won: the acquire must succeed under either answer, so
|
|
32
|
+
# any diff in the golden is a lock-order diff.
|
|
33
|
+
|
|
34
|
+
setup
|
|
35
|
+
{
|
|
36
|
+
insert into agent_board.claimgate_enforcement_flags(scope_key, enabled) values ('ownerless_guards', true)
|
|
37
|
+
on conflict (scope_key) do update set enabled = true;
|
|
38
|
+
insert into auth.users (id) values ('40500000-0000-4000-8000-0000000000e1');
|
|
39
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
|
|
40
|
+
values ('40500000-0000-4000-8000-000000000005', 'agent', 'agent',
|
|
41
|
+
'cpr-ks-owner', 'CPR KeyShare Owner', 'active',
|
|
42
|
+
'40500000-0000-4000-8000-0000000000e1', '40500000-0000-4000-8000-0000000000e1');
|
|
43
|
+
select count(*) from agent_board.create_board(
|
|
44
|
+
'40500000-0000-4000-8000-000000000005', 'cpr-ks-board', 'CPR KeyShare Board',
|
|
45
|
+
'message-board#405 writer-side KEY SHARE lock-order probe', jsonb_build_object(), 'private');
|
|
46
|
+
insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
|
|
47
|
+
values ('40500000-0000-4000-8000-000000000060', 'coordination',
|
|
48
|
+
'40500000-0000-4000-8000-000000000005', 'CPR keyshare thread',
|
|
49
|
+
(select id from agent_board.boards where slug = 'cpr-ks-board'));
|
|
50
|
+
update agent_board.boards set claim_policy = 'advisory' where slug = 'cpr-ks-board';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
teardown
|
|
54
|
+
{
|
|
55
|
+
delete from agent_board.claims where agent_id = '40500000-0000-4000-8000-000000000005';
|
|
56
|
+
delete from agent_board.threads where id = '40500000-0000-4000-8000-000000000060';
|
|
57
|
+
delete from agent_board.board_membership_events where board_id in (
|
|
58
|
+
select id from agent_board.boards where slug = 'cpr-ks-board');
|
|
59
|
+
delete from agent_board.board_memberships where board_id in (
|
|
60
|
+
select id from agent_board.boards where slug = 'cpr-ks-board');
|
|
61
|
+
delete from agent_board.boards where slug = 'cpr-ks-board';
|
|
62
|
+
delete from agent_board.agents where id = '40500000-0000-4000-8000-000000000005';
|
|
63
|
+
delete from auth.users where id = '40500000-0000-4000-8000-0000000000e1';
|
|
64
|
+
delete from agent_board.claimgate_enforcement_flags where scope_key = 'ownerless_guards';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# t: the FK holder. `for key share` is taken directly rather than by inserting a referencing row,
|
|
68
|
+
# because it is EXACTLY the lock mode an FK reference takes and it names the board without dragging
|
|
69
|
+
# a second RPC's own locking into the probe. Taken as the connecting role (not `authenticated`,
|
|
70
|
+
# which holds no UPDATE on boards and so cannot take a row lock at all); the acquire that follows
|
|
71
|
+
# drops into `authenticated`, which is the role the ownerless guards actually run for.
|
|
72
|
+
session "t"
|
|
73
|
+
step "t_begin" { BEGIN; }
|
|
74
|
+
step "t_keyshare" { select slug from agent_board.boards where slug = 'cpr-ks-board' for key share; }
|
|
75
|
+
step "t_acq" {
|
|
76
|
+
SET LOCAL ROLE authenticated;
|
|
77
|
+
SET LOCAL "request.jwt.claim.role" = 'authenticated';
|
|
78
|
+
SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000e1';
|
|
79
|
+
select resource_key
|
|
80
|
+
from agent_board.acquire_claim(null, 'file', 'cpr-ks-key', now() + interval '1 hour',
|
|
81
|
+
null, '40500000-0000-4000-8000-000000000060');
|
|
82
|
+
}
|
|
83
|
+
step "t_commit" { COMMIT; }
|
|
84
|
+
|
|
85
|
+
# w: the policy flip. Takes the advisory lock, then the `for no key update` row lock on the very
|
|
86
|
+
# row t is holding KEY SHARE on.
|
|
87
|
+
session "w"
|
|
88
|
+
step "w_begin" { BEGIN; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000e1'; }
|
|
89
|
+
step "w_flip" {
|
|
90
|
+
select claim_policy
|
|
91
|
+
from agent_board.set_board_claim_policy(
|
|
92
|
+
(select id from agent_board.boards where slug = 'cpr-ks-board'), 'inherit');
|
|
93
|
+
}
|
|
94
|
+
step "w_commit" { COMMIT; }
|
|
95
|
+
|
|
96
|
+
session "chk"
|
|
97
|
+
step "chk" {
|
|
98
|
+
select coalesce(string_agg(resource_key, ',' order by resource_key), '<none>') as claims,
|
|
99
|
+
(select claim_policy from agent_board.boards where slug = 'cpr-ks-board') as policy
|
|
100
|
+
from agent_board.claims
|
|
101
|
+
where agent_id = '40500000-0000-4000-8000-000000000005'
|
|
102
|
+
and status = 'active' and released_at is null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# KEY SHARE held, then the flip, then the acquire. With `for no key update` the writer does not
|
|
106
|
+
# queue behind the FK holder, so there is exactly one wait edge and no cycle.
|
|
107
|
+
permutation "t_begin" "t_keyshare" "w_begin" "w_flip" "t_acq" "w_commit" "t_commit" "chk"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
-- Consiliency/message-board#405 — the ADVERSARIAL PRE-STATE for 20261001000100.
|
|
2
|
+
--
|
|
3
|
+
-- WHAT THIS GATES, and why an assertion could not. CPR-22 checks the lock helper's final ACL in the
|
|
4
|
+
-- ordinary fixture — where there is no delegated grant — so it stays GREEN if the migration's
|
|
5
|
+
-- `CASCADE` were removed or its sweep narrowed back to a list of known roles. The migration's
|
|
6
|
+
-- claimed behaviour is about a state the fixture never contains, so the fixture has to contain it.
|
|
7
|
+
--
|
|
8
|
+
-- THE STATE: `service_role` holds EXECUTE **WITH GRANT OPTION** and has granted it onward to
|
|
9
|
+
-- `anon`. That is the adopter shape the migration exists to survive: a plain `REVOKE` defaults to
|
|
10
|
+
-- RESTRICT and fails `dependent privileges exist` against it, which on an adopter database is a
|
|
11
|
+
-- FAILED UPGRADE rather than a tidier ACL. Measured against this bundle: the plain form errors, the
|
|
12
|
+
-- shipped form exits 0 and leaves `{postgres=X/postgres}`.
|
|
13
|
+
--
|
|
14
|
+
-- HOW THE GATE READS. This runs BEFORE 20261001000100 (the runner's tests/seeds/<migration>.pre.sql
|
|
15
|
+
-- hook, #103). Afterwards, either
|
|
16
|
+
-- * the migration swept the chain and CPR-22 reports an empty ACL — the claim holds; or
|
|
17
|
+
-- * the migration RAISED, and the whole bundle replay fails loudly at this migration.
|
|
18
|
+
-- There is no third outcome in which a narrowed sweep passes quietly.
|
|
19
|
+
--
|
|
20
|
+
-- ONLY PRE-EXISTING ROLES ARE USED — no CREATE ROLE. Roles are cluster-global, so a seed that
|
|
21
|
+
-- created them would leak them into a shared cluster and could collide with a concurrent run;
|
|
22
|
+
-- service_role and anon are part of the adopter base this bundle already assumes.
|
|
23
|
+
--
|
|
24
|
+
-- WHAT THIS FIXTURE GATES, precisely — and the second clause is NARROWER than an earlier version of
|
|
25
|
+
-- this paragraph claimed. It gates that the sweep CASCADEs, and that it reaches a grantee OUTSIDE
|
|
26
|
+
-- THE HISTORICAL HAND LIST (public/anon/authenticated/service_role). It does NOT gate that the
|
|
27
|
+
-- sweep is catalog-DERIVED: a migration hard-coded to those four PLUS `authenticator` clears this
|
|
28
|
+
-- fixture and its guard below. A fixture names specific roles by construction, so only a randomly
|
|
29
|
+
-- generated grantee defeats any hand list.
|
|
30
|
+
--
|
|
31
|
+
-- A DISPOSABLE RANDOM GRANTEE WAS CONSIDERED AND REJECTED, checked rather than reasoned about. The
|
|
32
|
+
-- runner has a `.pre.sql` hook and no post hook, so a role created here is never dropped; a seed
|
|
33
|
+
-- that swept `mb_seed405_%` at its start to compensate would be dropping roles belonging to any
|
|
34
|
+
-- CONCURRENT run on the same cluster, and this cluster is shared. Trading a stated gap for
|
|
35
|
+
-- cross-run interference is the worse deal. Derivation itself stays mutation-verified: narrowing
|
|
36
|
+
-- the sweep to known roles was injected and CPR-22 named the survivor.
|
|
37
|
+
--
|
|
38
|
+
-- WHAT IT DOES NOT GATE: that the sweep derives its SIGNATURES. That needs a pre-existing overload,
|
|
39
|
+
-- and a seeded overload PERSISTS — the runner has a `.pre.sql` hook and no post hook, so nothing
|
|
40
|
+
-- drops it, and tests/catalog/p_metadata_parameters.json enumerates every agent_board signature and
|
|
41
|
+
-- would go red. Checked rather than assumed. So a migration narrowed back to the `(uuid)` signature
|
|
42
|
+
-- stays green here; that half is mutation-verified only, and this paragraph is the record of it
|
|
43
|
+
-- rather than a silence someone has to rediscover.
|
|
44
|
+
|
|
45
|
+
do $$
|
|
46
|
+
begin
|
|
47
|
+
-- Idempotent: a re-replay onto a database that already carries the chain must not fail here, or
|
|
48
|
+
-- the seed becomes a worse failure mode than the one it tests for.
|
|
49
|
+
if not exists (select 1 from pg_roles where rolname = 'service_role')
|
|
50
|
+
or not exists (select 1 from pg_roles where rolname = 'anon') then
|
|
51
|
+
raise exception
|
|
52
|
+
'claimpolicy lock-grant seed: service_role/anon absent — the adopter base this bundle assumes is not present'
|
|
53
|
+
using errcode = '42704';
|
|
54
|
+
end if;
|
|
55
|
+
|
|
56
|
+
-- A GRANTEE OUTSIDE THE FOUR NAMES THE OLD HAND LIST USED. Without this the fixture gates only
|
|
57
|
+
-- "CASCADE is present": a migration hard-coded to revoke public/anon/authenticated/service_role
|
|
58
|
+
-- with CASCADE would sweep the chain below, pass, and still abandon every arbitrary adopter
|
|
59
|
+
-- grantee — which is the entire reason the sweep was made catalog-derived. `authenticator` is
|
|
60
|
+
-- outside that list and is part of the Supabase adopter base, so nothing has to be created:
|
|
61
|
+
-- a role invented here would linger cluster-wide, since a seed has no post hook to drop it.
|
|
62
|
+
execute 'grant execute on function agent_board._claimgate_lock_board_policy(uuid) to authenticator';
|
|
63
|
+
|
|
64
|
+
execute 'grant execute on function agent_board._claimgate_lock_board_policy(uuid) to service_role with grant option';
|
|
65
|
+
end $$;
|
|
66
|
+
|
|
67
|
+
-- The onward grant must be made BY service_role, so the dependency really exists.
|
|
68
|
+
--
|
|
69
|
+
-- `SET ROLE`, NOT `SET LOCAL ROLE`, and that distinction is the whole gate. The runner applies this
|
|
70
|
+
-- file with `psql -f` in AUTOCOMMIT, so every statement is its own implicit transaction and a
|
|
71
|
+
-- `SET LOCAL` is discarded before the next one runs. The first version used SET LOCAL: the grant was
|
|
72
|
+
-- then made by `postgres`, recorded as `anon=X/postgres` with no dependency on service_role's grant,
|
|
73
|
+
-- and a plain RESTRICT revoke would have swept it happily — a gate that passed whether or not the
|
|
74
|
+
-- migration cascaded. The check below caught exactly that, which is why it is here.
|
|
75
|
+
set role service_role;
|
|
76
|
+
grant execute on function agent_board._claimgate_lock_board_policy(uuid) to anon;
|
|
77
|
+
reset role;
|
|
78
|
+
|
|
79
|
+
-- Proof the pre-state is what this file claims, checked rather than assumed: if the chain did not
|
|
80
|
+
-- form, the migration would sweep a state no harder than the ordinary one and the gate would be
|
|
81
|
+
-- vacuous — green for the wrong reason, which is the failure mode this whole effort keeps finding.
|
|
82
|
+
do $$
|
|
83
|
+
declare v_acl text;
|
|
84
|
+
begin
|
|
85
|
+
select coalesce(proacl::text, '<null>') into v_acl
|
|
86
|
+
from pg_proc where oid = to_regprocedure('agent_board._claimgate_lock_board_policy(uuid)');
|
|
87
|
+
if v_acl not like '%anon=X/service_role%' then
|
|
88
|
+
raise exception
|
|
89
|
+
'claimpolicy lock-grant seed: the delegated grant did not form (acl=%) — refusing to leave a VACUOUS gate that would pass whether or not the migration cascades',
|
|
90
|
+
v_acl
|
|
91
|
+
using errcode = '55000';
|
|
92
|
+
end if;
|
|
93
|
+
if v_acl not like '%authenticator=%' then
|
|
94
|
+
raise exception
|
|
95
|
+
'claimpolicy lock-grant seed: the out-of-list grantee did not take (acl=%) — without it this fixture gates only CASCADE, and a migration narrowed to the four historical role names would pass',
|
|
96
|
+
v_acl
|
|
97
|
+
using errcode = '55000';
|
|
98
|
+
end if;
|
|
99
|
+
end $$;
|