1	//! The staged-release cache (M4-D7, REQ-UPD-1) — the disk seat of update
2	//! peer-propagation (ADR-0004 §D).
3	//!
4	//! A node holds at most **one staged release**: the newest `SignedRelease` +
5	//! artifact pair that passed the full per-node gate (`update::plan_verified`).
6	//! Staging is what makes a node a *server* for its peers — `serve_update`
7	//! answers queries from here — so propagation chains hop by hop with the gate
8	//! at every node: verified-pull ⇒ staged ⇒ servable. **There is no code path
9	//! from wire bytes to this cache except through the gate** (the caller stages
10	//! only after `plan_verified` returns a plan); the cache itself is dumb
11	//! storage and re-verification happens on every *read* path that matters (a
12	//! pulling peer re-runs the gate on what we serve it).
13	//!
14	//! Commit discipline: both files land via the atomic temp→rename write
15	//! (`spt-store::atomic`), artifact first, metadata last — `release.json`'s
16	//! presence is the commit point, so [`ReleaseCache::staged`] never observes a
17	//! metadata file whose artifact is missing or torn mid-write. A digest
18	//! mismatch between the two (a corrupted or tampered artifact at rest) is
19	//! surfaced to the *peer's* gate as `ArtifactMismatch` — never silently
20	//! re-served as healthy (the serve side does not re-verify; the receive side
21	//! must, per-node).
22	// [impl->REQ-UPD-1]
23
24	use std::io;
25	use std::path::{Path, PathBuf};
26
27	use spt_store::atomic::{atomic_write_bytes, atomic_write_string};
28
29	use crate::release::{SignedRelease, SignedUpdateSet, UpdateSetMetadata};
30
31	/// File name of the staged release's signed metadata (the commit point).
32	const RELEASE_FILE: &str = "release.json";
33	/// File name of the staged artifact bytes.
34	const ARTIFACT_FILE: &str = "artifact.bin";
35	/// Directory for platform-targeted update-set artifact bytes.
36	const ARTIFACTS_DIR: &str = "artifacts";
37
38	/// A staged update can be the legacy single-artifact shape or the newer
39	/// platform-targeted set shape. Both use the same detached-signature wire
40	/// envelope; the signed metadata's inner schema distinguishes them.
41	// [impl->REQ-UPD-6]
42	#[derive(Debug, Clone)]
43	pub enum StagedUpdate {
44	    Single(SignedRelease),
45	    Set(SignedUpdateSet),
46	}
47
48	impl StagedUpdate {
49	    pub fn metadata_json(&self) -> &str {
50	        match self {
51	            StagedUpdate::Single(s) => &s.metadata_json,
52	            StagedUpdate::Set(s) => &s.metadata_json,
53	        }
54	    }
55
56	    pub fn signature_hex(&self) -> &str {
57	        match self {
58	            StagedUpdate::Single(s) => &s.signature_hex,
59	            StagedUpdate::Set(s) => &s.signature_hex,
60	        }
61	    }
62
63	    pub fn version(&self) -> Option<u64> {
64	        let meta: serde_json::Value = serde_json::from_str(self.metadata_json()).ok()?;
65	        meta.get("version").and_then(|v| v.as_u64())
66	    }
67
68	    pub fn channel(&self) -> Option<String> {
69	        let meta: serde_json::Value = serde_json::from_str(self.metadata_json()).ok()?;
70	        meta.get("channel")
71	            .and_then(|v| v.as_str())
72	            .map(str::to_string)
73	    }
74	}
75
76	/// The on-disk staged-release cache: one directory holding at most one
77	/// `(SignedRelease, artifact)` pair. Constructed with an explicit directory —
78	/// production resolution (under the daemon's data root) rides the D9
79	/// lifecycle wiring; tests pass temp dirs.
80	#[derive(Debug, Clone)]
81	pub struct ReleaseCache {
82	    dir: PathBuf,
83	}
84
85	impl ReleaseCache {
86	    /// A cache rooted at `dir` (created lazily on first stage).
87	    pub fn open(dir: &Path) -> ReleaseCache {
88	        ReleaseCache {
89	            dir: dir.to_path_buf(),
90	        }
91	    }
92
93	    /// Path of the staged artifact bytes.
94	    pub fn artifact_path(&self) -> PathBuf {
95	        self.dir.join(ARTIFACT_FILE)
96	    }
97
98	    /// Path for one platform artifact in an update set.
99	    // [impl->REQ-UPD-6]
100	    pub fn artifact_path_for_platform(&self, platform: &str) -> PathBuf {
101	        self.dir
102	            .join(ARTIFACTS_DIR)
103	            .join(format!("{}.bin", sanitize_platform(platform)))
104	    }
105
106	    fn release_path(&self) -> PathBuf {
107	        self.dir.join(RELEASE_FILE)
108	    }
109
110	    /// Stage a verified release: artifact first, signed metadata last (the
111	    /// commit point), both atomic. Restaging replaces the previous release —
112	    /// the cache holds only the newest verified one.
113	    ///
114	    /// The caller MUST have run the gate (`update::plan_verified`) on exactly
115	    /// this `(signed, artifact)` pair — staging unverified bytes would make
116	    /// this node a poison server (ADR-0004 §D).
117	    // [impl->REQ-UPD-1]
118	    pub fn stage(&self, signed: &SignedRelease, artifact: &[u8]) -> io::Result<()> {
119	        std::fs::create_dir_all(&self.dir)?;
120	        let _ = std::fs::remove_dir_all(self.dir.join(ARTIFACTS_DIR));
121	        atomic_write_bytes(&self.artifact_path(), artifact)?;
122	        let json = serde_json::to_string_pretty(signed)
123	            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
124	        atomic_write_string(&self.release_path(), &json)
125	    }
126
127	    /// Stage a platform-targeted update set: every artifact first, signed
128	    /// metadata last (commit point). The caller MUST have verified that the
129	    /// provided bytes match the signed digests.
130	    // [impl->REQ-UPD-6]
131	    pub fn stage_update_set(
132	        &self,
133	        signed: &SignedUpdateSet,
134	        artifacts: &std::collections::BTreeMap<String, Vec<u8>>,
135	    ) -> io::Result<()> {
136	        std::fs::create_dir_all(self.dir.join(ARTIFACTS_DIR))?;
137	        let _ = std::fs::remove_file(self.artifact_path());
138	        for (platform, bytes) in artifacts {
139	            atomic_write_bytes(&self.artifact_path_for_platform(platform), bytes)?;
140	        }
141	        let json = serde_json::to_string_pretty(signed)
142	            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
143	        atomic_write_string(&self.release_path(), &json)
144	    }
145
146	    /// The staged release's signed metadata, if a complete pair is present.
147	    /// `None` when nothing is staged, the metadata is unreadable/corrupt, or
148	    /// the artifact file is missing (a torn stage never offers) — degrade,
149	    /// don't die.
150	    // [impl->REQ-UPD-1]
151	    pub fn staged(&self) -> Option<SignedRelease> {
152	        match self.staged_update()? {
153	            StagedUpdate::Single(s) => Some(s),
154	            StagedUpdate::Set(_) => None,
155	        }
156	    }
157
158	    /// Any complete staged update, legacy single or platform-targeted set.
159	    // [impl->REQ-UPD-6]
160	    pub fn staged_update(&self) -> Option<StagedUpdate> {
161	        let signed: SignedRelease =
162	            serde_json::from_str(&std::fs::read_to_string(self.release_path()).ok()?).ok()?;
163	        if let Ok(set_meta) = serde_json::from_str::<UpdateSetMetadata>(&signed.metadata_json) {
164	            if set_meta
165	                .artifacts
166	                .keys()
167	                .any(|p| self.artifact_path_for_platform(p).exists())
168	            {
169	                Some(StagedUpdate::Set(SignedUpdateSet {
170	                    metadata_json: signed.metadata_json,
171	                    signature_hex: signed.signature_hex,
172	                }))
173	            } else {
174	                None
175	            }
176	        } else if self.artifact_path().exists() {
177	            Some(StagedUpdate::Single(signed))
178	        } else {
179	            None
180	        }
181	    }
182
183	    /// The staged artifact bytes (for serving / re-verification).
184	    pub fn load_artifact(&self) -> io::Result<Vec<u8>> {
185	        std::fs::read(self.artifact_path())
186	    }
187
188	    /// Load the bytes for `platform` from the staged update. Legacy releases
189	    /// ignore `platform`; update sets require a matching platform file.
190	    // [impl->REQ-UPD-6]
191	    pub fn load_artifact_for_platform(&self, platform: &str) -> io::Result<Vec<u8>> {
192	        match self.staged_update() {
193	            Some(StagedUpdate::Single(_)) => self.load_artifact(),
194	            Some(StagedUpdate::Set(_)) => std::fs::read(self.artifact_path_for_platform(platform)),
195	            None => Err(io::Error::new(
196	                io::ErrorKind::NotFound,
197	                "no complete staged update",
198	            )),
199	        }
200	    }
201
202	    /// The staged release's version, `None` when nothing is staged (or the
203	    /// staged metadata doesn't parse — same degrade posture as
204	    /// [`staged`](Self::staged)). The update pump's `current_version` floor:
205	    /// only strictly-newer offers pass the monotonic gate
206	    /// (REQ-HAZARD-UPDATE-ROLLBACK).
207	    // [impl->REQ-UPD-1]
208	    pub fn staged_version(&self) -> Option<u64> {
209	        self.staged_update()?.version()
210	    }
211
212	    /// The staged update's channel, if present.
213	    // [impl->REQ-UPD-6]
214	    pub fn staged_channel(&self) -> Option<String> {
215	        self.staged_update()?.channel()
216	    }
217
218	    fn applied_path(&self) -> PathBuf {
219	        self.dir.join("applied.json")
220	    }
221
222	    fn last_outcome_path(&self) -> PathBuf {
223	        self.dir.join("last-outcome.json")
224	    }
225
226	    /// Record that a verified release was APPLIED (the binary swap landed) —
227	    /// the durable fact the status-only query reports as `applied_version`
228	    /// (M8 decision 19, `xtask debug-converge`). Best-effort at the apply
229	    /// site; an unrecorded apply degrades the convergence table, never the
230	    /// apply itself.
231	    // [impl->REQ-UPD-6]
232	    pub fn record_applied(&self, version: u64) -> io::Result<()> {
233	        std::fs::create_dir_all(&self.dir)?;
234	        atomic_write_string(
235	            &self.applied_path(),
236	            &serde_json::json!({ "version": version }).to_string(),
237	        )
238	    }
239
240	    /// The last applied version, if one was ever recorded here.
241	    // [impl->REQ-UPD-6]
242	    pub fn applied_version(&self) -> Option<u64> {
243	        let s = std::fs::read_to_string(self.applied_path()).ok()?;
244	        serde_json::from_str::<serde_json::Value>(&s)
245	            .ok()?
246	            .get("version")?
247	            .as_u64()
248	    }
249
250	    /// Record the last typed pull/apply outcome (`staged` / `applied` /
251	    /// `rejected:<reason>` …) — the `Rejected{reason}` source for the
252	    /// convergence table. One value, newest wins.
253	    // [impl->REQ-UPD-6]
254	    pub fn record_last_outcome(&self, outcome: &str) -> io::Result<()> {
255	        std::fs::create_dir_all(&self.dir)?;
256	        atomic_write_string(
257	            &self.last_outcome_path(),
258	            &serde_json::json!({ "outcome": outcome }).to_string(),
259	        )
260	    }
261
262	    /// The last recorded typed outcome, if any.
263	    // [impl->REQ-UPD-6]
264	    pub fn last_outcome(&self) -> Option<String> {
265	        let s = std::fs::read_to_string(self.last_outcome_path()).ok()?;
266	        Some(
267	            serde_json::from_str::<serde_json::Value>(&s)
268	                .ok()?
269	                .get("outcome")?
270	                .as_str()?
271	                .to_string(),
272	        )
273	    }
274	}
275
276	fn sanitize_platform(platform: &str) -> String {
277	    platform
278	        .chars()
279	        .map(|c| {
280	            if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
281	                c
282	            } else {
283	                '_'
284	            }
285	        })
286	        .collect()
287	}
288
289	#[cfg(test)]
290	mod tests {
291	    use super::*;
292	    use std::collections::BTreeMap;
293
294	    fn signed(version: u64) -> SignedRelease {
295	        SignedRelease {
296	            metadata_json: format!("{{\"version\":{version}}}"),
297	            signature_hex: "ab".repeat(64),
298	        }
299	    }
300
301	    fn signed_set(version: u64) -> SignedUpdateSet {
302	        SignedUpdateSet {
303	            metadata_json: format!(
304	                "{{\"version\":{version},\"channel\":\"debug\",\"expires_at_ms\":999999,\"key_id\":\"dev\",\"artifacts\":{{\"x86_64-pc-windows-msvc\":{{\"artifact_sha256\":\"00\",\"brain_ipc_version\":1,\"broker_resource_abi\":1}},\"x86_64-unknown-linux-gnu\":{{\"artifact_sha256\":\"11\",\"brain_ipc_version\":1,\"broker_resource_abi\":1}}}}}}"
305	            ),
306	            signature_hex: "cd".repeat(64),
307	        }
308	    }
309
310	    // [unit->REQ-UPD-1] stage → staged/load round-trips the pair; restaging
311	    // replaces it (the cache holds only the newest verified release).
312	    #[test]
313	    fn stage_round_trips_and_restage_replaces() {
314	        let dir = tempfile::tempdir().unwrap();
315	        let cache = ReleaseCache::open(dir.path());
316	        assert!(cache.staged().is_none(), "empty cache offers nothing");
317
318	        cache.stage(&signed(6), b"v6 bytes").unwrap();
319	        assert_eq!(
320	            cache.staged().unwrap().metadata_json,
321	            signed(6).metadata_json
322	        );
323	        assert_eq!(cache.load_artifact().unwrap(), b"v6 bytes");
324
325	        cache.stage(&signed(7), b"v7 bytes").unwrap();
326	        assert_eq!(
327	            cache.staged().unwrap().metadata_json,
328	            signed(7).metadata_json
329	        );
330	        assert_eq!(cache.load_artifact().unwrap(), b"v7 bytes");
331	    }
332
333	    // [unit->REQ-UPD-1] a torn stage never offers: metadata without its
334	    // artifact (or corrupt metadata) yields None rather than a poisoned offer.
335	    #[test]
336	    fn torn_or_corrupt_stage_offers_nothing() {
337	        let dir = tempfile::tempdir().unwrap();
338	        let cache = ReleaseCache::open(dir.path());
339
340	        // Metadata present, artifact missing (the impossible order — stage
341	        // writes the artifact first — but disk damage can produce it).
342	        cache.stage(&signed(6), b"bytes").unwrap();
343	        std::fs::remove_file(cache.artifact_path()).unwrap();
344	        assert!(
345	            cache.staged().is_none(),
346	            "metadata without artifact never offers"
347	        );
348
349	        // Corrupt metadata is skipped, not fatal.
350	        cache.stage(&signed(6), b"bytes").unwrap();
351	        std::fs::write(dir.path().join(RELEASE_FILE), b"{ not json").unwrap();
352	        assert!(cache.staged().is_none(), "corrupt metadata never offers");
353	    }
354
355	    // [unit->REQ-UPD-6] platform-targeted update sets round-trip through the
356	    // same release metadata commit point, with one artifact file per target.
357	    #[test]
358	    fn update_set_round_trips_and_loads_by_platform() {
359	        let dir = tempfile::tempdir().unwrap();
360	        let cache = ReleaseCache::open(dir.path());
361	        let signed = signed_set(42);
362	        let artifacts = BTreeMap::from([
363	            ("x86_64-pc-windows-msvc".to_string(), b"win".to_vec()),
364	            ("x86_64-unknown-linux-gnu".to_string(), b"linux".to_vec()),
365	        ]);
366
367	        cache.stage_update_set(&signed, &artifacts).unwrap();
368	        match cache.staged_update().expect("set staged") {
369	            StagedUpdate::Set(s) => assert_eq!(s.metadata_json, signed.metadata_json),
370	            other => panic!("expected update set, got {other:?}"),
371	        }
372	        assert_eq!(cache.staged_version(), Some(42));
373	        assert_eq!(cache.staged_channel().as_deref(), Some("debug"));
374	        assert_eq!(
375	            cache
376	                .load_artifact_for_platform("x86_64-unknown-linux-gnu")
377	                .unwrap(),
378	            b"linux"
379	        );
380	        assert_eq!(
381	            cache
382	                .load_artifact_for_platform("x86_64-pc-windows-msvc")
383	                .unwrap(),
384	            b"win"
385	        );
386	        assert!(
387	            cache.staged().is_none(),
388	            "legacy staged() does not misclassify sets"
389	        );
390	    }
391
392	    // [unit->REQ-UPD-6] a set may be partially staged: a recipient fetches and
393	    // re-serves only its own platform artifact, while the coordinator can hold
394	    // the whole set.
395	    #[test]
396	    fn partial_update_set_can_serve_held_platform_only() {
397	        let dir = tempfile::tempdir().unwrap();
398	        let cache = ReleaseCache::open(dir.path());
399	        let signed = signed_set(42);
400	        let artifacts = BTreeMap::from([
401	            ("x86_64-pc-windows-msvc".to_string(), b"win".to_vec()),
402	            ("x86_64-unknown-linux-gnu".to_string(), b"linux".to_vec()),
403	        ]);
404	        cache.stage_update_set(&signed, &artifacts).unwrap();
405	        std::fs::remove_file(cache.artifact_path_for_platform("x86_64-pc-windows-msvc")).unwrap();
406	        assert!(matches!(cache.staged_update(), Some(StagedUpdate::Set(_))));
407	        assert!(cache
408	            .load_artifact_for_platform("x86_64-pc-windows-msvc")
409	            .is_err());
410	        assert_eq!(
411	            cache
412	                .load_artifact_for_platform("x86_64-unknown-linux-gnu")
413	                .unwrap(),
414	            b"linux"
415	        );
416	    }
417	}
418

//! The staged-release cache (M4-D7, REQ-UPD-1) — the disk seat of update
//! peer-propagation (ADR-0004 §D).
//!
//! A node holds at most **one staged release**: the newest `SignedRelease` +
//! artifact pair that passed the full per-node gate (`update::plan_verified`).
//! Staging is what makes a node a *server* for its peers — `serve_update`
//! answers queries from here — so propagation chains hop by hop with the gate
//! at every node: verified-pull ⇒ staged ⇒ servable. **There is no code path
//! from wire bytes to this cache except through the gate** (the caller stages
//! only after `plan_verified` returns a plan); the cache itself is dumb
//! storage and re-verification happens on every *read* path that matters (a
//! pulling peer re-runs the gate on what we serve it).
//!
//! Commit discipline: both files land via the atomic temp→rename write
//! (`spt-store::atomic`), artifact first, metadata last — `release.json`'s
//! presence is the commit point, so [`ReleaseCache::staged`] never observes a
//! metadata file whose artifact is missing or torn mid-write. A digest
//! mismatch between the two (a corrupted or tampered artifact at rest) is
//! surfaced to the *peer's* gate as `ArtifactMismatch` — never silently
//! re-served as healthy (the serve side does not re-verify; the receive side
//! must, per-node).
// [impl->REQ-UPD-1]

use std::io;
use std::path::{Path, PathBuf};

use spt_store::atomic::{atomic_write_bytes, atomic_write_string};

use crate::release::{SignedRelease, SignedUpdateSet, UpdateSetMetadata};

/// File name of the staged release's signed metadata (the commit point).
const RELEASE_FILE: &str = "release.json";
/// File name of the staged artifact bytes.
const ARTIFACT_FILE: &str = "artifact.bin";
/// Directory for platform-targeted update-set artifact bytes.
const ARTIFACTS_DIR: &str = "artifacts";

/// A staged update can be the legacy single-artifact shape or the newer
/// platform-targeted set shape. Both use the same detached-signature wire
/// envelope; the signed metadata's inner schema distinguishes them.
// [impl->REQ-UPD-6]
#[derive(Debug, Clone)]
pub enum StagedUpdate {
    Single(SignedRelease),
    Set(SignedUpdateSet),
}

impl StagedUpdate {
    pub fn metadata_json(&self) -> &str {
        match self {
            StagedUpdate::Single(s) => &s.metadata_json,
            StagedUpdate::Set(s) => &s.metadata_json,
        }
    }

    pub fn signature_hex(&self) -> &str {
        match self {
            StagedUpdate::Single(s) => &s.signature_hex,
            StagedUpdate::Set(s) => &s.signature_hex,
        }
    }

    pub fn version(&self) -> Option<u64> {
        let meta: serde_json::Value = serde_json::from_str(self.metadata_json()).ok()?;
        meta.get("version").and_then(|v| v.as_u64())
    }

    pub fn channel(&self) -> Option<String> {
        let meta: serde_json::Value = serde_json::from_str(self.metadata_json()).ok()?;
        meta.get("channel")
            .and_then(|v| v.as_str())
            .map(str::to_string)
    }
}

/// The on-disk staged-release cache: one directory holding at most one
/// `(SignedRelease, artifact)` pair. Constructed with an explicit directory —
/// production resolution (under the daemon's data root) rides the D9
/// lifecycle wiring; tests pass temp dirs.
#[derive(Debug, Clone)]
pub struct ReleaseCache {
    dir: PathBuf,
}

impl ReleaseCache {
    /// A cache rooted at `dir` (created lazily on first stage).
    pub fn open(dir: &Path) -> ReleaseCache {
        ReleaseCache {
            dir: dir.to_path_buf(),
        }
    }

    /// Path of the staged artifact bytes.
    pub fn artifact_path(&self) -> PathBuf {
        self.dir.join(ARTIFACT_FILE)
    }

    /// Path for one platform artifact in an update set.
    // [impl->REQ-UPD-6]
    pub fn artifact_path_for_platform(&self, platform: &str) -> PathBuf {
        self.dir
            .join(ARTIFACTS_DIR)
            .join(format!("{}.bin", sanitize_platform(platform)))
    }

    fn release_path(&self) -> PathBuf {
        self.dir.join(RELEASE_FILE)
    }

    /// Stage a verified release: artifact first, signed metadata last (the
    /// commit point), both atomic. Restaging replaces the previous release —
    /// the cache holds only the newest verified one.
    ///
    /// The caller MUST have run the gate (`update::plan_verified`) on exactly
    /// this `(signed, artifact)` pair — staging unverified bytes would make
    /// this node a poison server (ADR-0004 §D).
    // [impl->REQ-UPD-1]
    pub fn stage(&self, signed: &SignedRelease, artifact: &[u8]) -> io::Result<()> {
        std::fs::create_dir_all(&self.dir)?;
        let _ = std::fs::remove_dir_all(self.dir.join(ARTIFACTS_DIR));
        atomic_write_bytes(&self.artifact_path(), artifact)?;
        let json = serde_json::to_string_pretty(signed)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
        atomic_write_string(&self.release_path(), &json)
    }

    /// Stage a platform-targeted update set: every artifact first, signed
    /// metadata last (commit point). The caller MUST have verified that the
    /// provided bytes match the signed digests.
    // [impl->REQ-UPD-6]
    pub fn stage_update_set(
        &self,
        signed: &SignedUpdateSet,
        artifacts: &std::collections::BTreeMap<String, Vec<u8>>,
    ) -> io::Result<()> {
        std::fs::create_dir_all(self.dir.join(ARTIFACTS_DIR))?;
        let _ = std::fs::remove_file(self.artifact_path());
        for (platform, bytes) in artifacts {
            atomic_write_bytes(&self.artifact_path_for_platform(platform), bytes)?;
        }
        let json = serde_json::to_string_pretty(signed)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
        atomic_write_string(&self.release_path(), &json)
    }

    /// The staged release's signed metadata, if a complete pair is present.
    /// `None` when nothing is staged, the metadata is unreadable/corrupt, or
    /// the artifact file is missing (a torn stage never offers) — degrade,
    /// don't die.
    // [impl->REQ-UPD-1]
    pub fn staged(&self) -> Option<SignedRelease> {
        match self.staged_update()? {
            StagedUpdate::Single(s) => Some(s),
            StagedUpdate::Set(_) => None,
        }
    }

    /// Any complete staged update, legacy single or platform-targeted set.
    // [impl->REQ-UPD-6]
    pub fn staged_update(&self) -> Option<StagedUpdate> {
        let signed: SignedRelease =
            serde_json::from_str(&std::fs::read_to_string(self.release_path()).ok()?).ok()?;
        if let Ok(set_meta) = serde_json::from_str::<UpdateSetMetadata>(&signed.metadata_json) {
            if set_meta
                .artifacts
                .keys()
                .any(|p| self.artifact_path_for_platform(p).exists())
            {
                Some(StagedUpdate::Set(SignedUpdateSet {
                    metadata_json: signed.metadata_json,
                    signature_hex: signed.signature_hex,
                }))
            } else {
                None
            }
        } else if self.artifact_path().exists() {
            Some(StagedUpdate::Single(signed))
        } else {
            None
        }
    }

    /// The staged artifact bytes (for serving / re-verification).
    pub fn load_artifact(&self) -> io::Result<Vec<u8>> {
        std::fs::read(self.artifact_path())
    }

    /// Load the bytes for `platform` from the staged update. Legacy releases
    /// ignore `platform`; update sets require a matching platform file.
    // [impl->REQ-UPD-6]
    pub fn load_artifact_for_platform(&self, platform: &str) -> io::Result<Vec<u8>> {
        match self.staged_update() {
            Some(StagedUpdate::Single(_)) => self.load_artifact(),
            Some(StagedUpdate::Set(_)) => std::fs::read(self.artifact_path_for_platform(platform)),
            None => Err(io::Error::new(
                io::ErrorKind::NotFound,
                "no complete staged update",
            )),
        }
    }

    /// The staged release's version, `None` when nothing is staged (or the
    /// staged metadata doesn't parse — same degrade posture as
    /// [`staged`](Self::staged)). The update pump's `current_version` floor:
    /// only strictly-newer offers pass the monotonic gate
    /// (REQ-HAZARD-UPDATE-ROLLBACK).
    // [impl->REQ-UPD-1]
    pub fn staged_version(&self) -> Option<u64> {
        self.staged_update()?.version()
    }

    /// The staged update's channel, if present.
    // [impl->REQ-UPD-6]
    pub fn staged_channel(&self) -> Option<String> {
        self.staged_update()?.channel()
    }

    fn applied_path(&self) -> PathBuf {
        self.dir.join("applied.json")
    }

    fn last_outcome_path(&self) -> PathBuf {
        self.dir.join("last-outcome.json")
    }

    /// Record that a verified release was APPLIED (the binary swap landed) —
    /// the durable fact the status-only query reports as `applied_version`
    /// (M8 decision 19, `xtask debug-converge`). Best-effort at the apply
    /// site; an unrecorded apply degrades the convergence table, never the
    /// apply itself.
    // [impl->REQ-UPD-6]
    pub fn record_applied(&self, version: u64) -> io::Result<()> {
        std::fs::create_dir_all(&self.dir)?;
        atomic_write_string(
            &self.applied_path(),
            &serde_json::json!({ "version": version }).to_string(),
        )
    }

    /// The last applied version, if one was ever recorded here.
    // [impl->REQ-UPD-6]
    pub fn applied_version(&self) -> Option<u64> {
        let s = std::fs::read_to_string(self.applied_path()).ok()?;
        serde_json::from_str::<serde_json::Value>(&s)
            .ok()?
            .get("version")?
            .as_u64()
    }

    /// Record the last typed pull/apply outcome (`staged` / `applied` /
    /// `rejected:<reason>` …) — the `Rejected{reason}` source for the
    /// convergence table. One value, newest wins.
    // [impl->REQ-UPD-6]
    pub fn record_last_outcome(&self, outcome: &str) -> io::Result<()> {
        std::fs::create_dir_all(&self.dir)?;
        atomic_write_string(
            &self.last_outcome_path(),
            &serde_json::json!({ "outcome": outcome }).to_string(),
        )
    }

    /// The last recorded typed outcome, if any.
    // [impl->REQ-UPD-6]
    pub fn last_outcome(&self) -> Option<String> {
        let s = std::fs::read_to_string(self.last_outcome_path()).ok()?;
        Some(
            serde_json::from_str::<serde_json::Value>(&s)
                .ok()?
                .get("outcome")?
                .as_str()?
                .to_string(),
        )
    }
}

fn sanitize_platform(platform: &str) -> String {
    platform
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
                c
            } else {
                '_'
            }
        })
        .collect()
}

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

    fn signed(version: u64) -> SignedRelease {
        SignedRelease {
            metadata_json: format!("{{\"version\":{version}}}"),
            signature_hex: "ab".repeat(64),
        }
    }

    fn signed_set(version: u64) -> SignedUpdateSet {
        SignedUpdateSet {
            metadata_json: format!(
                "{{\"version\":{version},\"channel\":\"debug\",\"expires_at_ms\":999999,\"key_id\":\"dev\",\"artifacts\":{{\"x86_64-pc-windows-msvc\":{{\"artifact_sha256\":\"00\",\"brain_ipc_version\":1,\"broker_resource_abi\":1}},\"x86_64-unknown-linux-gnu\":{{\"artifact_sha256\":\"11\",\"brain_ipc_version\":1,\"broker_resource_abi\":1}}}}}}"
            ),
            signature_hex: "cd".repeat(64),
        }
    }

    // [unit->REQ-UPD-1] stage → staged/load round-trips the pair; restaging
    // replaces it (the cache holds only the newest verified release).
    #[test]
    fn stage_round_trips_and_restage_replaces() {
        let dir = tempfile::tempdir().unwrap();
        let cache = ReleaseCache::open(dir.path());
        assert!(cache.staged().is_none(), "empty cache offers nothing");

        cache.stage(&signed(6), b"v6 bytes").unwrap();
        assert_eq!(
            cache.staged().unwrap().metadata_json,
            signed(6).metadata_json
        );
        assert_eq!(cache.load_artifact().unwrap(), b"v6 bytes");

        cache.stage(&signed(7), b"v7 bytes").unwrap();
        assert_eq!(
            cache.staged().unwrap().metadata_json,
            signed(7).metadata_json
        );
        assert_eq!(cache.load_artifact().unwrap(), b"v7 bytes");
    }

    // [unit->REQ-UPD-1] a torn stage never offers: metadata without its
    // artifact (or corrupt metadata) yields None rather than a poisoned offer.
    #[test]
    fn torn_or_corrupt_stage_offers_nothing() {
        let dir = tempfile::tempdir().unwrap();
        let cache = ReleaseCache::open(dir.path());

        // Metadata present, artifact missing (the impossible order — stage
        // writes the artifact first — but disk damage can produce it).
        cache.stage(&signed(6), b"bytes").unwrap();
        std::fs::remove_file(cache.artifact_path()).unwrap();
        assert!(
            cache.staged().is_none(),
            "metadata without artifact never offers"
        );

        // Corrupt metadata is skipped, not fatal.
        cache.stage(&signed(6), b"bytes").unwrap();
        std::fs::write(dir.path().join(RELEASE_FILE), b"{ not json").unwrap();
        assert!(cache.staged().is_none(), "corrupt metadata never offers");
    }

    // [unit->REQ-UPD-6] platform-targeted update sets round-trip through the
    // same release metadata commit point, with one artifact file per target.
    #[test]
    fn update_set_round_trips_and_loads_by_platform() {
        let dir = tempfile::tempdir().unwrap();
        let cache = ReleaseCache::open(dir.path());
        let signed = signed_set(42);
        let artifacts = BTreeMap::from([
            ("x86_64-pc-windows-msvc".to_string(), b"win".to_vec()),
            ("x86_64-unknown-linux-gnu".to_string(), b"linux".to_vec()),
        ]);

        cache.stage_update_set(&signed, &artifacts).unwrap();
        match cache.staged_update().expect("set staged") {
            StagedUpdate::Set(s) => assert_eq!(s.metadata_json, signed.metadata_json),
            other => panic!("expected update set, got {other:?}"),
        }
        assert_eq!(cache.staged_version(), Some(42));
        assert_eq!(cache.staged_channel().as_deref(), Some("debug"));
        assert_eq!(
            cache
                .load_artifact_for_platform("x86_64-unknown-linux-gnu")
                .unwrap(),
            b"linux"
        );
        assert_eq!(
            cache
                .load_artifact_for_platform("x86_64-pc-windows-msvc")
                .unwrap(),
            b"win"
        );
        assert!(
            cache.staged().is_none(),
            "legacy staged() does not misclassify sets"
        );
    }

    // [unit->REQ-UPD-6] a set may be partially staged: a recipient fetches and
    // re-serves only its own platform artifact, while the coordinator can hold
    // the whole set.
    #[test]
    fn partial_update_set_can_serve_held_platform_only() {
        let dir = tempfile::tempdir().unwrap();
        let cache = ReleaseCache::open(dir.path());
        let signed = signed_set(42);
        let artifacts = BTreeMap::from([
            ("x86_64-pc-windows-msvc".to_string(), b"win".to_vec()),
            ("x86_64-unknown-linux-gnu".to_string(), b"linux".to_vec()),
        ]);
        cache.stage_update_set(&signed, &artifacts).unwrap();
        std::fs::remove_file(cache.artifact_path_for_platform("x86_64-pc-windows-msvc")).unwrap();
        assert!(matches!(cache.staged_update(), Some(StagedUpdate::Set(_))));
        assert!(cache
            .load_artifact_for_platform("x86_64-pc-windows-msvc")
            .is_err());
        assert_eq!(
            cache
                .load_artifact_for_platform("x86_64-unknown-linux-gnu")
                .unwrap(),
            b"linux"
        );
    }
}


Now the fetch cmd + xtask publish + apply message.


Found 10 files
V032-PLAN.md
docs\DEBUG-ROLLOUT.md
crates\xtask\src\main.rs
crates\spt-daemon\tests\propagate.rs
crates\spt-daemon\src\update.rs
crates\spt-daemon\src\release.rs
crates\spt-daemon\src\relcache.rs
crates\spt-daemon\src\propagate.rs
crates\spt-daemon\src\lib.rs
crates\spt-daemon\src\applyhost.rs


Found 4 files
V032-PLAN.md
crates\spt\src\cli.rs
crates\spt-daemon\src\msg.rs
M5-D7-PLAN.md


818:            UpdateCmd::Apply => cmd_update_apply(),
819:            UpdateCmd::Fetch { channel, tag } => cmd_update_fetch(channel, tag),
1701:fn cmd_update_apply() -> i32 {
1720:            println!("APPLIED:{version}");
1785:fn cmd_update_fetch(channel: Option<String>, tag: Option<String>) -> i32 {


1690	        }
1691	        Err(e) => {
1692	            eprintln!("DISMISS_FAIL:{notif_id}: {e}");
1693	            1
1694	        }
1695	    }
1696	}
1697
1698	/// `spt update apply` — the explicit ack named by the update-consent notif.
1699	// [impl->REQ-UPD-4]
1700	// [impl->REQ-UPD-3]
1701	fn cmd_update_apply() -> i32 {
1702	    if let Err(code) = ensure_daemon_announced() {
1703	        return code;
1704	    }
1705	    let exe = match std::env::current_exe() {
1706	        Ok(p) => p,
1707	        Err(e) => {
1708	            eprintln!("CURRENT_EXE_FAIL: {e}");
1709	            return 1;
1710	        }
1711	    };
1712	    let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join("releases"));
1713	    let keys = perch::identity_dir().join("release-keys.json");
1714	    match spt_daemon::apply_staged(&cache, &keys, &exe, &spt_daemon::broker_socket_name()) {
1715	        Ok(spt_daemon::ApplyStagedOutcome::NoUpdate) => {
1716	            println!("NO_UPDATE");
1717	            0
1718	        }
1719	        Ok(spt_daemon::ApplyStagedOutcome::Applied { version }) => {
1720	            println!("APPLIED:{version}");
1721	            0
1722	        }
1723	        Ok(spt_daemon::ApplyStagedOutcome::RefusedClass(class)) => {
1724	            eprintln!("UPDATE_APPLY_REFUSED:{class:?} (full-swap orchestration is not M5 scope)");
1725	            1
1726	        }
1727	        Ok(spt_daemon::ApplyStagedOutcome::Rejected(reason)) => {
1728	            eprintln!("UPDATE_APPLY_REJECTED:{reason}");
1729	            1
1730	        }
1731	        Err(e) => {
1732	            eprintln!("UPDATE_APPLY_FAIL:{e}");
1733	            1
1734	        }
1735	    }
1736	}
1737
1738	/// The release-asset name for THIS platform — matches the release-publish
1739	/// assets on spt-releases. Pure for tests.
1740	fn platform_release_asset() -> &'static str {
1741	    if cfg!(windows) {
1742	        "spt-x86_64-windows.exe"
1743	    } else {
1744	        "spt-x86_64-linux"
1745	    }
1746	}
1747
1748	/// The two origin URLs (artifact, then its `.release.json` metadata) for a
1749	/// release on the GitHub origin. `tag = None` ⇒ the `latest` release. Pure.
1750	fn origin_release_urls(repo: &str, asset: &str, tag: Option<&str>) -> (String, String) {
1751	    let base = match tag {
1752	        Some(t) => format!("https://github.com/{repo}/releases/download/{t}"),
1753	        None => format!("https://github.com/{repo}/releases/latest/download"),
1754	    };
1755	    (
1756	        format!("{base}/{asset}"),
1757	        format!("{base}/{asset}.release.json"),
1758	    )
1759	}
1760
1761	fn http_get_text(client: &reqwest::blocking::Client, url: &str) -> Result<String, String> {
1762	    let resp = client.get(url).send().map_err(|e| e.to_string())?;
1763	    if !resp.status().is_success() {
1764	        return Err(format!("HTTP {}", resp.status()));
1765	    }
1766	    resp.text().map_err(|e| e.to_string())
1767	}
1768
1769	fn http_get_bytes(client: &reqwest::blocking::Client, url: &str) -> Result<Vec<u8>, String> {
1770	    let resp = client.get(url).send().map_err(|e| e.to_string())?;
1771	    if !resp.status().is_success() {
1772	        return Err(format!("HTTP {}", resp.status()));
1773	    }
1774	    resp.bytes().map(|b| b.to_vec()).map_err(|e| e.to_string())
1775	}
1776
1777	/// `spt update fetch` (REQ-UPD-7) — pull the signed release from the GitHub
1778	/// origin and stage it through the SAME verify gate the P2P pull uses
1779	/// (`plan_verified`: two-key signature + channel + monotonic rollback floor +
1780	/// SHA-256), after which the existing `spt update apply` flow takes over. Closes
1781	/// the peer-only discovery gap: a first-in-fleet / isolated node can update with
1782	/// no peer to pull from. The signed-release anchor keeps the GitHub transport
1783	/// untrusted-but-verified.
1784	// [impl->REQ-UPD-7]
1785	fn cmd_update_fetch(channel: Option<String>, tag: Option<String>) -> i32 {
1786	    let repo =
1787	        std::env::var("SPT_INSTALL_REPO").unwrap_or_else(|_| "SaberMage/spt-releases".to_string());
1788	    let asset = platform_release_asset();
1789	    let (art_url, meta_url) = origin_release_urls(&repo, asset, tag.as_deref());
1790
1791	    // Verify policy: the two-key anchor + the node's release-keys overlay; the
1792	    // rollback floor is the staged version on the pinned channel (mirrors the
1793	    // P2P path, peerloop.rs). `--channel` overrides the pin to deliberately
1794	    // accept another channel's release.
1795	    let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join("releases"));
1796	    let keys = perch::identity_dir().join("release-keys.json");
1797	    let mut policy = spt_daemon::release::VerifyPolicy::load_from(&keys, 0, now_ms());
1798	    let pinned = policy.pinned_channel.clone();
1799	    policy.current_version = if cache.staged_channel().as_deref() == Some(pinned.as_str()) {
1800	        cache.staged_version().unwrap_or(0)
1801	    } else {
1802	        0
1803	    };
1804	    if let Some(c) = channel {
1805	        policy.pinned_channel = c;
1806	    }
1807	    if policy.trusted_keys.is_empty() {
1808	        eprintln!("UPDATE_FETCH_FAIL: no trusted release keys");
1809	        return 1;
1810	    }
1811
1812	    eprintln!("Fetching {asset} from {repo}…");
1813	    let client = match reqwest::blocking::Client::builder()
1814	        .user_agent(concat!("spt/", env!("CARGO_PKG_VERSION")))
1815	        .build()
1816	    {
1817	        Ok(c) => c,
1818	        Err(e) => {
1819	            eprintln!("UPDATE_FETCH_FAIL: http client: {e}");
1820	            return 1;
1821	        }
1822	    };
1823	    let meta_text = match http_get_text(&client, &meta_url) {
1824	        Ok(t) => t,
1825	        Err(e) => {
1826	            eprintln!("UPDATE_FETCH_FAIL: GET {meta_url}: {e}");
1827	            return 1;
1828	        }
1829	    };
1830	    let signed: spt_daemon::release::SignedRelease = match serde_json::from_str(&meta_text) {
1831	        Ok(s) => s,
1832	        Err(e) => {
1833	            eprintln!("UPDATE_FETCH_FAIL: bad release.json: {e}");
1834	            return 1;
1835	        }
1836	    };
1837	    let artifact = match http_get_bytes(&client, &art_url) {
1838	        Ok(b) => b,
1839	        Err(e) => {
1840	            eprintln!("UPDATE_FETCH_FAIL: GET {art_url}: {e}");
1841	            return 1;
1842	        }
1843	    };
1844
1845	    // The SAME gate the daemon's peer pull runs — no inherited trust from the
1846	    // GitHub transport.
1847	    match spt_daemon::update::plan_verified(
1848	        &spt_daemon::update::BrokerAbi::current(),
1849	        &signed,
1850	        &artifact,
1851	        &policy,
1852	    ) {
1853	        Ok(_plan) => {
1854	            if let Err(e) = cache.stage(&signed, &artifact) {
1855	                eprintln!("UPDATE_FETCH_FAIL: stage: {e}");
1856	                return 1;
1857	            }
1858	            let v = cache
1859	                .staged_version()
1860	                .map(|v| v.to_string())
1861	                .unwrap_or_else(|| "?".to_string());
1862	            println!("UPDATE_STAGED:{v} (from {repo}) — run `spt update apply` to apply");
1863	            0
1864	        }
1865	        Err(reason) => {
1866	            eprintln!("UPDATE_FETCH_REJECTED:{reason:?}");
1867	            1
1868	        }
1869	    }
1870	}
1871
1872	/// Epoch milliseconds (the notif surfacing clock).
1873	fn now_ms() -> u64 {
1874	    std::time::SystemTime::now()
1875	        .duration_since(std::time::UNIX_EPOCH)
1876	        .map(|d| d.as_millis() as u64)
1877	        .unwrap_or(0)
1878	}
1879
1880	/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".
1881	fn resolve_from(from: Option<String>) -> String {
1882	    from.or_else(roster::detect_self_id).unwrap_or_default()
1883	}
1884
1885	fn cmd_send(
1886	    target: Option<String>,
1887	    from: Option<String>,
1888	    reply_to: Option<String>,
1889	    deferred: bool,
1890	) -> i32 {
1891	    let is_reply = reply_to.is_some();
1892	    // --reply-to provides the target when no positional target is given.
1893	    let target = match target.or_else(|| reply_to.clone()) {
1894	        Some(t) => t,
1895	        None => {
1896	            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
1897	            return 2;
1898	        }
1899	    };
1900	    let from = resolve_from(from);
1901	    // Trim stdin like cmd_notify (REQ-HAZARD-ENVELOPE-CR-LINESAFE parity): a
1902	    // Windows `echo | spt send` pipes a trailing CRLF — strip it so the message
1903	    // has no trailing blank line (the codec also neutralizes any interior CR).
1904	    let body = read_stdin();
1905	    let body = body.trim();
1906	    let owlery = perch::owlery_dir();
1907
1908	    let outcome = if deferred {
1909	        deliver::send_deferred(&target, &from, body)
1910	    } else {
1911	        deliver::send(&target, &from, body, &owlery)
1912	    };
1913
1914	    match outcome {
1915	        SendOutcome::Sent => {
1916	            ok_line(format!(
1917	                "{}:{}",
1918	                if is_reply { "REPLIED" } else { "SENT" },
1919	                target

        }
        Err(e) => {
            eprintln!("DISMISS_FAIL:{notif_id}: {e}");
            1
        }
    }
}

/// `spt update apply` — the explicit ack named by the update-consent notif.
// [impl->REQ-UPD-4]
// [impl->REQ-UPD-3]
fn cmd_update_apply() -> i32 {
    if let Err(code) = ensure_daemon_announced() {
        return code;
    }
    let exe = match std::env::current_exe() {
        Ok(p) => p,
        Err(e) => {
            eprintln!("CURRENT_EXE_FAIL: {e}");
            return 1;
        }
    };
    let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join("releases"));
    let keys = perch::identity_dir().join("release-keys.json");
    match spt_daemon::apply_staged(&cache, &keys, &exe, &spt_daemon::broker_socket_name()) {
        Ok(spt_daemon::ApplyStagedOutcome::NoUpdate) => {
            println!("NO_UPDATE");
            0
        }
        Ok(spt_daemon::ApplyStagedOutcome::Applied { version }) => {
            println!("APPLIED:{version}");
            0
        }
        Ok(spt_daemon::ApplyStagedOutcome::RefusedClass(class)) => {
            eprintln!("UPDATE_APPLY_REFUSED:{class:?} (full-swap orchestration is not M5 scope)");
            1
        }
        Ok(spt_daemon::ApplyStagedOutcome::Rejected(reason)) => {
            eprintln!("UPDATE_APPLY_REJECTED:{reason}");
            1
        }
        Err(e) => {
            eprintln!("UPDATE_APPLY_FAIL:{e}");
            1
        }
    }
}

/// The release-asset name for THIS platform — matches the release-publish
/// assets on spt-releases. Pure for tests.
fn platform_release_asset() -> &'static str {
    if cfg!(windows) {
        "spt-x86_64-windows.exe"
    } else {
        "spt-x86_64-linux"
    }
}

/// The two origin URLs (artifact, then its `.release.json` metadata) for a
/// release on the GitHub origin. `tag = None` ⇒ the `latest` release. Pure.
fn origin_release_urls(repo: &str, asset: &str, tag: Option<&str>) -> (String, String) {
    let base = match tag {
        Some(t) => format!("https://github.com/{repo}/releases/download/{t}"),
        None => format!("https://github.com/{repo}/releases/latest/download"),
    };
    (
        format!("{base}/{asset}"),
        format!("{base}/{asset}.release.json"),
    )
}

fn http_get_text(client: &reqwest::blocking::Client, url: &str) -> Result<String, String> {
    let resp = client.get(url).send().map_err(|e| e.to_string())?;
    if !resp.status().is_success() {
        return Err(format!("HTTP {}", resp.status()));
    }
    resp.text().map_err(|e| e.to_string())
}

fn http_get_bytes(client: &reqwest::blocking::Client, url: &str) -> Result<Vec<u8>, String> {
    let resp = client.get(url).send().map_err(|e| e.to_string())?;
    if !resp.status().is_success() {
        return Err(format!("HTTP {}", resp.status()));
    }
    resp.bytes().map(|b| b.to_vec()).map_err(|e| e.to_string())
}

/// `spt update fetch` (REQ-UPD-7) — pull the signed release from the GitHub
/// origin and stage it through the SAME verify gate the P2P pull uses
/// (`plan_verified`: two-key signature + channel + monotonic rollback floor +
/// SHA-256), after which the existing `spt update apply` flow takes over. Closes
/// the peer-only discovery gap: a first-in-fleet / isolated node can update with
/// no peer to pull from. The signed-release anchor keeps the GitHub transport
/// untrusted-but-verified.
// [impl->REQ-UPD-7]
fn cmd_update_fetch(channel: Option<String>, tag: Option<String>) -> i32 {
    let repo =
        std::env::var("SPT_INSTALL_REPO").unwrap_or_else(|_| "SaberMage/spt-releases".to_string());
    let asset = platform_release_asset();
    let (art_url, meta_url) = origin_release_urls(&repo, asset, tag.as_deref());

    // Verify policy: the two-key anchor + the node's release-keys overlay; the
    // rollback floor is the staged version on the pinned channel (mirrors the
    // P2P path, peerloop.rs). `--channel` overrides the pin to deliberately
    // accept another channel's release.
    let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join("releases"));
    let keys = perch::identity_dir().join("release-keys.json");
    let mut policy = spt_daemon::release::VerifyPolicy::load_from(&keys, 0, now_ms());
    let pinned = policy.pinned_channel.clone();
    policy.current_version = if cache.staged_channel().as_deref() == Some(pinned.as_str()) {
        cache.staged_version().unwrap_or(0)
    } else {
        0
    };
    if let Some(c) = channel {
        policy.pinned_channel = c;
    }
    if policy.trusted_keys.is_empty() {
        eprintln!("UPDATE_FETCH_FAIL: no trusted release keys");
        return 1;
    }

    eprintln!("Fetching {asset} from {repo}…");
    let client = match reqwest::blocking::Client::builder()
        .user_agent(concat!("spt/", env!("CARGO_PKG_VERSION")))
        .build()
    {
        Ok(c) => c,
        Err(e) => {
            eprintln!("UPDATE_FETCH_FAIL: http client: {e}");
            return 1;
        }
    };
    let meta_text = match http_get_text(&client, &meta_url) {
        Ok(t) => t,
        Err(e) => {
            eprintln!("UPDATE_FETCH_FAIL: GET {meta_url}: {e}");
            return 1;
        }
    };
    let signed: spt_daemon::release::SignedRelease = match serde_json::from_str(&meta_text) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("UPDATE_FETCH_FAIL: bad release.json: {e}");
            return 1;
        }
    };
    let artifact = match http_get_bytes(&client, &art_url) {
        Ok(b) => b,
        Err(e) => {
            eprintln!("UPDATE_FETCH_FAIL: GET {art_url}: {e}");
            return 1;
        }
    };

    // The SAME gate the daemon's peer pull runs — no inherited trust from the
    // GitHub transport.
    match spt_daemon::update::plan_verified(
        &spt_daemon::update::BrokerAbi::current(),
        &signed,
        &artifact,
        &policy,
    ) {
        Ok(_plan) => {
            if let Err(e) = cache.stage(&signed, &artifact) {
                eprintln!("UPDATE_FETCH_FAIL: stage: {e}");
                return 1;
            }
            let v = cache
                .staged_version()
                .map(|v| v.to_string())
                .unwrap_or_else(|| "?".to_string());
            println!("UPDATE_STAGED:{v} (from {repo}) — run `spt update apply` to apply");
            0
        }
        Err(reason) => {
            eprintln!("UPDATE_FETCH_REJECTED:{reason:?}");
            1
        }
    }
}

/// Epoch milliseconds (the notif surfacing clock).
fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".
fn resolve_from(from: Option<String>) -> String {
    from.or_else(roster::detect_self_id).unwrap_or_default()
}

fn cmd_send(
    target: Option<String>,
    from: Option<String>,
    reply_to: Option<String>,
    deferred: bool,
) -> i32 {
    let is_reply = reply_to.is_some();
    // --reply-to provides the target when no positional target is given.
    let target = match target.or_else(|| reply_to.clone()) {
        Some(t) => t,
        None => {
            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
            return 2;
        }
    };
    let from = resolve_from(from);
    // Trim stdin like cmd_notify (REQ-HAZARD-ENVELOPE-CR-LINESAFE parity): a
    // Windows `echo | spt send` pipes a trailing CRLF — strip it so the message
    // has no trailing blank line (the codec also neutralizes any interior CR).
    let body = read_stdin();
    let body = body.trim();
    let owlery = perch::owlery_dir();

    let outcome = if deferred {
        deliver::send_deferred(&target, &from, body)
    } else {
        deliver::send(&target, &from, body, &owlery)
    };

    match outcome {
        SendOutcome::Sent => {
            ok_line(format!(
                "{}:{}",
                if is_reply { "REPLIED" } else { "SENT" },
                target


Now xtask publish around the update-set construction.


700	    json["channel"] = serde_json::Value::String("debug".to_string());
701	    if json.get("revoked").is_none() {
702	        json["revoked"] = serde_json::json!([]);
703	    }
704	    std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap())
705	        .expect("write release keys");
706	    println!("debug-pinned node release policy: {}", path.display());
707	}
708
709	// [impl->REQ-UPD-6]
710	fn debug_rollout(args: &[String]) {
711	    let mut key_id = "dev-debug-2026".to_string();
712	    let mut version = None;
713	    let mut expires_days: u64 = 14;
714	    let mut stage_dir = None;
715	    let mut state_path = None;
716	    let mut build_current = false;
717	    let mut artifact_args: Vec<String> = Vec::new();
718	    let mut it = args.iter();
719	    while let Some(a) = it.next() {
720	        match a.as_str() {
721	            "--key-id" => key_id = it.next().cloned().expect("--key-id <id>"),
722	            "--version" => {
723	                version = Some(
724	                    it.next()
725	                        .and_then(|v| v.parse().ok())
726	                        .expect("--version <u64>"),
727	                )
728	            }
729	            "--expires-days" => {
730	                expires_days = it
731	                    .next()
732	                    .and_then(|v| v.parse().ok())
733	                    .expect("--expires-days <n>")
734	            }
735	            "--stage-dir" => stage_dir = it.next().map(PathBuf::from),
736	            "--state" => state_path = it.next().map(PathBuf::from),
737	            "--build-current" => build_current = true,
738	            "--artifact" => {
739	                artifact_args.push(it.next().cloned().expect("--artifact <target=path>"))
740	            }
741	            other => panic!("unknown arg {other}"),
742	        }
743	    }
744
745	    let root = repo_root();
746	    let state_path =
747	        state_path.unwrap_or_else(|| root.join("target").join("debug-rollout-state.json"));
748	    let version = version.unwrap_or_else(|| next_debug_version(&state_path));
749	    let identity = debug_signing_identity();
750
751	    let mut artifact_paths: Vec<(String, PathBuf)> =
752	        artifact_args.iter().map(|a| parse_target_path(a)).collect();
753	    if build_current {
754	        let path = build_release_spt(&root);
755	        artifact_paths.push((spt_daemon::current_platform().to_string(), path));
756	    }
757	    assert!(
758	        !artifact_paths.is_empty(),
759	        "provide --artifact <target=path> or --build-current"
760	    );
761
762	    let now = system_time_ms();
763	    let mut artifact_meta = std::collections::BTreeMap::new();
764	    let mut artifact_bytes = std::collections::BTreeMap::new();
765	    for (target, path) in artifact_paths {
766	        let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
767	        artifact_meta.insert(
768	            target.clone(),
769	            spt_daemon::UpdateArtifactMetadata {
770	                artifact_sha256: spt_daemon::sha256_hex(&bytes),
771	                brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
772	                broker_resource_abi: spt_daemon::BROKER_RESOURCE_ABI,
773	                asset_name: path
774	                    .file_name()
775	                    .and_then(|n| n.to_str())
776	                    .map(str::to_string),
777	            },
778	        );
779	        artifact_bytes.insert(target, bytes);
780	    }
781
782	    let meta = spt_daemon::UpdateSetMetadata {
783	        version,
784	        channel: "debug".to_string(),
785	        expires_at_ms: now + expires_days * 24 * 60 * 60 * 1000,
786	        key_id: key_id.clone(),
787	        artifacts: artifact_meta,
788	        provenance: Some(spt_daemon::UpdateSetProvenance {
789	            git_commit: git_stdout(&root, &["rev-parse", "HEAD"]),
790	            git_dirty: Some(
791	                git_stdout(&root, &["status", "--porcelain"]).is_some_and(|s| !s.is_empty()),
792	            ),
793	            build_host: build_host(),
794	            build_timestamp_ms: Some(now),
795	        }),
796	    };
797	    let metadata_json = serde_json::to_string(&meta).expect("serialize update set");
798	    let sig = identity.sign(metadata_json.as_bytes());
799	    let signed = spt_daemon::SignedUpdateSet {
800	        metadata_json,
801	        signature_hex: sig.iter().map(|b| format!("{b:02x}")).collect(),
802	    };
803
804	    let pub_hex = identity.public_key().to_hex();
805	    let vk = spt_daemon::parse_verifying_key(&pub_hex).expect("debug public key parses");
806	    let policy = spt_daemon::VerifyPolicy {
807	        trusted_keys: std::collections::BTreeMap::from([(key_id.clone(), vk)]),
808	        revoked_keys: std::collections::BTreeSet::new(),
809	        pinned_channel: "debug".to_string(),
810	        current_version: 0,
811	        now_ms: now,
812	    };
813	    let verified = spt_daemon::verify_update_set_metadata(&signed, &policy)
814	        .expect("freshly signed update set verifies");
815	    for (target, bytes) in &artifact_bytes {
816	        spt_daemon::verify_update_set_artifact(&verified, target, bytes)
817	            .unwrap_or_else(|e| panic!("{target}: artifact verification failed: {e}"));
818	    }
819
820	    let stage_dir = stage_dir.unwrap_or_else(|| spt_store::perch::spt_home().join("releases"));
821	    spt_daemon::ReleaseCache::open(&stage_dir)
822	        .stage_update_set(&signed, &artifact_bytes)
823	        .expect("stage debug update set");
824	    write_debug_version(&state_path, version);
825	    println!(
826	        "DEBUG_ROLLOUT_STAGED version={version} channel=debug platforms={} stage_dir={}",
827	        verified
828	            .artifacts
829	            .keys()
830	            .cloned()
831	            .collect::<Vec<_>>()
832	            .join(","),
833	        stage_dir.display()
834	    );
835	    println!("Peers pinned to debug will fetch via the normal update pump; run/poke the daemon pump for immediate propagation.");
836	}
837
838	/// `xtask debug-converge` (M8 decision 19, REQ-UPD-6,
839	/// docs/DEBUG-CONVERGE-PLAN.md): after a coordinator stages a debug rollout,
840	/// answer "did every expected lab node converge on the target debug version,
841	/// and if not, why not?" without hand-walking nodes. Watches via the
842	/// status-only update query (`spt_daemon::propagate::request_update_status`)
843	/// over the local daemon's broker until every expected node is `Applied`, or
844	/// prints the per-node timeout table. Maintainer tooling only — never the
845	/// public `spt` surface (ADR-0016).
846	///
847	/// Exit codes: `0` converged, `1` not converged (timeout/failure rows),
848	/// `2` usage — machine-distinguishable per the plan.
849	// [impl->REQ-UPD-6]
850	fn debug_converge(args: &[String]) {
851	    use spt_daemon::propagate::{classify_status, ConvergeState, UpdateStatusReport};
852
853	    let mut version: Option<u64> = None;
854	    let mut nodes: Vec<String> = Vec::new();
855	    let mut subnet: Option<String> = None;
856	    let mut timeout_secs: u64 = 120;
857	    let mut poll_secs: u64 = 3;
858	    let mut it = args.iter();
859	    while let Some(a) = it.next() {

    json["channel"] = serde_json::Value::String("debug".to_string());
    if json.get("revoked").is_none() {
        json["revoked"] = serde_json::json!([]);
    }
    std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap())
        .expect("write release keys");
    println!("debug-pinned node release policy: {}", path.display());
}

// [impl->REQ-UPD-6]
fn debug_rollout(args: &[String]) {
    let mut key_id = "dev-debug-2026".to_string();
    let mut version = None;
    let mut expires_days: u64 = 14;
    let mut stage_dir = None;
    let mut state_path = None;
    let mut build_current = false;
    let mut artifact_args: Vec<String> = Vec::new();
    let mut it = args.iter();
    while let Some(a) = it.next() {
        match a.as_str() {
            "--key-id" => key_id = it.next().cloned().expect("--key-id <id>"),
            "--version" => {
                version = Some(
                    it.next()
                        .and_then(|v| v.parse().ok())
                        .expect("--version <u64>"),
                )
            }
            "--expires-days" => {
                expires_days = it
                    .next()
                    .and_then(|v| v.parse().ok())
                    .expect("--expires-days <n>")
            }
            "--stage-dir" => stage_dir = it.next().map(PathBuf::from),
            "--state" => state_path = it.next().map(PathBuf::from),
            "--build-current" => build_current = true,
            "--artifact" => {
                artifact_args.push(it.next().cloned().expect("--artifact <target=path>"))
            }
            other => panic!("unknown arg {other}"),
        }
    }

    let root = repo_root();
    let state_path =
        state_path.unwrap_or_else(|| root.join("target").join("debug-rollout-state.json"));
    let version = version.unwrap_or_else(|| next_debug_version(&state_path));
    let identity = debug_signing_identity();

    let mut artifact_paths: Vec<(String, PathBuf)> =
        artifact_args.iter().map(|a| parse_target_path(a)).collect();
    if build_current {
        let path = build_release_spt(&root);
        artifact_paths.push((spt_daemon::current_platform().to_string(), path));
    }
    assert!(
        !artifact_paths.is_empty(),
        "provide --artifact <target=path> or --build-current"
    );

    let now = system_time_ms();
    let mut artifact_meta = std::collections::BTreeMap::new();
    let mut artifact_bytes = std::collections::BTreeMap::new();
    for (target, path) in artifact_paths {
        let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
        artifact_meta.insert(
            target.clone(),
            spt_daemon::UpdateArtifactMetadata {
                artifact_sha256: spt_daemon::sha256_hex(&bytes),
                brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
                broker_resource_abi: spt_daemon::BROKER_RESOURCE_ABI,
                asset_name: path
                    .file_name()
                    .and_then(|n| n.to_str())
                    .map(str::to_string),
            },
        );
        artifact_bytes.insert(target, bytes);
    }

    let meta = spt_daemon::UpdateSetMetadata {
        version,
        channel: "debug".to_string(),
        expires_at_ms: now + expires_days * 24 * 60 * 60 * 1000,
        key_id: key_id.clone(),
        artifacts: artifact_meta,
        provenance: Some(spt_daemon::UpdateSetProvenance {
            git_commit: git_stdout(&root, &["rev-parse", "HEAD"]),
            git_dirty: Some(
                git_stdout(&root, &["status", "--porcelain"]).is_some_and(|s| !s.is_empty()),
            ),
            build_host: build_host(),
            build_timestamp_ms: Some(now),
        }),
    };
    let metadata_json = serde_json::to_string(&meta).expect("serialize update set");
    let sig = identity.sign(metadata_json.as_bytes());
    let signed = spt_daemon::SignedUpdateSet {
        metadata_json,
        signature_hex: sig.iter().map(|b| format!("{b:02x}")).collect(),
    };

    let pub_hex = identity.public_key().to_hex();
    let vk = spt_daemon::parse_verifying_key(&pub_hex).expect("debug public key parses");
    let policy = spt_daemon::VerifyPolicy {
        trusted_keys: std::collections::BTreeMap::from([(key_id.clone(), vk)]),
        revoked_keys: std::collections::BTreeSet::new(),
        pinned_channel: "debug".to_string(),
        current_version: 0,
        now_ms: now,
    };
    let verified = spt_daemon::verify_update_set_metadata(&signed, &policy)
        .expect("freshly signed update set verifies");
    for (target, bytes) in &artifact_bytes {
        spt_daemon::verify_update_set_artifact(&verified, target, bytes)
            .unwrap_or_else(|e| panic!("{target}: artifact verification failed: {e}"));
    }

    let stage_dir = stage_dir.unwrap_or_else(|| spt_store::perch::spt_home().join("releases"));
    spt_daemon::ReleaseCache::open(&stage_dir)
        .stage_update_set(&signed, &artifact_bytes)
        .expect("stage debug update set");
    write_debug_version(&state_path, version);
    println!(
        "DEBUG_ROLLOUT_STAGED version={version} channel=debug platforms={} stage_dir={}",
        verified
            .artifacts
            .keys()
            .cloned()
            .collect::<Vec<_>>()
            .join(","),
        stage_dir.display()
    );
    println!("Peers pinned to debug will fetch via the normal update pump; run/poke the daemon pump for immediate propagation.");
}

/// `xtask debug-converge` (M8 decision 19, REQ-UPD-6,
/// docs/DEBUG-CONVERGE-PLAN.md): after a coordinator stages a debug rollout,
/// answer "did every expected lab node converge on the target debug version,
/// and if not, why not?" without hand-walking nodes. Watches via the
/// status-only update query (`spt_daemon::propagate::request_update_status`)
/// over the local daemon's broker until every expected node is `Applied`, or
/// prints the per-node timeout table. Maintainer tooling only — never the
/// public `spt` surface (ADR-0016).
///
/// Exit codes: `0` converged, `1` not converged (timeout/failure rows),
/// `2` usage — machine-distinguishable per the plan.
// [impl->REQ-UPD-6]
fn debug_converge(args: &[String]) {
    use spt_daemon::propagate::{classify_status, ConvergeState, UpdateStatusReport};

    let mut version: Option<u64> = None;
    let mut nodes: Vec<String> = Vec::new();
    let mut subnet: Option<String> = None;
    let mut timeout_secs: u64 = 120;
    let mut poll_secs: u64 = 3;
    let mut it = args.iter();
    while let Some(a) = it.next() {


Now find the production release-publish path in xtask.


45:        "release-publish" => release_publish(&args[1..]),
53:                "usage: xtask <gen|check|site <outdir>|release-keygen <key-id>|release-sign …|release-publish …|debug-keygen <key-id>|debug-pin …|debug-rollout …|debug-converge --version <u64> …>"
351:///  <artifact>…` writes `<artifact>.release.json` beside each artifact.
423:/// Sign each artifact, writing `<artifact>.release.json` beside it.
477:/// `xtask release-publish --tag v0.1.0 --key-id rel-primary-2026 --version 1`
482:/// 4. upload the *.release.json assets
485:fn release_publish(args: &[String]) {
541:        if name.ends_with(".release.json") {
590:/// `xtask release-verify <signed.release.json> [artifact] [--current-version <u64>]`


340	    println!("2. Add (\"{key_id}\", \"<public_hex>\") to BUILTIN_RELEASE_KEYS in");
341	    println!("   crates/spt-daemon/src/release.rs and rebuild.");
342	    println!("3. Clear this terminal's scrollback.");
343	}
344
345	/// The manual local signing step (ADR-0015 — no CI secrets): produce the
346	/// exact `SignedRelease` JSON the self-updater consumes, one per platform
347	/// artifact. The signing seed comes from the SPT_RELEASE_SEED env var
348	/// (paste from the password manager into the shell session, never argv).
349	///
350	/// `xtask release-sign --key-id <id> --version <u64> [--expires-days <n>]
351	///  <artifact>…` writes `<artifact>.release.json` beside each artifact.
352	// [impl->REQ-REL-2]
353	// [impl->REQ-REL-3]
354	fn release_sign(args: &[String]) {
355	    let mut key_id = None;
356	    let mut version = None;
357	    let mut expires_days: u64 = 365;
358	    let mut artifacts: Vec<PathBuf> = Vec::new();
359	    let mut it = args.iter();
360	    while let Some(a) = it.next() {
361	        match a.as_str() {
362	            "--key-id" => key_id = it.next().cloned(),
363	            "--version" => version = it.next().and_then(|v| v.parse::<u64>().ok()),
364	            "--expires-days" => {
365	                expires_days = it
366	                    .next()
367	                    .and_then(|v| v.parse().ok())
368	                    .expect("--expires-days <n>")
369	            }
370	            other => artifacts.push(PathBuf::from(other)),
371	        }
372	    }
373	    let key_id = key_id.expect("--key-id <id> (must match a BUILTIN_RELEASE_KEYS id)");
374	    let version = version.expect("--version <u64> (the monotonic release counter)");
375	    assert!(!artifacts.is_empty(), "at least one artifact to sign");
376
377	    let identity = signing_identity();
378	    println!(
379	        "signing as: {} (key_id {key_id})",
380	        identity.public_key().to_hex()
381	    );
382	    sign_artifacts(&identity, &key_id, version, expires_days, &artifacts);
383	}
384
385	/// Resolve the signing identity. Preference order:
386	/// 1. `SPT_RELEASE_SEED_CMD` — a command that prints the seed hex (the
387	///    password-manager CLI integration, e.g.
388	///    `op read "op://Private/spt release primary/seed"` or
389	///    `bw get notes spt-release-primary`): the seed never sits in shell
390	///    history or a long-lived env var.
391	/// 2. `SPT_RELEASE_SEED` — the seed hex pasted into the env directly.
392	// [impl->REQ-REL-3]
393	fn signing_identity() -> spt_proto::identity::Identity {
394	    let seed_hex = if let Ok(cmd) = std::env::var("SPT_RELEASE_SEED_CMD") {
395	        let out = if cfg!(windows) {
396	            Command::new("powershell")
397	                .args(["-NoProfile", "-Command", &cmd])
398	                .output()
399	        } else {
400	            Command::new("sh").args(["-c", &cmd]).output()
401	        }
402	        .expect("run SPT_RELEASE_SEED_CMD");
403	        assert!(
404	            out.status.success(),
405	            "SPT_RELEASE_SEED_CMD failed: {}",
406	            String::from_utf8_lossy(&out.stderr)
407	        );
408	        String::from_utf8_lossy(&out.stdout).trim().to_string()
409	    } else {
410	        std::env::var("SPT_RELEASE_SEED").expect(
411	            "set SPT_RELEASE_SEED_CMD (a command printing the seed, e.g. your \
412	             password manager's CLI) or SPT_RELEASE_SEED (the seed hex itself)",
413	        )
414	    };
415	    let seed: Vec<u8> = (0..seed_hex.len())
416	        .step_by(2)
417	        .map(|i| u8::from_str_radix(&seed_hex[i..i + 2], 16).expect("seed is hex"))
418	        .collect();
419	    let seed: [u8; 32] = seed.as_slice().try_into().expect("seed is 32 bytes");
420	    spt_proto::identity::Identity::from_seed(&seed)
421	}
422
423	/// Sign each artifact, writing `<artifact>.release.json` beside it.
424	// [impl->REQ-REL-2]
425	fn sign_artifacts(
426	    identity: &spt_proto::identity::Identity,
427	    key_id: &str,
428	    version: u64,
429	    expires_days: u64,
430	    artifacts: &[PathBuf],
431	) -> Vec<PathBuf> {
432	    let now_ms = std::time::SystemTime::now()
433	        .duration_since(std::time::UNIX_EPOCH)
434	        .unwrap()
435	        .as_millis() as u64;
436	    let mut outs = Vec::new();
437	    for artifact in artifacts {
438	        let bytes = std::fs::read(artifact).expect("read artifact");
439	        let meta = spt_daemon::release::ReleaseMetadata {
440	            version,
441	            channel: spt_daemon::release::DEFAULT_CHANNEL.to_string(),
442	            expires_at_ms: now_ms + expires_days * 24 * 60 * 60 * 1000,
443	            key_id: key_id.to_string(),
444	            artifact_sha256: spt_daemon::release::sha256_hex(&bytes),
445	            brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
446	            broker_resource_abi: spt_daemon::update::BROKER_RESOURCE_ABI,
447	        };
448	        let metadata_json = serde_json::to_string(&meta).expect("serialize metadata");
449	        let sig = identity.sign(metadata_json.as_bytes());
450	        let sig_hex: String = sig.iter().map(|b| format!("{b:02x}")).collect();
451	        let signed = spt_daemon::release::SignedRelease {
452	            metadata_json,
453	            signature_hex: sig_hex,
454	        };
455	        let out = artifact.with_extension(format!(
456	            "{}release.json",
457	            artifact
458	                .extension()
459	                .map(|e| format!("{}.", e.to_string_lossy()))
460	                .unwrap_or_default()
461	        ));
462	        std::fs::write(&out, serde_json::to_string_pretty(&signed).unwrap()).expect("write");
463	        println!(
464	            "signed {} (sha256 {}) -> {}",
465	            artifact.display(),
466	            meta.artifact_sha256,
467	            out.display()
468	        );
469	        outs.push(out);
470	    }
471	    outs
472	}
473
474	/// The whole maintainer half of a release in one local command (the manual
475	/// ceremony, automated — the seed still never leaves this machine):
476	///
477	/// `xtask release-publish --tag v0.1.0 --key-id rel-primary-2026 --version 1`
478	///
479	/// 1. download the CI draft's binaries + SHA256SUMS from spt-releases
480	/// 2. verify every binary against SHA256SUMS (never sign unverified bytes)
481	/// 3. sign each binary (seed via SPT_RELEASE_SEED_CMD / SPT_RELEASE_SEED)
482	/// 4. upload the *.release.json assets
483	/// 5. publish the draft
484	// [impl->REQ-REL-2]
485	fn release_publish(args: &[String]) {
486	    let mut tag = None;
487	    let mut key_id = None;
488	    let mut version = None;
489	    let mut expires_days: u64 = 365;
490	    let mut it = args.iter();
491	    while let Some(a) = it.next() {
492	        match a.as_str() {
493	            "--tag" => tag = it.next().cloned(),
494	            "--key-id" => key_id = it.next().cloned(),
495	            "--version" => version = it.next().and_then(|v| v.parse::<u64>().ok()),
496	            "--expires-days" => {
497	                expires_days = it
498	                    .next()
499	                    .and_then(|v| v.parse().ok())
500	                    .expect("--expires-days <n>")
501	            }
502	            other => panic!("unknown arg {other}"),
503	        }
504	    }
505	    let tag = tag.expect("--tag <vX.Y.Z>");
506	    let key_id = key_id.expect("--key-id <id>");
507	    let version = version.expect("--version <u64> (the monotonic release counter)");
508	    const REPO: &str = "SaberMage/spt-releases";
509
510	    // 1. Pull the draft's binaries + SHA256SUMS into a scratch dir.
511	    let work = std::env::temp_dir().join(format!("spt-release-{tag}"));
512	    let _ = std::fs::remove_dir_all(&work);
513	    std::fs::create_dir_all(&work).unwrap();
514	    run(
515	        Command::new("gh").args([
516	            "release",
517	            "download",
518	            &tag,
519	            "--repo",
520	            REPO,
521	            "--dir",
522	            work.to_str().unwrap(),
523	            "--pattern",
524	            "spt-x86_64-*",
525	            "--pattern",
526	            "SHA256SUMS",
527	        ]),
528	        "gh release download",
529	    );
530
531	    // 2. Verify before signing — the signature must attest to exactly the
532	    //    bytes CI published, and those bytes must match its own SHA256SUMS.
533	    let sums = std::fs::read_to_string(work.join("SHA256SUMS")).expect("SHA256SUMS");
534	    let mut artifacts = Vec::new();
535	    for line in sums.lines() {
536	        let mut parts = line.split_whitespace();
537	        let (Some(want), Some(name)) = (parts.next(), parts.next()) else {
538	            continue;
539	        };
540	        let name = name.trim_start_matches('*');
541	        if name.ends_with(".release.json") {
542	            continue;
543	        }
544	        let path = work.join(name);
545	        let bytes =
546	            std::fs::read(&path).unwrap_or_else(|e| panic!("downloaded asset {name} missing: {e}"));
547	        let got = spt_daemon::release::sha256_hex(&bytes);
548	        assert_eq!(
549	            got,
550	            want.to_ascii_lowercase(),
551	            "{name}: sha256 mismatch — refusing to sign"
552	        );
553	        println!("verified {name} ({got})");
554	        artifacts.push(path);
555	    }
556	    assert!(
557	        !artifacts.is_empty(),
558	        "no binaries found in the draft release"
559	    );
560
561	    // 3. Sign.
562	    let identity = signing_identity();
563	    println!(
564	        "signing as: {} (key_id {key_id})",
565	        identity.public_key().to_hex()
566	    );
567	    let signed = sign_artifacts(&identity, &key_id, version, expires_days, &artifacts);
568
569	    // 4. Upload the SignedRelease assets.
570	    let mut upload = Command::new("gh");
571	    upload.args(["release", "upload", &tag, "--repo", REPO, "--clobber"]);
572	    for s in &signed {
573	        upload.arg(s);
574	    }
575	    run(&mut upload, "gh release upload");
576
577	    // 5. Publish.
578	    run(
579	        Command::new("gh").args(["release", "edit", &tag, "--repo", REPO, "--draft=false"]),
580	        "gh release edit --draft=false",
581	    );
582	    println!("release {tag} signed + published.");
583	}
584
585	/// The `spt update` dry path, runnable by hand: verify a SignedRelease
586	/// metadata file (and optionally its artifact bytes) against the **embedded**
587	/// two-key trust anchor — exactly the policy a deployed node applies to a
588	/// propagated release. The D7 acceptance verifier.
589	///
590	/// `xtask release-verify <signed.release.json> [artifact] [--current-version <u64>]`
591	// [impl->REQ-REL-2]
592	fn release_verify(args: &[String]) {
593	    let mut signed_path = None;
594	    let mut artifact = None;
595	    let mut current_version: u64 = 0;
596	    let mut it = args.iter();
597	    while let Some(a) = it.next() {
598	        match a.as_str() {
599	            "--current-version" => {

    println!("2. Add (\"{key_id}\", \"<public_hex>\") to BUILTIN_RELEASE_KEYS in");
    println!("   crates/spt-daemon/src/release.rs and rebuild.");
    println!("3. Clear this terminal's scrollback.");
}

/// The manual local signing step (ADR-0015 — no CI secrets): produce the
/// exact `SignedRelease` JSON the self-updater consumes, one per platform
/// artifact. The signing seed comes from the SPT_RELEASE_SEED env var
/// (paste from the password manager into the shell session, never argv).
///
/// `xtask release-sign --key-id <id> --version <u64> [--expires-days <n>]
///  <artifact>…` writes `<artifact>.release.json` beside each artifact.
// [impl->REQ-REL-2]
// [impl->REQ-REL-3]
fn release_sign(args: &[String]) {
    let mut key_id = None;
    let mut version = None;
    let mut expires_days: u64 = 365;
    let mut artifacts: Vec<PathBuf> = Vec::new();
    let mut it = args.iter();
    while let Some(a) = it.next() {
        match a.as_str() {
            "--key-id" => key_id = it.next().cloned(),
            "--version" => version = it.next().and_then(|v| v.parse::<u64>().ok()),
            "--expires-days" => {
                expires_days = it
                    .next()
                    .and_then(|v| v.parse().ok())
                    .expect("--expires-days <n>")
            }
            other => artifacts.push(PathBuf::from(other)),
        }
    }
    let key_id = key_id.expect("--key-id <id> (must match a BUILTIN_RELEASE_KEYS id)");
    let version = version.expect("--version <u64> (the monotonic release counter)");
    assert!(!artifacts.is_empty(), "at least one artifact to sign");

    let identity = signing_identity();
    println!(
        "signing as: {} (key_id {key_id})",
        identity.public_key().to_hex()
    );
    sign_artifacts(&identity, &key_id, version, expires_days, &artifacts);
}

/// Resolve the signing identity. Preference order:
/// 1. `SPT_RELEASE_SEED_CMD` — a command that prints the seed hex (the
///    password-manager CLI integration, e.g.
///    `op read "op://Private/spt release primary/seed"` or
///    `bw get notes spt-release-primary`): the seed never sits in shell
///    history or a long-lived env var.
/// 2. `SPT_RELEASE_SEED` — the seed hex pasted into the env directly.
// [impl->REQ-REL-3]
fn signing_identity() -> spt_proto::identity::Identity {
    let seed_hex = if let Ok(cmd) = std::env::var("SPT_RELEASE_SEED_CMD") {
        let out = if cfg!(windows) {
            Command::new("powershell")
                .args(["-NoProfile", "-Command", &cmd])
                .output()
        } else {
            Command::new("sh").args(["-c", &cmd]).output()
        }
        .expect("run SPT_RELEASE_SEED_CMD");
        assert!(
            out.status.success(),
            "SPT_RELEASE_SEED_CMD failed: {}",
            String::from_utf8_lossy(&out.stderr)
        );
        String::from_utf8_lossy(&out.stdout).trim().to_string()
    } else {
        std::env::var("SPT_RELEASE_SEED").expect(
            "set SPT_RELEASE_SEED_CMD (a command printing the seed, e.g. your \
             password manager's CLI) or SPT_RELEASE_SEED (the seed hex itself)",
        )
    };
    let seed: Vec<u8> = (0..seed_hex.len())
        .step_by(2)
        .map(|i| u8::from_str_radix(&seed_hex[i..i + 2], 16).expect("seed is hex"))
        .collect();
    let seed: [u8; 32] = seed.as_slice().try_into().expect("seed is 32 bytes");
    spt_proto::identity::Identity::from_seed(&seed)
}

/// Sign each artifact, writing `<artifact>.release.json` beside it.
// [impl->REQ-REL-2]
fn sign_artifacts(
    identity: &spt_proto::identity::Identity,
    key_id: &str,
    version: u64,
    expires_days: u64,
    artifacts: &[PathBuf],
) -> Vec<PathBuf> {
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_millis() as u64;
    let mut outs = Vec::new();
    for artifact in artifacts {
        let bytes = std::fs::read(artifact).expect("read artifact");
        let meta = spt_daemon::release::ReleaseMetadata {
            version,
            channel: spt_daemon::release::DEFAULT_CHANNEL.to_string(),
            expires_at_ms: now_ms + expires_days * 24 * 60 * 60 * 1000,
            key_id: key_id.to_string(),
            artifact_sha256: spt_daemon::release::sha256_hex(&bytes),
            brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
            broker_resource_abi: spt_daemon::update::BROKER_RESOURCE_ABI,
        };
        let metadata_json = serde_json::to_string(&meta).expect("serialize metadata");
        let sig = identity.sign(metadata_json.as_bytes());
        let sig_hex: String = sig.iter().map(|b| format!("{b:02x}")).collect();
        let signed = spt_daemon::release::SignedRelease {
            metadata_json,
            signature_hex: sig_hex,
        };
        let out = artifact.with_extension(format!(
            "{}release.json",
            artifact
                .extension()
                .map(|e| format!("{}.", e.to_string_lossy()))
                .unwrap_or_default()
        ));
        std::fs::write(&out, serde_json::to_string_pretty(&signed).unwrap()).expect("write");
        println!(
            "signed {} (sha256 {}) -> {}",
            artifact.display(),
            meta.artifact_sha256,
            out.display()
        );
        outs.push(out);
    }
    outs
}

/// The whole maintainer half of a release in one local command (the manual
/// ceremony, automated — the seed still never leaves this machine):
///
/// `xtask release-publish --tag v0.1.0 --key-id rel-primary-2026 --version 1`
///
/// 1. download the CI draft's binaries + SHA256SUMS from spt-releases
/// 2. verify every binary against SHA256SUMS (never sign unverified bytes)
/// 3. sign each binary (seed via SPT_RELEASE_SEED_CMD / SPT_RELEASE_SEED)
/// 4. upload the *.release.json assets
/// 5. publish the draft
// [impl->REQ-REL-2]
fn release_publish(args: &[String]) {
    let mut tag = None;
    let mut key_id = None;
    let mut version = None;
    let mut expires_days: u64 = 365;
    let mut it = args.iter();
    while let Some(a) = it.next() {
        match a.as_str() {
            "--tag" => tag = it.next().cloned(),
            "--key-id" => key_id = it.next().cloned(),
            "--version" => version = it.next().and_then(|v| v.parse::<u64>().ok()),
            "--expires-days" => {
                expires_days = it
                    .next()
                    .and_then(|v| v.parse().ok())
                    .expect("--expires-days <n>")
            }
            other => panic!("unknown arg {other}"),
        }
    }
    let tag = tag.expect("--tag <vX.Y.Z>");
    let key_id = key_id.expect("--key-id <id>");
    let version = version.expect("--version <u64> (the monotonic release counter)");
    const REPO: &str = "SaberMage/spt-releases";

    // 1. Pull the draft's binaries + SHA256SUMS into a scratch dir.
    let work = std::env::temp_dir().join(format!("spt-release-{tag}"));
    let _ = std::fs::remove_dir_all(&work);
    std::fs::create_dir_all(&work).unwrap();
    run(
        Command::new("gh").args([
            "release",
            "download",
            &tag,
            "--repo",
            REPO,
            "--dir",
            work.to_str().unwrap(),
            "--pattern",
            "spt-x86_64-*",
            "--pattern",
            "SHA256SUMS",
        ]),
        "gh release download",
    );

    // 2. Verify before signing — the signature must attest to exactly the
    //    bytes CI published, and those bytes must match its own SHA256SUMS.
    let sums = std::fs::read_to_string(work.join("SHA256SUMS")).expect("SHA256SUMS");
    let mut artifacts = Vec::new();
    for line in sums.lines() {
        let mut parts = line.split_whitespace();
        let (Some(want), Some(name)) = (parts.next(), parts.next()) else {
            continue;
        };
        let name = name.trim_start_matches('*');
        if name.ends_with(".release.json") {
            continue;
        }
        let path = work.join(name);
        let bytes =
            std::fs::read(&path).unwrap_or_else(|e| panic!("downloaded asset {name} missing: {e}"));
        let got = spt_daemon::release::sha256_hex(&bytes);
        assert_eq!(
            got,
            want.to_ascii_lowercase(),
            "{name}: sha256 mismatch — refusing to sign"
        );
        println!("verified {name} ({got})");
        artifacts.push(path);
    }
    assert!(
        !artifacts.is_empty(),
        "no binaries found in the draft release"
    );

    // 3. Sign.
    let identity = signing_identity();
    println!(
        "signing as: {} (key_id {key_id})",
        identity.public_key().to_hex()
    );
    let signed = sign_artifacts(&identity, &key_id, version, expires_days, &artifacts);

    // 4. Upload the SignedRelease assets.
    let mut upload = Command::new("gh");
    upload.args(["release", "upload", &tag, "--repo", REPO, "--clobber"]);
    for s in &signed {
        upload.arg(s);
    }
    run(&mut upload, "gh release upload");

    // 5. Publish.
    run(
        Command::new("gh").args(["release", "edit", &tag, "--repo", REPO, "--draft=false"]),
        "gh release edit --draft=false",
    );
    println!("release {tag} signed + published.");
}

/// The `spt update` dry path, runnable by hand: verify a SignedRelease
/// metadata file (and optionally its artifact bytes) against the **embedded**
/// two-key trust anchor — exactly the policy a deployed node applies to a
/// propagated release. The D7 acceptance verifier.
///
/// `xtask release-verify <signed.release.json> [artifact] [--current-version <u64>]`
// [impl->REQ-REL-2]
fn release_verify(args: &[String]) {
    let mut signed_path = None;
    let mut artifact = None;
    let mut current_version: u64 = 0;
    let mut it = args.iter();
    while let Some(a) = it.next() {
        match a.as_str() {
            "--current-version" => {


Plan premise check: the handoff said "release-publish already builds+signs a SignedUpdateSet (main.rs:799)." **False.** Line 799 is `debug_rollout` (debug channel). Production `release_publish` (line 485) only signs per-platform `SignedRelease`s — no `SignedUpdateSet`, no `update-set.json`. Must ADD set construction + upload to publish. Reading P2P serve/pull to scope the guard.