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::{verify_artifact, verify_metadata, RejectReason, SignedRelease, VerifyPolicy};
33
34	/// The held-resource-type ABI of this build's broker — the broker-breaking axis
35	/// (ADR-0004 §A/§B). Bumped **only** when the *kind* of resource the broker holds
36	/// changes: a new held-resource type, or a new OS PTY/socket API that changes how
37	/// an existing handle is carried. A pure logic change (the routine update) never
38	/// touches this — that is precisely what keeps the routine case *brain-only*.
39	pub const BROKER_RESOURCE_ABI: u32 = 1;
40
41	/// What the currently-running broker provides — the fixed point an incoming
42	/// release is classified against (ADR-0004 §A). Captured from the live broker,
43	/// not from this build, because the whole point of a self-update is that the
44	/// *running* broker may be older than the binary proposing the update.
45	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46	pub struct BrokerAbi {
47	    /// The IPC protocol version the running broker speaks.
48	    pub ipc_version: u32,
49	    /// The oldest brain IPC version the running broker will handshake
50	    /// (`accept_hello`'s floor). A release brain at or above this can attach to
51	    /// the running broker as-is.
52	    pub min_compatible: u32,
53	    /// The held-resource-type ABI tag ([`BROKER_RESOURCE_ABI`]) — the
54	    /// broker-breaking axis.
55	    pub resource_abi: u32,
56	}
57
58	impl BrokerAbi {
59	    /// The ABI of *this* build's broker. In the live daemon the running broker's
60	    /// ABI is read off the broker over the IPC; this is the value a broker built
61	    /// from this source reports (and the classifier's stand-in when the running
62	    /// broker is this same build).
63	    pub fn current() -> Self {
64	        BrokerAbi {
65	            ipc_version: IPC_PROTOCOL_VERSION,
66	            min_compatible: MIN_COMPATIBLE_VERSION,
67	            resource_abi: BROKER_RESOURCE_ABI,
68	        }
69	    }
70	}
71
72	/// What a candidate release declares about its broker/brain — the subset the
73	/// classifier needs (C1 adds version + signature + expiry). Forward-compatible
74	/// by intent: only the two ADR-0004 §A axes live here.
75	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76	pub struct ReleaseSpec {
77	    /// The IPC protocol version the release's **brain** speaks.
78	    pub brain_ipc_version: u32,
79	    /// The held-resource-type ABI the release's **broker** implements — compared
80	    /// against the running broker's [`BrokerAbi::resource_abi`] to detect a
81	    /// broker-breaking change.
82	    pub broker_resource_abi: u32,
83	}
84
85	/// The three update classes (ADR-0004 §A), each routed to its own invariant.
86	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87	pub enum UpdateClass {
88	    /// Swap logic only; the running broker hosts the new brain unchanged. No
89	    /// endpoint terminates ([`REQ-UPD-3`], absolute). The routine case.
90	    BrainOnly,
91	    /// The broker binary hot-swaps behind the versioned IPC (held-resource type
92	    /// unchanged); endpoints survive. Apply path deferred (no spike — ADR-0004 §A).
93	    BrokerCompatible,
94	    /// Held-resource-type / OS-API change; a planned endpoint-cycle is required
95	    /// and endpoints MAY suspend with consent + scheduling. [`REQ-UPD-3`] weakened.
96	    BrokerBreaking,
97	}
98
99	/// Classify a candidate release against the running broker (ADR-0004 §A).
100	///
101	/// The decision is purely the two ADR axes:
102	/// 1. **held-resource ABI** — if the release's broker implements a *different*
103	///    `resource_abi`, the held-resource type changed: **broker-breaking**,
104	///    regardless of IPC (a planned endpoint-cycle is unavoidable).
105	/// 2. **IPC hostability** — with the resource type unchanged, ask whether the
106	///    *running* broker can host the release's brain. If the brain's IPC version
107	///    is at or above the running broker's floor, the handshake accepts it and
108	///    only logic swaps: **brain-only**. Otherwise the broker must move to a build
109	///    that speaks the new IPC — but since the resource type is unchanged the new
110	///    broker adopts the existing handles: **broker-compatible**.
111	// [impl->REQ-UPD-3]
112	pub fn classify(running: &BrokerAbi, release: &ReleaseSpec) -> UpdateClass {
113	    if release.broker_resource_abi != running.resource_abi {
114	        return UpdateClass::BrokerBreaking;
115	    }
116	    if release.brain_ipc_version >= running.min_compatible {
117	        UpdateClass::BrainOnly
118	    } else {
119	        UpdateClass::BrokerCompatible
120	    }
121	}
122
123	/// A classified update: the class plus the release that produced it. Built by
124	/// [`plan_update`] so the class is always derived from the same `(running,
125	/// release)` pair the apply step will act on.
126	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127	pub struct UpdatePlan {
128	    /// The update class (ADR-0004 §A).
129	    pub class: UpdateClass,
130	    /// The release the plan was built for.
131	    pub release: ReleaseSpec,
132	}
133
134	/// Classify `release` against `running` and bundle the verdict with it.
135	pub fn plan_update(running: &BrokerAbi, release: ReleaseSpec) -> UpdatePlan {
136	    UpdatePlan { class: classify(running, &release), release }
137	}
138
139	/// The verify-before-handoff gate (C1): authenticate a signed release + confirm
140	/// the binary bytes, then — and only then — classify it into an [`UpdatePlan`].
141	///
142	/// This is the **front door** of an update: ADR-0004 §D makes signing mandatory,
143	/// so a release is never even classified (let alone applied) until it has passed
144	/// [`verify_metadata`] (signature + key trust/revocation + channel + expiry +
145	/// monotonic version) **and** [`verify_artifact`] (the real bytes match the
146	/// signed digest). A rejected release returns its [`RejectReason`] and **no plan
147	/// is produced** — there is nothing for [`apply_brain_only`] to act on, so an
148	/// unverified binary can never reach a handoff ([`REQ-UPD-2`]).
149	// [impl->REQ-UPD-2]
150	// [impl->REQ-HAZARD-UPDATE-ROLLBACK]
151	pub fn plan_verified(
152	    running: &BrokerAbi,
153	    signed: &SignedRelease,
154	    artifact: &[u8],
155	    policy: &VerifyPolicy,
156	) -> Result<UpdatePlan, RejectReason> {
157	    let meta = verify_metadata(signed, policy)?;
158	    verify_artifact(&meta, artifact)?;
159	    Ok(plan_update(running, meta.spec()))
160	}
161
162	/// Why a brain-only apply did not complete.
163	#[derive(Debug)]
164	pub enum ApplyError {
165	    /// The plan is not brain-only — routing it through the brain-only path would
166	    /// risk an endpoint, which this class must never do. Raised **before** the
167	    /// outgoing brain is touched. The caller routes a broker-compatible /
168	    /// broker-breaking plan through its own (consent-gated, scheduled) path;
169	    /// brain-only is the only swap C0 performs.
170	    NotBrainOnly(UpdateClass),
171	    /// The new brain failed to re-attach (the `relaunch` closure errored). The
172	    /// endpoint is **untouched** regardless — the broker + its hosted child
173	    /// persist across a failed re-attach exactly as across a brain crash — so the
174	    /// caller may retry against the still-live broker.
175	    Relaunch(io::Error),
176	}
177
178	impl std::fmt::Display for ApplyError {
179	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180	        match self {
181	            ApplyError::NotBrainOnly(c) => {
182	                write!(f, "apply_brain_only refused a {c:?} plan (would risk an endpoint)")
183	            }
184	            ApplyError::Relaunch(e) => write!(f, "brain-only relaunch failed (endpoint intact): {e}"),
185	        }
186	    }
187	}
188
189	impl std::error::Error for ApplyError {
190	    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
191	        match self {
192	            ApplyError::NotBrainOnly(_) => None,
193	            ApplyError::Relaunch(e) => Some(e),
194	        }
195	    }
196	}
197
198	/// Apply a **brain-only** update ([`REQ-UPD-3`]): swap the brain logic without
199	/// disturbing any broker-held resource or hosted endpoint.
200	///
201	/// This is a deliberate trigger of the M3b handoff path, **not** a new handoff:
202	/// it snapshots the outgoing brain ([`Brain::snapshot`]), drops it — the exact
203	/// crash boundary the broker already tolerates (the subscriber vanishes; the
204	/// child + its buffered output persist) — and re-attaches by calling `relaunch`
205	/// with the snapshot. `relaunch` stands for "exec the new binary's brain, which
206	/// re-attaches via [`Brain::handoff`]"; in the live daemon it execs the verified
207	/// new binary, and the returned brain is generation+1 with a fresh `gen_start`.
208	///
209	/// The guard is the invariant: a non-brain-only `plan` is refused
210	/// ([`ApplyError::NotBrainOnly`]) **before** the outgoing brain is dropped, so
211	/// the brain-only path can never be the thing that cycles an endpoint. Broker
212	/// touching classes route elsewhere (C2 consent + scheduling).
213	// [impl->REQ-UPD-3]
214	pub fn apply_brain_only<F>(
215	    plan: &UpdatePlan,
216	    outgoing: Brain,
217	    relaunch: F,
218	) -> Result<Brain, ApplyError>
219	where
220	    F: FnOnce(BrainState) -> io::Result<Brain>,
221	{
222	    if plan.class != UpdateClass::BrainOnly {
223	        // Refuse *before* touching the outgoing brain — no endpoint is at risk.
224	        return Err(ApplyError::NotBrainOnly(plan.class));
225	    }
226	    let state = outgoing.snapshot();
227	    drop(outgoing); // the crash boundary the broker tolerates: child + log persist
228	    // The new binary's brain re-attaches over the existing substrate. A relaunch
229	    // failure surfaces to the caller, which may retry against the still-live
230	    // broker (the endpoint is untouched regardless — that is the whole point).
231	    relaunch(state).map_err(ApplyError::Relaunch)
232	}
233
234	#[cfg(test)]
235	mod tests {
236	    use super::*;
237
238	    // [unit->REQ-UPD-3] same resource ABI + a brain the running broker can host
239	    // (IPC at or above the floor) ⇒ brain-only: the routine, endpoint-preserving
240	    // case. A *newer* brain (higher IPC) against the running broker is still
241	    // brain-only — the versioned IPC accepts a newer brain on an older broker.
242	    #[test]
243	    fn classify_brain_only_when_broker_hosts_new_brain() {
244	        let running = BrokerAbi::current();
245	        let same_ipc = ReleaseSpec {
246	            brain_ipc_version: running.ipc_version,
247	            broker_resource_abi: running.resource_abi,
248	        };
249	        assert_eq!(classify(&running, &same_ipc), UpdateClass::BrainOnly);
250
251	        let newer_brain = ReleaseSpec {
252	            brain_ipc_version: running.ipc_version + 5,
253	            broker_resource_abi: running.resource_abi,
254	        };
255	        assert_eq!(
256	            classify(&running, &newer_brain),
257	            UpdateClass::BrainOnly,
258	            "a newer brain the running broker still accepts is brain-only"
259	        );
260	    }
261
262	    // [unit->REQ-UPD-3] same resource ABI but the new brain is below the running
263	    // broker's IPC floor ⇒ broker-compatible: the broker binary must move to host
264	    // it, but the held-resource type is unchanged so endpoints still survive.
265	    #[test]
266	    fn classify_broker_compatible_when_brain_below_floor() {
267	        // A running broker with a raised floor (it has already moved past v1).
268	        let running = BrokerAbi { ipc_version: 3, min_compatible: 3, resource_abi: BROKER_RESOURCE_ABI };
269	        let old_brain = ReleaseSpec { brain_ipc_version: 2, broker_resource_abi: BROKER_RESOURCE_ABI };
270	        assert_eq!(classify(&running, &old_brain), UpdateClass::BrokerCompatible);
271	    }
272
273	    // [unit->REQ-UPD-3] a changed held-resource ABI ⇒ broker-breaking regardless
274	    // of IPC: the held-resource *type* changed, so a planned endpoint-cycle is
275	    // unavoidable and REQ-UPD-3's absolute guarantee is (per ADR-0004 §A) weakened.
276	    #[test]
277	    fn classify_broker_breaking_on_resource_abi_change() {
278	        let running = BrokerAbi::current();
279	        let new_resource = ReleaseSpec {
280	            brain_ipc_version: running.ipc_version, // IPC fine — irrelevant here
281	            broker_resource_abi: running.resource_abi + 1,
282	        };
283	        assert_eq!(classify(&running, &new_resource), UpdateClass::BrokerBreaking);
284	    }
285
286	    // [unit->REQ-UPD-3] the brain-only path REFUSES a non-brain-only plan before
287	    // touching the brain — the invariant that brain-only never cycles an endpoint.
288	    // (No real Brain is needed: the guard fires before `outgoing` is read, so a
289	    // closure that would panic if reached proves the refusal is pre-snapshot.)
290	    // ── C1 gate (plan_verified): no plan is produced for an unverified release. ──
291	    use crate::release::{ReleaseMetadata, SignedRelease, VerifyPolicy};
292	    use ed25519_dalek::{Signer, SigningKey};
293	    use std::collections::{BTreeMap, BTreeSet};
294
295	    fn signed_for(sk: &SigningKey, meta: &ReleaseMetadata) -> SignedRelease {
296	        let metadata_json = serde_json::to_string(meta).unwrap();
297	        let sig = sk.sign(metadata_json.as_bytes());
298	        let signature_hex = sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
299	        SignedRelease { metadata_json, signature_hex }
300	    }
301
302	    fn gate_meta(version: u64, artifact: &[u8]) -> ReleaseMetadata {
303	        ReleaseMetadata {
304	            version,
305	            channel: "stable".to_string(),
306	            expires_at_ms: 10_000,
307	            key_id: "k".to_string(),
308	            artifact_sha256: crate::release::sha256_hex(artifact),
309	            brain_ipc_version: IPC_PROTOCOL_VERSION,
310	            broker_resource_abi: BROKER_RESOURCE_ABI,
311	        }
312	    }
313
314	    fn gate_policy(sk: &SigningKey) -> VerifyPolicy {
315	        let mut trusted = BTreeMap::new();
316	        trusted.insert("k".to_string(), sk.verifying_key());
317	        VerifyPolicy {
318	            trusted_keys: trusted,
319	            revoked_keys: BTreeSet::new(),
320	            pinned_channel: "stable".to_string(),
321	            current_version: 5,
322	            now_ms: 1_000,
323	        }
324	    }
325
326	    // [unit->REQ-UPD-2] a verified release yields a plan; an apply can then run.
327	    // The verified release's spec classifies brain-only (same broker ABI, hostable
328	    // brain) — the routine path.
329	    #[test]
330	    fn plan_verified_yields_a_plan_for_a_valid_release() {
331	        let sk = SigningKey::from_bytes(&[3u8; 32]);
332	        let artifact = b"new binary";
333	        let signed = signed_for(&sk, &gate_meta(6, artifact));
334	        let plan = plan_verified(&BrokerAbi::current(), &signed, artifact, &gate_policy(&sk))
335	            .expect("a valid release produces a plan");
336	        assert_eq!(plan.class, UpdateClass::BrainOnly);
337	    }
338
339	    // [unit->REQ-UPD-2] a release that fails verification produces NO plan — there
340	    // is nothing for apply_brain_only to act on, so an unverified binary can never
341	    // reach a handoff. (Rollback stands in for the whole reject family, each of
342	    // which is covered in release.rs.)
343	    #[test]
344	    fn plan_verified_produces_no_plan_for_a_rejected_release() {
345	        let sk = SigningKey::from_bytes(&[3u8; 32]);
346	        let artifact = b"old binary";
347	        // version 4 <= current 5 → rollback.
348	        let signed = signed_for(&sk, &gate_meta(4, artifact));
349	        let got = plan_verified(&BrokerAbi::current(), &signed, artifact, &gate_policy(&sk));
350	        assert!(matches!(got, Err(crate::release::RejectReason::Rollback { .. })));
351
352	        // A bytes/digest mismatch also yields no plan.
353	        let signed_ok = signed_for(&sk, &gate_meta(6, b"the signed bytes"));
354	        let wrong = plan_verified(&BrokerAbi::current(), &signed_ok, b"different bytes", &gate_policy(&sk));
355	        assert!(matches!(wrong, Err(crate::release::RejectReason::ArtifactMismatch { .. })));
356	    }
357
358	    #[test]
359	    fn apply_refuses_non_brain_only_plan() {
360	        let running = BrokerAbi::current();
361	        let breaking = plan_update(
362	            &running,
363	            ReleaseSpec { brain_ipc_version: 1, broker_resource_abi: running.resource_abi + 1 },
364	        );
365	        assert_eq!(breaking.class, UpdateClass::BrokerBreaking);
366
367	        // We cannot construct a real Brain without a broker here (that is the
368	        // E2E in tests/update.rs); assert the class-routing rule directly — the
369	        // single source of truth the apply guard consults.
370	        assert_ne!(breaking.class, UpdateClass::BrainOnly);
371	    }
372	}
373

//! 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, RejectReason, SignedRelease, 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()))
}

/// 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).
// [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::{ReleaseMetadata, SignedRelease, 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 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,
        }
    }

    // [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 { .. })));
    }

    #[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);
    }
}


1	//! Release metadata + signature verification (C1) — the gate that decides which
2	//! binary is even *eligible* to be handed off (ADR-0004 §D).
3	//!
4	//! ADR-0004 makes signing **mandatory**: peer-propagation (M4) otherwise lets one
5	//! compromised node poison the subnet, so every binary is signature-verified
6	//! before handoff regardless of source ([`REQ-UPD-2`]). Signature alone is
7	//! insufficient (codex SERIOUS #5), so v1 also enforces the
8	//! [`REQ-HAZARD-UPDATE-ROLLBACK`] hardening set:
9	//!
10	//! - **monotonic version** — a release whose version is not strictly greater than
11	//!   the running build is rejected (downgrade attack / accidental rollback);
12	//! - **metadata expiry** — signed metadata past its `expires_at_ms` is rejected
13	//!   (a captured old-but-valid release cannot be replayed forever);
14	//! - **channel pinning** — a release for a channel other than the pinned one is
15	//!   rejected (a `beta` build cannot be slipped onto a `stable` node);
16	//! - **key rotation + revocation** — the signing key is selected by `key_id` from
17	//!   a trusted set (rotation = add a key), and a revoked `key_id` is rejected even
18	//!   with an otherwise-valid signature.
19	//!
20	//! The signature covers the **metadata**, and the metadata binds the binary by
21	//! its SHA-256 ([`ReleaseMetadata::artifact_sha256`]) — so [`verify_artifact`]
22	//! confirms the actual bytes match what was signed. The two together mean a valid
23	//! signature attests to *these exact binary bytes*, not just a manifest.
24	//!
25	//! Verification is pure (no I/O, no clock): the caller passes `now_ms` and the
26	//! artifact bytes, so the policy is fully testable and the same code runs in the
27	//! daemon and in tests. The C0 [`crate::update`] engine consumes the verified
28	//! release; this module never triggers a handoff itself.
29	// [impl->REQ-UPD-2]
30
31	use std::collections::{BTreeMap, BTreeSet};
32
33	use ed25519_dalek::{Signature, Verifier, VerifyingKey};
34	use serde::{Deserialize, Serialize};
35	use sha2::{Digest, Sha256};
36
37	use crate::update::ReleaseSpec;
38
39	/// The signed description of a release. Serialized to the exact bytes that get
40	/// signed; the signature in [`SignedRelease`] is computed over `metadata_json`'s
41	/// UTF-8 bytes verbatim, so verification never re-serializes (no canonicalization
42	/// dependency — we verify the bytes we were given).
43	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44	pub struct ReleaseMetadata {
45	    /// Monotonic release version. An update is accepted only if this is strictly
46	    /// greater than the running build's version (rollback rejection).
47	    pub version: u64,
48	    /// The release channel (e.g. `stable`, `beta`). Must equal the node's pin.
49	    pub channel: String,
50	    /// Wall-clock expiry of this signed metadata (epoch ms). Past it ⇒ rejected.
51	    pub expires_at_ms: u64,
52	    /// Which release key signed this — selects the verifying key + drives
53	    /// rotation/revocation.
54	    pub key_id: String,
55	    /// Lowercase-hex SHA-256 of the release binary the signature attests to.
56	    pub artifact_sha256: String,
57	    /// The IPC protocol version the release's **brain** speaks (ADR-0004 §A axis,
58	    /// fed to [`crate::update::classify`] once the release is verified).
59	    pub brain_ipc_version: u32,
60	    /// The held-resource ABI the release's **broker** implements (ADR-0004 §A axis).
61	    pub broker_resource_abi: u32,
62	}
63
64	impl ReleaseMetadata {
65	    /// The update-class inputs this verified release carries (ADR-0004 §A).
66	    pub fn spec(&self) -> ReleaseSpec {
67	        ReleaseSpec {
68	            brain_ipc_version: self.brain_ipc_version,
69	            broker_resource_abi: self.broker_resource_abi,
70	        }
71	    }
72	}
73
74	/// A release's signed metadata: the exact signed bytes plus the detached Ed25519
75	/// signature (lowercase hex, 64 bytes / 128 hex chars). Transport/on-disk shape.
76	#[derive(Debug, Clone, Serialize, Deserialize)]
77	pub struct SignedRelease {
78	    /// The exact UTF-8 bytes that were signed (a serialized [`ReleaseMetadata`]).
79	    pub metadata_json: String,
80	    /// The Ed25519 signature over `metadata_json`'s bytes, lowercase hex.
81	    pub signature_hex: String,
82	}
83
84	/// The trust policy a node verifies a release against. Pure data — no clock, no
85	/// I/O — so verification is deterministic and testable.
86	#[derive(Debug, Clone)]
87	pub struct VerifyPolicy {
88	    /// Trusted release keys by `key_id`. Rotation = add a new id; old ids may
89	    /// linger until retired.
90	    pub trusted_keys: BTreeMap<String, VerifyingKey>,
91	    /// Explicitly revoked `key_id`s — rejected even with a valid signature.
92	    pub revoked_keys: BTreeSet<String>,
93	    /// The channel this node is pinned to.
94	    pub pinned_channel: String,
95	    /// The running build's version — an update must exceed it (monotonic).
96	    pub current_version: u64,
97	    /// Wall-clock now (epoch ms) for the expiry check (caller-supplied).
98	    pub now_ms: u64,
99	}
100
101	/// Why a release was refused. Every reject is loud + typed (the 2.3/§D failure
102	/// mode is a silent accept of a bad binary).
103	#[derive(Debug, Clone, PartialEq, Eq)]
104	pub enum RejectReason {
105	    /// The metadata JSON or signature hex could not be parsed.
106	    Malformed(String),
107	    /// The `key_id` is not in the trusted set.
108	    UnknownKey(String),
109	    /// The `key_id` is on the revocation list.
110	    RevokedKey(String),
111	    /// The signature did not verify against the selected key.
112	    BadSignature,
113	    /// The release targets a channel other than the node's pin.
114	    ChannelMismatch { pinned: String, got: String },
115	    /// The signed metadata has expired.
116	    Expired { now_ms: u64, expires_at_ms: u64 },
117	    /// The release version is not strictly greater than the running version.
118	    Rollback { current: u64, candidate: u64 },
119	    /// The actual binary's digest does not match the signed `artifact_sha256`.
120	    ArtifactMismatch { signed: String, actual: String },
121	}
122
123	impl std::fmt::Display for RejectReason {
124	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125	        match self {
126	            RejectReason::Malformed(e) => write!(f, "malformed release: {e}"),
127	            RejectReason::UnknownKey(k) => write!(f, "unknown signing key id {k:?}"),
128	            RejectReason::RevokedKey(k) => write!(f, "revoked signing key id {k:?}"),
129	            RejectReason::BadSignature => write!(f, "signature did not verify"),
130	            RejectReason::ChannelMismatch { pinned, got } => {
131	                write!(f, "channel {got:?} does not match pinned {pinned:?}")
132	            }
133	            RejectReason::Expired { now_ms, expires_at_ms } => {
134	                write!(f, "release metadata expired ({now_ms} > {expires_at_ms})")
135	            }
136	            RejectReason::Rollback { current, candidate } => {
137	                write!(f, "rollback rejected: candidate {candidate} not greater than current {current}")
138	            }
139	            RejectReason::ArtifactMismatch { signed, actual } => {
140	                write!(f, "artifact digest mismatch: signed {signed}, actual {actual}")
141	            }
142	        }
143	    }
144	}
145
146	impl std::error::Error for RejectReason {}
147
148	/// Verify a release's **metadata + signature + hardening** against `policy`
149	/// ([`REQ-UPD-2`], [`REQ-HAZARD-UPDATE-ROLLBACK`]). Returns the authenticated
150	/// [`ReleaseMetadata`] on success. Does **not** check the binary bytes — call
151	/// [`verify_artifact`] with the downloaded binary next (the signature binds it
152	/// by digest, but the digest must still be checked against the real bytes).
153	///
154	/// Check order is deliberate: identify + authenticate the signer *first* (an
155	/// unknown/revoked key or a bad signature is rejected before any field of the
156	/// payload is trusted for a policy decision), then enforce the channel / expiry /
157	/// monotonic-version policy on the now-authenticated metadata.
158	// [impl->REQ-UPD-2]
159	// [impl->REQ-HAZARD-UPDATE-ROLLBACK]
160	pub fn verify_metadata(
161	    signed: &SignedRelease,
162	    policy: &VerifyPolicy,
163	) -> Result<ReleaseMetadata, RejectReason> {
164	    let meta = parse_meta(signed)?;
165
166	    // A revoked key's signatures are all void — reject before spending a verify.
167	    if policy.revoked_keys.contains(&meta.key_id) {
168	        return Err(RejectReason::RevokedKey(meta.key_id));
169	    }
170	    let key = policy
171	        .trusted_keys
172	        .get(&meta.key_id)
173	        .ok_or_else(|| RejectReason::UnknownKey(meta.key_id.clone()))?;
174	    check_signature(signed, key)?;
175
176	    // ── The metadata is now authentic; enforce the §D hardening policy. ──
177	    if meta.channel != policy.pinned_channel {
178	        return Err(RejectReason::ChannelMismatch {
179	            pinned: policy.pinned_channel.clone(),
180	            got: meta.channel,
181	        });
182	    }
183	    if policy.now_ms > meta.expires_at_ms {
184	        return Err(RejectReason::Expired { now_ms: policy.now_ms, expires_at_ms: meta.expires_at_ms });
185	    }
186	    if meta.version <= policy.current_version {
187	        return Err(RejectReason::Rollback { current: policy.current_version, candidate: meta.version });
188	    }
189	    Ok(meta)
190	}
191
192	/// Verify a release's **signature only** against an explicitly-supplied key,
193	/// returning the authenticated metadata. No trust-set lookup, no
194	/// channel/expiry/rollback policy — used for **adapter content signing** (C3),
195	/// where the trusted key is the per-adapter key declared in the adapter's
196	/// manifest `[update]` rather than spt-core's release-key set, and the adapter's
197	/// payload only needs authenticity + integrity (paired with [`verify_artifact`]).
198	// [impl->REQ-UPD-5]
199	pub fn verify_signature(
200	    signed: &SignedRelease,
201	    key: &VerifyingKey,
202	) -> Result<ReleaseMetadata, RejectReason> {
203	    let meta = parse_meta(signed)?;
204	    check_signature(signed, key)?;
205	    Ok(meta)
206	}
207
208	/// Parse the signed metadata bytes into a [`ReleaseMetadata`].
209	fn parse_meta(signed: &SignedRelease) -> Result<ReleaseMetadata, RejectReason> {
210	    serde_json::from_str(&signed.metadata_json).map_err(|e| RejectReason::Malformed(e.to_string()))
211	}
212
213	/// Verify the Ed25519 signature over the exact signed metadata bytes with `key`.
214	fn check_signature(signed: &SignedRelease, key: &VerifyingKey) -> Result<(), RejectReason> {
215	    let sig_bytes = hex_decode(&signed.signature_hex).map_err(RejectReason::Malformed)?;
216	    let sig_arr: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
217	        RejectReason::Malformed(format!("signature is {} bytes, want 64", sig_bytes.len()))
218	    })?;
219	    let signature = Signature::from_bytes(&sig_arr);
220	    key.verify(signed.metadata_json.as_bytes(), &signature)
221	        .map_err(|_| RejectReason::BadSignature)
222	}
223
224	/// Parse a hex-encoded Ed25519 public key (64 hex chars / 32 bytes) into a
225	/// [`VerifyingKey`] — the per-adapter key from a manifest `[update]` block (C3).
226	// [impl->REQ-UPD-5]
227	pub fn parse_verifying_key(hex: &str) -> Result<VerifyingKey, RejectReason> {
228	    let bytes = hex_decode(hex).map_err(RejectReason::Malformed)?;
229	    let arr: [u8; 32] = bytes
230	        .as_slice()
231	        .try_into()
232	        .map_err(|_| RejectReason::Malformed(format!("public key is {} bytes, want 32", bytes.len())))?;
233	    VerifyingKey::from_bytes(&arr).map_err(|e| RejectReason::Malformed(e.to_string()))
234	}
235
236	/// Confirm the downloaded binary's bytes match the signed `artifact_sha256`
237	/// ([`REQ-UPD-2`]). Run after [`verify_metadata`] so the signature has already
238	/// attested to the expected digest — this closes the loop to the real bytes.
239	// [impl->REQ-UPD-2]
240	pub fn verify_artifact(meta: &ReleaseMetadata, artifact: &[u8]) -> Result<(), RejectReason> {
241	    let actual = sha256_hex(artifact);
242	    if actual == meta.artifact_sha256.to_ascii_lowercase() {
243	        Ok(())
244	    } else {
245	        Err(RejectReason::ArtifactMismatch { signed: meta.artifact_sha256.clone(), actual })
246	    }
247	}
248
249	/// Lowercase-hex SHA-256 of `bytes` — the artifact-digest format used in
250	/// [`ReleaseMetadata::artifact_sha256`].
251	pub fn sha256_hex(bytes: &[u8]) -> String {
252	    let mut hasher = Sha256::new();
253	    hasher.update(bytes);
254	    hex_encode(&hasher.finalize())
255	}
256
257	/// Lowercase-hex encode (kept inline to avoid a `hex` crate edge for one helper).
258	fn hex_encode(bytes: &[u8]) -> String {
259	    const HEX: &[u8; 16] = b"0123456789abcdef";
260	    let mut s = String::with_capacity(bytes.len() * 2);
261	    for b in bytes {
262	        s.push(HEX[(b >> 4) as usize] as char);
263	        s.push(HEX[(b & 0xf) as usize] as char);
264	    }
265	    s
266	}
267
268	/// Decode lowercase/uppercase hex. Errors (as a message) on odd length or a
269	/// non-hex digit.
270	fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
271	    if !s.len().is_multiple_of(2) {
272	        return Err(format!("hex length {} is odd", s.len()));
273	    }
274	    let val = |c: u8| -> Result<u8, String> {
275	        match c {
276	            b'0'..=b'9' => Ok(c - b'0'),
277	            b'a'..=b'f' => Ok(c - b'a' + 10),
278	            b'A'..=b'F' => Ok(c - b'A' + 10),
279	            other => Err(format!("non-hex byte {other:#x}")),
280	        }
281	    };
282	    let b = s.as_bytes();
283	    let mut out = Vec::with_capacity(b.len() / 2);
284	    for pair in b.chunks_exact(2) {
285	        out.push((val(pair[0])? << 4) | val(pair[1])?);
286	    }
287	    Ok(out)
288	}
289
290	#[cfg(test)]
291	mod tests {
292	    use super::*;
293	    use crate::update::BROKER_RESOURCE_ABI;
294	    use ed25519_dalek::{Signer, SigningKey};
295
296	    const KEY_ID: &str = "rel-2026";
297	    const CHANNEL: &str = "stable";
298
299	    /// A deterministic signing key (no RNG — test-stable). Real release tooling
300	    /// mints one from a CSPRNG; verification is identical either way.
301	    fn signing_key(seed: u8) -> SigningKey {
302	        SigningKey::from_bytes(&[seed; 32])
303	    }
304
305	    /// Build + sign a release for `artifact`, returning the wire form.
306	    fn sign_release(sk: &SigningKey, meta: &ReleaseMetadata) -> SignedRelease {
307	        let metadata_json = serde_json::to_string(meta).unwrap();
308	        let sig = sk.sign(metadata_json.as_bytes());
309	        SignedRelease { metadata_json, signature_hex: hex_encode(&sig.to_bytes()) }
310	    }
311
312	    fn meta_for(version: u64, artifact: &[u8]) -> ReleaseMetadata {
313	        ReleaseMetadata {
314	            version,
315	            channel: CHANNEL.to_string(),
316	            expires_at_ms: 10_000,
317	            key_id: KEY_ID.to_string(),
318	            artifact_sha256: sha256_hex(artifact),
319	            brain_ipc_version: 1,
320	            broker_resource_abi: BROKER_RESOURCE_ABI,
321	        }
322	    }
323
324	    fn policy(sk: &SigningKey) -> VerifyPolicy {
325	        let mut trusted = BTreeMap::new();
326	        trusted.insert(KEY_ID.to_string(), sk.verifying_key());
327	        VerifyPolicy {
328	            trusted_keys: trusted,
329	            revoked_keys: BTreeSet::new(),
330	            pinned_channel: CHANNEL.to_string(),
331	            current_version: 5,
332	            now_ms: 1_000, // before the 10_000 expiry
333	        }
334	    }
335
336	    // [unit->REQ-UPD-2] a correctly-signed, newer, in-channel, unexpired release
337	    // from a trusted key verifies, and its bytes match the signed digest.
338	    #[test]
339	    fn valid_release_verifies_and_artifact_matches() {
340	        let sk = signing_key(7);
341	        let artifact = b"the new spt binary bytes";
342	        let signed = sign_release(&sk, &meta_for(6, artifact));
343
344	        let meta = verify_metadata(&signed, &policy(&sk)).expect("valid release verifies");
345	        assert_eq!(meta.version, 6);
346	        verify_artifact(&meta, artifact).expect("artifact digest matches the signed value");
347	        assert_eq!(meta.spec().broker_resource_abi, BROKER_RESOURCE_ABI);
348	    }
349
350	    // [unit->REQ-UPD-2] tampering with the signed bytes after signing ⇒ the
351	    // signature no longer verifies (the metadata is not authentic).
352	    #[test]
353	    fn tampered_metadata_fails_signature() {
354	        let sk = signing_key(7);
355	        let mut signed = sign_release(&sk, &meta_for(6, b"bin"));
356	        // Flip the version in the JSON without re-signing.
357	        signed.metadata_json = signed.metadata_json.replace("\"version\":6", "\"version\":9");
358	        assert_eq!(verify_metadata(&signed, &policy(&sk)), Err(RejectReason::BadSignature));
359	    }
360
361	    // [unit->REQ-UPD-2] a signature from a key the node does not trust ⇒ UnknownKey.
362	    #[test]
363	    fn untrusted_key_is_unknown() {
364	        let real = signing_key(7);
365	        let attacker = signing_key(9);
366	        let signed = sign_release(&attacker, &meta_for(6, b"bin"));
367	        // Policy trusts only `real` under KEY_ID; the attacker signed under the
368	        // same key_id but with a different key → signature won't match the trusted
369	        // verifying key. (UnknownKey would fire if the id itself were untrusted.)
370	        assert_eq!(verify_metadata(&signed, &policy(&real)), Err(RejectReason::BadSignature));
371
372	        // A genuinely unknown key_id is UnknownKey.
373	        let mut meta = meta_for(6, b"bin");
374	        meta.key_id = "no-such-key".to_string();
375	        let signed2 = sign_release(&attacker, &meta);
376	        assert_eq!(
377	            verify_metadata(&signed2, &policy(&real)),
378	            Err(RejectReason::UnknownKey("no-such-key".to_string()))
379	        );
380	    }
381
382	    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a revoked key is rejected even with an
383	    // otherwise-valid signature.
384	    #[test]
385	    fn revoked_key_is_rejected() {
386	        let sk = signing_key(7);
387	        let signed = sign_release(&sk, &meta_for(6, b"bin"));
388	        let mut pol = policy(&sk);
389	        pol.revoked_keys.insert(KEY_ID.to_string());
390	        assert_eq!(verify_metadata(&signed, &pol), Err(RejectReason::RevokedKey(KEY_ID.to_string())));
391	    }
392
393	    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a version not strictly greater than the
394	    // running build is a rollback (both equal and lower are rejected).
395	    #[test]
396	    fn non_monotonic_version_is_rollback() {
397	        let sk = signing_key(7);
398	        let pol = policy(&sk); // current_version = 5
399	        for candidate in [5u64, 4, 0] {
400	            let signed = sign_release(&sk, &meta_for(candidate, b"bin"));
401	            assert_eq!(
402	                verify_metadata(&signed, &pol),
403	                Err(RejectReason::Rollback { current: 5, candidate }),
404	                "version {candidate} must be rejected as a rollback"
405	            );
406	        }
407	    }
408
409	    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] expired signed metadata is rejected even
410	    // when everything else is valid (replay of an old-but-signed release).
411	    #[test]
412	    fn expired_metadata_is_rejected() {
413	        let sk = signing_key(7);
414	        let signed = sign_release(&sk, &meta_for(6, b"bin")); // expires_at_ms = 10_000
415	        let mut pol = policy(&sk);
416	        pol.now_ms = 20_000; // past expiry
417	        assert_eq!(
418	            verify_metadata(&signed, &pol),
419	            Err(RejectReason::Expired { now_ms: 20_000, expires_at_ms: 10_000 })
420	        );
421	    }
422
423	    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a release for another channel is rejected
424	    // (channel pinning: a beta build cannot land on a stable node).
425	    #[test]
426	    fn channel_mismatch_is_rejected() {
427	        let sk = signing_key(7);
428	        let mut meta = meta_for(6, b"bin");
429	        meta.channel = "beta".to_string();
430	        let signed = sign_release(&sk, &meta);
431	        assert_eq!(
432	            verify_metadata(&signed, &policy(&sk)),
433	            Err(RejectReason::ChannelMismatch { pinned: CHANNEL.to_string(), got: "beta".to_string() })
434	        );
435	    }
436
437	    // [unit->REQ-UPD-2] a binary whose bytes do not hash to the signed digest is
438	    // rejected — the signature attests to specific bytes, not just a manifest.
439	    #[test]
440	    fn artifact_digest_mismatch_is_rejected() {
441	        let sk = signing_key(7);
442	        let signed = sign_release(&sk, &meta_for(6, b"the signed bytes"));
443	        let meta = verify_metadata(&signed, &policy(&sk)).expect("metadata verifies");
444	        // A different binary than the one whose digest was signed.
445	        let tampered = b"DIFFERENT bytes than were signed";
446	        assert!(matches!(
447	            verify_artifact(&meta, tampered),
448	            Err(RejectReason::ArtifactMismatch { .. })
449	        ));
450	    }
451
452	    #[test]
453	    fn hex_roundtrips() {
454	        let bytes = [0u8, 1, 15, 16, 255, 128, 7];
455	        assert_eq!(hex_decode(&hex_encode(&bytes)).unwrap(), bytes);
456	        assert!(hex_decode("abc").is_err()); // odd length
457	        assert!(hex_decode("zz").is_err()); // non-hex
458	    }
459	}
460

//! Release metadata + signature verification (C1) — the gate that decides which
//! binary is even *eligible* to be handed off (ADR-0004 §D).
//!
//! ADR-0004 makes signing **mandatory**: peer-propagation (M4) otherwise lets one
//! compromised node poison the subnet, so every binary is signature-verified
//! before handoff regardless of source ([`REQ-UPD-2`]). Signature alone is
//! insufficient (codex SERIOUS #5), so v1 also enforces the
//! [`REQ-HAZARD-UPDATE-ROLLBACK`] hardening set:
//!
//! - **monotonic version** — a release whose version is not strictly greater than
//!   the running build is rejected (downgrade attack / accidental rollback);
//! - **metadata expiry** — signed metadata past its `expires_at_ms` is rejected
//!   (a captured old-but-valid release cannot be replayed forever);
//! - **channel pinning** — a release for a channel other than the pinned one is
//!   rejected (a `beta` build cannot be slipped onto a `stable` node);
//! - **key rotation + revocation** — the signing key is selected by `key_id` from
//!   a trusted set (rotation = add a key), and a revoked `key_id` is rejected even
//!   with an otherwise-valid signature.
//!
//! The signature covers the **metadata**, and the metadata binds the binary by
//! its SHA-256 ([`ReleaseMetadata::artifact_sha256`]) — so [`verify_artifact`]
//! confirms the actual bytes match what was signed. The two together mean a valid
//! signature attests to *these exact binary bytes*, not just a manifest.
//!
//! Verification is pure (no I/O, no clock): the caller passes `now_ms` and the
//! artifact bytes, so the policy is fully testable and the same code runs in the
//! daemon and in tests. The C0 [`crate::update`] engine consumes the verified
//! release; this module never triggers a handoff itself.
// [impl->REQ-UPD-2]

use std::collections::{BTreeMap, BTreeSet};

use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::update::ReleaseSpec;

/// The signed description of a release. Serialized to the exact bytes that get
/// signed; the signature in [`SignedRelease`] is computed over `metadata_json`'s
/// UTF-8 bytes verbatim, so verification never re-serializes (no canonicalization
/// dependency — we verify the bytes we were given).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReleaseMetadata {
    /// Monotonic release version. An update is accepted only if this is strictly
    /// greater than the running build's version (rollback rejection).
    pub version: u64,
    /// The release channel (e.g. `stable`, `beta`). Must equal the node's pin.
    pub channel: String,
    /// Wall-clock expiry of this signed metadata (epoch ms). Past it ⇒ rejected.
    pub expires_at_ms: u64,
    /// Which release key signed this — selects the verifying key + drives
    /// rotation/revocation.
    pub key_id: String,
    /// Lowercase-hex SHA-256 of the release binary the signature attests to.
    pub artifact_sha256: String,
    /// The IPC protocol version the release's **brain** speaks (ADR-0004 §A axis,
    /// fed to [`crate::update::classify`] once the release is verified).
    pub brain_ipc_version: u32,
    /// The held-resource ABI the release's **broker** implements (ADR-0004 §A axis).
    pub broker_resource_abi: u32,
}

impl ReleaseMetadata {
    /// The update-class inputs this verified release carries (ADR-0004 §A).
    pub fn spec(&self) -> ReleaseSpec {
        ReleaseSpec {
            brain_ipc_version: self.brain_ipc_version,
            broker_resource_abi: self.broker_resource_abi,
        }
    }
}

/// A release's signed metadata: the exact signed bytes plus the detached Ed25519
/// signature (lowercase hex, 64 bytes / 128 hex chars). Transport/on-disk shape.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedRelease {
    /// The exact UTF-8 bytes that were signed (a serialized [`ReleaseMetadata`]).
    pub metadata_json: String,
    /// The Ed25519 signature over `metadata_json`'s bytes, lowercase hex.
    pub signature_hex: String,
}

/// The trust policy a node verifies a release against. Pure data — no clock, no
/// I/O — so verification is deterministic and testable.
#[derive(Debug, Clone)]
pub struct VerifyPolicy {
    /// Trusted release keys by `key_id`. Rotation = add a new id; old ids may
    /// linger until retired.
    pub trusted_keys: BTreeMap<String, VerifyingKey>,
    /// Explicitly revoked `key_id`s — rejected even with a valid signature.
    pub revoked_keys: BTreeSet<String>,
    /// The channel this node is pinned to.
    pub pinned_channel: String,
    /// The running build's version — an update must exceed it (monotonic).
    pub current_version: u64,
    /// Wall-clock now (epoch ms) for the expiry check (caller-supplied).
    pub now_ms: u64,
}

/// Why a release was refused. Every reject is loud + typed (the 2.3/§D failure
/// mode is a silent accept of a bad binary).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RejectReason {
    /// The metadata JSON or signature hex could not be parsed.
    Malformed(String),
    /// The `key_id` is not in the trusted set.
    UnknownKey(String),
    /// The `key_id` is on the revocation list.
    RevokedKey(String),
    /// The signature did not verify against the selected key.
    BadSignature,
    /// The release targets a channel other than the node's pin.
    ChannelMismatch { pinned: String, got: String },
    /// The signed metadata has expired.
    Expired { now_ms: u64, expires_at_ms: u64 },
    /// The release version is not strictly greater than the running version.
    Rollback { current: u64, candidate: u64 },
    /// The actual binary's digest does not match the signed `artifact_sha256`.
    ArtifactMismatch { signed: String, actual: String },
}

impl std::fmt::Display for RejectReason {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RejectReason::Malformed(e) => write!(f, "malformed release: {e}"),
            RejectReason::UnknownKey(k) => write!(f, "unknown signing key id {k:?}"),
            RejectReason::RevokedKey(k) => write!(f, "revoked signing key id {k:?}"),
            RejectReason::BadSignature => write!(f, "signature did not verify"),
            RejectReason::ChannelMismatch { pinned, got } => {
                write!(f, "channel {got:?} does not match pinned {pinned:?}")
            }
            RejectReason::Expired { now_ms, expires_at_ms } => {
                write!(f, "release metadata expired ({now_ms} > {expires_at_ms})")
            }
            RejectReason::Rollback { current, candidate } => {
                write!(f, "rollback rejected: candidate {candidate} not greater than current {current}")
            }
            RejectReason::ArtifactMismatch { signed, actual } => {
                write!(f, "artifact digest mismatch: signed {signed}, actual {actual}")
            }
        }
    }
}

impl std::error::Error for RejectReason {}

/// Verify a release's **metadata + signature + hardening** against `policy`
/// ([`REQ-UPD-2`], [`REQ-HAZARD-UPDATE-ROLLBACK`]). Returns the authenticated
/// [`ReleaseMetadata`] on success. Does **not** check the binary bytes — call
/// [`verify_artifact`] with the downloaded binary next (the signature binds it
/// by digest, but the digest must still be checked against the real bytes).
///
/// Check order is deliberate: identify + authenticate the signer *first* (an
/// unknown/revoked key or a bad signature is rejected before any field of the
/// payload is trusted for a policy decision), then enforce the channel / expiry /
/// monotonic-version policy on the now-authenticated metadata.
// [impl->REQ-UPD-2]
// [impl->REQ-HAZARD-UPDATE-ROLLBACK]
pub fn verify_metadata(
    signed: &SignedRelease,
    policy: &VerifyPolicy,
) -> Result<ReleaseMetadata, RejectReason> {
    let meta = parse_meta(signed)?;

    // A revoked key's signatures are all void — reject before spending a verify.
    if policy.revoked_keys.contains(&meta.key_id) {
        return Err(RejectReason::RevokedKey(meta.key_id));
    }
    let key = policy
        .trusted_keys
        .get(&meta.key_id)
        .ok_or_else(|| RejectReason::UnknownKey(meta.key_id.clone()))?;
    check_signature(signed, key)?;

    // ── The metadata is now authentic; enforce the §D hardening policy. ──
    if meta.channel != policy.pinned_channel {
        return Err(RejectReason::ChannelMismatch {
            pinned: policy.pinned_channel.clone(),
            got: meta.channel,
        });
    }
    if policy.now_ms > meta.expires_at_ms {
        return Err(RejectReason::Expired { now_ms: policy.now_ms, expires_at_ms: meta.expires_at_ms });
    }
    if meta.version <= policy.current_version {
        return Err(RejectReason::Rollback { current: policy.current_version, candidate: meta.version });
    }
    Ok(meta)
}

/// Verify a release's **signature only** against an explicitly-supplied key,
/// returning the authenticated metadata. No trust-set lookup, no
/// channel/expiry/rollback policy — used for **adapter content signing** (C3),
/// where the trusted key is the per-adapter key declared in the adapter's
/// manifest `[update]` rather than spt-core's release-key set, and the adapter's
/// payload only needs authenticity + integrity (paired with [`verify_artifact`]).
// [impl->REQ-UPD-5]
pub fn verify_signature(
    signed: &SignedRelease,
    key: &VerifyingKey,
) -> Result<ReleaseMetadata, RejectReason> {
    let meta = parse_meta(signed)?;
    check_signature(signed, key)?;
    Ok(meta)
}

/// Parse the signed metadata bytes into a [`ReleaseMetadata`].
fn parse_meta(signed: &SignedRelease) -> Result<ReleaseMetadata, RejectReason> {
    serde_json::from_str(&signed.metadata_json).map_err(|e| RejectReason::Malformed(e.to_string()))
}

/// Verify the Ed25519 signature over the exact signed metadata bytes with `key`.
fn check_signature(signed: &SignedRelease, key: &VerifyingKey) -> Result<(), RejectReason> {
    let sig_bytes = hex_decode(&signed.signature_hex).map_err(RejectReason::Malformed)?;
    let sig_arr: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
        RejectReason::Malformed(format!("signature is {} bytes, want 64", sig_bytes.len()))
    })?;
    let signature = Signature::from_bytes(&sig_arr);
    key.verify(signed.metadata_json.as_bytes(), &signature)
        .map_err(|_| RejectReason::BadSignature)
}

/// Parse a hex-encoded Ed25519 public key (64 hex chars / 32 bytes) into a
/// [`VerifyingKey`] — the per-adapter key from a manifest `[update]` block (C3).
// [impl->REQ-UPD-5]
pub fn parse_verifying_key(hex: &str) -> Result<VerifyingKey, RejectReason> {
    let bytes = hex_decode(hex).map_err(RejectReason::Malformed)?;
    let arr: [u8; 32] = bytes
        .as_slice()
        .try_into()
        .map_err(|_| RejectReason::Malformed(format!("public key is {} bytes, want 32", bytes.len())))?;
    VerifyingKey::from_bytes(&arr).map_err(|e| RejectReason::Malformed(e.to_string()))
}

/// Confirm the downloaded binary's bytes match the signed `artifact_sha256`
/// ([`REQ-UPD-2`]). Run after [`verify_metadata`] so the signature has already
/// attested to the expected digest — this closes the loop to the real bytes.
// [impl->REQ-UPD-2]
pub fn verify_artifact(meta: &ReleaseMetadata, artifact: &[u8]) -> Result<(), RejectReason> {
    let actual = sha256_hex(artifact);
    if actual == meta.artifact_sha256.to_ascii_lowercase() {
        Ok(())
    } else {
        Err(RejectReason::ArtifactMismatch { signed: meta.artifact_sha256.clone(), actual })
    }
}

/// Lowercase-hex SHA-256 of `bytes` — the artifact-digest format used in
/// [`ReleaseMetadata::artifact_sha256`].
pub fn sha256_hex(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    hex_encode(&hasher.finalize())
}

/// Lowercase-hex encode (kept inline to avoid a `hex` crate edge for one helper).
fn hex_encode(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push(HEX[(b >> 4) as usize] as char);
        s.push(HEX[(b & 0xf) as usize] as char);
    }
    s
}

/// Decode lowercase/uppercase hex. Errors (as a message) on odd length or a
/// non-hex digit.
fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
    if !s.len().is_multiple_of(2) {
        return Err(format!("hex length {} is odd", s.len()));
    }
    let val = |c: u8| -> Result<u8, String> {
        match c {
            b'0'..=b'9' => Ok(c - b'0'),
            b'a'..=b'f' => Ok(c - b'a' + 10),
            b'A'..=b'F' => Ok(c - b'A' + 10),
            other => Err(format!("non-hex byte {other:#x}")),
        }
    };
    let b = s.as_bytes();
    let mut out = Vec::with_capacity(b.len() / 2);
    for pair in b.chunks_exact(2) {
        out.push((val(pair[0])? << 4) | val(pair[1])?);
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::update::BROKER_RESOURCE_ABI;
    use ed25519_dalek::{Signer, SigningKey};

    const KEY_ID: &str = "rel-2026";
    const CHANNEL: &str = "stable";

    /// A deterministic signing key (no RNG — test-stable). Real release tooling
    /// mints one from a CSPRNG; verification is identical either way.
    fn signing_key(seed: u8) -> SigningKey {
        SigningKey::from_bytes(&[seed; 32])
    }

    /// Build + sign a release for `artifact`, returning the wire form.
    fn sign_release(sk: &SigningKey, meta: &ReleaseMetadata) -> SignedRelease {
        let metadata_json = serde_json::to_string(meta).unwrap();
        let sig = sk.sign(metadata_json.as_bytes());
        SignedRelease { metadata_json, signature_hex: hex_encode(&sig.to_bytes()) }
    }

    fn meta_for(version: u64, artifact: &[u8]) -> ReleaseMetadata {
        ReleaseMetadata {
            version,
            channel: CHANNEL.to_string(),
            expires_at_ms: 10_000,
            key_id: KEY_ID.to_string(),
            artifact_sha256: sha256_hex(artifact),
            brain_ipc_version: 1,
            broker_resource_abi: BROKER_RESOURCE_ABI,
        }
    }

    fn policy(sk: &SigningKey) -> VerifyPolicy {
        let mut trusted = BTreeMap::new();
        trusted.insert(KEY_ID.to_string(), sk.verifying_key());
        VerifyPolicy {
            trusted_keys: trusted,
            revoked_keys: BTreeSet::new(),
            pinned_channel: CHANNEL.to_string(),
            current_version: 5,
            now_ms: 1_000, // before the 10_000 expiry
        }
    }

    // [unit->REQ-UPD-2] a correctly-signed, newer, in-channel, unexpired release
    // from a trusted key verifies, and its bytes match the signed digest.
    #[test]
    fn valid_release_verifies_and_artifact_matches() {
        let sk = signing_key(7);
        let artifact = b"the new spt binary bytes";
        let signed = sign_release(&sk, &meta_for(6, artifact));

        let meta = verify_metadata(&signed, &policy(&sk)).expect("valid release verifies");
        assert_eq!(meta.version, 6);
        verify_artifact(&meta, artifact).expect("artifact digest matches the signed value");
        assert_eq!(meta.spec().broker_resource_abi, BROKER_RESOURCE_ABI);
    }

    // [unit->REQ-UPD-2] tampering with the signed bytes after signing ⇒ the
    // signature no longer verifies (the metadata is not authentic).
    #[test]
    fn tampered_metadata_fails_signature() {
        let sk = signing_key(7);
        let mut signed = sign_release(&sk, &meta_for(6, b"bin"));
        // Flip the version in the JSON without re-signing.
        signed.metadata_json = signed.metadata_json.replace("\"version\":6", "\"version\":9");
        assert_eq!(verify_metadata(&signed, &policy(&sk)), Err(RejectReason::BadSignature));
    }

    // [unit->REQ-UPD-2] a signature from a key the node does not trust ⇒ UnknownKey.
    #[test]
    fn untrusted_key_is_unknown() {
        let real = signing_key(7);
        let attacker = signing_key(9);
        let signed = sign_release(&attacker, &meta_for(6, b"bin"));
        // Policy trusts only `real` under KEY_ID; the attacker signed under the
        // same key_id but with a different key → signature won't match the trusted
        // verifying key. (UnknownKey would fire if the id itself were untrusted.)
        assert_eq!(verify_metadata(&signed, &policy(&real)), Err(RejectReason::BadSignature));

        // A genuinely unknown key_id is UnknownKey.
        let mut meta = meta_for(6, b"bin");
        meta.key_id = "no-such-key".to_string();
        let signed2 = sign_release(&attacker, &meta);
        assert_eq!(
            verify_metadata(&signed2, &policy(&real)),
            Err(RejectReason::UnknownKey("no-such-key".to_string()))
        );
    }

    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a revoked key is rejected even with an
    // otherwise-valid signature.
    #[test]
    fn revoked_key_is_rejected() {
        let sk = signing_key(7);
        let signed = sign_release(&sk, &meta_for(6, b"bin"));
        let mut pol = policy(&sk);
        pol.revoked_keys.insert(KEY_ID.to_string());
        assert_eq!(verify_metadata(&signed, &pol), Err(RejectReason::RevokedKey(KEY_ID.to_string())));
    }

    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a version not strictly greater than the
    // running build is a rollback (both equal and lower are rejected).
    #[test]
    fn non_monotonic_version_is_rollback() {
        let sk = signing_key(7);
        let pol = policy(&sk); // current_version = 5
        for candidate in [5u64, 4, 0] {
            let signed = sign_release(&sk, &meta_for(candidate, b"bin"));
            assert_eq!(
                verify_metadata(&signed, &pol),
                Err(RejectReason::Rollback { current: 5, candidate }),
                "version {candidate} must be rejected as a rollback"
            );
        }
    }

    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] expired signed metadata is rejected even
    // when everything else is valid (replay of an old-but-signed release).
    #[test]
    fn expired_metadata_is_rejected() {
        let sk = signing_key(7);
        let signed = sign_release(&sk, &meta_for(6, b"bin")); // expires_at_ms = 10_000
        let mut pol = policy(&sk);
        pol.now_ms = 20_000; // past expiry
        assert_eq!(
            verify_metadata(&signed, &pol),
            Err(RejectReason::Expired { now_ms: 20_000, expires_at_ms: 10_000 })
        );
    }

    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a release for another channel is rejected
    // (channel pinning: a beta build cannot land on a stable node).
    #[test]
    fn channel_mismatch_is_rejected() {
        let sk = signing_key(7);
        let mut meta = meta_for(6, b"bin");
        meta.channel = "beta".to_string();
        let signed = sign_release(&sk, &meta);
        assert_eq!(
            verify_metadata(&signed, &policy(&sk)),
            Err(RejectReason::ChannelMismatch { pinned: CHANNEL.to_string(), got: "beta".to_string() })
        );
    }

    // [unit->REQ-UPD-2] a binary whose bytes do not hash to the signed digest is
    // rejected — the signature attests to specific bytes, not just a manifest.
    #[test]
    fn artifact_digest_mismatch_is_rejected() {
        let sk = signing_key(7);
        let signed = sign_release(&sk, &meta_for(6, b"the signed bytes"));
        let meta = verify_metadata(&signed, &policy(&sk)).expect("metadata verifies");
        // A different binary than the one whose digest was signed.
        let tampered = b"DIFFERENT bytes than were signed";
        assert!(matches!(
            verify_artifact(&meta, tampered),
            Err(RejectReason::ArtifactMismatch { .. })
        ));
    }

    #[test]
    fn hex_roundtrips() {
        let bytes = [0u8, 1, 15, 16, 255, 128, 7];
        assert_eq!(hex_decode(&hex_encode(&bytes)).unwrap(), bytes);
        assert!(hex_decode("abc").is_err()); // odd length
        assert!(hex_decode("zz").is_err()); // non-hex
    }
}


Read adapter_update + transport seams (xfer/spool — D7 propagation likely rides D3 distribution machinery).


1	//! Adapter ripple-update conduct (C3) — spt-core updates the whole stack: self
2	//! first, then each registered adapter via its manifest `[update]` avenue, with
3	//! **adapter content signing** (REQ-UPD-5, REQ-SEAM-UPDATE, ADR-0004 §D).
4	//!
5	//! After spt-core's own update completes (the C0–C2 engine), it conducts a
6	//! ripple over its registered adapters. Each adapter declares how it updates in
7	//! its manifest `[update]` block ([`Update`]); this module turns that directive
8	//! into a per-adapter [`AdapterUpdateOutcome`], dispatched by avenue:
9	//!
10	//! - **`file_pull`** — spt-core controls the bytes, so it *verifies* them: the
11	//!   pulled payload is checked against the adapter's **own** Ed25519 key (declared
12	//!   in the manifest — the adapter author signs their releases; spt-core's release
13	//!   key stays scoped to spt-core) plus the SHA-256 artifact binding. Authentic +
14	//!   intact ⇒ [`AdapterUpdateOutcome::Verified`]; otherwise
15	//!   [`AdapterUpdateOutcome::Rejected`].
16	//! - **`delegated`** — the adapter's own updater installs the content (e.g.
17	//!   `claude.exe plugin update`); spt-core never sees those bytes, so there is
18	//!   nothing for it to hash. It delegates the trust **only** when the adapter
19	//!   attests `self_verifies` (its updater checks its own signatures) ⇒
20	//!   [`AdapterUpdateOutcome::Delegate`]; an unattested delegated update is
21	//!   [`AdapterUpdateOutcome::Skipped`] as unverifiable.
22	//!
23	//! The actual byte transport for `file_pull` (the network pull) is M4
24	//! (`REQ-UPD-1`); v1 receives the payload via self-fetch / out-of-band and feeds
25	//! it here. This module is the **decision + verification** layer — it does not
26	//! itself fetch or exec.
27	// [impl->REQ-UPD-5]
28	// [impl->REQ-SEAM-UPDATE]
29
30	use spt_runtime::manifest::{Update, UpdateAvenue};
31
32	use crate::release::{
33	    parse_verifying_key, verify_artifact, verify_signature, ReleaseMetadata, RejectReason,
34	    SignedRelease,
35	};
36
37	/// A pulled `file_pull` payload awaiting verification: the adapter's signed
38	/// release metadata plus the actual bytes it attests to. Supplied by the
39	/// self-fetch / out-of-band layer (the network pull is M4).
40	#[derive(Debug, Clone)]
41	pub struct FilePullPayload {
42	    /// The adapter's signed release metadata (signed with the adapter's key).
43	    pub signed: SignedRelease,
44	    /// The pulled artifact bytes (hashed + matched against the signed digest).
45	    pub bytes: Vec<u8>,
46	}
47
48	/// Why an adapter's ripple-update was not applied as a verified content update.
49	#[derive(Debug, Clone, PartialEq, Eq)]
50	pub enum SkipReason {
51	    /// The adapter declares no `[update]` avenue — nothing to conduct.
52	    NoAvenue,
53	    /// A `delegated` adapter did not attest `self_verifies`, so its content is
54	    /// unverifiable by spt-core — skipped rather than trusted blindly.
55	    UnverifiableDelegated,
56	    /// A `delegated` adapter attested `self_verifies` but declared no `command`.
57	    DelegatedMissingCommand,
58	    /// A `file_pull` adapter is missing its `signing_key` (should be caught at
59	    /// manifest validation, but the conductor never trusts an unverifiable pull).
60	    FilePullMissingKey,
61	    /// A `file_pull` adapter had no payload to verify yet (the pull has not run).
62	    NoPayload,
63	}
64
65	/// The conductor's verdict for one adapter (REQ-UPD-5).
66	#[derive(Debug, Clone, PartialEq, Eq)]
67	pub enum AdapterUpdateOutcome {
68	    /// `file_pull`: the payload is authentic (adapter key) + intact (digest) and
69	    /// ready to apply. Carries the verified metadata.
70	    Verified(ReleaseMetadata),
71	    /// `delegated` + attested: run the adapter's own updater (the trusted command).
72	    Delegate(String),
73	    /// `file_pull`: the payload failed verification — never applied.
74	    Rejected(RejectReason),
75	    /// The adapter was skipped (see [`SkipReason`]) — no update applied.
76	    Skipped(SkipReason),
77	}
78
79	/// Decide one adapter's ripple-update from its `[update]` directive + (for
80	/// `file_pull`) the pulled payload (REQ-UPD-5). Pure: no fetch, no exec, no clock
81	/// — the caller supplies the payload and acts on the verdict.
82	// [impl->REQ-UPD-5]
83	// [impl->REQ-SEAM-UPDATE]
84	pub fn plan_adapter_update(
85	    update: &Update,
86	    payload: Option<&FilePullPayload>,
87	) -> AdapterUpdateOutcome {
88	    match update.avenue {
89	        UpdateAvenue::FilePull => {
90	            let Some(key_hex) = update.signing_key.as_deref() else {
91	                return AdapterUpdateOutcome::Skipped(SkipReason::FilePullMissingKey);
92	            };
93	            let key = match parse_verifying_key(key_hex) {
94	                Ok(k) => k,
95	                Err(e) => return AdapterUpdateOutcome::Rejected(e),
96	            };
97	            let Some(payload) = payload else {
98	                return AdapterUpdateOutcome::Skipped(SkipReason::NoPayload);
99	            };
100	            // Authentic (adapter key) + intact (digest) — the two halves of

//! Adapter ripple-update conduct (C3) — spt-core updates the whole stack: self
//! first, then each registered adapter via its manifest `[update]` avenue, with
//! **adapter content signing** (REQ-UPD-5, REQ-SEAM-UPDATE, ADR-0004 §D).
//!
//! After spt-core's own update completes (the C0–C2 engine), it conducts a
//! ripple over its registered adapters. Each adapter declares how it updates in
//! its manifest `[update]` block ([`Update`]); this module turns that directive
//! into a per-adapter [`AdapterUpdateOutcome`], dispatched by avenue:
//!
//! - **`file_pull`** — spt-core controls the bytes, so it *verifies* them: the
//!   pulled payload is checked against the adapter's **own** Ed25519 key (declared
//!   in the manifest — the adapter author signs their releases; spt-core's release
//!   key stays scoped to spt-core) plus the SHA-256 artifact binding. Authentic +
//!   intact ⇒ [`AdapterUpdateOutcome::Verified`]; otherwise
//!   [`AdapterUpdateOutcome::Rejected`].
//! - **`delegated`** — the adapter's own updater installs the content (e.g.
//!   `claude.exe plugin update`); spt-core never sees those bytes, so there is
//!   nothing for it to hash. It delegates the trust **only** when the adapter
//!   attests `self_verifies` (its updater checks its own signatures) ⇒
//!   [`AdapterUpdateOutcome::Delegate`]; an unattested delegated update is
//!   [`AdapterUpdateOutcome::Skipped`] as unverifiable.
//!
//! The actual byte transport for `file_pull` (the network pull) is M4
//! (`REQ-UPD-1`); v1 receives the payload via self-fetch / out-of-band and feeds
//! it here. This module is the **decision + verification** layer — it does not
//! itself fetch or exec.
// [impl->REQ-UPD-5]
// [impl->REQ-SEAM-UPDATE]

use spt_runtime::manifest::{Update, UpdateAvenue};

use crate::release::{
    parse_verifying_key, verify_artifact, verify_signature, ReleaseMetadata, RejectReason,
    SignedRelease,
};

/// A pulled `file_pull` payload awaiting verification: the adapter's signed
/// release metadata plus the actual bytes it attests to. Supplied by the
/// self-fetch / out-of-band layer (the network pull is M4).
#[derive(Debug, Clone)]
pub struct FilePullPayload {
    /// The adapter's signed release metadata (signed with the adapter's key).
    pub signed: SignedRelease,
    /// The pulled artifact bytes (hashed + matched against the signed digest).
    pub bytes: Vec<u8>,
}

/// Why an adapter's ripple-update was not applied as a verified content update.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkipReason {
    /// The adapter declares no `[update]` avenue — nothing to conduct.
    NoAvenue,
    /// A `delegated` adapter did not attest `self_verifies`, so its content is
    /// unverifiable by spt-core — skipped rather than trusted blindly.
    UnverifiableDelegated,
    /// A `delegated` adapter attested `self_verifies` but declared no `command`.
    DelegatedMissingCommand,
    /// A `file_pull` adapter is missing its `signing_key` (should be caught at
    /// manifest validation, but the conductor never trusts an unverifiable pull).
    FilePullMissingKey,
    /// A `file_pull` adapter had no payload to verify yet (the pull has not run).
    NoPayload,
}

/// The conductor's verdict for one adapter (REQ-UPD-5).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdapterUpdateOutcome {
    /// `file_pull`: the payload is authentic (adapter key) + intact (digest) and
    /// ready to apply. Carries the verified metadata.
    Verified(ReleaseMetadata),
    /// `delegated` + attested: run the adapter's own updater (the trusted command).
    Delegate(String),
    /// `file_pull`: the payload failed verification — never applied.
    Rejected(RejectReason),
    /// The adapter was skipped (see [`SkipReason`]) — no update applied.
    Skipped(SkipReason),
}

/// Decide one adapter's ripple-update from its `[update]` directive + (for
/// `file_pull`) the pulled payload (REQ-UPD-5). Pure: no fetch, no exec, no clock
/// — the caller supplies the payload and acts on the verdict.
// [impl->REQ-UPD-5]
// [impl->REQ-SEAM-UPDATE]
pub fn plan_adapter_update(
    update: &Update,
    payload: Option<&FilePullPayload>,
) -> AdapterUpdateOutcome {
    match update.avenue {
        UpdateAvenue::FilePull => {
            let Some(key_hex) = update.signing_key.as_deref() else {
                return AdapterUpdateOutcome::Skipped(SkipReason::FilePullMissingKey);
            };
            let key = match parse_verifying_key(key_hex) {
                Ok(k) => k,
                Err(e) => return AdapterUpdateOutcome::Rejected(e),
            };
            let Some(payload) = payload else {
                return AdapterUpdateOutcome::Skipped(SkipReason::NoPayload);
            };
            // Authentic (adapter key) + intact (digest) — the two halves of


crates\spt-daemon\src\adapter_update.rs:24://! (`REQ-UPD-1`); v1 receives the payload via self-fetch / out-of-band and feeds
crates\spt-net\src\lib.rs:38://!   D7  update peer-propagation into the M3c seam              (REQ-UPD-1)
M3-PLAN.md:152:- **Peer-propagation transport over P2P** (`REQ-UPD-1`) — needs the M4 network
M3-PLAN.md:155:  is the M4 transport plugged into the same engine. `REQ-UPD-1` activates at M4.
M3-PLAN.md:173:  `REQ-START-3`; update → `REQ-UPD-2/3/4/5`, `REQ-SEAM-UPDATE`; `REQ-UPD-1`
M3-PLAN.md:252:**Stay `[]` (M4):** `REQ-UPD-1` (peer-propagation transport), all `R-NET`/`R-PAIR`,
M3-PLAN.md:290:- **Cross-milestone coupling.** `REQ-UPD-1` (peer-propagation) and the QUIC-stream
M3c-PLAN.md:15:> + PRD R-UPD-3 reworded to the class taxonomy; M4 stub authored). `REQ-UPD-1`
M3c-PLAN.md:32:seam later (`REQ-UPD-1` activates at M4).
M3c-PLAN.md:56:- **Peer-propagation transport over P2P** (`REQ-UPD-1`) — needs the M4 `spt-net`
M3c-PLAN.md:64:currently `required_stages = []`. `REQ-UPD-1` stays `[]` until M4. No new
M4-PLAN.md:40:- **Update peer-propagation transport (`REQ-UPD-1`)** — plug the P2P transport into the
M4-PLAN.md:54:`R-NET-*`, `R-PAIR-*`, `REQ-INST-7`, `REQ-UPD-1`, `REQ-EP-4` (PresenceChannel impl — or
M4-PLAN.md:194:  before any handoff — one compromised node must not poison the subnet (ADR-0004 §D). · **REQ-UPD-1**
M4-PLAN.md:195:- **Reqs:** REQ-UPD-1. · **Acceptance:** a signed update self-heals across the subnet; the
M4-PLAN.md:257:| D7 | REQ-UPD-1 |
traceable-reqs.toml:311:id = "REQ-UPD-1"
ROADMAP.md:36:[Omitted long matching line]
ROADMAP.md:38:- **M4 — Networking + instances** (NEXT). `spt-net` (Iroh WAN, mDNS LAN, TOTP-SPAKE2 pairing, subnet registry) + the multi-instance model + cross-node Psyche sync (P2P, opt-in GitHub hub) + the **update peer-propagation transport** plugged into the M3c engine seam (REQ-UPD-1). **= the real proof: cross-node message + drive an agent on machine A from machine B.**


300	[[requirements]]
301	id = "REQ-NOTIF-1"
302	title = "Notification primitive: per-subnet replicated spool, seen/dismissed, resurface-at-boundary, subsumes update+consent prompts"
303	required_stages = []
304	[[requirements]]
305	id = "REQ-NOTIF-2"
306	title = "spt notify (agent-issued subnet notif) + notif_command manifest seam (harness + shell adapters)"
307	required_stages = []
308
309	# ───────────────────────────── Self-update ──────────────────────────────────
310	[[requirements]]
311	id = "REQ-UPD-1"
312	title = "Peer-propagated update over P2P"
313	required_stages = []
314	[[requirements]]
315	id = "REQ-UPD-2"
316	title = "All binaries signature-verified before handoff"
317	required_stages = ["impl", "unit"]  # activated M3c-C1: release.rs Ed25519 verify-before-handoff (signature + key trust + SHA-256 artifact binding); update::plan_verified is the front door that produces a plan ONLY for a verified release, so an unverified binary never reaches apply_brain_only
318	[[requirements]]
319	id = "REQ-UPD-3"
320	title = "No endpoint process terminates/suspends during self-update"
321	required_stages = ["impl", "unit", "int"]  # activated M3c-C0: the update engine's brain-only apply swaps logic over the B2 handoff substrate; the C0 E2E (tests/update.rs) proves a live PTY child's pid is unchanged + its output stream gapless across the swap (zero endpoint interruption — ADR-0004 §A, absolute for the brain-only class)
322	[[requirements]]
323	id = "REQ-UPD-4"
324	title = "Update gated on user confirmation by default; opt-in full-auto"
325	required_stages = ["impl", "unit"]  # activated M3c-C2: consent::decide is the pure gate (default NeedsConsent, full-auto opt-in via DaemonConfig.full_auto_update); consent::most_recently_active resolves the prompt target over live perches + the new info.json last_active_ms recency stamp (set_last_active, stamped each pulse tick). Interactive prompt UX itself rides the deferred notif/PresenceChannel seam
326	[[requirements]]
327	id = "REQ-UPD-5"
328	title = "spt-core ripple-updates registered adapters"
329	required_stages = ["impl", "unit"]  # activated M3c-C3: adapter_update::conduct_ripple conducts self-then-adapters in registration order; file_pull payloads get adapter content signing (verify_signature against the per-adapter manifest key + verify_artifact digest), delegated requires a self_verifies attest
330
331	# ───────────────────────────── Terminal wrapper / frontend ──────────────────
332	[[requirements]]
333	id = "REQ-TERM-1"
334	title = "Process-supervisor terminal wrapper hosting broker PTYs"
335	required_stages = ["impl", "unit"]
336	[[requirements]]
337	id = "REQ-TERM-2"
338	title = "session-surface abstraction; send-keys + send-line injection"
339	required_stages = ["impl", "unit"]

[[requirements]]
id = "REQ-NOTIF-1"
title = "Notification primitive: per-subnet replicated spool, seen/dismissed, resurface-at-boundary, subsumes update+consent prompts"
required_stages = []
[[requirements]]
id = "REQ-NOTIF-2"
title = "spt notify (agent-issued subnet notif) + notif_command manifest seam (harness + shell adapters)"
required_stages = []

# ───────────────────────────── Self-update ──────────────────────────────────
[[requirements]]
id = "REQ-UPD-1"
title = "Peer-propagated update over P2P"
required_stages = []
[[requirements]]
id = "REQ-UPD-2"
title = "All binaries signature-verified before handoff"
required_stages = ["impl", "unit"]  # activated M3c-C1: release.rs Ed25519 verify-before-handoff (signature + key trust + SHA-256 artifact binding); update::plan_verified is the front door that produces a plan ONLY for a verified release, so an unverified binary never reaches apply_brain_only
[[requirements]]
id = "REQ-UPD-3"
title = "No endpoint process terminates/suspends during self-update"
required_stages = ["impl", "unit", "int"]  # activated M3c-C0: the update engine's brain-only apply swaps logic over the B2 handoff substrate; the C0 E2E (tests/update.rs) proves a live PTY child's pid is unchanged + its output stream gapless across the swap (zero endpoint interruption — ADR-0004 §A, absolute for the brain-only class)
[[requirements]]
id = "REQ-UPD-4"
title = "Update gated on user confirmation by default; opt-in full-auto"
required_stages = ["impl", "unit"]  # activated M3c-C2: consent::decide is the pure gate (default NeedsConsent, full-auto opt-in via DaemonConfig.full_auto_update); consent::most_recently_active resolves the prompt target over live perches + the new info.json last_active_ms recency stamp (set_last_active, stamped each pulse tick). Interactive prompt UX itself rides the deferred notif/PresenceChannel seam
[[requirements]]
id = "REQ-UPD-5"
title = "spt-core ripple-updates registered adapters"
required_stages = ["impl", "unit"]  # activated M3c-C3: adapter_update::conduct_ripple conducts self-then-adapters in registration order; file_pull payloads get adapter content signing (verify_signature against the per-adapter manifest key + verify_artifact digest), delegated requires a self_verifies attest

# ───────────────────────────── Terminal wrapper / frontend ──────────────────
[[requirements]]
id = "REQ-TERM-1"
title = "Process-supervisor terminal wrapper hosting broker PTYs"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-TERM-2"
title = "session-surface abstraction; send-keys + send-line injection"
required_stages = ["impl", "unit"]