@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,66 @@
|
|
|
1
|
+
Parsed test spec with 3 sessions
|
|
2
|
+
|
|
3
|
+
starting permutation: w_begin w_flip w_mark r_begin r_renew w_commit r_commit chk
|
|
4
|
+
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';
|
|
5
|
+
step w_flip:
|
|
6
|
+
select claim_policy
|
|
7
|
+
from agent_board.set_board_claim_policy(
|
|
8
|
+
(select id from agent_board.boards where slug = 'cpr-ren-board'), 'inherit');
|
|
9
|
+
|
|
10
|
+
claim_policy
|
|
11
|
+
------------
|
|
12
|
+
inherit
|
|
13
|
+
(1 row)
|
|
14
|
+
|
|
15
|
+
step w_mark:
|
|
16
|
+
insert into public.cpr_renew_mark(lease)
|
|
17
|
+
select lease_expires_at from agent_board.claims where id = '40500000-0000-4000-8000-000000000030';
|
|
18
|
+
|
|
19
|
+
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';
|
|
20
|
+
step r_renew:
|
|
21
|
+
select resource_key
|
|
22
|
+
from agent_board.renew_claim('40500000-0000-4000-8000-0000000000f1', now() + interval '400 days');
|
|
23
|
+
<waiting ...>
|
|
24
|
+
step w_commit: COMMIT;
|
|
25
|
+
step r_renew: <... completed>
|
|
26
|
+
resource_key
|
|
27
|
+
------------
|
|
28
|
+
cpr-ren-key
|
|
29
|
+
(1 row)
|
|
30
|
+
|
|
31
|
+
step r_commit: COMMIT;
|
|
32
|
+
step chk:
|
|
33
|
+
select (c.lease_expires_at > now() + interval '31 days') as over_cap_lease,
|
|
34
|
+
(c.lease_expires_at = (select m.lease from public.cpr_renew_mark m)) as lease_kept_by_revert
|
|
35
|
+
from agent_board.claims c
|
|
36
|
+
where c.id = '40500000-0000-4000-8000-000000000030';
|
|
37
|
+
|
|
38
|
+
over_cap_lease|lease_kept_by_revert
|
|
39
|
+
--------------+--------------------
|
|
40
|
+
f |t
|
|
41
|
+
(1 row)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
starting permutation: r_begin r_renew r_commit chk
|
|
45
|
+
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';
|
|
46
|
+
step r_renew:
|
|
47
|
+
select resource_key
|
|
48
|
+
from agent_board.renew_claim('40500000-0000-4000-8000-0000000000f1', now() + interval '400 days');
|
|
49
|
+
|
|
50
|
+
resource_key
|
|
51
|
+
------------
|
|
52
|
+
cpr-ren-key
|
|
53
|
+
(1 row)
|
|
54
|
+
|
|
55
|
+
step r_commit: COMMIT;
|
|
56
|
+
step chk:
|
|
57
|
+
select (c.lease_expires_at > now() + interval '31 days') as over_cap_lease,
|
|
58
|
+
(c.lease_expires_at = (select m.lease from public.cpr_renew_mark m)) as lease_kept_by_revert
|
|
59
|
+
from agent_board.claims c
|
|
60
|
+
where c.id = '40500000-0000-4000-8000-000000000030';
|
|
61
|
+
|
|
62
|
+
over_cap_lease|lease_kept_by_revert
|
|
63
|
+
--------------+--------------------
|
|
64
|
+
t |
|
|
65
|
+
(1 row)
|
|
66
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Parsed test spec with 3 sessions
|
|
2
|
+
|
|
3
|
+
starting permutation: t_begin t_keyshare w_begin w_flip t_acq w_commit t_commit chk
|
|
4
|
+
step t_begin: BEGIN;
|
|
5
|
+
step t_keyshare: select slug from agent_board.boards where slug = 'cpr-ks-board' for key share;
|
|
6
|
+
slug
|
|
7
|
+
------------
|
|
8
|
+
cpr-ks-board
|
|
9
|
+
(1 row)
|
|
10
|
+
|
|
11
|
+
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';
|
|
12
|
+
step w_flip:
|
|
13
|
+
select claim_policy
|
|
14
|
+
from agent_board.set_board_claim_policy(
|
|
15
|
+
(select id from agent_board.boards where slug = 'cpr-ks-board'), 'inherit');
|
|
16
|
+
|
|
17
|
+
claim_policy
|
|
18
|
+
------------
|
|
19
|
+
inherit
|
|
20
|
+
(1 row)
|
|
21
|
+
|
|
22
|
+
step t_acq:
|
|
23
|
+
SET LOCAL ROLE authenticated;
|
|
24
|
+
SET LOCAL "request.jwt.claim.role" = 'authenticated';
|
|
25
|
+
SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000e1';
|
|
26
|
+
select resource_key
|
|
27
|
+
from agent_board.acquire_claim(null, 'file', 'cpr-ks-key', now() + interval '1 hour',
|
|
28
|
+
null, '40500000-0000-4000-8000-000000000060');
|
|
29
|
+
<waiting ...>
|
|
30
|
+
step w_commit: COMMIT;
|
|
31
|
+
step t_acq: <... completed>
|
|
32
|
+
resource_key
|
|
33
|
+
------------
|
|
34
|
+
cpr-ks-key
|
|
35
|
+
(1 row)
|
|
36
|
+
|
|
37
|
+
step t_commit: COMMIT;
|
|
38
|
+
step chk:
|
|
39
|
+
select coalesce(string_agg(resource_key, ',' order by resource_key), '<none>') as claims,
|
|
40
|
+
(select claim_policy from agent_board.boards where slug = 'cpr-ks-board') as policy
|
|
41
|
+
from agent_board.claims
|
|
42
|
+
where agent_id = '40500000-0000-4000-8000-000000000005'
|
|
43
|
+
and status = 'active' and released_at is null;
|
|
44
|
+
|
|
45
|
+
claims |policy
|
|
46
|
+
----------+-------
|
|
47
|
+
cpr-ks-key|inherit
|
|
48
|
+
(1 row)
|
|
49
|
+
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Consiliency/message-board#405 — an acquire whose snapshot predates a committed
|
|
2
|
+
# advisory -> inherit revert must NOT bank a full-horizon ownerless lease.
|
|
3
|
+
#
|
|
4
|
+
# WHY ACQUIRE IS THE WORSE HALF OF THIS FAMILY. #406's cap reset clamps the
|
|
5
|
+
# board's EXISTING ownerless claims on a revert. On this path there is no claim
|
|
6
|
+
# yet: the acquire inserts AFTER the revert committed, so there is nothing for
|
|
7
|
+
# the reset to reach even in principle. Whatever horizon a stale 'advisory' read
|
|
8
|
+
# lets through is banked outright.
|
|
9
|
+
#
|
|
10
|
+
# AND IT IS THE PATH THE FIRST DESIGN MISSED. That design said the board lock
|
|
11
|
+
# goes "before the agent-scoped lock". acquire_claim's agent-scoped quota lock
|
|
12
|
+
# lives INSIDE the branch that a stale 'advisory' read SKIPS ENTIRELY, so on the
|
|
13
|
+
# dangerous path it is never reached and ordering against it means nothing. This
|
|
14
|
+
# spec drives exactly that branch: the acquirer reads 'advisory' unless it is
|
|
15
|
+
# made to wait, and if it reads 'advisory' it takes no lock at all.
|
|
16
|
+
#
|
|
17
|
+
# THE INTERLEAVE. w (the board owner) opens a transaction and reverts the board
|
|
18
|
+
# to 'inherit', holding the board-scoped advisory lock until commit. a then asks
|
|
19
|
+
# for a 400-day 'file' lease. Under READ COMMITTED a's snapshot at that moment
|
|
20
|
+
# still shows 'advisory'.
|
|
21
|
+
#
|
|
22
|
+
# WHAT THE FIX PRODUCES (this golden): a's acquire prints `<waiting ...>` on w's
|
|
23
|
+
# lock; after w commits, a RE-READS the policy in a fresh statement, sees
|
|
24
|
+
# 'inherit', and the ownerless TTL cap rejects the 400-day horizon with
|
|
25
|
+
# `ERROR: ownerless claim lease horizon exceeds cap`. The checker sees ZERO
|
|
26
|
+
# claims.
|
|
27
|
+
#
|
|
28
|
+
# MUTATION SIGNAL (verified in the PR): delete the `perform
|
|
29
|
+
# agent_board._claimgate_lock_board_policy(v_board_id)` line from acquire_claim
|
|
30
|
+
# -> a never waits, resolves the stale 'advisory', skips the whole ownerless
|
|
31
|
+
# block and INSERTS. The golden diverges on the missing `<waiting ...>`, on the
|
|
32
|
+
# missing error, and on the checker (1 claim, over-cap lease = t).
|
|
33
|
+
#
|
|
34
|
+
# THE TWIN IS NOT OPTIONAL. A guard that simply over-serialises — or one that
|
|
35
|
+
# resolved every board to 'inherit' — would pass the race permutation and break
|
|
36
|
+
# the feature. The second permutation runs the SAME acquire with no writer at
|
|
37
|
+
# all: the board is still 'advisory', so the 400-day lease must be GRANTED, with
|
|
38
|
+
# no wait anywhere in the trace.
|
|
39
|
+
#
|
|
40
|
+
# Sessions run as `authenticated` (service_role is exempt from these guards
|
|
41
|
+
# entirely, so a service_role probe would prove nothing).
|
|
42
|
+
|
|
43
|
+
setup
|
|
44
|
+
{
|
|
45
|
+
insert into agent_board.claimgate_enforcement_flags(scope_key, enabled) values ('ownerless_guards', true)
|
|
46
|
+
on conflict (scope_key) do update set enabled = true;
|
|
47
|
+
insert into auth.users (id) values ('40500000-0000-4000-8000-0000000000a1');
|
|
48
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
|
|
49
|
+
values ('40500000-0000-4000-8000-000000000001', 'agent', 'agent',
|
|
50
|
+
'cpr-acq-owner', 'CPR Acquire Owner', 'active',
|
|
51
|
+
'40500000-0000-4000-8000-0000000000a1', '40500000-0000-4000-8000-0000000000a1');
|
|
52
|
+
select count(*) from agent_board.create_board(
|
|
53
|
+
'40500000-0000-4000-8000-000000000001',
|
|
54
|
+
'cpr-acq-board', 'CPR Acquire Board',
|
|
55
|
+
'message-board#405 acquire stale-policy probe', jsonb_build_object(), 'private');
|
|
56
|
+
insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
|
|
57
|
+
values ('40500000-0000-4000-8000-000000000010', 'coordination',
|
|
58
|
+
'40500000-0000-4000-8000-000000000001', 'CPR acquire thread',
|
|
59
|
+
(select id from agent_board.boards where slug = 'cpr-acq-board'));
|
|
60
|
+
-- The board starts OPTED OUT. Written directly rather than through the RPC so the setup does not
|
|
61
|
+
-- depend on the very gate this spec exercises; the RPC's own transitions are BCP's job.
|
|
62
|
+
update agent_board.boards set claim_policy = 'advisory' where slug = 'cpr-acq-board';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
teardown
|
|
66
|
+
{
|
|
67
|
+
delete from agent_board.claims where agent_id = '40500000-0000-4000-8000-000000000001';
|
|
68
|
+
delete from agent_board.threads where id = '40500000-0000-4000-8000-000000000010';
|
|
69
|
+
delete from agent_board.board_membership_events where board_id in (
|
|
70
|
+
select id from agent_board.boards where slug = 'cpr-acq-board');
|
|
71
|
+
delete from agent_board.board_memberships where board_id in (
|
|
72
|
+
select id from agent_board.boards where slug = 'cpr-acq-board');
|
|
73
|
+
delete from agent_board.boards where slug = 'cpr-acq-board';
|
|
74
|
+
delete from agent_board.agents where id = '40500000-0000-4000-8000-000000000001';
|
|
75
|
+
delete from auth.users where id = '40500000-0000-4000-8000-0000000000a1';
|
|
76
|
+
delete from agent_board.claimgate_enforcement_flags where scope_key = 'ownerless_guards';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# w: the board owner reverting the opt-out. Holds the board policy lock until commit.
|
|
80
|
+
session "w"
|
|
81
|
+
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-0000000000a1'; }
|
|
82
|
+
step "w_flip" {
|
|
83
|
+
select claim_policy
|
|
84
|
+
from agent_board.set_board_claim_policy(
|
|
85
|
+
(select id from agent_board.boards where slug = 'cpr-acq-board'), 'inherit');
|
|
86
|
+
}
|
|
87
|
+
step "w_commit" { COMMIT; }
|
|
88
|
+
|
|
89
|
+
# a: the in-flight acquire. 400 days is far past the 30-day ownerless TTL cap, so the horizon it
|
|
90
|
+
# gets is a direct readout of which policy it resolved.
|
|
91
|
+
session "a"
|
|
92
|
+
step "a_begin" { BEGIN; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000a1'; }
|
|
93
|
+
step "a_acq" {
|
|
94
|
+
select resource_key
|
|
95
|
+
from agent_board.acquire_claim(null, 'file', 'cpr-acq-key', now() + interval '400 days',
|
|
96
|
+
null, '40500000-0000-4000-8000-000000000010');
|
|
97
|
+
}
|
|
98
|
+
step "a_commit" { COMMIT; }
|
|
99
|
+
|
|
100
|
+
# checker: a count and a boolean, never a timestamp — a golden with a clock in it is not a golden.
|
|
101
|
+
session "chk"
|
|
102
|
+
step "chk" {
|
|
103
|
+
select count(*)::int as active_claims,
|
|
104
|
+
coalesce(bool_or(lease_expires_at > now() + interval '31 days'), false) as over_cap_lease
|
|
105
|
+
from agent_board.claims
|
|
106
|
+
where agent_id = '40500000-0000-4000-8000-000000000001'
|
|
107
|
+
and resource_kind = 'file'
|
|
108
|
+
and status = 'active'
|
|
109
|
+
and released_at is null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# THE RACE: the acquire is issued while the revert is uncommitted.
|
|
113
|
+
permutation "w_begin" "w_flip" "a_begin" "a_acq" "w_commit" "a_commit" "chk"
|
|
114
|
+
|
|
115
|
+
# THE TWIN: no revert at all. The board is advisory, so the opt-out must still WORK — the 400-day
|
|
116
|
+
# lease is granted and nothing waits. A guard that over-serialises, or one that resolved every
|
|
117
|
+
# board to 'inherit', fails here and passes the permutation above.
|
|
118
|
+
permutation "a_begin" "a_acq" "a_commit" "chk"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Consiliency/message-board#405 — the policy lock is BOARD-SCOPED. Two boards
|
|
2
|
+
# must not serialise against each other.
|
|
3
|
+
#
|
|
4
|
+
# WHY THIS IS ITS OWN SPEC. Every other spec in this family passes just as well
|
|
5
|
+
# with a GLOBAL lock key, and a global key would be a fleet-wide serialisation
|
|
6
|
+
# point on the hottest write path in the schema — every ownerless acquire and
|
|
7
|
+
# every renew queued behind every other board's policy flip. "It waits when it
|
|
8
|
+
# should" and "it does not wait when it should not" are different claims, and
|
|
9
|
+
# only the second one fails on a constant key.
|
|
10
|
+
#
|
|
11
|
+
# BOTH DIRECTIONS IN ONE FILE, so the probe is discriminating rather than merely
|
|
12
|
+
# green:
|
|
13
|
+
#
|
|
14
|
+
# permutation 1 (SAME board) — the acquirer must WAIT on the flip.
|
|
15
|
+
# permutation 2 (DIFFERENT board) — the acquirer must NOT wait, and its board
|
|
16
|
+
# is still 'advisory', so its 400-day lease
|
|
17
|
+
# is granted.
|
|
18
|
+
#
|
|
19
|
+
# MUTATION SIGNAL (measured, not predicted): replace the board id in
|
|
20
|
+
# `_claimgate_lock_board_policy`'s key with a constant -> permutation 2 gains a
|
|
21
|
+
# `<waiting ...>` line and the golden diverges. Permutation 1 is UNCHANGED by that
|
|
22
|
+
# mutation, which is exactly why it cannot stand alone.
|
|
23
|
+
#
|
|
24
|
+
# w COMMITS BEFORE a2 in permutation 2, and that ordering is about the mutant rather
|
|
25
|
+
# than the fix: with a constant key and a2 committing first, nothing ever releases the
|
|
26
|
+
# lock a2 is queued on and isolationtester spends its full 300-second cancel timeout
|
|
27
|
+
# before producing the diff. Committing w first leaves the independence claim intact --
|
|
28
|
+
# a2 still runs its whole acquire while w's flip is uncommitted -- and makes the mutant
|
|
29
|
+
# fail in a second.
|
|
30
|
+
|
|
31
|
+
setup
|
|
32
|
+
{
|
|
33
|
+
insert into agent_board.claimgate_enforcement_flags(scope_key, enabled) values ('ownerless_guards', true)
|
|
34
|
+
on conflict (scope_key) do update set enabled = true;
|
|
35
|
+
insert into auth.users (id) values ('40500000-0000-4000-8000-0000000000c1');
|
|
36
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
|
|
37
|
+
values ('40500000-0000-4000-8000-000000000003', 'agent', 'agent',
|
|
38
|
+
'cpr-scope-owner', 'CPR Scope Owner', 'active',
|
|
39
|
+
'40500000-0000-4000-8000-0000000000c1', '40500000-0000-4000-8000-0000000000c1');
|
|
40
|
+
select count(*) from agent_board.create_board(
|
|
41
|
+
'40500000-0000-4000-8000-000000000003', 'cpr-scope-b1', 'CPR Scope B1',
|
|
42
|
+
'message-board#405 board-scope probe (flipped board)', jsonb_build_object(), 'private');
|
|
43
|
+
select count(*) from agent_board.create_board(
|
|
44
|
+
'40500000-0000-4000-8000-000000000003', 'cpr-scope-b2', 'CPR Scope B2',
|
|
45
|
+
'message-board#405 board-scope probe (untouched board)', 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-000000000041', 'coordination',
|
|
48
|
+
'40500000-0000-4000-8000-000000000003', 'CPR scope thread b1',
|
|
49
|
+
(select id from agent_board.boards where slug = 'cpr-scope-b1')),
|
|
50
|
+
('40500000-0000-4000-8000-000000000042', 'coordination',
|
|
51
|
+
'40500000-0000-4000-8000-000000000003', 'CPR scope thread b2',
|
|
52
|
+
(select id from agent_board.boards where slug = 'cpr-scope-b2'));
|
|
53
|
+
update agent_board.boards set claim_policy = 'advisory' where slug in ('cpr-scope-b1', 'cpr-scope-b2');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
teardown
|
|
57
|
+
{
|
|
58
|
+
delete from agent_board.claims where agent_id = '40500000-0000-4000-8000-000000000003';
|
|
59
|
+
delete from agent_board.threads where id in ('40500000-0000-4000-8000-000000000041',
|
|
60
|
+
'40500000-0000-4000-8000-000000000042');
|
|
61
|
+
delete from agent_board.board_membership_events where board_id in (
|
|
62
|
+
select id from agent_board.boards where slug in ('cpr-scope-b1', 'cpr-scope-b2'));
|
|
63
|
+
delete from agent_board.board_memberships where board_id in (
|
|
64
|
+
select id from agent_board.boards where slug in ('cpr-scope-b1', 'cpr-scope-b2'));
|
|
65
|
+
delete from agent_board.boards where slug in ('cpr-scope-b1', 'cpr-scope-b2');
|
|
66
|
+
delete from agent_board.agents where id = '40500000-0000-4000-8000-000000000003';
|
|
67
|
+
delete from auth.users where id = '40500000-0000-4000-8000-0000000000c1';
|
|
68
|
+
delete from agent_board.claimgate_enforcement_flags where scope_key = 'ownerless_guards';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# w reverts board 1, and holds its policy lock until commit.
|
|
72
|
+
session "w"
|
|
73
|
+
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-0000000000c1'; }
|
|
74
|
+
step "w_flip" {
|
|
75
|
+
select claim_policy
|
|
76
|
+
from agent_board.set_board_claim_policy(
|
|
77
|
+
(select id from agent_board.boards where slug = 'cpr-scope-b1'), 'inherit');
|
|
78
|
+
}
|
|
79
|
+
step "w_commit" { COMMIT; }
|
|
80
|
+
|
|
81
|
+
# a1 acquires on the FLIPPED board: it must wait, then be refused by the TTL cap.
|
|
82
|
+
session "a1"
|
|
83
|
+
step "a1_begin" { BEGIN; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000c1'; }
|
|
84
|
+
step "a1_acq" {
|
|
85
|
+
select resource_key
|
|
86
|
+
from agent_board.acquire_claim(null, 'file', 'cpr-scope-key-b1', now() + interval '400 days',
|
|
87
|
+
null, '40500000-0000-4000-8000-000000000041');
|
|
88
|
+
}
|
|
89
|
+
step "a1_commit" { COMMIT; }
|
|
90
|
+
|
|
91
|
+
# a2 acquires on the OTHER board: it must not wait at all, and its opt-out still stands.
|
|
92
|
+
session "a2"
|
|
93
|
+
step "a2_begin" { BEGIN; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000c1'; }
|
|
94
|
+
step "a2_acq" {
|
|
95
|
+
select resource_key
|
|
96
|
+
from agent_board.acquire_claim(null, 'file', 'cpr-scope-key-b2', now() + interval '400 days',
|
|
97
|
+
null, '40500000-0000-4000-8000-000000000042');
|
|
98
|
+
}
|
|
99
|
+
step "a2_commit" { COMMIT; }
|
|
100
|
+
|
|
101
|
+
session "chk"
|
|
102
|
+
step "chk" {
|
|
103
|
+
select resource_key, (lease_expires_at > now() + interval '31 days') as over_cap_lease
|
|
104
|
+
from agent_board.claims
|
|
105
|
+
where agent_id = '40500000-0000-4000-8000-000000000003'
|
|
106
|
+
and status = 'active' and released_at is null
|
|
107
|
+
order by resource_key;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# SAME BOARD: waits, then the revert's 'inherit' refuses the 400-day horizon.
|
|
111
|
+
permutation "w_begin" "w_flip" "a1_begin" "a1_acq" "w_commit" "a1_commit" "chk"
|
|
112
|
+
|
|
113
|
+
# DIFFERENT BOARD: no wait anywhere, and board 2's opt-out grants the 400-day lease while board 1's
|
|
114
|
+
# flip is still in flight. A constant lock key turns this into a wait and fails the golden.
|
|
115
|
+
permutation "w_begin" "w_flip" "a2_begin" "a2_acq" "w_commit" "a2_commit" "chk"
|
|
@@ -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"
|