@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.
- package/fixtures/already-applied.json +24 -0
- package/migrations/20260926000100_agent_board_ownerless_quota_census.sql +268 -0
- package/migrations/20260927000100_agent_board_fleet_domain_no_downgrade.sql +393 -0
- package/migrations/20260928000100_agent_board_ownerless_quota_renew_convergence.sql +562 -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 +57 -3
- package/src/tooling.test.mjs +52 -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_fleet_machine_hijack.test.sql +270 -1
- package/tests/agent_board_fleet_machine_reclaim.test.sql +28 -2
- package/tests/agent_board_ownerless_quota_census.test.sql +406 -0
- package/tests/agent_board_ownerless_renew_quota.test.sql +580 -0
- package/tests/catalog/p_metadata_parameters.json +28 -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/expected/fleet_domain_no_downgrade_demote_race.out +447 -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/isolation/specs/fleet_domain_no_downgrade_demote_race.spec +292 -0
- package/tests/seeds/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql.pre.sql +99 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Consiliency/message-board#405 — the WRITER-SIDE lock-order question, MEASURED
|
|
2
|
+
# rather than argued (PR #397's standard).
|
|
3
|
+
#
|
|
4
|
+
# THE SHAPE OF THE CYCLE THAT WOULD EXIST. `set_board_claim_policy` now takes the
|
|
5
|
+
# board policy advisory lock and THEN wants a row lock on `agent_board.boards`.
|
|
6
|
+
# Any transaction that references that board row -- inserting a thread, a
|
|
7
|
+
# membership, an invite, an inbox item: twelve tables carry an FK to `boards` --
|
|
8
|
+
# holds `FOR KEY SHARE` on it, and if that same transaction then enters an
|
|
9
|
+
# ownerless claim path it wants the advisory lock. advisory -> row versus row ->
|
|
10
|
+
# advisory is a cycle, and #159 is open on lock ordering in this schema, so this
|
|
11
|
+
# is exactly the class that has already produced a revert here (ab38805, a 40P01
|
|
12
|
+
# on a comparable path).
|
|
13
|
+
#
|
|
14
|
+
# WHY IT IS NOT A CYCLE, AND WHY THAT IS AN OBSERVATION AND NOT A CLAIM. #400
|
|
15
|
+
# reads the outgoing policy `for no key update`, which does NOT conflict with
|
|
16
|
+
# `FOR KEY SHARE` -- so the writer never waits on the FK holder, and the wait
|
|
17
|
+
# graph has one edge, not two. This spec drives the arrangement and records that
|
|
18
|
+
# the writer COMPLETES: `t` holds KEY SHARE on the board row, `w` flips the
|
|
19
|
+
# policy anyway, `t`'s acquire then queues on `w`'s advisory lock and proceeds
|
|
20
|
+
# once `w` commits.
|
|
21
|
+
#
|
|
22
|
+
# THE PROBE IS DISCRIMINATING, which is the part that makes it evidence.
|
|
23
|
+
# MUTATION SIGNAL (verified in the PR): change `for no key update` to
|
|
24
|
+
# `for update` in set_board_claim_policy -> `w_flip` prints `<waiting ...>` on
|
|
25
|
+
# t's KEY SHARE, `t_acq` then queues on w's advisory lock, and PostgreSQL
|
|
26
|
+
# reports `ERROR: deadlock detected` (40P01). The same permutation, the same
|
|
27
|
+
# fixture, one lock mode apart. CPR-10 pins the source spelling so the two fail
|
|
28
|
+
# together.
|
|
29
|
+
#
|
|
30
|
+
# THE LEASE IS DELIBERATELY WITHIN THE CAP. This spec is about the wait graph,
|
|
31
|
+
# not about which policy won: the acquire must succeed under either answer, so
|
|
32
|
+
# any diff in the golden is a lock-order diff.
|
|
33
|
+
|
|
34
|
+
setup
|
|
35
|
+
{
|
|
36
|
+
insert into agent_board.claimgate_enforcement_flags(scope_key, enabled) values ('ownerless_guards', true)
|
|
37
|
+
on conflict (scope_key) do update set enabled = true;
|
|
38
|
+
insert into auth.users (id) values ('40500000-0000-4000-8000-0000000000e1');
|
|
39
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
|
|
40
|
+
values ('40500000-0000-4000-8000-000000000005', 'agent', 'agent',
|
|
41
|
+
'cpr-ks-owner', 'CPR KeyShare Owner', 'active',
|
|
42
|
+
'40500000-0000-4000-8000-0000000000e1', '40500000-0000-4000-8000-0000000000e1');
|
|
43
|
+
select count(*) from agent_board.create_board(
|
|
44
|
+
'40500000-0000-4000-8000-000000000005', 'cpr-ks-board', 'CPR KeyShare Board',
|
|
45
|
+
'message-board#405 writer-side KEY SHARE lock-order probe', jsonb_build_object(), 'private');
|
|
46
|
+
insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
|
|
47
|
+
values ('40500000-0000-4000-8000-000000000060', 'coordination',
|
|
48
|
+
'40500000-0000-4000-8000-000000000005', 'CPR keyshare thread',
|
|
49
|
+
(select id from agent_board.boards where slug = 'cpr-ks-board'));
|
|
50
|
+
update agent_board.boards set claim_policy = 'advisory' where slug = 'cpr-ks-board';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
teardown
|
|
54
|
+
{
|
|
55
|
+
delete from agent_board.claims where agent_id = '40500000-0000-4000-8000-000000000005';
|
|
56
|
+
delete from agent_board.threads where id = '40500000-0000-4000-8000-000000000060';
|
|
57
|
+
delete from agent_board.board_membership_events where board_id in (
|
|
58
|
+
select id from agent_board.boards where slug = 'cpr-ks-board');
|
|
59
|
+
delete from agent_board.board_memberships where board_id in (
|
|
60
|
+
select id from agent_board.boards where slug = 'cpr-ks-board');
|
|
61
|
+
delete from agent_board.boards where slug = 'cpr-ks-board';
|
|
62
|
+
delete from agent_board.agents where id = '40500000-0000-4000-8000-000000000005';
|
|
63
|
+
delete from auth.users where id = '40500000-0000-4000-8000-0000000000e1';
|
|
64
|
+
delete from agent_board.claimgate_enforcement_flags where scope_key = 'ownerless_guards';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# t: the FK holder. `for key share` is taken directly rather than by inserting a referencing row,
|
|
68
|
+
# because it is EXACTLY the lock mode an FK reference takes and it names the board without dragging
|
|
69
|
+
# a second RPC's own locking into the probe. Taken as the connecting role (not `authenticated`,
|
|
70
|
+
# which holds no UPDATE on boards and so cannot take a row lock at all); the acquire that follows
|
|
71
|
+
# drops into `authenticated`, which is the role the ownerless guards actually run for.
|
|
72
|
+
session "t"
|
|
73
|
+
step "t_begin" { BEGIN; }
|
|
74
|
+
step "t_keyshare" { select slug from agent_board.boards where slug = 'cpr-ks-board' for key share; }
|
|
75
|
+
step "t_acq" {
|
|
76
|
+
SET LOCAL ROLE authenticated;
|
|
77
|
+
SET LOCAL "request.jwt.claim.role" = 'authenticated';
|
|
78
|
+
SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000e1';
|
|
79
|
+
select resource_key
|
|
80
|
+
from agent_board.acquire_claim(null, 'file', 'cpr-ks-key', now() + interval '1 hour',
|
|
81
|
+
null, '40500000-0000-4000-8000-000000000060');
|
|
82
|
+
}
|
|
83
|
+
step "t_commit" { COMMIT; }
|
|
84
|
+
|
|
85
|
+
# w: the policy flip. Takes the advisory lock, then the `for no key update` row lock on the very
|
|
86
|
+
# row t is holding KEY SHARE on.
|
|
87
|
+
session "w"
|
|
88
|
+
step "w_begin" { BEGIN; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000e1'; }
|
|
89
|
+
step "w_flip" {
|
|
90
|
+
select claim_policy
|
|
91
|
+
from agent_board.set_board_claim_policy(
|
|
92
|
+
(select id from agent_board.boards where slug = 'cpr-ks-board'), 'inherit');
|
|
93
|
+
}
|
|
94
|
+
step "w_commit" { COMMIT; }
|
|
95
|
+
|
|
96
|
+
session "chk"
|
|
97
|
+
step "chk" {
|
|
98
|
+
select coalesce(string_agg(resource_key, ',' order by resource_key), '<none>') as claims,
|
|
99
|
+
(select claim_policy from agent_board.boards where slug = 'cpr-ks-board') as policy
|
|
100
|
+
from agent_board.claims
|
|
101
|
+
where agent_id = '40500000-0000-4000-8000-000000000005'
|
|
102
|
+
and status = 'active' and released_at is null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# KEY SHARE held, then the flip, then the acquire. With `for no key update` the writer does not
|
|
106
|
+
# queue behind the FK holder, so there is exactly one wait edge and no cycle.
|
|
107
|
+
permutation "t_begin" "t_keyshare" "w_begin" "w_flip" "t_acq" "w_commit" "t_commit" "chk"
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# #397 — register_fleet_machine's no-downgrade guard (#395) against a destination DEMOTED while
|
|
2
|
+
# the move waits.
|
|
3
|
+
#
|
|
4
|
+
# THE WINDOW. The guard's rank comparison is a conjunct of the ON CONFLICT DO UPDATE's WHERE, so
|
|
5
|
+
# it is atomic against the conflicting MACHINE row. The DESTINATION domain's lifecycle is not:
|
|
6
|
+
# pinning it needs a row lock on fleet_trust_domains, which was added to this function once
|
|
7
|
+
# (PR #158, ab38805) and REVERTED for deadlocking 40P01 against reclaim_fleet_machine — #159 owns
|
|
8
|
+
# the lock order and is open. So a `service_role` demotion of the destination can commit while the
|
|
9
|
+
# upsert is blocked on the machine row, and the machine lands in a domain that is 'pending' at
|
|
10
|
+
# commit time, still 'active' itself, with its fleet_machine_agents bindings intact (they key on
|
|
11
|
+
# machine_id) — #395 again, under concurrency.
|
|
12
|
+
#
|
|
13
|
+
# WHAT WAS MEASURED, and why the obvious repair is not the one shipped. Moving the destination
|
|
14
|
+
# read INTO the upsert as a correlated subquery does NOT close this: under READ COMMITTED an
|
|
15
|
+
# upsert that waits on the conflicting row re-reads THAT ROW's latest version on resume but
|
|
16
|
+
# evaluates every other relation against the statement's ORIGINAL snapshot, so the domain is still
|
|
17
|
+
# read as of before the wait. Both the cached read and the correlated subquery were run against
|
|
18
|
+
# this interleaving and both allowed the move. What closes it is a re-check of the destination in
|
|
19
|
+
# a SEPARATE statement after the move — a fresh snapshot — which raises 42501 and unwinds it.
|
|
20
|
+
#
|
|
21
|
+
# P1 — the pin. rl locks the machine row and holds it; rm's register BLOCKS (isolationtester
|
|
22
|
+
# prints `<waiting ...>`); rl then demotes the destination to 'pending' and commits; rm resumes,
|
|
23
|
+
# its upsert applies the move against the row it now locks, and the post-move re-check — a fresh
|
|
24
|
+
# statement, fresh snapshot — sees 'pending' and RAISES 42501, aborting rm. FINAL: the machine is
|
|
25
|
+
# still in its ORIGINAL active domain.
|
|
26
|
+
#
|
|
27
|
+
# P2 — the twin. The same move with NO concurrent demotion SUCCEEDS and lands. Without it, a guard
|
|
28
|
+
# that refused every move would satisfy P1 and this spec would be scoring an over-refusal as a
|
|
29
|
+
# fix. P2 leaves the machine in the destination domain, so it runs after P1's fixture reset (each
|
|
30
|
+
# permutation re-runs setup).
|
|
31
|
+
#
|
|
32
|
+
# WHAT EACH PERMUTATION PINS, and the mutation that falsifies it. Every refusal case carries a twin
|
|
33
|
+
# so a guard that refused everything could not pass this spec.
|
|
34
|
+
#
|
|
35
|
+
# P1 / P2 destination demoted during the wait → refused; uncontended move → succeeds
|
|
36
|
+
# P3 / P3-twin a concurrent session RELOCATES the machine, then demotes → refused;
|
|
37
|
+
# the relocation alone → the equal-rank move succeeds
|
|
38
|
+
# P4 / P4-twin the LOCK HOLDER relocates, then demotes → refused; relocation alone → succeeds
|
|
39
|
+
# P5 / P5-reverse register and reclaim serialize on the machine row in both starting orders
|
|
40
|
+
# (see the rc session for what these do NOT show)
|
|
41
|
+
# P6 / P6-promote / the first-registration race: the loser reaches the conflict path with no row
|
|
42
|
+
# P6-control to lock, so it is REFUSED 40001 whether the destination is demoted (P6) or the
|
|
43
|
+
# SOURCE is promoted (P6-promote); serialized, the same two registrations
|
|
44
|
+
# succeed (P6-control)
|
|
45
|
+
#
|
|
46
|
+
# MUTATION SIGNALS, all four measured:
|
|
47
|
+
# * delete the post-move comparison → P1, P3, P4 no longer raise; the checker shows the
|
|
48
|
+
# machine 'active' in a 'pending' domain
|
|
49
|
+
# * remove the pre-read's FOR UPDATE → P3, P4 no longer raise (the stale-pre-read bypass)
|
|
50
|
+
# * delete the lost-race (40001) refusal → P6, P6-promote no longer raise
|
|
51
|
+
# * inject the #158-shaped fleet_trust_domains
|
|
52
|
+
# FOR UPDATE into register → P1, P3, P4 become `ERROR: deadlock detected`.
|
|
53
|
+
# THIS is the evidence that the machine-only lock avoids ab38805's revert; P5/P5-reverse stay
|
|
54
|
+
# GREEN under that injection and were mis-cited for it in an earlier round.
|
|
55
|
+
|
|
56
|
+
setup
|
|
57
|
+
{
|
|
58
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status) values
|
|
59
|
+
('a3970000-0000-4000-8000-000000000001', 'agent', 'nodowngrade-iso-owner', 'No-downgrade Iso Owner', 'active'),
|
|
60
|
+
('a3970000-0000-4000-8000-000000000002', 'agent', 'nodowngrade-iso-operator', 'No-downgrade Iso Operator', 'active'),
|
|
61
|
+
('a3970000-0000-4000-8000-000000000003', 'agent', 'nodowngrade-iso-newowner', 'No-downgrade Iso New Owner', 'active');
|
|
62
|
+
|
|
63
|
+
-- Two ACTIVE domains owned by that agent: the machine's current one, and the move's destination.
|
|
64
|
+
insert into agent_board.fleet_trust_domains(id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
|
|
65
|
+
values ('d3970000-0000-4000-8000-000000000001', 'nodowngrade-iso-current', 'No-downgrade Iso Current', 'tailnet',
|
|
66
|
+
'a3970000-0000-4000-8000-000000000001', 'active', now()),
|
|
67
|
+
('d3970000-0000-4000-8000-000000000002', 'nodowngrade-iso-dest', 'No-downgrade Iso Destination', 'tailnet',
|
|
68
|
+
'a3970000-0000-4000-8000-000000000001', 'active', now()),
|
|
69
|
+
-- X: a third ACTIVE domain the concurrent session RELOCATES the machine into (#397 round 2).
|
|
70
|
+
('d3970000-0000-4000-8000-000000000003', 'nodowngrade-iso-x', 'No-downgrade Iso X', 'tailnet',
|
|
71
|
+
'a3970000-0000-4000-8000-000000000001', 'active', now()),
|
|
72
|
+
-- The reclaim probe's destination: active, owned by the arbitrated NEW owner.
|
|
73
|
+
('d3970000-0000-4000-8000-000000000004', 'nodowngrade-iso-newdom', 'No-downgrade Iso New Owner Domain', 'tailnet',
|
|
74
|
+
'a3970000-0000-4000-8000-000000000003', 'active', now()),
|
|
75
|
+
-- P6 family: the first-registration race runs between two PENDING domains, which is the
|
|
76
|
+
-- shape the source-promotion case needs (#397 round 3, finding 1).
|
|
77
|
+
('d3970000-0000-4000-8000-000000000005', 'nodowngrade-iso-race-src', 'No-downgrade Iso Race Source', 'tailnet',
|
|
78
|
+
'a3970000-0000-4000-8000-000000000001', 'pending', null),
|
|
79
|
+
('d3970000-0000-4000-8000-000000000006', 'nodowngrade-iso-race-dest', 'No-downgrade Iso Race Destination', 'tailnet',
|
|
80
|
+
'a3970000-0000-4000-8000-000000000001', 'pending', null);
|
|
81
|
+
|
|
82
|
+
-- The bound-shaped machine the move would relocate. Inserted directly so its id is fixed.
|
|
83
|
+
insert into agent_board.fleet_machines(id, trust_domain_id, owner_agent_id, machine_label, machine_fingerprint,
|
|
84
|
+
lifecycle_state, enrollment_source, enrolled_at, metadata)
|
|
85
|
+
values ('c3970000-0000-4000-8000-000000000001', 'd3970000-0000-4000-8000-000000000001',
|
|
86
|
+
'a3970000-0000-4000-8000-000000000001', 'nodowngrade-iso-machine', 'sha256:nodowngrade-iso',
|
|
87
|
+
'active', 'portal', now(), jsonb_build_object('redacted', true, 'metadata-safe', true));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
teardown
|
|
91
|
+
{
|
|
92
|
+
delete from agent_board.fleet_machines where id = 'c3970000-0000-4000-8000-000000000001';
|
|
93
|
+
delete from agent_board.fleet_machines where machine_fingerprint = 'sha256:nodowngrade-iso-fresh';
|
|
94
|
+
delete from agent_board.fleet_trust_domains where id in (
|
|
95
|
+
'd3970000-0000-4000-8000-000000000001',
|
|
96
|
+
'd3970000-0000-4000-8000-000000000002',
|
|
97
|
+
'd3970000-0000-4000-8000-000000000003',
|
|
98
|
+
'd3970000-0000-4000-8000-000000000004',
|
|
99
|
+
'd3970000-0000-4000-8000-000000000005',
|
|
100
|
+
'd3970000-0000-4000-8000-000000000006');
|
|
101
|
+
delete from agent_board.agents where id in (
|
|
102
|
+
'a3970000-0000-4000-8000-000000000001',
|
|
103
|
+
'a3970000-0000-4000-8000-000000000002',
|
|
104
|
+
'a3970000-0000-4000-8000-000000000003');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# rl: holds the machine row (any concurrent writer of that row will do — reclaim, revoke and
|
|
108
|
+
# reactivate all take it) and demotes the destination domain before releasing it.
|
|
109
|
+
session "rl"
|
|
110
|
+
step "rl_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
111
|
+
step "rl_lock" {
|
|
112
|
+
select id from agent_board.fleet_machines
|
|
113
|
+
where id = 'c3970000-0000-4000-8000-000000000001' for update;
|
|
114
|
+
}
|
|
115
|
+
step "rl_demote" {
|
|
116
|
+
update agent_board.fleet_trust_domains
|
|
117
|
+
set lifecycle_state = 'pending', approved_at = null, updated_at = now()
|
|
118
|
+
where id = 'd3970000-0000-4000-8000-000000000002';
|
|
119
|
+
}
|
|
120
|
+
step "rl_relocate_dest" {
|
|
121
|
+
update agent_board.fleet_machines
|
|
122
|
+
set trust_domain_id = 'd3970000-0000-4000-8000-000000000002', updated_at = now()
|
|
123
|
+
where id = 'c3970000-0000-4000-8000-000000000001';
|
|
124
|
+
}
|
|
125
|
+
step "rl_relocate_x" {
|
|
126
|
+
update agent_board.fleet_machines
|
|
127
|
+
set trust_domain_id = 'd3970000-0000-4000-8000-000000000003', updated_at = now()
|
|
128
|
+
where id = 'c3970000-0000-4000-8000-000000000001';
|
|
129
|
+
}
|
|
130
|
+
step "rl_promote_race_src" {
|
|
131
|
+
update agent_board.fleet_trust_domains
|
|
132
|
+
set lifecycle_state = 'active', approved_at = now(), updated_at = now()
|
|
133
|
+
where id = 'd3970000-0000-4000-8000-000000000005';
|
|
134
|
+
}
|
|
135
|
+
step "rl_demote_race_dest" {
|
|
136
|
+
update agent_board.fleet_trust_domains
|
|
137
|
+
set lifecycle_state = 'quarantined', approved_at = null, updated_at = now()
|
|
138
|
+
where id = 'd3970000-0000-4000-8000-000000000006';
|
|
139
|
+
}
|
|
140
|
+
step "rl_demote_current" {
|
|
141
|
+
update agent_board.fleet_trust_domains
|
|
142
|
+
set lifecycle_state = 'pending', approved_at = null, updated_at = now()
|
|
143
|
+
where id = 'd3970000-0000-4000-8000-000000000001';
|
|
144
|
+
}
|
|
145
|
+
step "rl_commit" { COMMIT; }
|
|
146
|
+
|
|
147
|
+
# rm: the owner re-registering their own fingerprint into the destination domain — the #395 move.
|
|
148
|
+
session "rm"
|
|
149
|
+
step "rm_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
150
|
+
step "rm_move" {
|
|
151
|
+
select trust_domain_id
|
|
152
|
+
from agent_board.register_fleet_machine(
|
|
153
|
+
'a3970000-0000-4000-8000-000000000001', 'd3970000-0000-4000-8000-000000000002',
|
|
154
|
+
'nodowngrade-iso-machine-moved', 'sha256:nodowngrade-iso', 'portal',
|
|
155
|
+
jsonb_build_object('redacted', true));
|
|
156
|
+
}
|
|
157
|
+
step "rm_move_current" {
|
|
158
|
+
select trust_domain_id
|
|
159
|
+
from agent_board.register_fleet_machine(
|
|
160
|
+
'a3970000-0000-4000-8000-000000000001', 'd3970000-0000-4000-8000-000000000001',
|
|
161
|
+
'nodowngrade-iso-machine-relabelled', 'sha256:nodowngrade-iso', 'portal',
|
|
162
|
+
jsonb_build_object('redacted', true));
|
|
163
|
+
}
|
|
164
|
+
step "rm_commit" { COMMIT; }
|
|
165
|
+
|
|
166
|
+
# rc: the operator reclaim. It locks the machine row and then takes an FK KEY SHARE on the
|
|
167
|
+
# destination domain — machine ≺ domain, the SAME acquisition order register takes.
|
|
168
|
+
#
|
|
169
|
+
# WHAT P5/P5-reverse DO AND DO NOT SHOW, corrected after a review seat pointed out that the first
|
|
170
|
+
# version of this comment claimed the stronger thing. They show that register and reclaim SERIALIZE
|
|
171
|
+
# on the machine row in both starting orders and that neither wedges. They do NOT reproduce the
|
|
172
|
+
# historical domain↔machine cycle: they target different domains, and each RPC only starts after
|
|
173
|
+
# the other has RETURNED, so no session is ever mid-call holding one lock and waiting for the other.
|
|
174
|
+
# Measured, not assumed: injecting the #158-shaped fleet_trust_domains FOR UPDATE leaves BOTH of
|
|
175
|
+
# them green. The permutations that actually falsify that lock are P1/P3/P4 — see the header.
|
|
176
|
+
session "rc"
|
|
177
|
+
step "rc_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
178
|
+
step "rc_reclaim" {
|
|
179
|
+
select owner_agent_id, trust_domain_id
|
|
180
|
+
from agent_board.reclaim_fleet_machine(
|
|
181
|
+
'c3970000-0000-4000-8000-000000000001', 'a3970000-0000-4000-8000-000000000003',
|
|
182
|
+
'a3970000-0000-4000-8000-000000000002', 'iso reclaim vs register',
|
|
183
|
+
'd3970000-0000-4000-8000-000000000004');
|
|
184
|
+
}
|
|
185
|
+
step "rc_commit" { COMMIT; }
|
|
186
|
+
|
|
187
|
+
# rf: the session that WINS a first-registration race on a fingerprint no row exists for yet.
|
|
188
|
+
# The loser's pre-read legitimately finds nothing, so v_prior_domain is null and the skip predicate
|
|
189
|
+
# has no baseline — the one conflict path the locked pre-read cannot cover.
|
|
190
|
+
session "rf"
|
|
191
|
+
step "rf_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
192
|
+
step "rf_register" {
|
|
193
|
+
select trust_domain_id
|
|
194
|
+
from agent_board.register_fleet_machine(
|
|
195
|
+
'a3970000-0000-4000-8000-000000000001', 'd3970000-0000-4000-8000-000000000005',
|
|
196
|
+
'nodowngrade-iso-fresh-winner', 'sha256:nodowngrade-iso-fresh', 'portal',
|
|
197
|
+
jsonb_build_object('redacted', true));
|
|
198
|
+
}
|
|
199
|
+
step "rf_commit" { COMMIT; }
|
|
200
|
+
|
|
201
|
+
# rm2: the loser of that race. Its insert blocks on the unique index, then takes the CONFLICT path
|
|
202
|
+
# against a row it never read.
|
|
203
|
+
session "rm2"
|
|
204
|
+
step "rm2_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
205
|
+
step "rm2_register" {
|
|
206
|
+
select trust_domain_id
|
|
207
|
+
from agent_board.register_fleet_machine(
|
|
208
|
+
'a3970000-0000-4000-8000-000000000001', 'd3970000-0000-4000-8000-000000000006',
|
|
209
|
+
'nodowngrade-iso-fresh-loser', 'sha256:nodowngrade-iso-fresh', 'portal',
|
|
210
|
+
jsonb_build_object('redacted', true));
|
|
211
|
+
}
|
|
212
|
+
step "rm2_commit" { COMMIT; }
|
|
213
|
+
|
|
214
|
+
# checker for the raced fingerprint.
|
|
215
|
+
session "chkf"
|
|
216
|
+
step "chkf" {
|
|
217
|
+
select fm.trust_domain_id, fm.lifecycle_state as machine_state, ftd.lifecycle_state as domain_state
|
|
218
|
+
from agent_board.fleet_machines fm
|
|
219
|
+
join agent_board.fleet_trust_domains ftd on ftd.id = fm.trust_domain_id
|
|
220
|
+
where fm.machine_fingerprint = 'sha256:nodowngrade-iso-fresh';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# checker: where the machine ended up, and the lifecycle of the domain it is in.
|
|
224
|
+
session "chk"
|
|
225
|
+
step "chk" {
|
|
226
|
+
select fm.trust_domain_id, fm.owner_agent_id, fm.lifecycle_state as machine_state,
|
|
227
|
+
ftd.lifecycle_state as domain_state
|
|
228
|
+
from agent_board.fleet_machines fm
|
|
229
|
+
join agent_board.fleet_trust_domains ftd on ftd.id = fm.trust_domain_id
|
|
230
|
+
where fm.id = 'c3970000-0000-4000-8000-000000000001';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
# P1: the move blocks, the destination is demoted under it, and the post-move re-check refuses.
|
|
234
|
+
permutation "rl_begin" "rl_lock" "rm_begin" "rm_move" "rl_demote" "rl_commit" "rm_commit" "chk"
|
|
235
|
+
|
|
236
|
+
# P2 (twin): the same move with no concurrent demotion still succeeds and lands.
|
|
237
|
+
permutation "rm_begin" "rm_move" "rm_commit" "chk"
|
|
238
|
+
|
|
239
|
+
# P3 (#397 round 2, case A — the concurrent session RELOCATES the machine). rl moves the machine
|
|
240
|
+
# D1→D2 uncommitted; rm re-registers into D1, so rm's prior-read sees the COMMITTED D1 and records
|
|
241
|
+
# v_prior_domain = D1; rl then demotes D1 and commits; rm resumes against the machine row as it now
|
|
242
|
+
# is (D2) and moves it D2→D1. The destination it lands in EQUALS the stale v_prior_domain, so a
|
|
243
|
+
# skip predicate built on that pre-read reads "nothing moved" and skips the fresh-snapshot check —
|
|
244
|
+
# leaving an active machine in a pending domain. MUST refuse.
|
|
245
|
+
permutation "rl_begin" "rl_relocate_dest" "rm_begin" "rm_move_current" "rl_demote_current" "rl_commit" "rm_commit" "chk"
|
|
246
|
+
|
|
247
|
+
# P3-twin: the same relocation with NO demotion. D1 is still active, so the move back into it is a
|
|
248
|
+
# legitimate equal-rank relocation and MUST succeed — an over-refusing guard fails here.
|
|
249
|
+
permutation "rl_begin" "rl_relocate_dest" "rm_begin" "rm_move_current" "rl_commit" "rm_commit" "chk"
|
|
250
|
+
|
|
251
|
+
# P4 (#397 round 2, case B — the LOCK HOLDER relocates). rm re-registers into the machine's OWN
|
|
252
|
+
# domain D1 (a label update, so both the destination gate and the rank pass and v_prior_domain =
|
|
253
|
+
# D1); rl holds the machine row, relocates it to X, demotes D1, and commits; rm resumes and the
|
|
254
|
+
# upsert genuinely moves the machine X→D1, which is now pending. Same stale-predicate skip.
|
|
255
|
+
# rm blocks at the UPSERT, not at the prior-read — a plain SELECT does not wait, which is why the
|
|
256
|
+
# pre-read is stale rather than merely early. MUST refuse.
|
|
257
|
+
permutation "rl_begin" "rl_lock" "rm_begin" "rm_move_current" "rl_relocate_x" "rl_demote_current" "rl_commit" "rm_commit" "chk"
|
|
258
|
+
|
|
259
|
+
# P4-twin: the same, without the demotion — X→D1 is equal-rank and MUST succeed.
|
|
260
|
+
permutation "rl_begin" "rl_lock" "rm_begin" "rm_move_current" "rl_relocate_x" "rl_commit" "rm_commit" "chk"
|
|
261
|
+
|
|
262
|
+
# P5 (lock order). register's machine-row FOR UPDATE is the SAME row reclaim_fleet_machine locks,
|
|
263
|
+
# and both then reach fleet_trust_domains only through the FK — machine ≺ domain in both, so no
|
|
264
|
+
# cycle. Evidence, not assertion: reclaim holds the machine row, register blocks, reclaim commits,
|
|
265
|
+
# register resumes and is refused by #141 (the machine now belongs to the new owner). No 40P01.
|
|
266
|
+
permutation "rc_begin" "rc_reclaim" "rm_begin" "rm_move_current" "rc_commit" "rm_commit" "chk"
|
|
267
|
+
|
|
268
|
+
# P5-reverse: the other acquisition order. register takes the machine row first and holds it to
|
|
269
|
+
# commit; reclaim blocks and completes afterwards. Still no cycle.
|
|
270
|
+
permutation "rm_begin" "rm_move_current" "rc_begin" "rc_reclaim" "rm_commit" "rc_commit" "chk"
|
|
271
|
+
|
|
272
|
+
# P6 (the first-registration race — the one conflict path the locked pre-read cannot cover). Both
|
|
273
|
+
# race domains are PENDING. rf first-registers the fingerprint into SRC and holds it uncommitted;
|
|
274
|
+
# rm2 registers the SAME fingerprint into DEST and blocks on the unique index; DEST is demoted to
|
|
275
|
+
# quarantined (rank 0, below pending) and committed while rm2 waits; rf commits; rm2 resumes onto
|
|
276
|
+
# the CONFLICT path against a row it never read and never locked. There is no recoverable baseline
|
|
277
|
+
# — the domain the machine was in belonged to a row we never read, and the upsert has overwritten
|
|
278
|
+
# it — so this is REFUSED as a serialization failure (40001, retryable) rather than approximated.
|
|
279
|
+
permutation "rf_begin" "rf_register" "rm2_begin" "rm2_register" "rl_begin" "rl_demote_race_dest" "rl_commit" "rf_commit" "rm2_commit" "chkf"
|
|
280
|
+
|
|
281
|
+
# P6-promote: THE ROUND-3 PANEL'S CASE, in its own shape. Nothing is demoted — the SOURCE is
|
|
282
|
+
# PROMOTED to active while both racers are in flight, so rm2 would move the machine OUT of a
|
|
283
|
+
# now-active domain INTO a pending one. Round 2's fallback compared the cached destination rank
|
|
284
|
+
# against itself (1 >= 1) and passed this; the refusal must come from the ABSENT BASELINE, which is
|
|
285
|
+
# why it does not depend on any rank comparison at all.
|
|
286
|
+
permutation "rf_begin" "rf_register" "rm2_begin" "rm2_register" "rl_begin" "rl_promote_race_src" "rl_commit" "rf_commit" "rm2_commit" "chkf"
|
|
287
|
+
|
|
288
|
+
# P6-control (the twin): the SAME two registrations, SERIALIZED — rf commits before rm2 starts, so
|
|
289
|
+
# rm2 has a row to lock and a real baseline. Both domains are pending, so its move is equal-rank and
|
|
290
|
+
# MUST succeed and land in DEST. Without this, a guard that refused every second registration would
|
|
291
|
+
# satisfy P6 and P6-promote.
|
|
292
|
+
permutation "rf_begin" "rf_register" "rf_commit" "rm2_begin" "rm2_register" "rm2_commit" "chkf"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
-- Consiliency/message-board#405 — the ADVERSARIAL PRE-STATE for 20261001000100.
|
|
2
|
+
--
|
|
3
|
+
-- WHAT THIS GATES, and why an assertion could not. CPR-22 checks the lock helper's final ACL in the
|
|
4
|
+
-- ordinary fixture — where there is no delegated grant — so it stays GREEN if the migration's
|
|
5
|
+
-- `CASCADE` were removed or its sweep narrowed back to a list of known roles. The migration's
|
|
6
|
+
-- claimed behaviour is about a state the fixture never contains, so the fixture has to contain it.
|
|
7
|
+
--
|
|
8
|
+
-- THE STATE: `service_role` holds EXECUTE **WITH GRANT OPTION** and has granted it onward to
|
|
9
|
+
-- `anon`. That is the adopter shape the migration exists to survive: a plain `REVOKE` defaults to
|
|
10
|
+
-- RESTRICT and fails `dependent privileges exist` against it, which on an adopter database is a
|
|
11
|
+
-- FAILED UPGRADE rather than a tidier ACL. Measured against this bundle: the plain form errors, the
|
|
12
|
+
-- shipped form exits 0 and leaves `{postgres=X/postgres}`.
|
|
13
|
+
--
|
|
14
|
+
-- HOW THE GATE READS. This runs BEFORE 20261001000100 (the runner's tests/seeds/<migration>.pre.sql
|
|
15
|
+
-- hook, #103). Afterwards, either
|
|
16
|
+
-- * the migration swept the chain and CPR-22 reports an empty ACL — the claim holds; or
|
|
17
|
+
-- * the migration RAISED, and the whole bundle replay fails loudly at this migration.
|
|
18
|
+
-- There is no third outcome in which a narrowed sweep passes quietly.
|
|
19
|
+
--
|
|
20
|
+
-- ONLY PRE-EXISTING ROLES ARE USED — no CREATE ROLE. Roles are cluster-global, so a seed that
|
|
21
|
+
-- created them would leak them into a shared cluster and could collide with a concurrent run;
|
|
22
|
+
-- service_role and anon are part of the adopter base this bundle already assumes.
|
|
23
|
+
--
|
|
24
|
+
-- WHAT THIS FIXTURE GATES, precisely — and the second clause is NARROWER than an earlier version of
|
|
25
|
+
-- this paragraph claimed. It gates that the sweep CASCADEs, and that it reaches a grantee OUTSIDE
|
|
26
|
+
-- THE HISTORICAL HAND LIST (public/anon/authenticated/service_role). It does NOT gate that the
|
|
27
|
+
-- sweep is catalog-DERIVED: a migration hard-coded to those four PLUS `authenticator` clears this
|
|
28
|
+
-- fixture and its guard below. A fixture names specific roles by construction, so only a randomly
|
|
29
|
+
-- generated grantee defeats any hand list.
|
|
30
|
+
--
|
|
31
|
+
-- A DISPOSABLE RANDOM GRANTEE WAS CONSIDERED AND REJECTED, checked rather than reasoned about. The
|
|
32
|
+
-- runner has a `.pre.sql` hook and no post hook, so a role created here is never dropped; a seed
|
|
33
|
+
-- that swept `mb_seed405_%` at its start to compensate would be dropping roles belonging to any
|
|
34
|
+
-- CONCURRENT run on the same cluster, and this cluster is shared. Trading a stated gap for
|
|
35
|
+
-- cross-run interference is the worse deal. Derivation itself stays mutation-verified: narrowing
|
|
36
|
+
-- the sweep to known roles was injected and CPR-22 named the survivor.
|
|
37
|
+
--
|
|
38
|
+
-- WHAT IT DOES NOT GATE: that the sweep derives its SIGNATURES. That needs a pre-existing overload,
|
|
39
|
+
-- and a seeded overload PERSISTS — the runner has a `.pre.sql` hook and no post hook, so nothing
|
|
40
|
+
-- drops it, and tests/catalog/p_metadata_parameters.json enumerates every agent_board signature and
|
|
41
|
+
-- would go red. Checked rather than assumed. So a migration narrowed back to the `(uuid)` signature
|
|
42
|
+
-- stays green here; that half is mutation-verified only, and this paragraph is the record of it
|
|
43
|
+
-- rather than a silence someone has to rediscover.
|
|
44
|
+
|
|
45
|
+
do $$
|
|
46
|
+
begin
|
|
47
|
+
-- Idempotent: a re-replay onto a database that already carries the chain must not fail here, or
|
|
48
|
+
-- the seed becomes a worse failure mode than the one it tests for.
|
|
49
|
+
if not exists (select 1 from pg_roles where rolname = 'service_role')
|
|
50
|
+
or not exists (select 1 from pg_roles where rolname = 'anon') then
|
|
51
|
+
raise exception
|
|
52
|
+
'claimpolicy lock-grant seed: service_role/anon absent — the adopter base this bundle assumes is not present'
|
|
53
|
+
using errcode = '42704';
|
|
54
|
+
end if;
|
|
55
|
+
|
|
56
|
+
-- A GRANTEE OUTSIDE THE FOUR NAMES THE OLD HAND LIST USED. Without this the fixture gates only
|
|
57
|
+
-- "CASCADE is present": a migration hard-coded to revoke public/anon/authenticated/service_role
|
|
58
|
+
-- with CASCADE would sweep the chain below, pass, and still abandon every arbitrary adopter
|
|
59
|
+
-- grantee — which is the entire reason the sweep was made catalog-derived. `authenticator` is
|
|
60
|
+
-- outside that list and is part of the Supabase adopter base, so nothing has to be created:
|
|
61
|
+
-- a role invented here would linger cluster-wide, since a seed has no post hook to drop it.
|
|
62
|
+
execute 'grant execute on function agent_board._claimgate_lock_board_policy(uuid) to authenticator';
|
|
63
|
+
|
|
64
|
+
execute 'grant execute on function agent_board._claimgate_lock_board_policy(uuid) to service_role with grant option';
|
|
65
|
+
end $$;
|
|
66
|
+
|
|
67
|
+
-- The onward grant must be made BY service_role, so the dependency really exists.
|
|
68
|
+
--
|
|
69
|
+
-- `SET ROLE`, NOT `SET LOCAL ROLE`, and that distinction is the whole gate. The runner applies this
|
|
70
|
+
-- file with `psql -f` in AUTOCOMMIT, so every statement is its own implicit transaction and a
|
|
71
|
+
-- `SET LOCAL` is discarded before the next one runs. The first version used SET LOCAL: the grant was
|
|
72
|
+
-- then made by `postgres`, recorded as `anon=X/postgres` with no dependency on service_role's grant,
|
|
73
|
+
-- and a plain RESTRICT revoke would have swept it happily — a gate that passed whether or not the
|
|
74
|
+
-- migration cascaded. The check below caught exactly that, which is why it is here.
|
|
75
|
+
set role service_role;
|
|
76
|
+
grant execute on function agent_board._claimgate_lock_board_policy(uuid) to anon;
|
|
77
|
+
reset role;
|
|
78
|
+
|
|
79
|
+
-- Proof the pre-state is what this file claims, checked rather than assumed: if the chain did not
|
|
80
|
+
-- form, the migration would sweep a state no harder than the ordinary one and the gate would be
|
|
81
|
+
-- vacuous — green for the wrong reason, which is the failure mode this whole effort keeps finding.
|
|
82
|
+
do $$
|
|
83
|
+
declare v_acl text;
|
|
84
|
+
begin
|
|
85
|
+
select coalesce(proacl::text, '<null>') into v_acl
|
|
86
|
+
from pg_proc where oid = to_regprocedure('agent_board._claimgate_lock_board_policy(uuid)');
|
|
87
|
+
if v_acl not like '%anon=X/service_role%' then
|
|
88
|
+
raise exception
|
|
89
|
+
'claimpolicy lock-grant seed: the delegated grant did not form (acl=%) — refusing to leave a VACUOUS gate that would pass whether or not the migration cascades',
|
|
90
|
+
v_acl
|
|
91
|
+
using errcode = '55000';
|
|
92
|
+
end if;
|
|
93
|
+
if v_acl not like '%authenticator=%' then
|
|
94
|
+
raise exception
|
|
95
|
+
'claimpolicy lock-grant seed: the out-of-list grantee did not take (acl=%) — without it this fixture gates only CASCADE, and a migration narrowed to the four historical role names would pass',
|
|
96
|
+
v_acl
|
|
97
|
+
using errcode = '55000';
|
|
98
|
+
end if;
|
|
99
|
+
end $$;
|