@consiliency/agent-board-schema 1.44.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.
- package/fixtures/already-applied.json +16 -0
- package/migrations/20260925000100_agent_board_ownerless_renew_ttl_cap.sql +166 -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/package.json +1 -1
- package/schema-manifest.json +39 -3
- package/src/tooling.test.mjs +35 -2
- 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 +400 -0
- package/tests/agent_board_ownerless_renew_quota.test.sql +551 -0
- package/tests/agent_board_ownerless_renew_ttl.test.sql +226 -0
- package/tests/catalog/p_metadata_parameters.json +12 -0
- package/tests/isolation/expected/fleet_domain_no_downgrade_demote_race.out +447 -0
- package/tests/isolation/specs/fleet_domain_no_downgrade_demote_race.spec +292 -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.';
|