@consiliency/agent-board-schema 1.45.0 → 1.48.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.
@@ -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"