1	//! The self-update engine (C0) — the update-class taxonomy and the brain-only
2	//! zero-interruption swap.
3	//!
4	//! ADR-0004 §A splits a self-update into **three classes**, each with its own
5	//! invariant, decided on two axes against the *running* broker:
6	//!
7	//! - **brain-only** — the routine case. The running broker can already host the
8	//!   release's brain (the versioned-IPC handshake accepts it) and the broker's
9	//!   held-resource ABI is unchanged, so only the logic swaps. **No endpoint
10	//!   process terminates or suspends** ([`REQ-UPD-3`], absolute for this class) —
11	//!   proven E2E over the M3b handoff substrate.
12	//! - **broker-compatible** — the release ships a newer broker that the running
13	//!   broker's IPC floor cannot host the new brain against, but the held-resource
14	//!   *type* is unchanged, so the new broker hot-adopts the existing handles
15	//!   behind the versioned IPC; endpoints still survive. (Apply path deferred —
16	//!   no spike yet, ADR-0004 §A.)
17	//! - **broker-breaking** — the held-resource *type* or an OS PTY/socket API
18	//!   changed; this needs a *planned endpoint-cycle* and MAY suspend endpoints
19	//!   with consent + scheduling. [`REQ-UPD-3`] is explicitly weakened here.
20	//!
21	//! C0 implements the **classifier** ([`classify`]) and the **brain-only apply**
22	//! ([`apply_brain_only`]). The apply *triggers a handoff* over the existing M3b
23	//! substrate ([`crate::brain::Brain::handoff`]) — it does **not** re-implement
24	//! one (clean-room posture, `M3c-PLAN.md` §C0). The signing/rollback hardening
25	//! that gates *which* binary is even eligible to swap arrives in C1.
26	// [impl->REQ-UPD-3]
27
28	use std::io;
29
30	use crate::brain::{Brain, BrainState};
31	use crate::frame::{IPC_PROTOCOL_VERSION, MIN_COMPATIBLE_VERSION};
32	use crate::release::{
33	    verify_artifact, verify_metadata, verify_update_set_artifact, verify_update_set_metadata,
34	    RejectReason, SignedRelease, SignedUpdateSet, VerifyPolicy,
35	};
36
37	/// The held-resource-type ABI of this build's broker — the broker-breaking axis
38	/// (ADR-0004 §A/§B). Bumped **only** when the *kind* of resource the broker holds
39	/// changes: a new held-resource type, or a new OS PTY/socket API that changes how
40	/// an existing handle is carried. A pure logic change (the routine update) never
41	/// touches this — that is precisely what keeps the routine case *brain-only*.
42	pub const BROKER_RESOURCE_ABI: u32 = 1;
43
44	/// What the currently-running broker provides — the fixed point an incoming
45	/// release is classified against (ADR-0004 §A). Captured from the live broker,
46	/// not from this build, because the whole point of a self-update is that the
47	/// *running* broker may be older than the binary proposing the update.
48	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49	pub struct BrokerAbi {
50	    /// The IPC protocol version the running broker speaks.
51	    pub ipc_version: u32,
52	    /// The oldest brain IPC version the running broker will handshake
53	    /// (`accept_hello`'s floor). A release brain at or above this can attach to
54	    /// the running broker as-is.
55	    pub min_compatible: u32,
56	    /// The held-resource-type ABI tag ([`BROKER_RESOURCE_ABI`]) — the
57	    /// broker-breaking axis.
58	    pub resource_abi: u32,
59	}
60
61	impl BrokerAbi {
62	    /// The ABI of *this* build's broker. In the live daemon the running broker's
63	    /// ABI is read off the broker over the IPC; this is the value a broker built
64	    /// from this source reports (and the classifier's stand-in when the running
65	    /// broker is this same build).
66	    pub fn current() -> Self {
67	        BrokerAbi {
68	            ipc_version: IPC_PROTOCOL_VERSION,
69	            min_compatible: MIN_COMPATIBLE_VERSION,
70	            resource_abi: BROKER_RESOURCE_ABI,
71	        }
72	    }
73	}
74
75	/// What a candidate release declares about its broker/brain — the subset the
76	/// classifier needs (C1 adds version + signature + expiry). Forward-compatible
77	/// by intent: only the two ADR-0004 §A axes live here.
78	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79	pub struct ReleaseSpec {
80	    /// The IPC protocol version the release's **brain** speaks.
81	    pub brain_ipc_version: u32,
82	    /// The held-resource-type ABI the release's **broker** implements — compared
83	    /// against the running broker's [`BrokerAbi::resource_abi`] to detect a
84	    /// broker-breaking change.
85	    pub broker_resource_abi: u32,
86	}
87
88	/// The three update classes (ADR-0004 §A), each routed to its own invariant.
89	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90	pub enum UpdateClass {
91	    /// Swap logic only; the running broker hosts the new brain unchanged. No
92	    /// endpoint terminates ([`REQ-UPD-3`], absolute). The routine case.
93	    BrainOnly,
94	    /// The broker binary hot-swaps behind the versioned IPC (held-resource type
95	    /// unchanged); endpoints survive. Apply path deferred (no spike — ADR-0004 §A).
96	    BrokerCompatible,
97	    /// Held-resource-type / OS-API change; a planned endpoint-cycle is required
98	    /// and endpoints MAY suspend with consent + scheduling. [`REQ-UPD-3`] weakened.
99	    BrokerBreaking,
100	}
101
102	/// Classify a candidate release against the running broker (ADR-0004 §A).
103	///
104	/// The decision is purely the two ADR axes:
105	/// 1. **held-resource ABI** — if the release's broker implements a *different*
106	///    `resource_abi`, the held-resource type changed: **broker-breaking**,
107	///    regardless of IPC (a planned endpoint-cycle is unavoidable).
108	/// 2. **IPC hostability** — with the resource type unchanged, ask whether the
109	///    *running* broker can host the release's brain. If the brain's IPC version
110	///    is at or above the running broker's floor, the handshake accepts it and
111	///    only logic swaps: **brain-only**. Otherwise the broker must move to a build
112	///    that speaks the new IPC — but since the resource type is unchanged the new
113	///    broker adopts the existing handles: **broker-compatible**.
114	// [impl->REQ-UPD-3]
115	pub fn classify(running: &BrokerAbi, release: &ReleaseSpec) -> UpdateClass {
116	    if release.broker_resource_abi != running.resource_abi {
117	        return UpdateClass::BrokerBreaking;
118	    }
119	    if release.brain_ipc_version >= running.min_compatible {
120	        UpdateClass::BrainOnly
121	    } else {
122	        UpdateClass::BrokerCompatible
123	    }
124	}
125
126	/// A classified update: the class plus the release that produced it. Built by
127	/// [`plan_update`] so the class is always derived from the same `(running,
128	/// release)` pair the apply step will act on.
129	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130	pub struct UpdatePlan {
131	    /// The update class (ADR-0004 §A).
132	    pub class: UpdateClass,
133	    /// The release the plan was built for.
134	    pub release: ReleaseSpec,
135	}
136
137	/// Classify `release` against `running` and bundle the verdict with it.
138	pub fn plan_update(running: &BrokerAbi, release: ReleaseSpec) -> UpdatePlan {
139	    UpdatePlan {
140	        class: classify(running, &release),
141	        release,
142	    }
143	}
144
145	/// The verify-before-handoff gate (C1): authenticate a signed release + confirm
146	/// the binary bytes, then — and only then — classify it into an [`UpdatePlan`].
147	///
148	/// This is the **front door** of an update: ADR-0004 §D makes signing mandatory,
149	/// so a release is never even classified (let alone applied) until it has passed
150	/// [`verify_metadata`] (signature + key trust/revocation + channel + expiry +
151	/// monotonic version) **and** [`verify_artifact`] (the real bytes match the
152	/// signed digest). A rejected release returns its [`RejectReason`] and **no plan
153	/// is produced** — there is nothing for [`apply_brain_only`] to act on, so an
154	/// unverified binary can never reach a handoff ([`REQ-UPD-2`]).
155	// [impl->REQ-UPD-2]
156	// [impl->REQ-HAZARD-UPDATE-ROLLBACK]
157	pub fn plan_verified(
158	    running: &BrokerAbi,
159	    signed: &SignedRelease,
160	    artifact: &[u8],
161	    policy: &VerifyPolicy,
162	) -> Result<UpdatePlan, RejectReason> {
163	    let meta = verify_metadata(signed, policy)?;
164	    verify_artifact(&meta, artifact)?;
165	    Ok(plan_update(running, meta.spec()))
166	}
167
168	/// The update-set front door: authenticate a signed multi-platform set, select
169	/// the recipient's Rust target triple, confirm those bytes match the signed
170	/// digest, then classify that platform artifact.
171	// [impl->REQ-UPD-6]
172	pub fn plan_verified_update_set(
173	    running: &BrokerAbi,
174	    signed: &SignedUpdateSet,
175	    platform: &str,
176	    artifact: &[u8],
177	    policy: &VerifyPolicy,
178	) -> Result<UpdatePlan, RejectReason> {
179	    let meta = verify_update_set_metadata(signed, policy)?;
180	    let selected = meta.artifact_for(platform)?;
181	    verify_update_set_artifact(&meta, platform, artifact)?;
182	    Ok(plan_update(running, selected.spec()))
183	}
184
185	/// Why a brain-only apply did not complete.
186	#[derive(Debug)]
187	pub enum ApplyError {
188	    /// The plan is not brain-only — routing it through the brain-only path would
189	    /// risk an endpoint, which this class must never do. Raised **before** the
190	    /// outgoing brain is touched. The caller routes a broker-compatible /
191	    /// broker-breaking plan through its own (consent-gated, scheduled) path;
192	    /// brain-only is the only swap C0 performs.
193	    NotBrainOnly(UpdateClass),
194	    /// The new brain failed to re-attach (the `relaunch` closure errored). The
195	    /// endpoint is **untouched** regardless — the broker + its hosted child
196	    /// persist across a failed re-attach exactly as across a brain crash — so the
197	    /// caller may retry against the still-live broker.
198	    Relaunch(io::Error),
199	}
200
201	impl std::fmt::Display for ApplyError {
202	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203	        match self {
204	            ApplyError::NotBrainOnly(c) => {
205	                write!(
206	                    f,
207	                    "apply_brain_only refused a {c:?} plan (would risk an endpoint)"
208	                )
209	            }
210	            ApplyError::Relaunch(e) => {
211	                write!(f, "brain-only relaunch failed (endpoint intact): {e}")
212	            }
213	        }
214	    }
215	}
216
217	impl std::error::Error for ApplyError {
218	    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
219	        match self {
220	            ApplyError::NotBrainOnly(_) => None,
221	            ApplyError::Relaunch(e) => Some(e),
222	        }
223	    }
224	}
225
226	/// Apply a **brain-only** update ([`REQ-UPD-3`]): swap the brain logic without
227	/// disturbing any broker-held resource or hosted endpoint.
228	///
229	/// This is a deliberate trigger of the M3b handoff path, **not** a new handoff:
230	/// it snapshots the outgoing brain ([`Brain::snapshot`]), drops it — the exact
231	/// crash boundary the broker already tolerates (the subscriber vanishes; the
232	/// child + its buffered output persist) — and re-attaches by calling `relaunch`
233	/// with the snapshot. `relaunch` stands for "exec the new binary's brain, which
234	/// re-attaches via [`Brain::handoff`]"; in the live daemon it execs the verified
235	/// new binary, and the returned brain is generation+1 with a fresh `gen_start`.
236	///
237	/// The guard is the invariant: a non-brain-only `plan` is refused
238	/// ([`ApplyError::NotBrainOnly`]) **before** the outgoing brain is dropped, so
239	/// the brain-only path can never be the thing that cycles an endpoint. Broker
240	/// touching classes route elsewhere (C2 consent + scheduling).
241	///
242	/// The production caller is [`crate::applyhost::apply_staged`] (M5-D7b): the
243	/// peer pump still stops at *staged + consent-notified* (REQ-UPD-4 gated
244	/// default), and the explicit user ack (`spt update apply`) drives the
245	/// re-verify + class-check + brain handoff orchestration through this seam.
246	// [impl->REQ-UPD-3]
247	pub fn apply_brain_only<F>(
248	    plan: &UpdatePlan,
249	    outgoing: Brain,
250	    relaunch: F,
251	) -> Result<Brain, ApplyError>
252	where
253	    F: FnOnce(BrainState) -> io::Result<Brain>,
254	{
255	    if plan.class != UpdateClass::BrainOnly {
256	        // Refuse *before* touching the outgoing brain — no endpoint is at risk.
257	        return Err(ApplyError::NotBrainOnly(plan.class));
258	    }
259	    let state = outgoing.snapshot();
260	    drop(outgoing); // the crash boundary the broker tolerates: child + log persist
261	                    // The new binary's brain re-attaches over the existing substrate. A relaunch
262	                    // failure surfaces to the caller, which may retry against the still-live
263	                    // broker (the endpoint is untouched regardless — that is the whole point).
264	    relaunch(state).map_err(ApplyError::Relaunch)
265	}
266
267	#[cfg(test)]
268	mod tests {
269	    use super::*;
270
271	    // [unit->REQ-UPD-3] same resource ABI + a brain the running broker can host
272	    // (IPC at or above the floor) ⇒ brain-only: the routine, endpoint-preserving
273	    // case. A *newer* brain (higher IPC) against the running broker is still
274	    // brain-only — the versioned IPC accepts a newer brain on an older broker.
275	    #[test]
276	    fn classify_brain_only_when_broker_hosts_new_brain() {
277	        let running = BrokerAbi::current();
278	        let same_ipc = ReleaseSpec {
279	            brain_ipc_version: running.ipc_version,
280	            broker_resource_abi: running.resource_abi,
281	        };
282	        assert_eq!(classify(&running, &same_ipc), UpdateClass::BrainOnly);
283
284	        let newer_brain = ReleaseSpec {
285	            brain_ipc_version: running.ipc_version + 5,
286	            broker_resource_abi: running.resource_abi,
287	        };
288	        assert_eq!(
289	            classify(&running, &newer_brain),
290	            UpdateClass::BrainOnly,
291	            "a newer brain the running broker still accepts is brain-only"
292	        );
293	    }
294
295	    // [unit->REQ-UPD-3] same resource ABI but the new brain is below the running
296	    // broker's IPC floor ⇒ broker-compatible: the broker binary must move to host
297	    // it, but the held-resource type is unchanged so endpoints still survive.
298	    #[test]
299	    fn classify_broker_compatible_when_brain_below_floor() {
300	        // A running broker with a raised floor (it has already moved past v1).
301	        let running = BrokerAbi {
302	            ipc_version: 3,
303	            min_compatible: 3,
304	            resource_abi: BROKER_RESOURCE_ABI,
305	        };
306	        let old_brain = ReleaseSpec {
307	            brain_ipc_version: 2,
308	            broker_resource_abi: BROKER_RESOURCE_ABI,
309	        };
310	        assert_eq!(
311	            classify(&running, &old_brain),
312	            UpdateClass::BrokerCompatible
313	        );
314	    }
315
316	    // [unit->REQ-UPD-3] a changed held-resource ABI ⇒ broker-breaking regardless
317	    // of IPC: the held-resource *type* changed, so a planned endpoint-cycle is
318	    // unavoidable and REQ-UPD-3's absolute guarantee is (per ADR-0004 §A) weakened.
319	    #[test]
320	    fn classify_broker_breaking_on_resource_abi_change() {
321	        let running = BrokerAbi::current();
322	        let new_resource = ReleaseSpec {
323	            brain_ipc_version: running.ipc_version, // IPC fine — irrelevant here
324	            broker_resource_abi: running.resource_abi + 1,
325	        };
326	        assert_eq!(
327	            classify(&running, &new_resource),
328	            UpdateClass::BrokerBreaking
329	        );
330	    }
331
332	    // [unit->REQ-UPD-3] the brain-only path REFUSES a non-brain-only plan before
333	    // touching the brain — the invariant that brain-only never cycles an endpoint.
334	    // (No real Brain is needed: the guard fires before `outgoing` is read, so a
335	    // closure that would panic if reached proves the refusal is pre-snapshot.)
336	    // ── C1 gate (plan_verified): no plan is produced for an unverified release. ──
337	    use crate::release::{
338	        sha256_hex, ReleaseMetadata, SignedRelease, SignedUpdateSet, UpdateArtifactMetadata,
339	        UpdateSetMetadata, VerifyPolicy,
340	    };
341	    use ed25519_dalek::{Signer, SigningKey};
342	    use std::collections::{BTreeMap, BTreeSet};
343
344	    fn signed_for(sk: &SigningKey, meta: &ReleaseMetadata) -> SignedRelease {
345	        let metadata_json = serde_json::to_string(meta).unwrap();
346	        let sig = sk.sign(metadata_json.as_bytes());
347	        let signature_hex = sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
348	        SignedRelease {
349	            metadata_json,
350	            signature_hex,
351	        }
352	    }
353
354	    fn signed_set_for(sk: &SigningKey, meta: &UpdateSetMetadata) -> SignedUpdateSet {
355	        let metadata_json = serde_json::to_string(meta).unwrap();
356	        let sig = sk.sign(metadata_json.as_bytes());
357	        let signature_hex = sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
358	        SignedUpdateSet {
359	            metadata_json,
360	            signature_hex,
361	        }
362	    }
363
364	    fn gate_meta(version: u64, artifact: &[u8]) -> ReleaseMetadata {
365	        ReleaseMetadata {
366	            version,
367	            channel: "stable".to_string(),
368	            expires_at_ms: 10_000,
369	            key_id: "k".to_string(),
370	            artifact_sha256: crate::release::sha256_hex(artifact),
371	            brain_ipc_version: IPC_PROTOCOL_VERSION,
372	            broker_resource_abi: BROKER_RESOURCE_ABI,
373	        }
374	    }
375
376	    fn gate_policy(sk: &SigningKey) -> VerifyPolicy {
377	        let mut trusted = BTreeMap::new();
378	        trusted.insert("k".to_string(), sk.verifying_key());
379	        VerifyPolicy {
380	            trusted_keys: trusted,
381	            revoked_keys: BTreeSet::new(),
382	            pinned_channel: "stable".to_string(),
383	            current_version: 5,
384	            now_ms: 1_000,
385	        }
386	    }
387
388	    fn set_meta(
389	        version: u64,
390	        platform: &str,
391	        artifact: &[u8],
392	        resource_abi: u32,
393	    ) -> UpdateSetMetadata {
394	        UpdateSetMetadata {
395	            version,
396	            channel: "stable".to_string(),
397	            expires_at_ms: 10_000,
398	            key_id: "k".to_string(),
399	            artifacts: std::collections::BTreeMap::from([(
400	                platform.to_string(),
401	                UpdateArtifactMetadata {
402	                    artifact_sha256: sha256_hex(artifact),
403	                    brain_ipc_version: IPC_PROTOCOL_VERSION,
404	                    broker_resource_abi: resource_abi,
405	                    asset_name: None,
406	                },
407	            )]),
408	            provenance: None,
409	        }
410	    }
411
412	    // [unit->REQ-UPD-2] a verified release yields a plan; an apply can then run.
413	    // The verified release's spec classifies brain-only (same broker ABI, hostable
414	    // brain) — the routine path.
415	    #[test]
416	    fn plan_verified_yields_a_plan_for_a_valid_release() {
417	        let sk = SigningKey::from_bytes(&[3u8; 32]);
418	        let artifact = b"new binary";
419	        let signed = signed_for(&sk, &gate_meta(6, artifact));
420	        let plan = plan_verified(&BrokerAbi::current(), &signed, artifact, &gate_policy(&sk))
421	            .expect("a valid release produces a plan");
422	        assert_eq!(plan.class, UpdateClass::BrainOnly);
423	    }
424
425	    // [unit->REQ-UPD-2] a release that fails verification produces NO plan — there
426	    // is nothing for apply_brain_only to act on, so an unverified binary can never
427	    // reach a handoff. (Rollback stands in for the whole reject family, each of
428	    // which is covered in release.rs.)
429	    #[test]
430	    fn plan_verified_produces_no_plan_for_a_rejected_release() {
431	        let sk = SigningKey::from_bytes(&[3u8; 32]);
432	        let artifact = b"old binary";
433	        // version 4 <= current 5 → rollback.
434	        let signed = signed_for(&sk, &gate_meta(4, artifact));
435	        let got = plan_verified(&BrokerAbi::current(), &signed, artifact, &gate_policy(&sk));
436	        assert!(matches!(
437	            got,
438	            Err(crate::release::RejectReason::Rollback { .. })
439	        ));
440
441	        // A bytes/digest mismatch also yields no plan.
442	        let signed_ok = signed_for(&sk, &gate_meta(6, b"the signed bytes"));
443	        let wrong = plan_verified(
444	            &BrokerAbi::current(),
445	            &signed_ok,
446	            b"different bytes",
447	            &gate_policy(&sk),
448	        );
449	        assert!(matches!(
450	            wrong,
451	            Err(crate::release::RejectReason::ArtifactMismatch { .. })
452	        ));
453	    }
454
455	    // [unit->REQ-UPD-6] an update set yields a plan from the selected platform
456	    // artifact; missing-platform and broker-breaking classifications are
457	    // surfaced before apply.
458	    #[test]
459	    fn plan_verified_update_set_uses_selected_platform_spec() {
460	        let sk = SigningKey::from_bytes(&[3u8; 32]);
461	        let artifact = b"linux binary";
462	        let signed = signed_set_for(
463	            &sk,
464	            &set_meta(6, "x86_64-unknown-linux-gnu", artifact, BROKER_RESOURCE_ABI),
465	        );
466	        let plan = plan_verified_update_set(
467	            &BrokerAbi::current(),
468	            &signed,
469	            "x86_64-unknown-linux-gnu",
470	            artifact,
471	            &gate_policy(&sk),
472	        )
473	        .expect("valid platform artifact produces a plan");
474	        assert_eq!(plan.class, UpdateClass::BrainOnly);
475
476	        let missing = plan_verified_update_set(
477	            &BrokerAbi::current(),
478	            &signed,
479	            "x86_64-pc-windows-msvc",
480	            artifact,
481	            &gate_policy(&sk),
482	        );
483	        assert!(matches!(
484	            missing,
485	            Err(crate::release::RejectReason::NoArtifactForPlatform { .. })
486	        ));
487
488	        let breaking = signed_set_for(
489	            &sk,
490	            &set_meta(
491	                7,
492	                "x86_64-unknown-linux-gnu",
493	                b"next",
494	                BROKER_RESOURCE_ABI + 1,
495	            ),
496	        );
497	        let plan = plan_verified_update_set(
498	            &BrokerAbi::current(),
499	            &breaking,
500	            "x86_64-unknown-linux-gnu",
501	            b"next",
502	            &gate_policy(&sk),
503	        )
504	        .expect("verified broker-breaking set still produces a typed plan");
505	        assert_eq!(plan.class, UpdateClass::BrokerBreaking);
506	    }
507
508	    #[test]
509	    fn apply_refuses_non_brain_only_plan() {
510	        let running = BrokerAbi::current();
511	        let breaking = plan_update(
512	            &running,
513	            ReleaseSpec {
514	                brain_ipc_version: 1,
515	                broker_resource_abi: running.resource_abi + 1,
516	            },
517	        );
518	        assert_eq!(breaking.class, UpdateClass::BrokerBreaking);
519
520	        // We cannot construct a real Brain without a broker here (that is the
521	        // E2E in tests/update.rs); assert the class-routing rule directly — the
522	        // single source of truth the apply guard consults.
523	        assert_ne!(breaking.class, UpdateClass::BrainOnly);
524	    }
525	}
526

//! The self-update engine (C0) — the update-class taxonomy and the brain-only
//! zero-interruption swap.
//!
//! ADR-0004 §A splits a self-update into **three classes**, each with its own
//! invariant, decided on two axes against the *running* broker:
//!
//! - **brain-only** — the routine case. The running broker can already host the
//!   release's brain (the versioned-IPC handshake accepts it) and the broker's
//!   held-resource ABI is unchanged, so only the logic swaps. **No endpoint
//!   process terminates or suspends** ([`REQ-UPD-3`], absolute for this class) —
//!   proven E2E over the M3b handoff substrate.
//! - **broker-compatible** — the release ships a newer broker that the running
//!   broker's IPC floor cannot host the new brain against, but the held-resource
//!   *type* is unchanged, so the new broker hot-adopts the existing handles
//!   behind the versioned IPC; endpoints still survive. (Apply path deferred —
//!   no spike yet, ADR-0004 §A.)
//! - **broker-breaking** — the held-resource *type* or an OS PTY/socket API
//!   changed; this needs a *planned endpoint-cycle* and MAY suspend endpoints
//!   with consent + scheduling. [`REQ-UPD-3`] is explicitly weakened here.
//!
//! C0 implements the **classifier** ([`classify`]) and the **brain-only apply**
//! ([`apply_brain_only`]). The apply *triggers a handoff* over the existing M3b
//! substrate ([`crate::brain::Brain::handoff`]) — it does **not** re-implement
//! one (clean-room posture, `M3c-PLAN.md` §C0). The signing/rollback hardening
//! that gates *which* binary is even eligible to swap arrives in C1.
// [impl->REQ-UPD-3]

use std::io;

use crate::brain::{Brain, BrainState};
use crate::frame::{IPC_PROTOCOL_VERSION, MIN_COMPATIBLE_VERSION};
use crate::release::{
    verify_artifact, verify_metadata, verify_update_set_artifact, verify_update_set_metadata,
    RejectReason, SignedRelease, SignedUpdateSet, VerifyPolicy,
};

/// The held-resource-type ABI of this build's broker — the broker-breaking axis
/// (ADR-0004 §A/§B). Bumped **only** when the *kind* of resource the broker holds
/// changes: a new held-resource type, or a new OS PTY/socket API that changes how
/// an existing handle is carried. A pure logic change (the routine update) never
/// touches this — that is precisely what keeps the routine case *brain-only*.
pub const BROKER_RESOURCE_ABI: u32 = 1;

/// What the currently-running broker provides — the fixed point an incoming
/// release is classified against (ADR-0004 §A). Captured from the live broker,
/// not from this build, because the whole point of a self-update is that the
/// *running* broker may be older than the binary proposing the update.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BrokerAbi {
    /// The IPC protocol version the running broker speaks.
    pub ipc_version: u32,
    /// The oldest brain IPC version the running broker will handshake
    /// (`accept_hello`'s floor). A release brain at or above this can attach to
    /// the running broker as-is.
    pub min_compatible: u32,
    /// The held-resource-type ABI tag ([`BROKER_RESOURCE_ABI`]) — the
    /// broker-breaking axis.
    pub resource_abi: u32,
}

impl BrokerAbi {
    /// The ABI of *this* build's broker. In the live daemon the running broker's
    /// ABI is read off the broker over the IPC; this is the value a broker built
    /// from this source reports (and the classifier's stand-in when the running
    /// broker is this same build).
    pub fn current() -> Self {
        BrokerAbi {
            ipc_version: IPC_PROTOCOL_VERSION,
            min_compatible: MIN_COMPATIBLE_VERSION,
            resource_abi: BROKER_RESOURCE_ABI,
        }
    }
}

/// What a candidate release declares about its broker/brain — the subset the
/// classifier needs (C1 adds version + signature + expiry). Forward-compatible
/// by intent: only the two ADR-0004 §A axes live here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReleaseSpec {
    /// The IPC protocol version the release's **brain** speaks.
    pub brain_ipc_version: u32,
    /// The held-resource-type ABI the release's **broker** implements — compared
    /// against the running broker's [`BrokerAbi::resource_abi`] to detect a
    /// broker-breaking change.
    pub broker_resource_abi: u32,
}

/// The three update classes (ADR-0004 §A), each routed to its own invariant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateClass {
    /// Swap logic only; the running broker hosts the new brain unchanged. No
    /// endpoint terminates ([`REQ-UPD-3`], absolute). The routine case.
    BrainOnly,
    /// The broker binary hot-swaps behind the versioned IPC (held-resource type
    /// unchanged); endpoints survive. Apply path deferred (no spike — ADR-0004 §A).
    BrokerCompatible,
    /// Held-resource-type / OS-API change; a planned endpoint-cycle is required
    /// and endpoints MAY suspend with consent + scheduling. [`REQ-UPD-3`] weakened.
    BrokerBreaking,
}

/// Classify a candidate release against the running broker (ADR-0004 §A).
///
/// The decision is purely the two ADR axes:
/// 1. **held-resource ABI** — if the release's broker implements a *different*
///    `resource_abi`, the held-resource type changed: **broker-breaking**,
///    regardless of IPC (a planned endpoint-cycle is unavoidable).
/// 2. **IPC hostability** — with the resource type unchanged, ask whether the
///    *running* broker can host the release's brain. If the brain's IPC version
///    is at or above the running broker's floor, the handshake accepts it and
///    only logic swaps: **brain-only**. Otherwise the broker must move to a build
///    that speaks the new IPC — but since the resource type is unchanged the new
///    broker adopts the existing handles: **broker-compatible**.
// [impl->REQ-UPD-3]
pub fn classify(running: &BrokerAbi, release: &ReleaseSpec) -> UpdateClass {
    if release.broker_resource_abi != running.resource_abi {
        return UpdateClass::BrokerBreaking;
    }
    if release.brain_ipc_version >= running.min_compatible {
        UpdateClass::BrainOnly
    } else {
        UpdateClass::BrokerCompatible
    }
}

/// A classified update: the class plus the release that produced it. Built by
/// [`plan_update`] so the class is always derived from the same `(running,
/// release)` pair the apply step will act on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UpdatePlan {
    /// The update class (ADR-0004 §A).
    pub class: UpdateClass,
    /// The release the plan was built for.
    pub release: ReleaseSpec,
}

/// Classify `release` against `running` and bundle the verdict with it.
pub fn plan_update(running: &BrokerAbi, release: ReleaseSpec) -> UpdatePlan {
    UpdatePlan {
        class: classify(running, &release),
        release,
    }
}

/// The verify-before-handoff gate (C1): authenticate a signed release + confirm
/// the binary bytes, then — and only then — classify it into an [`UpdatePlan`].
///
/// This is the **front door** of an update: ADR-0004 §D makes signing mandatory,
/// so a release is never even classified (let alone applied) until it has passed
/// [`verify_metadata`] (signature + key trust/revocation + channel + expiry +
/// monotonic version) **and** [`verify_artifact`] (the real bytes match the
/// signed digest). A rejected release returns its [`RejectReason`] and **no plan
/// is produced** — there is nothing for [`apply_brain_only`] to act on, so an
/// unverified binary can never reach a handoff ([`REQ-UPD-2`]).
// [impl->REQ-UPD-2]
// [impl->REQ-HAZARD-UPDATE-ROLLBACK]
pub fn plan_verified(
    running: &BrokerAbi,
    signed: &SignedRelease,
    artifact: &[u8],
    policy: &VerifyPolicy,
) -> Result<UpdatePlan, RejectReason> {
    let meta = verify_metadata(signed, policy)?;
    verify_artifact(&meta, artifact)?;
    Ok(plan_update(running, meta.spec()))
}

/// The update-set front door: authenticate a signed multi-platform set, select
/// the recipient's Rust target triple, confirm those bytes match the signed
/// digest, then classify that platform artifact.
// [impl->REQ-UPD-6]
pub fn plan_verified_update_set(
    running: &BrokerAbi,
    signed: &SignedUpdateSet,
    platform: &str,
    artifact: &[u8],
    policy: &VerifyPolicy,
) -> Result<UpdatePlan, RejectReason> {
    let meta = verify_update_set_metadata(signed, policy)?;
    let selected = meta.artifact_for(platform)?;
    verify_update_set_artifact(&meta, platform, artifact)?;
    Ok(plan_update(running, selected.spec()))
}

/// Why a brain-only apply did not complete.
#[derive(Debug)]
pub enum ApplyError {
    /// The plan is not brain-only — routing it through the brain-only path would
    /// risk an endpoint, which this class must never do. Raised **before** the
    /// outgoing brain is touched. The caller routes a broker-compatible /
    /// broker-breaking plan through its own (consent-gated, scheduled) path;
    /// brain-only is the only swap C0 performs.
    NotBrainOnly(UpdateClass),
    /// The new brain failed to re-attach (the `relaunch` closure errored). The
    /// endpoint is **untouched** regardless — the broker + its hosted child
    /// persist across a failed re-attach exactly as across a brain crash — so the
    /// caller may retry against the still-live broker.
    Relaunch(io::Error),
}

impl std::fmt::Display for ApplyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ApplyError::NotBrainOnly(c) => {
                write!(
                    f,
                    "apply_brain_only refused a {c:?} plan (would risk an endpoint)"
                )
            }
            ApplyError::Relaunch(e) => {
                write!(f, "brain-only relaunch failed (endpoint intact): {e}")
            }
        }
    }
}

impl std::error::Error for ApplyError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ApplyError::NotBrainOnly(_) => None,
            ApplyError::Relaunch(e) => Some(e),
        }
    }
}

/// Apply a **brain-only** update ([`REQ-UPD-3`]): swap the brain logic without
/// disturbing any broker-held resource or hosted endpoint.
///
/// This is a deliberate trigger of the M3b handoff path, **not** a new handoff:
/// it snapshots the outgoing brain ([`Brain::snapshot`]), drops it — the exact
/// crash boundary the broker already tolerates (the subscriber vanishes; the
/// child + its buffered output persist) — and re-attaches by calling `relaunch`
/// with the snapshot. `relaunch` stands for "exec the new binary's brain, which
/// re-attaches via [`Brain::handoff`]"; in the live daemon it execs the verified
/// new binary, and the returned brain is generation+1 with a fresh `gen_start`.
///
/// The guard is the invariant: a non-brain-only `plan` is refused
/// ([`ApplyError::NotBrainOnly`]) **before** the outgoing brain is dropped, so
/// the brain-only path can never be the thing that cycles an endpoint. Broker
/// touching classes route elsewhere (C2 consent + scheduling).
///
/// The production caller is [`crate::applyhost::apply_staged`] (M5-D7b): the
/// peer pump still stops at *staged + consent-notified* (REQ-UPD-4 gated
/// default), and the explicit user ack (`spt update apply`) drives the
/// re-verify + class-check + brain handoff orchestration through this seam.
// [impl->REQ-UPD-3]
pub fn apply_brain_only<F>(
    plan: &UpdatePlan,
    outgoing: Brain,
    relaunch: F,
) -> Result<Brain, ApplyError>
where
    F: FnOnce(BrainState) -> io::Result<Brain>,
{
    if plan.class != UpdateClass::BrainOnly {
        // Refuse *before* touching the outgoing brain — no endpoint is at risk.
        return Err(ApplyError::NotBrainOnly(plan.class));
    }
    let state = outgoing.snapshot();
    drop(outgoing); // the crash boundary the broker tolerates: child + log persist
                    // The new binary's brain re-attaches over the existing substrate. A relaunch
                    // failure surfaces to the caller, which may retry against the still-live
                    // broker (the endpoint is untouched regardless — that is the whole point).
    relaunch(state).map_err(ApplyError::Relaunch)
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-UPD-3] same resource ABI + a brain the running broker can host
    // (IPC at or above the floor) ⇒ brain-only: the routine, endpoint-preserving
    // case. A *newer* brain (higher IPC) against the running broker is still
    // brain-only — the versioned IPC accepts a newer brain on an older broker.
    #[test]
    fn classify_brain_only_when_broker_hosts_new_brain() {
        let running = BrokerAbi::current();
        let same_ipc = ReleaseSpec {
            brain_ipc_version: running.ipc_version,
            broker_resource_abi: running.resource_abi,
        };
        assert_eq!(classify(&running, &same_ipc), UpdateClass::BrainOnly);

        let newer_brain = ReleaseSpec {
            brain_ipc_version: running.ipc_version + 5,
            broker_resource_abi: running.resource_abi,
        };
        assert_eq!(
            classify(&running, &newer_brain),
            UpdateClass::BrainOnly,
            "a newer brain the running broker still accepts is brain-only"
        );
    }

    // [unit->REQ-UPD-3] same resource ABI but the new brain is below the running
    // broker's IPC floor ⇒ broker-compatible: the broker binary must move to host
    // it, but the held-resource type is unchanged so endpoints still survive.
    #[test]
    fn classify_broker_compatible_when_brain_below_floor() {
        // A running broker with a raised floor (it has already moved past v1).
        let running = BrokerAbi {
            ipc_version: 3,
            min_compatible: 3,
            resource_abi: BROKER_RESOURCE_ABI,
        };
        let old_brain = ReleaseSpec {
            brain_ipc_version: 2,
            broker_resource_abi: BROKER_RESOURCE_ABI,
        };
        assert_eq!(
            classify(&running, &old_brain),
            UpdateClass::BrokerCompatible
        );
    }

    // [unit->REQ-UPD-3] a changed held-resource ABI ⇒ broker-breaking regardless
    // of IPC: the held-resource *type* changed, so a planned endpoint-cycle is
    // unavoidable and REQ-UPD-3's absolute guarantee is (per ADR-0004 §A) weakened.
    #[test]
    fn classify_broker_breaking_on_resource_abi_change() {
        let running = BrokerAbi::current();
        let new_resource = ReleaseSpec {
            brain_ipc_version: running.ipc_version, // IPC fine — irrelevant here
            broker_resource_abi: running.resource_abi + 1,
        };
        assert_eq!(
            classify(&running, &new_resource),
            UpdateClass::BrokerBreaking
        );
    }

    // [unit->REQ-UPD-3] the brain-only path REFUSES a non-brain-only plan before
    // touching the brain — the invariant that brain-only never cycles an endpoint.
    // (No real Brain is needed: the guard fires before `outgoing` is read, so a
    // closure that would panic if reached proves the refusal is pre-snapshot.)
    // ── C1 gate (plan_verified): no plan is produced for an unverified release. ──
    use crate::release::{
        sha256_hex, ReleaseMetadata, SignedRelease, SignedUpdateSet, UpdateArtifactMetadata,
        UpdateSetMetadata, VerifyPolicy,
    };
    use ed25519_dalek::{Signer, SigningKey};
    use std::collections::{BTreeMap, BTreeSet};

    fn signed_for(sk: &SigningKey, meta: &ReleaseMetadata) -> SignedRelease {
        let metadata_json = serde_json::to_string(meta).unwrap();
        let sig = sk.sign(metadata_json.as_bytes());
        let signature_hex = sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
        SignedRelease {
            metadata_json,
            signature_hex,
        }
    }

    fn signed_set_for(sk: &SigningKey, meta: &UpdateSetMetadata) -> SignedUpdateSet {
        let metadata_json = serde_json::to_string(meta).unwrap();
        let sig = sk.sign(metadata_json.as_bytes());
        let signature_hex = sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
        SignedUpdateSet {
            metadata_json,
            signature_hex,
        }
    }

    fn gate_meta(version: u64, artifact: &[u8]) -> ReleaseMetadata {
        ReleaseMetadata {
            version,
            channel: "stable".to_string(),
            expires_at_ms: 10_000,
            key_id: "k".to_string(),
            artifact_sha256: crate::release::sha256_hex(artifact),
            brain_ipc_version: IPC_PROTOCOL_VERSION,
            broker_resource_abi: BROKER_RESOURCE_ABI,
        }
    }

    fn gate_policy(sk: &SigningKey) -> VerifyPolicy {
        let mut trusted = BTreeMap::new();
        trusted.insert("k".to_string(), sk.verifying_key());
        VerifyPolicy {
            trusted_keys: trusted,
            revoked_keys: BTreeSet::new(),
            pinned_channel: "stable".to_string(),
            current_version: 5,
            now_ms: 1_000,
        }
    }

    fn set_meta(
        version: u64,
        platform: &str,
        artifact: &[u8],
        resource_abi: u32,
    ) -> UpdateSetMetadata {
        UpdateSetMetadata {
            version,
            channel: "stable".to_string(),
            expires_at_ms: 10_000,
            key_id: "k".to_string(),
            artifacts: std::collections::BTreeMap::from([(
                platform.to_string(),
                UpdateArtifactMetadata {
                    artifact_sha256: sha256_hex(artifact),
                    brain_ipc_version: IPC_PROTOCOL_VERSION,
                    broker_resource_abi: resource_abi,
                    asset_name: None,
                },
            )]),
            provenance: None,
        }
    }

    // [unit->REQ-UPD-2] a verified release yields a plan; an apply can then run.
    // The verified release's spec classifies brain-only (same broker ABI, hostable
    // brain) — the routine path.
    #[test]
    fn plan_verified_yields_a_plan_for_a_valid_release() {
        let sk = SigningKey::from_bytes(&[3u8; 32]);
        let artifact = b"new binary";
        let signed = signed_for(&sk, &gate_meta(6, artifact));
        let plan = plan_verified(&BrokerAbi::current(), &signed, artifact, &gate_policy(&sk))
            .expect("a valid release produces a plan");
        assert_eq!(plan.class, UpdateClass::BrainOnly);
    }

    // [unit->REQ-UPD-2] a release that fails verification produces NO plan — there
    // is nothing for apply_brain_only to act on, so an unverified binary can never
    // reach a handoff. (Rollback stands in for the whole reject family, each of
    // which is covered in release.rs.)
    #[test]
    fn plan_verified_produces_no_plan_for_a_rejected_release() {
        let sk = SigningKey::from_bytes(&[3u8; 32]);
        let artifact = b"old binary";
        // version 4 <= current 5 → rollback.
        let signed = signed_for(&sk, &gate_meta(4, artifact));
        let got = plan_verified(&BrokerAbi::current(), &signed, artifact, &gate_policy(&sk));
        assert!(matches!(
            got,
            Err(crate::release::RejectReason::Rollback { .. })
        ));

        // A bytes/digest mismatch also yields no plan.
        let signed_ok = signed_for(&sk, &gate_meta(6, b"the signed bytes"));
        let wrong = plan_verified(
            &BrokerAbi::current(),
            &signed_ok,
            b"different bytes",
            &gate_policy(&sk),
        );
        assert!(matches!(
            wrong,
            Err(crate::release::RejectReason::ArtifactMismatch { .. })
        ));
    }

    // [unit->REQ-UPD-6] an update set yields a plan from the selected platform
    // artifact; missing-platform and broker-breaking classifications are
    // surfaced before apply.
    #[test]
    fn plan_verified_update_set_uses_selected_platform_spec() {
        let sk = SigningKey::from_bytes(&[3u8; 32]);
        let artifact = b"linux binary";
        let signed = signed_set_for(
            &sk,
            &set_meta(6, "x86_64-unknown-linux-gnu", artifact, BROKER_RESOURCE_ABI),
        );
        let plan = plan_verified_update_set(
            &BrokerAbi::current(),
            &signed,
            "x86_64-unknown-linux-gnu",
            artifact,
            &gate_policy(&sk),
        )
        .expect("valid platform artifact produces a plan");
        assert_eq!(plan.class, UpdateClass::BrainOnly);

        let missing = plan_verified_update_set(
            &BrokerAbi::current(),
            &signed,
            "x86_64-pc-windows-msvc",
            artifact,
            &gate_policy(&sk),
        );
        assert!(matches!(
            missing,
            Err(crate::release::RejectReason::NoArtifactForPlatform { .. })
        ));

        let breaking = signed_set_for(
            &sk,
            &set_meta(
                7,
                "x86_64-unknown-linux-gnu",
                b"next",
                BROKER_RESOURCE_ABI + 1,
            ),
        );
        let plan = plan_verified_update_set(
            &BrokerAbi::current(),
            &breaking,
            "x86_64-unknown-linux-gnu",
            b"next",
            &gate_policy(&sk),
        )
        .expect("verified broker-breaking set still produces a typed plan");
        assert_eq!(plan.class, UpdateClass::BrokerBreaking);
    }

    #[test]
    fn apply_refuses_non_brain_only_plan() {
        let running = BrokerAbi::current();
        let breaking = plan_update(
            &running,
            ReleaseSpec {
                brain_ipc_version: 1,
                broker_resource_abi: running.resource_abi + 1,
            },
        );
        assert_eq!(breaking.class, UpdateClass::BrokerBreaking);

        // We cannot construct a real Brain without a broker here (that is the
        // E2E in tests/update.rs); assert the class-routing rule directly — the
        // single source of truth the apply guard consults.
        assert_ne!(breaking.class, UpdateClass::BrainOnly);
    }
}


Now applyhost, relcache, fetch cmd, xtask publish.


1	//! Staged-update apply orchestration (M5-D7b) — the **production caller** for
2	//! [`crate::update::apply_brain_only`], closing the M4-D9 honest residual
3	//! ("nothing in the daemon invokes this apply after the user acks the consent
4	//! notif").
5	//!
6	//! The ack IS the verb the consent notif names: its body says "confirm to
7	//! apply (`spt update apply`) or dismiss" (ADR-0007 — the harness's native
8	//! confirm affordance answers by running the command; dismiss latches the
9	//! notif and is a *prompt* answer, not a lock — an apply after a dismiss
10	//! still works, the user changed their mind).
11	//!
12	//! [`apply_staged`] drives the whole chain against what the peer pump left
13	//! behind (REQ-UPD-4: staged + consent-notified, never auto-applied):
14	//!
15	//! 1. **Re-verify at rest** (`plan_verified` under the production
16	//!    `release-keys.json` policy): apply never trusts the cache — a tampered
17	//!    or expired artifact refuses here. The monotonic-version floor was
18	//!    enforced at *stage* time (REQ-HAZARD-UPDATE-ROLLBACK); the apply-time
19	//!    re-check guards authenticity/integrity/expiry of the bytes on disk.
20	//! 2. **Classify** against the running broker's ABI. Only
21	//!    [`UpdateClass::BrainOnly`] applies in M5 — the broker-touching classes
22	//!    refuse typed, the artifact stays staged (their consent-and-scheduling
23	//!    orchestration is post-M5; ADR-0004 §A).
24	//! 3. **Swap the binary**: the current executable steps aside
25	//!    (`<exe>.old-<ver>` — a running image survives its own rename on both
26	//!    OSes) and the verified artifact lands at the executable path
27	//!    atomically. A failed landing rolls the original back — never a
28	//!    half-swapped seat.
29	//! 4. **Trigger the brain handoff** ([`apply_brain_only`]): snapshot → drop →
30	//!    re-attach against the live broker. No hosted endpoint terminates or
31	//!    suspends (REQ-UPD-3, absolute for this class). The relaunch closure
32	//!    re-attaches in-process over the existing substrate — the same
33	//!    documented stand-in `brain_swap.rs` proves the real handoff with; a
34	//!    freshly-exec'd brain of the new binary takes the identical path.
35	//!
36	//! The daemon's own resident loops keep running the old logic until its next
37	//! restart (`ensure_running` spawns the executable path — now the new
38	//! binary); that restart is routine lifecycle, never an endpoint cycle.
39	// [impl->REQ-UPD-3]
40	// [impl->REQ-UPD-4]
41
42	use std::io;
43	use std::path::Path;
44	use std::time::Duration;
45
46	use crate::brain::{now_ms, Brain, BrainState};
47	use crate::relcache::{ReleaseCache, StagedUpdate};
48	use crate::release::{current_platform, VerifyPolicy};
49	use crate::update::{
50	    apply_brain_only, plan_verified, plan_verified_update_set, BrokerAbi, UpdateClass,
51	};
52
53	/// What [`apply_staged`] did — loud + typed, never silent.
54	#[derive(Debug, Clone, PartialEq, Eq)]
55	pub enum ApplyStagedOutcome {
56	    /// Nothing staged (or a torn stage that never offers) — idempotent no-op.
57	    NoUpdate,
58	    /// The at-rest re-verification refused (tamper / expiry / key trust) —
59	    /// nothing swapped, the reason says why.
60	    Rejected(String),
61	    /// Verified but not brain-only — the M5 apply surface refuses the
62	    /// broker-touching classes typed; the artifact stays staged.
63	    RefusedClass(UpdateClass),
64	    /// Swapped + handed off. The version is the staged release's.
65	    Applied { version: u64 },
66	}
67
68	/// Apply the staged release (module docs). `exe_path` is the executable to
69	/// swap (production: `std::env::current_exe()`); `broker_name` the canonical
70	/// broker socket the brain handoff runs against.
71	// [impl->REQ-UPD-3]
72	// [impl->REQ-UPD-4]
73	pub fn apply_staged(
74	    cache: &ReleaseCache,
75	    release_keys: &Path,
76	    exe_path: &Path,
77	    broker_name: &str,
78	) -> Result<ApplyStagedOutcome, String> {
79	    let Some(staged) = cache.staged_update() else {
80	        return Ok(ApplyStagedOutcome::NoUpdate);
81	    };
82	    let version = cache.staged_version().unwrap_or(0);
83
84	    // At-rest re-verification. Version floor 0: monotonicity already gated
85	    // the stage; this check is authenticity/integrity/expiry of the bytes on
86	    // disk. Fail-closed on an unprovisioned key file (same posture as the
87	    // pump's pull leg).
88	    let policy = VerifyPolicy::load_from(release_keys, 0, now_ms());
89	    if policy.trusted_keys.is_empty() {
90	        return Ok(ApplyStagedOutcome::Rejected(
91	            "release-keys.json unprovisioned (no trusted keys)".to_string(),
92	        ));
93	    }
94	    let (plan, artifact) = match staged {
95	        StagedUpdate::Single(signed) => {
96	            let artifact = cache
97	                .load_artifact()
98	                .map_err(|e| format!("staged artifact: {e}"))?;
99	            let plan = match plan_verified(&BrokerAbi::current(), &signed, &artifact, &policy) {
100	                Ok(p) => p,
101	                Err(reason) => return Ok(ApplyStagedOutcome::Rejected(format!("{reason:?}"))),
102	            };
103	            (plan, artifact)
104	        }
105	        StagedUpdate::Set(signed) => {
106	            let platform = current_platform();
107	            let artifact = cache
108	                .load_artifact_for_platform(platform)
109	                .map_err(|e| format!("staged artifact for {platform}: {e}"))?;
110	            let plan = match plan_verified_update_set(
111	                &BrokerAbi::current(),
112	                &signed,
113	                platform,
114	                &artifact,
115	                &policy,
116	            ) {
117	                Ok(p) => p,
118	                Err(reason) => return Ok(ApplyStagedOutcome::Rejected(format!("{reason:?}"))),
119	            };
120	            (plan, artifact)
121	        }
122	    };
123	    if plan.class != UpdateClass::BrainOnly {
124	        return Ok(ApplyStagedOutcome::RefusedClass(plan.class));
125	    }
126
127	    // The brain connects BEFORE the binary moves — a dead broker fails the
128	    // apply with the seat untouched.
129	    let brain = connect_retry(broker_name).map_err(|e| format!("broker connect: {e}"))?;
130
131	    // Swap the binary: step aside, land the artifact, roll back on failure.
132	    let aside = swap_aside_path(exe_path, version);
133	    let _ = std::fs::remove_file(&aside); // stale prior apply of this version
134	    std::fs::rename(exe_path, &aside).map_err(|e| format!("step current binary aside: {e}"))?;
135	    if let Err(e) = land_artifact(exe_path, &artifact) {
136	        let _ = std::fs::rename(&aside, exe_path); // roll back — never half-swapped
137	        return Err(format!("land new binary (rolled back): {e}"));
138	    }
139	    // The binary IS swapped from here on — record the applied fact for the
140	    // status-only convergence query (M8 decision 19, `xtask debug-converge`),
141	    // whatever the brain handoff below concludes. Best-effort: an unrecorded
142	    // apply degrades the convergence table, never the apply.
143	    // [impl->REQ-UPD-6]
144	    let _ = cache.record_applied(version);
145	    let _ = cache.record_last_outcome("applied");
146
147	    // The brain-only handoff against the live broker — REQ-UPD-3's moment. A
148	    // node with no broker-hosted sessions has nothing to hand off; the
149	    // invariant is vacuous there, and the next daemon start uses the swapped
150	    // executable.
151	    match brain {
152	        Some(brain) => {
153	            let name = broker_name.to_string();
154	            match apply_brain_only(&plan, brain, move |state| handoff_retry(&name, state)) {
155	                Ok(_new_brain) => Ok(ApplyStagedOutcome::Applied { version }),
156	                Err(e) => Err(format!(
157	                    "binary swapped to v{version}, but the brain re-attach failed: {e} — the broker \
158	                     and every hosted endpoint are intact; retry `spt update apply` or restart the brain"
159	                )),
160	            }
161	        }
162	        None => Ok(ApplyStagedOutcome::Applied { version }),
163	    }
164	}
165
166	/// Where the outgoing binary steps aside: a sibling `<file>.old-<version>`
167	/// (suffix appended, never `set_extension` — `spt.exe` must keep `.exe` out
168	/// of the aside name's way).
169	fn swap_aside_path(exe_path: &Path, version: u64) -> std::path::PathBuf {
170	    let name = exe_path
171	        .file_name()
172	        .and_then(|n| n.to_str())
173	        .unwrap_or("spt-binary");
174	    exe_path.with_file_name(format!("{name}.old-{version}"))
175	}
176
177	/// Land the artifact at the executable path (atomic temp→rename) and mark it
178	/// executable where the OS needs that.
179	fn land_artifact(exe_path: &Path, artifact: &[u8]) -> io::Result<()> {
180	    spt_store::atomic::atomic_write_bytes(exe_path, artifact)?;
181	    #[cfg(unix)]
182	    {
183	        use std::os::unix::fs::PermissionsExt;
184	        std::fs::set_permissions(exe_path, std::fs::Permissions::from_mode(0o755))?;
185	    }
186	    Ok(())
187	}
188
189	/// Connect a brain to the broker and attach it to an existing broker-held
190	/// session, retrying through startup races. The update engine's handoff
191	/// primitive snapshots a session-driving brain; this attaches as a logic
192	/// successor over an already-hosted session without spawning or terminating
193	/// anything.
194	fn connect_retry(broker_name: &str) -> io::Result<Option<Brain>> {
195	    let mut last = io::Error::other("broker never answered");
196	    for _ in 0..300 {
197	        match Brain::cold_start(broker_name, now_ms()) {
198	            Ok(mut b) => match b.sessions() {
199	                Ok(reply) => {
200	                    if let Some(session) = reply.sessions.first() {
201	                        if let Err(e) = b.attach(session.session_id, 0) {
202	                            last = e;
203	                        } else {
204	                            return Ok(Some(b));
205	                        }
206	                    } else {
207	                        return Ok(None);
208	                    }
209	                }
210	                Err(e) => last = e,
211	            },
212	            Err(e) => last = e,
213	        }
214	        std::thread::sleep(Duration::from_millis(10));
215	    }
216	    Err(last)
217	}
218
219	/// The relaunch closure's body: the new brain re-attaches over the existing
220	/// substrate, retrying the slot race (the broker may not have freed the
221	/// outgoing brain's seat the instant it dropped) — the `brain_swap.rs` shape.
222	fn handoff_retry(broker_name: &str, prior: BrainState) -> io::Result<Brain> {
223	    let mut last = None;
224	    for _ in 0..300 {
225	        match Brain::handoff(broker_name, prior.clone(), now_ms()) {
226	            Ok(b) => return Ok(b),
227	            Err(e) => last = Some(e),
228	        }
229	        std::thread::sleep(Duration::from_millis(10));
230	    }
231	    Err(last.unwrap_or_else(|| io::Error::other("relaunch never connected")))
232	}
233
234	#[cfg(test)]
235	mod tests {
236	    use super::*;
237	    use crate::broker::Broker;
238	    use crate::msg::SpawnReq;
239	    use crate::release::{
240	        current_platform, sha256_hex, ReleaseMetadata, SignedRelease, SignedUpdateSet,
241	        UpdateArtifactMetadata, UpdateSetMetadata,
242	    };
243	    use crate::update::BROKER_RESOURCE_ABI;
244	    use ed25519_dalek::{Signer, SigningKey};
245	    use std::sync::atomic::{AtomicU32, Ordering};
246	    use std::sync::Arc;
247
248	    static SEQ: AtomicU32 = AtomicU32::new(0);
249	    fn unique_name() -> String {
250	        let n = SEQ.fetch_add(1, Ordering::Relaxed);
251	        format!("spt-daemon-d7b-{}-{}.sock", std::process::id(), n)
252	    }
253
254	    fn served_broker(name: &str) -> Arc<Broker> {
255	        let broker = Broker::bind(name).expect("bind broker");
256	        let serve = Arc::clone(&broker);
257	        std::thread::spawn(move || {
258	            let _ = serve.serve();
259	        });
260	        broker
261	    }
262
263	    fn cold_connect_retry(broker_name: &str) -> Brain {
264	        for _ in 0..300 {
265	            if let Ok(b) = Brain::cold_start(broker_name, now_ms()) {
266	                return b;
267	            }
268	            std::thread::sleep(Duration::from_millis(10));
269	        }
270	        panic!("brain cold-start could not connect");
271	    }
272
273	    /// Stage a signed release + write the matching release-keys.json.
274	    /// `resource_abi` lets a test force the broker-breaking class.
275	    fn stage(
276	        dir: &Path,
277	        version: u64,
278	        artifact: &[u8],
279	        resource_abi: u32,
280	    ) -> (ReleaseCache, std::path::PathBuf) {
281	        let sk = SigningKey::from_bytes(&[9u8; 32]);
282	        let meta = ReleaseMetadata {
283	            version,
284	            channel: "stable".to_string(),
285	            expires_at_ms: u64::MAX,
286	            key_id: "k".to_string(),
287	            artifact_sha256: sha256_hex(artifact),
288	            brain_ipc_version: crate::IPC_PROTOCOL_VERSION,
289	            broker_resource_abi: resource_abi,
290	        };
291	        let metadata_json = serde_json::to_string(&meta).unwrap();
292	        let sig = sk.sign(metadata_json.as_bytes());
293	        let signed = SignedRelease {
294	            metadata_json,
295	            signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
296	        };
297	        let cache = ReleaseCache::open(&dir.join("releases"));
298	        cache.stage(&signed, artifact).expect("stage");
299
300	        let keys = dir.join("release-keys.json");
301	        let pub_hex: String = sk
302	            .verifying_key()
303	            .to_bytes()
304	            .iter()
305	            .map(|b| format!("{b:02x}"))
306	            .collect();
307	        std::fs::write(
308	            &keys,
309	            serde_json::json!({"keys": {"k": pub_hex}, "channel": "stable"}).to_string(),
310	        )
311	        .unwrap();
312	        (cache, keys)
313	    }
314
315	    /// Stage a signed update set + write matching release keys.
316	    fn stage_set(dir: &Path, version: u64, artifact: &[u8]) -> (ReleaseCache, std::path::PathBuf) {
317	        stage_set_with_resource_abi(dir, version, artifact, BROKER_RESOURCE_ABI)
318	    }
319
320	    fn stage_set_with_resource_abi(
321	        dir: &Path,
322	        version: u64,
323	        artifact: &[u8],
324	        resource_abi: u32,
325	    ) -> (ReleaseCache, std::path::PathBuf) {
326	        let sk = SigningKey::from_bytes(&[9u8; 32]);
327	        let meta = UpdateSetMetadata {
328	            version,
329	            channel: "stable".to_string(),
330	            expires_at_ms: u64::MAX,
331	            key_id: "k".to_string(),
332	            artifacts: std::collections::BTreeMap::from([(
333	                current_platform().to_string(),
334	                UpdateArtifactMetadata {
335	                    artifact_sha256: sha256_hex(artifact),
336	                    brain_ipc_version: crate::IPC_PROTOCOL_VERSION,
337	                    broker_resource_abi: resource_abi,
338	                    asset_name: None,
339	                },
340	            )]),
341	            provenance: None,
342	        };
343	        let metadata_json = serde_json::to_string(&meta).unwrap();
344	        let sig = sk.sign(metadata_json.as_bytes());
345	        let signed = SignedUpdateSet {
346	            metadata_json,
347	            signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
348	        };
349	        let cache = ReleaseCache::open(&dir.join("releases"));
350	        cache
351	            .stage_update_set(
352	                &signed,
353	                &std::collections::BTreeMap::from([(
354	                    current_platform().to_string(),
355	                    artifact.to_vec(),
356	                )]),
357	            )
358	            .expect("stage set");
359
360	        let keys = dir.join("release-keys.json");
361	        let pub_hex: String = sk
362	            .verifying_key()
363	            .to_bytes()
364	            .iter()
365	            .map(|b| format!("{b:02x}"))
366	            .collect();
367	        std::fs::write(
368	            &keys,
369	            serde_json::json!({"keys": {"k": pub_hex}, "channel": "stable"}).to_string(),
370	        )
371	        .unwrap();
372	        (cache, keys)
373	    }
374
375	    fn echo_spawn_req() -> SpawnReq {
376	        #[cfg(unix)]
377	        let (program, args) = ("cat".to_string(), Vec::<String>::new());
378	        #[cfg(windows)]
379	        let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
380	        SpawnReq {
381	            program,
382	            args,
383	            rows: 24,
384	            cols: 80,
385	            endpoint: String::new(),
386	        }
387	    }
388
389	    // [unit->REQ-UPD-4] the ack-driven apply end to end: a staged + verified
390	    // brain-only release swaps the binary (artifact lands at the exe path,
391	    // the original steps aside) and triggers the brain handoff with the
392	    // hosted endpoint untouched — the production caller the M4-D9 residual
393	    // promised. [unit->REQ-UPD-3]
394	    #[test]
395	    fn apply_staged_swaps_binary_and_hands_off_without_touching_the_endpoint() {
396	        let dir = tempfile::tempdir().unwrap();
397	        let name = unique_name();
398	        let broker = served_broker(&name);
399
400	        // A hosted endpoint that must survive the apply.
401	        let mut seat = cold_connect_retry(&name);
402	        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
403	        let child_pid = broker.session_pid(sid);
404	        drop(seat); // the seat brain detaches; the broker keeps the child
405
406	        let exe = dir.path().join("spt-binary");
407	        std::fs::write(&exe, b"OLD BINARY").unwrap();
408	        let (cache, keys) = stage(dir.path(), 7, b"NEW BINARY BYTES", BROKER_RESOURCE_ABI);
409
410	        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
411	        assert!(
412	            matches!(out, ApplyStagedOutcome::Applied { version: 7 }),
413	            "got {out:?}"
414	        );
415	        assert_eq!(
416	            std::fs::read(&exe).unwrap(),
417	            b"NEW BINARY BYTES",
418	            "artifact landed"
419	        );
420	        assert_eq!(
421	            std::fs::read(swap_aside_path(&exe, 7)).unwrap(),
422	            b"OLD BINARY",
423	            "the outgoing binary stepped aside"
424	        );
425	        // REQ-UPD-3: the hosted child never noticed.
426	        assert_eq!(
427	            broker.session_count(),
428	            1,
429	            "exactly one session across the apply"
430	        );
431	        assert_eq!(broker.session_pid(sid), child_pid, "child pid unchanged");
432	    }
433
434	    // [unit->REQ-UPD-6] apply-time re-verification handles platform-targeted
435	    // update sets by selecting the local target triple's artifact before
436	    // swapping the executable.
437	    #[test]
438	    fn apply_staged_update_set_selects_local_platform_artifact() {
439	        let dir = tempfile::tempdir().unwrap();
440	        let name = unique_name();
441	        let broker = served_broker(&name);
442
443	        let mut seat = cold_connect_retry(&name);
444	        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
445	        let child_pid = broker.session_pid(sid);
446	        drop(seat);
447
448	        let exe = dir.path().join("spt-binary");
449	        std::fs::write(&exe, b"OLD").unwrap();
450	        let (cache, keys) = stage_set(dir.path(), 11, b"SET BINARY BYTES");
451
452	        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
453	        assert!(
454	            matches!(out, ApplyStagedOutcome::Applied { version: 11 }),
455	            "got {out:?}"
456	        );
457	        assert_eq!(std::fs::read(&exe).unwrap(), b"SET BINARY BYTES");
458	        assert_eq!(broker.session_pid(sid), child_pid, "child pid unchanged");
459	    }
460
461	    // [unit->REQ-UPD-3] A node with no broker-hosted sessions still applies a
462	    // brain-only update: there is nothing to hand off, and no endpoint can be
463	    // terminated by the apply.
464	    #[test]
465	    fn apply_staged_without_broker_hosted_sessions_swaps_binary() {
466	        let dir = tempfile::tempdir().unwrap();
467	        let name = unique_name();
468	        let broker = served_broker(&name);
469
470	        let exe = dir.path().join("spt-binary");
471	        std::fs::write(&exe, b"OLD").unwrap();
472	        let (cache, keys) = stage(dir.path(), 13, b"NEW WITHOUT SESSIONS", BROKER_RESOURCE_ABI);
473
474	        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
475	        assert!(
476	            matches!(out, ApplyStagedOutcome::Applied { version: 13 }),
477	            "got {out:?}"
478	        );
479	        assert_eq!(std::fs::read(&exe).unwrap(), b"NEW WITHOUT SESSIONS");
480	        assert_eq!(broker.session_count(), 0);
481	    }
482
483	    // [unit->REQ-UPD-6] Broker-touching debug/update-set payloads are accepted
484	    // as staged candidates, but apply refuses them typed while the broker-held
485	    // runtime is live. The first broker-touching flow is refusal-only; it does
486	    // not auto-suspend or cycle hosted work.
487	    #[test]
488	    fn apply_staged_update_set_refuses_broker_touching_payloads() {
489	        let dir = tempfile::tempdir().unwrap();
490	        let name = unique_name();
491	        let _broker = served_broker(&name);
492
493	        let exe = dir.path().join("spt-binary");
494	        std::fs::write(&exe, b"OLD").unwrap();
495	        let (cache, keys) = stage_set_with_resource_abi(
496	            dir.path(),
497	            12,
498	            b"SET BROKER BYTES",
499	            BROKER_RESOURCE_ABI + 1,
500	        );
501
502	        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
503	        assert!(
504	            matches!(
505	                out,
506	                ApplyStagedOutcome::RefusedClass(UpdateClass::BrokerBreaking)
507	            ),
508	            "got {out:?}"
509	        );
510	        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");
511	        assert!(cache.staged_update().is_some(), "candidate stays staged");
512	    }
513
514	    // [unit->REQ-UPD-4] idempotent no-op and fail-closed gates: nothing
515	    // staged ⇒ NoUpdate; a non-brain-only class refuses typed with the seat
516	    // untouched; a tampered at-rest artifact rejects with the seat untouched.
517	    #[test]
518	    fn apply_staged_gates_fail_closed_with_the_seat_untouched() {
519	        let dir = tempfile::tempdir().unwrap();
520	        let name = unique_name();
521	        let _broker = served_broker(&name);
522	        let exe = dir.path().join("spt-binary");
523	        std::fs::write(&exe, b"OLD").unwrap();
524
525	        // Nothing staged.
526	        let empty = ReleaseCache::open(&dir.path().join("releases"));
527	        let keys = dir.path().join("release-keys.json");
528	        let out = apply_staged(&empty, &keys, &exe, &name).expect("ok");
529	        assert!(matches!(out, ApplyStagedOutcome::NoUpdate), "got {out:?}");
530
531	        // Broker-breaking class: typed refusal, artifact stays staged.
532	        let (cache, keys) = stage(dir.path(), 8, b"X", BROKER_RESOURCE_ABI + 1);
533	        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
534	        assert!(
535	            matches!(
536	                out,
537	                ApplyStagedOutcome::RefusedClass(UpdateClass::BrokerBreaking)
538	            ),
539	            "got {out:?}"
540	        );
541	        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");
542	        assert!(cache.staged().is_some(), "artifact stays staged");
543
544	        // Tampered at rest: the apply-time re-verification rejects.
545	        let (cache, keys) = stage(dir.path(), 9, b"GOOD BYTES", BROKER_RESOURCE_ABI);
546	        std::fs::write(cache.artifact_path(), b"EVIL BYTES").unwrap();
547	        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
548	        assert!(
549	            matches!(out, ApplyStagedOutcome::Rejected(_)),
550	            "got {out:?}"
551	        );
552	        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");
553
554	        // Unprovisioned keys: fail-closed.
555	        let (cache, _) = stage(dir.path(), 10, b"Y", BROKER_RESOURCE_ABI);
556	        let out =
557	            apply_staged(&cache, &dir.path().join("missing-keys.json"), &exe, &name).expect("ok");
558	        assert!(
559	            matches!(out, ApplyStagedOutcome::Rejected(_)),
560	            "got {out:?}"
561	        );
562	        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD");
563	    }
564	}
565

//! Staged-update apply orchestration (M5-D7b) — the **production caller** for
//! [`crate::update::apply_brain_only`], closing the M4-D9 honest residual
//! ("nothing in the daemon invokes this apply after the user acks the consent
//! notif").
//!
//! The ack IS the verb the consent notif names: its body says "confirm to
//! apply (`spt update apply`) or dismiss" (ADR-0007 — the harness's native
//! confirm affordance answers by running the command; dismiss latches the
//! notif and is a *prompt* answer, not a lock — an apply after a dismiss
//! still works, the user changed their mind).
//!
//! [`apply_staged`] drives the whole chain against what the peer pump left
//! behind (REQ-UPD-4: staged + consent-notified, never auto-applied):
//!
//! 1. **Re-verify at rest** (`plan_verified` under the production
//!    `release-keys.json` policy): apply never trusts the cache — a tampered
//!    or expired artifact refuses here. The monotonic-version floor was
//!    enforced at *stage* time (REQ-HAZARD-UPDATE-ROLLBACK); the apply-time
//!    re-check guards authenticity/integrity/expiry of the bytes on disk.
//! 2. **Classify** against the running broker's ABI. Only
//!    [`UpdateClass::BrainOnly`] applies in M5 — the broker-touching classes
//!    refuse typed, the artifact stays staged (their consent-and-scheduling
//!    orchestration is post-M5; ADR-0004 §A).
//! 3. **Swap the binary**: the current executable steps aside
//!    (`<exe>.old-<ver>` — a running image survives its own rename on both
//!    OSes) and the verified artifact lands at the executable path
//!    atomically. A failed landing rolls the original back — never a
//!    half-swapped seat.
//! 4. **Trigger the brain handoff** ([`apply_brain_only`]): snapshot → drop →
//!    re-attach against the live broker. No hosted endpoint terminates or
//!    suspends (REQ-UPD-3, absolute for this class). The relaunch closure
//!    re-attaches in-process over the existing substrate — the same
//!    documented stand-in `brain_swap.rs` proves the real handoff with; a
//!    freshly-exec'd brain of the new binary takes the identical path.
//!
//! The daemon's own resident loops keep running the old logic until its next
//! restart (`ensure_running` spawns the executable path — now the new
//! binary); that restart is routine lifecycle, never an endpoint cycle.
// [impl->REQ-UPD-3]
// [impl->REQ-UPD-4]

use std::io;
use std::path::Path;
use std::time::Duration;

use crate::brain::{now_ms, Brain, BrainState};
use crate::relcache::{ReleaseCache, StagedUpdate};
use crate::release::{current_platform, VerifyPolicy};
use crate::update::{
    apply_brain_only, plan_verified, plan_verified_update_set, BrokerAbi, UpdateClass,
};

/// What [`apply_staged`] did — loud + typed, never silent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApplyStagedOutcome {
    /// Nothing staged (or a torn stage that never offers) — idempotent no-op.
    NoUpdate,
    /// The at-rest re-verification refused (tamper / expiry / key trust) —
    /// nothing swapped, the reason says why.
    Rejected(String),
    /// Verified but not brain-only — the M5 apply surface refuses the
    /// broker-touching classes typed; the artifact stays staged.
    RefusedClass(UpdateClass),
    /// Swapped + handed off. The version is the staged release's.
    Applied { version: u64 },
}

/// Apply the staged release (module docs). `exe_path` is the executable to
/// swap (production: `std::env::current_exe()`); `broker_name` the canonical
/// broker socket the brain handoff runs against.
// [impl->REQ-UPD-3]
// [impl->REQ-UPD-4]
pub fn apply_staged(
    cache: &ReleaseCache,
    release_keys: &Path,
    exe_path: &Path,
    broker_name: &str,
) -> Result<ApplyStagedOutcome, String> {
    let Some(staged) = cache.staged_update() else {
        return Ok(ApplyStagedOutcome::NoUpdate);
    };
    let version = cache.staged_version().unwrap_or(0);

    // At-rest re-verification. Version floor 0: monotonicity already gated
    // the stage; this check is authenticity/integrity/expiry of the bytes on
    // disk. Fail-closed on an unprovisioned key file (same posture as the
    // pump's pull leg).
    let policy = VerifyPolicy::load_from(release_keys, 0, now_ms());
    if policy.trusted_keys.is_empty() {
        return Ok(ApplyStagedOutcome::Rejected(
            "release-keys.json unprovisioned (no trusted keys)".to_string(),
        ));
    }
    let (plan, artifact) = match staged {
        StagedUpdate::Single(signed) => {
            let artifact = cache
                .load_artifact()
                .map_err(|e| format!("staged artifact: {e}"))?;
            let plan = match plan_verified(&BrokerAbi::current(), &signed, &artifact, &policy) {
                Ok(p) => p,
                Err(reason) => return Ok(ApplyStagedOutcome::Rejected(format!("{reason:?}"))),
            };
            (plan, artifact)
        }
        StagedUpdate::Set(signed) => {
            let platform = current_platform();
            let artifact = cache
                .load_artifact_for_platform(platform)
                .map_err(|e| format!("staged artifact for {platform}: {e}"))?;
            let plan = match plan_verified_update_set(
                &BrokerAbi::current(),
                &signed,
                platform,
                &artifact,
                &policy,
            ) {
                Ok(p) => p,
                Err(reason) => return Ok(ApplyStagedOutcome::Rejected(format!("{reason:?}"))),
            };
            (plan, artifact)
        }
    };
    if plan.class != UpdateClass::BrainOnly {
        return Ok(ApplyStagedOutcome::RefusedClass(plan.class));
    }

    // The brain connects BEFORE the binary moves — a dead broker fails the
    // apply with the seat untouched.
    let brain = connect_retry(broker_name).map_err(|e| format!("broker connect: {e}"))?;

    // Swap the binary: step aside, land the artifact, roll back on failure.
    let aside = swap_aside_path(exe_path, version);
    let _ = std::fs::remove_file(&aside); // stale prior apply of this version
    std::fs::rename(exe_path, &aside).map_err(|e| format!("step current binary aside: {e}"))?;
    if let Err(e) = land_artifact(exe_path, &artifact) {
        let _ = std::fs::rename(&aside, exe_path); // roll back — never half-swapped
        return Err(format!("land new binary (rolled back): {e}"));
    }
    // The binary IS swapped from here on — record the applied fact for the
    // status-only convergence query (M8 decision 19, `xtask debug-converge`),
    // whatever the brain handoff below concludes. Best-effort: an unrecorded
    // apply degrades the convergence table, never the apply.
    // [impl->REQ-UPD-6]
    let _ = cache.record_applied(version);
    let _ = cache.record_last_outcome("applied");

    // The brain-only handoff against the live broker — REQ-UPD-3's moment. A
    // node with no broker-hosted sessions has nothing to hand off; the
    // invariant is vacuous there, and the next daemon start uses the swapped
    // executable.
    match brain {
        Some(brain) => {
            let name = broker_name.to_string();
            match apply_brain_only(&plan, brain, move |state| handoff_retry(&name, state)) {
                Ok(_new_brain) => Ok(ApplyStagedOutcome::Applied { version }),
                Err(e) => Err(format!(
                    "binary swapped to v{version}, but the brain re-attach failed: {e} — the broker \
                     and every hosted endpoint are intact; retry `spt update apply` or restart the brain"
                )),
            }
        }
        None => Ok(ApplyStagedOutcome::Applied { version }),
    }
}

/// Where the outgoing binary steps aside: a sibling `<file>.old-<version>`
/// (suffix appended, never `set_extension` — `spt.exe` must keep `.exe` out
/// of the aside name's way).
fn swap_aside_path(exe_path: &Path, version: u64) -> std::path::PathBuf {
    let name = exe_path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("spt-binary");
    exe_path.with_file_name(format!("{name}.old-{version}"))
}

/// Land the artifact at the executable path (atomic temp→rename) and mark it
/// executable where the OS needs that.
fn land_artifact(exe_path: &Path, artifact: &[u8]) -> io::Result<()> {
    spt_store::atomic::atomic_write_bytes(exe_path, artifact)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(exe_path, std::fs::Permissions::from_mode(0o755))?;
    }
    Ok(())
}

/// Connect a brain to the broker and attach it to an existing broker-held
/// session, retrying through startup races. The update engine's handoff
/// primitive snapshots a session-driving brain; this attaches as a logic
/// successor over an already-hosted session without spawning or terminating
/// anything.
fn connect_retry(broker_name: &str) -> io::Result<Option<Brain>> {
    let mut last = io::Error::other("broker never answered");
    for _ in 0..300 {
        match Brain::cold_start(broker_name, now_ms()) {
            Ok(mut b) => match b.sessions() {
                Ok(reply) => {
                    if let Some(session) = reply.sessions.first() {
                        if let Err(e) = b.attach(session.session_id, 0) {
                            last = e;
                        } else {
                            return Ok(Some(b));
                        }
                    } else {
                        return Ok(None);
                    }
                }
                Err(e) => last = e,
            },
            Err(e) => last = e,
        }
        std::thread::sleep(Duration::from_millis(10));
    }
    Err(last)
}

/// The relaunch closure's body: the new brain re-attaches over the existing
/// substrate, retrying the slot race (the broker may not have freed the
/// outgoing brain's seat the instant it dropped) — the `brain_swap.rs` shape.
fn handoff_retry(broker_name: &str, prior: BrainState) -> io::Result<Brain> {
    let mut last = None;
    for _ in 0..300 {
        match Brain::handoff(broker_name, prior.clone(), now_ms()) {
            Ok(b) => return Ok(b),
            Err(e) => last = Some(e),
        }
        std::thread::sleep(Duration::from_millis(10));
    }
    Err(last.unwrap_or_else(|| io::Error::other("relaunch never connected")))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::broker::Broker;
    use crate::msg::SpawnReq;
    use crate::release::{
        current_platform, sha256_hex, ReleaseMetadata, SignedRelease, SignedUpdateSet,
        UpdateArtifactMetadata, UpdateSetMetadata,
    };
    use crate::update::BROKER_RESOURCE_ABI;
    use ed25519_dalek::{Signer, SigningKey};
    use std::sync::atomic::{AtomicU32, Ordering};
    use std::sync::Arc;

    static SEQ: AtomicU32 = AtomicU32::new(0);
    fn unique_name() -> String {
        let n = SEQ.fetch_add(1, Ordering::Relaxed);
        format!("spt-daemon-d7b-{}-{}.sock", std::process::id(), n)
    }

    fn served_broker(name: &str) -> Arc<Broker> {
        let broker = Broker::bind(name).expect("bind broker");
        let serve = Arc::clone(&broker);
        std::thread::spawn(move || {
            let _ = serve.serve();
        });
        broker
    }

    fn cold_connect_retry(broker_name: &str) -> Brain {
        for _ in 0..300 {
            if let Ok(b) = Brain::cold_start(broker_name, now_ms()) {
                return b;
            }
            std::thread::sleep(Duration::from_millis(10));
        }
        panic!("brain cold-start could not connect");
    }

    /// Stage a signed release + write the matching release-keys.json.
    /// `resource_abi` lets a test force the broker-breaking class.
    fn stage(
        dir: &Path,
        version: u64,
        artifact: &[u8],
        resource_abi: u32,
    ) -> (ReleaseCache, std::path::PathBuf) {
        let sk = SigningKey::from_bytes(&[9u8; 32]);
        let meta = ReleaseMetadata {
            version,
            channel: "stable".to_string(),
            expires_at_ms: u64::MAX,
            key_id: "k".to_string(),
            artifact_sha256: sha256_hex(artifact),
            brain_ipc_version: crate::IPC_PROTOCOL_VERSION,
            broker_resource_abi: resource_abi,
        };
        let metadata_json = serde_json::to_string(&meta).unwrap();
        let sig = sk.sign(metadata_json.as_bytes());
        let signed = SignedRelease {
            metadata_json,
            signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
        };
        let cache = ReleaseCache::open(&dir.join("releases"));
        cache.stage(&signed, artifact).expect("stage");

        let keys = dir.join("release-keys.json");
        let pub_hex: String = sk
            .verifying_key()
            .to_bytes()
            .iter()
            .map(|b| format!("{b:02x}"))
            .collect();
        std::fs::write(
            &keys,
            serde_json::json!({"keys": {"k": pub_hex}, "channel": "stable"}).to_string(),
        )
        .unwrap();
        (cache, keys)
    }

    /// Stage a signed update set + write matching release keys.
    fn stage_set(dir: &Path, version: u64, artifact: &[u8]) -> (ReleaseCache, std::path::PathBuf) {
        stage_set_with_resource_abi(dir, version, artifact, BROKER_RESOURCE_ABI)
    }

    fn stage_set_with_resource_abi(
        dir: &Path,
        version: u64,
        artifact: &[u8],
        resource_abi: u32,
    ) -> (ReleaseCache, std::path::PathBuf) {
        let sk = SigningKey::from_bytes(&[9u8; 32]);
        let meta = UpdateSetMetadata {
            version,
            channel: "stable".to_string(),
            expires_at_ms: u64::MAX,
            key_id: "k".to_string(),
            artifacts: std::collections::BTreeMap::from([(
                current_platform().to_string(),
                UpdateArtifactMetadata {
                    artifact_sha256: sha256_hex(artifact),
                    brain_ipc_version: crate::IPC_PROTOCOL_VERSION,
                    broker_resource_abi: resource_abi,
                    asset_name: None,
                },
            )]),
            provenance: None,
        };
        let metadata_json = serde_json::to_string(&meta).unwrap();
        let sig = sk.sign(metadata_json.as_bytes());
        let signed = SignedUpdateSet {
            metadata_json,
            signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
        };
        let cache = ReleaseCache::open(&dir.join("releases"));
        cache
            .stage_update_set(
                &signed,
                &std::collections::BTreeMap::from([(
                    current_platform().to_string(),
                    artifact.to_vec(),
                )]),
            )
            .expect("stage set");

        let keys = dir.join("release-keys.json");
        let pub_hex: String = sk
            .verifying_key()
            .to_bytes()
            .iter()
            .map(|b| format!("{b:02x}"))
            .collect();
        std::fs::write(
            &keys,
            serde_json::json!({"keys": {"k": pub_hex}, "channel": "stable"}).to_string(),
        )
        .unwrap();
        (cache, keys)
    }

    fn echo_spawn_req() -> SpawnReq {
        #[cfg(unix)]
        let (program, args) = ("cat".to_string(), Vec::<String>::new());
        #[cfg(windows)]
        let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
        SpawnReq {
            program,
            args,
            rows: 24,
            cols: 80,
            endpoint: String::new(),
        }
    }

    // [unit->REQ-UPD-4] the ack-driven apply end to end: a staged + verified
    // brain-only release swaps the binary (artifact lands at the exe path,
    // the original steps aside) and triggers the brain handoff with the
    // hosted endpoint untouched — the production caller the M4-D9 residual
    // promised. [unit->REQ-UPD-3]
    #[test]
    fn apply_staged_swaps_binary_and_hands_off_without_touching_the_endpoint() {
        let dir = tempfile::tempdir().unwrap();
        let name = unique_name();
        let broker = served_broker(&name);

        // A hosted endpoint that must survive the apply.
        let mut seat = cold_connect_retry(&name);
        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
        let child_pid = broker.session_pid(sid);
        drop(seat); // the seat brain detaches; the broker keeps the child

        let exe = dir.path().join("spt-binary");
        std::fs::write(&exe, b"OLD BINARY").unwrap();
        let (cache, keys) = stage(dir.path(), 7, b"NEW BINARY BYTES", BROKER_RESOURCE_ABI);

        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
        assert!(
            matches!(out, ApplyStagedOutcome::Applied { version: 7 }),
            "got {out:?}"
        );
        assert_eq!(
            std::fs::read(&exe).unwrap(),
            b"NEW BINARY BYTES",
            "artifact landed"
        );
        assert_eq!(
            std::fs::read(swap_aside_path(&exe, 7)).unwrap(),
            b"OLD BINARY",
            "the outgoing binary stepped aside"
        );
        // REQ-UPD-3: the hosted child never noticed.
        assert_eq!(
            broker.session_count(),
            1,
            "exactly one session across the apply"
        );
        assert_eq!(broker.session_pid(sid), child_pid, "child pid unchanged");
    }

    // [unit->REQ-UPD-6] apply-time re-verification handles platform-targeted
    // update sets by selecting the local target triple's artifact before
    // swapping the executable.
    #[test]
    fn apply_staged_update_set_selects_local_platform_artifact() {
        let dir = tempfile::tempdir().unwrap();
        let name = unique_name();
        let broker = served_broker(&name);

        let mut seat = cold_connect_retry(&name);
        let sid = seat.spawn_session(echo_spawn_req()).expect("spawn");
        let child_pid = broker.session_pid(sid);
        drop(seat);

        let exe = dir.path().join("spt-binary");
        std::fs::write(&exe, b"OLD").unwrap();
        let (cache, keys) = stage_set(dir.path(), 11, b"SET BINARY BYTES");

        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
        assert!(
            matches!(out, ApplyStagedOutcome::Applied { version: 11 }),
            "got {out:?}"
        );
        assert_eq!(std::fs::read(&exe).unwrap(), b"SET BINARY BYTES");
        assert_eq!(broker.session_pid(sid), child_pid, "child pid unchanged");
    }

    // [unit->REQ-UPD-3] A node with no broker-hosted sessions still applies a
    // brain-only update: there is nothing to hand off, and no endpoint can be
    // terminated by the apply.
    #[test]
    fn apply_staged_without_broker_hosted_sessions_swaps_binary() {
        let dir = tempfile::tempdir().unwrap();
        let name = unique_name();
        let broker = served_broker(&name);

        let exe = dir.path().join("spt-binary");
        std::fs::write(&exe, b"OLD").unwrap();
        let (cache, keys) = stage(dir.path(), 13, b"NEW WITHOUT SESSIONS", BROKER_RESOURCE_ABI);

        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
        assert!(
            matches!(out, ApplyStagedOutcome::Applied { version: 13 }),
            "got {out:?}"
        );
        assert_eq!(std::fs::read(&exe).unwrap(), b"NEW WITHOUT SESSIONS");
        assert_eq!(broker.session_count(), 0);
    }

    // [unit->REQ-UPD-6] Broker-touching debug/update-set payloads are accepted
    // as staged candidates, but apply refuses them typed while the broker-held
    // runtime is live. The first broker-touching flow is refusal-only; it does
    // not auto-suspend or cycle hosted work.
    #[test]
    fn apply_staged_update_set_refuses_broker_touching_payloads() {
        let dir = tempfile::tempdir().unwrap();
        let name = unique_name();
        let _broker = served_broker(&name);

        let exe = dir.path().join("spt-binary");
        std::fs::write(&exe, b"OLD").unwrap();
        let (cache, keys) = stage_set_with_resource_abi(
            dir.path(),
            12,
            b"SET BROKER BYTES",
            BROKER_RESOURCE_ABI + 1,
        );

        let out = apply_staged(&cache, &keys, &exe, &name).expect("apply ok");
        assert!(
            matches!(
                out,
                ApplyStagedOutcome::RefusedClass(UpdateClass::BrokerBreaking)
            ),
            "got {out:?}"
        );
        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");
        assert!(cache.staged_update().is_some(), "candidate stays staged");
    }

    // [unit->REQ-UPD-4] idempotent no-op and fail-closed gates: nothing
    // staged ⇒ NoUpdate; a non-brain-only class refuses typed with the seat
    // untouched; a tampered at-rest artifact rejects with the seat untouched.
    #[test]
    fn apply_staged_gates_fail_closed_with_the_seat_untouched() {
        let dir = tempfile::tempdir().unwrap();
        let name = unique_name();
        let _broker = served_broker(&name);
        let exe = dir.path().join("spt-binary");
        std::fs::write(&exe, b"OLD").unwrap();

        // Nothing staged.
        let empty = ReleaseCache::open(&dir.path().join("releases"));
        let keys = dir.path().join("release-keys.json");
        let out = apply_staged(&empty, &keys, &exe, &name).expect("ok");
        assert!(matches!(out, ApplyStagedOutcome::NoUpdate), "got {out:?}");

        // Broker-breaking class: typed refusal, artifact stays staged.
        let (cache, keys) = stage(dir.path(), 8, b"X", BROKER_RESOURCE_ABI + 1);
        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
        assert!(
            matches!(
                out,
                ApplyStagedOutcome::RefusedClass(UpdateClass::BrokerBreaking)
            ),
            "got {out:?}"
        );
        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");
        assert!(cache.staged().is_some(), "artifact stays staged");

        // Tampered at rest: the apply-time re-verification rejects.
        let (cache, keys) = stage(dir.path(), 9, b"GOOD BYTES", BROKER_RESOURCE_ABI);
        std::fs::write(cache.artifact_path(), b"EVIL BYTES").unwrap();
        let out = apply_staged(&cache, &keys, &exe, &name).expect("ok");
        assert!(
            matches!(out, ApplyStagedOutcome::Rejected(_)),
            "got {out:?}"
        );
        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD", "seat untouched");

        // Unprovisioned keys: fail-closed.
        let (cache, _) = stage(dir.path(), 10, b"Y", BROKER_RESOURCE_ABI);
        let out =
            apply_staged(&cache, &dir.path().join("missing-keys.json"), &exe, &name).expect("ok");
        assert!(
            matches!(out, ApplyStagedOutcome::Rejected(_)),
            "got {out:?}"
        );
        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD");
    }
}


Now relcache + fetch cmd + xtask publish.