sed -n '76,100p' crates/spt-daemon/src/update.rs; grep -n "^use" crates/spt-daemon/src/release.rs

ReleaseSpec fields + release.rs imports


pub struct ReleaseSpec {
    /// The IPC protocol version the release's **brain** speaks.
    pub brain_ipc_version: u32,
    /// The held-resource-type ABI the release's **broker** implements — compared
    /// against the running broker's [`BrokerAbi::resource_abi`] to detect a
    /// broker-breaking change.
    pub broker_resource_abi: u32,
}

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

/// Classify a candidate release against the running broker (ADR-0004 §A).
///
31:use std::collections::{BTreeMap, BTreeSet};
33:use ed25519_dalek::{Signature, Verifier, VerifyingKey};
34:use serde::{Deserialize, Serialize};
35:use sha2::{Digest, Sha256};
37:use crate::update::ReleaseSpec;


84	/// The trust policy a node verifies a release against. Pure data — no clock, no
85	/// I/O — so verification is deterministic and testable.
86	#[derive(Debug, Clone)]
87	pub struct VerifyPolicy {
88	    /// Trusted release keys by `key_id`. Rotation = add a new id; old ids may
89	    /// linger until retired.
90	    pub trusted_keys: BTreeMap<String, VerifyingKey>,
91	    /// Explicitly revoked `key_id`s — rejected even with a valid signature.
92	    pub revoked_keys: BTreeSet<String>,
93	    /// The channel this node is pinned to.
94	    pub pinned_channel: String,
95	    /// The running build's version — an update must exceed it (monotonic).
96	    pub current_version: u64,
97	    /// Wall-clock now (epoch ms) for the expiry check (caller-supplied).
98	    pub now_ms: u64,
99	}
100
101	/// Why a release was refused. Every reject is loud + typed (the 2.3/§D failure
102	/// mode is a silent accept of a bad binary).
103	#[derive(Debug, Clone, PartialEq, Eq)]
104	pub enum RejectReason {
105	    /// The metadata JSON or signature hex could not be parsed.
106	    Malformed(String),
107	    /// The `key_id` is not in the trusted set.
108	    UnknownKey(String),
109	    /// The `key_id` is on the revocation list.
110	    RevokedKey(String),
111	    /// The signature did not verify against the selected key.
112	    BadSignature,
113	    /// The release targets a channel other than the node's pin.
114	    ChannelMismatch { pinned: String, got: String },
115	    /// The signed metadata has expired.
116	    Expired { now_ms: u64, expires_at_ms: u64 },
117	    /// The release version is not strictly greater than the running version.
118	    Rollback { current: u64, candidate: u64 },

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

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


Edit

/// Why a release was refused. Every reject is loud + typed (the 2.3/§D failure
/// mode is a silent accept of a bad binary).

/// The default channel pin when `release-keys.json` names none.
pub const DEFAULT_CHANNEL: &str = "stable";

/// On-disk trusted-release-keys config (`identity/release-keys.json`) — the
/// production [`VerifyPolicy`] source (D9-1). Human-editable JSON:
/// `{"keys": {"<key_id>": "<verifying-key-hex>"}, "revoked": ["<key_id>"],
/// "channel": "stable"}`. A missing or malformed file yields an **empty**
/// trusted set — every offer rejects `UnknownKey`, fail-closed: a node with
/// no provisioned release keys can never accept a propagated binary.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ReleaseKeysFile {
    #[serde(default)]
    keys: BTreeMap<String, String>,
    #[serde(default)]
    revoked: BTreeSet<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    channel: Option<String>,
}

impl VerifyPolicy {
    /// Assemble the production policy: trusted keys + channel pin from
    /// `release-keys.json` under the identity dir, the caller's
    /// `current_version` (the staged release's version, or `0` when nothing
    /// is staged — only strictly-newer offers pass the monotonic gate), and
    /// the caller's clock.
    // [impl->REQ-UPD-2]
    pub fn production(current_version: u64, now_ms: u64) -> VerifyPolicy {
        Self::load_from(
            &spt_store::perch::identity_dir().join("release-keys.json"),
            current_version,
            now_ms,
        )
    }

    /// Load from an explicit keys file (tests; the pump E2E points each
    /// in-process "node" at its own). Unparseable key hex is skipped loud —
    /// a typo must not silently widen or narrow the trusted set unnoticed.
    // [impl->REQ-UPD-2]
    pub fn load_from(path: &std::path::Path, current_version: u64, now_ms: u64) -> VerifyPolicy {
        let file: ReleaseKeysFile = std::fs::read_to_string(path)
            .ok()
            .and_then(|raw| serde_json::from_str(&raw).ok())
            .unwrap_or_default();
        let mut trusted_keys = BTreeMap::new();
        for (key_id, hex) in &file.keys {
            match parse_verifying_key(hex) {
                Some(vk) => {
                    trusted_keys.insert(key_id.clone(), vk);
                }
                None => eprintln!("RELEASE_KEY_BAD:{key_id} — skipped (not a valid key)"),
            }
        }
        VerifyPolicy {
            trusted_keys,
            revoked_keys: file.revoked,
            pinned_channel: file.channel.unwrap_or_else(|| DEFAULT_CHANNEL.to_string()),
            current_version,
            now_ms,
        }
    }
}

/// Parse a lowercase-hex ed25519 verifying key.
fn parse_verifying_key(hex: &str) -> Option<VerifyingKey> {
    if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
        return None;
    }
    let mut bytes = [0u8; 32];
    for (i, b) in bytes.iter_mut().enumerate() {
        *b = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
    }
    VerifyingKey::from_bytes(&bytes).ok()
}

/// Why a release was refused. Every reject is loud + typed (the 2.3/§D failure
/// mode is a silent accept of a bad binary).


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\release.rs has been updated successfully. (file state is current in your context — no need to Read it back)

/// The default channel pin when `release-keys.json` names none.
pub const DEFAULT_CHANNEL: &str = "stable";

/// On-disk trusted-release-keys config (`identity/release-keys.json`) — the
/// production [`VerifyPolicy`] source (D9-1). Human-editable JSON:
/// `{"keys": {"<key_id>": "<verifying-key-hex>"}, "revoked": ["<key_id>"],
/// "channel": "stable"}`. A missing or malformed file yields an **empty**
/// trusted set — every offer rejects `UnknownKey`, fail-closed: a node with
/// no provisioned release keys can never accept a propagated binary.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ReleaseKeysFile {
    #[serde(default)]
    keys: BTreeMap<String, String>,
    #[serde(default)]
    revoked: BTreeSet<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    channel: Option<String>,
}

impl VerifyPolicy {
    /// Assemble the production policy: trusted keys + channel pin from
    /// `release-keys.json` under the identity dir, the caller's
    /// `current_version` (the staged release's version, or `0` when nothing
    /// is staged — only strictly-newer offers pass the monotonic gate), and
    /// the caller's clock.
    // [impl->REQ-UPD-2]
    pub fn production(current_version: u64, now_ms: u64) -> VerifyPolicy {
        Self::load_from(
            &spt_store::perch::identity_dir().join("release-keys.json"),
            current_version,
            now_ms,
        )
    }

    /// Load from an explicit keys file (tests; the pump E2E points each
    /// in-process "node" at its own). Unparseable key hex is skipped loud —
    /// a typo must not silently widen or narrow the trusted set unnoticed.
    // [impl->REQ-UPD-2]
    pub fn load_from(path: &std::path::Path, current_version: u64, now_ms: u64) -> VerifyPolicy {
        let file: ReleaseKeysFile = std::fs::read_to_string(path)
            .ok()
            .and_then(|raw| serde_json::from_str(&raw).ok())
            .unwrap_or_default();
        let mut trusted_keys = BTreeMap::new();
        for (key_id, hex) in &file.keys {
            match parse_verifying_key(hex) {
                Some(vk) => {
                    trusted_keys.insert(key_id.clone(), vk);
                }
                None => eprintln!("RELEASE_KEY_BAD:{key_id} — skipped (not a valid key)"),
            }
        }
        VerifyPolicy {
            trusted_keys,
            revoked_keys: file.revoked,
            pinned_channel: file.channel.unwrap_or_else(|| DEFAULT_CHANNEL.to_string()),
            current_version,
            now_ms,
        }
    }
}

/// Parse a lowercase-hex ed25519 verifying key.
fn parse_verifying_key(hex: &str) -> Option<VerifyingKey> {
    if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
        return None;
    }
    let mut bytes = [0u8; 32];
    for (i, b) in bytes.iter_mut().enumerate() {
        *b = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
    }
    VerifyingKey::from_bytes(&bytes).ok()
}

/// Why a release was refused. Every reject is loud + typed (the 2.3/§D failure
/// mode is a silent accept of a bad binary).

+/// The default channel pin when `release-keys.json` names none.

+pub const DEFAULT_CHANNEL: &str = "stable";

+/// On-disk trusted-release-keys config (`identity/release-keys.json`) — the

+/// production [`VerifyPolicy`] source (D9-1). Human-editable JSON:

+/// `{"keys": {"<key_id>": "<verifying-key-hex>"}, "revoked": ["<key_id>"],

+/// "channel": "stable"}`. A missing or malformed file yields an **empty**

+/// trusted set — every offer rejects `UnknownKey`, fail-closed: a node with

+/// no provisioned release keys can never accept a propagated binary.

+#[derive(Debug, Clone, Default, Serialize, Deserialize)]

+    #[serde(default, skip_serializing_if = "Option::is_none")]

+    /// Assemble the production policy: trusted keys + channel pin from

+    /// `release-keys.json` under the identity dir, the caller's

+    /// `current_version` (the staged release's version, or `0` when nothing

+    /// is staged — only strictly-newer offers pass the monotonic gate), and

+    pub fn production(current_version: u64, now_ms: u64) -> VerifyPolicy {

+            &spt_store::perch::identity_dir().join("release-keys.json"),

+    /// Load from an explicit keys file (tests; the pump E2E points each

+    /// in-process "node" at its own). Unparseable key hex is skipped loud —

+    /// a typo must not silently widen or narrow the trusted set unnoticed.

+    pub fn load_from(path: &std::path::Path, current_version: u64, now_ms: u64) -> VerifyPolicy {

+        let file: ReleaseKeysFile = std::fs::read_to_string(path)

+            .and_then(|raw| serde_json::from_str(&raw).ok())

+        let mut trusted_keys = BTreeMap::new();

+        for (key_id, hex) in &file.keys {

+            match parse_verifying_key(hex) {

+                    trusted_keys.insert(key_id.clone(), vk);

+                None => eprintln!("RELEASE_KEY_BAD:{key_id} — skipped (not a valid key)"),

+            revoked_keys: file.revoked,

+            pinned_channel: file.channel.unwrap_or_else(|| DEFAULT_CHANNEL.to_string()),

+/// Parse a lowercase-hex ed25519 verifying key.

+fn parse_verifying_key(hex: &str) -> Option<VerifyingKey> {

+    if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {

+    for (i, b) in bytes.iter_mut().enumerate() {

+    VerifyingKey::from_bytes(&bytes).ok()


Bash

sed -n '78,95p' crates/spt-daemon/src/relcache.rs; grep -n "mod tests" crates/spt-daemon/src/release.rs

relcache staged + release tests mod location


    /// the artifact file is missing (a torn stage never offers) — degrade,
    /// don't die.
    // [impl->REQ-UPD-1]
    pub fn staged(&self) -> Option<SignedRelease> {
        let signed: SignedRelease =
            serde_json::from_str(&std::fs::read_to_string(self.release_path()).ok()?).ok()?;
        if self.artifact_path().exists() {
            Some(signed)
        } else {
            None
        }
    }

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


Edit

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

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

    /// The staged release's version, `None` when nothing is staged (or the
    /// staged metadata doesn't parse — same degrade posture as [`staged`]).
    /// The update pump's `current_version` floor: only strictly-newer offers
    /// pass the monotonic gate (REQ-HAZARD-UPDATE-ROLLBACK).
    // [impl->REQ-UPD-1]
    pub fn staged_version(&self) -> Option<u64> {
        let signed = self.staged()?;
        let meta: serde_json::Value = serde_json::from_str(&signed.metadata_json).ok()?;
        meta.get("version").and_then(|v| v.as_u64())
    }
}


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


88	        }
89	    }
90
91	    /// The staged artifact bytes (for serving / re-verification).
92	    pub fn load_artifact(&self) -> io::Result<Vec<u8>> {
93	        std::fs::read(self.artifact_path())
94	    }
95	}
96
97	#[cfg(test)]
98	mod tests {
99	    use super::*;

        }
    }

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

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


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

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

    /// The staged release's version, `None` when nothing is staged (or the
    /// staged metadata doesn't parse — same degrade posture as
    /// [`staged`](Self::staged)). The update pump's `current_version` floor:
    /// only strictly-newer offers pass the monotonic gate
    /// (REQ-HAZARD-UPDATE-ROLLBACK).
    // [impl->REQ-UPD-1]
    pub fn staged_version(&self) -> Option<u64> {
        let signed = self.staged()?;
        let meta: serde_json::Value = serde_json::from_str(&signed.metadata_json).ok()?;
        meta.get("version").and_then(|v| v.as_u64())
    }
}


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\relcache.rs has been updated successfully. (file state is current in your context — no need to Read it back)

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

    /// The staged release's version, `None` when nothing is staged (or the
    /// staged metadata doesn't parse — same degrade posture as
    /// [`staged`](Self::staged)). The update pump's `current_version` floor:
    /// only strictly-newer offers pass the monotonic gate
    /// (REQ-HAZARD-UPDATE-ROLLBACK).
    // [impl->REQ-UPD-1]
    pub fn staged_version(&self) -> Option<u64> {
        let signed = self.staged()?;
        let meta: serde_json::Value = serde_json::from_str(&signed.metadata_json).ok()?;
        meta.get("version").and_then(|v| v.as_u64())
    }
}

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

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

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

use crate::release::SignedRelease;

/// File name of the staged release's signed metadata (the commit point).
const RELEASE_FILE: &str = "release.json";
/// File name of the staged artifact bytes.
const ARTIFACT_FILE: &str = "artifact.bin";

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

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

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

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

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

    /// The staged release's signed metadata, if a complete pair is present.
    /// `None` when nothing is staged, the metadata is unreadable/corrupt, or
    /// the artifact file is missing (a torn stage never offers) — degrade,
    /// don't die.
    // [impl->REQ-UPD-1]
    pub fn staged(&self) -> Option<SignedRelease> {
        let signed: SignedRelease =
            serde_json::from_str(&std::fs::read_to_string(self.release_path()).ok()?).ok()?;
        if self.artifact_path().exists() {
            Some(signed)
        } else {
            None
        }
    }

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

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

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

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

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

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

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

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

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

+    /// The staged release's version, `None` when nothing is staged (or the

+    /// staged metadata doesn't parse — same degrade posture as

+    /// [`staged`](Self::staged)). The update pump's `current_version` floor:

+    /// only strictly-newer offers pass the monotonic gate

+    pub fn staged_version(&self) -> Option<u64> {

+        let meta: serde_json::Value = serde_json::from_str(&signed.metadata_json).ok()?;

+        meta.get("version").and_then(|v| v.as_u64())


Now release.rs loader unit tests:


365	mod tests {
366	    use super::*;
367	    use crate::update::BROKER_RESOURCE_ABI;
368	    use ed25519_dalek::{Signer, SigningKey};
369
370	    const KEY_ID: &str = "rel-2026";
371	    const CHANNEL: &str = "stable";
372
373	    /// A deterministic signing key (no RNG — test-stable). Real release tooling
374	    /// mints one from a CSPRNG; verification is identical either way.
375	    fn signing_key(seed: u8) -> SigningKey {
376	        SigningKey::from_bytes(&[seed; 32])
377	    }
378
379	    /// Build + sign a release for `artifact`, returning the wire form.
380	    fn sign_release(sk: &SigningKey, meta: &ReleaseMetadata) -> SignedRelease {
381	        let metadata_json = serde_json::to_string(meta).unwrap();
382	        let sig = sk.sign(metadata_json.as_bytes());
383	        SignedRelease { metadata_json, signature_hex: hex_encode(&sig.to_bytes()) }
384	    }
385
386	    fn meta_for(version: u64, artifact: &[u8]) -> ReleaseMetadata {
387	        ReleaseMetadata {
388	            version,
389	            channel: CHANNEL.to_string(),
390	            expires_at_ms: 10_000,
391	            key_id: KEY_ID.to_string(),
392	            artifact_sha256: sha256_hex(artifact),
393	            brain_ipc_version: 1,
394	            broker_resource_abi: BROKER_RESOURCE_ABI,
395	        }
396	    }
397
398	    fn policy(sk: &SigningKey) -> VerifyPolicy {
399	        let mut trusted = BTreeMap::new();

mod tests {
    use super::*;
    use crate::update::BROKER_RESOURCE_ABI;
    use ed25519_dalek::{Signer, SigningKey};

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

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

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

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

    fn policy(sk: &SigningKey) -> VerifyPolicy {
        let mut trusted = BTreeMap::new();


D9-1: production trigger loops + VerifyPolicy + spt send WAN CLI

peerloop.rs pump host (sync/update/notif/reconcile thin calls over existing drivers), DaemonConfig knobs, production VerifyPolicy assembly, spt send WAN leg, loopback E2E proving loops fire undriven. Gotchas #1/#9.

D9-2: resting-state model + transition echo + wake-edge resurface

State machine (active→dormant→suspended, wake), Status::Suspended, transition echo commune (KH 3.3), resurface_at_boundary(Wake), freshness pull trigger. REQ-INST-3/4 → [impl,unit]; KH 7.4 if multi-agent echo scheduling.

D9-3: dormancy budget measurement + warm/cold policy lock

SPT_BUDGET=1 harness on real adapters (both rig hosts), docs/DORMANCY-BUDGET.md, policy lock, ADR-0003 #9 closed. doc→REQ-INST-3.

D9-4: REQ-INST-14 resource advertisement

resources field on Instance, spt resources set/show/list projection (visibility-gated). [doc,impl,unit].

D9-5: REQ-INST-15 immutable home subnet + spt fork

Home-at-creation (auto/ask/local-until-join, no setter), sync_subnets=[home] seed, adapter-at-creation rule, spt fork copy-then-diverge (--delete-source; same-node v1). [doc,impl,unit].

D9-6: two-host E2E + runbook + gh-repo interim retirement

SPT_TWO_HOST=1 int tests (full ladder), docs/TWO-HOST-RUNBOOK.md, run on rig, int stage activations, retire gh-repo-sync interim.

D9-7: closeout sweep

required_stages audit, traceable-reqs green, ROADMAP M4-delivered, CONTEXT amendments, plan-status COMPLETE, CI matrix FINAL green.


    fn policy(sk: &SigningKey) -> VerifyPolicy {
        let mut trusted = BTreeMap::new();

    // [unit->REQ-UPD-2] the production keys file loads: valid keys trusted,
    // bad hex skipped, channel pinned; a missing file = EMPTY trusted set
    // (fail-closed — an unprovisioned node accepts nothing).
    #[test]
    fn production_policy_loads_fail_closed() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("release-keys.json");

        // Missing file: empty trusted set, default channel.
        let p = VerifyPolicy::load_from(&path, 3, 1_000);
        assert!(p.trusted_keys.is_empty(), "no file => trust nobody");
        assert_eq!(p.pinned_channel, DEFAULT_CHANNEL);
        assert_eq!((p.current_version, p.now_ms), (3, 1_000));

        // A real key + a malformed one + a revocation + a channel pin.
        let vk = signing_key(9).verifying_key();
        let json = serde_json::json!({
            "keys": { "good": hex_encode(vk.as_bytes()), "bad": "zz-not-hex" },
            "revoked": ["old-2024"],
            "channel": "beta",
        });
        std::fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();
        let p = VerifyPolicy::load_from(&path, 5, 2_000);
        assert_eq!(p.trusted_keys.len(), 1, "bad hex skipped, good key kept");
        assert!(p.trusted_keys.contains_key("good"));
        assert!(p.revoked_keys.contains("old-2024"));
        assert_eq!(p.pinned_channel, "beta");

        // Malformed file degrades to fail-closed, never a panic.
        std::fs::write(&path, "not json {{{").unwrap();
        assert!(VerifyPolicy::load_from(&path, 0, 0).trusted_keys.is_empty());
    }

    fn policy(sk: &SigningKey) -> VerifyPolicy {
        let mut trusted = BTreeMap::new();


    // [unit->REQ-UPD-2] the production keys file loads: valid keys trusted,
    // bad hex skipped, channel pinned; a missing file = EMPTY trusted set
    // (fail-closed — an unprovisioned node accepts nothing).
    #[test]
    fn production_policy_loads_fail_closed() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("release-keys.json");

        // Missing file: empty trusted set, default channel.
        let p = VerifyPolicy::load_from(&path, 3, 1_000);
        assert!(p.trusted_keys.is_empty(), "no file => trust nobody");
        assert_eq!(p.pinned_channel, DEFAULT_CHANNEL);
        assert_eq!((p.current_version, p.now_ms), (3, 1_000));

        // A real key + a malformed one + a revocation + a channel pin.
        let vk = signing_key(9).verifying_key();
        let json = serde_json::json!({
            "keys": { "good": hex_encode(vk.as_bytes()), "bad": "zz-not-hex" },
            "revoked": ["old-2024"],
            "channel": "beta",
        });
        std::fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();
        let p = VerifyPolicy::load_from(&path, 5, 2_000);
        assert_eq!(p.trusted_keys.len(), 1, "bad hex skipped, good key kept");
        assert!(p.trusted_keys.contains_key("good"));
        assert!(p.revoked_keys.contains("old-2024"));
        assert_eq!(p.pinned_channel, "beta");

        // Malformed file degrades to fail-closed, never a panic.
        std::fs::write(&path, "not json {{{").unwrap();
        assert!(VerifyPolicy::load_from(&path, 0, 0).trusted_keys.is_empty());
    }

    fn policy(sk: &SigningKey) -> VerifyPolicy {
        let mut trusted = BTreeMap::new();

+    // [unit->REQ-UPD-2] the production keys file loads: valid keys trusted,

+    // bad hex skipped, channel pinned; a missing file = EMPTY trusted set

+    // (fail-closed — an unprovisioned node accepts nothing).

+    fn production_policy_loads_fail_closed() {

+        let dir = tempfile::tempdir().unwrap();

+        let path = dir.path().join("release-keys.json");

+        // Missing file: empty trusted set, default channel.

+        let p = VerifyPolicy::load_from(&path, 3, 1_000);

+        assert!(p.trusted_keys.is_empty(), "no file => trust nobody");

+        assert_eq!(p.pinned_channel, DEFAULT_CHANNEL);

+        assert_eq!((p.current_version, p.now_ms), (3, 1_000));

+        // A real key + a malformed one + a revocation + a channel pin.

+        let vk = signing_key(9).verifying_key();

+            "keys": { "good": hex_encode(vk.as_bytes()), "bad": "zz-not-hex" },

+        std::fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();

+        let p = VerifyPolicy::load_from(&path, 5, 2_000);

+        assert_eq!(p.trusted_keys.len(), 1, "bad hex skipped, good key kept");

+        assert!(p.trusted_keys.contains_key("good"));

+        assert!(p.revoked_keys.contains("old-2024"));

+        assert_eq!(p.pinned_channel, "beta");

+        // Malformed file degrades to fail-closed, never a panic.

+        std::fs::write(&path, "not json {{{").unwrap();

+        assert!(VerifyPolicy::load_from(&path, 0, 0).trusted_keys.is_empty());


Now the pump's update leg in peerloop.rs:


use crate::brain::Brain;
use crate::config::DaemonConfig;
use crate::notifsync::emit_notif_feed;
use crate::registryhost::RegistryHost;
use crate::sync::{request_sync, select_refs, SyncPolicy, SyncPullReport};

use crate::brain::Brain;
use crate::config::DaemonConfig;
use crate::consent::{decide, most_recently_active};
use crate::notif::{produce_consent_notif, NotifSurfacePolicy};
use crate::notifsync::emit_notif_feed;
use crate::propagate::{request_update, UpdatePullOutcome};
use crate::registryhost::RegistryHost;
use crate::relcache::ReleaseCache;
use crate::release::VerifyPolicy;
use crate::sync::{request_sync, select_refs, SyncPolicy, SyncPullReport};
use crate::update::BrokerAbi;


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\peerloop.rs has been updated successfully. (file state is current in your context — no need to Read it back)

use crate::brain::Brain;
use crate::config::DaemonConfig;
use crate::notifsync::emit_notif_feed;
use crate::registryhost::RegistryHost;
use crate::sync::{request_sync, select_refs, SyncPolicy, SyncPullReport};

use crate::brain::Brain;
use crate::config::DaemonConfig;
use crate::consent::{decide, most_recently_active};
use crate::notif::{produce_consent_notif, NotifSurfacePolicy};
use crate::notifsync::emit_notif_feed;
use crate::propagate::{request_update, UpdatePullOutcome};
use crate::registryhost::RegistryHost;
use crate::relcache::ReleaseCache;
use crate::release::VerifyPolicy;
use crate::sync::{request_sync, select_refs, SyncPolicy, SyncPullReport};
use crate::update::BrokerAbi;

/// The pump's tick granularity — each cadence fires when its period elapsed.

const TICK: Duration = Duration::from_millis(200);


    /// The pump's durable open-op counter (its own file — colliding with the
    /// registry's advertisement epochs would break neither, but a shared
    /// in-memory cursor pair over one file double-mints; separate files keep
    /// each `EpochSource` the sole writer of its counter).
    pub ops: PathBuf,
}

    /// The pump's durable open-op counter (its own file — colliding with the
    /// registry's advertisement epochs would break neither, but a shared
    /// in-memory cursor pair over one file double-mints; separate files keep
    /// each `EpochSource` the sole writer of its counter).
    pub ops: PathBuf,
    /// The staged-release cache dir ([`ReleaseCache::open`]).
    pub releases: PathBuf,
    /// `release-keys.json` ([`VerifyPolicy::load_from`]).
    pub release_keys: PathBuf,
}


    /// The pump's durable open-op counter (its own file — colliding with the
    /// registry's advertisement epochs would break neither, but a shared
    /// in-memory cursor pair over one file double-mints; separate files keep
    /// each `EpochSource` the sole writer of its counter).
    pub ops: PathBuf,
}

    /// The pump's durable open-op counter (its own file — colliding with the
    /// registry's advertisement epochs would break neither, but a shared
    /// in-memory cursor pair over one file double-mints; separate files keep
    /// each `EpochSource` the sole writer of its counter).
    pub ops: PathBuf,
    /// The staged-release cache dir ([`ReleaseCache::open`]).
    pub releases: PathBuf,
    /// `release-keys.json` ([`VerifyPolicy::load_from`]).
    pub release_keys: PathBuf,
}

+    /// The staged-release cache dir ([`ReleaseCache::open`]).

+    /// `release-keys.json` ([`VerifyPolicy::load_from`]).


            vis: identity.join("visibility.json"),
            ops: identity.join("pump-ops.json"),
        }
    }
}

            vis: identity.join("visibility.json"),
            ops: identity.join("pump-ops.json"),
            releases: home.join("releases"),
            release_keys: identity.join("release-keys.json"),
        }
    }
}


            vis: identity.join("visibility.json"),
            ops: identity.join("pump-ops.json"),
        }
    }
}

            vis: identity.join("visibility.json"),
            ops: identity.join("pump-ops.json"),
            releases: home.join("releases"),
            release_keys: identity.join("release-keys.json"),
        }
    }
}

+            releases: home.join("releases"),

+            release_keys: identity.join("release-keys.json"),


/// Pump cadences (config-driven in production: [`DaemonConfig`]).
#[derive(Debug, Clone, Copy)]
pub struct PumpCadence {
    pub registry: Duration,
    pub notif: Duration,
    pub sync: Duration,
}

impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
        }
    }
}

/// Pump cadences (config-driven in production: [`DaemonConfig`]).
#[derive(Debug, Clone, Copy)]
pub struct PumpCadence {
    pub registry: Duration,
    pub notif: Duration,
    pub sync: Duration,
    pub update: Duration,
}

impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
            update: cfg.update_check_period,
        }
    }
}


/// Pump cadences (config-driven in production: [`DaemonConfig`]).
#[derive(Debug, Clone, Copy)]
pub struct PumpCadence {
    pub registry: Duration,
    pub notif: Duration,
    pub sync: Duration,
}

impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
        }
    }
}

/// Pump cadences (config-driven in production: [`DaemonConfig`]).
#[derive(Debug, Clone, Copy)]
pub struct PumpCadence {
    pub registry: Duration,
    pub notif: Duration,
    pub sync: Duration,
    pub update: Duration,
}

impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
            update: cfg.update_check_period,
        }
    }
}

pub registry: Duration,
pub notif: Duration,
pub sync: Duration,
+    pub update: Duration,
}
impl PumpCadence {

+            update: cfg.update_check_period,


pub fn run_peer_pump(
    broker_name: &str,
    registry: Arc<RegistryHost>,
    paths: &PumpPaths,
    resolver: PeerResolver,
    cadence: PumpCadence,
    mut hooks: PumpHooks,
    stop: &AtomicBool,
) -> io::Result<()> {
    let mut brain = connect(broker_name)?;
    let mut ops = EpochSource::load_from(&paths.ops);
    let mut conns: HashMap<String, u64> = HashMap::new();
    // Stagger from "everything due now" — the first tick primes peers.
    let far = Instant::now() - Duration::from_secs(86_400);
    let (mut last_reg, mut last_notif, mut last_sync) = (far, far, far);

    while !stop.load(Ordering::Relaxed) {
        let now = Instant::now();
        let due_reg = now.duration_since(last_reg) >= cadence.registry;
        let due_notif = now.duration_since(last_notif) >= cadence.notif;
        let due_sync = now.duration_since(last_sync) >= cadence.sync;
        if !(due_reg || due_notif || due_sync) {
            thread::sleep(TICK);
            continue;
        }

pub fn run_peer_pump(
    broker_name: &str,
    registry: Arc<RegistryHost>,
    paths: &PumpPaths,
    resolver: PeerResolver,
    cadence: PumpCadence,
    full_auto_update: bool,
    mut hooks: PumpHooks,
    stop: &AtomicBool,
) -> io::Result<()> {
    let mut brain = connect(broker_name)?;
    let mut ops = EpochSource::load_from(&paths.ops);
    let mut conns: HashMap<String, u64> = HashMap::new();
    // Stagger from "everything due now" — the first tick primes peers.
    let far = Instant::now() - Duration::from_secs(86_400);
    let (mut last_reg, mut last_notif, mut last_sync, mut last_upd) = (far, far, far, far);

    while !stop.load(Ordering::Relaxed) {
        let now = Instant::now();
        let due_reg = now.duration_since(last_reg) >= cadence.registry;
        let due_notif = now.duration_since(last_notif) >= cadence.notif;
        let due_sync = now.duration_since(last_sync) >= cadence.sync;
        let due_upd = now.duration_since(last_upd) >= cadence.update;
        if !(due_reg || due_notif || due_sync || due_upd) {
            thread::sleep(TICK);
            continue;
        }


pub fn run_peer_pump(
    broker_name: &str,
    registry: Arc<RegistryHost>,
    paths: &PumpPaths,
    resolver: PeerResolver,
    cadence: PumpCadence,
    mut hooks: PumpHooks,
    stop: &AtomicBool,
) -> io::Result<()> {
    let mut brain = connect(broker_name)?;
    let mut ops = EpochSource::load_from(&paths.ops);
    let mut conns: HashMap<String, u64> = HashMap::new();
    // Stagger from "everything due now" — the first tick primes peers.
    let far = Instant::now() - Duration::from_secs(86_400);
    let (mut last_reg, mut last_notif, mut last_sync) = (far, far, far);

    while !stop.load(Ordering::Relaxed) {
        let now = Instant::now();
        let due_reg = now.duration_since(last_reg) >= cadence.registry;
        let due_notif = now.duration_since(last_notif) >= cadence.notif;
        let due_sync = now.duration_since(last_sync) >= cadence.sync;
        if !(due_reg || due_notif || due_sync) {
            thread::sleep(TICK);
            continue;
        }

pub fn run_peer_pump(
    broker_name: &str,
    registry: Arc<RegistryHost>,
    paths: &PumpPaths,
    resolver: PeerResolver,
    cadence: PumpCadence,
    full_auto_update: bool,
    mut hooks: PumpHooks,
    stop: &AtomicBool,
) -> io::Result<()> {
    let mut brain = connect(broker_name)?;
    let mut ops = EpochSource::load_from(&paths.ops);
    let mut conns: HashMap<String, u64> = HashMap::new();
    // Stagger from "everything due now" — the first tick primes peers.
    let far = Instant::now() - Duration::from_secs(86_400);
    let (mut last_reg, mut last_notif, mut last_sync, mut last_upd) = (far, far, far, far);

    while !stop.load(Ordering::Relaxed) {
        let now = Instant::now();
        let due_reg = now.duration_since(last_reg) >= cadence.registry;
        let due_notif = now.duration_since(last_notif) >= cadence.notif;
        let due_sync = now.duration_since(last_sync) >= cadence.sync;
        let due_upd = now.duration_since(last_upd) >= cadence.update;
        if !(due_reg || due_notif || due_sync || due_upd) {
            thread::sleep(TICK);
            continue;
        }

paths: &PumpPaths,
resolver: PeerResolver,
cadence: PumpCadence,
+    full_auto_update: bool,
mut hooks: PumpHooks,
stop: &AtomicBool,
) -> io::Result<()> {

-    let (mut last_reg, mut last_notif, mut last_sync) = (far, far, far);

+    let (mut last_reg, mut last_notif, mut last_sync, mut last_upd) = (far, far, far, far);

-        if !(due_reg || due_notif || due_sync) {

+        let due_upd = now.duration_since(last_upd) >= cadence.update;

+        if !(due_reg || due_notif || due_sync || due_upd) {


                        if !want.is_empty() {
                            let open_op = ops.next_epoch()?;
                            let report = request_sync(
                                &mut brain,
                                conn_id,
                                &want,
                                open_op,
                                &cs,
                                &paths.scratch,
                            )?;
                            (hooks.on_pull)(&peer.pubkey_hex, &report);
                        }
                    }
                    Ok(())
                };

                        if !want.is_empty() {
                            let open_op = ops.next_epoch()?;
                            let report = request_sync(
                                &mut brain,
                                conn_id,
                                &want,
                                open_op,
                                &cs,
                                &paths.scratch,
                            )?;
                            (hooks.on_pull)(&peer.pubkey_hex, &report);
                        }
                    }
                    if due_upd {
                        // The peer-propagation pull (REQ-UPD-1) under the
                        // PRODUCTION gate: keys + channel pin from
                        // release-keys.json (fail-closed when unprovisioned),
                        // version floor = the staged release (rollback gate),
                        // wall clock for expiry only.
                        let cache = ReleaseCache::open(&paths.releases);
                        let current = cache.staged_version().unwrap_or(0);
                        let policy =
                            VerifyPolicy::load_from(&paths.release_keys, current, now_ms());
                        if !policy.trusted_keys.is_empty() {
                            let open_op = ops.next_epoch()?;
                            match request_update(
                                &mut brain,
                                conn_id,
                                open_op,
                                &BrokerAbi::current(),
                                &policy,
                                &cache,
                                &paths.scratch,
                            )? {
                                UpdatePullOutcome::Updated(plan) => {
                                    let version = cache
                                        .staged_version()
                                        .map(|v| v.to_string())
                                        .unwrap_or_else(|| "?".to_string());
                                    eprintln!(
                                        "UPDATE_STAGED:{version}:{:?} (from {})",
                                        plan.class, peer.pubkey_hex
                                    );
                                    // REQ-UPD-4: gated on user confirmation by
                                    // default — the staged release surfaces as
                                    // a consent notif (D8's producer); apply
                                    // rides the existing self-update machinery
                                    // once confirmed. Full-auto writes nothing.
                                    let decision = decide(
                                        full_auto_update,
                                        most_recently_active(&paths.owlery),
                                    );
                                    let surface = NotifSurfacePolicy {
                                        subnets: subnets
                                            .subnets
                                            .iter()
                                            .map(|s| (s.name.clone(), s.hide_new_endpoints))
                                            .collect(),
                                        vis: VisibilityStore::load_from(&paths.vis),
                                    };
                                    let store = NotifStore::open_at(&paths.notif_db)
                                        .map_err(|e| io::Error::other(e.to_string()))?;
                                    if let Err(e) = produce_consent_notif(
                                        &decision,
                                        &version,
                                        &store,
                                        registry.node_hex(),
                                        &mut ops,
                                        &sub.name,
                                        &surface,
                                        &paths.owlery,
                                        now_ms(),
                                    ) {
                                        eprintln!("UPDATE_NOTIF_FAIL:{e}");
                                    }
                                }
                                UpdatePullOutcome::UpToDate => {}
                                UpdatePullOutcome::Rejected(reason) => {
                                    // Loud + typed, never silent (ADR-0004 §D).
                                    eprintln!(
                                        "UPDATE_REJECTED:{}:{reason}",
                                        peer.pubkey_hex
                                    );
                                }
                            }
                        }
                    }
                    Ok(())
                };


                        if !want.is_empty() {
                            let open_op = ops.next_epoch()?;
                            let report = request_sync(
                                &mut brain,
                                conn_id,
                                &want,
                                open_op,
                                &cs,
                                &paths.scratch,
                            )?;
                            (hooks.on_pull)(&peer.pubkey_hex, &report);
                        }
                    }
                    Ok(())
                };

                        if !want.is_empty() {
                            let open_op = ops.next_epoch()?;
                            let report = request_sync(
                                &mut brain,
                                conn_id,
                                &want,
                                open_op,
                                &cs,
                                &paths.scratch,
                            )?;
                            (hooks.on_pull)(&peer.pubkey_hex, &report);
                        }
                    }
                    if due_upd {
                        // The peer-propagation pull (REQ-UPD-1) under the
                        // PRODUCTION gate: keys + channel pin from
                        // release-keys.json (fail-closed when unprovisioned),
                        // version floor = the staged release (rollback gate),
                        // wall clock for expiry only.
                        let cache = ReleaseCache::open(&paths.releases);
                        let current = cache.staged_version().unwrap_or(0);
                        let policy =
                            VerifyPolicy::load_from(&paths.release_keys, current, now_ms());
                        if !policy.trusted_keys.is_empty() {
                            let open_op = ops.next_epoch()?;
                            match request_update(
                                &mut brain,
                                conn_id,
                                open_op,
                                &BrokerAbi::current(),
                                &policy,
                                &cache,
                                &paths.scratch,
                            )? {
                                UpdatePullOutcome::Updated(plan) => {
                                    let version = cache
                                        .staged_version()
                                        .map(|v| v.to_string())
                                        .unwrap_or_else(|| "?".to_string());
                                    eprintln!(
                                        "UPDATE_STAGED:{version}:{:?} (from {})",
                                        plan.class, peer.pubkey_hex
                                    );
                                    // REQ-UPD-4: gated on user confirmation by
                                    // default — the staged release surfaces as
                                    // a consent notif (D8's producer); apply
                                    // rides the existing self-update machinery
                                    // once confirmed. Full-auto writes nothing.
                                    let decision = decide(
                                        full_auto_update,
                                        most_recently_active(&paths.owlery),
                                    );
                                    let surface = NotifSurfacePolicy {
                                        subnets: subnets
                                            .subnets
                                            .iter()
                                            .map(|s| (s.name.clone(), s.hide_new_endpoints))
                                            .collect(),
                                        vis: VisibilityStore::load_from(&paths.vis),
                                    };
                                    let store = NotifStore::open_at(&paths.notif_db)
                                        .map_err(|e| io::Error::other(e.to_string()))?;
                                    if let Err(e) = produce_consent_notif(
                                        &decision,
                                        &version,
                                        &store,
                                        registry.node_hex(),
                                        &mut ops,
                                        &sub.name,
                                        &surface,
                                        &paths.owlery,
                                        now_ms(),
                                    ) {
                                        eprintln!("UPDATE_NOTIF_FAIL:{e}");
                                    }
                                }
                                UpdatePullOutcome::UpToDate => {}
                                UpdatePullOutcome::Rejected(reason) => {
                                    // Loud + typed, never silent (ADR-0004 §D).
                                    eprintln!(
                                        "UPDATE_REJECTED:{}:{reason}",
                                        peer.pubkey_hex
                                    );
                                }
                            }
                        }
                    }
                    Ok(())
                };

+                        // The peer-propagation pull (REQ-UPD-1) under the

+                        // PRODUCTION gate: keys + channel pin from

+                        // release-keys.json (fail-closed when unprovisioned),

+                        // version floor = the staged release (rollback gate),

+                        // wall clock for expiry only.

+                        let cache = ReleaseCache::open(&paths.releases);

+                        let current = cache.staged_version().unwrap_or(0);

+                            VerifyPolicy::load_from(&paths.release_keys, current, now_ms());

+                        if !policy.trusted_keys.is_empty() {

+                            let open_op = ops.next_epoch()?;

+                            match request_update(

+                                &mut brain,

+                                conn_id,

+                                open_op,

+                                &BrokerAbi::current(),

+                                &policy,

+                                &cache,

+                                &paths.scratch,

+                                UpdatePullOutcome::Updated(plan) => {

+                                    let version = cache

+                                        .staged_version()

+                                        .map(|v| v.to_string())

+                                        .unwrap_or_else(|| "?".to_string());

+                                    eprintln!(

+                                        "UPDATE_STAGED:{version}:{:?} (from {})",

+                                        plan.class, peer.pubkey_hex

+                                    // REQ-UPD-4: gated on user confirmation by

+                                    // default — the staged release surfaces as

+                                    // a consent notif (D8's producer); apply

+                                    // rides the existing self-update machinery

+                                    // once confirmed. Full-auto writes nothing.

+                                    let decision = decide(

+                                        full_auto_update,

+                                        most_recently_active(&paths.owlery),

+                                    let surface = NotifSurfacePolicy {

+                                        subnets: subnets

+                                            .subnets

+                                            .iter()

+                                            .map(|s| (s.name.clone(), s.hide_new_endpoints))

+                                            .collect(),

+                                        vis: VisibilityStore::load_from(&paths.vis),

+                                    let store = NotifStore::open_at(&paths.notif_db)

+                                        .map_err(|e| io::Error::other(e.to_string()))?;

+                                    if let Err(e) = produce_consent_notif(

+                                        &decision,

+                                        &version,

+                                        &store,

+                                        registry.node_hex(),

+                                        &mut ops,

+                                        &sub.name,

+                                        &surface,

+                                        &paths.owlery,

+                                        now_ms(),

+                                        eprintln!("UPDATE_NOTIF_FAIL:{e}");

+                                UpdatePullOutcome::UpToDate => {}

+                                UpdatePullOutcome::Rejected(reason) => {

+                                    // Loud + typed, never silent (ADR-0004 §D).

+                                    eprintln!(

+                                        "UPDATE_REJECTED:{}:{reason}",

+                                        peer.pubkey_hex


        let now = Instant::now();
        if due_reg {
            last_reg = now;
        }
        if due_notif {
            last_notif = now;
        }
        if due_sync {
            last_sync = now;
        }
    }
    Ok(())
}

        let now = Instant::now();
        if due_reg {
            last_reg = now;
        }
        if due_notif {
            last_notif = now;
        }
        if due_sync {
            last_sync = now;
        }
        if due_upd {
            last_upd = now;
        }
    }
    Ok(())
}


        let now = Instant::now();
        if due_reg {
            last_reg = now;
        }
        if due_notif {
            last_notif = now;
        }
        if due_sync {
            last_sync = now;
        }
    }
    Ok(())
}

        let now = Instant::now();
        if due_reg {
            last_reg = now;
        }
        if due_notif {
            last_notif = now;
        }
        if due_sync {
            last_sync = now;
        }
        if due_upd {
            last_upd = now;
        }
    }
    Ok(())
}

if due_sync {
last_sync = now;
}
+        if due_upd {
+            last_upd = now;
+        }
}
Ok(())
}


    thread::spawn(move || {
        let paths = PumpPaths::production();
        if let Err(e) = run_peer_pump(
            &broker_name,
            registry,
            &paths,
            production_resolver(),
            PumpCadence::from_config(&cfg),
            PumpHooks::default(),
            &stop,
        ) {
            eprintln!("PEER_PUMP_FAIL: {e}");
        }
    })

    thread::spawn(move || {
        let paths = PumpPaths::production();
        if let Err(e) = run_peer_pump(
            &broker_name,
            registry,
            &paths,
            production_resolver(),
            PumpCadence::from_config(&cfg),
            cfg.full_auto_update,
            PumpHooks::default(),
            &stop,
        ) {
            eprintln!("PEER_PUMP_FAIL: {e}");
        }
    })


    thread::spawn(move || {
        let paths = PumpPaths::production();
        if let Err(e) = run_peer_pump(
            &broker_name,
            registry,
            &paths,
            production_resolver(),
            PumpCadence::from_config(&cfg),
            PumpHooks::default(),
            &stop,
        ) {
            eprintln!("PEER_PUMP_FAIL: {e}");
        }
    })

    thread::spawn(move || {
        let paths = PumpPaths::production();
        if let Err(e) = run_peer_pump(
            &broker_name,
            registry,
            &paths,
            production_resolver(),
            PumpCadence::from_config(&cfg),
            cfg.full_auto_update,
            PumpHooks::default(),
            &stop,
        ) {
            eprintln!("PEER_PUMP_FAIL: {e}");
        }
    })


D9-7: closeout sweep


<task-notification>
<task-id>bo89ajpsv</task-id>
<tool-use-id>toolu_01Ae12gTFAF92v9WTq1uga6h</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bo89ajpsv.output</output-file>
<status>completed</status>
<summary>Background command "Poll CI until FINAL for 822d3ce" completed (exit code 0)</summary>
</task-notification>


Updating the pump E2E for the update leg:


use spt_daemon::peerloop::{run_peer_pump, PeerResolver, PumpCadence, PumpHooks, PumpPaths};
use spt_daemon::registryhost::{RegistryGatePolicy, RegistryHost};

use spt_daemon::peerloop::{run_peer_pump, PeerResolver, PumpCadence, PumpHooks, PumpPaths};
use spt_daemon::registryhost::{RegistryGatePolicy, RegistryHost};
use spt_daemon::relcache::ReleaseCache;
use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease};
use spt_daemon::update::BROKER_RESOURCE_ABI;


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\peerloop.rs has been updated successfully. (file state is current in your context — no need to Read it back)

use spt_daemon::peerloop::{run_peer_pump, PeerResolver, PumpCadence, PumpHooks, PumpPaths};
use spt_daemon::registryhost::{RegistryGatePolicy, RegistryHost};
use spt_daemon::relcache::ReleaseCache;
use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease};
use spt_daemon::update::BROKER_RESOURCE_ABI;

+use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease};

use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};


    let cs_b = ContextStore::open_or_init().expect("B context store");
    let p = cs_b.live_context_path("ling-pumped").unwrap();
    std::fs::write(&p, stamped("a mind worth following")).unwrap();
    cs_b.commit_live("ling-pumped", "seed").unwrap();

    let cs_b = ContextStore::open_or_init().expect("B context store");
    let p = cs_b.live_context_path("ling-pumped").unwrap();
    std::fs::write(&p, stamped("a mind worth following")).unwrap();
    cs_b.commit_live("ling-pumped", "seed").unwrap();

    // B stages a v6 release (the canonical releases dir B's dispatcher serves
    // from); A's keys file will trust the signer.
    use ed25519_dalek::{Signer, SigningKey};
    let sk = SigningKey::from_bytes(&[5u8; 32]);
    let artifact = b"pump-propagated binary".to_vec();
    let meta = ReleaseMetadata {
        version: 6,
        channel: "stable".to_string(),
        expires_at_ms: u64::MAX,
        key_id: "k".to_string(),
        artifact_sha256: sha256_hex(&artifact),
        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
        broker_resource_abi: BROKER_RESOURCE_ABI,
    };
    let metadata_json = serde_json::to_string(&meta).unwrap();
    let sig = sk.sign(metadata_json.as_bytes());
    let signed = SignedRelease {
        metadata_json,
        signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
    };
    ReleaseCache::open(&perch::spt_home().join("releases"))
        .stage(&signed, &artifact)
        .expect("stage at B");


    let cs_b = ContextStore::open_or_init().expect("B context store");
    let p = cs_b.live_context_path("ling-pumped").unwrap();
    std::fs::write(&p, stamped("a mind worth following")).unwrap();
    cs_b.commit_live("ling-pumped", "seed").unwrap();

    // B stages a v6 release (the canonical releases dir B's dispatcher serves
    // from); A's keys file will trust the signer.
    use ed25519_dalek::{Signer, SigningKey};
    let sk = SigningKey::from_bytes(&[5u8; 32]);
    let artifact = b"pump-propagated binary".to_vec();
    let meta = ReleaseMetadata {
        version: 6,
        channel: "stable".to_string(),
        expires_at_ms: u64::MAX,
        key_id: "k".to_string(),
        artifact_sha256: sha256_hex(&artifact),
        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
        broker_resource_abi: BROKER_RESOURCE_ABI,
    };
    let metadata_json = serde_json::to_string(&meta).unwrap();
    let sig = sk.sign(metadata_json.as_bytes());
    let signed = SignedRelease {
        metadata_json,
        signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
    };
    ReleaseCache::open(&perch::spt_home().join("releases"))
        .stage(&signed, &artifact)
        .expect("stage at B");

+    // B stages a v6 release (the canonical releases dir B's dispatcher serves

+    // from); A's keys file will trust the signer.

+    use ed25519_dalek::{Signer, SigningKey};

+    let sk = SigningKey::from_bytes(&[5u8; 32]);

+    let artifact = b"pump-propagated binary".to_vec();

+        artifact_sha256: sha256_hex(&artifact),

+        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,

+        broker_resource_abi: BROKER_RESOURCE_ABI,

+    let metadata_json = serde_json::to_string(&meta).unwrap();

+    let sig = sk.sign(metadata_json.as_bytes());

+        signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),

+    ReleaseCache::open(&perch::spt_home().join("releases"))

let b_registry = Arc::new(RegistryHost::new_at(


Prompt:
<task-notification>
<task-id>bo89ajpsv</task-id>
<tool-use-id>toolu_01Ae12gTFAF92v9WTq1uga6h</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bo89ajpsv.output</output-file>
<status>completed</status>
<summary>Background command "Poll CI until FINAL for 822d3ce" completed (exit code 0)</summary>
</task-notification>


Bash

CI result for 822d3ce


FINAL:
ci success


Edit

        subnets: a_root.join("subnet.json"),
        trust: a_root.join("trust.json"),
        vis: a_root.join("visibility.json"),
        ops: a_root.join("pump-ops.json"),
    };

        subnets: a_root.join("subnet.json"),
        trust: a_root.join("trust.json"),
        vis: a_root.join("visibility.json"),
        ops: a_root.join("pump-ops.json"),
        releases: a_root.join("releases"),
        release_keys: a_root.join("release-keys.json"),
    };
    // A trusts B's release signer — the update leg's production gate.
    std::fs::create_dir_all(&a_root).unwrap();
    let keys_json = serde_json::json!({
        "keys": { "k": sk.verifying_key().as_bytes().iter()
            .map(|b| format!("{b:02x}")).collect::<String>() },
        "channel": "stable",
    });
    std::fs::write(&a_paths.release_keys, serde_json::to_string(&keys_json).unwrap()).unwrap();


        subnets: a_root.join("subnet.json"),
        trust: a_root.join("trust.json"),
        vis: a_root.join("visibility.json"),
        ops: a_root.join("pump-ops.json"),
    };

        subnets: a_root.join("subnet.json"),
        trust: a_root.join("trust.json"),
        vis: a_root.join("visibility.json"),
        ops: a_root.join("pump-ops.json"),
        releases: a_root.join("releases"),
        release_keys: a_root.join("release-keys.json"),
    };
    // A trusts B's release signer — the update leg's production gate.
    std::fs::create_dir_all(&a_root).unwrap();
    let keys_json = serde_json::json!({
        "keys": { "k": sk.verifying_key().as_bytes().iter()
            .map(|b| format!("{b:02x}")).collect::<String>() },
        "channel": "stable",
    });
    std::fs::write(&a_paths.release_keys, serde_json::to_string(&keys_json).unwrap()).unwrap();

+        releases: a_root.join("releases"),

+        release_keys: a_root.join("release-keys.json"),

+    // A trusts B's release signer — the update leg's production gate.

+    std::fs::create_dir_all(&a_root).unwrap();

+    let keys_json = serde_json::json!({

+        "keys": { "k": sk.verifying_key().as_bytes().iter()

+            .map(|b| format!("{b:02x}")).collect::<String>() },

+    std::fs::write(&a_paths.release_keys, serde_json::to_string(&keys_json).unwrap()).unwrap();

let perch_path = perch::resolve_perch_path_in(&a_paths.owlery, "pump-ag", ParentHint::Infer);

let rec = InfoJson::new("pump-ag", "now", std::process::id(), "s", "ready_agent");


        let paths = PumpPaths {
            owlery: a_paths.owlery.clone(),
            ctx_root: a_paths.ctx_root.clone(),
            notif_db: a_paths.notif_db.clone(),
            scratch: a_paths.scratch.clone(),
            subnets: a_paths.subnets.clone(),
            trust: a_paths.trust.clone(),
            vis: a_paths.vis.clone(),
            ops: a_paths.ops.clone(),
        };
        thread::spawn(move || {
            let cadence = PumpCadence {
                registry: Duration::from_millis(100),
                notif: Duration::from_millis(100),
                sync: Duration::from_millis(100),
            };
            let _ =
                run_peer_pump(&name, reg, &paths, resolver, cadence, PumpHooks::default(), &flag);
        });
    }

        let paths = PumpPaths {
            owlery: a_paths.owlery.clone(),
            ctx_root: a_paths.ctx_root.clone(),
            notif_db: a_paths.notif_db.clone(),
            scratch: a_paths.scratch.clone(),
            subnets: a_paths.subnets.clone(),
            trust: a_paths.trust.clone(),
            vis: a_paths.vis.clone(),
            ops: a_paths.ops.clone(),
            releases: a_paths.releases.clone(),
            release_keys: a_paths.release_keys.clone(),
        };
        thread::spawn(move || {
            let cadence = PumpCadence {
                registry: Duration::from_millis(100),
                notif: Duration::from_millis(100),
                sync: Duration::from_millis(100),
                update: Duration::from_millis(100),
            };
            let _ = run_peer_pump(
                &name,
                reg,
                &paths,
                resolver,
                cadence,
                false, // gated default (REQ-UPD-4) — the consent notif is the assert
                PumpHooks::default(),
                &flag,
            );
        });
    }


        let paths = PumpPaths {
            owlery: a_paths.owlery.clone(),
            ctx_root: a_paths.ctx_root.clone(),
            notif_db: a_paths.notif_db.clone(),
            scratch: a_paths.scratch.clone(),
            subnets: a_paths.subnets.clone(),
            trust: a_paths.trust.clone(),
            vis: a_paths.vis.clone(),
            ops: a_paths.ops.clone(),
        };
        thread::spawn(move || {
            let cadence = PumpCadence {
                registry: Duration::from_millis(100),
                notif: Duration::from_millis(100),
                sync: Duration::from_millis(100),
            };
            let _ =
                run_peer_pump(&name, reg, &paths, resolver, cadence, PumpHooks::default(), &flag);
        });
    }

        let paths = PumpPaths {
            owlery: a_paths.owlery.clone(),
            ctx_root: a_paths.ctx_root.clone(),
            notif_db: a_paths.notif_db.clone(),
            scratch: a_paths.scratch.clone(),
            subnets: a_paths.subnets.clone(),
            trust: a_paths.trust.clone(),
            vis: a_paths.vis.clone(),
            ops: a_paths.ops.clone(),
            releases: a_paths.releases.clone(),
            release_keys: a_paths.release_keys.clone(),
        };
        thread::spawn(move || {
            let cadence = PumpCadence {
                registry: Duration::from_millis(100),
                notif: Duration::from_millis(100),
                sync: Duration::from_millis(100),
                update: Duration::from_millis(100),
            };
            let _ = run_peer_pump(
                &name,
                reg,
                &paths,
                resolver,
                cadence,
                false, // gated default (REQ-UPD-4) — the consent notif is the assert
                PumpHooks::default(),
                &flag,
            );
        });
    }

+            releases: a_paths.releases.clone(),

+            release_keys: a_paths.release_keys.clone(),

+                update: Duration::from_millis(100),

-                run_peer_pump(&name, reg, &paths, resolver, cadence, PumpHooks::default(), &flag);

+                false, // gated default (REQ-UPD-4) — the consent notif is the assert


    // 3. The bootstrap pull: A now holds a mind it NEVER had locally — the
    //    want-ref came from A's registry, the serve gate ran at B.
    let cs_a = ContextStore::open_or_init_in(&a_paths.ctx_root).expect("A context store");
    converge("A pulled the registry-derived mind", || {
        cs_a.branch_store().tip("a-ling-pumped").ok().flatten().is_some()
    });

    stop.store(true, Ordering::Relaxed);
}

    // 3. The bootstrap pull: A now holds a mind it NEVER had locally — the
    //    want-ref came from A's registry, the serve gate ran at B.
    let cs_a = ContextStore::open_or_init_in(&a_paths.ctx_root).expect("A context store");
    converge("A pulled the registry-derived mind", || {
        cs_a.branch_store().tip("a-ling-pumped").ok().flatten().is_some()
    });

    // 4. The update leg: A pulled, gated, and staged B's v6 under the
    //    production keys file — and surfaced it as a CONSENT notif (REQ-UPD-4
    //    gated default), never a silent auto-apply.
    let cache_a = ReleaseCache::open(&a_paths.releases);
    converge("A staged the propagated release", || {
        cache_a.load_artifact().map(|b| b == artifact).unwrap_or(false)
    });
    converge("A holds the consent notif", || {
        a_store
            .list(subnet)
            .map(|rows| rows.iter().any(|r| r.kind == "consent"))
            .unwrap_or(false)
    });

    stop.store(true, Ordering::Relaxed);
}


    // 3. The bootstrap pull: A now holds a mind it NEVER had locally — the
    //    want-ref came from A's registry, the serve gate ran at B.
    let cs_a = ContextStore::open_or_init_in(&a_paths.ctx_root).expect("A context store");
    converge("A pulled the registry-derived mind", || {
        cs_a.branch_store().tip("a-ling-pumped").ok().flatten().is_some()
    });

    stop.store(true, Ordering::Relaxed);
}

    // 3. The bootstrap pull: A now holds a mind it NEVER had locally — the
    //    want-ref came from A's registry, the serve gate ran at B.
    let cs_a = ContextStore::open_or_init_in(&a_paths.ctx_root).expect("A context store");
    converge("A pulled the registry-derived mind", || {
        cs_a.branch_store().tip("a-ling-pumped").ok().flatten().is_some()
    });

    // 4. The update leg: A pulled, gated, and staged B's v6 under the
    //    production keys file — and surfaced it as a CONSENT notif (REQ-UPD-4
    //    gated default), never a silent auto-apply.
    let cache_a = ReleaseCache::open(&a_paths.releases);
    converge("A staged the propagated release", || {
        cache_a.load_artifact().map(|b| b == artifact).unwrap_or(false)
    });
    converge("A holds the consent notif", || {
        a_store
            .list(subnet)
            .map(|rows| rows.iter().any(|r| r.kind == "consent"))
            .unwrap_or(false)
    });

    stop.store(true, Ordering::Relaxed);
}

+    // 4. The update leg: A pulled, gated, and staged B's v6 under the

+    //    production keys file — and surfaced it as a CONSENT notif (REQ-UPD-4

+    //    gated default), never a silent auto-apply.

+    let cache_a = ReleaseCache::open(&a_paths.releases);

+    converge("A staged the propagated release", || {

+        cache_a.load_artifact().map(|b| b == artifact).unwrap_or(false)

+    converge("A holds the consent notif", || {

+            .map(|rows| rows.iter().any(|r| r.kind == "consent"))


Bash

cargo test -p spt-daemon --test peerloop 2>&1 | tail -12

Run extended pump E2E


183 |     parse_verifying_key, sha256_hex, verify_artifact, verify_metadata, verify_signature,
    |     ^^^^^^^^^^^^^^^^^^^ private function
    |
note: the function `parse_verifying_key` is defined here
   --> crates\spt-daemon\src\release.rs:164:1
    |
164 | fn parse_verifying_key(hex: &str) -> Option<VerifyingKey> {
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Some errors have detailed explanations: E0428, E0603.
For more information about an error, try `rustc --explain E0428`.
error: could not compile `spt-daemon` (lib) due to 3 previous errors


grep -n "parse_verifying_key\|hex_encode" crates/spt-daemon/src/release.rs | head; sed -n '180,186p' crates/spt-daemon/src/lib.rs

Find duplicate definitions


146:            match parse_verifying_key(hex) {
164:fn parse_verifying_key(hex: &str) -> Option<VerifyingKey> {
301:pub fn parse_verifying_key(hex: &str) -> Result<VerifyingKey, RejectReason> {
328:    hex_encode(&hasher.finalize())
332:fn hex_encode(bytes: &[u8]) -> String {
383:        SignedRelease { metadata_json, signature_hex: hex_encode(&sig.to_bytes()) }
415:            "keys": { "good": hex_encode(vk.as_bytes()), "bad": "zz-not-hex" },
562:        assert_eq!(hex_decode(&hex_encode(&bytes)).unwrap(), bytes);
};
pub use transport::{DaemonTransport, LocalSocketTransport};
pub use release::{
    parse_verifying_key, sha256_hex, verify_artifact, verify_metadata, verify_signature,
    RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy,
};
pub use update::{


Edit

        let mut trusted_keys = BTreeMap::new();
        for (key_id, hex) in &file.keys {
            match parse_verifying_key(hex) {
                Some(vk) => {
                    trusted_keys.insert(key_id.clone(), vk);
                }
                None => eprintln!("RELEASE_KEY_BAD:{key_id} — skipped (not a valid key)"),
            }
        }

        let mut trusted_keys = BTreeMap::new();
        for (key_id, hex) in &file.keys {
            match parse_verifying_key(hex) {
                Ok(vk) => {
                    trusted_keys.insert(key_id.clone(), vk);
                }
                Err(_) => eprintln!("RELEASE_KEY_BAD:{key_id} — skipped (not a valid key)"),
            }
        }


        let mut trusted_keys = BTreeMap::new();
        for (key_id, hex) in &file.keys {
            match parse_verifying_key(hex) {
                Some(vk) => {
                    trusted_keys.insert(key_id.clone(), vk);
                }
                None => eprintln!("RELEASE_KEY_BAD:{key_id} — skipped (not a valid key)"),
            }
        }

        let mut trusted_keys = BTreeMap::new();
        for (key_id, hex) in &file.keys {
            match parse_verifying_key(hex) {
                Ok(vk) => {
                    trusted_keys.insert(key_id.clone(), vk);
                }
                Err(_) => eprintln!("RELEASE_KEY_BAD:{key_id} — skipped (not a valid key)"),
            }
        }

-                None => eprintln!("RELEASE_KEY_BAD:{key_id} — skipped (not a valid key)"),

+                Err(_) => eprintln!("RELEASE_KEY_BAD:{key_id} — skipped (not a valid key)"),


/// Parse a lowercase-hex ed25519 verifying key.
fn parse_verifying_key(hex: &str) -> Option<VerifyingKey> {
    if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
        return None;
    }
    let mut bytes = [0u8; 32];
    for (i, b) in bytes.iter_mut().enumerate() {
        *b = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
    }
    VerifyingKey::from_bytes(&bytes).ok()
}

/// Why a release was refused. Every reject is loud + typed (the 2.3/§D failure
/// mode is a silent accept of a bad binary).

/// Why a release was refused. Every reject is loud + typed (the 2.3/§D failure
/// mode is a silent accept of a bad binary).


/// Parse a lowercase-hex ed25519 verifying key.
fn parse_verifying_key(hex: &str) -> Option<VerifyingKey> {
    if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
        return None;
    }
    let mut bytes = [0u8; 32];
    for (i, b) in bytes.iter_mut().enumerate() {
        *b = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
    }
    VerifyingKey::from_bytes(&bytes).ok()
}

/// Why a release was refused. Every reject is loud + typed (the 2.3/§D failure
/// mode is a silent accept of a bad binary).

-/// Parse a lowercase-hex ed25519 verifying key.

-fn parse_verifying_key(hex: &str) -> Option<VerifyingKey> {

-    if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {

-    for (i, b) in bytes.iter_mut().enumerate() {

-    VerifyingKey::from_bytes(&bytes).ok()


Bash

cargo test -p spt-daemon --test peerloop 2>&1 | tail -8

Re-run pump E2E


    Finished `test` profile [unoptimized + debuginfo] target(s) in 8.46s
     Running tests\peerloop.rs (target\debug\deps\peerloop-316f7864d7428407.exe)

running 1 test
test pump_and_dispatch_self_drive_the_subnet ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.12s


cargo test -p spt-daemon 2>&1 | grep -E "test result|FAILED|panicked" | grep -v "ok\." | head -5; echo SUITE_OK; cargo clippy -p spt-daemon --all-targets 2>&1 | grep -cE "^warning|^error"; traceable-reqs check > /dev/null 2>&1; echo TR:$?

Full suite + clippy + traceability


SUITE_OK
3
TR:0


cargo clippy -p spt-daemon --all-targets 2>&1 | grep -E "^warning" -A 8 | head -40

Show clippy warnings


warning: this function has too many arguments (8/7)
   --> crates\spt-daemon\src\peerloop.rs:184:1
    |
184 | / pub fn run_peer_pump(
185 | |     broker_name: &str,
186 | |     registry: Arc<RegistryHost>,
187 | |     paths: &PumpPaths,
...   |
192 | |     stop: &AtomicBool,
--
warning: `spt-daemon` (lib) generated 1 warning
warning: `spt-daemon` (lib test) generated 1 warning (1 duplicate)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s


Edit

impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
            update: cfg.update_check_period,
        }
    }
}

impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
            update: cfg.update_check_period,
        }
    }
}

/// The pump's resolved knobs: cadences + the REQ-UPD-4 consent stance.
#[derive(Debug, Clone, Copy)]
pub struct PumpConfig {
    pub cadence: PumpCadence,
    /// `true` = the explicit opt-in to prompt-free updates; default `false`
    /// surfaces every staged release as a consent notif.
    pub full_auto_update: bool,
}

impl PumpConfig {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpConfig {
        PumpConfig {
            cadence: PumpCadence::from_config(cfg),
            full_auto_update: cfg.full_auto_update,
        }
    }
}


impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
            update: cfg.update_check_period,
        }
    }
}

/// The pump's resolved knobs: cadences + the REQ-UPD-4 consent stance.
#[derive(Debug, Clone, Copy)]
pub struct PumpConfig {
    pub cadence: PumpCadence,
    /// `true` = the explicit opt-in to prompt-free updates; default `false`
    /// surfaces every staged release as a consent notif.
    pub full_auto_update: bool,
}

impl PumpConfig {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpConfig {
        PumpConfig {
            cadence: PumpCadence::from_config(cfg),
            full_auto_update: cfg.full_auto_update,
        }
    }
}

+/// The pump's resolved knobs: cadences + the REQ-UPD-4 consent stance.

+    /// `true` = the explicit opt-in to prompt-free updates; default `false`

+    /// surfaces every staged release as a consent notif.

+    pub fn from_config(cfg: &DaemonConfig) -> PumpConfig {

+            cadence: PumpCadence::from_config(cfg),

+            full_auto_update: cfg.full_auto_update,

/// A pull observer: `(peer_hex, report)` after every completed sync pull.

pub type OnPull = Box<dyn FnMut(&str, &SyncPullReport) + Send>;


pub fn run_peer_pump(
    broker_name: &str,
    registry: Arc<RegistryHost>,
    paths: &PumpPaths,
    resolver: PeerResolver,
    cadence: PumpCadence,
    full_auto_update: bool,
    mut hooks: PumpHooks,
    stop: &AtomicBool,
) -> io::Result<()> {

pub fn run_peer_pump(
    broker_name: &str,
    registry: Arc<RegistryHost>,
    paths: &PumpPaths,
    resolver: PeerResolver,
    config: PumpConfig,
    mut hooks: PumpHooks,
    stop: &AtomicBool,
) -> io::Result<()> {
    let PumpConfig { cadence, full_auto_update } = config;


pub fn run_peer_pump(
    broker_name: &str,
    registry: Arc<RegistryHost>,
    paths: &PumpPaths,
    resolver: PeerResolver,
    cadence: PumpCadence,
    full_auto_update: bool,
    mut hooks: PumpHooks,
    stop: &AtomicBool,
) -> io::Result<()> {

pub fn run_peer_pump(
    broker_name: &str,
    registry: Arc<RegistryHost>,
    paths: &PumpPaths,
    resolver: PeerResolver,
    config: PumpConfig,
    mut hooks: PumpHooks,
    stop: &AtomicBool,
) -> io::Result<()> {
    let PumpConfig { cadence, full_auto_update } = config;

+    let PumpConfig { cadence, full_auto_update } = config;

let mut ops = EpochSource::load_from(&paths.ops);

let mut conns: HashMap<String, u64> = HashMap::new();


        if let Err(e) = run_peer_pump(
            &broker_name,
            registry,
            &paths,
            production_resolver(),
            PumpCadence::from_config(&cfg),
            cfg.full_auto_update,
            PumpHooks::default(),
            &stop,
        ) {

        if let Err(e) = run_peer_pump(
            &broker_name,
            registry,
            &paths,
            production_resolver(),
            PumpConfig::from_config(&cfg),
            PumpHooks::default(),
            &stop,
        ) {


        if let Err(e) = run_peer_pump(
            &broker_name,
            registry,
            &paths,
            production_resolver(),
            PumpCadence::from_config(&cfg),
            cfg.full_auto_update,
            PumpHooks::default(),
            &stop,
        ) {

        if let Err(e) = run_peer_pump(
            &broker_name,
            registry,
            &paths,
            production_resolver(),
            PumpConfig::from_config(&cfg),
            PumpHooks::default(),
            &stop,
        ) {

-            PumpCadence::from_config(&cfg),

+            PumpConfig::from_config(&cfg),


use spt_daemon::peerloop::{run_peer_pump, PeerResolver, PumpCadence, PumpHooks, PumpPaths};

use spt_daemon::peerloop::{
    run_peer_pump, PeerResolver, PumpCadence, PumpConfig, PumpHooks, PumpPaths,
};


use spt_daemon::peerloop::{
    run_peer_pump, PeerResolver, PumpCadence, PumpConfig, PumpHooks, PumpPaths,
};

-use spt_daemon::peerloop::{run_peer_pump, PeerResolver, PumpCadence, PumpHooks, PumpPaths};

+    run_peer_pump, PeerResolver, PumpCadence, PumpConfig, PumpHooks, PumpPaths,

use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease};


        thread::spawn(move || {
            let cadence = PumpCadence {
                registry: Duration::from_millis(100),
                notif: Duration::from_millis(100),
                sync: Duration::from_millis(100),
                update: Duration::from_millis(100),
            };
            let _ = run_peer_pump(
                &name,
                reg,
                &paths,
                resolver,
                cadence,
                false, // gated default (REQ-UPD-4) — the consent notif is the assert
                PumpHooks::default(),
                &flag,
            );
        });

        thread::spawn(move || {
            let config = PumpConfig {
                cadence: PumpCadence {
                    registry: Duration::from_millis(100),
                    notif: Duration::from_millis(100),
                    sync: Duration::from_millis(100),
                    update: Duration::from_millis(100),
                },
                // Gated default (REQ-UPD-4) — the consent notif is the assert.
                full_auto_update: false,
            };
            let _ = run_peer_pump(
                &name,
                reg,
                &paths,
                resolver,
                config,
                PumpHooks::default(),
                &flag,
            );
        });


        thread::spawn(move || {
            let cadence = PumpCadence {
                registry: Duration::from_millis(100),
                notif: Duration::from_millis(100),
                sync: Duration::from_millis(100),
                update: Duration::from_millis(100),
            };
            let _ = run_peer_pump(
                &name,
                reg,
                &paths,
                resolver,
                cadence,
                false, // gated default (REQ-UPD-4) — the consent notif is the assert
                PumpHooks::default(),
                &flag,
            );
        });

        thread::spawn(move || {
            let config = PumpConfig {
                cadence: PumpCadence {
                    registry: Duration::from_millis(100),
                    notif: Duration::from_millis(100),
                    sync: Duration::from_millis(100),
                    update: Duration::from_millis(100),
                },
                // Gated default (REQ-UPD-4) — the consent notif is the assert.
                full_auto_update: false,
            };
            let _ = run_peer_pump(
                &name,
                reg,
                &paths,
                resolver,
                config,
                PumpHooks::default(),
                &flag,
            );
        });

-            let cadence = PumpCadence {

-                registry: Duration::from_millis(100),

-                notif: Duration::from_millis(100),

-                sync: Duration::from_millis(100),

-                update: Duration::from_millis(100),

+                    registry: Duration::from_millis(100),

+                    notif: Duration::from_millis(100),

+                    sync: Duration::from_millis(100),

+                    update: Duration::from_millis(100),

+                // Gated default (REQ-UPD-4) — the consent notif is the assert.

+                full_auto_update: false,

-                false, // gated default (REQ-UPD-4) — the consent notif is the assert


Bash

cargo clippy -p spt-daemon --all-targets 2>&1 | grep -cE "^warning|^error"; cargo test -p spt-daemon --test peerloop 2>&1 | grep "test result"; cargo test -p spt-daemon --lib release 2>&1 | grep "test result"

Clippy + pump E2E + release lib tests


0
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.08s
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 106 filtered out; finished in 0.03s


All green. Committing D9-1-3:


git add crates/spt-daemon/src/release.rs crates/spt-daemon/src/relcache.rs crates/spt-daemon/src/peerloop.rs crates/spt-daemon/tests/peerloop.rs && git commit -m "feat(daemon): M4-D9-1-3 production VerifyPolicy + update pump leg (REQ-UPD-1/2/4)

VerifyPolicy::production/load_from: the D7 deferral assembled - trusted
release keys + channel pin from identity/release-keys.json (human-editable;
missing/malformed file = EMPTY trusted set, fail-closed: an unprovisioned
node can never accept a propagated binary; bad key hex skipped loud),
version floor = the staged release's version (ReleaseCache::staged_version,
the rollback gate's production source), caller clock for expiry only.

peerloop grows the update leg (update_check_period knob): per trusted peer,
request_update under the production gate; Updated => staged + surfaced as a
CONSENT notif via the D8 producer (REQ-UPD-4 gated default - full-auto is
the explicit opt-in and writes nothing); Rejected => loud + typed, never
silent (ADR-0004 §D). run_peer_pump signature folds knobs into PumpConfig.

E2E extended: A's pump pulls B's staged v6 under a real keys file, stages
byte-identical, and the consent notif lands in A's spool - undriven.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>" && git push && git log --oneline -1

Commit + push D9-1-3


warning: in the working copy of 'crates/spt-daemon/src/peerloop.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/relcache.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/release.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/peerloop.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform b15cf2c] feat(daemon): M4-D9-1-3 production VerifyPolicy + update pump leg (REQ-UPD-1/2/4)
 4 files changed, 297 insertions(+), 11 deletions(-)
To https://github.com/SaberMage/spt-core.git
   822d3ce..b15cf2c  dev-freeform -> dev-freeform
b15cf2c feat(daemon): M4-D9-1-3 production VerifyPolicy + update pump leg (REQ-UPD-1/2/4)


D9-7: closeout sweep


sleep 45; for i in $(seq 1 60); do out=$(gh run list --branch dev-freeform --limit 3 --json headSha,conclusion,status,workflowName 2>/dev/null); echo "$out" | grep -q "b15cf2c" || { sleep 30; continue; }; pending=$(echo "$out" | python -c "import json,sys; rs=[r for r in json.load(sys.stdin) if r['headSha'].startswith('b15cf2c')]; print(sum(1 for r in rs if r['status']!='completed'))" 2>/dev/null); if [ "$pending" = "0" ]; then echo "FINAL:"; echo "$out" | python -c "import json,sys; rs=[r for r in json.load(sys.stdin) if r['headSha'].startswith('b15cf2c')]; [print(r['workflowName'], r['conclusion']) for r in rs]"; exit 0; fi; sleep 30; done; echo TIMEOUT

Poll CI until FINAL for b15cf2c


Command running in background with ID: bhuz2mo4a. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bhuz2mo4a.output. You will be notified when it completes. To check interim output, use Read on that file path.


grep -n "pub fn resolve_across_visible" -B6 -A 30 crates/spt-net/src/net/registry.rs | head -50; echo ===; grep -n "pub enum Resolution\|pub struct Resolved" -A 12 crates/spt-net/src/net/registry.rs | head -20

resolve_across_visible signature + Resolution type


415-/// [`resolve_across`] honoring **visibility** (D3e, ADR-0006 §6): `excluded(subnet,
416-/// id)` gates each per-subnet resolution. An endpoint hidden in one subnet but visible
417-/// in another resolves cleanly in the visible one — exclusion *removes* a subnet from
418-/// the ambiguity set rather than poisoning the whole resolution. Hidden everywhere ⇒
419-/// [`NotFound`](Resolution::NotFound).
420-// [impl->REQ-INST-12]
421:pub fn resolve_across_visible(
422-    subnets: &BTreeMap<String, SubnetRegistry>,
423-    address: &Address,
424-    local_node: &str,
425-    excluded: impl Fn(&str, &str) -> bool,
426-) -> Resolution {
427-    // Subnet-qualified: resolve only there, tagging the subnet onto a hit.
428-    if let Some(subnet) = &address.subnet {
429-        return match subnets.get(subnet) {
430-            Some(reg) => match reg.resolve_visible(address, local_node, |id| excluded(subnet, id))
431-            {
432-                Resolution::Resolved { instance, .. } => {
433-                    Resolution::Resolved { subnet: Some(subnet.clone()), instance }
434-                }
435-                other => other,
436-            },
437-            None => Resolution::NotFound,
438-        };
439-    }
440-
441-    // Unqualified: gather hits across all subnets. A within-subnet ambiguity surfaces
442-    // immediately (it needs `@node`, not `subnet:`).
443-    let mut hits: Vec<(String, Resolution)> = Vec::new();
444-    for (name, reg) in subnets {
445-        match reg.resolve_visible(address, local_node, |id| excluded(name, id)) {
446-            Resolution::Resolved { instance, .. } => {
447-                hits.push((name.clone(), Resolution::Resolved { subnet: Some(name.clone()), instance }));
448-            }
449-            ambiguous @ Resolution::Ambiguous(_) => return ambiguous,
450-            Resolution::NotFound => {}
451-        }
===
322:pub enum Resolution {
323-    /// A single live instance was selected. `subnet` is set when resolution crossed a
324-    /// per-subnet registry (multi-subnet form), `None` for single-subnet resolution.
325-    Resolved { subnet: Option<String>, instance: Instance },
326-    /// Ambiguous — resolution refuses and forces the user to qualify (ADR-0006 §2: no
327-    /// guessing). The payload says which qualifier disambiguates.
328-    Ambiguous(Ambiguity),
329-    /// No live (routable) instance matched.
330-    NotFound,
331-}
332-
333-/// Why resolution refused, and how to qualify past it.
334-#[derive(Debug, Clone, PartialEq, Eq)]


CI polling. D9-1-4 = `spt send` WAN leg. Checking Address parser + spt crate deps: