@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,393 @@
1
+ -- #395 re-registration may not move a bound machine into a LESS APPROVED trust domain.
2
+ --
3
+ -- THE HOLE. register_fleet_machine's `on conflict (machine_fingerprint) do update` sets
4
+ -- trust_domain_id = excluded.trust_domain_id, so a re-registration MOVES the machine. Three
5
+ -- facts compose (#395):
6
+ -- 1. the destination domain is accepted at lifecycle_state in ('active','pending')
7
+ -- (20260823000100:52) — a never-approved domain is a legal destination;
8
+ -- 2. lifecycle_state is deliberately NOT updated on conflict (#141), so the machine stays
9
+ -- 'active' through the move;
10
+ -- 3. fleet_machine_agents rows key on machine_id, which does not change — the bindings
11
+ -- SURVIVE the move.
12
+ -- evaluate_fleet_sender_policy (last definer 20260826000100:161-168) joins fma + fm and never
13
+ -- reads ftd, so after the move the sender is still fleet-bound under a domain nobody approved.
14
+ -- The owner needs no privilege for this: the `where owner_agent_id = v_actor_agent_id` gate
15
+ -- exists to refuse a THIRD-PARTY hijack (#141) and permits the owner's own self-move.
16
+ --
17
+ -- THE REMEDY, and the two it is not. This migration refuses the MOVE. It deliberately does NOT
18
+ -- * make evaluate_fleet_sender_policy read ftd.lifecycle_state — widest blast radius, and it
19
+ -- would also change the domain-DEMOTION semantics (active -> pending on an already-bound
20
+ -- domain is permitted and is not a send off-switch) that were left alone on purpose; or
21
+ -- * treat a domain move as re-enrollment and reset the bindings — most disruptive.
22
+ -- Send-path semantics are unchanged by this migration. Nothing here reads or writes
23
+ -- fleet_machine_agents, evaluate_fleet_sender_policy, or any revocation ledger.
24
+ --
25
+ -- THE ORDERING, stated so it is a rule and not an intuition. Approval rank over
26
+ -- fleet_trust_domains.lifecycle_state (agent_board._fleet_trust_domain_approval_rank):
27
+ --
28
+ -- active 2 approved, in force
29
+ -- pending 1 registered, never yet approved
30
+ -- quarantined 0 approval WITHDRAWN
31
+ -- suspended 0 approval WITHDRAWN
32
+ -- revoked 0 approval WITHDRAWN
33
+ --
34
+ -- The three withdrawn states sit BELOW pending, not between pending and active: each one means
35
+ -- an approval that was taken away, which is not a stronger claim than "never approved". They
36
+ -- are ranked equal to each other because nothing in this schema distinguishes their authority —
37
+ -- the send path reads none of them (it reads the fleet_revocation_events ledger), so ordering
38
+ -- them against each other would be a distinction with no reader.
39
+ --
40
+ -- THE RULE: the re-registration is refused when
41
+ -- rank(destination domain) < rank(the machine's CURRENT domain).
42
+ -- An EQUAL rank is permitted — a legitimate relocation between two active domains, or between
43
+ -- two pending ones, still works. An UPGRADE (pending -> active) is permitted. A FIRST
44
+ -- registration has no current domain and is unaffected (the guard lives in the ON CONFLICT
45
+ -- branch, which a fresh insert never enters).
46
+ --
47
+ -- REACHABILITY, so the guard is not read as wider than it is. Because the destination gate
48
+ -- already restricts the destination to ('active','pending'), the ONLY refusal this rule can
49
+ -- produce today is active -> pending, which is exactly #395. The comparison is written in terms
50
+ -- of the general rank anyway so that widening the destination gate, or adding a lifecycle value,
51
+ -- cannot silently open a second hole: an unranked state yields NULL, `>=` over NULL is not true,
52
+ -- and the re-registration is REFUSED. Every arm of this guard fails CLOSED.
53
+ --
54
+ -- CONCURRENCY, stated at its measured size rather than as "atomic" (#397). The two halves of the
55
+ -- comparison have DIFFERENT guarantees, and the earlier version of this file claimed the stronger
56
+ -- one for both:
57
+ --
58
+ -- * The MACHINE half is atomic. The comparison is a conjunct of the ON CONFLICT DO UPDATE's
59
+ -- WHERE, evaluated by the same statement that would perform the move, against the conflicting
60
+ -- row that statement has already locked. No select-then-update window — the same reason #141
61
+ -- put its ownership gate there rather than in a pre-check.
62
+ -- * The DESTINATION half cannot be pinned without a row lock on fleet_trust_domains, and that
63
+ -- lock is NOT ours to take: it was added to this very function once (PR #158, ab38805) and
64
+ -- REVERTED for deadlocking 40P01 against reclaim_fleet_machine, which locks the machine row
65
+ -- first and then takes an FK KEY SHARE on the destination domain. Restoring a global
66
+ -- domain ≺ machine order means re-ordering a merged core function; #159 owns that and is open.
67
+ -- `service_role` holds UPDATE on fleet_trust_domains, so a concurrent demotion of the
68
+ -- destination is a real event, not a hypothetical.
69
+ --
70
+ -- MEASURED, not reasoned: moving the destination read INTO the upsert as a correlated subquery
71
+ -- — the obvious repair — does NOT close the window. Under READ COMMITTED an upsert that waits on
72
+ -- the conflicting row's lock re-reads THAT ROW's latest version when it resumes, but evaluates
73
+ -- every other relation against the statement's ORIGINAL snapshot, so the domain's lifecycle is
74
+ -- still read as of before the wait. Both forms were run against a two-session probe (session B
75
+ -- holds the machine row, demotes the destination, commits; session A's move resumes) and both
76
+ -- allowed the move.
77
+ --
78
+ -- So the destination is re-checked AFTER the move, in a SEPARATE statement, which under READ
79
+ -- COMMITTED takes a FRESH snapshot and therefore sees a demotion that committed while we waited.
80
+ -- It raises, which aborts the transaction and unwinds the move. Same probe: refused, machine left
81
+ -- in its original domain. The post-check runs ONLY when the domain actually changed
82
+ -- (`is distinct from`), so a same-domain re-register — including the revoked-machine case #141
83
+ -- protects — is never failed by it. A FIRST registration has no prior domain, and what covers it is
84
+ -- the LOST-RACE REFUSAL below, NOT the absence of a check — an earlier version of this sentence
85
+ -- said a fresh insert "cannot be a downgrade at all", which is true of an insert and false of the
86
+ -- insert that LOSES a first-registration race and lands on the conflict path.
87
+ --
88
+ -- ROUND 2 (#397), because the first version of that paragraph was wrong in a way worth recording.
89
+ -- The skip predicate compares the landed domain against a PRE-READ, and the pre-read was unlocked.
90
+ -- "The destination equals what I read before the wait" is NOT "nothing moved": a concurrent
91
+ -- session that RELOCATES the machine separates them, and if our upsert then moves the machine back
92
+ -- to the domain our stale read named, the skip fires and the fresh-snapshot check never runs. Two
93
+ -- interleavings did exactly that and were reproduced before this was changed:
94
+ -- P3 — another session moves the machine D1→D2 uncommitted; we re-register into D1 (our pre-read
95
+ -- says D1); it demotes D1 and commits; we resume, move D2→D1, and D1 == our stale pre-read.
96
+ -- P4 — we re-register into the machine's OWN domain D1; the lock holder relocates the machine to
97
+ -- X and demotes D1; we resume, genuinely move X→D1, and again D1 == the pre-read. (We block
98
+ -- at the UPSERT, not at the pre-read — a plain SELECT does not wait, which is why the
99
+ -- pre-read is stale rather than merely early.)
100
+ -- The pre-read now takes FOR UPDATE on the MACHINE row — the same row the upsert locks, so the
101
+ -- acquisition order is unchanged and no fleet_trust_domains lock is involved (see the read itself
102
+ -- for why that distinction is the difference between this and the reverted #158 lock). It observes
103
+ -- post-wait truth, nothing can relocate the machine between it and the upsert, and the skip
104
+ -- predicate means what it says.
105
+ --
106
+ -- ROUND 3 (#397) replaced what this paragraph used to describe, and the correction is the point.
107
+ -- Round 2 gave the no-prior-row path a fallback that compared the landed domain against the
108
+ -- DESTINATION RANK IT HAD CACHED. A review seat showed that is not a baseline: PROMOTE the source
109
+ -- domain during the race and the cached destination rank still matches itself (1 >= 1), so a
110
+ -- machine moves out of a now-active domain into a pending one and the comparison never notices
111
+ -- (isolation P6-promote, reproduced before this was changed).
112
+ --
113
+ -- There is nothing to recover in that case — the domain the machine was in belonged to a row we
114
+ -- never read, and the upsert has overwritten it — so the lost race is now REFUSED (40001,
115
+ -- retryable) rather than approximated, and the decision below reads BOTH ranks in ONE statement
116
+ -- instead of comparing values captured at different times. That pairing is what ends the shape
117
+ -- every round of this issue has had; see the two blocks in the body for the construction argument
118
+ -- and for the class that still survives.
119
+ --
120
+ -- RESIDUAL, scoped rather than claimed away: a demotion that commits AFTER the post-check's
121
+ -- snapshot still lands. That outcome is serial-equivalent to `register` followed by `demote the
122
+ -- destination`, which is a PERMITTED operation this migration deliberately does not change
123
+ -- (FLEETBOOT: "demotion is not a send off-switch"). Closing it needs the domain lock, i.e. #159.
124
+ --
125
+ -- REFUSAL SHAPE. 42501, consistent with this function's other refusals. When the do-update
126
+ -- matches nothing there are now TWO possible reasons, so the row is re-read to name the actual
127
+ -- one: a different owner still raises #141's message VERBATIM (it is documented behaviour), and
128
+ -- the new case names the domain move. The re-read is stable because the conflicting row is
129
+ -- locked by the upsert above.
130
+ --
131
+ -- CLASS (rule 1). The writers of fleet_machines.trust_domain_id are register_fleet_machine and
132
+ -- reclaim_fleet_machine — catalog-derived and pinned in agent_board_fleet_machine_hijack.test.sql,
133
+ -- so a future third writer names itself. reclaim_fleet_machine does NOT need this guard and is
134
+ -- exempt on a stronger gate, not a weaker one: its p_new_trust_domain_id must be
135
+ -- lifecycle_state = 'active' AND owned by the new owner (20260829000100:135-142), so its
136
+ -- destination is always rank 2 and it cannot express a downgrade. That exemption is asserted
137
+ -- POSITIVELY (a pending destination raises 42501) in agent_board_fleet_machine_reclaim.test.sql
138
+ -- rather than left as an absence.
139
+ --
140
+ -- #118 discipline: create-or-replace preserves the ACL and COMMENT, but both are restated below.
141
+
142
+ -- ------------------------------------------------------------------------------------------
143
+ -- The approval ordering, as a function so it has ONE definition that a test can interrogate.
144
+ -- SECURITY INVOKER, IMMUTABLE, and total on its input: an unknown state returns NULL, which
145
+ -- every caller must treat as "refuse" (a NULL comparison is not true). Adding a lifecycle value
146
+ -- to the fleet_trust_domains CHECK without ranking it here fails the CHECK-derived class
147
+ -- assertion in agent_board_fleet_machine_hijack.test.sql BY NAME.
148
+ -- ------------------------------------------------------------------------------------------
149
+ create or replace function agent_board._fleet_trust_domain_approval_rank(p_lifecycle_state text)
150
+ returns integer
151
+ language sql
152
+ immutable
153
+ set search_path = agent_board, public
154
+ as $$
155
+ select case p_lifecycle_state
156
+ when 'active' then 2
157
+ when 'pending' then 1
158
+ when 'quarantined' then 0
159
+ when 'suspended' then 0
160
+ when 'revoked' then 0
161
+ else null
162
+ end
163
+ $$;
164
+
165
+ revoke all on function agent_board._fleet_trust_domain_approval_rank(text) from public, anon;
166
+ grant execute on function agent_board._fleet_trust_domain_approval_rank(text) to authenticated, service_role;
167
+
168
+ comment on function agent_board._fleet_trust_domain_approval_rank(text) is
169
+ 'Approval ordering over fleet_trust_domains.lifecycle_state (#395): active=2, pending=1, quarantined/suspended/revoked=0 (approval WITHDRAWN ranks below never-approved). Returns NULL for any state outside the column CHECK, so a caller comparing ranks refuses rather than fails open. Used by register_fleet_machine to refuse a re-registration that would move a machine into a less approved domain.';
170
+
171
+ -- ------------------------------------------------------------------------------------------
172
+ -- register_fleet_machine — reproduced from 20260823000100 (its last definer) VERBATIM except:
173
+ -- * v_dest_rank / v_existing declarations;
174
+ -- * the destination-rank read after the existing destination gate;
175
+ -- * the rank conjunct on the ON CONFLICT DO UPDATE's WHERE;
176
+ -- * the two-reason refusal that replaces the single #141 raise (whose message is unchanged);
177
+ -- * the prior-domain capture and the post-move re-check (#397).
178
+ -- ------------------------------------------------------------------------------------------
179
+ create or replace function agent_board.register_fleet_machine(
180
+ p_owner_agent_id uuid,
181
+ p_trust_domain_id uuid,
182
+ p_machine_label text,
183
+ p_machine_fingerprint text,
184
+ p_enrollment_source text default 'portal',
185
+ p_metadata jsonb default '{}'::jsonb
186
+ )
187
+ returns table(machine_id uuid, trust_domain_id uuid, lifecycle_state text, enrolled_at timestamptz)
188
+ language plpgsql
189
+ security definer
190
+ set search_path = agent_board, public
191
+ as $$
192
+ declare
193
+ v_machine record;
194
+ v_existing agent_board.fleet_machines%rowtype;
195
+ v_actor_agent_id uuid;
196
+ v_dest_rank integer;
197
+ v_prior_domain uuid;
198
+ v_landed_rank integer;
199
+ v_source_rank integer;
200
+ v_upserted_rows integer;
201
+ begin
202
+ if nullif(trim(p_machine_label), '') is null or nullif(trim(p_machine_fingerprint), '') is null then
203
+ raise exception 'machine label and fingerprint are required' using errcode = '22023';
204
+ end if;
205
+
206
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_owner_agent_id, 'p_owner_agent_id');
207
+
208
+ if not exists (
209
+ select 1
210
+ from agent_board.fleet_trust_domains ftd
211
+ where ftd.id = p_trust_domain_id
212
+ and ftd.owner_agent_id = v_actor_agent_id
213
+ and ftd.lifecycle_state in ('active', 'pending')
214
+ ) then
215
+ raise exception 'trust domain is not active for owner' using errcode = '42501';
216
+ end if;
217
+
218
+ -- #395: the destination's approval rank, read once. NULL is impossible while the gate above
219
+ -- admits only ranked states; it is still compared fail-closed below.
220
+ select agent_board._fleet_trust_domain_approval_rank(ftd.lifecycle_state)
221
+ into v_dest_rank
222
+ from agent_board.fleet_trust_domains ftd
223
+ where ftd.id = p_trust_domain_id;
224
+
225
+ -- #397 (round 2): the conflicting row's domain BEFORE the move, read UNDER THE ROW LOCK.
226
+ --
227
+ -- The lock is the whole point, and its absence was a bypass. An unlocked read here answers
228
+ -- "what domain was this machine in at some committed instant before we waited", and the
229
+ -- post-move skip predicate below then treats that as "we did not relocate the machine". Those
230
+ -- two diverge exactly when a CONCURRENT session relocates the machine while our upsert waits:
231
+ -- if it moves the machine elsewhere and our upsert then moves it back to the domain our stale
232
+ -- read named, the predicate reads "unchanged", the fresh-snapshot check is skipped, and an
233
+ -- active machine commits in a demoted domain — the original hole. Two interleavings did exactly
234
+ -- that (isolation P3/P4, both reproduced before this line was written).
235
+ --
236
+ -- FOR UPDATE makes the read wait for whoever holds the row and observe the POST-WAIT truth, and
237
+ -- nothing can relocate the machine between here and the upsert because we hold it.
238
+ --
239
+ -- ON fleet_machines ONLY — deliberately not a join with FOR UPDATE, which would lock the
240
+ -- fleet_trust_domains row too. THAT is the lock #158/ab38805 added and had reverted for
241
+ -- deadlocking 40P01 against reclaim_fleet_machine (#159). This lock is the SAME row the upsert
242
+ -- below locks anyway, taken a moment earlier, so the acquisition order is unchanged: machine
243
+ -- row first, fleet_trust_domains only through the insert's FK KEY SHARE — which is exactly
244
+ -- reclaim_fleet_machine's own order. Isolation P5/P5-reverse drive the two against each other
245
+ -- in both directions and show serialization with no 40P01.
246
+ --
247
+ -- Null when there is no conflicting row: a first registration takes no lock here and skips the
248
+ -- post-check; the ON CONFLICT conjunct is what covers it if a concurrent insert makes one appear.
249
+ select fm.trust_domain_id
250
+ into v_prior_domain
251
+ from agent_board.fleet_machines fm
252
+ where fm.machine_fingerprint = trim(p_machine_fingerprint)
253
+ for update;
254
+
255
+
256
+ insert into agent_board.fleet_machines(
257
+ trust_domain_id,
258
+ owner_agent_id,
259
+ machine_label,
260
+ machine_fingerprint,
261
+ lifecycle_state,
262
+ enrollment_source,
263
+ enrolled_at,
264
+ revoked_at,
265
+ metadata
266
+ )
267
+ values (
268
+ p_trust_domain_id,
269
+ v_actor_agent_id,
270
+ trim(p_machine_label),
271
+ trim(p_machine_fingerprint),
272
+ 'active',
273
+ coalesce(nullif(p_enrollment_source, ''), 'portal'),
274
+ now(),
275
+ null,
276
+ coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('redacted', true, 'metadata-safe', true)
277
+ )
278
+ on conflict (machine_fingerprint)
279
+ do update set
280
+ trust_domain_id = excluded.trust_domain_id,
281
+ machine_label = excluded.machine_label,
282
+ enrollment_source = excluded.enrollment_source,
283
+ enrolled_at = coalesce(agent_board.fleet_machines.enrolled_at, excluded.enrolled_at),
284
+ metadata = excluded.metadata,
285
+ -- owner_agent_id, lifecycle_state, revoked_at intentionally NOT updated (#141):
286
+ -- ownership is immutable via re-register and a revoked machine is not resurrected.
287
+ updated_at = now()
288
+ where agent_board.fleet_machines.owner_agent_id = v_actor_agent_id
289
+ -- #395: and the move may not LOWER the machine's domain approval. The subquery reads the
290
+ -- CONFLICTING row's current domain; NULL on either side makes this not-true, i.e. refuse.
291
+ and v_dest_rank >= (
292
+ select agent_board._fleet_trust_domain_approval_rank(ftd.lifecycle_state)
293
+ from agent_board.fleet_trust_domains ftd
294
+ where ftd.id = agent_board.fleet_machines.trust_domain_id
295
+ )
296
+ returning *, (xmax <> 0) as was_update into v_machine;
297
+ get diagnostics v_upserted_rows = row_count;
298
+
299
+ if v_upserted_rows = 0 then
300
+ -- Two reasons now match no row. Name the real one. The conflicting row is locked by the
301
+ -- upsert above (ON CONFLICT DO UPDATE locks before evaluating its WHERE), so this is stable.
302
+ select * into v_existing
303
+ from agent_board.fleet_machines fm
304
+ where fm.machine_fingerprint = trim(p_machine_fingerprint);
305
+
306
+ -- A fingerprint conflict on a row owned by a DIFFERENT owner: refuse the ownership-hijack
307
+ -- attempt (#141). Also the fail-closed default if the row cannot be re-read at all.
308
+ if v_existing.id is null or v_existing.owner_agent_id <> v_actor_agent_id then
309
+ raise exception 'machine fingerprint is registered to another owner' using errcode = '42501';
310
+ end if;
311
+
312
+ -- Own machine, refused by the approval-rank guard (#395).
313
+ raise exception 're-registration would move machine into a less approved trust domain (destination %, current %)',
314
+ p_trust_domain_id, v_existing.trust_domain_id using errcode = '42501';
315
+ end if;
316
+
317
+ -- #397 ROUND 3 — THE LOST RACE IS REFUSED, NOT GUESSED AT.
318
+ --
319
+ -- The pre-read above locks the conflicting row when there IS one. When there is not, we took no
320
+ -- lock — and our insert can still land on the CONFLICT path, because a concurrent first
321
+ -- registration of the same fingerprint commits while we block on the unique index. Round 2 tried
322
+ -- to cover that by comparing against the destination rank we had cached, and round 3's panel
323
+ -- showed why that is not a baseline: promote the SOURCE domain during the wait and the cached
324
+ -- destination rank still matches itself, so a machine moves out of a now-active domain into a
325
+ -- pending one and the comparison never notices.
326
+ --
327
+ -- There is no trustworthy baseline to recover here: the domain the machine was in belonged to a
328
+ -- row we never read, and the upsert has already overwritten it. So this refuses instead of
329
+ -- approximating. `xmax <> 0` distinguishes the conflict path from a genuine insert (a fresh
330
+ -- tuple carries xmax = 0), which makes the condition exact rather than inferred.
331
+ --
332
+ -- 40001, not 42501: this is a serialization failure and the caller should RETRY, not conclude it
333
+ -- lacks authority. The retry finds the row committed, locks it, and takes the ordinary path — so
334
+ -- the refusal costs one round trip and closes the whole no-baseline class.
335
+ if v_prior_domain is null and v_machine.was_update then
336
+ raise exception 'concurrent registration of machine fingerprint % — retry', trim(p_machine_fingerprint)
337
+ using errcode = '40001';
338
+ end if;
339
+
340
+ -- #397 ROUND 3 — THE DECISION: ONE BASELINE, ONE INSTANT.
341
+ --
342
+ -- Every earlier variant of this guard compared two ranks captured at DIFFERENT times, and each
343
+ -- round closed one instance of that shape while leaving the shape itself. This statement ends
344
+ -- the shape: it reads the landed domain's rank and the source domain's rank TOGETHER, in one
345
+ -- statement, hence from one snapshot, AFTER the move, while we hold the machine row.
346
+ --
347
+ -- What that pins by construction, rather than by enumerating interleavings:
348
+ -- * the machine's identity and its SOURCE domain id cannot change under us — we have held the
349
+ -- row since before the upsert (that is round 2's fix, and it is what makes `is distinct from`
350
+ -- mean "this statement relocated the machine");
351
+ -- * the LANDED domain id is the one our own upsert wrote, under that same lock;
352
+ -- * the two lifecycle values are read by ONE STATEMENT, hence from ONE SNAPSHOT, so neither a
353
+ -- demotion of the destination nor a promotion of the source can sit BETWEEN them. This is a
354
+ -- property of the single statement and NOT of the absence of a wait: READ COMMITTED lets
355
+ -- another transaction commit between successive COMMANDS, so splitting this select in two
356
+ -- would give the two reads two snapshots and reopen the cross-time comparison every earlier
357
+ -- round of #397 closed one instance of. No behavioural test can see that split — both reads
358
+ -- agree single-session — so it is gated STRUCTURALLY by assertion 28 of
359
+ -- agent_board_fleet_machine_hijack.test.sql, which requires both rank calls and the
360
+ -- `into v_landed_rank, v_source_rank` target list to be reachable without crossing a `;`.
361
+ -- DO NOT split this statement.
362
+ -- Nothing else feeds the decision. v_dest_rank is a pre-filter on the upsert's WHERE and is NOT
363
+ -- authoritative: it can only make the statement refuse EARLIER, never permit something this
364
+ -- check would refuse.
365
+ --
366
+ -- SURVIVING CLASS, stated exactly: a lifecycle change that commits AFTER this read. It is
367
+ -- indistinguishable from one serialized after the whole registration, i.e. from `register` then
368
+ -- `demote`, which is permitted and deliberately unchanged. Excluding it needs a lock on
369
+ -- fleet_trust_domains, which is #159's to add (see the pre-read for why it is not taken here).
370
+ if v_prior_domain is not null and v_machine.trust_domain_id is distinct from v_prior_domain then
371
+ select agent_board._fleet_trust_domain_approval_rank(landed.lifecycle_state),
372
+ agent_board._fleet_trust_domain_approval_rank(source.lifecycle_state)
373
+ into v_landed_rank, v_source_rank
374
+ from agent_board.fleet_trust_domains landed,
375
+ agent_board.fleet_trust_domains source
376
+ where landed.id = v_machine.trust_domain_id
377
+ and source.id = v_prior_domain;
378
+
379
+ if v_landed_rank is null or v_source_rank is null or v_landed_rank < v_source_rank then
380
+ raise exception 're-registration would move machine into a less approved trust domain (destination %, current %) — measured after the move, from one snapshot',
381
+ v_machine.trust_domain_id, v_prior_domain using errcode = '42501';
382
+ end if;
383
+ end if;
384
+
385
+ return query select v_machine.id, v_machine.trust_domain_id, v_machine.lifecycle_state, v_machine.enrolled_at;
386
+ end;
387
+ $$;
388
+
389
+ revoke all on function agent_board.register_fleet_machine(uuid, uuid, text, text, text, jsonb) from public, anon;
390
+ grant execute on function agent_board.register_fleet_machine(uuid, uuid, text, text, text, jsonb) to authenticated, service_role;
391
+
392
+ comment on function agent_board.register_fleet_machine(uuid, uuid, text, text, text, jsonb) is
393
+ 'Registers/updates a fleet machine for the resolved actor. Anti-hijack (#141): a fingerprint conflict owned by a different owner raises 42501 (ownership is immutable via re-register); re-register never resurrects a revoked machine (revocation authority lives in fleet_revocation_events). No-downgrade (#395): a re-registration may not MOVE the machine into a trust domain LESS APPROVED than its current one (approval rank: active > pending > quarantined = suspended = revoked, per _fleet_trust_domain_approval_rank) and raises 42501 if it would; an equal-rank relocation, an upgrade, and a first registration are unaffected. An unranked lifecycle value refuses rather than fails open. Concurrency (#397): the rank comparison is a conjunct of the ON CONFLICT DO UPDATE, so it is atomic against the conflicting MACHINE row, but the destination domain''s lifecycle cannot be pinned without a fleet_trust_domains lock (reverted once for deadlocking against reclaim_fleet_machine; #159 owns the lock order). A demotion that commits while the upsert waits is caught by a post-move re-check in a fresh statement, which raises 42501 and unwinds the move; a demotion committing after that check still lands and is serial-equivalent to register-then-demote, which is permitted.';