1	//! Update peer-propagation over broker streams (M4-D7, REQ-UPD-1) — the
2	//! serve/request drivers for the [`UpdRecord`] protocol, plugged into the M3c
3	//! update-engine front door (ADR-0004 §D).
4	//!
5	//! One pull rides one bidi QUIC stream: the requester's daemon opens it and
6	//! sends [`UpdRecord::Query`] with its running version + pinned channel; the
7	//! responder's [`serve_update`] answers from its **staged, already-verified**
8	//! release ([`crate::relcache::ReleaseCache`]) — an offer carrying just the
9	//! signed metadata, or up-to-date when it has nothing newer. The requester
10	//! gates the offer's metadata **before fetching any bytes** (a rollback /
11	//! expired / off-channel / bad-signature offer is rejected with zero chunk
12	//! records on the wire), pulls the artifact, then runs the full per-node gate
13	//! ([`crate::update::plan_verified`]) on the real bytes. Only a release that
14	//! produced an [`UpdatePlan`] is staged — staging is what makes this node a
15	//! server for its peers, so propagation chains hop by hop with the gate at
16	//! every node (one compromised node must not poison the subnet, REQ-UPD-2).
17	//!
18	//! ## Serve gate + wire discipline
19	//!
20	//! A query is answered only when the **handshake-proven** origin (the QUIC
21	//! identity from the broker's stream table, never payload bytes —
22	//! KNOWN-HAZARDS 7.5) is a roster member in ≥1 subnet; a non-member origin is
23	//! refused by not offering (the up-to-date shape — fail-closed, learns
24	//! nothing). Sends are fire-and-forget (`op_id: None` — a journaled send
25	//! starves a pump, the D5b lesson), chunks dedup by absolute offset, and a
26	//! torn pull needs no resume protocol: staging is atomic and the query is
27	//! idempotent, so the recovery is simply "query again" (FAULT-MATRIX row).
28	// [impl->REQ-UPD-1]
29	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
30
31	use std::io::{self, Seek, SeekFrom, Write};
32	use std::path::Path;
33
34	use spt_net::net::update::{UpdDecoder, UpdRecord};
35	use spt_store::roster::RosterStore;
36
37	use crate::brain::{Brain, BrokerEvent};
38	use crate::msg::{decode_bytes, encode_bytes};
39	use crate::relcache::{ReleaseCache, StagedUpdate};
40	use crate::release::{
41	    current_platform, verify_metadata, verify_update_set_metadata, RejectReason, ReleaseMetadata,
42	    SignedRelease, SignedUpdateSet, UpdateSetMetadata, VerifyPolicy,
43	};
44	use crate::update::{plan_verified, plan_verified_update_set, BrokerAbi, UpdatePlan};
45
46	/// Raw bytes per [`UpdRecord::Chunk`] (base64 inside the JSON line — the
47	/// shared NDJSON posture, no binary fork of the codec).
48	const CHUNK_BYTES: usize = 64 * 1024;
49
50	/// Why a responder-side serve loop returned.
51	#[derive(Debug, Clone, PartialEq, Eq)]
52	pub enum UpdateServeOutcome {
53	    /// The artifact was served (`bytes` on the wire).
54	    Served { bytes: u64 },
55	    /// Nothing newer than the requester's version on its channel (or nothing
56	    /// staged at all) — the honest up-to-date answer.
57	    UpToDate,
58	    /// The origin is not a roster member in any subnet — refused by not
59	    /// offering (the same wire shape as up-to-date; the outcome is distinct
60	    /// so callers/tests observe the gate fired).
61	    Refused,
62	    /// The serve failed; an `Err` record was sent to the peer.
63	    Failed(String),
64	    /// The requester closed its send side before fetching (e.g. its own gate
65	    /// rejected the offer, or it detached mid-protocol).
66	    Detached,
67	    /// A status-only query was answered (M8 decision 19,
68	    /// `xtask debug-converge`) — no artifact bytes moved.
69	    // [impl->REQ-UPD-6]
70	    StatusServed,
71	}
72
73	#[derive(Debug, Clone)]
74	enum OfferedUpdate {
75	    Single(SignedRelease),
76	    Set(SignedUpdateSet),
77	}
78
79	impl OfferedUpdate {
80	    fn metadata_json(&self) -> String {
81	        match self {
82	            OfferedUpdate::Single(s) => s.metadata_json.clone(),
83	            OfferedUpdate::Set(s) => s.metadata_json.clone(),
84	        }
85	    }
86
87	    fn signature_hex(&self) -> String {
88	        match self {
89	            OfferedUpdate::Single(s) => s.signature_hex.clone(),
90	            OfferedUpdate::Set(s) => s.signature_hex.clone(),
91	        }
92	    }
93	}
94
95	/// Serve one update pull from this node's staged release, blocking until the
96	/// pull completes, the requester detaches, or the request fails.
97	///
98	/// `origin_node` MUST be the handshake-proven remote id from the broker's
99	/// stream table (`NetStreamInfo::remote_id_hex`) — never payload bytes
100	/// (REQ-HAZARD-WAN-ORIGIN-AUTH). The origin is gated against the `roster`
101	/// **here**, fail-closed: a non-member peer gets the up-to-date shape and
102	/// learns nothing (not even whether a release is staged).
103	// [impl->REQ-UPD-1]
104	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
105	// [impl->REQ-MESH-5]
106	pub fn serve_update(
107	    brain: &mut Brain,
108	    stream_id: u64,
109	    origin_node: &str,
110	    net_from_seq: u64,
111	    cache: &ReleaseCache,
112	    roster: &RosterStore,
113	) -> io::Result<UpdateServeOutcome> {
114	    brain.net_stream_subscribe(stream_id, net_from_seq)?;
115	    let mut decoder = UpdDecoder::new();
116	    // Set once an Offer goes out; the Fetch must echo the same pull id.
117	    let mut offered: Option<String> = None;
118
119	    loop {
120	        match brain.read_event()? {
121	            BrokerEvent::NetStreamData {
122	                stream_id: sid,
123	                bytes,
124	                ..
125	            } if sid == stream_id => {
126	                for rec in decoder.push(&bytes) {
127	                    match rec {
128	                        UpdRecord::StatusQuery { upd_id } => {
129	                            // Status-only convergence query (M8 decision 19,
130	                            // REQ-UPD-6): same trust gate as Query — an
131	                            // untrusted origin gets the all-None shape and
132	                            // learns nothing (not channel, not versions).
133	                            // [impl->REQ-UPD-6]
134	                            let trusted = roster.is_member_any(origin_node);
135	                            let status = if trusted {
136	                                let policy = VerifyPolicy::load_from(
137	                                    &spt_store::perch::identity_dir().join("release-keys.json"),
138	                                    0,
139	                                    0,
140	                                );
141	                                UpdRecord::Status {
142	                                    upd_id,
143	                                    channel: Some(policy.pinned_channel),
144	                                    staged_version: cache.staged_version(),
145	                                    applied_version: cache.applied_version(),
146	                                    last_outcome: cache.last_outcome(),
147	                                }
148	                            } else {
149	                                UpdRecord::Status {
150	                                    upd_id,
151	                                    channel: None,
152	                                    staged_version: None,
153	                                    applied_version: None,
154	                                    last_outcome: None,
155	                                }
156	                            };
157	                            brain.net_stream_send(stream_id, &status.encode_line(), None, true)?;
158	                            return Ok(if trusted {
159	                                UpdateServeOutcome::StatusServed
160	                            } else {
161	                                UpdateServeOutcome::Refused
162	                            });
163	                        }
164	                        UpdRecord::Query {
165	                            upd_id,
166	                            current_version,
167	                            channel,
168	                        } => {
169	                            // The gate: only a roster member in ≥1 subnet is
170	                            // answered; everyone else gets the up-to-date
171	                            // shape (fail-closed, indistinguishable from
172	                            // having nothing).
173	                            let trusted = roster.is_member_any(origin_node);
174	                            let staged = if trusted { cache.staged_update() } else { None };
175	                            // Offer only a release that is actually newer on
176	                            // the requester's channel (an honest responder
177	                            // never proposes a rollback — the requester's own
178	                            // gate re-checks regardless).
179	                            let newer = staged
180	                                .as_ref()
181	                                .and_then(|s| newer_offer(s, current_version, &channel));
182	                            match newer {
183	                                Some(offered_update) => {
184	                                    let line = UpdRecord::Offer {
185	                                        upd_id: upd_id.clone(),
186	                                        metadata_json: offered_update.metadata_json(),
187	                                        signature_hex: offered_update.signature_hex(),
188	                                    }
189	                                    .encode_line();
190	                                    brain.net_stream_send(stream_id, &line, None, false)?;
191	                                    offered = Some(upd_id);
192	                                }
193	                                None => {
194	                                    let line = UpdRecord::UpToDate { upd_id }.encode_line();
195	                                    brain.net_stream_send(stream_id, &line, None, true)?;
196	                                    return Ok(if trusted {
197	                                        UpdateServeOutcome::UpToDate
198	                                    } else {
199	                                        UpdateServeOutcome::Refused
200	                                    });
201	                                }
202	                            }
203	                        }
204	                        UpdRecord::Fetch { upd_id } if offered.as_deref() == Some(&upd_id) => {
205	                            // Pump the staged artifact as positional chunks
206	                            // (fire-and-forget; the offset is the dedup).
207	                            let artifact = match cache.load_artifact() {
208	                                Ok(a) => a,
209	                                Err(e) => {
210	                                    let line = UpdRecord::Err {
211	                                        upd_id,
212	                                        message: format!("artifact unreadable: {e}"),
213	                                    }
214	                                    .encode_line();
215	                                    brain.net_stream_send(stream_id, &line, None, true)?;
216	                                    return Ok(UpdateServeOutcome::Failed(e.to_string()));
217	                                }
218	                            };
219	                            let mut offset = 0u64;
220	                            for chunk in artifact.chunks(CHUNK_BYTES) {
221	                                let line = UpdRecord::Chunk {
222	                                    upd_id: upd_id.clone(),
223	                                    offset,
224	                                    data_b64: encode_bytes(chunk),
225	                                }
226	                                .encode_line();
227	                                brain.net_stream_send(stream_id, &line, None, false)?;
228	                                offset += chunk.len() as u64;
229	                            }
230	                            let done = UpdRecord::Done {
231	                                upd_id,
232	                                total: artifact.len() as u64,
233	                            }
234	                            .encode_line();
235	                            brain.net_stream_send(stream_id, &done, None, true)?;
236	                            return Ok(UpdateServeOutcome::Served {
237	                                bytes: artifact.len() as u64,
238	                            });
239	                        }
240	                        UpdRecord::FetchArtifact { upd_id, platform }
241	                            if offered.as_deref() == Some(&upd_id) =>
242	                        {
243	                            // [impl->REQ-UPD-6] update-set fetch: serve only
244	                            // the requested platform bytes if this peer holds
245	                            // them; a partial peer may legitimately lack other
246	                            // platform artifacts.
247	                            let artifact = match cache.load_artifact_for_platform(&platform) {
248	                                Ok(a) => a,
249	                                Err(e) => {
250	                                    let line = UpdRecord::Err {
251	                                        upd_id,
252	                                        message: format!(
253	                                            "artifact for platform {platform} unreadable: {e}"
254	                                        ),
255	                                    }
256	                                    .encode_line();
257	                                    brain.net_stream_send(stream_id, &line, None, true)?;
258	                                    return Ok(UpdateServeOutcome::Failed(e.to_string()));
259	                                }
260	                            };
261	                            let mut offset = 0u64;
262	                            for chunk in artifact.chunks(CHUNK_BYTES) {
263	                                let line = UpdRecord::Chunk {
264	                                    upd_id: upd_id.clone(),
265	                                    offset,
266	                                    data_b64: encode_bytes(chunk),
267	                                }
268	                                .encode_line();
269	                                brain.net_stream_send(stream_id, &line, None, false)?;
270	                                offset += chunk.len() as u64;
271	                            }
272	                            let done = UpdRecord::Done {
273	                                upd_id,
274	                                total: artifact.len() as u64,
275	                            }
276	                            .encode_line();
277	                            brain.net_stream_send(stream_id, &done, None, true)?;
278	                            return Ok(UpdateServeOutcome::Served {
279	                                bytes: artifact.len() as u64,
280	                            });
281	                        }
282	                        _ => {} // protocol noise — keep waiting
283	                    }
284	                }
285	            }
286	            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
287	                brain.net_stream_send(stream_id, &[], None, true)?;
288	                return Ok(UpdateServeOutcome::Detached);
289	            }
290	            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
291	            _ => {}
292	        }
293	    }
294	}
295
296	fn newer_offer(
297	    staged: &StagedUpdate,
298	    current_version: u64,
299	    channel: &str,
300	) -> Option<OfferedUpdate> {
301	    match staged {
302	        StagedUpdate::Single(s) => {
303	            let meta: ReleaseMetadata = serde_json::from_str(&s.metadata_json).ok()?;
304	            (meta.version > current_version && meta.channel == channel)
305	                .then_some(OfferedUpdate::Single(s.clone()))
306	        }
307	        StagedUpdate::Set(s) => {
308	            let meta: UpdateSetMetadata = serde_json::from_str(&s.metadata_json).ok()?;
309	            (meta.version > current_version && meta.channel == channel)
310	                .then_some(OfferedUpdate::Set(s.clone()))
311	        }
312	    }
313	}
314
315	/// What one update pull concluded.
316	#[derive(Debug)]
317	pub enum UpdatePullOutcome {
318	    /// The peer's release passed the full per-node gate; the artifact is
319	    /// staged ([`ReleaseCache`]) and the classified plan is ready for the
320	    /// consent/apply path (C2/C0 — not triggered here).
321	    Updated(UpdatePlan),
322	    /// The peer had nothing newer on this node's channel.
323	    UpToDate,
324	    /// The peer's release failed this node's gate — loud + typed, never a
325	    /// silent accept (ADR-0004 §D). Nothing was staged. Pre-fetch rejects
326	    /// (rollback / expired / off-channel / bad signature on the offered
327	    /// metadata) carry zero artifact bytes; a post-fetch reject means the
328	    /// pulled bytes did not match the signed digest.
329	    Rejected(RejectReason),
330	}
331
332	/// Requester side: pull a newer release from the peer over `conn_id`, gate it
333	/// per-node, and stage it on success. `open_op` is the durable id for the
334	/// exactly-once stream open; everything after rides fire-and-forget. A torn
335	/// pull errors — recovery is a fresh `request_update` (the query is
336	/// idempotent, staging is atomic; FAULT-MATRIX stream-loss row).
337	///
338	/// The gate runs **twice by design** (REQ-UPD-2: per-node, never inherited):
339	/// [`verify_metadata`] on the offer before any bytes move, then the whole
340	/// [`plan_verified`] front door (metadata + artifact digest + classify) on
341	/// the real bytes before staging.
342	// [impl->REQ-UPD-1]
343	// [impl->REQ-UPD-2]
344	pub fn request_update(
345	    brain: &mut Brain,
346	    conn_id: u64,
347	    open_op: u64,
348	    running: &BrokerAbi,
349	    policy: &VerifyPolicy,
350	    cache: &ReleaseCache,
351	    scratch_dir: &Path,
352	) -> io::Result<UpdatePullOutcome> {
353	    let opened = brain.net_open_stream(conn_id, Some(open_op))?;
354	    let stream_id = opened.stream_id;
355	    brain.net_stream_subscribe(stream_id, 0)?;
356
357	    let upd_id = format!("upd-{open_op}");
358	    let query = UpdRecord::Query {
359	        upd_id: upd_id.clone(),
360	        current_version: policy.current_version,
361	        channel: policy.pinned_channel.clone(),
362	    }
363	    .encode_line();
364	    brain.net_stream_send(stream_id, &query, None, false)?;
365
366	    std::fs::create_dir_all(scratch_dir)?;
367	    let artifact_path = scratch_dir.join(format!("pull-{open_op}.artifact"));
368	    let mut file: Option<std::fs::File> = None;
369	    let mut signed: Option<OfferedUpdate> = None;
370	    let mut decoder = UpdDecoder::new();
371
372	    loop {
373	        match brain.read_event()? {
374	            BrokerEvent::NetStreamData {
375	                stream_id: sid,
376	                bytes,
377	                ..
378	            } if sid == stream_id => {
379	                for rec in decoder.push(&bytes) {
380	                    match rec {
381	                        UpdRecord::UpToDate { upd_id: xid } if xid == upd_id => {
382	                            brain.net_stream_send(stream_id, &[], None, true)?;
383	                            return Ok(UpdatePullOutcome::UpToDate);
384	                        }
385	                        UpdRecord::Offer {
386	                            upd_id: xid,
387	                            metadata_json,
388	                            signature_hex,
389	                        } if xid == upd_id => {
390	                            let as_set = SignedUpdateSet {
391	                                metadata_json: metadata_json.clone(),
392	                                signature_hex: signature_hex.clone(),
393	                            };
394	                            // The pre-fetch gate: reject a bad offer before a
395	                            // single artifact byte moves (REQ-UPD-2,
396	                            // REQ-HAZARD-UPDATE-ROLLBACK — a rollback offer
397	                            // costs the subnet nothing).
398	                            // [impl->REQ-HAZARD-UPDATE-ROLLBACK]
399	                            // [impl->REQ-UPD-6] Try the update-set schema
400	                            // first; legacy SignedRelease remains supported
401	                            // during migration.
402	                            match verify_update_set_metadata(&as_set, policy) {
403	                                Ok(meta) => {
404	                                    if let Err(reason) = meta.artifact_for(current_platform()) {
405	                                        brain.net_stream_send(stream_id, &[], None, true)?;
406	                                        return Ok(UpdatePullOutcome::Rejected(reason));
407	                                    }
408	                                    signed = Some(OfferedUpdate::Set(as_set));
409	                                    let fetch = UpdRecord::FetchArtifact {
410	                                        upd_id: upd_id.clone(),
411	                                        platform: current_platform().to_string(),
412	                                    }
413	                                    .encode_line();
414	                                    brain.net_stream_send(stream_id, &fetch, None, true)?;
415	                                }
416	                                Err(set_reason) => {
417	                                    let offer = SignedRelease {
418	                                        metadata_json,
419	                                        signature_hex,
420	                                    };
421	                                    if let Err(reason) = verify_metadata(&offer, policy) {
422	                                        brain.net_stream_send(stream_id, &[], None, true)?;
423	                                        return Ok(UpdatePullOutcome::Rejected(match set_reason {
424	                                            RejectReason::Malformed(_) => reason,
425	                                            _ => set_reason,
426	                                        }));
427	                                    }
428	                                    signed = Some(OfferedUpdate::Single(offer));
429	                                    let fetch = UpdRecord::Fetch {
430	                                        upd_id: upd_id.clone(),
431	                                    }
432	                                    .encode_line();
433	                                    brain.net_stream_send(stream_id, &fetch, None, true)?;
434	                                }
435	                            }
436	                        }
437	                        UpdRecord::Chunk {
438	                            upd_id: xid,
439	                            offset,
440	                            data_b64,
441	                        } if xid == upd_id => {
442	                            let chunk = decode_bytes(&data_b64)
443	                                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
444	                            let f = match file.as_mut() {
445	                                Some(f) => f,
446	                                None => file.insert(
447	                                    std::fs::OpenOptions::new()
448	                                        .create(true)
449	                                        .truncate(true)
450	                                        .write(true)
451	                                        .open(&artifact_path)?,
452	                                ),
453	                            };
454	                            // Positional write — a replayed chunk rewrites the
455	                            // same bytes in place (idempotent by construction).
456	                            f.seek(SeekFrom::Start(offset))?;
457	                            f.write_all(&chunk)?;
458	                        }
459	                        UpdRecord::Done { upd_id: xid, .. } if xid == upd_id => {
460	                            drop(file.take()); // flush before reading back
461	                            let artifact = std::fs::read(&artifact_path).unwrap_or_default();
462	                            let _ = std::fs::remove_file(&artifact_path);
463	                            let offer = signed.take().ok_or_else(|| {
464	                                io::Error::other("peer sent Done before any Offer")
465	                            })?;
466	                            // The full per-node front door on the real bytes:
467	                            // no plan ⇒ nothing staged ⇒ this node never
468	                            // re-serves what it could not verify.
469	                            // [impl->REQ-UPD-2]
470	                            return match offer {
471	                                OfferedUpdate::Single(offer) => {
472	                                    match plan_verified(running, &offer, &artifact, policy) {
473	                                        Ok(plan) => {
474	                                            cache.stage(&offer, &artifact)?;
475	                                            Ok(UpdatePullOutcome::Updated(plan))
476	                                        }
477	                                        Err(reason) => Ok(UpdatePullOutcome::Rejected(reason)),
478	                                    }
479	                                }
480	                                OfferedUpdate::Set(offer) => {
481	                                    match plan_verified_update_set(
482	                                        running,
483	                                        &offer,
484	                                        current_platform(),
485	                                        &artifact,
486	                                        policy,
487	                                    ) {
488	                                        Ok(plan) => {
489	                                            cache.stage_update_set(
490	                                                &offer,
491	                                                &std::collections::BTreeMap::from([(
492	                                                    current_platform().to_string(),
493	                                                    artifact,
494	                                                )]),
495	                                            )?;
496	                                            Ok(UpdatePullOutcome::Updated(plan))
497	                                        }
498	                                        Err(reason) => Ok(UpdatePullOutcome::Rejected(reason)),
499	                                    }
500	                                }
501	                            };
502	                        }
503	                        UpdRecord::Err {
504	                            upd_id: xid,
505	                            message,
506	                        } if xid == upd_id => {
507	                            return Err(io::Error::other(format!("peer failed: {message}")));
508	                        }
509	                        _ => {} // protocol noise
510	                    }
511	                }
512	            }
513	            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
514	                return Err(io::Error::other(
515	                    "peer closed mid-pull (re-query to recover)",
516	                ));
517	            }
518	            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
519	            _ => {}
520	        }
521	    }
522	}
523
524	/// One node's status-only answer (M8 decision 19, `xtask debug-converge`):
525	/// the convergence facts a coordinator classifies. All-`None` = the
526	/// responder refused (untrusted) or predates the status wire.
527	// [impl->REQ-UPD-6]
528	#[derive(Debug, Clone, Default, PartialEq, Eq)]
529	pub struct UpdateStatusReport {
530	    pub channel: Option<String>,
531	    pub staged_version: Option<u64>,
532	    pub applied_version: Option<u64>,
533	    pub last_outcome: Option<String>,
534	}
535
536	/// Requester side of the status-only query: one `StatusQuery` on a fresh
537	/// stream, one `Status` back — no artifact bytes ever move. The stream open
538	/// is non-journaled (`op_id: None`): a torn status query is simply re-asked
539	/// next poll round — there is nothing exactly-once about reading status.
540	/// Used by `xtask debug-converge`'s poll loop, never the public CLI.
541	// [impl->REQ-UPD-6]
542	pub fn request_update_status(brain: &mut Brain, conn_id: u64) -> io::Result<UpdateStatusReport> {
543	    let opened = brain.net_open_stream(conn_id, None)?;
544	    let stream_id = opened.stream_id;
545	    brain.net_stream_subscribe(stream_id, 0)?;
546
547	    let upd_id = format!("upds-{stream_id}");
548	    let query = UpdRecord::StatusQuery {
549	        upd_id: upd_id.clone(),
550	    }
551	    .encode_line();
552	    brain.net_stream_send(stream_id, &query, None, true)?;
553
554	    let mut decoder = UpdDecoder::new();
555	    loop {
556	        match brain.read_event()? {
557	            BrokerEvent::NetStreamData {
558	                stream_id: sid,
559	                bytes,
560	                ..
561	            } if sid == stream_id => {
562	                for rec in decoder.push(&bytes) {
563	                    if let UpdRecord::Status {
564	                        upd_id: xid,
565	                        channel,
566	                        staged_version,
567	                        applied_version,
568	                        last_outcome,
569	                    } = rec
570	                    {
571	                        if xid == upd_id {
572	                            return Ok(UpdateStatusReport {
573	                                channel,
574	                                staged_version,
575	                                applied_version,
576	                                last_outcome,
577	                            });
578	                        }
579	                    }
580	                }
581	            }
582	            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
583	                return Err(io::Error::other(
584	                    "peer closed without a status (pre-status-wire responder?)",
585	                ));
586	            }
587	            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
588	            _ => {}
589	        }
590	    }
591	}
592
593	/// One expected node's classification in the convergence table (M8 decision
594	/// 19; state names mirror `docs/DEBUG-CONVERGE-PLAN.md` so the pull/apply
595	/// vocabulary stays one set).
596	// [impl->REQ-UPD-6]
597	#[derive(Debug, Clone, PartialEq, Eq)]
598	pub enum ConvergeState {
599	    /// The node applied a version ≥ the target — converged.
600	    Applied { version: u64 },
601	    /// The node is pinned to a different channel — the metadata gate would
602	    /// reject the rollout (expected-set bug, or intentional).
603	    NotPinned { channel: String },
604	    /// The target (or newer) is staged, default-gated, no apply yet.
605	    StagedAwaitingConsent { staged: u64 },
606	    /// A broker-touching candidate refused while live endpoints are held —
607	    /// the operator must quiesce before apply succeeds.
608	    BlockedByBrokerResources,
609	    /// A typed refusal was the node's last outcome.
610	    Rejected { reason: String },
611	    /// Answered, but no candidate at the target has landed yet.
612	    Pending,
613	    /// Unreachable / refused / no status this round.
614	    Offline,
615	}
616
617	impl ConvergeState {
618	    /// Terminal-success: the watcher stops early when every node is here.
619	    pub fn converged(&self) -> bool {
620	        matches!(self, ConvergeState::Applied { .. })
621	    }
622	}
623
624	/// Classify one node's status answer against the rollout target (pure — the
625	/// unit table `docs/DEBUG-CONVERGE-PLAN.md` asks for). `None` = the node
626	/// never answered this round (Offline).
627	// [impl->REQ-UPD-6]
628	pub fn classify_status(
629	    target_version: u64,
630	    target_channel: &str,
631	    report: Option<&UpdateStatusReport>,
632	) -> ConvergeState {
633	    let Some(r) = report else {
634	        return ConvergeState::Offline;
635	    };
636	    match &r.channel {
637	        None => return ConvergeState::Offline, // the fail-closed all-None shape
638	        Some(c) if c != target_channel => return ConvergeState::NotPinned { channel: c.clone() },
639	        Some(_) => {}
640	    }
641	    if let Some(v) = r.applied_version {
642	        if v >= target_version {
643	            return ConvergeState::Applied { version: v };
644	        }
645	    }
646	    if let Some(s) = r.staged_version {
647	        if s >= target_version {
648	            // Staged at (or past) the target: waiting on consent — unless
649	            // the last apply attempt refused on broker resources.
650	            if r.last_outcome
651	                .as_deref()
652	                .is_some_and(|o| o.contains("BrokerBreaking"))
653	            {
654	                return ConvergeState::BlockedByBrokerResources;
655	            }
656	            return ConvergeState::StagedAwaitingConsent { staged: s };
657	        }
658	    }
659	    if let Some(reason) = r
660	        .last_outcome
661	        .as_deref()
662	        .and_then(|o| o.strip_prefix("rejected:"))
663	    {
664	        return ConvergeState::Rejected {
665	            reason: reason.to_string(),
666	        };
667	    }
668	    ConvergeState::Pending
669	}
670
671	#[cfg(test)]
672	mod tests {
673	    use super::*;
674
675	    fn report(
676	        channel: Option<&str>,
677	        staged: Option<u64>,
678	        applied: Option<u64>,
679	        outcome: Option<&str>,
680	    ) -> UpdateStatusReport {
681	        UpdateStatusReport {
682	            channel: channel.map(str::to_string),
683	            staged_version: staged,
684	            applied_version: applied,
685	            last_outcome: outcome.map(str::to_string),
686	        }
687	    }
688
689	    // [unit->REQ-UPD-6] the convergence classifier maps every status shape
690	    // onto exactly one DEBUG-CONVERGE-PLAN state: applied-at-target wins,
691	    // wrong channel is NotPinned, a staged target awaits consent (or is
692	    // blocked on broker resources), a typed refusal renders its reason,
693	    // no-answer / fail-closed-None is Offline, and an answered node with no
694	    // candidate yet is Pending.
695	    #[test]
696	    fn classifier_maps_every_status_shape() {
697	        let t = |r: Option<&UpdateStatusReport>| classify_status(7, "debug", r);
698
699	        assert_eq!(t(None), ConvergeState::Offline, "no answer this round");
700	        assert_eq!(
701	            t(Some(&report(None, None, None, None))),
702	            ConvergeState::Offline,
703	            "the untrusted all-None shape classifies as unreachable"
704	        );
705	        assert_eq!(
706	            t(Some(&report(Some("stable"), None, None, None))),
707	            ConvergeState::NotPinned {
708	                channel: "stable".into()
709	            },
710	            "a stable-pinned node would reject at the metadata gate"
711	        );
712	        assert_eq!(
713	            t(Some(&report(
714	                Some("debug"),
715	                Some(7),
716	                Some(7),
717	                Some("applied")
718	            ))),
719	            ConvergeState::Applied { version: 7 }
720	        );
721	        assert_eq!(
722	            t(Some(&report(Some("debug"), None, Some(9), Some("applied")))),
723	            ConvergeState::Applied { version: 9 },
724	            "newer-than-target counts as converged"
725	        );
726	        assert_eq!(
727	            t(Some(&report(
728	                Some("debug"),
729	                Some(7),
730	                Some(6),
731	                Some("staged")
732	            ))),
733	            ConvergeState::StagedAwaitingConsent { staged: 7 },
734	            "staged at target, older apply — waiting on consent"
735	        );
736	        assert_eq!(
737	            t(Some(&report(
738	                Some("debug"),
739	                Some(7),
740	                None,
741	                Some("rejected:RefusedClass(BrokerBreaking)")
742	            ))),
743	            ConvergeState::BlockedByBrokerResources,
744	            "broker-touching refusal needs an operator quiesce"
745	        );
746	        assert_eq!(
747	            t(Some(&report(
748	                Some("debug"),
749	                None,
750	                None,
751	                Some("rejected:NoArtifactForPlatform(\"x86_64-apple-darwin\")")
752	            ))),
753	            ConvergeState::Rejected {
754	                reason: "NoArtifactForPlatform(\"x86_64-apple-darwin\")".into()
755	            }
756	        );
757	        assert_eq!(
758	            t(Some(&report(Some("debug"), Some(3), Some(3), None))),
759	            ConvergeState::Pending,
760	            "answered, but nothing at the target yet"
761	        );
762
763	        assert!(ConvergeState::Applied { version: 7 }.converged());
764	        assert!(!ConvergeState::Pending.converged());
765	    }
766	}
767

//! Update peer-propagation over broker streams (M4-D7, REQ-UPD-1) — the
//! serve/request drivers for the [`UpdRecord`] protocol, plugged into the M3c
//! update-engine front door (ADR-0004 §D).
//!
//! One pull rides one bidi QUIC stream: the requester's daemon opens it and
//! sends [`UpdRecord::Query`] with its running version + pinned channel; the
//! responder's [`serve_update`] answers from its **staged, already-verified**
//! release ([`crate::relcache::ReleaseCache`]) — an offer carrying just the
//! signed metadata, or up-to-date when it has nothing newer. The requester
//! gates the offer's metadata **before fetching any bytes** (a rollback /
//! expired / off-channel / bad-signature offer is rejected with zero chunk
//! records on the wire), pulls the artifact, then runs the full per-node gate
//! ([`crate::update::plan_verified`]) on the real bytes. Only a release that
//! produced an [`UpdatePlan`] is staged — staging is what makes this node a
//! server for its peers, so propagation chains hop by hop with the gate at
//! every node (one compromised node must not poison the subnet, REQ-UPD-2).
//!
//! ## Serve gate + wire discipline
//!
//! A query is answered only when the **handshake-proven** origin (the QUIC
//! identity from the broker's stream table, never payload bytes —
//! KNOWN-HAZARDS 7.5) is a roster member in ≥1 subnet; a non-member origin is
//! refused by not offering (the up-to-date shape — fail-closed, learns
//! nothing). Sends are fire-and-forget (`op_id: None` — a journaled send
//! starves a pump, the D5b lesson), chunks dedup by absolute offset, and a
//! torn pull needs no resume protocol: staging is atomic and the query is
//! idempotent, so the recovery is simply "query again" (FAULT-MATRIX row).
// [impl->REQ-UPD-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]

use std::io::{self, Seek, SeekFrom, Write};
use std::path::Path;

use spt_net::net::update::{UpdDecoder, UpdRecord};
use spt_store::roster::RosterStore;

use crate::brain::{Brain, BrokerEvent};
use crate::msg::{decode_bytes, encode_bytes};
use crate::relcache::{ReleaseCache, StagedUpdate};
use crate::release::{
    current_platform, verify_metadata, verify_update_set_metadata, RejectReason, ReleaseMetadata,
    SignedRelease, SignedUpdateSet, UpdateSetMetadata, VerifyPolicy,
};
use crate::update::{plan_verified, plan_verified_update_set, BrokerAbi, UpdatePlan};

/// Raw bytes per [`UpdRecord::Chunk`] (base64 inside the JSON line — the
/// shared NDJSON posture, no binary fork of the codec).
const CHUNK_BYTES: usize = 64 * 1024;

/// Why a responder-side serve loop returned.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateServeOutcome {
    /// The artifact was served (`bytes` on the wire).
    Served { bytes: u64 },
    /// Nothing newer than the requester's version on its channel (or nothing
    /// staged at all) — the honest up-to-date answer.
    UpToDate,
    /// The origin is not a roster member in any subnet — refused by not
    /// offering (the same wire shape as up-to-date; the outcome is distinct
    /// so callers/tests observe the gate fired).
    Refused,
    /// The serve failed; an `Err` record was sent to the peer.
    Failed(String),
    /// The requester closed its send side before fetching (e.g. its own gate
    /// rejected the offer, or it detached mid-protocol).
    Detached,
    /// A status-only query was answered (M8 decision 19,
    /// `xtask debug-converge`) — no artifact bytes moved.
    // [impl->REQ-UPD-6]
    StatusServed,
}

#[derive(Debug, Clone)]
enum OfferedUpdate {
    Single(SignedRelease),
    Set(SignedUpdateSet),
}

impl OfferedUpdate {
    fn metadata_json(&self) -> String {
        match self {
            OfferedUpdate::Single(s) => s.metadata_json.clone(),
            OfferedUpdate::Set(s) => s.metadata_json.clone(),
        }
    }

    fn signature_hex(&self) -> String {
        match self {
            OfferedUpdate::Single(s) => s.signature_hex.clone(),
            OfferedUpdate::Set(s) => s.signature_hex.clone(),
        }
    }
}

/// Serve one update pull from this node's staged release, blocking until the
/// pull completes, the requester detaches, or the request fails.
///
/// `origin_node` MUST be the handshake-proven remote id from the broker's
/// stream table (`NetStreamInfo::remote_id_hex`) — never payload bytes
/// (REQ-HAZARD-WAN-ORIGIN-AUTH). The origin is gated against the `roster`
/// **here**, fail-closed: a non-member peer gets the up-to-date shape and
/// learns nothing (not even whether a release is staged).
// [impl->REQ-UPD-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
// [impl->REQ-MESH-5]
pub fn serve_update(
    brain: &mut Brain,
    stream_id: u64,
    origin_node: &str,
    net_from_seq: u64,
    cache: &ReleaseCache,
    roster: &RosterStore,
) -> io::Result<UpdateServeOutcome> {
    brain.net_stream_subscribe(stream_id, net_from_seq)?;
    let mut decoder = UpdDecoder::new();
    // Set once an Offer goes out; the Fetch must echo the same pull id.
    let mut offered: Option<String> = None;

    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData {
                stream_id: sid,
                bytes,
                ..
            } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        UpdRecord::StatusQuery { upd_id } => {
                            // Status-only convergence query (M8 decision 19,
                            // REQ-UPD-6): same trust gate as Query — an
                            // untrusted origin gets the all-None shape and
                            // learns nothing (not channel, not versions).
                            // [impl->REQ-UPD-6]
                            let trusted = roster.is_member_any(origin_node);
                            let status = if trusted {
                                let policy = VerifyPolicy::load_from(
                                    &spt_store::perch::identity_dir().join("release-keys.json"),
                                    0,
                                    0,
                                );
                                UpdRecord::Status {
                                    upd_id,
                                    channel: Some(policy.pinned_channel),
                                    staged_version: cache.staged_version(),
                                    applied_version: cache.applied_version(),
                                    last_outcome: cache.last_outcome(),
                                }
                            } else {
                                UpdRecord::Status {
                                    upd_id,
                                    channel: None,
                                    staged_version: None,
                                    applied_version: None,
                                    last_outcome: None,
                                }
                            };
                            brain.net_stream_send(stream_id, &status.encode_line(), None, true)?;
                            return Ok(if trusted {
                                UpdateServeOutcome::StatusServed
                            } else {
                                UpdateServeOutcome::Refused
                            });
                        }
                        UpdRecord::Query {
                            upd_id,
                            current_version,
                            channel,
                        } => {
                            // The gate: only a roster member in ≥1 subnet is
                            // answered; everyone else gets the up-to-date
                            // shape (fail-closed, indistinguishable from
                            // having nothing).
                            let trusted = roster.is_member_any(origin_node);
                            let staged = if trusted { cache.staged_update() } else { None };
                            // Offer only a release that is actually newer on
                            // the requester's channel (an honest responder
                            // never proposes a rollback — the requester's own
                            // gate re-checks regardless).
                            let newer = staged
                                .as_ref()
                                .and_then(|s| newer_offer(s, current_version, &channel));
                            match newer {
                                Some(offered_update) => {
                                    let line = UpdRecord::Offer {
                                        upd_id: upd_id.clone(),
                                        metadata_json: offered_update.metadata_json(),
                                        signature_hex: offered_update.signature_hex(),
                                    }
                                    .encode_line();
                                    brain.net_stream_send(stream_id, &line, None, false)?;
                                    offered = Some(upd_id);
                                }
                                None => {
                                    let line = UpdRecord::UpToDate { upd_id }.encode_line();
                                    brain.net_stream_send(stream_id, &line, None, true)?;
                                    return Ok(if trusted {
                                        UpdateServeOutcome::UpToDate
                                    } else {
                                        UpdateServeOutcome::Refused
                                    });
                                }
                            }
                        }
                        UpdRecord::Fetch { upd_id } if offered.as_deref() == Some(&upd_id) => {
                            // Pump the staged artifact as positional chunks
                            // (fire-and-forget; the offset is the dedup).
                            let artifact = match cache.load_artifact() {
                                Ok(a) => a,
                                Err(e) => {
                                    let line = UpdRecord::Err {
                                        upd_id,
                                        message: format!("artifact unreadable: {e}"),
                                    }
                                    .encode_line();
                                    brain.net_stream_send(stream_id, &line, None, true)?;
                                    return Ok(UpdateServeOutcome::Failed(e.to_string()));
                                }
                            };
                            let mut offset = 0u64;
                            for chunk in artifact.chunks(CHUNK_BYTES) {
                                let line = UpdRecord::Chunk {
                                    upd_id: upd_id.clone(),
                                    offset,
                                    data_b64: encode_bytes(chunk),
                                }
                                .encode_line();
                                brain.net_stream_send(stream_id, &line, None, false)?;
                                offset += chunk.len() as u64;
                            }
                            let done = UpdRecord::Done {
                                upd_id,
                                total: artifact.len() as u64,
                            }
                            .encode_line();
                            brain.net_stream_send(stream_id, &done, None, true)?;
                            return Ok(UpdateServeOutcome::Served {
                                bytes: artifact.len() as u64,
                            });
                        }
                        UpdRecord::FetchArtifact { upd_id, platform }
                            if offered.as_deref() == Some(&upd_id) =>
                        {
                            // [impl->REQ-UPD-6] update-set fetch: serve only
                            // the requested platform bytes if this peer holds
                            // them; a partial peer may legitimately lack other
                            // platform artifacts.
                            let artifact = match cache.load_artifact_for_platform(&platform) {
                                Ok(a) => a,
                                Err(e) => {
                                    let line = UpdRecord::Err {
                                        upd_id,
                                        message: format!(
                                            "artifact for platform {platform} unreadable: {e}"
                                        ),
                                    }
                                    .encode_line();
                                    brain.net_stream_send(stream_id, &line, None, true)?;
                                    return Ok(UpdateServeOutcome::Failed(e.to_string()));
                                }
                            };
                            let mut offset = 0u64;
                            for chunk in artifact.chunks(CHUNK_BYTES) {
                                let line = UpdRecord::Chunk {
                                    upd_id: upd_id.clone(),
                                    offset,
                                    data_b64: encode_bytes(chunk),
                                }
                                .encode_line();
                                brain.net_stream_send(stream_id, &line, None, false)?;
                                offset += chunk.len() as u64;
                            }
                            let done = UpdRecord::Done {
                                upd_id,
                                total: artifact.len() as u64,
                            }
                            .encode_line();
                            brain.net_stream_send(stream_id, &done, None, true)?;
                            return Ok(UpdateServeOutcome::Served {
                                bytes: artifact.len() as u64,
                            });
                        }
                        _ => {} // protocol noise — keep waiting
                    }
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                brain.net_stream_send(stream_id, &[], None, true)?;
                return Ok(UpdateServeOutcome::Detached);
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}

fn newer_offer(
    staged: &StagedUpdate,
    current_version: u64,
    channel: &str,
) -> Option<OfferedUpdate> {
    match staged {
        StagedUpdate::Single(s) => {
            let meta: ReleaseMetadata = serde_json::from_str(&s.metadata_json).ok()?;
            (meta.version > current_version && meta.channel == channel)
                .then_some(OfferedUpdate::Single(s.clone()))
        }
        StagedUpdate::Set(s) => {
            let meta: UpdateSetMetadata = serde_json::from_str(&s.metadata_json).ok()?;
            (meta.version > current_version && meta.channel == channel)
                .then_some(OfferedUpdate::Set(s.clone()))
        }
    }
}

/// What one update pull concluded.
#[derive(Debug)]
pub enum UpdatePullOutcome {
    /// The peer's release passed the full per-node gate; the artifact is
    /// staged ([`ReleaseCache`]) and the classified plan is ready for the
    /// consent/apply path (C2/C0 — not triggered here).
    Updated(UpdatePlan),
    /// The peer had nothing newer on this node's channel.
    UpToDate,
    /// The peer's release failed this node's gate — loud + typed, never a
    /// silent accept (ADR-0004 §D). Nothing was staged. Pre-fetch rejects
    /// (rollback / expired / off-channel / bad signature on the offered
    /// metadata) carry zero artifact bytes; a post-fetch reject means the
    /// pulled bytes did not match the signed digest.
    Rejected(RejectReason),
}

/// Requester side: pull a newer release from the peer over `conn_id`, gate it
/// per-node, and stage it on success. `open_op` is the durable id for the
/// exactly-once stream open; everything after rides fire-and-forget. A torn
/// pull errors — recovery is a fresh `request_update` (the query is
/// idempotent, staging is atomic; FAULT-MATRIX stream-loss row).
///
/// The gate runs **twice by design** (REQ-UPD-2: per-node, never inherited):
/// [`verify_metadata`] on the offer before any bytes move, then the whole
/// [`plan_verified`] front door (metadata + artifact digest + classify) on
/// the real bytes before staging.
// [impl->REQ-UPD-1]
// [impl->REQ-UPD-2]
pub fn request_update(
    brain: &mut Brain,
    conn_id: u64,
    open_op: u64,
    running: &BrokerAbi,
    policy: &VerifyPolicy,
    cache: &ReleaseCache,
    scratch_dir: &Path,
) -> io::Result<UpdatePullOutcome> {
    let opened = brain.net_open_stream(conn_id, Some(open_op))?;
    let stream_id = opened.stream_id;
    brain.net_stream_subscribe(stream_id, 0)?;

    let upd_id = format!("upd-{open_op}");
    let query = UpdRecord::Query {
        upd_id: upd_id.clone(),
        current_version: policy.current_version,
        channel: policy.pinned_channel.clone(),
    }
    .encode_line();
    brain.net_stream_send(stream_id, &query, None, false)?;

    std::fs::create_dir_all(scratch_dir)?;
    let artifact_path = scratch_dir.join(format!("pull-{open_op}.artifact"));
    let mut file: Option<std::fs::File> = None;
    let mut signed: Option<OfferedUpdate> = None;
    let mut decoder = UpdDecoder::new();

    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData {
                stream_id: sid,
                bytes,
                ..
            } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        UpdRecord::UpToDate { upd_id: xid } if xid == upd_id => {
                            brain.net_stream_send(stream_id, &[], None, true)?;
                            return Ok(UpdatePullOutcome::UpToDate);
                        }
                        UpdRecord::Offer {
                            upd_id: xid,
                            metadata_json,
                            signature_hex,
                        } if xid == upd_id => {
                            let as_set = SignedUpdateSet {
                                metadata_json: metadata_json.clone(),
                                signature_hex: signature_hex.clone(),
                            };
                            // The pre-fetch gate: reject a bad offer before a
                            // single artifact byte moves (REQ-UPD-2,
                            // REQ-HAZARD-UPDATE-ROLLBACK — a rollback offer
                            // costs the subnet nothing).
                            // [impl->REQ-HAZARD-UPDATE-ROLLBACK]
                            // [impl->REQ-UPD-6] Try the update-set schema
                            // first; legacy SignedRelease remains supported
                            // during migration.
                            match verify_update_set_metadata(&as_set, policy) {
                                Ok(meta) => {
                                    if let Err(reason) = meta.artifact_for(current_platform()) {
                                        brain.net_stream_send(stream_id, &[], None, true)?;
                                        return Ok(UpdatePullOutcome::Rejected(reason));
                                    }
                                    signed = Some(OfferedUpdate::Set(as_set));
                                    let fetch = UpdRecord::FetchArtifact {
                                        upd_id: upd_id.clone(),
                                        platform: current_platform().to_string(),
                                    }
                                    .encode_line();
                                    brain.net_stream_send(stream_id, &fetch, None, true)?;
                                }
                                Err(set_reason) => {
                                    let offer = SignedRelease {
                                        metadata_json,
                                        signature_hex,
                                    };
                                    if let Err(reason) = verify_metadata(&offer, policy) {
                                        brain.net_stream_send(stream_id, &[], None, true)?;
                                        return Ok(UpdatePullOutcome::Rejected(match set_reason {
                                            RejectReason::Malformed(_) => reason,
                                            _ => set_reason,
                                        }));
                                    }
                                    signed = Some(OfferedUpdate::Single(offer));
                                    let fetch = UpdRecord::Fetch {
                                        upd_id: upd_id.clone(),
                                    }
                                    .encode_line();
                                    brain.net_stream_send(stream_id, &fetch, None, true)?;
                                }
                            }
                        }
                        UpdRecord::Chunk {
                            upd_id: xid,
                            offset,
                            data_b64,
                        } if xid == upd_id => {
                            let chunk = decode_bytes(&data_b64)
                                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                            let f = match file.as_mut() {
                                Some(f) => f,
                                None => file.insert(
                                    std::fs::OpenOptions::new()
                                        .create(true)
                                        .truncate(true)
                                        .write(true)
                                        .open(&artifact_path)?,
                                ),
                            };
                            // Positional write — a replayed chunk rewrites the
                            // same bytes in place (idempotent by construction).
                            f.seek(SeekFrom::Start(offset))?;
                            f.write_all(&chunk)?;
                        }
                        UpdRecord::Done { upd_id: xid, .. } if xid == upd_id => {
                            drop(file.take()); // flush before reading back
                            let artifact = std::fs::read(&artifact_path).unwrap_or_default();
                            let _ = std::fs::remove_file(&artifact_path);
                            let offer = signed.take().ok_or_else(|| {
                                io::Error::other("peer sent Done before any Offer")
                            })?;
                            // The full per-node front door on the real bytes:
                            // no plan ⇒ nothing staged ⇒ this node never
                            // re-serves what it could not verify.
                            // [impl->REQ-UPD-2]
                            return match offer {
                                OfferedUpdate::Single(offer) => {
                                    match plan_verified(running, &offer, &artifact, policy) {
                                        Ok(plan) => {
                                            cache.stage(&offer, &artifact)?;
                                            Ok(UpdatePullOutcome::Updated(plan))
                                        }
                                        Err(reason) => Ok(UpdatePullOutcome::Rejected(reason)),
                                    }
                                }
                                OfferedUpdate::Set(offer) => {
                                    match plan_verified_update_set(
                                        running,
                                        &offer,
                                        current_platform(),
                                        &artifact,
                                        policy,
                                    ) {
                                        Ok(plan) => {
                                            cache.stage_update_set(
                                                &offer,
                                                &std::collections::BTreeMap::from([(
                                                    current_platform().to_string(),
                                                    artifact,
                                                )]),
                                            )?;
                                            Ok(UpdatePullOutcome::Updated(plan))
                                        }
                                        Err(reason) => Ok(UpdatePullOutcome::Rejected(reason)),
                                    }
                                }
                            };
                        }
                        UpdRecord::Err {
                            upd_id: xid,
                            message,
                        } if xid == upd_id => {
                            return Err(io::Error::other(format!("peer failed: {message}")));
                        }
                        _ => {} // protocol noise
                    }
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                return Err(io::Error::other(
                    "peer closed mid-pull (re-query to recover)",
                ));
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}

/// One node's status-only answer (M8 decision 19, `xtask debug-converge`):
/// the convergence facts a coordinator classifies. All-`None` = the
/// responder refused (untrusted) or predates the status wire.
// [impl->REQ-UPD-6]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UpdateStatusReport {
    pub channel: Option<String>,
    pub staged_version: Option<u64>,
    pub applied_version: Option<u64>,
    pub last_outcome: Option<String>,
}

/// Requester side of the status-only query: one `StatusQuery` on a fresh
/// stream, one `Status` back — no artifact bytes ever move. The stream open
/// is non-journaled (`op_id: None`): a torn status query is simply re-asked
/// next poll round — there is nothing exactly-once about reading status.
/// Used by `xtask debug-converge`'s poll loop, never the public CLI.
// [impl->REQ-UPD-6]
pub fn request_update_status(brain: &mut Brain, conn_id: u64) -> io::Result<UpdateStatusReport> {
    let opened = brain.net_open_stream(conn_id, None)?;
    let stream_id = opened.stream_id;
    brain.net_stream_subscribe(stream_id, 0)?;

    let upd_id = format!("upds-{stream_id}");
    let query = UpdRecord::StatusQuery {
        upd_id: upd_id.clone(),
    }
    .encode_line();
    brain.net_stream_send(stream_id, &query, None, true)?;

    let mut decoder = UpdDecoder::new();
    loop {
        match brain.read_event()? {
            BrokerEvent::NetStreamData {
                stream_id: sid,
                bytes,
                ..
            } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    if let UpdRecord::Status {
                        upd_id: xid,
                        channel,
                        staged_version,
                        applied_version,
                        last_outcome,
                    } = rec
                    {
                        if xid == upd_id {
                            return Ok(UpdateStatusReport {
                                channel,
                                staged_version,
                                applied_version,
                                last_outcome,
                            });
                        }
                    }
                }
            }
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                return Err(io::Error::other(
                    "peer closed without a status (pre-status-wire responder?)",
                ));
            }
            BrokerEvent::Error { message } => return Err(io::Error::other(message)),
            _ => {}
        }
    }
}

/// One expected node's classification in the convergence table (M8 decision
/// 19; state names mirror `docs/DEBUG-CONVERGE-PLAN.md` so the pull/apply
/// vocabulary stays one set).
// [impl->REQ-UPD-6]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConvergeState {
    /// The node applied a version ≥ the target — converged.
    Applied { version: u64 },
    /// The node is pinned to a different channel — the metadata gate would
    /// reject the rollout (expected-set bug, or intentional).
    NotPinned { channel: String },
    /// The target (or newer) is staged, default-gated, no apply yet.
    StagedAwaitingConsent { staged: u64 },
    /// A broker-touching candidate refused while live endpoints are held —
    /// the operator must quiesce before apply succeeds.
    BlockedByBrokerResources,
    /// A typed refusal was the node's last outcome.
    Rejected { reason: String },
    /// Answered, but no candidate at the target has landed yet.
    Pending,
    /// Unreachable / refused / no status this round.
    Offline,
}

impl ConvergeState {
    /// Terminal-success: the watcher stops early when every node is here.
    pub fn converged(&self) -> bool {
        matches!(self, ConvergeState::Applied { .. })
    }
}

/// Classify one node's status answer against the rollout target (pure — the
/// unit table `docs/DEBUG-CONVERGE-PLAN.md` asks for). `None` = the node
/// never answered this round (Offline).
// [impl->REQ-UPD-6]
pub fn classify_status(
    target_version: u64,
    target_channel: &str,
    report: Option<&UpdateStatusReport>,
) -> ConvergeState {
    let Some(r) = report else {
        return ConvergeState::Offline;
    };
    match &r.channel {
        None => return ConvergeState::Offline, // the fail-closed all-None shape
        Some(c) if c != target_channel => return ConvergeState::NotPinned { channel: c.clone() },
        Some(_) => {}
    }
    if let Some(v) = r.applied_version {
        if v >= target_version {
            return ConvergeState::Applied { version: v };
        }
    }
    if let Some(s) = r.staged_version {
        if s >= target_version {
            // Staged at (or past) the target: waiting on consent — unless
            // the last apply attempt refused on broker resources.
            if r.last_outcome
                .as_deref()
                .is_some_and(|o| o.contains("BrokerBreaking"))
            {
                return ConvergeState::BlockedByBrokerResources;
            }
            return ConvergeState::StagedAwaitingConsent { staged: s };
        }
    }
    if let Some(reason) = r
        .last_outcome
        .as_deref()
        .and_then(|o| o.strip_prefix("rejected:"))
    {
        return ConvergeState::Rejected {
            reason: reason.to_string(),
        };
    }
    ConvergeState::Pending
}

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

    fn report(
        channel: Option<&str>,
        staged: Option<u64>,
        applied: Option<u64>,
        outcome: Option<&str>,
    ) -> UpdateStatusReport {
        UpdateStatusReport {
            channel: channel.map(str::to_string),
            staged_version: staged,
            applied_version: applied,
            last_outcome: outcome.map(str::to_string),
        }
    }

    // [unit->REQ-UPD-6] the convergence classifier maps every status shape
    // onto exactly one DEBUG-CONVERGE-PLAN state: applied-at-target wins,
    // wrong channel is NotPinned, a staged target awaits consent (or is
    // blocked on broker resources), a typed refusal renders its reason,
    // no-answer / fail-closed-None is Offline, and an answered node with no
    // candidate yet is Pending.
    #[test]
    fn classifier_maps_every_status_shape() {
        let t = |r: Option<&UpdateStatusReport>| classify_status(7, "debug", r);

        assert_eq!(t(None), ConvergeState::Offline, "no answer this round");
        assert_eq!(
            t(Some(&report(None, None, None, None))),
            ConvergeState::Offline,
            "the untrusted all-None shape classifies as unreachable"
        );
        assert_eq!(
            t(Some(&report(Some("stable"), None, None, None))),
            ConvergeState::NotPinned {
                channel: "stable".into()
            },
            "a stable-pinned node would reject at the metadata gate"
        );
        assert_eq!(
            t(Some(&report(
                Some("debug"),
                Some(7),
                Some(7),
                Some("applied")
            ))),
            ConvergeState::Applied { version: 7 }
        );
        assert_eq!(
            t(Some(&report(Some("debug"), None, Some(9), Some("applied")))),
            ConvergeState::Applied { version: 9 },
            "newer-than-target counts as converged"
        );
        assert_eq!(
            t(Some(&report(
                Some("debug"),
                Some(7),
                Some(6),
                Some("staged")
            ))),
            ConvergeState::StagedAwaitingConsent { staged: 7 },
            "staged at target, older apply — waiting on consent"
        );
        assert_eq!(
            t(Some(&report(
                Some("debug"),
                Some(7),
                None,
                Some("rejected:RefusedClass(BrokerBreaking)")
            ))),
            ConvergeState::BlockedByBrokerResources,
            "broker-touching refusal needs an operator quiesce"
        );
        assert_eq!(
            t(Some(&report(
                Some("debug"),
                None,
                None,
                Some("rejected:NoArtifactForPlatform(\"x86_64-apple-darwin\")")
            ))),
            ConvergeState::Rejected {
                reason: "NoArtifactForPlatform(\"x86_64-apple-darwin\")".into()
            }
        );
        assert_eq!(
            t(Some(&report(Some("debug"), Some(3), Some(3), None))),
            ConvergeState::Pending,
            "answered, but nothing at the target yet"
        );

        assert!(ConvergeState::Applied { version: 7 }.converged());
        assert!(!ConvergeState::Pending.converged());
    }
}


Now I have the full picture. Critical correction confirmed: production `release_publish` does **not** build a `SignedUpdateSet` — only `debug_rollout` does. I must add set construction + upload to publish.

Let me check remaining unknowns: who parses `APPLIED:`, workspace version, release.yml, lib re-exports.


Found 8 files
V032-PLAN.md
crates\spt\src\cli.rs
crates\spt-daemon\src\broker.rs
crates\spt-net\src\net\update.rs
crates\spt-daemon\src\relcache.rs
crates\spt-daemon\src\msg.rs
crates\spt-daemon\src\brain.rs
M5-D7-PLAN.md


Bash

grep -n "^version" Cargo.toml | head; echo "---spt pkg---"; grep -n "version" crates/spt/Cargo.toml | head; echo "---release.yml---"; ls .github/workflows/ 2>/dev/null

Find workspace version + workflows


19:version = "0.3.1"
---spt pkg---
3:version.workspace = true
25:clap = { version = "4", features = ["derive", "wrap_help"] }
28:serde_json = { version = "1", features = ["preserve_order"] }
32:qrcode = { version = "0.14", default-features = false }
35:chrono = { version = "0.4", default-features = false, features = ["clock"] }
40:reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }
49:windows-sys = { version = "0.59", features = [
---release.yml---
ci.yml
docs-publish.yml
release.yml


grep -rn "APPLIED:" --include=*.rs --include=*.sh --include=*.ps1 --include=*.md crates/ scripts/ docs/ 2>/dev/null | grep -iv "record_applied" | head -40

Find APPLIED: string consumers


crates/spt/src/cli.rs:1720:            println!("APPLIED:{version}");
crates/spt-daemon/src/msg.rs:41:pub const KIND_APPLIED: &str = "applied";


160:pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
161:pub use adapter_update::{
165:pub use applyhost::{apply_staged, ApplyStagedOutcome};
166:pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
167:pub use brain::{Brain, BrainState, BrokerEvent};
168:pub use broker::Broker;
169:pub use config::{DaemonConfig, RelayChoice};
170:pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
171:pub use daemon::{
175:pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
176:pub use digesthub::{
180:pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
181:pub use endpoint::{
185:pub use frame::{
189:pub use lifecycle::{BrainLifecycle, TickReport};
190:pub use linkhost::{
193:pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
194:pub use serveprobe::{
197:pub use notif::{
202:pub use notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};
203:pub use propagate::{request_update, serve_update, UpdatePullOutcome, UpdateServeOutcome};
204:pub use psyrelay::{
207:pub use relay::Relay;
208:pub use relcache::ReleaseCache;
209:pub use release::{
212:    ReleaseMetadata, SignedRelease, SignedUpdateSet, UpdateArtifactMetadata, UpdateSetMetadata,
215:pub use resthost::{request_rest, serve_rest, RestRequestOutcome, RestServeOutcome};
216:pub use resting::{
221:pub use seedmap::{put_seed, take_seed, SeedRegistry};
222:pub use sync::{
226:pub use transport::{DaemonTransport, LocalSocketTransport};
227:pub use update::{
231:pub use wan::{receive_wan, WanOutcome};
232:pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};


209	pub use release::{
210	    current_platform, parse_verifying_key, sha256_hex, verify_artifact, verify_metadata,
211	    verify_signature, verify_update_set_artifact, verify_update_set_metadata, RejectReason,
212	    ReleaseMetadata, SignedRelease, SignedUpdateSet, UpdateArtifactMetadata, UpdateSetMetadata,
213	    UpdateSetProvenance, VerifyPolicy,
214	};
215	pub use resthost::{request_rest, serve_rest, RestRequestOutcome, RestServeOutcome};
216	pub use resting::{
217	    apply_event, arm_transition_echo, daemon_rest_event, effective_auto_suspend, fire_wake_effects,
218	    read_rest, request_freshness_pull, take_freshness_pull, transition, write_rest, EdgeReport,
219	    RestEvent, RestRecord, RestState, PULL_MARKER_FILE,
220	};
221	pub use seedmap::{put_seed, take_seed, SeedRegistry};
222	pub use sync::{
223	    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
224	    SyncPullReport, SyncServeOutcome,
225	};
226	pub use transport::{DaemonTransport, LocalSocketTransport};
227	pub use update::{
228	    apply_brain_only, classify, plan_update, plan_verified, plan_verified_update_set, ApplyError,
229	    BrokerAbi, ReleaseSpec, UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
230	};

pub use release::{
    current_platform, parse_verifying_key, sha256_hex, verify_artifact, verify_metadata,
    verify_signature, verify_update_set_artifact, verify_update_set_metadata, RejectReason,
    ReleaseMetadata, SignedRelease, SignedUpdateSet, UpdateArtifactMetadata, UpdateSetMetadata,
    UpdateSetProvenance, VerifyPolicy,
};
pub use resthost::{request_rest, serve_rest, RestRequestOutcome, RestServeOutcome};
pub use resting::{
    apply_event, arm_transition_echo, daemon_rest_event, effective_auto_suspend, fire_wake_effects,
    read_rest, request_freshness_pull, take_freshness_pull, transition, write_rest, EdgeReport,
    RestEvent, RestRecord, RestState, PULL_MARKER_FILE,
};
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use sync::{
    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
    SyncPullReport, SyncServeOutcome,
};
pub use transport::{DaemonTransport, LocalSocketTransport};
pub use update::{
    apply_brain_only, classify, plan_update, plan_verified, plan_verified_update_set, ApplyError,
    BrokerAbi, ReleaseSpec, UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
};


1	# M6-D6: the tag-triggered release pipeline (grill decision 9, ADR-0015).
2	#
3	# CI's half of the ceremony: build the platform binaries on both self-hosted
4	# runners, assemble the asset set the self-updater + installer consume
5	# (binaries, SHA256SUMS, manifest.schema.json, mock-adapter.zip), and create
6	# a DRAFT release on SaberMage/spt-releases (cross-repo via the
7	# RELEASES_TOKEN fine-grained PAT).
8	#
9	# The maintainer's half stays manual and local (no signing keys in CI):
10	#   1. download the draft's binaries (or use the local builds)
11	#   2. SPT_RELEASE_SEED=<seed> cargo run -p xtask -- release-sign \
12	#        --key-id rel-primary-2026 --version <N> spt-x86_64-* …
13	#   3. gh release upload <tag> *.release.json --repo SaberMage/spt-releases
14	#   4. publish the draft
15	#
16	# [impl->REQ-REL-2]
17	name: release
18
19	on:
20	  push:
21	    tags: ["v*"]
22
23	jobs:
24	  build:
25	    strategy:
26	      fail-fast: false
27	      matrix:
28	        include:
29	          - runner: [self-hosted, Linux, kitsubito]
30	            asset: spt-x86_64-linux
31	            built: target/release/spt
32	          - runner: [self-hosted, Windows, hfenduleam]
33	            asset: spt-x86_64-windows.exe
34	            built: target/release/spt.exe
35	    runs-on: ${{ matrix.runner }}
36	    timeout-minutes: 30
37	    steps:
38	      - uses: actions/checkout@v5
39	      - name: Build release binary
40	        run: cargo build --release -p spt --bin spt
41	      # Two staging steps: the Windows runner has no bash on PATH (the same
42	      # fact the dormancy-budget steps already encode) — each OS keeps its
43	      # native shell.
44	      - name: Stage asset (linux)
45	        if: runner.os == 'Linux'
46	        run: |
47	          mkdir -p dist
48	          cp "${{ matrix.built }}" "dist/${{ matrix.asset }}"
49	      - name: Stage asset (windows)
50	        if: runner.os == 'Windows'
51	        # pwsh (PowerShell 7), not Windows PowerShell 5.1: the runner's 5.1
52	        # execution policy blocks GitHub's dot-sourced temp script
53	        # (PSSecurityException). pwsh is the runner's default shell (the build
54	        # step above runs under it) and carries its own, permissive policy.
55	        shell: pwsh
56	        run: |
57	          New-Item -ItemType Directory -Force dist | Out-Null
58	          Copy-Item "${{ matrix.built }}" "dist/${{ matrix.asset }}"
59	      - uses: actions/upload-artifact@v4
60	        with:
61	          name: ${{ matrix.asset }}
62	          path: dist/${{ matrix.asset }}
63	          if-no-files-found: error
64
65	  assemble:
66	    needs: build
67	    runs-on: [self-hosted, Linux, kitsubito]
68	    timeout-minutes: 15
69	    steps:
70	      - uses: actions/checkout@v5
71	      - uses: actions/download-artifact@v4
72	        with:
73	          path: dist
74	          merge-multiple: true
75	      - name: Assemble asset set
76	        # The exact set REQ-REL-2 names: platform binaries + SHA256SUMS (what
77	        # install.sh/.ps1 verify) + the manifest schema + the mock-adapter
78	        # source zip (the adapter quickstart's reference download).
79	        run: |
80	          cd dist
81	          sha256sum spt-x86_64-linux spt-x86_64-windows.exe > SHA256SUMS
82	          cp ../crates/spt-runtime/manifest.schema.json .
83	          # Zip rooted at the adapter dir so `unzip -d mock-adapter` yields
84	          # mock-adapter/manifest.toml exactly as the adapter quickstart says.
85	          (cd ../adapters/mock && zip -r "$OLDPWD/mock-adapter.zip" . \
86	             -x 'target/*')
87	          ls -la
88	      - name: Build user-facing release notes from CHANGELOG
89	        # The release BODY is the user-facing changelog: the `## [<version>]`
90	        # section of CHANGELOG.md (Added / Changed / Fixed, UX items only — no
91	        # internal lingo). Authoring that section before tagging is a required
92	        # release step (docs/RELEASE-RUNBOOK.md). A missing section fails the
93	        # release loudly rather than shipping an empty/boilerplate body — the
94	        # changelog is not optional.
95	        run: |
96	          ver="${GITHUB_REF_NAME#v}"   # v0.1.2 -> 0.1.2
97	          notes="dist/RELEASE_NOTES.md"
98	          # Literal string matching (index, not regex) so the section header is
99	          # matched identically across awk dialects — no bracket-escaping traps.
100	          awk -v want="## [${ver}]" '
101	            index($0, want) == 1 { grab=1; next }
102	            grab && index($0, "## [") == 1 { exit }
103	            grab { print }
104	          ' CHANGELOG.md > "$notes"
105	          if ! grep -q '[^[:space:]]' "$notes"; then
106	            echo "::error::CHANGELOG.md has no '## [$ver]' section — add the user-facing changelog for this release before tagging (see docs/RELEASE-RUNBOOK.md)."
107	            exit 1
108	          fi
109	          echo "----- release body for ${GITHUB_REF_NAME} -----"
110	          cat "$notes"
111	      - name: Create draft release on spt-releases
112	        env:
113	          GH_TOKEN: ${{ secrets.RELEASES_TOKEN }}
114	        run: |
115	          gh release create "${GITHUB_REF_NAME}" \
116	            --repo SaberMage/spt-releases \
117	            --draft \
118	            --title "spt ${GITHUB_REF_NAME}" \
119	            --notes-file dist/RELEASE_NOTES.md \
120	            dist/spt-x86_64-linux \
121	            dist/spt-x86_64-windows.exe \
122	            dist/SHA256SUMS \
123	            dist/manifest.schema.json \
124	            dist/mock-adapter.zip
125

# M6-D6: the tag-triggered release pipeline (grill decision 9, ADR-0015).
#
# CI's half of the ceremony: build the platform binaries on both self-hosted
# runners, assemble the asset set the self-updater + installer consume
# (binaries, SHA256SUMS, manifest.schema.json, mock-adapter.zip), and create
# a DRAFT release on SaberMage/spt-releases (cross-repo via the
# RELEASES_TOKEN fine-grained PAT).
#
# The maintainer's half stays manual and local (no signing keys in CI):
#   1. download the draft's binaries (or use the local builds)
#   2. SPT_RELEASE_SEED=<seed> cargo run -p xtask -- release-sign \
#        --key-id rel-primary-2026 --version <N> spt-x86_64-* …
#   3. gh release upload <tag> *.release.json --repo SaberMage/spt-releases
#   4. publish the draft
#
# [impl->REQ-REL-2]
name: release

on:
  push:
    tags: ["v*"]

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - runner: [self-hosted, Linux, kitsubito]
            asset: spt-x86_64-linux
            built: target/release/spt
          - runner: [self-hosted, Windows, hfenduleam]
            asset: spt-x86_64-windows.exe
            built: target/release/spt.exe
    runs-on: ${{ matrix.runner }}
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v5
      - name: Build release binary
        run: cargo build --release -p spt --bin spt
      # Two staging steps: the Windows runner has no bash on PATH (the same
      # fact the dormancy-budget steps already encode) — each OS keeps its
      # native shell.
      - name: Stage asset (linux)
        if: runner.os == 'Linux'
        run: |
          mkdir -p dist
          cp "${{ matrix.built }}" "dist/${{ matrix.asset }}"
      - name: Stage asset (windows)
        if: runner.os == 'Windows'
        # pwsh (PowerShell 7), not Windows PowerShell 5.1: the runner's 5.1
        # execution policy blocks GitHub's dot-sourced temp script
        # (PSSecurityException). pwsh is the runner's default shell (the build
        # step above runs under it) and carries its own, permissive policy.
        shell: pwsh
        run: |
          New-Item -ItemType Directory -Force dist | Out-Null
          Copy-Item "${{ matrix.built }}" "dist/${{ matrix.asset }}"
      - uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.asset }}
          path: dist/${{ matrix.asset }}
          if-no-files-found: error

  assemble:
    needs: build
    runs-on: [self-hosted, Linux, kitsubito]
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v5
      - uses: actions/download-artifact@v4
        with:
          path: dist
          merge-multiple: true
      - name: Assemble asset set
        # The exact set REQ-REL-2 names: platform binaries + SHA256SUMS (what
        # install.sh/.ps1 verify) + the manifest schema + the mock-adapter
        # source zip (the adapter quickstart's reference download).
        run: |
          cd dist
          sha256sum spt-x86_64-linux spt-x86_64-windows.exe > SHA256SUMS
          cp ../crates/spt-runtime/manifest.schema.json .
          # Zip rooted at the adapter dir so `unzip -d mock-adapter` yields
          # mock-adapter/manifest.toml exactly as the adapter quickstart says.
          (cd ../adapters/mock && zip -r "$OLDPWD/mock-adapter.zip" . \
             -x 'target/*')
          ls -la
      - name: Build user-facing release notes from CHANGELOG
        # The release BODY is the user-facing changelog: the `## [<version>]`
        # section of CHANGELOG.md (Added / Changed / Fixed, UX items only — no
        # internal lingo). Authoring that section before tagging is a required
        # release step (docs/RELEASE-RUNBOOK.md). A missing section fails the
        # release loudly rather than shipping an empty/boilerplate body — the
        # changelog is not optional.
        run: |
          ver="${GITHUB_REF_NAME#v}"   # v0.1.2 -> 0.1.2
          notes="dist/RELEASE_NOTES.md"
          # Literal string matching (index, not regex) so the section header is
          # matched identically across awk dialects — no bracket-escaping traps.
          awk -v want="## [${ver}]" '
            index($0, want) == 1 { grab=1; next }
            grab && index($0, "## [") == 1 { exit }
            grab { print }
          ' CHANGELOG.md > "$notes"
          if ! grep -q '[^[:space:]]' "$notes"; then
            echo "::error::CHANGELOG.md has no '## [$ver]' section — add the user-facing changelog for this release before tagging (see docs/RELEASE-RUNBOOK.md)."
            exit 1
          fi
          echo "----- release body for ${GITHUB_REF_NAME} -----"
          cat "$notes"
      - name: Create draft release on spt-releases
        env:
          GH_TOKEN: ${{ secrets.RELEASES_TOKEN }}
        run: |
          gh release create "${GITHUB_REF_NAME}" \
            --repo SaberMage/spt-releases \
            --draft \
            --title "spt ${GITHUB_REF_NAME}" \
            --notes-file dist/RELEASE_NOTES.md \
            dist/spt-x86_64-linux \
            dist/spt-x86_64-windows.exe \
            dist/SHA256SUMS \
            dist/manifest.schema.json \
            dist/mock-adapter.zip


Now I have everything. Let me check cli.rs test module + xtask Cargo version + propagate/twohost tests that might stage singles.


grep -n "version.workspace\|^version\|name = " crates/xtask/Cargo.toml | head; echo "===cli tests==="; grep -n "mod tests\|#\[cfg(test)\]\|fn origin_release_urls\|platform_release_asset" crates/spt/src/cli.rs | head; echo "===tests dir==="; ls crates/spt-daemon/tests/; echo "===single staging in integ tests==="; grep -rn "\.stage(\|stage_update_set\|cache.stage" crates/spt-daemon/tests/ crates/spt/ | head

Check xtask version, cli tests, integ test staging


2:name = "xtask"
3:version.workspace = true
===cli tests===
1740:fn platform_release_asset() -> &'static str {
1750:fn origin_release_urls(repo: &str, asset: &str, tag: Option<&str>) -> (String, String) {
1788:    let asset = platform_release_asset();
5103:#[cfg(test)]
5104:mod tests {
7306:    fn origin_release_urls_latest_and_tag() {
7307:        let asset = platform_release_asset();
===tests dir===
access.rs
attach.rs
brain_swap.rs
broker.rs
budget.rs
daemon_e2e.rs
digest.rs
dispatch.rs
handoff.rs
idempotent.rs
mesh.rs
netbroker.rs
netstream.rs
notifsync.rs
pairjoin.rs
peerloop.rs
presence.rs
propagate.rs
replicate.rs
reseed.rs
rosterprop.rs
seedproofx.rs
shellchan.rs
sync.rs
twohost.rs
wanmsg.rs
xfer.rs
===single staging in integ tests===
crates/spt-daemon/tests/dispatch.rs:241:        .stage(&signed, &artifact)
crates/spt-daemon/tests/peerloop.rs:177:        .stage(&signed, &artifact)
crates/spt-daemon/tests/propagate.rs:289:        .stage(&signed_release(6, &artifact), &artifact)
crates/spt-daemon/tests/propagate.rs:356:        .stage_update_set(
crates/spt-daemon/tests/propagate.rs:421:        .stage(&signed_release(6, &artifact), &artifact)
crates/spt-daemon/tests/propagate.rs:434:        c.cache.staged().is_none(),
crates/spt-daemon/tests/propagate.rs:504:        v.cache.staged().is_none(),
crates/spt-daemon/tests/propagate.rs:613:        .stage_update_set(
crates/spt-daemon/tests/propagate.rs:697:        .stage(&signed_release(6, &artifact), &artifact)
crates/spt-daemon/tests/propagate.rs:712:    assert!(b.cache.staged().is_none(), "nothing landed");


grep -rn "apply_staged\|ApplyStagedOutcome\|Applied {" crates/spt-daemon/tests/ crates/spt/src/ | grep -v "cli.rs" | head

Find apply_staged consumers in tests


crates/spt-daemon/tests/idempotent.rs:131:                BrokerEvent::Applied { op_id, .. } if op_id == op => break,
crates/spt-daemon/tests/propagate.rs:637:    assert_eq!(state, ConvergeState::Applied { version: target });


All clear. No integration test applies a single — only applyhost unit tests. Setting up task tracking and starting edits.


ToolSearch

Question: select:TaskCreate


Question: select:TaskCreate


TaskCreate

Mint REQ-UPD-8 in traceable-reqs.toml

Add REQ-UPD-8: platform-safe origin fetch + apply platform-guard + friendly apply message. required_stages = ["impl","unit"].


Task #1 created successfully: Mint REQ-UPD-8 in traceable-reqs.toml

Mint REQ-UPD-8 in traceable-reqs.toml


Add product_version field to metadata

Add additive serde `product_version: String` to ReleaseMetadata + UpdateSetMetadata in release.rs (default ""). Update test helpers.


Cache platform stamp + product_version helper

relcache.rs: platform sidecar (staged_platform, stage_single_with_platform, stage()/stage_update_set() clear stamp), StagedUpdate::product_version().


Apply platform guard + product_version outcome

applyhost.rs: Single path refuses unstamped/mismatched platform. Applied{version,product_version}. Update tests + add regression test.


Fetch stages update-set + apply message

cli.rs: cmd_update_fetch downloads update-set.json + all platform artifacts, verifies, stages set. Friendly apply message + render helper. Unit tests for URL/asset derivation + message.


Task #2 created successfully: Add product_version field to metadata

Add product_version field to metadata


Task #3 created successfully: Cache platform stamp + product_version helper


Task #4 created successfully: Apply platform guard + product_version outcome


Task #5 created successfully: Fetch stages update-set + apply message

Fetch stages update-set + apply message


xtask publish emits + uploads update-set.json

xtask main.rs release_publish: build+sign SignedUpdateSet over both platforms, stamp product_version from tag, write+upload update-set.json. Stamp product_version in sign_artifacts too.


Task #6 created successfully: xtask publish emits + uploads update-set.json


Run gates

build, test, clippy, --no-default-features, traceable-reqs check, xtask check.


Task #7 created successfully: Run gates

Run gates


Task 1: mint REQ-UPD-8.


Edit

required_stages = ["impl", "unit"]  # activate v0.3.1. impl = `spt update fetch [--channel <ch>] [--tag vX.Y.Z]` (default latest on pinned channel); HTTPS GET of artifact + <asset>.release.json from releases/{latest,tag/<tag>}/download; feed both through plan_verified then cache.stage(); reuse cmd_update_apply machinery downstream. Add a direct HTTPS client (reqwest, blocking+rustls) on the `spt` CLI crate (fetch runs in the CLI, NOT the daemon — no new daemon HTTP surface); honor SPT_INSTALL_REPO override. unit = URL/asset-name derivation (platform × latest/tag) + verify-then-stage against a fixture release.json (reuse release.rs vectors); rollback floor + channel-pin rejection paths.

# ───────────────────────────── Terminal wrapper / frontend ──────────────────

required_stages = ["impl", "unit"]  # activate v0.3.1. impl = `spt update fetch [--channel <ch>] [--tag vX.Y.Z]` (default latest on pinned channel); HTTPS GET of artifact + <asset>.release.json from releases/{latest,tag/<tag>}/download; feed both through plan_verified then cache.stage(); reuse cmd_update_apply machinery downstream. Add a direct HTTPS client (reqwest, blocking+rustls) on the `spt` CLI crate (fetch runs in the CLI, NOT the daemon — no new daemon HTTP surface); honor SPT_INSTALL_REPO override. unit = URL/asset-name derivation (platform × latest/tag) + verify-then-stage against a fixture release.json (reuse release.rs vectors); rollback floor + channel-pin rejection paths.
[[requirements]]
id = "REQ-UPD-8"
title = "Platform-safe `spt update fetch` + apply platform-guard (v0.3.1 cross-OS brick fix): `spt update fetch` stages the signed multi-platform `SignedUpdateSet` (`update-set.json` + every platform artifact it names), never a platform-blind single `SignedRelease`, so local apply selects `current_platform()` and P2P re-serve lets each peer select ITS own platform. Defense-in-depth: `apply_staged` REFUSES a staged single-release artifact unless it is platform-stamped for THIS node (an unstamped pre-v0.3.2 single, or a single stamped for another OS, fail-safe refuses — the guard that alone prevents the v0.3.1 brick where a Linux ELF was applied as `spt.exe`). UX: a friendly post-apply message (`Updated spt-core to vX.Y.Z.` + changelog URL) driven by an additive `product_version` metadata field, with a release-counter fallback when absent."
required_stages = ["impl", "unit"]  # activate v0.3.2. impl = fetch set-staging (cli) + apply_staged single platform-guard (applyhost) + ReleaseCache platform stamp (relcache) + product_version metadata (release.rs) + xtask release-publish emits/uploads update-set.json stamped with product_version. unit = (a) fetch update-set.json + per-asset URL/triple derivation; (b) apply REFUSES a platform-mismatched / unstamped single (the regression that bricked hfenduleam) + a matching-stamp single applies; (c) the apply message render (product_version present → "Updated spt-core to vX.Y.Z." + URL; absent → release-counter fallback).

# ───────────────────────────── Terminal wrapper / frontend ──────────────────