Write

# M4-D7 — update peer-propagation into the M3c engine seam (JIT plan)

**Status:** DRAFTED 2026-06-04 (D6 complete `fa97b80`). Not started.
Reqs: **REQ-UPD-1** (activates here); extends evidence on REQ-UPD-2,
REQ-HAZARD-UPDATE-ROLLBACK, REQ-HAZARD-WAN-ORIGIN-AUTH.

## Goal

The subnet self-heals to the latest signed release over P2P: a node missing the
newest spt-core release pulls it from any peer that has it, **the full M3c
signature/rollback gate runs on every receiving node before anything is staged**
(one compromised node must not poison the subnet — ADR-0004 §D), and a node that
verified a release becomes a server for its peers (propagation = pull + restage,
no central distributor). Acceptance (M4-PLAN §D7): a signed update self-heals
across the subnet; the Ed25519+rollback gate runs on every receiving node; a
tampered propagated binary is rejected.

## Shape (locked by what exists — no new design forks)

- **The engine is not re-architected (D7a).** `update::plan_verified` is already
  the front door: signature + key trust/revocation + channel + expiry +
  monotonic version (`release::verify_metadata`) → artifact digest
  (`release::verify_artifact`) → classify → `UpdatePlan`. D7 builds the
  transport that *feeds* it; apply stays C0's `apply_brain_only` (proven E2E at
  M3c) and consent stays C2. Nothing in `update.rs`/`release.rs` changes.
- **Transport = the D5c/D6c posture verbatim.** One pull rides one broker bidi
  QUIC stream; NDJSON tagged records (`net/ndjson.rs` decoder, unknown kind
  skipped); chunks carry absolute byte `offset` (positionally idempotent);
  sends fire-and-forget (`op_id: None` — a journaled send starves a pump, D5b);
  records carry **no origin field** — the gate's subject is the handshake-proven
  `remote_id_hex` from the broker's stream table (KNOWN-HAZARDS 7.5).
- **Pull-based, offer-then-fetch (the cheap pre-gate).** Requester sends
  `Query { current_version, channel }`; responder answers `Offer { signed }`
  (just the `SignedRelease` metadata — small) or `UpToDate`. The requester runs
  `verify_metadata` on the offer **before fetching any artifact bytes** — a
  rollback / expired / off-channel / bad-sig offer is rejected without pulling
  the binary. Only a metadata-verified offer proceeds to `Fetch` → `Chunk`* →
  `Done`, then `plan_verified` closes the loop on the real bytes (D7b: the gate
  is per-node, never inherited from the sender's claim to have verified).
- **Staging = becoming a server (the self-heal mechanism).** A release that
  passed `plan_verified` is staged into the node's release cache
  (`SPT_HOME`-anchored dir: `release.json` = the `SignedRelease`, `artifact.bin`
  = the bytes; atomic temp→rename, same posture as D5c commit). `serve_update`
  answers queries from the staged release. Verified-pull ⇒ staged ⇒ servable:
  propagation chains A→B→C with the gate at every hop. **Unverified bytes are
  never staged** — there is no code path from wire bytes to the cache except
  through `plan_verified`.
- **Serve gate: trusted peers only, fail-closed.** A query is answered only when
  the handshake-proven origin is trusted in ≥1 subnet (`TrustStore`, the same
  check as the D6c `p-` tier). Anything else gets `UpToDate`-shaped silence —
  refuse by not offering.
- **Torn pull = re-query.** No resume protocol: staging is atomic, chunks are
  positional, the query is idempotent. FAULT-MATRIX row.
- **VerifyPolicy is caller-supplied** (pure posture, same as C1/C2): the
  `request_update` driver takes `&VerifyPolicy`. Production policy assembly
  (trusted-key store, channel pin, running version) rides the D9 daemon
  lifecycle with the trigger loops — same deferral as the D5/D6 serve loops.

## Slices (each CI-green before the next)

1. **D7-1 — wire records + release cache.**
   - `spt-net/src/net/update.rs`: `UpdRecord` enum — `Query { upd_id,
     current_version, channel }`, `Offer { upd_id, metadata_json, signature_hex }`,
     `UpToDate { upd_id }`, `Fetch { upd_id }`, `Chunk { upd_id, offset,
     data_b64 }`, `Done { upd_id, total }`, `Err { upd_id, message }`;
     `UpdDecoder = NdjsonDecoder<UpdRecord>`. Unit tests mirror `sync.rs`:
     round-trip all kinds, unknown-kind skip, split-record reassembly, forged
     origin-field inert (`[unit->REQ-HAZARD-WAN-ORIGIN-AUTH]`).
   - `spt-daemon/src/relcache.rs`: `ReleaseCache` — `stage(signed, artifact)`
     (atomic temp→rename pair), `staged() -> Option<(SignedRelease, PathBuf)>`,
     `load_artifact()`. Unit tests: stage/load round-trip, restage replaces,
     torn temp file ignored.
   - Activate **REQ-UPD-1 → `[impl, unit]`** in `traceable-reqs.toml`; tag
     evidence in both files.
2. **D7-2 — serve/request drivers + loopback propagation E2E.**
   - `spt-daemon/src/propagate.rs`:
     `serve_update(brain, stream_id, origin_node, net_from_seq, cache, trust)`
     — trust-gate the origin, answer Query from the staged release (Offer or
     UpToDate), pump chunks on Fetch, Done; Detached/Failed outcomes mirroring
     `SyncServeOutcome`.
     `request_update(brain, conn_id, open_op, policy, running, cache, scratch)
     -> io::Result<UpdatePullOutcome>` — Query → gate the Offer's metadata
     (reject pre-fetch) → Fetch → reassemble in scratch → `plan_verified` →
     stage → return `Updated(UpdatePlan)` / `UpToDate` /
     `Rejected(RejectReason)` (loud + typed, never silent — the §D failure
     mode).
   - Int tests (`tests/propagate.rs`, two/three loopback brokers — copy the
     `tests/sync.rs` harness incl. **stream-table snapshot before the requester
     opens**, gotcha: the table holds locally-opened streams too):
     - happy path: B (v5) pulls v6 from A; gate passes; plan is `BrainOnly`;
       staged bytes byte-identical (`[unit->REQ-UPD-1]`).
     - **chain self-heal**: A→B→C — C pulls from B *after* B staged; gate ran
       at B and again at C (`[unit->REQ-UPD-1]`).
     - **tampered relay**: B's staged artifact bytes are corrupted on disk; C's
       pull ends `Rejected(ArtifactMismatch)`, nothing staged at C
       (`[unit->REQ-UPD-2]`).
     - **rollback offer refused pre-fetch**: A offers v4 to a v5 node — reject
       happens with zero Chunk records on the wire
       (`[unit->REQ-HAZARD-UPDATE-ROLLBACK]`).
     - **untrusted origin refused**: a peer absent from A's trust store queries
       — no Offer (`[unit->REQ-HAZARD-WAN-ORIGIN-AUTH]`).
   - FAULT-MATRIX rows (same commit): stream loss mid-artifact (re-query),
     tampered relay node (per-hop gate), rollback/expired offer (pre-fetch
     reject), untrusted query (no offer).

## NOT in D7

- Apply/handoff trigger + consent prompt wiring — exists (C0/C2); the
  production "verified plan → consent → apply" loop rides the D9 daemon
  lifecycle with every other trigger loop.
- Release *publishing* tooling (minting/signing real releases) — out of band,
  v1 keeps self-fetch as the seed path (ADR-0004 §D: peer-propagation is
  *layered on* self-fetch).
- Adapter-payload propagation (REQ-UPD-5 transport) — the conductor verifies
  payloads regardless of source; carrying adapter payloads over this channel is
  a post-v1 extension of the same records.
- D7.5 (Psyche outbound relay) — next after D7; separate plan.

## Conventions (carried)

- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence.
  `traceable-reqs check` from repo root before done. Linux CI clippy
  `-D warnings` is the real gate. Push each slice → watch FINAL green before
  next (`gh run list --json conclusion`).
  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\M4-D7-PLAN.md (file state is current in your context — no need to Read it back)

# M4-D7 — update peer-propagation into the M3c engine seam (JIT plan)

**Status:** DRAFTED 2026-06-04 (D6 complete `fa97b80`). Not started.
Reqs: **REQ-UPD-1** (activates here); extends evidence on REQ-UPD-2,
REQ-HAZARD-UPDATE-ROLLBACK, REQ-HAZARD-WAN-ORIGIN-AUTH.

## Goal

The subnet self-heals to the latest signed release over P2P: a node missing the
newest spt-core release pulls it from any peer that has it, **the full M3c
signature/rollback gate runs on every receiving node before anything is staged**
(one compromised node must not poison the subnet — ADR-0004 §D), and a node that
verified a release becomes a server for its peers (propagation = pull + restage,
no central distributor). Acceptance (M4-PLAN §D7): a signed update self-heals
across the subnet; the Ed25519+rollback gate runs on every receiving node; a
tampered propagated binary is rejected.

## Shape (locked by what exists — no new design forks)

- **The engine is not re-architected (D7a).** `update::plan_verified` is already
  the front door: signature + key trust/revocation + channel + expiry +
  monotonic version (`release::verify_metadata`) → artifact digest
  (`release::verify_artifact`) → classify → `UpdatePlan`. D7 builds the
  transport that *feeds* it; apply stays C0's `apply_brain_only` (proven E2E at
  M3c) and consent stays C2. Nothing in `update.rs`/`release.rs` changes.
- **Transport = the D5c/D6c posture verbatim.** One pull rides one broker bidi
  QUIC stream; NDJSON tagged records (`net/ndjson.rs` decoder, unknown kind
  skipped); chunks carry absolute byte `offset` (positionally idempotent);
  sends fire-and-forget (`op_id: None` — a journaled send starves a pump, D5b);
  records carry **no origin field** — the gate's subject is the handshake-proven
  `remote_id_hex` from the broker's stream table (KNOWN-HAZARDS 7.5).
- **Pull-based, offer-then-fetch (the cheap pre-gate).** Requester sends
  `Query { current_version, channel }`; responder answers `Offer { signed }`
  (just the `SignedRelease` metadata — small) or `UpToDate`. The requester runs
  `verify_metadata` on the offer **before fetching any artifact bytes** — a
  rollback / expired / off-channel / bad-sig offer is rejected without pulling
  the binary. Only a metadata-verified offer proceeds to `Fetch` → `Chunk`* →
  `Done`, then `plan_verified` closes the loop on the real bytes (D7b: the gate
  is per-node, never inherited from the sender's claim to have verified).
- **Staging = becoming a server (the self-heal mechanism).** A release that
  passed `plan_verified` is staged into the node's release cache
  (`SPT_HOME`-anchored dir: `release.json` = the `SignedRelease`, `artifact.bin`
  = the bytes; atomic temp→rename, same posture as D5c commit). `serve_update`
  answers queries from the staged release. Verified-pull ⇒ staged ⇒ servable:
  propagation chains A→B→C with the gate at every hop. **Unverified bytes are
  never staged** — there is no code path from wire bytes to the cache except
  through `plan_verified`.
- **Serve gate: trusted peers only, fail-closed.** A query is answered only when
  the handshake-proven origin is trusted in ≥1 subnet (`TrustStore`, the same
  check as the D6c `p-` tier). Anything else gets `UpToDate`-shaped silence —
  refuse by not offering.
- **Torn pull = re-query.** No resume protocol: staging is atomic, chunks are
  positional, the query is idempotent. FAULT-MATRIX row.
- **VerifyPolicy is caller-supplied** (pure posture, same as C1/C2): the
  `request_update` driver takes `&VerifyPolicy`. Production policy assembly
  (trusted-key store, channel pin, running version) rides the D9 daemon
  lifecycle with the trigger loops — same deferral as the D5/D6 serve loops.

## Slices (each CI-green before the next)

1. **D7-1 — wire records + release cache.**
   - `spt-net/src/net/update.rs`: `UpdRecord` enum — `Query { upd_id,
     current_version, channel }`, `Offer { upd_id, metadata_json, signature_hex }`,
     `UpToDate { upd_id }`, `Fetch { upd_id }`, `Chunk { upd_id, offset,
     data_b64 }`, `Done { upd_id, total }`, `Err { upd_id, message }`;
     `UpdDecoder = NdjsonDecoder<UpdRecord>`. Unit tests mirror `sync.rs`:
     round-trip all kinds, unknown-kind skip, split-record reassembly, forged
     origin-field inert (`[unit->REQ-HAZARD-WAN-ORIGIN-AUTH]`).
   - `spt-daemon/src/relcache.rs`: `ReleaseCache` — `stage(signed, artifact)`
     (atomic temp→rename pair), `staged() -> Option<(SignedRelease, PathBuf)>`,
     `load_artifact()`. Unit tests: stage/load round-trip, restage replaces,
     torn temp file ignored.
   - Activate **REQ-UPD-1 → `[impl, unit]`** in `traceable-reqs.toml`; tag
     evidence in both files.
2. **D7-2 — serve/request drivers + loopback propagation E2E.**
   - `spt-daemon/src/propagate.rs`:
     `serve_update(brain, stream_id, origin_node, net_from_seq, cache, trust)`
     — trust-gate the origin, answer Query from the staged release (Offer or
     UpToDate), pump chunks on Fetch, Done; Detached/Failed outcomes mirroring
     `SyncServeOutcome`.
     `request_update(brain, conn_id, open_op, policy, running, cache, scratch)
     -> io::Result<UpdatePullOutcome>` — Query → gate the Offer's metadata
     (reject pre-fetch) → Fetch → reassemble in scratch → `plan_verified` →
     stage → return `Updated(UpdatePlan)` / `UpToDate` /
     `Rejected(RejectReason)` (loud + typed, never silent — the §D failure
     mode).
   - Int tests (`tests/propagate.rs`, two/three loopback brokers — copy the
     `tests/sync.rs` harness incl. **stream-table snapshot before the requester
     opens**, gotcha: the table holds locally-opened streams too):
     - happy path: B (v5) pulls v6 from A; gate passes; plan is `BrainOnly`;
       staged bytes byte-identical (`[unit->REQ-UPD-1]`).
     - **chain self-heal**: A→B→C — C pulls from B *after* B staged; gate ran
       at B and again at C (`[unit->REQ-UPD-1]`).
     - **tampered relay**: B's staged artifact bytes are corrupted on disk; C's
       pull ends `Rejected(ArtifactMismatch)`, nothing staged at C
       (`[unit->REQ-UPD-2]`).
     - **rollback offer refused pre-fetch**: A offers v4 to a v5 node — reject
       happens with zero Chunk records on the wire
       (`[unit->REQ-HAZARD-UPDATE-ROLLBACK]`).
     - **untrusted origin refused**: a peer absent from A's trust store queries
       — no Offer (`[unit->REQ-HAZARD-WAN-ORIGIN-AUTH]`).
   - FAULT-MATRIX rows (same commit): stream loss mid-artifact (re-query),
     tampered relay node (per-hop gate), rollback/expired offer (pre-fetch
     reject), untrusted query (no offer).

## NOT in D7

- Apply/handoff trigger + consent prompt wiring — exists (C0/C2); the
  production "verified plan → consent → apply" loop rides the D9 daemon
  lifecycle with every other trigger loop.
- Release *publishing* tooling (minting/signing real releases) — out of band,
  v1 keeps self-fetch as the seed path (ADR-0004 §D: peer-propagation is
  *layered on* self-fetch).
- Adapter-payload propagation (REQ-UPD-5 transport) — the conductor verifies
  payloads regardless of source; carrying adapter payloads over this channel is
  a post-v1 extension of the same records.
- D7.5 (Psyche outbound relay) — next after D7; separate plan.

## Conventions (carried)

- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence.
  `traceable-reqs check` from repo root before done. Linux CI clippy
  `-D warnings` is the real gate. Push each slice → watch FINAL green before
  next (`gh run list --json conclusion`).
  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.


Plan drafted. Start D7-1: wire records + release cache. Check ndjson + net module registration.


1	//! NDJSON line framing shared by the wire feeds (registry replication M4-D4d,
2	//! WAN messages M4-D5a).
3	//!
4	//! One JSON object per `\n`-terminated line. QUIC stream reads do **not**
5	//! preserve write boundaries (the read pump pulls up-to-64KiB chunks — writes
6	//! coalesce and split arbitrarily), so records need framing independent of
7	//! chunk shape; a newline delimiter on a JSON document (whose interior newlines
8	//! are always escaped) is the simplest framing that needs no length
9	//! bookkeeping. Decoding tolerates unknown fields (a newer node may add fields
10	//! — same posture as the IPC catalog) and skips undecodable lines (degrade,
11	//! don't die — the 4.3 stale-clean philosophy: one corrupt record must not
12	//! wedge the feed).
13	//!
14	//! Extracted from `replicate.rs` (where D4d proved the framing for
15	//! [`RegistryUpdate`](crate::net::replicate::RegistryUpdate)) when D5a's
16	//! [`WanMessage`](crate::net::wanmsg::WanMessage) became the second record
17	//! type riding it. The decode rules are identical for every record type; per-
18	//! record semantics (epoch lease, op-id dedup) stay with the record's module.
19
20	use std::marker::PhantomData;
21
22	use serde::de::DeserializeOwned;
23	use serde::Serialize;
24
25	/// Encode one record as one NDJSON line (the JSON document + `\n`).
26	pub fn encode_line<T: Serialize>(record: &T) -> Vec<u8> {
27	    let mut line = serde_json::to_vec(record).expect("wire record serializes");
28	    line.push(b'\n');
29	    line
30	}
31
32	/// Reassembles NDJSON records from arbitrarily-chunked stream bytes: push each
33	/// received chunk, take the complete records. A partial trailing line stays
34	/// buffered until its `\n` arrives; an undecodable line is skipped (see module
35	/// docs). One decoder per stream — records never span streams.
36	#[derive(Debug)]
37	pub struct NdjsonDecoder<T> {
38	    buf: Vec<u8>,
39	    _records: PhantomData<fn() -> T>,
40	}
41
42	impl<T> Default for NdjsonDecoder<T> {
43	    fn default() -> NdjsonDecoder<T> {
44	        NdjsonDecoder { buf: Vec::new(), _records: PhantomData }
45	    }
46	}
47
48	impl<T: DeserializeOwned> NdjsonDecoder<T> {
49	    /// A fresh decoder (empty buffer).
50	    pub fn new() -> NdjsonDecoder<T> {
51	        NdjsonDecoder::default()
52	    }
53
54	    /// Push one received chunk and return every record completed by it, in wire
55	    /// order. Bytes after the last `\n` are retained for the next push.
56	    // [impl->REQ-INST-7]
57	    pub fn push(&mut self, chunk: &[u8]) -> Vec<T> {
58	        self.buf.extend_from_slice(chunk);
59	        let mut out = Vec::new();
60	        // Split off complete lines; keep the tail.
61	        while let Some(pos) = self.buf.iter().position(|b| *b == b'\n') {
62	            let rest = self.buf.split_off(pos + 1);
63	            let line = std::mem::replace(&mut self.buf, rest);
64	            if let Ok(rec) = serde_json::from_slice::<T>(&line[..pos]) {
65	                out.push(rec);
66	            }
67	            // else: skipped — a corrupt record must not wedge the feed.
68	        }
69	        out
70	    }
71	}
72

//! NDJSON line framing shared by the wire feeds (registry replication M4-D4d,
//! WAN messages M4-D5a).
//!
//! One JSON object per `\n`-terminated line. QUIC stream reads do **not**
//! preserve write boundaries (the read pump pulls up-to-64KiB chunks — writes
//! coalesce and split arbitrarily), so records need framing independent of
//! chunk shape; a newline delimiter on a JSON document (whose interior newlines
//! are always escaped) is the simplest framing that needs no length
//! bookkeeping. Decoding tolerates unknown fields (a newer node may add fields
//! — same posture as the IPC catalog) and skips undecodable lines (degrade,
//! don't die — the 4.3 stale-clean philosophy: one corrupt record must not
//! wedge the feed).
//!
//! Extracted from `replicate.rs` (where D4d proved the framing for
//! [`RegistryUpdate`](crate::net::replicate::RegistryUpdate)) when D5a's
//! [`WanMessage`](crate::net::wanmsg::WanMessage) became the second record
//! type riding it. The decode rules are identical for every record type; per-
//! record semantics (epoch lease, op-id dedup) stay with the record's module.

use std::marker::PhantomData;

use serde::de::DeserializeOwned;
use serde::Serialize;

/// Encode one record as one NDJSON line (the JSON document + `\n`).
pub fn encode_line<T: Serialize>(record: &T) -> Vec<u8> {
    let mut line = serde_json::to_vec(record).expect("wire record serializes");
    line.push(b'\n');
    line
}

/// Reassembles NDJSON records from arbitrarily-chunked stream bytes: push each
/// received chunk, take the complete records. A partial trailing line stays
/// buffered until its `\n` arrives; an undecodable line is skipped (see module
/// docs). One decoder per stream — records never span streams.
#[derive(Debug)]
pub struct NdjsonDecoder<T> {
    buf: Vec<u8>,
    _records: PhantomData<fn() -> T>,
}

impl<T> Default for NdjsonDecoder<T> {
    fn default() -> NdjsonDecoder<T> {
        NdjsonDecoder { buf: Vec::new(), _records: PhantomData }
    }
}

impl<T: DeserializeOwned> NdjsonDecoder<T> {
    /// A fresh decoder (empty buffer).
    pub fn new() -> NdjsonDecoder<T> {
        NdjsonDecoder::default()
    }

    /// Push one received chunk and return every record completed by it, in wire
    /// order. Bytes after the last `\n` are retained for the next push.
    // [impl->REQ-INST-7]
    pub fn push(&mut self, chunk: &[u8]) -> Vec<T> {
        self.buf.extend_from_slice(chunk);
        let mut out = Vec::new();
        // Split off complete lines; keep the tail.
        while let Some(pos) = self.buf.iter().position(|b| *b == b'\n') {
            let rest = self.buf.split_off(pos + 1);
            let line = std::mem::replace(&mut self.buf, rest);
            if let Ok(rec) = serde_json::from_slice::<T>(&line[..pos]) {
                out.push(rec);
            }
            // else: skipped — a corrupt record must not wedge the feed.
        }
        out
    }
}


1	//! The `net`-gated transport tree.
2	//!
3	//! Populated from D1 onward (see `M4-PLAN.md` / `lib.rs`): the Iroh endpoint binds
4	//! here first, then pairing, the per-subnet registry, cross-node sync, and the
5	//! update-propagation transport. A `--no-default-features` lib build excludes this
6	//! whole module (and the iroh/tokio footprint with it).
7
8	pub mod attach;
9	pub mod endpoint;
10	pub mod ndjson;
11	pub mod pairing;
12	pub mod registry;
13	pub mod replicate;
14	pub mod sync;
15	pub mod wanmsg;
16	pub mod xfer;
17

//! The `net`-gated transport tree.
//!
//! Populated from D1 onward (see `M4-PLAN.md` / `lib.rs`): the Iroh endpoint binds
//! here first, then pairing, the per-subnet registry, cross-node sync, and the
//! update-propagation transport. A `--no-default-features` lib build excludes this
//! whole module (and the iroh/tokio footprint with it).

pub mod attach;
pub mod endpoint;
pub mod ndjson;
pub mod pairing;
pub mod registry;
pub mod replicate;
pub mod sync;
pub mod wanmsg;
pub mod xfer;


Write D7-1 slice. First: `spt-net/src/net/update.rs` wire records.


//! Update peer-propagation wire records (M4-D7, REQ-UPD-1) — pull-based,
//! offer-then-fetch release exchange over a broker-owned QUIC stream, feeding
//! the M3c update-engine front door (ADR-0004 §D).
//!
//! One pull rides one bidi stream: the requester opens it (D4) and sends
//! [`UpdRecord::Query`] with its running version + pinned channel; the
//! responder answers from its **staged, already-verified** release —
//! [`UpdRecord::Offer`] carrying just the signed metadata (small), or
//! [`UpdRecord::UpToDate`] when it has nothing newer (or refuses: an untrusted
//! origin is refused by not offering, fail-closed). The requester verifies the
//! offer's metadata **before pulling any artifact bytes** — a rollback /
//! expired / off-channel / bad-signature offer is rejected without the
//! transfer — then [`UpdRecord::Fetch`] pulls the binary as positional
//! [`UpdRecord::Chunk`]s settled by [`UpdRecord::Done`]. The receiving node
//! runs the full signature/rollback gate on the real bytes (`plan_verified`)
//! before anything is staged: the gate is **per-node**, never inherited from
//! the sender's claim to have verified (one compromised node must not poison
//! the subnet).
//!
//! ## Same wire discipline as the D5c/D6c channels
//!
//! Chunks carry their absolute byte `offset` (positionally idempotent), sends
//! are fire-and-forget (a journaled `net_stream_send` starves a pump — the
//! D5b lesson), and records carry **no origin field**: the serve gate's
//! subject is the QUIC handshake identity from the broker's stream table,
//! never payload bytes (KNOWN-HAZARDS 7.5, REQ-HAZARD-WAN-ORIGIN-AUTH). A
//! torn pull needs no resume protocol — staging is atomic and the query is
//! idempotent, so the recovery is simply "query again".

use serde::{Deserialize, Serialize};

use crate::net::ndjson::{self, NdjsonDecoder};

/// One update-protocol record. Tagged (`kind`) so both directions share one
/// stream decoder; an unknown future kind fails the line's decode and is
/// skipped (degrade, don't die — same posture as every wire feed).
// [impl->REQ-UPD-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum UpdRecord {
    /// Requester → responder, first record on the stream: do you hold a
    /// release newer than `current_version` on `channel`? `upd_id` is the
    /// requester-minted id naming this pull end-to-end.
    Query {
        upd_id: String,
        current_version: u64,
        channel: String,
    },
    /// Responder → requester: the staged release's signed metadata — the exact
    /// signed bytes + detached Ed25519 signature (`SignedRelease` shape), so
    /// the requester can run `verify_metadata` **before** fetching bytes.
    Offer {
        upd_id: String,
        metadata_json: String,
        signature_hex: String,
    },
    /// Responder → requester: nothing newer to offer (also the fail-closed
    /// refusal shape — an untrusted origin learns nothing).
    UpToDate { upd_id: String },
    /// Requester → responder: the offer's metadata verified; send the artifact.
    Fetch { upd_id: String },
    /// One chunk of artifact bytes at absolute byte `offset` — positionally
    /// idempotent, same as the D5c transfer channel.
    Chunk {
        upd_id: String,
        offset: u64,
        data_b64: String,
    },
    /// The artifact is complete: `total` bytes on the wire.
    Done { upd_id: String, total: u64 },
    /// The pull failed on the other side; nothing should be staged.
    Err { upd_id: String, message: String },
}

impl UpdRecord {
    /// Encode as one NDJSON line (the JSON document + `\n`).
    pub fn encode_line(&self) -> Vec<u8> {
        ndjson::encode_line(self)
    }
}

/// The update stream's NDJSON reassembler — the shared
/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
/// [`UpdRecord`]s. One decoder per stream side.
pub type UpdDecoder = NdjsonDecoder<UpdRecord>;

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

    // [unit->REQ-UPD-1] every record kind round-trips through the NDJSON line
    // encoding, and the decoder skips an unknown future kind without wedging
    // the stream (forward compat: degrade, don't die).
    #[test]
    fn records_round_trip_and_unknown_kind_is_skipped() {
        let records = vec![
            UpdRecord::Query { upd_id: "u:1".into(), current_version: 5, channel: "stable".into() },
            UpdRecord::Offer {
                upd_id: "u:1".into(),
                metadata_json: "{\"version\":6}".into(),
                signature_hex: "ab".into(),
            },
            UpdRecord::UpToDate { upd_id: "u:1".into() },
            UpdRecord::Fetch { upd_id: "u:1".into() },
            UpdRecord::Chunk { upd_id: "u:1".into(), offset: 4096, data_b64: "aGk=".into() },
            UpdRecord::Done { upd_id: "u:1".into(), total: 9 },
            UpdRecord::Err { upd_id: "u:1".into(), message: "bundle failed".into() },
        ];
        let mut wire = Vec::new();
        for r in &records {
            wire.extend_from_slice(&r.encode_line());
        }
        let mut dec = UpdDecoder::new();
        assert_eq!(dec.push(&wire), records, "all kinds round-trip in order");

        let mut with_junk = records[0].encode_line();
        with_junk.extend_from_slice(b"{\"kind\":\"added_at_d9\",\"x\":1}\n");
        with_junk.extend_from_slice(&records[5].encode_line());
        let mut dec = UpdDecoder::new();
        assert_eq!(
            dec.push(&with_junk),
            vec![records[0].clone(), records[5].clone()],
            "unknown kind skipped, feed not wedged"
        );
    }

    // [unit->REQ-UPD-1] a record split mid-bytes across pushes reassembles
    // (QUIC chunking is not record framing).
    #[test]
    fn record_split_across_chunks_reassembles() {
        let r = UpdRecord::Chunk { upd_id: "u:7".into(), offset: 0, data_b64: "c3BsaXQ=".into() };
        let line = r.encode_line();
        let mut dec = UpdDecoder::new();
        assert!(dec.push(&line[..line.len() / 2]).is_empty());
        assert_eq!(dec.push(&line[line.len() / 2..]), vec![r]);
    }

    // [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] update records carry no origin field:
    // a forged `origin_node` (or `node`) inside the JSON decodes as an ignored
    // unknown field — nothing in the decoded record can impersonate a node.
    // The serve gate's subject comes from the QUIC handshake (the broker's
    // stream table), asserted end-to-end in the daemon's propagate int tests.
    #[test]
    fn forged_origin_field_is_inert() {
        let wire = br#"{"kind":"query","upd_id":"u:1","current_version":5,"channel":"stable","origin_node":"evil","node":"evil"}"#;
        let decoded: UpdRecord = serde_json::from_slice(wire).unwrap();
        assert_eq!(
            decoded,
            UpdRecord::Query { upd_id: "u:1".into(), current_version: 5, channel: "stable".into() }
        );
    }
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\update.rs (file state is current in your context — no need to Read it back)

//! Update peer-propagation wire records (M4-D7, REQ-UPD-1) — pull-based,
//! offer-then-fetch release exchange over a broker-owned QUIC stream, feeding
//! the M3c update-engine front door (ADR-0004 §D).
//!
//! One pull rides one bidi stream: the requester opens it (D4) and sends
//! [`UpdRecord::Query`] with its running version + pinned channel; the
//! responder answers from its **staged, already-verified** release —
//! [`UpdRecord::Offer`] carrying just the signed metadata (small), or
//! [`UpdRecord::UpToDate`] when it has nothing newer (or refuses: an untrusted
//! origin is refused by not offering, fail-closed). The requester verifies the
//! offer's metadata **before pulling any artifact bytes** — a rollback /
//! expired / off-channel / bad-signature offer is rejected without the
//! transfer — then [`UpdRecord::Fetch`] pulls the binary as positional
//! [`UpdRecord::Chunk`]s settled by [`UpdRecord::Done`]. The receiving node
//! runs the full signature/rollback gate on the real bytes (`plan_verified`)
//! before anything is staged: the gate is **per-node**, never inherited from
//! the sender's claim to have verified (one compromised node must not poison
//! the subnet).
//!
//! ## Same wire discipline as the D5c/D6c channels
//!
//! Chunks carry their absolute byte `offset` (positionally idempotent), sends
//! are fire-and-forget (a journaled `net_stream_send` starves a pump — the
//! D5b lesson), and records carry **no origin field**: the serve gate's
//! subject is the QUIC handshake identity from the broker's stream table,
//! never payload bytes (KNOWN-HAZARDS 7.5, REQ-HAZARD-WAN-ORIGIN-AUTH). A
//! torn pull needs no resume protocol — staging is atomic and the query is
//! idempotent, so the recovery is simply "query again".

use serde::{Deserialize, Serialize};

use crate::net::ndjson::{self, NdjsonDecoder};

/// One update-protocol record. Tagged (`kind`) so both directions share one
/// stream decoder; an unknown future kind fails the line's decode and is
/// skipped (degrade, don't die — same posture as every wire feed).
// [impl->REQ-UPD-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum UpdRecord {
    /// Requester → responder, first record on the stream: do you hold a
    /// release newer than `current_version` on `channel`? `upd_id` is the
    /// requester-minted id naming this pull end-to-end.
    Query {
        upd_id: String,
        current_version: u64,
        channel: String,
    },
    /// Responder → requester: the staged release's signed metadata — the exact
    /// signed bytes + detached Ed25519 signature (`SignedRelease` shape), so
    /// the requester can run `verify_metadata` **before** fetching bytes.
    Offer {
        upd_id: String,
        metadata_json: String,
        signature_hex: String,
    },
    /// Responder → requester: nothing newer to offer (also the fail-closed
    /// refusal shape — an untrusted origin learns nothing).
    UpToDate { upd_id: String },
    /// Requester → responder: the offer's metadata verified; send the artifact.
    Fetch { upd_id: String },
    /// One chunk of artifact bytes at absolute byte `offset` — positionally
    /// idempotent, same as the D5c transfer channel.
    Chunk {
        upd_id: String,
        offset: u64,
        data_b64: String,
    },
    /// The artifact is complete: `total` bytes on the wire.
    Done { upd_id: String, total: u64 },
    /// The pull failed on the other side; nothing should be staged.
    Err { upd_id: String, message: String },
}

impl UpdRecord {
    /// Encode as one NDJSON line (the JSON document + `\n`).
    pub fn encode_line(&self) -> Vec<u8> {
        ndjson::encode_line(self)
    }
}

/// The update stream's NDJSON reassembler — the shared
/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
/// [`UpdRecord`]s. One decoder per stream side.
pub type UpdDecoder = NdjsonDecoder<UpdRecord>;

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

    // [unit->REQ-UPD-1] every record kind round-trips through the NDJSON line
    // encoding, and the decoder skips an unknown future kind without wedging
    // the stream (forward compat: degrade, don't die).
    #[test]
    fn records_round_trip_and_unknown_kind_is_skipped() {
        let records = vec![
            UpdRecord::Query { upd_id: "u:1".into(), current_version: 5, channel: "stable".into() },
            UpdRecord::Offer {
                upd_id: "u:1".into(),
                metadata_json: "{\"version\":6}".into(),
                signature_hex: "ab".into(),
            },
            UpdRecord::UpToDate { upd_id: "u:1".into() },
            UpdRecord::Fetch { upd_id: "u:1".into() },
            UpdRecord::Chunk { upd_id: "u:1".into(), offset: 4096, data_b64: "aGk=".into() },
            UpdRecord::Done { upd_id: "u:1".into(), total: 9 },
            UpdRecord::Err { upd_id: "u:1".into(), message: "bundle failed".into() },
        ];
        let mut wire = Vec::new();
        for r in &records {
            wire.extend_from_slice(&r.encode_line());
        }
        let mut dec = UpdDecoder::new();
        assert_eq!(dec.push(&wire), records, "all kinds round-trip in order");

        let mut with_junk = records[0].encode_line();
        with_junk.extend_from_slice(b"{\"kind\":\"added_at_d9\",\"x\":1}\n");
        with_junk.extend_from_slice(&records[5].encode_line());
        let mut dec = UpdDecoder::new();
        assert_eq!(
            dec.push(&with_junk),
            vec![records[0].clone(), records[5].clone()],
            "unknown kind skipped, feed not wedged"
        );
    }

    // [unit->REQ-UPD-1] a record split mid-bytes across pushes reassembles
    // (QUIC chunking is not record framing).
    #[test]
    fn record_split_across_chunks_reassembles() {
        let r = UpdRecord::Chunk { upd_id: "u:7".into(), offset: 0, data_b64: "c3BsaXQ=".into() };
        let line = r.encode_line();
        let mut dec = UpdDecoder::new();
        assert!(dec.push(&line[..line.len() / 2]).is_empty());
        assert_eq!(dec.push(&line[line.len() / 2..]), vec![r]);
    }

    // [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] update records carry no origin field:
    // a forged `origin_node` (or `node`) inside the JSON decodes as an ignored
    // unknown field — nothing in the decoded record can impersonate a node.
    // The serve gate's subject comes from the QUIC handshake (the broker's
    // stream table), asserted end-to-end in the daemon's propagate int tests.
    #[test]
    fn forged_origin_field_is_inert() {
        let wire = br#"{"kind":"query","upd_id":"u:1","current_version":5,"channel":"stable","origin_node":"evil","node":"evil"}"#;
        let decoded: UpdRecord = serde_json::from_slice(wire).unwrap();
        assert_eq!(
            decoded,
            UpdRecord::Query { upd_id: "u:1".into(), current_version: 5, channel: "stable".into() }
        );
    }
}


Edit

pub mod sync;
pub mod update;
pub mod wanmsg;


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

pub mod sync;
pub mod update;
pub mod wanmsg;

//! The `net`-gated transport tree.
//!
//! Populated from D1 onward (see `M4-PLAN.md` / `lib.rs`): the Iroh endpoint binds
//! here first, then pairing, the per-subnet registry, cross-node sync, and the
//! update-propagation transport. A `--no-default-features` lib build excludes this
//! whole module (and the iroh/tokio footprint with it).

pub mod attach;
pub mod endpoint;
pub mod ndjson;
pub mod pairing;
pub mod registry;
pub mod replicate;
pub mod sync;
pub mod wanmsg;
pub mod xfer;

pub mod registry;
pub mod replicate;
pub mod sync;
+pub mod update;
pub mod wanmsg;
pub mod xfer;


117:    pub fn load() -> TrustStore {
125:    pub fn load_from(path: &Path) -> TrustStore {


152:    pub fn is_trusted(&self, subnet: &str, pubkey_hex: &str) -> bool {
153-        self.peers
154-            .iter()
155-            .any(|p| p.subnet == subnet && p.pubkey_hex == pubkey_hex)
156-    }
157-
158-    /// All peers trusted in `subnet`.
159-    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
160-        self.peers.iter().filter(move |p| p.subnet == subnet)
--
172:    pub fn record(
173-        &mut self,
174-        subnet: &str,
175-        pubkey_hex: &str,
176-        label: Option<&str>,
177-        user: Option<&str>,
178-    ) -> TrustOutcome {
179-        // Exact identity already trusted → idempotent (a key isn't "changed" against
180-        // itself, even when a label is present).


60	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61	        match self {
62	            TrustError::NotFound(h) => write!(f, "no trusted peer for: {h}"),
63	            TrustError::Io(e) => write!(f, "trust store io: {e}"),
64	        }
65	    }
66	}
67
68	impl std::error::Error for TrustError {}
69
70	/// The result of recording (pinning) a peer — the TOFU decision the caller acts on.
71	#[derive(Debug, PartialEq, Eq)]
72	pub enum TrustOutcome {
73	    /// First use of this `(subnet, pubkey)`: pinned now. The normal post-ceremony path.
74	    Pinned,
75	    /// This exact `(subnet, pubkey)` was already trusted — an idempotent re-pair.
76	    AlreadyTrusted,
77	    /// A peer under this `(subnet, label)` is already pinned with a **different** key.
78	    /// NOT applied: the caller must warn the user and, only if the change is accepted,
79	    /// call [`repin`](TrustStore::repin). The danger case is a MITM presenting a fresh
80	    /// key under a known node's label.
81	    KeyChanged {
82	        /// The hex pubkey currently pinned for that label (the one being challenged).
83	        existing_pubkey_hex: String,
84	    },
85	}
86
87	/// One authorized peer: its pinned node pubkey, the subnet it's trusted within, an
88	/// optional human label (the warn-on-change anchor), the cross-user seam, and a stamp.
89	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90	pub struct TrustedPeer {
91	    /// The peer's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The identity.
92	    pub pubkey_hex: String,
93	    /// The subnet this peer paired into / is trusted within.
94	    pub subnet: String,
95	    /// Optional human node label. When present it is the stable handle warn-on-change
96	    /// keys against; absent (v1 default) means the pubkey is the sole identity.
97	    #[serde(skip_serializing_if = "Option::is_none")]
98	    pub label: Option<String>,
99	    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
100	    #[serde(skip_serializing_if = "Option::is_none")]
101	    pub user: Option<String>,
102	    /// First-trust stamp (epoch-seconds string; opaque).
103	    pub paired: String,
104	}
105
106	/// The whole `peers.json` document: every peer this node trusts, across all subnets.
107	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108	pub struct TrustStore {
109	    /// All trusted peers (multi-subnet — one node trusts peers across N subnets).
110	    #[serde(default)]
111	    pub peers: Vec<TrustedPeer>,
112	}
113
114	impl TrustStore {
115	    /// Load from the canonical [`trust_file`]. A missing file is the empty store (an
116	    /// unpaired node) — never an error.
117	    pub fn load() -> TrustStore {
118	        Self::load_from(&trust_file())
119	    }
120
121	    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable or
122	    /// corrupt file also degrades to empty rather than panicking — a damaged trust file
123	    /// must not brick the node (re-pair to rebuild it), the same stance as
124	    /// [`SubnetStore`](crate::subnet::SubnetStore).
125	    pub fn load_from(path: &Path) -> TrustStore {
126	        match std::fs::read_to_string(path) {
127	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
128	            Err(_) => TrustStore::default(),
129	        }
130	    }
131
132	    /// Persist to the canonical [`trust_file`], creating `identity/trust/` if needed.
133	    pub fn save(&self) -> Result<(), TrustError> {
134	        self.save_to(&trust_file())
135	    }
136
137	    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
138	    /// concurrent reader never sees a torn trust file (the [`atomic`](crate::atomic)
139	    /// invariant).
140	    pub fn save_to(&self, path: &Path) -> Result<(), TrustError> {
141	        if let Some(parent) = path.parent() {
142	            std::fs::create_dir_all(parent).map_err(|e| TrustError::Io(e.to_string()))?;
143	        }
144	        let json =
145	            serde_json::to_string_pretty(self).map_err(|e| TrustError::Io(e.to_string()))?;
146	        atomic_write_string(path, &json).map_err(|e| TrustError::Io(e.to_string()))
147	    }
148
149	    /// Whether `pubkey_hex` is a trusted peer in `subnet` — the connect-time gate (an

    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            TrustError::NotFound(h) => write!(f, "no trusted peer for: {h}"),
            TrustError::Io(e) => write!(f, "trust store io: {e}"),
        }
    }
}

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

/// The result of recording (pinning) a peer — the TOFU decision the caller acts on.
#[derive(Debug, PartialEq, Eq)]
pub enum TrustOutcome {
    /// First use of this `(subnet, pubkey)`: pinned now. The normal post-ceremony path.
    Pinned,
    /// This exact `(subnet, pubkey)` was already trusted — an idempotent re-pair.
    AlreadyTrusted,
    /// A peer under this `(subnet, label)` is already pinned with a **different** key.
    /// NOT applied: the caller must warn the user and, only if the change is accepted,
    /// call [`repin`](TrustStore::repin). The danger case is a MITM presenting a fresh
    /// key under a known node's label.
    KeyChanged {
        /// The hex pubkey currently pinned for that label (the one being challenged).
        existing_pubkey_hex: String,
    },
}

/// One authorized peer: its pinned node pubkey, the subnet it's trusted within, an
/// optional human label (the warn-on-change anchor), the cross-user seam, and a stamp.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustedPeer {
    /// The peer's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The identity.
    pub pubkey_hex: String,
    /// The subnet this peer paired into / is trusted within.
    pub subnet: String,
    /// Optional human node label. When present it is the stable handle warn-on-change
    /// keys against; absent (v1 default) means the pubkey is the sole identity.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<String>,
    /// First-trust stamp (epoch-seconds string; opaque).
    pub paired: String,
}

/// The whole `peers.json` document: every peer this node trusts, across all subnets.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TrustStore {
    /// All trusted peers (multi-subnet — one node trusts peers across N subnets).
    #[serde(default)]
    pub peers: Vec<TrustedPeer>,
}

impl TrustStore {
    /// Load from the canonical [`trust_file`]. A missing file is the empty store (an
    /// unpaired node) — never an error.
    pub fn load() -> TrustStore {
        Self::load_from(&trust_file())
    }

    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable or
    /// corrupt file also degrades to empty rather than panicking — a damaged trust file
    /// must not brick the node (re-pair to rebuild it), the same stance as
    /// [`SubnetStore`](crate::subnet::SubnetStore).
    pub fn load_from(path: &Path) -> TrustStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => TrustStore::default(),
        }
    }

    /// Persist to the canonical [`trust_file`], creating `identity/trust/` if needed.
    pub fn save(&self) -> Result<(), TrustError> {
        self.save_to(&trust_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
    /// concurrent reader never sees a torn trust file (the [`atomic`](crate::atomic)
    /// invariant).
    pub fn save_to(&self, path: &Path) -> Result<(), TrustError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| TrustError::Io(e.to_string()))?;
        }
        let json =
            serde_json::to_string_pretty(self).map_err(|e| TrustError::Io(e.to_string()))?;
        atomic_write_string(path, &json).map_err(|e| TrustError::Io(e.to_string()))
    }

    /// Whether `pubkey_hex` is a trusted peer in `subnet` — the connect-time gate (an


49:pub fn atomic_write_string(path: &Path, body: &str) -> io::Result<()> {
50-    atomic_write_bytes(path, body.as_bytes())
51-}
52-
53-/// Byte-oriented sibling of [`atomic_write_string`] — for binary payloads
54-/// (blobs, signature material) that are not valid UTF-8.
--
56:pub fn atomic_write_bytes(path: &Path, body: &[u8]) -> io::Result<()> {
57-    let tmp = path.with_file_name(format!(
58-        "{}.tmp",
59-        path.file_name().and_then(|s| s.to_str()).unwrap_or("epoch")
60-    ));
61-    fs::write(&tmp, body)?;


Write ReleaseCache.


Write

//! 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");
    }
}


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

//! 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");
    }
}


100	//! (R-ARCH-2).
101	//!
102	//! [`Envelope`]: frame::Envelope
103	//! [`Hello`]: frame::Hello
104	//! [`DaemonTransport`]: transport::DaemonTransport
105
106	pub mod access;
107	pub mod adapter_update;
108	pub mod attach;
109	pub mod brain;
110	pub mod broker;
111	pub mod codec;
112	pub mod config;
113	pub mod consent;
114	pub mod daemon;
115	pub mod digest;
116	pub mod digesthub;
117	pub mod effect;
118	pub mod endpoint;
119	pub mod frame;
120	pub mod lifecycle;
121	pub mod msg;
122	pub mod nethost;
123	pub mod reconcile;
124	pub mod relay;
125	pub mod release;
126	pub mod seedmap;
127	pub mod sync;
128	pub mod transport;
129	pub mod update;
130	pub mod wan;
131	pub mod xfer;
132
133	#[cfg(test)]
134	mod test_home;
135
136	pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
137	pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
138	pub use adapter_update::{
139	    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
140	    SkipReason,
141	};
142	pub use brain::{Brain, BrainState, BrokerEvent};
143	pub use broker::Broker;
144	pub use config::{DaemonConfig, RelayChoice};
145	pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
146	pub use daemon::{ensure_running, is_running, Daemon};
147	pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
148	pub use digesthub::{
149	    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
150	    update_to_json, DigestHub,
151	};
152	pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
153	pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
154	pub use lifecycle::{BrainLifecycle, TickReport};
155	pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
156	pub use relay::Relay;
157	pub use seedmap::{put_seed, take_seed, SeedRegistry};
158	pub use frame::{
159	    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
160	    MIN_COMPATIBLE_VERSION,
161	};
162	pub use transport::{DaemonTransport, LocalSocketTransport};
163	pub use release::{
164	    parse_verifying_key, sha256_hex, verify_artifact, verify_metadata, verify_signature,
165	    RejectReason, ReleaseMetadata, SignedRelease, VerifyPolicy,
166	};
167	pub use update::{
168	    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
169	    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
170	};
171	pub use sync::{
172	    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
173	    SyncPullReport, SyncServeOutcome,
174	};
175	pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
176	pub use wan::{receive_wan, WanOutcome};
177

//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod access;
pub mod adapter_update;
pub mod attach;
pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod consent;
pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod nethost;
pub mod reconcile;
pub mod relay;
pub mod release;
pub mod seedmap;
pub mod sync;
pub mod transport;
pub mod update;
pub mod wan;
pub mod xfer;

#[cfg(test)]
mod test_home;

pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
pub use adapter_update::{
    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
    SkipReason,
};
pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::{DaemonConfig, RelayChoice};
pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use relay::Relay;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
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::{
    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
};
pub use sync::{
    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
    SyncPullReport, SyncServeOutcome,
};
pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
pub use wan::{receive_wan, WanOutcome};


Edit

pub mod reconcile;
pub mod relay;
pub mod release;

pub mod reconcile;
pub mod relay;
pub mod relcache;
pub mod release;


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

pub mod reconcile;
pub mod relay;
pub mod relcache;
pub mod release;

//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
//!
//! ADR-0004 splits the logical daemon into two implementation layers so a
//! routine self-update swaps the logic without disturbing live endpoints:
//!
//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
//!   live consumer would lose continuity on if the logic restarted — PTY
//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
//!   sockets — and runs no logic;
//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
//!   **versioned local IPC** so a *newer brain talks to an older broker*.
//!
//! **B0 (this commit)** lands the crate's layer position plus that versioned
//! IPC seam — the load-bearing contract everything else rides:
//!
//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
//!
//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
//! half that hosts a real `spt-term` PTY child and relays its output/input over
//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
//! broker↔brain message catalog ([`msg`]).
//!
//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
//! constant; the only residue left in the harness-hosted session is the thin,
//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
//!
//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
//! instantiated on the real PTY-write boundary).
//!
//! **C0 (this commit)** opens M3c — the self-update engine ([`update`]): the
//! ADR-0004 §A update-class taxonomy ([`UpdateClass`] + [`classify`]) and the
//! **brain-only zero-interruption swap** ([`apply_brain_only`], [`REQ-UPD-3`]).
//! The brain-only apply *triggers* a handoff over the B2 substrate — snapshot →
//! drop → re-attach — so the live PTY child + its output stream survive the logic
//! swap untouched; it never re-implements the handoff and never touches a broker
//! resource.
//!
//! **C1 (this commit)** adds the verify-before-handoff gate ([`release`]): a
//! release is signature-verified ([`REQ-UPD-2`]) and hardened against rollback
//! ([`REQ-HAZARD-UPDATE-ROLLBACK`]) — Ed25519 over signed metadata, key
//! trust/revocation, channel pinning, metadata expiry, monotonic version, and a
//! SHA-256 binding the signature to the actual binary bytes. [`plan_verified`] is
//! the front door: it produces an [`UpdatePlan`] *only* for a release that passes
//! every check, so an unverified binary can never reach [`apply_brain_only`].
//!
//! **C3 (this commit)** conducts the **adapter ripple** ([`adapter_update`]):
//! after self-update, spt-core updates each registered adapter via its manifest
//! `[update]` avenue ([`REQ-UPD-5`], [`REQ-SEAM-UPDATE`]). `file_pull` payloads
//! are verified against the **adapter's own** key (manifest-declared) + SHA-256
//! (adapter content signing, ADR-0004 §D); `delegated` updaters are opaque, so
//! spt-core delegates the trust only when the adapter attests `self_verifies`,
//! else skips them as unverifiable.
//!
//! **D4b (this commit)** makes broker-held QUIC streams survive brain restarts
//! **gaplessly and exactly-once** (Spike #3 invariants A–D as real tests): each
//! stream's reads are buffered in a sequenced ring (the B2 `OutputLog`
//! discipline) a brain subscribes to with a resume cursor; sends/opens carry
//! durable `op_id`s journaled at the broker, so a restarted brain's re-drive
//! dedups instead of double-writing the wire.
//!
//! **D4a (this commit)** moves the node's network presence into the broker
//! ([`nethost::NetHost`], ADR-0004 §B): a dedicated tokio runtime hosts the
//! iroh endpoint + every accepted/dialed QUIC connection, so a brain restart
//! never interrupts a cross-node connection. The brain drives net operations
//! (endpoint-status, dial) over new IPC frames in the same forward-compatible
//! catalog; an `op_id`-carrying dial is exactly-once through the B5
//! [`effect::EffectJournal`]. The endpoint binds as the durable node identity
//! (`spt-store`'s `node.key`), with the relay stance from `daemon.json`.
//!
//! **D5a (this commit)** puts WAN message delivery on the D4 substrate: a
//! [`WanMessage`](spt_net::net::wanmsg::WanMessage) NDJSON feed over a
//! broker-owned QUIC stream funnels through [`wan::receive_wan`] — the ADR-0009
//! access-gate seam ([`access`], handshake-proven origin only, KNOWN-HAZARDS
//! 7.5) plus durable op-id dedup (7.2, wire-fed) — into the existing local
//! REQ-MSG delivery path.
//!
//! **D5b (this commit)** adds remote terminal attach ([`attach`], REQ-INST-8):
//! a byte-stream viewport onto a local session for an operator daemon on
//! another node — session output pumped as sequenced
//! [`AttachRecord`](spt_net::net::attach::AttachRecord)s (deduped at the
//! operator's render cursor), operator input through the B5 exactly-once PTY
//! path, the whole loop gated and restart-survivable purely from the D4/B5
//! machinery.
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod access;
pub mod adapter_update;
pub mod attach;
pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod consent;
pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod nethost;
pub mod reconcile;
pub mod relay;
pub mod release;
pub mod seedmap;
pub mod sync;
pub mod transport;
pub mod update;
pub mod wan;
pub mod xfer;

#[cfg(test)]
mod test_home;

pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
pub use adapter_update::{
    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
    SkipReason,
};
pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::{DaemonConfig, RelayChoice};
pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use relay::Relay;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
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::{
    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
};
pub use sync::{
    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
    SyncPullReport, SyncServeOutcome,
};
pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
pub use wan::{receive_wan, WanOutcome};

pub mod nethost;
pub mod reconcile;
pub mod relay;
+pub mod relcache;
pub mod release;
pub mod seedmap;
pub mod sync;


pub use relay::Relay;
pub use relcache::ReleaseCache;


pub use relay::Relay;
pub use relcache::ReleaseCache;

//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
//!
//! ADR-0004 splits the logical daemon into two implementation layers so a
//! routine self-update swaps the logic without disturbing live endpoints:
//!
//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
//!   live consumer would lose continuity on if the logic restarted — PTY
//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
//!   sockets — and runs no logic;
//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
//!   **versioned local IPC** so a *newer brain talks to an older broker*.
//!
//! **B0 (this commit)** lands the crate's layer position plus that versioned
//! IPC seam — the load-bearing contract everything else rides:
//!
//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
//!
//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
//! half that hosts a real `spt-term` PTY child and relays its output/input over
//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
//! broker↔brain message catalog ([`msg`]).
//!
//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
//! constant; the only residue left in the harness-hosted session is the thin,
//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
//!
//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
//! instantiated on the real PTY-write boundary).
//!
//! **C0 (this commit)** opens M3c — the self-update engine ([`update`]): the
//! ADR-0004 §A update-class taxonomy ([`UpdateClass`] + [`classify`]) and the
//! **brain-only zero-interruption swap** ([`apply_brain_only`], [`REQ-UPD-3`]).
//! The brain-only apply *triggers* a handoff over the B2 substrate — snapshot →
//! drop → re-attach — so the live PTY child + its output stream survive the logic
//! swap untouched; it never re-implements the handoff and never touches a broker
//! resource.
//!
//! **C1 (this commit)** adds the verify-before-handoff gate ([`release`]): a
//! release is signature-verified ([`REQ-UPD-2`]) and hardened against rollback
//! ([`REQ-HAZARD-UPDATE-ROLLBACK`]) — Ed25519 over signed metadata, key
//! trust/revocation, channel pinning, metadata expiry, monotonic version, and a
//! SHA-256 binding the signature to the actual binary bytes. [`plan_verified`] is
//! the front door: it produces an [`UpdatePlan`] *only* for a release that passes
//! every check, so an unverified binary can never reach [`apply_brain_only`].
//!
//! **C3 (this commit)** conducts the **adapter ripple** ([`adapter_update`]):
//! after self-update, spt-core updates each registered adapter via its manifest
//! `[update]` avenue ([`REQ-UPD-5`], [`REQ-SEAM-UPDATE`]). `file_pull` payloads
//! are verified against the **adapter's own** key (manifest-declared) + SHA-256
//! (adapter content signing, ADR-0004 §D); `delegated` updaters are opaque, so
//! spt-core delegates the trust only when the adapter attests `self_verifies`,
//! else skips them as unverifiable.
//!
//! **D4b (this commit)** makes broker-held QUIC streams survive brain restarts
//! **gaplessly and exactly-once** (Spike #3 invariants A–D as real tests): each
//! stream's reads are buffered in a sequenced ring (the B2 `OutputLog`
//! discipline) a brain subscribes to with a resume cursor; sends/opens carry
//! durable `op_id`s journaled at the broker, so a restarted brain's re-drive
//! dedups instead of double-writing the wire.
//!
//! **D4a (this commit)** moves the node's network presence into the broker
//! ([`nethost::NetHost`], ADR-0004 §B): a dedicated tokio runtime hosts the
//! iroh endpoint + every accepted/dialed QUIC connection, so a brain restart
//! never interrupts a cross-node connection. The brain drives net operations
//! (endpoint-status, dial) over new IPC frames in the same forward-compatible
//! catalog; an `op_id`-carrying dial is exactly-once through the B5
//! [`effect::EffectJournal`]. The endpoint binds as the durable node identity
//! (`spt-store`'s `node.key`), with the relay stance from `daemon.json`.
//!
//! **D5a (this commit)** puts WAN message delivery on the D4 substrate: a
//! [`WanMessage`](spt_net::net::wanmsg::WanMessage) NDJSON feed over a
//! broker-owned QUIC stream funnels through [`wan::receive_wan`] — the ADR-0009
//! access-gate seam ([`access`], handshake-proven origin only, KNOWN-HAZARDS
//! 7.5) plus durable op-id dedup (7.2, wire-fed) — into the existing local
//! REQ-MSG delivery path.
//!
//! **D5b (this commit)** adds remote terminal attach ([`attach`], REQ-INST-8):
//! a byte-stream viewport onto a local session for an operator daemon on
//! another node — session output pumped as sequenced
//! [`AttachRecord`](spt_net::net::attach::AttachRecord)s (deduped at the
//! operator's render cursor), operator input through the B5 exactly-once PTY
//! path, the whole loop gated and restart-survivable purely from the D4/B5
//! machinery.
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod access;
pub mod adapter_update;
pub mod attach;
pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod consent;
pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod nethost;
pub mod reconcile;
pub mod relay;
pub mod relcache;
pub mod release;
pub mod seedmap;
pub mod sync;
pub mod transport;
pub mod update;
pub mod wan;
pub mod xfer;

#[cfg(test)]
mod test_home;

pub use access::{access_check, classify_inbound, note_outbound, AccessDecision, InboundClass};
pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};
pub use adapter_update::{
    conduct_ripple, plan_adapter_update, AdapterRipple, AdapterUpdateOutcome, FilePullPayload,
    SkipReason,
};
pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::{DaemonConfig, RelayChoice};
pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
pub use daemon::{ensure_running, is_running, Daemon};
pub use digest::{ingest_chunk, DigestEngine, DigestUpdate};
pub use digesthub::{
    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
    update_to_json, DigestHub,
};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use nethost::{NetConfig, NetHost, NET_EFFECT_SESSION};
pub use relay::Relay;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
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::{
    apply_brain_only, classify, plan_update, plan_verified, ApplyError, BrokerAbi, ReleaseSpec,
    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
};
pub use sync::{
    reconcile_after_sync, request_sync, select_refs, serve_sync, ReconcileWiring, SyncPolicy,
    SyncPullReport, SyncServeOutcome,
};
pub use xfer::{fetch_file, push_file, serve_xfer, XferServeOutcome};
pub use wan::{receive_wan, WanOutcome};


crates\spt-daemon\Cargo.toml:45:tempfile = "3"
crates\spt-store\src\lib.rs:15:pub mod atomic;


Activate REQ-UPD-1 in registry, then build + test.


id = "REQ-UPD-1"
title = "Peer-propagated update over P2P"
required_stages = []

id = "REQ-UPD-1"
title = "Peer-propagated update over P2P"
required_stages = ["impl", "unit"]  # activated M4-D7-1: net/update.rs UpdRecord offer-then-fetch wire protocol + relcache.rs staged-release cache (verified-pull ⇒ staged ⇒ servable — the self-heal hop); D7-2 adds the serve/request drivers + loopback propagation E2E; int = D9 two-host


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

id = "REQ-UPD-1"
title = "Peer-propagated update over P2P"
required_stages = []

id = "REQ-UPD-1"
title = "Peer-propagated update over P2P"
required_stages = ["impl", "unit"]  # activated M4-D7-1: net/update.rs UpdRecord offer-then-fetch wire protocol + relcache.rs staged-release cache (verified-pull ⇒ staged ⇒ servable — the self-heal hop); D7-2 adds the serve/request drivers + loopback propagation E2E; int = D9 two-host

+required_stages = ["impl", "unit"]  # activated M4-D7-1: net/update.rs UpdRecord offer-then-fetch wire protocol + relcache.rs staged-release cache (verified-pull ⇒ staged ⇒ servable — the self-heal hop); D7-2 adds the serve/request drivers + loopback propagation E2E; int = D9 two-host

title = "All binaries signature-verified before handoff"


PowerShell

cargo test -p spt-net -p spt-daemon --lib 2>&1 | Select-Object -Last 30

Build and run lib tests for spt-net and spt-daemon


test net::sync::tests::forged_origin_field_is_inert ... ok
test net::sync::tests::record_split_across_chunks_reassembles ... ok
test net::pairing::spake::tests::wrong_subnet_fails ... ok
test net::sync::tests::records_round_trip_and_unknown_kind_is_skipped ... ok
test net::update::tests::forged_origin_field_is_inert ... ok
test net::update::tests::record_split_across_chunks_reassembles ... ok
test net::update::tests::records_round_trip_and_unknown_kind_is_skipped ... ok
test net::wanmsg::tests::forged_origin_field_is_inert ... ok
test net::wanmsg::tests::message_round_trips_and_tolerates_unknown_field ... ok
test net::wanmsg::tests::route_splits_local_and_remote_and_passes_refusals ... ok
test net::pairing::spake::tests::matching_code_pairs_and_agrees_on_key ... ok
test net::xfer::tests::forged_origin_field_is_inert ... ok
test net::xfer::tests::record_split_across_chunks_reassembles ... ok
test net::xfer::tests::records_round_trip_and_unknown_kind_is_skipped ... ok
test net::pairing::spake::tests::tampered_tag_fails ... ok
test net::pairing::spake::tests::stale_step_fails ... ok
test net::pairing::spake::tests::wrong_code_fails_at_confirmation_not_spake ... ok
test net::pairing::spake::tests::stale_epoch_fails ... ok
test net::pairing::spake::tests::substituted_pubkey_fails ... ok
test net::pairing::wire::tests::busy_subnet_refuses_ceremony ... ok
test net::pairing::wire::tests::unknown_subnet_is_rejected ... ok
test net::pairing::wire::tests::stale_step_is_rejected ... ok
test net::endpoint::tests::two_endpoints_loopback_echo ... ok
test net::pairing::wire::tests::wrong_code_fails_and_writes_no_trust ... ok
test net::pairing::wire::tests::create_new_then_join_learns_named_subnet ... ok
test net::pairing::wire::tests::repair_member_keeps_seed ... ok
test net::pairing::wire::tests::loopback_pairs_and_pins_both_sides ... ok

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


cargo test -p spt-daemon --lib relcache 2>&1 | Select-Object -Last 8; cargo clippy -p spt-net -p spt-daemon --all-targets -- -D warnings 2>&1 | Select-Object -Last 8

Run relcache unit tests + clippy gate


     Running unittests src\lib.rs (target\debug\deps\spt_daemon-df85ccfb281dc93f.exe)

running 2 tests
test relcache::tests::stage_round_trips_and_restage_replaces ... ok
test relcache::tests::torn_or_corrupt_stage_offers_nothing ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 91 filtered out; finished in 0.01s

    Checking spt-msg v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
    Checking spt-runtime v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime)
    Checking spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Checking spt-net v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Checking spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Checking spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.60s


.\target\debug\traceable-reqs.exe check 2>&1 | Select-Object -Last 5; if (-not $?) { traceable-reqs check 2>&1 | Select-Object -Last 5 }

Run traceable-reqs check


.\target\debug\traceable-reqs.exe: The term '.\target\debug\traceable-reqs.exe' is not recognized as a name of a cmdlet, function, script file, or executable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters


traceable-reqs check 2>&1 | Select-String -Pattern "REQ-UPD-1|FAIL|missing|invalid|exit|summary|All " | Select-Object -First 10; echo "exit=$LASTEXITCODE"

Check traceable-reqs result for REQ-UPD-1


       Many small acyclically-layered crates
       One per-machine spt-daemon owning all per-machine state
       A surfaced concurrent context pair is durably preserved (both versions, tracked artifacts) until a strictly dominating write
clears it; no reconcile failure path discards an unmerged version (6.6, ADR-0013)
       ConPTY reader must auto-answer DSR (ESC[6n) or all child output stalls (5.5)
       Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune
summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)
       Ephemeral perch cleanup on every ring exit path (3.1)
       Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)
       Stale registry entries degrade to fallback, never hard-fail (4.3)
       spt rename <id> rippled to all instances (collision-checked, 6.5-reconciled)
       Two install paths; signed one-line script; OS-service registration
exit=


traceable-reqs check > $null 2>&1; echo "exit=$LASTEXITCODE"; traceable-reqs check 2>&1 | Select-String -SimpleMatch "REQ-UPD-1" -Context 0,1

traceable-reqs exit code + REQ-UPD-1 row


exit=0

>   [OK] REQ-UPD-1  required: [impl, unit]  stages: -doc +impl +unit -int
         Peer-propagated update over P2P


git add M4-D7-PLAN.md crates/spt-net/src/net.rs crates/spt-net/src/net/update.rs crates/spt-daemon/src/lib.rs crates/spt-daemon/src/relcache.rs traceable-reqs.toml && git commit -m @'
feat(net,daemon): M4-D7-1 update propagation wire + staged-release cache (REQ-UPD-1)

- M4-D7-PLAN.md: JIT plan — offer-then-fetch pull protocol feeding the M3c
  engine front door unchanged; gate per receiving node; staged = servable
- spt-net net/update.rs: UpdRecord (Query/Offer/UpToDate/Fetch/Chunk/Done/Err)
  NDJSON records, no origin field (handshake-proven subject); UpdDecoder
- spt-daemon relcache.rs: ReleaseCache — atomic artifact-then-metadata stage
  (release.json = commit point), torn/corrupt stage never offers
- activate REQ-UPD-1 -> [impl, unit]

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

Commit and push D7-1 slice


warning: in the working copy of 'crates/spt-daemon/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'M4-D7-PLAN.md', 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-net/src/net/update.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 0d05e5c] feat(net,daemon): M4-D7-1 update propagation wire + staged-release cache (REQ-UPD-1)
 6 files changed, 425 insertions(+), 1 deletion(-)
 create mode 100644 M4-D7-PLAN.md
 create mode 100644 crates/spt-daemon/src/relcache.rs
 create mode 100644 crates/spt-net/src/net/update.rs
To https://github.com/SaberMage/spt-core.git
   fa97b80..0d05e5c  dev-freeform -> dev-freeform


Grep

pub fn net_stream_send|pub fn net_open_stream|pub fn net_streams|pub fn net_dial|pub fn net_status


469-    }
470-
471-    \ Query the broker-owned net endpoint's status (D4a): node id, dialable
472-    \ address, conn count — or `enabled: false` on a net-less broker. Reads
473-    \ until the reply (consuming interleaved events like [`Brain::spawn_session`]).
474-    // [impl->REQ-DAEMON-2]
475:    pub fn net_status(&mut self) -> io::Result<NetStatusReply> {
476-        self.send(KIND_NET_STATUS, serde_json::Value::Null)?;
477-        loop {
478-            match self.read_event()? {
479-                BrokerEvent::NetStatus(s) => return Ok(s),
480-                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
481-                _ => continue,
482-            }
483-        }
484-    }
485-
486-    /// Dial a peer over the broker-owned endpoint (D4a). `addr` is the peer's
487-    /// serialized `EndpointAddr` (as another node's `net-status` reply carries).
488-    \ With `op_id` the dial is exactly-once: a replay of the same durable id
489-    /// after a brain crash is deduped at the broker and answered with the
490-    \ already-open connection ([`NetDialed::applied_now`] distinguishes).
491-    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
492:    pub fn net_dial(
493-        &mut self,
494-        addr: serde_json::Value,
495-        op_id: Option<u64>,
496-    ) -> io::Result<NetDialed> {
497-        self.send(
498-            KIND_NET_DIAL,
499-            serde_json::to_value(NetDialReq { addr, op_id }).expect("NetDialReq serializes"),
500-        )?;
501-        loop {
502-            match self.read_event()? {
503-                BrokerEvent::NetDialed(d) => return Ok(d),
504-                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
--
509-
510-    /// Open a bidi stream on a held connection (D4b). With `op_id` the open is
511-    /// exactly-once (a replay is answered with the original stream). NOTE all
512-    /// net op ids — dials, opens, sends — share ONE durable namespace; mint
513-    /// them from a single durable counter.
514-    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
515:    pub fn net_open_stream(
516-        &mut self,
517-        conn_id: u64,
518-        op_id: Option<u64>,
519-    ) -> io::Result<NetStreamOpened> {
520-        self.send(
521-            KIND_NET_STREAM_OPEN,
522-            serde_json::to_value(NetStreamOpenReq { conn_id, op_id })
523-                .expect("NetStreamOpenReq serializes"),
524-        )?;
525-        loop {
526-            match self.read_event()? {
527-                BrokerEvent::NetStreamOpened(ev) => return Ok(ev),
--
533-
534-    /// Write bytes to a held stream over the broker (D4b). With `op_id` the
535-    /// write is exactly-once and this blocks for the `net-sent` ack (returned);
536-    /// without one it is a direct fire-and-forget write (`None` returned).
537-    /// `finish` closes the send side after the write.
538-    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
539:    pub fn net_stream_send(
540-        &mut self,
541-        stream_id: u64,
542-        bytes: &[u8],
543-        op_id: Option<u64>,
544-        finish: bool,
545-    ) -> io::Result<Option<NetSent>> {
546-        self.send(
547-            KIND_NET_STREAM_SEND,
548-            serde_json::to_value(NetStreamSendReq {
549-                stream_id,
550-                data_b64: encode_bytes(bytes),
551-                op_id,
--
564-            }
565-        }
566-    }
567-
568-    /// List the streams the broker holds (D4b) — how a re-attaching brain
569-    /// finds streams (incl. peer-initiated ones) that arrived while it was down.
570:    pub fn net_streams(&mut self) -> io::Result<NetStreamsReply> {
571-        self.send(KIND_NET_STREAMS, serde_json::Value::Null)?;
572-        loop {
573-            match self.read_event()? {
574-                BrokerEvent::NetStreams(ev) => return Ok(ev),
575-                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
576-                _ => continue,
577-            }
578-        }
579-    }
580-
581-    /// (Re)subscribe to a stream's buffered reads from `from_seq`, setting the
582-    /// local cursor (gapless, exactly-once resume — the PTY `subscribe`


`finish` semantics confirmed. Write D7-2 drivers.