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

//! The session-digest **log projection** — the daemon-side, on-demand projection
//! of an endpoint's live activity buffer from its normalized session logs
//! (ADR-0008 amendment, 2026-06-12; REQ-TERM-4 log-source re-foundation).
//!
//! ## On-demand projection, not a byte feed
//!
//! The superseded shape folded broker PTY bytes through an adapter-pattern parser
//! on every output chunk. The digest is now a **projection of the same normalized
//! history the echo-commune consumes** — so there is nothing to feed: a snapshot
//! pull (or a `digest-entry` push) **projects on demand** from the endpoint's
//! records via [`project_endpoint_digest`], and the hub publishes the result.
//! Render surfaces read the PTY (the raw byte ring, unchanged); content surfaces
//! read the logs.
//!
//! ## Sources (one per endpoint)
//!
//! - An adapter that declares `[history]` (Path A locate+normalize / fetcher, or
//!   Path B native store) → the projection tails those normalized records. This
//!   is the **topology-independent** path: a Claude-Code-hosted endpoint's
//!   per-session JSONL is Path A's worked example.
//! - A **log-less** adapter pushes digest-records directly via `api digest-entry`
//!   into the perch's `digest.log` → the projection reads that.
//!
//! The records carry the published digest-record contract
//! ([`spt_term::DigestRecord`]); the projection folds them into the structured
//! [`Digest`] (N-turn window, collapsed tool sprints, ~25-char arg truncation).
//!
//! ## Snapshot vs delta
//!
//! A projection is diffed against the hub's prior snapshot
//! ([`crate::digesthub::DigestHub::project_and_publish`]) to produce a
//! [`DigestUpdate`]: `from` is where the changed turns begin, and the subscriber
//! applies it by **truncating its view to `from`, then appending `turns`**. The
//! delta-stream **contract** ships; its deltas are driven by pull / digest-entry
//! publishes (autonomous file-watch freshness is deferred to the consuming
//! frontend milestone — CONTEXT.md "session digest").
// [impl->REQ-TERM-4]

use std::time::Duration;

use serde::{Deserialize, Serialize};

use spt_live::fetch_history;
use spt_store::perch::{self, ParentHint};
use spt_term::{Digest, DigestConfig, Turn};

/// Bound for the projection's history fetch (a `[history]` fetcher/normalize is a
/// subprocess — REQ-HAZARD-SUBPROCESS-TIMEOUT; a hang is killed, never blocks).
const DIGEST_FETCH_TIMEOUT: Duration = Duration::from_secs(30);

/// A structured digest change to push to a subscriber. Sent only when the digest
/// actually changed (so the stream carries deltas, not heartbeats).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DigestUpdate {
    /// Monotonic version of the digest after this change (starts at 1).
    pub version: u64,
    /// The window index where `turns` begins. Apply: truncate the view to `from`,
    /// then append `turns`. `from == 0` is a full replace (the base snapshot, or a
    /// window slide that invalidated old indices).
    pub from: usize,
    /// The changed turns starting at `from` (the whole window when `from == 0`).
    pub turns: Vec<Turn>,
}

/// Length of the equal-by-value prefix shared by two turn slices — the diff base
/// for a [`DigestUpdate`] (`from`). A pure in-place/extend keeps the head intact →
/// `from` points at the changed tail (a minimal delta); a window slide evicts the
/// oldest turn → the heads differ from index 0 → `from == 0` (a full refresh).
pub fn common_prefix_len(a: &[Turn], b: &[Turn]) -> usize {
    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
}

/// Project an endpoint's current session digest on demand from its normalized
/// records. Resolves the endpoint's source (the `[history]` strategy by its
/// recorded adapter, else the perch's `digest.log`), reads the records, and folds
/// them through the published-contract projection. An endpoint with no records
/// (or no resolvable source) projects an empty [`Digest`] — never an error, never
/// a block.
// [impl->REQ-TERM-4]
pub fn project_endpoint_digest(id: &str, config: &DigestConfig) -> Digest {
    let lines = endpoint_digest_records(id);
    spt_term::project_lines(lines.iter().map(String::as_str), config)
}

/// The endpoint's current normalized digest-records as raw lines. Source order:
/// the adapter's `[history]` strategy when declared (the topology-independent
/// path — fetch + normalize), otherwise the perch's `digest.log` (the log-less
/// `api digest-entry` push sink). A fetch failure or a missing log yields no
/// records (a fresh / source-less endpoint), not an error.
fn endpoint_digest_records(id: &str) -> Vec<String> {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let info = spt_store::info::read_info(&perch_path);
    let session_id = info.as_ref().map(|i| i.session_id.clone());

    // Resolve the adapter's [history] strategy by the perch's recorded adapter.
    let history = info
        .as_ref()
        .and_then(|i| i.adapter.as_deref())
        .and_then(|adapter| {
            spt_runtime::registry::resolve_option(&perch::adapters_dir(), adapter)
                .ok()
                .and_then(|(_, m)| m.history)
        });

    if let Some(h) = history {
        let mut keys = std::collections::BTreeMap::new();
        keys.insert("id".to_string(), id.to_string());
        if let Some(sid) = session_id {
            keys.insert("session_id".to_string(), sid);
        }
        return fetch_history(&h, &keys, id, ParentHint::Infer, DIGEST_FETCH_TIMEOUT)
            .map(|recs| recs.into_iter().map(|r| r.raw).collect())
            .unwrap_or_default();
    }

    // Log-less adapter: the `api digest-entry` push sink.
    match std::fs::read_to_string(perch_path.join("digest.log")) {
        Ok(s) => s.lines().map(str::to_string).collect(),
        Err(_) => Vec::new(),
    }
}

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

    fn turn(input: &str) -> Turn {
        Turn {
            input: Some(input.to_string()),
            entries: Vec::new(),
        }
    }

    // [unit->REQ-TERM-4] the diff base: a shared head yields its length (a tail
    // delta); a divergent front yields 0 (a full refresh).
    #[test]
    fn common_prefix_len_finds_the_unchanged_head() {
        let a = vec![turn("a"), turn("b")];
        let grown = vec![turn("a"), turn("b"), turn("c")];
        assert_eq!(common_prefix_len(&a, &grown), 2, "shared head of two");
        let slid = vec![turn("b"), turn("c"), turn("d")];
        assert_eq!(common_prefix_len(&a, &slid), 0, "front eviction → 0");
    }

    // [unit->REQ-TERM-4] a source-less endpoint (no perch, no records) projects an
    // empty digest — never an error, never a block.
    #[test]
    fn projects_empty_for_a_sourceless_endpoint() {
        let _h = crate::test_support::isolated_home();
        let d = project_endpoint_digest("nobody", &DigestConfig::default());
        assert!(d.turns.is_empty());
    }

    // [unit->REQ-TERM-4] a log-less endpoint projects from its `digest.log` push
    // sink (the `api digest-entry` source) — input opens a turn, agent appends,
    // consecutive tools collapse.
    #[test]
    fn projects_a_log_less_endpoint_from_digest_log() {
        let _h = crate::test_support::isolated_home();
        let perch = perch::resolve_perch_path("solo", ParentHint::Infer);
        std::fs::create_dir_all(&perch).unwrap();
        // No adapter recorded ⇒ the digest.log path.
        let rec = spt_store::info::InfoJson::new("solo", "0", std::process::id(), "sid", "live_agent");
        spt_store::info::write_info(&perch, &rec).unwrap();
        for line in [
            r#"{"role":"input","text":"build it"}"#,
            r#"{"role":"agent","text":"on it"}"#,
            r#"{"role":"tool","tool":{"name":"Write","arg":"a.rs"}}"#,
        ] {
            spt_store::history::append_digest_entry(&perch, line).unwrap();
        }
        let d = project_endpoint_digest("solo", &DigestConfig::default());
        assert_eq!(d.turns.len(), 1);
        assert_eq!(d.turns[0].input.as_deref(), Some("build it"));
        assert!(d.turns[0]
            .entries
            .iter()
            .any(|e| matches!(e, DigestEntry::ToolSprint(_))));
    }
}

-//! The PTY-digest engine (B8) — the **brain-side** projection of the live

-//! activity buffer (ADR-0008, REQ-TERM-4).

+//! The session-digest **log projection** — the daemon-side, on-demand projection

+//! of an endpoint's live activity buffer from its normalized session logs

+//! (ADR-0008 amendment, 2026-06-12; REQ-TERM-4 log-source re-foundation).

+//! ## On-demand projection, not a byte feed

-//! ADR-0008 frames the digest as a "broker-pushed structured-delta stream," but

-//! that wording predates the strict ADR-0004 §B split. Running adapter-supplied

-//! regexes over a byte stream is **logic** — routing/registry/parse is exactly

-//! what the broker must NOT hold (M3b-PLAN risk: "a task that finds itself

-//! putting parse in the broker has crossed the line"). So the digest is a

-//! brain-side projection: the brain already subscribes to the broker's R-TERM-3

-//! output stream for a session ([`crate::brain::Brain`]); this engine folds those

-//! same bytes through the M3a [`DigestParser`] and publishes the structured

-//! result. Raw bytes (R-TERM-3) and the parsed digest are the "two channels on

-//! the one session surface" ADR-0008 describes — the broker owns the bytes, the

+//! The superseded shape folded broker PTY bytes through an adapter-pattern parser

+//! on every output chunk. The digest is now a **projection of the same normalized

+//! history the echo-commune consumes** — so there is nothing to feed: a snapshot

+//! pull (or a `digest-entry` push) **projects on demand** from the endpoint's

+//! records via [`project_endpoint_digest`], and the hub publishes the result.

+//! Render surfaces read the PTY (the raw byte ring, unchanged); content surfaces

-//! [`DigestEngine::feed`] returns a [`DigestUpdate`] only when the digest changed,

-//! so a subscriber receives **only deltas**, never a re-push of an unchanged

-//! buffer. The update is index-anchored: `from` is where the changed turns begin

-//! in the window, and the subscriber applies it by **truncating its view to

-//! `from`, then appending `turns`**. In the steady state (the active turn grows

-//! as the agent emits output) `from` points at that last turn and `turns` is just

-//! it — a one-turn delta. When the rolling window slides (the oldest turn

-//! evicted) the front turns no longer match, so `from` is `0` and the whole

-//! current window is sent (a natural full refresh — no separate flag needed).

+//! - An adapter that declares `[history]` (Path A locate+normalize / fetcher, or

+//!   Path B native store) → the projection tails those normalized records. This

+//!   is the **topology-independent** path: a Claude-Code-hosted endpoint's

+//!   per-session JSONL is Path A's worked example.

+//! - A **log-less** adapter pushes digest-records directly via `api digest-entry`

+//!   into the perch's `digest.log` → the projection reads that.

-//! ## Opt-in Path-B persistence (ADR-0008 "Option (C)")

+//! The records carry the published digest-record contract

+//! ([`spt_term::DigestRecord`]); the projection folds them into the structured

+//! [`Digest`] (N-turn window, collapsed tool sprints, ~25-char arg truncation).

-//! When enabled, [`DigestUpdate::newly_completed`] carries each turn that settled

-//! this feed (every turn but the still-active last one, deduped against the last

-//! persisted turn). The feed driver appends them to the perch history store as a

-//! **coarse** activity log — explicitly not a replacement for Path A/B transcript

-//! history. Because the engine is fed every output chunk, a completed turn is

-//! observed long before the rolling window could evict it, so the dedup-by-value

-//! tracking never drops or double-writes a turn in practice (a multi-turn single

-//! feed is the only degenerate case, and it can at worst re-emit in-window turns —

-//! bounded by the window depth, acceptable for a coarse log).

+//! A projection is diffed against the hub's prior snapshot

+//! ([`crate::digesthub::DigestHub::project_and_publish`]) to produce a

+//! [`DigestUpdate`]: `from` is where the changed turns begin, and the subscriber

+//! applies it by **truncating its view to `from`, then appending `turns`**. The

+//! delta-stream **contract** ships; its deltas are driven by pull / digest-entry

+//! publishes (autonomous file-watch freshness is deferred to the consuming

+//! frontend milestone — CONTEXT.md "session digest").

-use spt_term::{Digest, DigestConfig, DigestError, DigestParser, DigestPatterns, Turn};

+use spt_term::{Digest, DigestConfig, Turn};

+/// Bound for the projection's history fetch (a `[history]` fetcher/normalize is a

+/// subprocess — REQ-HAZARD-SUBPROCESS-TIMEOUT; a hang is killed, never blocks).

+const DIGEST_FETCH_TIMEOUT: Duration = Duration::from_secs(30);

/// actually changed (so the stream carries deltas, not heartbeats).

/// The changed turns starting at `from` (the whole window when `from == 0`).

-    /// Turns that settled this feed (for opt-in Path-B persistence). Server-side

-    /// only — never crosses the control channel (the subscriber sees the parsed

-    /// turns via `turns`, not the persistence bookkeeping).

-/// The brain-side digest engine for one session: a [`DigestParser`] plus the

-/// change-tracking that turns "the whole window, re-derived" into pushable

-/// deltas + completed-turn persistence.

-    /// The last digest we computed (the diff base + the version anchor).

-    /// Monotonic version, bumped on each observed change.

-    /// Whether opt-in Path-B persistence is requested for this endpoint.

-    /// The most recent turn handed to persistence — the dedup floor so a turn is

-    /// appended exactly once as it settles.

+/// Length of the equal-by-value prefix shared by two turn slices — the diff base

+/// for a [`DigestUpdate`] (`from`). A pure in-place/extend keeps the head intact →

+/// `from` points at the changed tail (a minimal delta); a window slide evicts the

+/// oldest turn → the heads differ from index 0 → `from == 0` (a full refresh).

+pub fn common_prefix_len(a: &[Turn], b: &[Turn]) -> usize {

+    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()

-    /// Build an engine from the manifest [`PtyDigest`] seam. Compiles the

-    /// adapter-supplied patterns (returning [`DigestError`] naming the first that

-    /// fails) and applies the spt-core presentation defaults, with `window_turns`

-    /// overridden when the manifest sets it.

-    pub fn from_manifest(seam: &PtyDigest) -> Result<Self, DigestError> {

-        let patterns = DigestPatterns {

-            input: seam.input_pattern.clone(),

-            agent: seam.agent_pattern.clone(),

-            tools: seam.tool_patterns.clone(),

-            catchall: seam.catchall_pattern.clone(),

-        let mut config = DigestConfig::default();

-        if let Some(n) = seam.window_turns {

-        Self::with_parts(patterns, config, seam.persist)

+/// Project an endpoint's current session digest on demand from its normalized

+/// records. Resolves the endpoint's source (the `[history]` strategy by its

+/// recorded adapter, else the perch's `digest.log`), reads the records, and folds

+/// them through the published-contract projection. An endpoint with no records

+/// (or no resolvable source) projects an empty [`Digest`] — never an error, never

+pub fn project_endpoint_digest(id: &str, config: &DigestConfig) -> Digest {

+    let lines = endpoint_digest_records(id);

+    spt_term::project_lines(lines.iter().map(String::as_str), config)

-    /// Build directly from compiled parts (the testable seam; also lets a caller

-    /// override the presentation config wholesale).

-            parser: DigestParser::new(patterns, config)?,

+/// The endpoint's current normalized digest-records as raw lines. Source order:

+/// the adapter's `[history]` strategy when declared (the topology-independent

+/// path — fetch + normalize), otherwise the perch's `digest.log` (the log-less

+/// `api digest-entry` push sink). A fetch failure or a missing log yields no

+/// records (a fresh / source-less endpoint), not an error.

+fn endpoint_digest_records(id: &str) -> Vec<String> {

+    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);

+    let info = spt_store::info::read_info(&perch_path);

+    let session_id = info.as_ref().map(|i| i.session_id.clone());

-    /// Whether opt-in Path-B persistence is enabled for this engine.

+    // Resolve the adapter's [history] strategy by the perch's recorded adapter.

+        .and_then(|i| i.adapter.as_deref())

+            spt_runtime::registry::resolve_option(&perch::adapters_dir(), adapter)

+                .and_then(|(_, m)| m.history)

-    /// The current digest version (0 before any change).

-    /// The current structured digest (the snapshot a `spt digest <id>` pull

-    /// A base update carrying the whole current snapshot (`from == 0`) — what a

-    /// fresh subscriber receives first so it has an apply anchor for later deltas.

-    /// Carries the live version (records no change).

-    pub fn full_update(&self) -> DigestUpdate {

-            turns: self.last.turns.clone(),

-            newly_completed: Vec::new(),

+        let mut keys = std::collections::BTreeMap::new();

+        keys.insert("id".to_string(), id.to_string());

+        if let Some(sid) = session_id {

+            keys.insert("session_id".to_string(), sid);

+        return fetch_history(&h, &keys, id, ParentHint::Infer, DIGEST_FETCH_TIMEOUT)

+            .map(|recs| recs.into_iter().map(|r| r.raw).collect())

-    /// Feed a chunk of the session's PTY output. Re-derives the digest; returns a

-    /// [`DigestUpdate`] iff it changed (else `None` — nothing to push).

-    pub fn feed(&mut self, bytes: &[u8]) -> Option<DigestUpdate> {

-        let next = self.parser.digest();

-            return None; // unchanged — emit no delta

-        // The changed turns begin just past the equal-by-value head the new window

-        // shares with the old one. A pure in-place/extend keeps the head intact →

-        // `from` points at the active tail (a minimal delta). A window slide

-        // evicts the oldest turn → the heads differ from index 0 → `from == 0` and

-        // the whole current window is sent (a natural full refresh).

-        let from = common_prefix_len(&self.last.turns, &next.turns);

-        let turns = next.turns[from..].to_vec();

-        let newly_completed = if self.persist {

-            self.take_newly_completed(&next)

+    // Log-less adapter: the `api digest-entry` push sink.

+    match std::fs::read_to_string(perch_path.join("digest.log")) {

+        Ok(s) => s.lines().map(str::to_string).collect(),

-    /// Completed (settled) turns observed since the last call: every turn but the

-    /// still-active last one, starting just after the dedup floor. Advances the

-    /// floor to the newest completed turn so each settles exactly once.

-    fn take_newly_completed(&mut self, snap: &Digest) -> Vec<Turn> {

-            return Vec::new(); // only the active turn exists; nothing settled yet

-        let completed = &snap.turns[..snap.turns.len() - 1];

-        let start = match &self.persisted_floor {

-            // Resume just after the floor turn if it is still in the window;

-            // if it rolled off (None), fall back to the window start (coarse).

-                .position(|t| t == floor)

-        let fresh = completed[start..].to_vec();

-        if let Some(last) = fresh.last() {

-            self.persisted_floor = Some(last.clone());

-/// Length of the equal-by-value prefix shared by two turn slices.

-fn common_prefix_len(a: &[Turn], b: &[Turn]) -> usize {

-    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()

-/// Feed one PTY-output chunk for `endpoint_id` through `engine`, publish any

-/// resulting change to `hub`, and — when the engine opted into persistence —

-/// append each newly-completed turn to the endpoint's Path-B history store. This

-/// is the single per-chunk bridge the brain's digest feed

-/// ([`crate::brain::Brain::run_digest_feed`]) and any in-process daemon feed

-/// share, so the publish + persist behavior lives in exactly one place.

-pub fn ingest_chunk(engine: &mut DigestEngine, hub: &DigestHub, endpoint_id: &str, bytes: &[u8]) {

-    let Some(update) = engine.feed(bytes) else {

-        return; // unchanged — nothing to publish or persist

-        for turn in &update.newly_completed {

-            persist_turn(endpoint_id, turn);

-    hub.publish(endpoint_id, engine.snapshot(), update);

-/// Append one completed turn to `endpoint_id`'s Path-B history store as a coarse

-/// digest-activity record (ADR-0008 "Option (C)"). Best-effort: a store error is

-/// swallowed — the digest log is a convenience, never load-bearing.

-fn persist_turn(endpoint_id: &str, turn: &Turn) {

-    let perch_path = perch::resolve_perch_path(endpoint_id, ParentHint::Infer);

-    let record = serde_json::json!({ "kind": "digest_turn", "turn": turn });

-    let _ = spt_store::history::append_record(&perch_path, &record.to_string());

-            input_pattern: r"\nUSER> ".to_string(),

-            agent_pattern: r"\nAGENT> ".to_string(),

-            tool_patterns: vec![r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string()],

+            input: Some(input.to_string()),

-    // [unit->REQ-TERM-4] the manifest seam compiles into a working engine, and a

-    // first feed yields an update (from=0) with the parsed turn.

+    // [unit->REQ-TERM-4] the diff base: a shared head yields its length (a tail

+    // delta); a divergent front yields 0 (a full refresh).

-    fn from_manifest_feeds_and_snapshots() {

-        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");

-            .feed(b"\nUSER> hello\nAGENT> hi there\n")

-            "from an empty base, the change starts at index 0"

-        assert_eq!(up.turns[0].input.as_deref(), Some("hello"));

-        assert_eq!(e.snapshot().turns.len(), 1);

+    fn common_prefix_len_finds_the_unchanged_head() {

+        let a = vec![turn("a"), turn("b")];

+        let grown = vec![turn("a"), turn("b"), turn("c")];

+        assert_eq!(common_prefix_len(&a, &grown), 2, "shared head of two");

+        let slid = vec![turn("b"), turn("c"), turn("d")];

+        assert_eq!(common_prefix_len(&a, &slid), 0, "front eviction → 0");

-    // [unit->REQ-TERM-4] an unchanged feed pushes nothing (deltas only — no

-    // heartbeat re-push of a static buffer).

+    // [unit->REQ-TERM-4] a source-less endpoint (no perch, no records) projects an

+    // empty digest — never an error, never a block.

-    fn unchanged_feed_emits_no_delta() {

-        e.feed(b"\nUSER> hi\nAGENT> yo\n").expect("first change");

-        assert!(e.feed(b"").is_none(), "empty feed changes nothing");

-        assert!(e.feed(b"   ").is_none(), "whitespace-only adds no marker");

-        assert_eq!(e.version(), v, "version did not advance on a no-op");

+    fn projects_empty_for_a_sourceless_endpoint() {

+        let _h = crate::test_support::isolated_home();

+        let d = project_endpoint_digest("nobody", &DigestConfig::default());

-    // [unit->REQ-TERM-4] the active turn growing in place is a minimal tail delta:

-    // `from` points past the unchanged head, `turns` is just the changed tail.

+    // [unit->REQ-TERM-4] a log-less endpoint projects from its `digest.log` push

+    // sink (the `api digest-entry` source) — input opens a turn, agent appends,

-    fn active_turn_growth_is_a_tail_delta() {

-        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");

-        // Two settled turns + an active third.

-        e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();

-        e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();

-        let up = e.feed(b"\nUSER> c\nAGENT> 3").unwrap();

-        // 'a' and 'b' are unchanged head; only the new 'c' turn is sent.

-        assert_eq!(up.from, 2, "the two settled turns are the unchanged head");

-        assert_eq!(up.turns.last().unwrap().input.as_deref(), Some("c"));

-    // [unit->REQ-TERM-4] when the rolling window slides (oldest turn evicted), the

-    // head no longer matches → from=0, the whole current window is resent.

-    fn window_slide_resends_from_zero() {

-        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3

-            e.feed(format!("\nUSER> u{i}\nAGENT> r{i}\n").as_bytes())

+    fn projects_a_log_less_endpoint_from_digest_log() {

+        let _h = crate::test_support::isolated_home();

+        let perch = perch::resolve_perch_path("solo", ParentHint::Infer);

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

+        // No adapter recorded ⇒ the digest.log path.

+        let rec = spt_store::info::InfoJson::new("solo", "0", std::process::id(), "sid", "live_agent");

+        spt_store::info::write_info(&perch, &rec).unwrap();

+            r#"{"role":"input","text":"build it"}"#,

+            spt_store::history::append_digest_entry(&perch, line).unwrap();

-        // A 4th turn evicts u0 → the window frame shifts.

-        let up = e.feed(b"\nUSER> u3\nAGENT> r3\n").unwrap();

-        assert_eq!(up.from, 0, "a front eviction resends from index 0");

-        assert_eq!(up.turns.len(), 3, "the full current window");

-        assert_eq!(up.turns[0].input.as_deref(), Some("u1"), "oldest is now u1");

-    // [unit->REQ-TERM-4] applying successive deltas (truncate-to-`from` + append)

-    // reconstructs the engine's snapshot exactly — the subscriber stays in sync.

-    fn applying_deltas_reconstructs_snapshot() {

-        let mut view: Vec<Turn> = Vec::new();

-            b"\nUSER> d\nAGENT> 4\n", // evicts 'a' → from=0 refresh

-            if let Some(up) = e.feed(f) {

-                view.truncate(up.from);

-                "applied deltas track the snapshot"

-    // [unit->REQ-TERM-4] collapsed tool sprints survive into the structured turn

-    // (the digest is structured, source-tagged — not flat text).

-    fn tool_sprint_is_structured_in_the_turn() {

-        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");

-            .feed(b"\nUSER> go\nAGENT> ok\nWrite(a.rs) done\n")

-        let t = up.turns.last().unwrap();

+        let d = project_endpoint_digest("solo", &DigestConfig::default());

+        assert_eq!(d.turns[0].input.as_deref(), Some("build it"));

-            .any(|x| matches!(x, DigestEntry::ToolSprint(_))));

+            .any(|e| matches!(e, DigestEntry::ToolSprint(_))));

-    // [unit->REQ-TERM-4] with persistence on, a turn is reported newly-completed

-    // exactly once (as it settles), not re-emitted on later feeds.

-    fn persistence_reports_each_completed_turn_once() {

-        let mut e = DigestEngine::from_manifest(&s).expect("compile");

-        // Turn 'a' is still the active (last) turn → not yet completed.

-        let up = e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();

-            up.newly_completed.is_empty(),

-            "the active turn has not settled"

-        // Opening turn 'b' settles 'a' → reported once.

-        let up = e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();

-        assert_eq!(up.newly_completed.len(), 1);

-        assert_eq!(up.newly_completed[0].input.as_deref(), Some("a"));

-        // Growing 'b' in place settles nothing new.

-        let up = e.feed(b" more").unwrap();

-        assert!(up.newly_completed.is_empty(), "no new turn settled");

-        let up = e.feed(b"\nUSER> c\nAGENT> 3\n").unwrap();

-        assert_eq!(up.newly_completed[0].input.as_deref(), Some("b"));

-    // [unit->REQ-TERM-4] persistence off ⇒ no completed turns are ever surfaced.

-    fn persistence_off_surfaces_nothing() {

-        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // persist=false

-        e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();

-            "persistence off → never reported"


Now find every reference to the removed symbols + the test-helper available in spt-daemon:


crates\spt-daemon\src\access.rs:138:    use crate::test_home::with_home;
crates\spt-daemon\src\access.rs:145:        with_home(|_| {
crates\spt-daemon\src\access.rs:197:        with_home(|_| {
crates\spt-daemon\src\access.rs:239:        with_home(|_| {
crates\spt-daemon\src\brain.rs:36:use crate::digest::{ingest_chunk, DigestEngine};
crates\spt-daemon\src\brain.rs:754:    /// [`ingest_chunk`]. This is the brain-side digest projection — the parse is
crates\spt-daemon\src\brain.rs:760:    pub fn run_digest_feed(
crates\spt-daemon\src\brain.rs:762:        engine: &mut DigestEngine,
crates\spt-daemon\src\brain.rs:772:                BrokerEvent::Output { bytes, .. } => ingest_chunk(engine, hub, endpoint_id, &bytes),
crates\spt-daemon\src\grants.rs:522:        use crate::test_home::with_home;
crates\spt-daemon\src\grants.rs:528:        with_home(|home| {
crates\spt-daemon\src\daemon.rs:721:    use crate::test_home::with_home;
crates\spt-daemon\src\daemon.rs:772:        with_home(|_| {
crates\spt-daemon\src\digesthub.rs:3://! The brain runs a [`crate::digest::DigestEngine`] per daemon-hosted session and
crates\spt-daemon\src\digesthub.rs:97:    /// feed loop after each [`DigestEngine::feed`](crate::digest::DigestEngine::feed).
crates\spt-daemon\src\digesthub.rs:190:                        newly_completed: Vec::new(),
crates\spt-daemon\src\digesthub.rs:361:            newly_completed: Vec::new(),
crates\spt-daemon\src\machineid.rs:161:        crate::test_home::with_home(|_| {
crates\spt-daemon\src\linkhost.rs:401:    use crate::test_home::with_home;
crates\spt-daemon\src\linkhost.rs:439:        with_home(|_| {
crates\spt-daemon\src\lifecycle.rs:545:    use crate::test_home::with_home;
crates\spt-daemon\src\lifecycle.rs:611:        with_home(|_| {
crates\spt-daemon\src\lifecycle.rs:641:        with_home(|_| {
crates\spt-daemon\src\lifecycle.rs:681:        with_home(|_| {
crates\spt-daemon\src\lifecycle.rs:714:        with_home(|_| {
crates\spt-daemon\src\lifecycle.rs:749:        with_home(|_| {
crates\spt-daemon\src\lifecycle.rs:796:        with_home(|_| {
crates\spt-daemon\src\lifecycle.rs:849:        with_home(|home| {
crates\spt-daemon\src\lifecycle.rs:912:        with_home(|_| {
crates\spt-daemon\src\lifecycle.rs:945:        with_home(|_| {
crates\spt-daemon\src\lifecycle.rs:982:        with_home(|_| {
crates\spt-daemon\src\lifecycle.rs:1012:        with_home(|_| {
crates\spt-daemon\src\lifecycle.rs:1044:        with_home(|_| {
crates\spt-daemon\src\notif.rs:537:    use crate::test_home::with_home;
crates\spt-daemon\src\notif.rs:577:        with_home(|home| {
crates\spt-daemon\src\notif.rs:625:        with_home(|home| {
crates\spt-daemon\src\notif.rs:666:        with_home(|home| {
crates\spt-daemon\src\notif.rs:729:        with_home(|home| {
crates\spt-daemon\src\notif.rs:762:        with_home(|home| {
crates\spt-daemon\src\notif.rs:830:        with_home(|home| {

[Showing results with pagination = limit: 40]


Test helper is `crate::test_home::with_home`. Fix my digest.rs tests:


Edit

    // [unit->REQ-TERM-4] a source-less endpoint (no perch, no records) projects an
    // empty digest — never an error, never a block.
    #[test]
    fn projects_empty_for_a_sourceless_endpoint() {
        let _h = crate::test_support::isolated_home();
        let d = project_endpoint_digest("nobody", &DigestConfig::default());
        assert!(d.turns.is_empty());
    }

    // [unit->REQ-TERM-4] a log-less endpoint projects from its `digest.log` push
    // sink (the `api digest-entry` source) — input opens a turn, agent appends,
    // consecutive tools collapse.
    #[test]
    fn projects_a_log_less_endpoint_from_digest_log() {
        let _h = crate::test_support::isolated_home();
        let perch = perch::resolve_perch_path("solo", ParentHint::Infer);
        std::fs::create_dir_all(&perch).unwrap();
        // No adapter recorded ⇒ the digest.log path.
        let rec = spt_store::info::InfoJson::new("solo", "0", std::process::id(), "sid", "live_agent");
        spt_store::info::write_info(&perch, &rec).unwrap();
        for line in [
            r#"{"role":"input","text":"build it"}"#,
            r#"{"role":"agent","text":"on it"}"#,
            r#"{"role":"tool","tool":{"name":"Write","arg":"a.rs"}}"#,
        ] {
            spt_store::history::append_digest_entry(&perch, line).unwrap();
        }
        let d = project_endpoint_digest("solo", &DigestConfig::default());
        assert_eq!(d.turns.len(), 1);
        assert_eq!(d.turns[0].input.as_deref(), Some("build it"));
        assert!(d.turns[0]
            .entries
            .iter()
            .any(|e| matches!(e, DigestEntry::ToolSprint(_))));
    }
}

    // [unit->REQ-TERM-4] a source-less endpoint (no perch, no records) projects an
    // empty digest — never an error, never a block.
    #[test]
    fn projects_empty_for_a_sourceless_endpoint() {
        crate::test_home::with_home(|_| {
            let d = project_endpoint_digest("nobody", &DigestConfig::default());
            assert!(d.turns.is_empty());
        });
    }

    // [unit->REQ-TERM-4] a log-less endpoint projects from its `digest.log` push
    // sink (the `api digest-entry` source) — input opens a turn, agent appends,
    // consecutive tools collapse.
    #[test]
    fn projects_a_log_less_endpoint_from_digest_log() {
        crate::test_home::with_home(|_| {
            let perch = perch::resolve_perch_path("solo", ParentHint::Infer);
            std::fs::create_dir_all(&perch).unwrap();
            // No adapter recorded ⇒ the digest.log path.
            let rec =
                spt_store::info::InfoJson::new("solo", "0", std::process::id(), "sid", "live_agent");
            spt_store::info::write_info(&perch, &rec).unwrap();
            for line in [
                r#"{"role":"input","text":"build it"}"#,
                r#"{"role":"agent","text":"on it"}"#,
                r#"{"role":"tool","tool":{"name":"Write","arg":"a.rs"}}"#,
            ] {
                spt_store::history::append_digest_entry(&perch, line).unwrap();
            }
            let d = project_endpoint_digest("solo", &DigestConfig::default());
            assert_eq!(d.turns.len(), 1);
            assert_eq!(d.turns[0].input.as_deref(), Some("build it"));
            assert!(d.turns[0]
                .entries
                .iter()
                .any(|e| matches!(e, DigestEntry::ToolSprint(_))));
        });
    }
}


    // [unit->REQ-TERM-4] a source-less endpoint (no perch, no records) projects an
    // empty digest — never an error, never a block.
    #[test]
    fn projects_empty_for_a_sourceless_endpoint() {
        crate::test_home::with_home(|_| {
            let d = project_endpoint_digest("nobody", &DigestConfig::default());
            assert!(d.turns.is_empty());
        });
    }

    // [unit->REQ-TERM-4] a log-less endpoint projects from its `digest.log` push
    // sink (the `api digest-entry` source) — input opens a turn, agent appends,
    // consecutive tools collapse.
    #[test]
    fn projects_a_log_less_endpoint_from_digest_log() {
        crate::test_home::with_home(|_| {
            let perch = perch::resolve_perch_path("solo", ParentHint::Infer);
            std::fs::create_dir_all(&perch).unwrap();
            // No adapter recorded ⇒ the digest.log path.
            let rec =
                spt_store::info::InfoJson::new("solo", "0", std::process::id(), "sid", "live_agent");
            spt_store::info::write_info(&perch, &rec).unwrap();
            for line in [
                r#"{"role":"input","text":"build it"}"#,
                r#"{"role":"agent","text":"on it"}"#,
                r#"{"role":"tool","tool":{"name":"Write","arg":"a.rs"}}"#,
            ] {
                spt_store::history::append_digest_entry(&perch, line).unwrap();
            }
            let d = project_endpoint_digest("solo", &DigestConfig::default());
            assert_eq!(d.turns.len(), 1);
            assert_eq!(d.turns[0].input.as_deref(), Some("build it"));
            assert!(d.turns[0]
                .entries
                .iter()
                .any(|e| matches!(e, DigestEntry::ToolSprint(_))));
        });
    }
}

//! The session-digest **log projection** — the daemon-side, on-demand projection
//! of an endpoint's live activity buffer from its normalized session logs
//! (ADR-0008 amendment, 2026-06-12; REQ-TERM-4 log-source re-foundation).
//!
//! ## On-demand projection, not a byte feed
//!
//! The superseded shape folded broker PTY bytes through an adapter-pattern parser
//! on every output chunk. The digest is now a **projection of the same normalized
//! history the echo-commune consumes** — so there is nothing to feed: a snapshot
//! pull (or a `digest-entry` push) **projects on demand** from the endpoint's
//! records via [`project_endpoint_digest`], and the hub publishes the result.
//! Render surfaces read the PTY (the raw byte ring, unchanged); content surfaces
//! read the logs.
//!
//! ## Sources (one per endpoint)
//!
//! - An adapter that declares `[history]` (Path A locate+normalize / fetcher, or
//!   Path B native store) → the projection tails those normalized records. This
//!   is the **topology-independent** path: a Claude-Code-hosted endpoint's
//!   per-session JSONL is Path A's worked example.
//! - A **log-less** adapter pushes digest-records directly via `api digest-entry`
//!   into the perch's `digest.log` → the projection reads that.
//!
//! The records carry the published digest-record contract
//! ([`spt_term::DigestRecord`]); the projection folds them into the structured
//! [`Digest`] (N-turn window, collapsed tool sprints, ~25-char arg truncation).
//!
//! ## Snapshot vs delta
//!
//! A projection is diffed against the hub's prior snapshot
//! ([`crate::digesthub::DigestHub::project_and_publish`]) to produce a
//! [`DigestUpdate`]: `from` is where the changed turns begin, and the subscriber
//! applies it by **truncating its view to `from`, then appending `turns`**. The
//! delta-stream **contract** ships; its deltas are driven by pull / digest-entry
//! publishes (autonomous file-watch freshness is deferred to the consuming
//! frontend milestone — CONTEXT.md "session digest").
// [impl->REQ-TERM-4]

use std::time::Duration;

use serde::{Deserialize, Serialize};

use spt_live::fetch_history;
use spt_store::perch::{self, ParentHint};
use spt_term::{Digest, DigestConfig, Turn};

/// Bound for the projection's history fetch (a `[history]` fetcher/normalize is a
/// subprocess — REQ-HAZARD-SUBPROCESS-TIMEOUT; a hang is killed, never blocks).
const DIGEST_FETCH_TIMEOUT: Duration = Duration::from_secs(30);

/// A structured digest change to push to a subscriber. Sent only when the digest
/// actually changed (so the stream carries deltas, not heartbeats).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DigestUpdate {
    /// Monotonic version of the digest after this change (starts at 1).
    pub version: u64,
    /// The window index where `turns` begins. Apply: truncate the view to `from`,
    /// then append `turns`. `from == 0` is a full replace (the base snapshot, or a
    /// window slide that invalidated old indices).
    pub from: usize,
    /// The changed turns starting at `from` (the whole window when `from == 0`).
    pub turns: Vec<Turn>,
}

/// Length of the equal-by-value prefix shared by two turn slices — the diff base
/// for a [`DigestUpdate`] (`from`). A pure in-place/extend keeps the head intact →
/// `from` points at the changed tail (a minimal delta); a window slide evicts the
/// oldest turn → the heads differ from index 0 → `from == 0` (a full refresh).
pub fn common_prefix_len(a: &[Turn], b: &[Turn]) -> usize {
    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
}

/// Project an endpoint's current session digest on demand from its normalized
/// records. Resolves the endpoint's source (the `[history]` strategy by its
/// recorded adapter, else the perch's `digest.log`), reads the records, and folds
/// them through the published-contract projection. An endpoint with no records
/// (or no resolvable source) projects an empty [`Digest`] — never an error, never
/// a block.
// [impl->REQ-TERM-4]
pub fn project_endpoint_digest(id: &str, config: &DigestConfig) -> Digest {
    let lines = endpoint_digest_records(id);
    spt_term::project_lines(lines.iter().map(String::as_str), config)
}

/// The endpoint's current normalized digest-records as raw lines. Source order:
/// the adapter's `[history]` strategy when declared (the topology-independent
/// path — fetch + normalize), otherwise the perch's `digest.log` (the log-less
/// `api digest-entry` push sink). A fetch failure or a missing log yields no
/// records (a fresh / source-less endpoint), not an error.
fn endpoint_digest_records(id: &str) -> Vec<String> {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let info = spt_store::info::read_info(&perch_path);
    let session_id = info.as_ref().map(|i| i.session_id.clone());

    // Resolve the adapter's [history] strategy by the perch's recorded adapter.
    let history = info
        .as_ref()
        .and_then(|i| i.adapter.as_deref())
        .and_then(|adapter| {
            spt_runtime::registry::resolve_option(&perch::adapters_dir(), adapter)
                .ok()
                .and_then(|(_, m)| m.history)
        });

    if let Some(h) = history {
        let mut keys = std::collections::BTreeMap::new();
        keys.insert("id".to_string(), id.to_string());
        if let Some(sid) = session_id {
            keys.insert("session_id".to_string(), sid);
        }
        return fetch_history(&h, &keys, id, ParentHint::Infer, DIGEST_FETCH_TIMEOUT)
            .map(|recs| recs.into_iter().map(|r| r.raw).collect())
            .unwrap_or_default();
    }

    // Log-less adapter: the `api digest-entry` push sink.
    match std::fs::read_to_string(perch_path.join("digest.log")) {
        Ok(s) => s.lines().map(str::to_string).collect(),
        Err(_) => Vec::new(),
    }
}

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

    fn turn(input: &str) -> Turn {
        Turn {
            input: Some(input.to_string()),
            entries: Vec::new(),
        }
    }

    // [unit->REQ-TERM-4] the diff base: a shared head yields its length (a tail
    // delta); a divergent front yields 0 (a full refresh).
    #[test]
    fn common_prefix_len_finds_the_unchanged_head() {
        let a = vec![turn("a"), turn("b")];
        let grown = vec![turn("a"), turn("b"), turn("c")];
        assert_eq!(common_prefix_len(&a, &grown), 2, "shared head of two");
        let slid = vec![turn("b"), turn("c"), turn("d")];
        assert_eq!(common_prefix_len(&a, &slid), 0, "front eviction → 0");
    }

    // [unit->REQ-TERM-4] a source-less endpoint (no perch, no records) projects an
    // empty digest — never an error, never a block.
    #[test]
    fn projects_empty_for_a_sourceless_endpoint() {
        let _h = crate::test_support::isolated_home();
        let d = project_endpoint_digest("nobody", &DigestConfig::default());
        assert!(d.turns.is_empty());
    }

    // [unit->REQ-TERM-4] a log-less endpoint projects from its `digest.log` push
    // sink (the `api digest-entry` source) — input opens a turn, agent appends,
    // consecutive tools collapse.
    #[test]
    fn projects_a_log_less_endpoint_from_digest_log() {
        let _h = crate::test_support::isolated_home();
        let perch = perch::resolve_perch_path("solo", ParentHint::Infer);
        std::fs::create_dir_all(&perch).unwrap();
        // No adapter recorded ⇒ the digest.log path.
        let rec = spt_store::info::InfoJson::new("solo", "0", std::process::id(), "sid", "live_agent");
        spt_store::info::write_info(&perch, &rec).unwrap();
        for line in [
            r#"{"role":"input","text":"build it"}"#,
            r#"{"role":"agent","text":"on it"}"#,
            r#"{"role":"tool","tool":{"name":"Write","arg":"a.rs"}}"#,
        ] {
            spt_store::history::append_digest_entry(&perch, line).unwrap();
        }
        let d = project_endpoint_digest("solo", &DigestConfig::default());
        assert_eq!(d.turns.len(), 1);
        assert_eq!(d.turns[0].input.as_deref(), Some("build it"));
        assert!(d.turns[0]
            .entries
            .iter()
            .any(|e| matches!(e, DigestEntry::ToolSprint(_))));
    }
}

-        let _h = crate::test_support::isolated_home();

-        let d = project_endpoint_digest("nobody", &DigestConfig::default());

+        crate::test_home::with_home(|_| {

+            let d = project_endpoint_digest("nobody", &DigestConfig::default());

+            assert!(d.turns.is_empty());

-        let perch = perch::resolve_perch_path("solo", ParentHint::Infer);

-        std::fs::create_dir_all(&perch).unwrap();

-        // No adapter recorded ⇒ the digest.log path.

-        let rec = spt_store::info::InfoJson::new("solo", "0", std::process::id(), "sid", "live_agent");

-        spt_store::info::write_info(&perch, &rec).unwrap();

-            r#"{"role":"input","text":"build it"}"#,

-            spt_store::history::append_digest_entry(&perch, line).unwrap();

-        let d = project_endpoint_digest("solo", &DigestConfig::default());

-        assert_eq!(d.turns[0].input.as_deref(), Some("build it"));

-            .any(|e| matches!(e, DigestEntry::ToolSprint(_))));

+            let perch = perch::resolve_perch_path("solo", ParentHint::Infer);

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

+            // No adapter recorded ⇒ the digest.log path.

+                spt_store::info::InfoJson::new("solo", "0", std::process::id(), "sid", "live_agent");

+            spt_store::info::write_info(&perch, &rec).unwrap();

+                r#"{"role":"input","text":"build it"}"#,

+                spt_store::history::append_digest_entry(&perch, line).unwrap();

+            let d = project_endpoint_digest("solo", &DigestConfig::default());

+            assert_eq!(d.turns.len(), 1);

+            assert_eq!(d.turns[0].input.as_deref(), Some("build it"));

+                .any(|e| matches!(e, DigestEntry::ToolSprint(_))));


Now fix `brain.rs` — remove the digest import + `run_digest_feed`:


33	use interprocess::local_socket::{SendHalf, Stream};
34
35	use crate::codec::{read_frame, write_frame};
36	use crate::digest::{ingest_chunk, DigestEngine};
37	use crate::digesthub::DigestHub;
38	use crate::frame::{Envelope, Role};
39	use crate::msg::{
40	    decode_bytes, encode_bytes, AppliedEvent, BrainRestarted, ErrorEvent, ExitEvent, InputReq,

use interprocess::local_socket::{SendHalf, Stream};

use crate::codec::{read_frame, write_frame};
use crate::digest::{ingest_chunk, DigestEngine};
use crate::digesthub::DigestHub;
use crate::frame::{Envelope, Role};
use crate::msg::{
    decode_bytes, encode_bytes, AppliedEvent, BrainRestarted, ErrorEvent, ExitEvent, InputReq,


749	    }
750
751	    /// Run the **PTY-digest feed** for `endpoint_id` (B8): read this session's
752	    /// output events and fold each chunk through `engine`, publishing every change
753	    /// to `hub` (and persisting completed turns when the engine opted in) via
754	    /// [`ingest_chunk`]. This is the brain-side digest projection — the parse is
755	    /// logic, so it lives here, never in the broker (ADR-0004 §B). Returns when
756	    /// the session exits, `stop` is set (checked between events), or the broker
757	    /// connection drops. A non-output event other than `exit` is ignored (the
758	    /// feed only cares about the byte stream).
759	    // [impl->REQ-TERM-4]
760	    pub fn run_digest_feed(
761	        &mut self,
762	        engine: &mut DigestEngine,
763	        hub: &DigestHub,
764	        endpoint_id: &str,
765	        stop: &AtomicBool,
766	    ) -> io::Result<()> {
767	        loop {
768	            if stop.load(Ordering::Relaxed) {
769	                return Ok(());
770	            }
771	            match self.read_event()? {
772	                BrokerEvent::Output { bytes, .. } => ingest_chunk(engine, hub, endpoint_id, &bytes),
773	                BrokerEvent::Exit { .. } => return Ok(()),
774	                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
775	                _ => {}
776	            }
777	        }
778	    }
779

    }

    /// Run the **PTY-digest feed** for `endpoint_id` (B8): read this session's
    /// output events and fold each chunk through `engine`, publishing every change
    /// to `hub` (and persisting completed turns when the engine opted in) via
    /// [`ingest_chunk`]. This is the brain-side digest projection — the parse is
    /// logic, so it lives here, never in the broker (ADR-0004 §B). Returns when
    /// the session exits, `stop` is set (checked between events), or the broker
    /// connection drops. A non-output event other than `exit` is ignored (the
    /// feed only cares about the byte stream).
    // [impl->REQ-TERM-4]
    pub fn run_digest_feed(
        &mut self,
        engine: &mut DigestEngine,
        hub: &DigestHub,
        endpoint_id: &str,
        stop: &AtomicBool,
    ) -> io::Result<()> {
        loop {
            if stop.load(Ordering::Relaxed) {
                return Ok(());
            }
            match self.read_event()? {
                BrokerEvent::Output { bytes, .. } => ingest_chunk(engine, hub, endpoint_id, &bytes),
                BrokerEvent::Exit { .. } => return Ok(()),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => {}
            }
        }
    }


18://!   the broker replays buffered output from there. [`Brain::read_event`] accepts
37:use crate::digesthub::DigestHub;
223:    /// [`Brain::read_event`] under the same accept/dedup/gap discipline.
229:    /// [`Brain::read_event`] under the same accept/dedup/gap discipline.
374:    /// calls [`Brain::read_event`] on every interleaved frame, so on a *resume-mode*
381:    /// this non-blocking (one demux owns `read_event`, routing each frame to its
389:            match self.read_event()? {
393:                    // `read_event`'s first-sight `or_insert` seed (D4-2b) — no seed
473:            match self.read_event()? {
493:    /// entry (a whole `loop { read_event }` shares it, so unrelated events
501:    pub fn read_event(&mut self) -> io::Result<BrokerEvent> {
505:    /// Like [`Brain::read_event`] but each frame read is bounded by an optional
709:        // the per-session map via `read_event`'s first-sight `or_insert` seed
724:    /// so [`Brain::read_event`] dedups the broker's at-least-once boundary re-send
763:        hub: &DigestHub,
771:            match self.read_event()? {
787:            match self.read_event()? {
817:            match self.read_event()? {
915:            match self.read_event()? {
940:            match self.read_event()? {
957:            match self.read_event()? {


use crate::codec::{read_frame, write_frame};
use crate::digest::{ingest_chunk, DigestEngine};
use crate::digesthub::DigestHub;
use crate::frame::{Envelope, Role};

use crate::codec::{read_frame, write_frame};
use crate::frame::{Envelope, Role};


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

use crate::codec::{read_frame, write_frame};
use crate::digest::{ingest_chunk, DigestEngine};
use crate::digesthub::DigestHub;
use crate::frame::{Envelope, Role};

use crate::codec::{read_frame, write_frame};
use crate::frame::{Envelope, Role};

decode_bytes, encode_bytes, AppliedEvent, BrainRestarted, ErrorEvent, ExitEvent, InputReq,


    /// Run the **PTY-digest feed** for `endpoint_id` (B8): read this session's
    /// output events and fold each chunk through `engine`, publishing every change
    /// to `hub` (and persisting completed turns when the engine opted in) via
    /// [`ingest_chunk`]. This is the brain-side digest projection — the parse is
    /// logic, so it lives here, never in the broker (ADR-0004 §B). Returns when
    /// the session exits, `stop` is set (checked between events), or the broker
    /// connection drops. A non-output event other than `exit` is ignored (the
    /// feed only cares about the byte stream).
    // [impl->REQ-TERM-4]
    pub fn run_digest_feed(
        &mut self,
        engine: &mut DigestEngine,
        hub: &DigestHub,
        endpoint_id: &str,
        stop: &AtomicBool,
    ) -> io::Result<()> {
        loop {
            if stop.load(Ordering::Relaxed) {
                return Ok(());
            }
            match self.read_event()? {
                BrokerEvent::Output { bytes, .. } => ingest_chunk(engine, hub, endpoint_id, &bytes),
                BrokerEvent::Exit { .. } => return Ok(()),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => {}
            }
        }
    }


    /// Run the **PTY-digest feed** for `endpoint_id` (B8): read this session's
    /// output events and fold each chunk through `engine`, publishing every change
    /// to `hub` (and persisting completed turns when the engine opted in) via
    /// [`ingest_chunk`]. This is the brain-side digest projection — the parse is
    /// logic, so it lives here, never in the broker (ADR-0004 §B). Returns when
    /// the session exits, `stop` is set (checked between events), or the broker
    /// connection drops. A non-output event other than `exit` is ignored (the
    /// feed only cares about the byte stream).
    // [impl->REQ-TERM-4]
    pub fn run_digest_feed(
        &mut self,
        engine: &mut DigestEngine,
        hub: &DigestHub,
        endpoint_id: &str,
        stop: &AtomicBool,
    ) -> io::Result<()> {
        loop {
            if stop.load(Ordering::Relaxed) {
                return Ok(());
            }
            match self.read_event()? {
                BrokerEvent::Output { bytes, .. } => ingest_chunk(engine, hub, endpoint_id, &bytes),
                BrokerEvent::Exit { .. } => return Ok(()),
                BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                _ => {}
            }
        }
    }

-    /// Run the **PTY-digest feed** for `endpoint_id` (B8): read this session's

-    /// output events and fold each chunk through `engine`, publishing every change

-    /// to `hub` (and persisting completed turns when the engine opted in) via

-    /// [`ingest_chunk`]. This is the brain-side digest projection — the parse is

-    /// logic, so it lives here, never in the broker (ADR-0004 §B). Returns when

-    /// the session exits, `stop` is set (checked between events), or the broker

-    /// connection drops. A non-output event other than `exit` is ignored (the

-    /// feed only cares about the byte stream).

-            if stop.load(Ordering::Relaxed) {

-                BrokerEvent::Output { bytes, .. } => ingest_chunk(engine, hub, endpoint_id, &bytes),

-                BrokerEvent::Exit { .. } => return Ok(()),

-                BrokerEvent::Error { message } => return Err(io::Error::other(message)),

/// Query the broker-owned net endpoint's status (D4a): node id, dialable

/// address, conn count — or `enabled: false` on a net-less broker. Reads


Now the hub rewrite. Update imports + add the reproject kind:


use serde::{Deserialize, Serialize};
use spt_term::{Digest, DigestEntry, Turn};

use crate::codec::{read_frame, write_frame};
use crate::digest::DigestUpdate;
use crate::frame::{Envelope, Role};
use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};

/// Client→daemon: pull the current structured digest for an endpoint.
pub const KIND_DIGEST_SNAPSHOT: &str = "digest_snapshot";
/// Daemon→client: the snapshot reply (digest + version; absent ⇒ no live digest).
pub const KIND_DIGEST_RESULT: &str = "digest_result";
/// Client→daemon: subscribe to an endpoint's delta-stream from a version floor.
pub const KIND_DIGEST_SUBSCRIBE: &str = "digest_subscribe";
/// Daemon→client: one structured digest change (the streamed delta / base).
pub const KIND_DIGEST_DELTA: &str = "digest_delta";

use serde::{Deserialize, Serialize};
use spt_term::{Digest, DigestConfig, DigestEntry, Turn};

use crate::codec::{read_frame, write_frame};
use crate::digest::{common_prefix_len, project_endpoint_digest, DigestUpdate};
use crate::frame::{Envelope, Role};
use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};

/// Client→daemon: pull the current structured digest for an endpoint (projects
/// it on demand from the endpoint's normalized records).
pub const KIND_DIGEST_SNAPSHOT: &str = "digest_snapshot";
/// Daemon→client: the snapshot reply (digest + version; absent ⇒ no digest).
pub const KIND_DIGEST_RESULT: &str = "digest_result";
/// Client→daemon: subscribe to an endpoint's delta-stream from a version floor.
pub const KIND_DIGEST_SUBSCRIBE: &str = "digest_subscribe";
/// Daemon→client: one structured digest change (the streamed delta / base).
pub const KIND_DIGEST_DELTA: &str = "digest_delta";
/// Client→daemon: re-project an endpoint now and publish any change to live
/// subscribers — the push-driven delta after an `api digest-entry` append (no
/// reply; the delta reaches subscribers via the fan-out, REQ-TERM-4).
pub const KIND_DIGEST_REPROJECT: &str = "digest_reproject";


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

use serde::{Deserialize, Serialize};
use spt_term::{Digest, DigestEntry, Turn};

use crate::codec::{read_frame, write_frame};
use crate::digest::DigestUpdate;
use crate::frame::{Envelope, Role};
use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};

/// Client→daemon: pull the current structured digest for an endpoint.
pub const KIND_DIGEST_SNAPSHOT: &str = "digest_snapshot";
/// Daemon→client: the snapshot reply (digest + version; absent ⇒ no live digest).
pub const KIND_DIGEST_RESULT: &str = "digest_result";
/// Client→daemon: subscribe to an endpoint's delta-stream from a version floor.
pub const KIND_DIGEST_SUBSCRIBE: &str = "digest_subscribe";
/// Daemon→client: one structured digest change (the streamed delta / base).
pub const KIND_DIGEST_DELTA: &str = "digest_delta";

use serde::{Deserialize, Serialize};
use spt_term::{Digest, DigestConfig, DigestEntry, Turn};

use crate::codec::{read_frame, write_frame};
use crate::digest::{common_prefix_len, project_endpoint_digest, DigestUpdate};
use crate::frame::{Envelope, Role};
use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};

/// Client→daemon: pull the current structured digest for an endpoint (projects
/// it on demand from the endpoint's normalized records).
pub const KIND_DIGEST_SNAPSHOT: &str = "digest_snapshot";
/// Daemon→client: the snapshot reply (digest + version; absent ⇒ no digest).
pub const KIND_DIGEST_RESULT: &str = "digest_result";
/// Client→daemon: subscribe to an endpoint's delta-stream from a version floor.
pub const KIND_DIGEST_SUBSCRIBE: &str = "digest_subscribe";
/// Daemon→client: one structured digest change (the streamed delta / base).
pub const KIND_DIGEST_DELTA: &str = "digest_delta";
/// Client→daemon: re-project an endpoint now and publish any change to live
/// subscribers — the push-driven delta after an `api digest-entry` append (no
/// reply; the delta reaches subscribers via the fan-out, REQ-TERM-4).
pub const KIND_DIGEST_REPROJECT: &str = "digest_reproject";

-use spt_term::{Digest, DigestEntry, Turn};

+use spt_term::{Digest, DigestConfig, DigestEntry, Turn};

+use crate::digest::{common_prefix_len, project_endpoint_digest, DigestUpdate};

-/// Client→daemon: pull the current structured digest for an endpoint.

+/// Client→daemon: pull the current structured digest for an endpoint (projects

+/// it on demand from the endpoint's normalized records).

-/// Daemon→client: the snapshot reply (digest + version; absent ⇒ no live digest).

+/// Daemon→client: the snapshot reply (digest + version; absent ⇒ no digest).

+/// Client→daemon: re-project an endpoint now and publish any change to live

+/// subscribers — the push-driven delta after an `api digest-entry` append (no

+/// reply; the delta reaches subscribers via the fan-out, REQ-TERM-4).

+pub const KIND_DIGEST_REPROJECT: &str = "digest_reproject";

/// `digest_snapshot` / `digest_subscribe` request payload.

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


//! The daemon-held digest hub + its control channel (B8, ADR-0008 / REQ-TERM-4).
//!
//! The brain runs a [`crate::digest::DigestEngine`] per daemon-hosted session and
//! **publishes** each change into this hub; the hub holds the latest structured
//! snapshot per endpoint and fans every change out to live subscribers. The
//! control channel is the access surface ADR-0008 describes — two modes over one
//! transport:
//!
//! - **snapshot pull** — `spt digest <id>` ([`pull_snapshot`]) returns the current
//!   structured buffer for a daemon-hosted session;
//! - **delta-stream** — `spt digest <id> --follow` ([`follow`]) subscribes and
//!   receives a base snapshot then **only the changes** ([`DigestUpdate`]s) as the
//!   agent produces output (the "near-realtime reflection" mode).
//!
//! The channel rides the same forward-compatible [`Envelope`] + codec + version
//! handshake as the broker / seed-control IPC (a transient `spt digest` CLI
//! connects as a [`Role::Brain`] logic client). **Local addressing only** — the
//! qualified `[subnet:]id@node` cross-node form is M4 (rejected by the CLI before
//! it ever reaches here).
// [impl->REQ-TERM-4]

//! The daemon-held digest hub + its control channel (ADR-0008 amendment /
//! REQ-TERM-4).
//!
//! The hub holds the latest projected snapshot per endpoint and fans every change
//! out to live subscribers. The digest is **projected on demand** from the
//! endpoint's normalized records ([`project_endpoint_digest`]) — there is no live
//! feed; a pull or a `digest-entry` push triggers a projection, and
//! [`DigestHub::project_and_publish`] diffs it against the prior snapshot to
//! produce the [`DigestUpdate`] delta. The control channel is the access surface
//! ADR-0008 describes — two modes over one transport:
//!
//! - **snapshot pull** — `spt endpoint digest <id>` ([`pull_snapshot`]) projects
//!   the endpoint's current buffer on demand and returns it;
//! - **delta-stream** — `spt endpoint digest <id> --follow` ([`follow`])
//!   subscribes, receives a base snapshot, then **only the changes**
//!   ([`DigestUpdate`]s) as pulls / `digest-entry` pushes re-project. The stream
//!   contract ships; autonomous file-watch freshness is deferred to the consuming
//!   frontend milestone (CONTEXT.md "session digest").
//!
//! The channel rides the same forward-compatible [`Envelope`] + codec + version
//! handshake as the broker / seed-control IPC (a transient `spt` CLI connects as a
//! [`Role::Brain`] logic client). **Local addressing only** — the qualified
//! `[subnet:]id@node` cross-node form is a later milestone (rejected by the CLI
//! before it ever reaches here).
// [impl->REQ-TERM-4]


//! The daemon-held digest hub + its control channel (B8, ADR-0008 / REQ-TERM-4).
//!
//! The brain runs a [`crate::digest::DigestEngine`] per daemon-hosted session and
//! **publishes** each change into this hub; the hub holds the latest structured
//! snapshot per endpoint and fans every change out to live subscribers. The
//! control channel is the access surface ADR-0008 describes — two modes over one
//! transport:
//!
//! - **snapshot pull** — `spt digest <id>` ([`pull_snapshot`]) returns the current
//!   structured buffer for a daemon-hosted session;
//! - **delta-stream** — `spt digest <id> --follow` ([`follow`]) subscribes and
//!   receives a base snapshot then **only the changes** ([`DigestUpdate`]s) as the
//!   agent produces output (the "near-realtime reflection" mode).
//!
//! The channel rides the same forward-compatible [`Envelope`] + codec + version
//! handshake as the broker / seed-control IPC (a transient `spt digest` CLI
//! connects as a [`Role::Brain`] logic client). **Local addressing only** — the
//! qualified `[subnet:]id@node` cross-node form is M4 (rejected by the CLI before
//! it ever reaches here).
// [impl->REQ-TERM-4]

//! The daemon-held digest hub + its control channel (ADR-0008 amendment /
//! REQ-TERM-4).
//!
//! The hub holds the latest projected snapshot per endpoint and fans every change
//! out to live subscribers. The digest is **projected on demand** from the
//! endpoint's normalized records ([`project_endpoint_digest`]) — there is no live
//! feed; a pull or a `digest-entry` push triggers a projection, and
//! [`DigestHub::project_and_publish`] diffs it against the prior snapshot to
//! produce the [`DigestUpdate`] delta. The control channel is the access surface
//! ADR-0008 describes — two modes over one transport:
//!
//! - **snapshot pull** — `spt endpoint digest <id>` ([`pull_snapshot`]) projects
//!   the endpoint's current buffer on demand and returns it;
//! - **delta-stream** — `spt endpoint digest <id> --follow` ([`follow`])
//!   subscribes, receives a base snapshot, then **only the changes**
//!   ([`DigestUpdate`]s) as pulls / `digest-entry` pushes re-project. The stream
//!   contract ships; autonomous file-watch freshness is deferred to the consuming
//!   frontend milestone (CONTEXT.md "session digest").
//!
//! The channel rides the same forward-compatible [`Envelope`] + codec + version
//! handshake as the broker / seed-control IPC (a transient `spt` CLI connects as a
//! [`Role::Brain`] logic client). **Local addressing only** — the qualified
//! `[subnet:]id@node` cross-node form is a later milestone (rejected by the CLI
//! before it ever reaches here).
// [impl->REQ-TERM-4]

-//! The daemon-held digest hub + its control channel (B8, ADR-0008 / REQ-TERM-4).

+//! The daemon-held digest hub + its control channel (ADR-0008 amendment /

-//! The brain runs a [`crate::digest::DigestEngine`] per daemon-hosted session and

-//! **publishes** each change into this hub; the hub holds the latest structured

-//! snapshot per endpoint and fans every change out to live subscribers. The

-//! control channel is the access surface ADR-0008 describes — two modes over one

+//! The hub holds the latest projected snapshot per endpoint and fans every change

+//! out to live subscribers. The digest is **projected on demand** from the

+//! endpoint's normalized records ([`project_endpoint_digest`]) — there is no live

+//! feed; a pull or a `digest-entry` push triggers a projection, and

+//! [`DigestHub::project_and_publish`] diffs it against the prior snapshot to

+//! produce the [`DigestUpdate`] delta. The control channel is the access surface

+//! ADR-0008 describes — two modes over one transport:

-//! - **snapshot pull** — `spt digest <id>` ([`pull_snapshot`]) returns the current

-//!   structured buffer for a daemon-hosted session;

-//! - **delta-stream** — `spt digest <id> --follow` ([`follow`]) subscribes and

-//!   receives a base snapshot then **only the changes** ([`DigestUpdate`]s) as the

-//!   agent produces output (the "near-realtime reflection" mode).

+//! - **snapshot pull** — `spt endpoint digest <id>` ([`pull_snapshot`]) projects

+//!   the endpoint's current buffer on demand and returns it;

+//! - **delta-stream** — `spt endpoint digest <id> --follow` ([`follow`])

+//!   subscribes, receives a base snapshot, then **only the changes**

+//!   ([`DigestUpdate`]s) as pulls / `digest-entry` pushes re-project. The stream

+//!   contract ships; autonomous file-watch freshness is deferred to the consuming

+//!   frontend milestone (CONTEXT.md "session digest").

-//! handshake as the broker / seed-control IPC (a transient `spt digest` CLI

-//! connects as a [`Role::Brain`] logic client). **Local addressing only** — the

-//! qualified `[subnet:]id@node` cross-node form is M4 (rejected by the CLI before

+//! handshake as the broker / seed-control IPC (a transient `spt` CLI connects as a

+//! [`Role::Brain`] logic client). **Local addressing only** — the qualified

+//! `[subnet:]id@node` cross-node form is a later milestone (rejected by the CLI


Replace `publish` with `project_and_publish` (folds the delta diff into the hub):


    /// Publish a digest change for `id`: store the new `snapshot` + version and
    /// fan `update` out to every live subscriber (pruning any whose receiver has
    /// been dropped — a disconnected `--follow` client). Called by the brain's
    /// feed loop after each [`DigestEngine::feed`](crate::digest::DigestEngine::feed).
    pub fn publish(&self, id: &str, snapshot: Digest, update: DigestUpdate) {
        let mut map = self.map.lock().unwrap();
        let st = map.entry(id.to_string()).or_default();
        st.version = update.version;
        st.snapshot = snapshot;
        st.subscribers.retain(|tx| tx.send(update.clone()).is_ok());
    }

    /// Store a freshly projected `new` digest for `id`, diff it against the prior
    /// snapshot to compute the [`DigestUpdate`] delta, fan that out to every live
    /// subscriber (pruning any disconnected `--follow` client), and return the
    /// stored snapshot + its version. An unchanged projection is a no-op: no
    /// version bump, no fan-out. The projection itself ([`project_endpoint_digest`])
    /// runs **outside** this lock — only the diff + publish are serialized.
    // [impl->REQ-TERM-4]
    pub fn project_and_publish(&self, id: &str, new: Digest) -> (Digest, u64) {
        let mut map = self.map.lock().unwrap();
        let st = map.entry(id.to_string()).or_default();
        if new == st.snapshot {
            return (st.snapshot.clone(), st.version); // unchanged — no delta
        }
        // The changed turns begin past the equal-by-value head; a window slide
        // (front eviction) diverges at 0 → a full refresh.
        let from = common_prefix_len(&st.snapshot.turns, &new.turns);
        st.version += 1;
        let update = DigestUpdate {
            version: st.version,
            from,
            turns: new.turns[from..].to_vec(),
        };
        st.snapshot = new.clone();
        st.subscribers.retain(|tx| tx.send(update.clone()).is_ok());
        (new, st.version)
    }


    /// Publish a digest change for `id`: store the new `snapshot` + version and
    /// fan `update` out to every live subscriber (pruning any whose receiver has
    /// been dropped — a disconnected `--follow` client). Called by the brain's
    /// feed loop after each [`DigestEngine::feed`](crate::digest::DigestEngine::feed).
    pub fn publish(&self, id: &str, snapshot: Digest, update: DigestUpdate) {
        let mut map = self.map.lock().unwrap();
        let st = map.entry(id.to_string()).or_default();
        st.version = update.version;
        st.snapshot = snapshot;
        st.subscribers.retain(|tx| tx.send(update.clone()).is_ok());
    }

    /// Store a freshly projected `new` digest for `id`, diff it against the prior
    /// snapshot to compute the [`DigestUpdate`] delta, fan that out to every live
    /// subscriber (pruning any disconnected `--follow` client), and return the
    /// stored snapshot + its version. An unchanged projection is a no-op: no
    /// version bump, no fan-out. The projection itself ([`project_endpoint_digest`])
    /// runs **outside** this lock — only the diff + publish are serialized.
    // [impl->REQ-TERM-4]
    pub fn project_and_publish(&self, id: &str, new: Digest) -> (Digest, u64) {
        let mut map = self.map.lock().unwrap();
        let st = map.entry(id.to_string()).or_default();
        if new == st.snapshot {
            return (st.snapshot.clone(), st.version); // unchanged — no delta
        }
        // The changed turns begin past the equal-by-value head; a window slide
        // (front eviction) diverges at 0 → a full refresh.
        let from = common_prefix_len(&st.snapshot.turns, &new.turns);
        st.version += 1;
        let update = DigestUpdate {
            version: st.version,
            from,
            turns: new.turns[from..].to_vec(),
        };
        st.snapshot = new.clone();
        st.subscribers.retain(|tx| tx.send(update.clone()).is_ok());
        (new, st.version)
    }

-    /// Publish a digest change for `id`: store the new `snapshot` + version and

-    /// fan `update` out to every live subscriber (pruning any whose receiver has

-    /// been dropped — a disconnected `--follow` client). Called by the brain's

-    /// feed loop after each [`DigestEngine::feed`](crate::digest::DigestEngine::feed).

-    pub fn publish(&self, id: &str, snapshot: Digest, update: DigestUpdate) {

+    /// Store a freshly projected `new` digest for `id`, diff it against the prior

+    /// snapshot to compute the [`DigestUpdate`] delta, fan that out to every live

+    /// subscriber (pruning any disconnected `--follow` client), and return the

+    /// stored snapshot + its version. An unchanged projection is a no-op: no

+    /// version bump, no fan-out. The projection itself ([`project_endpoint_digest`])

+    /// runs **outside** this lock — only the diff + publish are serialized.

+    pub fn project_and_publish(&self, id: &str, new: Digest) -> (Digest, u64) {

+            return (st.snapshot.clone(), st.version); // unchanged — no delta

+        // The changed turns begin past the equal-by-value head; a window slide

+        // (front eviction) diverges at 0 → a full refresh.

+        let from = common_prefix_len(&st.snapshot.turns, &new.turns);

+            turns: new.turns[from..].to_vec(),

/// The current snapshot + version for `id`, if a live digest exists.


Now update the `handle_conn` handlers (snapshot/subscribe project on-demand; add reproject) and add the `reproject` client fn. Re-read the shifted region:


155	            .unwrap_or(0)
156	    }
157	}
158
159	/// Build a `digest_delta` envelope from a [`DigestUpdate`].
160	fn delta_frame(up: &DigestUpdate) -> Envelope {
161	    Envelope::new(
162	        KIND_DIGEST_DELTA,
163	        serde_json::to_value(up).expect("DigestUpdate serializes"),
164	    )
165	}
166
167	/// Bind the digest-control channel at `name` and serve it forever against `hub`.
168	/// Thread-per-connection (mirroring the broker / seed-control): a `--follow`
169	/// subscribe blocks its connection thread streaming deltas, so serving inline
170	/// would stall every other client.
171	pub fn serve_digest_control(name: &str, hub: Arc<DigestHub>) -> io::Result<()> {
172	    let listener = LocalSocketTransport::bind(name)?;
173	    loop {
174	        let conn = listener.accept()?;
175	        let hub = Arc::clone(&hub);
176	        thread::spawn(move || {
177	            let _ = handle_conn(conn, &hub);
178	        });
179	    }
180	}
181
182	/// Drive one control connection: handshake, then service snapshot pulls and
183	/// subscribe streams until the client disconnects.
184	fn handle_conn<C: Read + Write>(mut conn: C, hub: &DigestHub) -> io::Result<()> {
185	    recv_hello(&mut conn, Role::Brain)?;
186	    loop {
187	        let env = match read_frame(&mut conn) {
188	            Ok(e) => e,
189	            Err(_) => return Ok(()), // peer hung up
190	        };
191	        match env.kind.as_str() {
192	            KIND_DIGEST_SNAPSHOT => {
193	                let req: DigestReq = serde_json::from_value(env.payload)
194	                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
195	                let (digest, version) = match hub.snapshot(&req.id) {
196	                    Some((d, v)) => (Some(d), v),
197	                    None => (None, 0),
198	                };
199	                let res = DigestResult { digest, version };
200	                write_frame(
201	                    &mut conn,
202	                    &Envelope::new(KIND_DIGEST_RESULT, serde_json::to_value(res).unwrap()),
203	                )?;
204	            }
205	            KIND_DIGEST_SUBSCRIBE => {
206	                let req: DigestReq = serde_json::from_value(env.payload)
207	                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
208	                // Register, send the base snapshot (full), then stream changes.
209	                let (base, version, rx) = hub.subscribe(&req.id);
210	                if version >= req.from_version {
211	                    let base_update = DigestUpdate {
212	                        version,
213	                        from: 0, // a fresh subscriber replaces its (empty) view
214	                        turns: base.turns,
215	                        newly_completed: Vec::new(),
216	                    };
217	                    write_frame(&mut conn, &delta_frame(&base_update))?;
218	                }
219	                // Forward each published change until the client disconnects (a
220	                // write failure) or the hub drops the sender.
221	                while let Ok(up) = rx.recv() {
222	                    if up.version <= req.from_version {
223	                        continue;
224	                    }
225	                    if write_frame(&mut conn, &delta_frame(&up)).is_err() {
226	                        return Ok(()); // client gone — the next publish prunes us
227	                    }
228	                }
229	                return Ok(());
230	            }
231	            // Forward-compat: an unknown control kind is tolerated, not fatal.
232	            _ => {}
233	        }
234	    }
235	}
236
237	/// Connect to the digest-control channel `name`, announcing ourselves as a brain
238	/// (the `spt digest` CLI is a transient logic client of the daemon).
239	fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
240	    let mut conn = LocalSocketTransport::connect(name)?;
241	    send_hello(&mut conn, Role::Brain)?;
242	    Ok(conn)
243	}
244
245	/// Pull the current structured digest for `id` over the control channel `name`.
246	/// `Ok(None)` ⇒ the daemon holds no live digest for that endpoint.
247	// [impl->REQ-TERM-4]
248	pub fn pull_snapshot(name: &str, id: &str) -> io::Result<Option<(Digest, u64)>> {
249	    let mut conn = connect(name)?;
250	    let req = DigestReq {
251	        id: id.to_string(),
252	        from_version: 0,
253	    };
254	    write_frame(
255	        &mut conn,
256	        &Envelope::new(KIND_DIGEST_SNAPSHOT, serde_json::to_value(req).unwrap()),
257	    )?;
258	    let env = read_frame(&mut conn)?;
259	    let res: DigestResult = serde_json::from_value(env.payload)
260	        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
261	    Ok(res.digest.map(|d| (d, res.version)))
262	}
263
264	/// Subscribe to `id`'s delta-stream over `name`, invoking `on_update` for the base

            .unwrap_or(0)
    }
}

/// Build a `digest_delta` envelope from a [`DigestUpdate`].
fn delta_frame(up: &DigestUpdate) -> Envelope {
    Envelope::new(
        KIND_DIGEST_DELTA,
        serde_json::to_value(up).expect("DigestUpdate serializes"),
    )
}

/// Bind the digest-control channel at `name` and serve it forever against `hub`.
/// Thread-per-connection (mirroring the broker / seed-control): a `--follow`
/// subscribe blocks its connection thread streaming deltas, so serving inline
/// would stall every other client.
pub fn serve_digest_control(name: &str, hub: Arc<DigestHub>) -> io::Result<()> {
    let listener = LocalSocketTransport::bind(name)?;
    loop {
        let conn = listener.accept()?;
        let hub = Arc::clone(&hub);
        thread::spawn(move || {
            let _ = handle_conn(conn, &hub);
        });
    }
}

/// Drive one control connection: handshake, then service snapshot pulls and
/// subscribe streams until the client disconnects.
fn handle_conn<C: Read + Write>(mut conn: C, hub: &DigestHub) -> io::Result<()> {
    recv_hello(&mut conn, Role::Brain)?;
    loop {
        let env = match read_frame(&mut conn) {
            Ok(e) => e,
            Err(_) => return Ok(()), // peer hung up
        };
        match env.kind.as_str() {
            KIND_DIGEST_SNAPSHOT => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let (digest, version) = match hub.snapshot(&req.id) {
                    Some((d, v)) => (Some(d), v),
                    None => (None, 0),
                };
                let res = DigestResult { digest, version };
                write_frame(
                    &mut conn,
                    &Envelope::new(KIND_DIGEST_RESULT, serde_json::to_value(res).unwrap()),
                )?;
            }
            KIND_DIGEST_SUBSCRIBE => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                // Register, send the base snapshot (full), then stream changes.
                let (base, version, rx) = hub.subscribe(&req.id);
                if version >= req.from_version {
                    let base_update = DigestUpdate {
                        version,
                        from: 0, // a fresh subscriber replaces its (empty) view
                        turns: base.turns,
                        newly_completed: Vec::new(),
                    };
                    write_frame(&mut conn, &delta_frame(&base_update))?;
                }
                // Forward each published change until the client disconnects (a
                // write failure) or the hub drops the sender.
                while let Ok(up) = rx.recv() {
                    if up.version <= req.from_version {
                        continue;
                    }
                    if write_frame(&mut conn, &delta_frame(&up)).is_err() {
                        return Ok(()); // client gone — the next publish prunes us
                    }
                }
                return Ok(());
            }
            // Forward-compat: an unknown control kind is tolerated, not fatal.
            _ => {}
        }
    }
}

/// Connect to the digest-control channel `name`, announcing ourselves as a brain
/// (the `spt digest` CLI is a transient logic client of the daemon).
fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
    let mut conn = LocalSocketTransport::connect(name)?;
    send_hello(&mut conn, Role::Brain)?;
    Ok(conn)
}

/// Pull the current structured digest for `id` over the control channel `name`.
/// `Ok(None)` ⇒ the daemon holds no live digest for that endpoint.
// [impl->REQ-TERM-4]
pub fn pull_snapshot(name: &str, id: &str) -> io::Result<Option<(Digest, u64)>> {
    let mut conn = connect(name)?;
    let req = DigestReq {
        id: id.to_string(),
        from_version: 0,
    };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_DIGEST_SNAPSHOT, serde_json::to_value(req).unwrap()),
    )?;
    let env = read_frame(&mut conn)?;
    let res: DigestResult = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(res.digest.map(|d| (d, res.version)))
}

/// Subscribe to `id`'s delta-stream over `name`, invoking `on_update` for the base


        match env.kind.as_str() {
            KIND_DIGEST_SNAPSHOT => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let (digest, version) = match hub.snapshot(&req.id) {
                    Some((d, v)) => (Some(d), v),
                    None => (None, 0),
                };
                let res = DigestResult { digest, version };
                write_frame(
                    &mut conn,
                    &Envelope::new(KIND_DIGEST_RESULT, serde_json::to_value(res).unwrap()),
                )?;
            }
            KIND_DIGEST_SUBSCRIBE => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                // Register, send the base snapshot (full), then stream changes.
                let (base, version, rx) = hub.subscribe(&req.id);
                if version >= req.from_version {
                    let base_update = DigestUpdate {
                        version,
                        from: 0, // a fresh subscriber replaces its (empty) view
                        turns: base.turns,
                        newly_completed: Vec::new(),
                    };
                    write_frame(&mut conn, &delta_frame(&base_update))?;
                }
                // Forward each published change until the client disconnects (a
                // write failure) or the hub drops the sender.
                while let Ok(up) = rx.recv() {
                    if up.version <= req.from_version {
                        continue;
                    }
                    if write_frame(&mut conn, &delta_frame(&up)).is_err() {
                        return Ok(()); // client gone — the next publish prunes us
                    }
                }
                return Ok(());
            }
            // Forward-compat: an unknown control kind is tolerated, not fatal.
            _ => {}
        }

        match env.kind.as_str() {
            KIND_DIGEST_SNAPSHOT => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                // Project on demand from the endpoint's records, then publish (so a
                // concurrent subscriber sees the same change) and reply.
                let projected = project_endpoint_digest(&req.id, &DigestConfig::default());
                let (snap, version) = hub.project_and_publish(&req.id, projected);
                let digest = if version == 0 && snap.turns.is_empty() {
                    None // never projected anything for this endpoint
                } else {
                    Some(snap)
                };
                let res = DigestResult { digest, version };
                write_frame(
                    &mut conn,
                    &Envelope::new(KIND_DIGEST_RESULT, serde_json::to_value(res).unwrap()),
                )?;
            }
            KIND_DIGEST_REPROJECT => {
                // Push-driven delta: re-project now and fan any change to live
                // subscribers (the `api digest-entry` follow-up). No reply.
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let projected = project_endpoint_digest(&req.id, &DigestConfig::default());
                let _ = hub.project_and_publish(&req.id, projected);
            }
            KIND_DIGEST_SUBSCRIBE => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                // Seed the base from a fresh on-demand projection, then register and
                // send that base (full), then stream subsequent changes.
                let projected = project_endpoint_digest(&req.id, &DigestConfig::default());
                let _ = hub.project_and_publish(&req.id, projected);
                let (base, version, rx) = hub.subscribe(&req.id);
                if version >= req.from_version {
                    let base_update = DigestUpdate {
                        version,
                        from: 0, // a fresh subscriber replaces its (empty) view
                        turns: base.turns,
                    };
                    write_frame(&mut conn, &delta_frame(&base_update))?;
                }
                // Forward each published change until the client disconnects (a
                // write failure) or the hub drops the sender.
                while let Ok(up) = rx.recv() {
                    if up.version <= req.from_version {
                        continue;
                    }
                    if write_frame(&mut conn, &delta_frame(&up)).is_err() {
                        return Ok(()); // client gone — the next publish prunes us
                    }
                }
                return Ok(());
            }
            // Forward-compat: an unknown control kind is tolerated, not fatal.
            _ => {}
        }


        match env.kind.as_str() {
            KIND_DIGEST_SNAPSHOT => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let (digest, version) = match hub.snapshot(&req.id) {
                    Some((d, v)) => (Some(d), v),
                    None => (None, 0),
                };
                let res = DigestResult { digest, version };
                write_frame(
                    &mut conn,
                    &Envelope::new(KIND_DIGEST_RESULT, serde_json::to_value(res).unwrap()),
                )?;
            }
            KIND_DIGEST_SUBSCRIBE => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                // Register, send the base snapshot (full), then stream changes.
                let (base, version, rx) = hub.subscribe(&req.id);
                if version >= req.from_version {
                    let base_update = DigestUpdate {
                        version,
                        from: 0, // a fresh subscriber replaces its (empty) view
                        turns: base.turns,
                        newly_completed: Vec::new(),
                    };
                    write_frame(&mut conn, &delta_frame(&base_update))?;
                }
                // Forward each published change until the client disconnects (a
                // write failure) or the hub drops the sender.
                while let Ok(up) = rx.recv() {
                    if up.version <= req.from_version {
                        continue;
                    }
                    if write_frame(&mut conn, &delta_frame(&up)).is_err() {
                        return Ok(()); // client gone — the next publish prunes us
                    }
                }
                return Ok(());
            }
            // Forward-compat: an unknown control kind is tolerated, not fatal.
            _ => {}
        }

        match env.kind.as_str() {
            KIND_DIGEST_SNAPSHOT => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                // Project on demand from the endpoint's records, then publish (so a
                // concurrent subscriber sees the same change) and reply.
                let projected = project_endpoint_digest(&req.id, &DigestConfig::default());
                let (snap, version) = hub.project_and_publish(&req.id, projected);
                let digest = if version == 0 && snap.turns.is_empty() {
                    None // never projected anything for this endpoint
                } else {
                    Some(snap)
                };
                let res = DigestResult { digest, version };
                write_frame(
                    &mut conn,
                    &Envelope::new(KIND_DIGEST_RESULT, serde_json::to_value(res).unwrap()),
                )?;
            }
            KIND_DIGEST_REPROJECT => {
                // Push-driven delta: re-project now and fan any change to live
                // subscribers (the `api digest-entry` follow-up). No reply.
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let projected = project_endpoint_digest(&req.id, &DigestConfig::default());
                let _ = hub.project_and_publish(&req.id, projected);
            }
            KIND_DIGEST_SUBSCRIBE => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                // Seed the base from a fresh on-demand projection, then register and
                // send that base (full), then stream subsequent changes.
                let projected = project_endpoint_digest(&req.id, &DigestConfig::default());
                let _ = hub.project_and_publish(&req.id, projected);
                let (base, version, rx) = hub.subscribe(&req.id);
                if version >= req.from_version {
                    let base_update = DigestUpdate {
                        version,
                        from: 0, // a fresh subscriber replaces its (empty) view
                        turns: base.turns,
                    };
                    write_frame(&mut conn, &delta_frame(&base_update))?;
                }
                // Forward each published change until the client disconnects (a
                // write failure) or the hub drops the sender.
                while let Ok(up) = rx.recv() {
                    if up.version <= req.from_version {
                        continue;
                    }
                    if write_frame(&mut conn, &delta_frame(&up)).is_err() {
                        return Ok(()); // client gone — the next publish prunes us
                    }
                }
                return Ok(());
            }
            // Forward-compat: an unknown control kind is tolerated, not fatal.
            _ => {}
        }

-                let (digest, version) = match hub.snapshot(&req.id) {

+                // Project on demand from the endpoint's records, then publish (so a

+                // concurrent subscriber sees the same change) and reply.

+                let projected = project_endpoint_digest(&req.id, &DigestConfig::default());

+                let (snap, version) = hub.project_and_publish(&req.id, projected);

+                let digest = if version == 0 && snap.turns.is_empty() {

+                    None // never projected anything for this endpoint

+                // Push-driven delta: re-project now and fan any change to live

+                // subscribers (the `api digest-entry` follow-up). No reply.

+                let req: DigestReq = serde_json::from_value(env.payload)

+                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;

+                let _ = hub.project_and_publish(&req.id, projected);

-                // Register, send the base snapshot (full), then stream changes.

+                // Seed the base from a fresh on-demand projection, then register and

+                // send that base (full), then stream subsequent changes.

-                        newly_completed: Vec::new(),


Add the `reproject` client fn + refresh `pull_snapshot` doc:


/// Pull the current structured digest for `id` over the control channel `name`.
/// `Ok(None)` ⇒ the daemon holds no live digest for that endpoint.
// [impl->REQ-TERM-4]
pub fn pull_snapshot(name: &str, id: &str) -> io::Result<Option<(Digest, u64)>> {
    let mut conn = connect(name)?;
    let req = DigestReq {
        id: id.to_string(),
        from_version: 0,
    };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_DIGEST_SNAPSHOT, serde_json::to_value(req).unwrap()),
    )?;
    let env = read_frame(&mut conn)?;
    let res: DigestResult = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(res.digest.map(|d| (d, res.version)))
}

/// Pull the current structured digest for `id` over the control channel `name`:
/// the daemon projects it on demand from the endpoint's normalized records.
/// `Ok(None)` ⇒ nothing was projectable for that endpoint (no source / no records).
// [impl->REQ-TERM-4]
pub fn pull_snapshot(name: &str, id: &str) -> io::Result<Option<(Digest, u64)>> {
    let mut conn = connect(name)?;
    let req = DigestReq {
        id: id.to_string(),
        from_version: 0,
    };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_DIGEST_SNAPSHOT, serde_json::to_value(req).unwrap()),
    )?;
    let env = read_frame(&mut conn)?;
    let res: DigestResult = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(res.digest.map(|d| (d, res.version)))
}

/// Trigger an on-demand re-projection of `id` on the daemon and publish any change
/// to live subscribers — the push-driven delta after an `api digest-entry` append.
/// Fire-and-forget (no reply): a daemon that isn't running just means the next
/// pull projects fresh.
// [impl->REQ-TERM-4]
pub fn reproject(name: &str, id: &str) -> io::Result<()> {
    let mut conn = connect(name)?;
    let req = DigestReq {
        id: id.to_string(),
        from_version: 0,
    };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_DIGEST_REPROJECT, serde_json::to_value(req).unwrap()),
    )?;
    Ok(())
}