@consiliency/agent-board-schema 1.48.0 → 1.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +16 -0
- package/migrations/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/migrations/20261002000100_agent_board_session_challenge_supersede.sql +383 -0
- package/package.json +1 -1
- package/schema-manifest.json +39 -3
- package/src/tooling.test.mjs +33 -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_fk_lock_edges.test.sql +20 -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/agent_board_signing_challenge_pending_cap.test.sql +391 -36
- package/tests/catalog/p_metadata_parameters.json +16 -0
- package/tests/fixtures/test-prereqs.sql +186 -0
- package/tests/isolation/expected/challenge_supersede_lock_order.out +213 -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/challenge_supersede_lock_order.spec +183 -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,200 @@
|
|
|
1
|
+
# Consiliency/message-board#405 — the CLAIM-ROW side of the lock order, measured.
|
|
2
|
+
# #405's acceptance names two adjacent paths by name: the thread-insert
|
|
3
|
+
# `FOR KEY SHARE` on `boards` (measured in claimpolicy_writer_keyshare.spec) and
|
|
4
|
+
# `reclaim_fleet_machine`. This is the second one, plus the general question it
|
|
5
|
+
# is an instance of.
|
|
6
|
+
#
|
|
7
|
+
# THE QUESTION. Every path this issue touches now takes the board policy advisory
|
|
8
|
+
# lock and THEN a `claims` row lock: acquire's sweep, renew's UPDATE, and #400's
|
|
9
|
+
# cap reset are all after it. A cycle needs someone going the other way — holding
|
|
10
|
+
# a claims row lock and then wanting the board lock. `reclaim_fleet_machine` is
|
|
11
|
+
# the obvious candidate, because #150 MUST-FIX 2 made it RELEASE the squatter's
|
|
12
|
+
# active machine claim, so it genuinely holds claims row locks while also holding
|
|
13
|
+
# `fleet_machines ... for update`.
|
|
14
|
+
#
|
|
15
|
+
# PERMUTATION 1 — reclaim vs the policy writer. Measured result: NO contention in
|
|
16
|
+
# either direction. Reclaim's rows are `resource_kind = 'machine'` and #400's cap
|
|
17
|
+
# reset touches only the ownerless kinds, so the two hold disjoint rows; and
|
|
18
|
+
# reclaim never enters a policy path, so it never wants the advisory lock. The
|
|
19
|
+
# trace has no `<waiting ...>` at all, which is the observation this permutation
|
|
20
|
+
# exists to record.
|
|
21
|
+
#
|
|
22
|
+
# PERMUTATION 2 — THE PROBE THAT MAKES PERMUTATION 1 EVIDENCE. A permutation that
|
|
23
|
+
# never waits proves nothing on its own: it looks identical to a probe pointed at
|
|
24
|
+
# the wrong subject. So this one CONSTRUCTS the inversion, in the shape #405 asks
|
|
25
|
+
# for (PR #397 injected the reverted lock and observed 40P01). A session takes a
|
|
26
|
+
# plain `select ... for update` on the claim row and THEN calls `renew_claim`,
|
|
27
|
+
# which wants the board lock; the writer holds the board lock and wants that same
|
|
28
|
+
# row through the cap reset. PostgreSQL reports `deadlock detected` — so the cycle
|
|
29
|
+
# is REAL, and permutation 1's silence is a fact about which locks these paths
|
|
30
|
+
# actually take rather than a fact about the probe.
|
|
31
|
+
#
|
|
32
|
+
# AND WHAT IS ACTUALLY TRUE ABOUT REACHING IT, corrected. An earlier version of
|
|
33
|
+
# this paragraph said "no session can assemble permutation 2". That is FALSE, and
|
|
34
|
+
# it was false in two independent ways; both were measured against this bundle
|
|
35
|
+
# rather than argued, and both produced a real `deadlock detected`.
|
|
36
|
+
#
|
|
37
|
+
# It rested on two premises. The first was "no function in agent_board takes a
|
|
38
|
+
# claims row lock (CPR-14)" — but CPR-14 only ever asked about an EXPLICIT
|
|
39
|
+
# locking clause. `update agent_board.claims` takes row locks too, and FIVE
|
|
40
|
+
# functions do it. The ordered premise that does hold is CPR-14a's: of those
|
|
41
|
+
# five, the three that also want the board lock take the BOARD LOCK FIRST, and
|
|
42
|
+
# the other two (reclaim_fleet_machine, release_claim) never want it at all. So
|
|
43
|
+
# no single function inverts. The second premise was privilege: `select … for
|
|
44
|
+
# update` on claims really is refused 42501 to `authenticated` and to
|
|
45
|
+
# `service_role` alike, and `anon` cannot even SELECT (CPR-15/16 run the
|
|
46
|
+
# statement rather than asserting a proxy for it). But that closes only the
|
|
47
|
+
# DIRECT SPELLING.
|
|
48
|
+
#
|
|
49
|
+
# WHAT A SESSION CAN STILL DO IS COMPOSE. A definer function's `update` takes row
|
|
50
|
+
# locks scoped to the CALLING transaction, and they outlive the call. So a
|
|
51
|
+
# multi-statement transaction on a DIRECT database connection assembles the cycle
|
|
52
|
+
# out of granted RPCs, with no table privilege and no hand-written `for update`:
|
|
53
|
+
#
|
|
54
|
+
# as authenticated: release_claim(<token>) then renew_claim(<other>, …)
|
|
55
|
+
# against a concurrent set_board_claim_policy -> 40P01
|
|
56
|
+
# as service_role: renew_claim(<token>, …) then a DIRECT call to
|
|
57
|
+
# _claimgate_lock_board_policy -> 40P01
|
|
58
|
+
#
|
|
59
|
+
# The service_role path is CLOSED as of 20261001000100, which revokes that direct
|
|
60
|
+
# grant (CPR-22 pins the now-empty ACL, CPR-22a pins that the definer callers
|
|
61
|
+
# still work). The authenticated composition remains assemblable.
|
|
62
|
+
#
|
|
63
|
+
# AND IT IS A RESIDUAL ONLY UNDER A STATED ACCESS BOUNDARY. The boundary this
|
|
64
|
+
# repository actually ships, checked rather than assumed: every shipped client
|
|
65
|
+
# reaches the schema through PostgREST via @supabase/supabase-js — there is no
|
|
66
|
+
# direct Postgres driver (`pg`, Pool, a postgresql:// connection) anywhere in
|
|
67
|
+
# packages/*/src or supabase/functions. PostgREST runs one RPC per transaction,
|
|
68
|
+
# and the control below measures that shape as safe. A deployment that adds
|
|
69
|
+
# DIRECT authenticated-role sessions re-opens this; under that deployment it is
|
|
70
|
+
# unclosed rather than residual, and what it yields is 40P01 — a detected
|
|
71
|
+
# deadlock that aborts one side — not an authority bypass or a stale-policy read.
|
|
72
|
+
#
|
|
73
|
+
# SO THE ACCURATE CLAIM IS NARROWER, and it is a claim about the TRANSACTION
|
|
74
|
+
# rather than about privilege. No single RPC can assemble the inversion, and one
|
|
75
|
+
# RPC per transaction — PostgREST's model, which is how every shipped client
|
|
76
|
+
# reaches this schema — is safe. Measured as a control: the identical call
|
|
77
|
+
# sequence with a COMMIT between each RPC produces no deadlock and both calls
|
|
78
|
+
# succeed. Only a caller holding a direct connection across a multi-statement
|
|
79
|
+
# transaction can assemble it, and what it gets is 40P01 — a DETECTED DEADLOCK
|
|
80
|
+
# that aborts one side, not an authority bypass or a stale-policy read. The two
|
|
81
|
+
# shipped paths still agree on board-then-row.
|
|
82
|
+
#
|
|
83
|
+
# The deadlock victim is `w_flip` on every run: it enters the wait first, so its
|
|
84
|
+
# deadlock_timeout fires first and PostgreSQL aborts the detecting backend. That
|
|
85
|
+
# is what makes this golden stable; the ITER=10 gate is what checks the claim.
|
|
86
|
+
|
|
87
|
+
setup
|
|
88
|
+
{
|
|
89
|
+
insert into agent_board.claimgate_enforcement_flags(scope_key, enabled) values ('ownerless_guards', true)
|
|
90
|
+
on conflict (scope_key) do update set enabled = true;
|
|
91
|
+
insert into auth.users (id) values ('40500000-0000-4000-8000-0000000000f9');
|
|
92
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
|
|
93
|
+
values ('40500000-0000-4000-8000-000000000009', 'agent', 'agent',
|
|
94
|
+
'cpr-inv-owner', 'CPR Inversion Owner', 'active',
|
|
95
|
+
'40500000-0000-4000-8000-0000000000f9', '40500000-0000-4000-8000-0000000000f9');
|
|
96
|
+
-- The operator and the arbitrated new owner for the reclaim leg.
|
|
97
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id) values
|
|
98
|
+
('40500000-0000-4000-8000-00000000000a', 'agent', 'cpr-inv-operator', 'CPR Inversion Operator', 'active', null),
|
|
99
|
+
('40500000-0000-4000-8000-00000000000b', 'agent', 'cpr-inv-newowner', 'CPR Inversion New Owner', 'active', null);
|
|
100
|
+
insert into agent_board.fleet_trust_domains(id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
|
|
101
|
+
values ('40500000-0000-4000-8000-0000000000d0', 'cpr-inv-domain', 'CPR Inversion Domain', 'tailnet',
|
|
102
|
+
'40500000-0000-4000-8000-000000000009', 'active', now());
|
|
103
|
+
insert into agent_board.fleet_machines(id, trust_domain_id, owner_agent_id, machine_label, machine_fingerprint,
|
|
104
|
+
lifecycle_state, enrollment_source, enrolled_at, revoked_at, metadata)
|
|
105
|
+
values ('40500000-0000-4000-8000-0000000000c0', '40500000-0000-4000-8000-0000000000d0',
|
|
106
|
+
'40500000-0000-4000-8000-000000000009', 'cpr-inv-m', 'sha256:cpr-inv-m',
|
|
107
|
+
'active', 'portal', now(), null, jsonb_build_object('redacted', true, 'metadata-safe', true));
|
|
108
|
+
|
|
109
|
+
select count(*) from agent_board.create_board(
|
|
110
|
+
'40500000-0000-4000-8000-000000000009', 'cpr-inv-board', 'CPR Inversion Board',
|
|
111
|
+
'message-board#405 claim-row lock-order probe', jsonb_build_object(), 'private');
|
|
112
|
+
insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
|
|
113
|
+
values ('40500000-0000-4000-8000-000000000090', 'coordination',
|
|
114
|
+
'40500000-0000-4000-8000-000000000009', 'CPR inversion thread',
|
|
115
|
+
(select id from agent_board.boards where slug = 'cpr-inv-board'));
|
|
116
|
+
update agent_board.boards set claim_policy = 'advisory' where slug = 'cpr-inv-board';
|
|
117
|
+
-- The OWNERLESS claim #400's reset will clamp on the revert.
|
|
118
|
+
insert into agent_board.claims(id, agent_id, thread_id, resource_kind, resource_key, lease_token, lease_expires_at)
|
|
119
|
+
values ('40500000-0000-4000-8000-000000000091', '40500000-0000-4000-8000-000000000009',
|
|
120
|
+
'40500000-0000-4000-8000-000000000090', 'file', 'cpr-inv-key',
|
|
121
|
+
'40500000-0000-4000-8000-0000000000f8', now() + interval '400 days');
|
|
122
|
+
-- The MACHINE claim reclaim_fleet_machine releases (#150 MUST-FIX 2). Different kind, so the
|
|
123
|
+
-- reset never sees it -- which is exactly the disjointness permutation 1 measures.
|
|
124
|
+
insert into agent_board.claims(id, agent_id, resource_kind, resource_key, lease_token, lease_expires_at)
|
|
125
|
+
values ('40500000-0000-4000-8000-000000000092', '40500000-0000-4000-8000-000000000009',
|
|
126
|
+
'machine', 'sha256:cpr-inv-m', '40500000-0000-4000-8000-0000000000f7', now() + interval '1 hour');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
teardown
|
|
130
|
+
{
|
|
131
|
+
delete from agent_board.claims where agent_id in ('40500000-0000-4000-8000-000000000009',
|
|
132
|
+
'40500000-0000-4000-8000-00000000000b');
|
|
133
|
+
delete from agent_board.threads where id = '40500000-0000-4000-8000-000000000090';
|
|
134
|
+
delete from agent_board.board_membership_events where board_id in (
|
|
135
|
+
select id from agent_board.boards where slug = 'cpr-inv-board');
|
|
136
|
+
delete from agent_board.board_memberships where board_id in (
|
|
137
|
+
select id from agent_board.boards where slug = 'cpr-inv-board');
|
|
138
|
+
delete from agent_board.boards where slug = 'cpr-inv-board';
|
|
139
|
+
delete from agent_board.fleet_revocation_events where subject_id = '40500000-0000-4000-8000-0000000000c0';
|
|
140
|
+
delete from agent_board.fleet_machine_agents where machine_id = '40500000-0000-4000-8000-0000000000c0';
|
|
141
|
+
delete from agent_board.fleet_machines where id = '40500000-0000-4000-8000-0000000000c0';
|
|
142
|
+
delete from agent_board.fleet_trust_domains where id = '40500000-0000-4000-8000-0000000000d0';
|
|
143
|
+
delete from agent_board.agents where id in ('40500000-0000-4000-8000-000000000009',
|
|
144
|
+
'40500000-0000-4000-8000-00000000000a',
|
|
145
|
+
'40500000-0000-4000-8000-00000000000b');
|
|
146
|
+
delete from auth.users where id = '40500000-0000-4000-8000-0000000000f9';
|
|
147
|
+
delete from agent_board.claimgate_enforcement_flags where scope_key = 'ownerless_guards';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# rc: the operator reclaim. Holds fleet_machines FOR UPDATE and the machine claim's row lock.
|
|
151
|
+
session "rc"
|
|
152
|
+
step "rc_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
153
|
+
step "rc_reclaim" {
|
|
154
|
+
select owner_agent_id
|
|
155
|
+
from agent_board.reclaim_fleet_machine(
|
|
156
|
+
'40500000-0000-4000-8000-0000000000c0', '40500000-0000-4000-8000-00000000000b',
|
|
157
|
+
'40500000-0000-4000-8000-00000000000a', 'message-board#405 lock-order probe');
|
|
158
|
+
}
|
|
159
|
+
step "rc_commit" { COMMIT; }
|
|
160
|
+
|
|
161
|
+
# x: the constructed inversion — a claims row lock taken BEFORE entering renew_claim. Only the
|
|
162
|
+
# connecting role can do this; CPR-14/15/16 pin that no function and no grantable role can.
|
|
163
|
+
session "x"
|
|
164
|
+
step "x_begin" { BEGIN; }
|
|
165
|
+
step "x_rowlock" { select resource_key from agent_board.claims where id = '40500000-0000-4000-8000-000000000091' for update; }
|
|
166
|
+
step "x_renew" {
|
|
167
|
+
SET LOCAL ROLE authenticated;
|
|
168
|
+
SET LOCAL "request.jwt.claim.role" = 'authenticated';
|
|
169
|
+
SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000f9';
|
|
170
|
+
select resource_key
|
|
171
|
+
from agent_board.renew_claim('40500000-0000-4000-8000-0000000000f8', now() + interval '400 days');
|
|
172
|
+
}
|
|
173
|
+
step "x_commit" { COMMIT; }
|
|
174
|
+
|
|
175
|
+
# w: the policy writer. Board advisory lock, then boards FOR NO KEY UPDATE, then the cap reset's
|
|
176
|
+
# UPDATE on this board's ownerless claims.
|
|
177
|
+
session "w"
|
|
178
|
+
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-0000000000f9'; }
|
|
179
|
+
step "w_flip" {
|
|
180
|
+
select claim_policy
|
|
181
|
+
from agent_board.set_board_claim_policy(
|
|
182
|
+
(select id from agent_board.boards where slug = 'cpr-inv-board'), 'inherit');
|
|
183
|
+
}
|
|
184
|
+
step "w_commit" { COMMIT; }
|
|
185
|
+
|
|
186
|
+
session "chk"
|
|
187
|
+
step "chk" {
|
|
188
|
+
select (select claim_policy from agent_board.boards where slug = 'cpr-inv-board') as policy,
|
|
189
|
+
(select owner_agent_id from agent_board.fleet_machines
|
|
190
|
+
where id = '40500000-0000-4000-8000-0000000000c0') as machine_owner;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# 1. reclaim_fleet_machine holding its claims row locks, and the policy writer running anyway.
|
|
194
|
+
# Disjoint kinds, and reclaim never wants the board lock: no wait in either direction.
|
|
195
|
+
permutation "rc_begin" "rc_reclaim" "w_begin" "w_flip" "rc_commit" "w_commit" "chk"
|
|
196
|
+
|
|
197
|
+
# 2. The inversion, constructed: claims row lock -> board lock versus board lock -> claims row lock.
|
|
198
|
+
# `deadlock detected` is the point. It is unreachable through any grantable role, which is what
|
|
199
|
+
# makes permutation 1's silence a property of the code rather than of the probe.
|
|
200
|
+
permutation "x_begin" "x_rowlock" "w_begin" "w_flip" "x_renew" "w_commit" "x_commit" "chk"
|
|
@@ -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"
|