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

//! 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 trusted peer in ≥1 subnet; an untrusted 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::trust::TrustStore;

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 trusted peer 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 `trust`
/// **here**, fail-closed: an untrusted 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]
pub fn serve_update(
    brain: &mut Brain,
    stream_id: u64,
    origin_node: &str,
    net_from_seq: u64,
    cache: &ReleaseCache,
    trust: &TrustStore,
) -> 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 = trust.peers.iter().any(|p| p.pubkey_hex == 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 peer trusted in ≥1 subnet is
                            // answered; everyone else gets the up-to-date
                            // shape (fail-closed, indistinguishable from
                            // having nothing).
                            let trusted = trust.peers.iter().any(|p| p.pubkey_hex == 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());
    }
}