@consiliency/agent-board-schema 1.45.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.
Files changed (35) hide show
  1. package/fixtures/already-applied.json +24 -0
  2. package/migrations/20260926000100_agent_board_ownerless_quota_census.sql +268 -0
  3. package/migrations/20260927000100_agent_board_fleet_domain_no_downgrade.sql +393 -0
  4. package/migrations/20260928000100_agent_board_ownerless_quota_renew_convergence.sql +562 -0
  5. package/migrations/20260929000100_agent_board_advisory_revert_cap_reset.sql +371 -0
  6. package/migrations/20260930000100_agent_board_claim_policy_race.sql +922 -0
  7. package/migrations/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql +158 -0
  8. package/package.json +1 -1
  9. package/schema-manifest.json +57 -3
  10. package/src/tooling.test.mjs +52 -2
  11. package/tests/agent_board_board_claim_policy.test.sql +261 -1
  12. package/tests/agent_board_claim_subject_gate.test.sql +11 -2
  13. package/tests/agent_board_claimkey.test.sql +17 -2
  14. package/tests/agent_board_claimpolicy_race.test.sql +1374 -0
  15. package/tests/agent_board_fleet_machine_hijack.test.sql +270 -1
  16. package/tests/agent_board_fleet_machine_reclaim.test.sql +28 -2
  17. package/tests/agent_board_ownerless_quota_census.test.sql +406 -0
  18. package/tests/agent_board_ownerless_renew_quota.test.sql +580 -0
  19. package/tests/catalog/p_metadata_parameters.json +28 -0
  20. package/tests/fixtures/test-prereqs.sql +186 -0
  21. package/tests/isolation/expected/claimpolicy_acquire_revert_race.out +66 -0
  22. package/tests/isolation/expected/claimpolicy_board_scope.out +73 -0
  23. package/tests/isolation/expected/claimpolicy_claimrow_lock_order.out +80 -0
  24. package/tests/isolation/expected/claimpolicy_isolation_refusal.out +114 -0
  25. package/tests/isolation/expected/claimpolicy_renew_revert_race.out +66 -0
  26. package/tests/isolation/expected/claimpolicy_writer_keyshare.out +49 -0
  27. package/tests/isolation/expected/fleet_domain_no_downgrade_demote_race.out +447 -0
  28. package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +118 -0
  29. package/tests/isolation/specs/claimpolicy_board_scope.spec +115 -0
  30. package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +200 -0
  31. package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +162 -0
  32. package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +150 -0
  33. package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +107 -0
  34. package/tests/isolation/specs/fleet_domain_no_downgrade_demote_race.spec +292 -0
  35. package/tests/seeds/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql.pre.sql +99 -0
@@ -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"
@@ -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"