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

    #[test] // [unit->REQ-SEAM-HISTORY] native Path-B store: read the perch's history.log.
    fn native_reads_logged_records() {
        with_home(|_| {
            let perch = resolve_perch_path("alice", ParentHint::Infer);
            std::fs::create_dir_all(&perch).unwrap();
            std::fs::write(perch.join("history.log"), "{\"r\":\"u\"}\n{\"r\":\"a\"}\n").unwrap();
            let h = History {
                strategy: HistoryStrategy::Native,
                fetcher: None,
                locate_template: None,
                normalize_command: None,
            };
            let recs = fetch_history(
                &h,
                &no_keys(),
                "alice",
                ParentHint::Infer,
                Duration::from_secs(5),
            )
            .expect("fetch");
            assert_eq!(recs.len(), 2);
            assert_eq!(recs[0].raw, "{\"r\":\"u\"}");
        });
    }

    #[test] // [unit->REQ-SEAM-HISTORY] native Path-B store: read the perch's
            // history.log. The lines stay OPAQUE to the history subsystem (carried
            // verbatim); the sample shape is the published digest-record contract
            // (one schema across the tree — no second discriminator).
    fn native_reads_logged_records() {
        with_home(|_| {
            let perch = resolve_perch_path("alice", ParentHint::Infer);
            std::fs::create_dir_all(&perch).unwrap();
            std::fs::write(
                perch.join("history.log"),
                "{\"role\":\"input\",\"text\":\"hi\"}\n{\"role\":\"agent\",\"text\":\"yo\"}\n",
            )
            .unwrap();
            let h = History {
                strategy: HistoryStrategy::Native,
                fetcher: None,
                locate_template: None,
                normalize_command: None,
            };
            let recs = fetch_history(
                &h,
                &no_keys(),
                "alice",
                ParentHint::Infer,
                Duration::from_secs(5),
            )
            .expect("fetch");
            assert_eq!(recs.len(), 2);
            assert_eq!(recs[0].raw, "{\"role\":\"input\",\"text\":\"hi\"}");
        });
    }

-    #[test] // [unit->REQ-SEAM-HISTORY] native Path-B store: read the perch's history.log.

+    #[test] // [unit->REQ-SEAM-HISTORY] native Path-B store: read the perch's

+            // history.log. The lines stay OPAQUE to the history subsystem (carried

+            // verbatim); the sample shape is the published digest-record contract

+            // (one schema across the tree — no second discriminator).

+                perch.join("history.log"),


Now retitle REQ-TERM-4 in `traceable-reqs.toml` (doyle's honest title):


Edit

[[requirements]]
id = "REQ-TERM-4"
title = "Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log"
required_stages = ["impl", "unit", "int"]

[[requirements]]
id = "REQ-TERM-4"
title = "Live activity buffer (session digest): projection of normalized session logs, snapshot-pull (spt endpoint digest) + structured-delta-stream contract + api digest-entry push, opt-in Path-B log"
required_stages = ["impl", "unit", "int"]


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

[[requirements]]
id = "REQ-TERM-4"
title = "Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log"
required_stages = ["impl", "unit", "int"]

[[requirements]]
id = "REQ-TERM-4"
title = "Live activity buffer (session digest): projection of normalized session logs, snapshot-pull (spt endpoint digest) + structured-delta-stream contract + api digest-entry push, opt-in Path-B log"
required_stages = ["impl", "unit", "int"]

# ── Live activity buffer / PTY digest (ADR-0008, 2026-06-01; activate M3) ──

-title = "Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log"

+title = "Live activity buffer (session digest): projection of normalized session logs, snapshot-pull (spt endpoint digest) + structured-delta-stream contract + api digest-entry push, opt-in Path-B log"


Now verify the retire compiles clean across the workspace (catch any remaining references to retired symbols):


Found 14 files
M9-WAVE3-JIT.md
crates\spt-term\src\projection.rs
ROADMAP.md
CONTEXT.md
M9-PLAN.md
docs\adr\0008-live-activity-buffer-pty-digest.md
docs-site\src\harness-contract\integration-checklist.md
RESTORATION-PLAN.md
RESTORATION-D2-PLAN.md
docs\MANIFEST.md
docs-site\src\terminal\overview.md
docs-site\src\harness-contract\manifest.md
crates\spt-term\tests\digest.rs
M3b-PLAN.md


`crates/spt-term/tests/digest.rs` is a parser integration test — it won't compile now. Check it:


1	//! A5 integration test: the digest parser primitive over a **real** A4 byte
2	//! stream (an echo child on a native PTY), proving the stream→parser wiring on
3	//! both backends. The daemon-side fetch / delta-stream / persistence is M3b.
4
5	use std::time::Duration;
6
7	use spt_term::{
8	    DigestConfig, DigestEntry, DigestParser, DigestPatterns, PtySession, SessionSurface,
9	    SurfaceSize,
10	};
11
12	fn echo_child() -> PtySession {
13	    #[cfg(unix)]
14	    let s = PtySession::spawn_program("cat", Vec::<String>::new(), SurfaceSize::new(24, 80));
15	    #[cfg(windows)]
16	    let s = PtySession::spawn_program("findstr", ["."], SurfaceSize::new(24, 80));
17	    s.expect("spawn echo child")
18	}
19
20	fn patterns() -> DigestPatterns {
21	    DigestPatterns {
22	        input: r"(?m)^USER> ".to_string(),
23	        agent: r"(?m)^AGENT> ".to_string(),
24	        tools: vec![
25	            r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string(),
26	            r"(?P<name>Bash)\((?P<arg>[^)]*)\)".to_string(),
27	        ],
28	        catchall: None,
29	    }
30	}
31
32	// [unit->REQ-TERM-4]
33	#[test]
34	fn digest_parses_a_real_pty_stream() {
35	    let session = echo_child();
36	    let stream = session.byte_stream(256).expect("open stream");
37	    let mut parser = DigestParser::new(
38	        patterns(),
39	        DigestConfig {
40	            window_turns: 3,
41	            ..Default::default()
42	        },
43	    )
44	    .expect("compile patterns");
45
46	    // Drive a scripted transcript through the child. The PTY's input echo and
47	    // the child's own write are CONCURRENT writers into the master-read
48	    // stream: lines injected back-to-back can interleave at byte (not line)
49	    // granularity, splitting a tool line mid-`Write(…)` so the regex never
50	    // matches (FLAKE-LEDGER #2 — gravity). Serialize instead: after each
51	    // line, pump until the stream carried every expected copy of it back
52	    // before sending the next, so no later write can race an earlier one.
53	    // `copies` adapts to the backend's echo behavior off the first line
54	    // (echo + child = 2; echo-less = 1), bounded by a per-line deadline.
55	    let mut transcript = String::new();
56	    let mut copies = 2usize;
57	    for line in [
58	        "USER> first request",
59	        "AGENT> working on it",
60	        "Write(file.txt) then Bash(ls -la) done",
61	        "USER> second request",
62	        "AGENT> all finished",
63	    ] {
64	        session.send_line(line).expect("inject transcript line");
65	        let deadline = std::time::Instant::now() + Duration::from_secs(2);
66	        while transcript.matches(line).count() < copies {
67	            if std::time::Instant::now() >= deadline {
68	                // Echo-less backend (or a rendered/split echo): expect what
69	                // the first line actually produced from here on.
70	                copies = transcript.matches(line).count().max(1);
71	                break;
72	            }
73	            if let Some(chunk) = stream.recv_timeout(Duration::from_millis(25)) {
74	                transcript.push_str(&String::from_utf8_lossy(&chunk));
75	                parser.feed(&chunk);
76	            }
77	        }
78	    }
79
80	    // Pump the stream into the parser until the latest turn lands or we time out.
81	    let mut ok = false;
82	    for _ in 0..200 {
83	        while let Some(chunk) = stream.try_recv() {
84	            transcript.push_str(&String::from_utf8_lossy(&chunk));
85	            parser.feed(&chunk);
86	        }
87	        let d = parser.digest();
88	        if d.turns
89	            .last()
90	            .and_then(|t| t.input.as_deref())
91	            .is_some_and(|i| i.contains("second request"))
92	        {
93	            ok = true;
94	            break;
95	        }
96	        if let Some(chunk) = stream.recv_timeout(Duration::from_millis(25)) {
97	            transcript.push_str(&String::from_utf8_lossy(&chunk));
98	            parser.feed(&chunk);
99	        }
100	    }
101
102	    stream.request_stop();
103	    let _ = session.send_keys(&[0x03]);
104	    let _ = session.kill();
105
106	    let d = parser.digest();
107	    assert!(
108	        ok,
109	        "the digest must reflect the latest user turn; got {d:?}\nraw transcript: {transcript:?}"
110	    );
111
112	    // The first turn must show the collapsed Write+Bash tool sprint.
113	    let first = d.turns.iter().find(|t| {
114	        t.input
115	            .as_deref()
116	            .is_some_and(|i| i.contains("first request"))
117	    });
118	    let first = first.expect("first turn present in window");
119	    let has_sprint = first
120	        .entries
121	        .iter()
122	        .any(|e| matches!(e, DigestEntry::ToolSprint(s) if s.len() >= 2));
123	    assert!(
124	        has_sprint,
125	        "first turn must collapse Write+Bash into a sprint; got {first:?}\nraw transcript: {transcript:?}"
126	    );
127	}
128

//! A5 integration test: the digest parser primitive over a **real** A4 byte
//! stream (an echo child on a native PTY), proving the stream→parser wiring on
//! both backends. The daemon-side fetch / delta-stream / persistence is M3b.

use std::time::Duration;

use spt_term::{
    DigestConfig, DigestEntry, DigestParser, DigestPatterns, PtySession, SessionSurface,
    SurfaceSize,
};

fn echo_child() -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program("cat", Vec::<String>::new(), SurfaceSize::new(24, 80));
    #[cfg(windows)]
    let s = PtySession::spawn_program("findstr", ["."], SurfaceSize::new(24, 80));
    s.expect("spawn echo child")
}

fn patterns() -> DigestPatterns {
    DigestPatterns {
        input: r"(?m)^USER> ".to_string(),
        agent: r"(?m)^AGENT> ".to_string(),
        tools: vec![
            r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string(),
            r"(?P<name>Bash)\((?P<arg>[^)]*)\)".to_string(),
        ],
        catchall: None,
    }
}

// [unit->REQ-TERM-4]
#[test]
fn digest_parses_a_real_pty_stream() {
    let session = echo_child();
    let stream = session.byte_stream(256).expect("open stream");
    let mut parser = DigestParser::new(
        patterns(),
        DigestConfig {
            window_turns: 3,
            ..Default::default()
        },
    )
    .expect("compile patterns");

    // Drive a scripted transcript through the child. The PTY's input echo and
    // the child's own write are CONCURRENT writers into the master-read
    // stream: lines injected back-to-back can interleave at byte (not line)
    // granularity, splitting a tool line mid-`Write(…)` so the regex never
    // matches (FLAKE-LEDGER #2 — gravity). Serialize instead: after each
    // line, pump until the stream carried every expected copy of it back
    // before sending the next, so no later write can race an earlier one.
    // `copies` adapts to the backend's echo behavior off the first line
    // (echo + child = 2; echo-less = 1), bounded by a per-line deadline.
    let mut transcript = String::new();
    let mut copies = 2usize;
    for line in [
        "USER> first request",
        "AGENT> working on it",
        "Write(file.txt) then Bash(ls -la) done",
        "USER> second request",
        "AGENT> all finished",
    ] {
        session.send_line(line).expect("inject transcript line");
        let deadline = std::time::Instant::now() + Duration::from_secs(2);
        while transcript.matches(line).count() < copies {
            if std::time::Instant::now() >= deadline {
                // Echo-less backend (or a rendered/split echo): expect what
                // the first line actually produced from here on.
                copies = transcript.matches(line).count().max(1);
                break;
            }
            if let Some(chunk) = stream.recv_timeout(Duration::from_millis(25)) {
                transcript.push_str(&String::from_utf8_lossy(&chunk));
                parser.feed(&chunk);
            }
        }
    }

    // Pump the stream into the parser until the latest turn lands or we time out.
    let mut ok = false;
    for _ in 0..200 {
        while let Some(chunk) = stream.try_recv() {
            transcript.push_str(&String::from_utf8_lossy(&chunk));
            parser.feed(&chunk);
        }
        let d = parser.digest();
        if d.turns
            .last()
            .and_then(|t| t.input.as_deref())
            .is_some_and(|i| i.contains("second request"))
        {
            ok = true;
            break;
        }
        if let Some(chunk) = stream.recv_timeout(Duration::from_millis(25)) {
            transcript.push_str(&String::from_utf8_lossy(&chunk));
            parser.feed(&chunk);
        }
    }

    stream.request_stop();
    let _ = session.send_keys(&[0x03]);
    let _ = session.kill();

    let d = parser.digest();
    assert!(
        ok,
        "the digest must reflect the latest user turn; got {d:?}\nraw transcript: {transcript:?}"
    );

    // The first turn must show the collapsed Write+Bash tool sprint.
    let first = d.turns.iter().find(|t| {
        t.input
            .as_deref()
            .is_some_and(|i| i.contains("first request"))
    });
    let first = first.expect("first turn present in window");
    let has_sprint = first
        .entries
        .iter()
        .any(|e| matches!(e, DigestEntry::ToolSprint(s) if s.len() >= 2));
    assert!(
        has_sprint,
        "first turn must collapse Write+Bash into a sprint; got {first:?}\nraw transcript: {transcript:?}"
    );
}


Delete the retired parser integration test (its REQ-TERM-4 evidence is replaced by the projection units + the daemon CC-fixture E2E):


Bash

cd C:/Users/decid/Documents/projects/spt-core && git rm crates/spt-term/tests/digest.rs 2>&1 | tail -2

Remove the retired parser integration test


rm 'crates/spt-term/tests/digest.rs'


Now build + clippy + test the workspace to confirm the retire is clean:


cd C:/Users/decid/Documents/projects/spt-core && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | grep -E "error|warning:|Finished" | head -20

Clippy workspace after retire


error: struct update has no effect, all the fields in the struct have already been specified
error: could not compile `spt-term` (lib test) due to 1 previous error
warning: build failed, waiting for other jobs to finish...


`DigestConfig` now has exactly 2 fields, so `{both, ..Default::default()}` is redundant. Fix in projection.rs:


Edit

        let c = DigestConfig {
            window_turns: 3,
            arg_truncation: 10,
            ..Default::default()
        };

        let c = DigestConfig {
            window_turns: 3,
            arg_truncation: 10,
        };


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

        let c = DigestConfig {
            window_turns: 3,
            arg_truncation: 10,
            ..Default::default()
        };

        let c = DigestConfig {
            window_turns: 3,
            arg_truncation: 10,
        };


Bash

cd C:/Users/decid/Documents/projects/spt-core && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | grep -E "error|warning:|Finished" | head -20

Re-run clippy workspace


Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.74s


Clippy clean. Run the full workspace test + traceability (REQ-TERM-4 now evidenced only by the new projection/daemon/E2E):


cd C:/Users/decid/Documents/projects/spt-core && cargo test --workspace 2>&1 | grep -E "error\[|panicked|FAILED|test result: FAILED" | head -20; echo "=== traceable ==="; traceable-reqs check 2>&1 | grep -iE "REQ-TERM-4|FAIL|missing|invalid" | head; traceable-reqs check >/dev/null 2>&1; echo "EXIT=$?"

Workspace tests + traceable REQ-TERM-4


Command running in background with ID: b7m8d6rpn. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\c570fe63-c413-4a17-bb14-ee75e02a53bd\tasks\b7m8d6rpn.output. You will be notified when it completes. To check interim output, use Read on that file path.


While the workspace test runs, do the docs. Read the MANIFEST.md `[pty_digest]` section:


105
106	### `[identity]`
107	```toml
108	[identity]
109	session_id_source = "post_spawn"   # post_spawn | uuid_inject
110	parent_ancestor_name = "claude"    # process-tree anchor when session_id absent
111	```
112
113	### `[pty_digest]` — live activity buffer patterns (ADR-0008)
114
115	<!-- [doc->REQ-TERM-4] The adapter supplies the patterns; spt-core only runs them
116	over the broker PTY byte stream (Path A's "adapter owns the parser" rule, identical
117	to opaque command templates). This is NOT a built-in per-harness parser — it never
118	locates or parses the harness transcript. Absent section ⇒ the digest is simply
119	unavailable for the adapter. -->
120
121	```toml
122	[pty_digest]
123	input_pattern = "\\nUSER> "          # user-input boundary (opens a span until the next match)
124	agent_pattern = "\\nAGENT> "         # agent-output boundary (opens a span until the next match)
125	tool_patterns = [                     # one regex per known tool; name `arg` (+ optional `name`) groups
126	  "(?P<name>Write)\\((?P<arg>[^)]*)\\)",
127	  "(?P<name>Bash)\\((?P<arg>[^)]*)\\)",
128	]
129	catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"   # optional: unknowable user tools
130	window_turns = 3                      # optional presentation override (last N user turns); default ~3
131	persist = false                       # opt-in Path-B "Option (C)" coarse activity log; off by default
132	```
133
134	The two boundary markers are **required** when the section is present (an empty
135	boundary degenerates the stream to one undifferentiated span); `tool_patterns`,
136	`catchall_pattern`, and `window_turns` are optional. The patterns are **opaque
137	regex strings** spt-core compiles and runs — model/tool semantics live in the
138	adapter, never spt-core. Presentation knobs other than `window_turns` (tool-arg
139	truncation ~25 chars, ANSI stripping, the rolling scan-buffer cap) are spt-core-owned
140	defaults: the template controls *extraction*, spt-core controls *presentation*.
141
142	Two access modes ride the one R-TERM-3 session surface (raw bytes vs parsed digest
143	= two channels): a **snapshot pull** (`spt digest <id>`) and a **delta-stream**
144	(`spt digest <id> --follow` — only changed turns are pushed). Local addressing only;
145	the qualified `[subnet:]id@node` cross-node form is **M4**.
146
147	### `[update]` — adapter self-update
148	```toml
149	[update]
150	avenue = "delegated"               # delegated | file_pull
151	command = "claude plugin update spt"          # delegated
152	# file_pull: repo + path_regex
153	version_check = true               # verify spt-core satisfies min_spt_core_version before/after
154	uninstall = "claude plugin uninstall spt"     # optional; mirror of install — runs on `adapter remove` once quiesced

### `[identity]`
```toml
[identity]
session_id_source = "post_spawn"   # post_spawn | uuid_inject
parent_ancestor_name = "claude"    # process-tree anchor when session_id absent
```

### `[pty_digest]` — live activity buffer patterns (ADR-0008)

<!-- [doc->REQ-TERM-4] The adapter supplies the patterns; spt-core only runs them
over the broker PTY byte stream (Path A's "adapter owns the parser" rule, identical
to opaque command templates). This is NOT a built-in per-harness parser — it never
locates or parses the harness transcript. Absent section ⇒ the digest is simply
unavailable for the adapter. -->

```toml
[pty_digest]
input_pattern = "\\nUSER> "          # user-input boundary (opens a span until the next match)
agent_pattern = "\\nAGENT> "         # agent-output boundary (opens a span until the next match)
tool_patterns = [                     # one regex per known tool; name `arg` (+ optional `name`) groups
  "(?P<name>Write)\\((?P<arg>[^)]*)\\)",
  "(?P<name>Bash)\\((?P<arg>[^)]*)\\)",
]
catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"   # optional: unknowable user tools
window_turns = 3                      # optional presentation override (last N user turns); default ~3
persist = false                       # opt-in Path-B "Option (C)" coarse activity log; off by default
```

The two boundary markers are **required** when the section is present (an empty
boundary degenerates the stream to one undifferentiated span); `tool_patterns`,
`catchall_pattern`, and `window_turns` are optional. The patterns are **opaque
regex strings** spt-core compiles and runs — model/tool semantics live in the
adapter, never spt-core. Presentation knobs other than `window_turns` (tool-arg
truncation ~25 chars, ANSI stripping, the rolling scan-buffer cap) are spt-core-owned
defaults: the template controls *extraction*, spt-core controls *presentation*.

Two access modes ride the one R-TERM-3 session surface (raw bytes vs parsed digest
= two channels): a **snapshot pull** (`spt digest <id>`) and a **delta-stream**
(`spt digest <id> --follow` — only changed turns are pushed). Local addressing only;
the qualified `[subnet:]id@node` cross-node form is **M4**.

### `[update]` — adapter self-update
```toml
[update]
avenue = "delegated"               # delegated | file_pull
command = "claude plugin update spt"          # delegated
# file_pull: repo + path_regex
version_check = true               # verify spt-core satisfies min_spt_core_version before/after
uninstall = "claude plugin uninstall spt"     # optional; mirror of install — runs on `adapter remove` once quiesced


Replace the `[pty_digest]` MANIFEST section with the published digest-record contract (no manifest *seam*; the contract is the published surface):


Edit

### `[pty_digest]` — live activity buffer patterns (ADR-0008)

<!-- [doc->REQ-TERM-4] The adapter supplies the patterns; spt-core only runs them
over the broker PTY byte stream (Path A's "adapter owns the parser" rule, identical
to opaque command templates). This is NOT a built-in per-harness parser — it never
locates or parses the harness transcript. Absent section ⇒ the digest is simply
unavailable for the adapter. -->

```toml
[pty_digest]
input_pattern = "\\nUSER> "          # user-input boundary (opens a span until the next match)
agent_pattern = "\\nAGENT> "         # agent-output boundary (opens a span until the next match)
tool_patterns = [                     # one regex per known tool; name `arg` (+ optional `name`) groups
  "(?P<name>Write)\\((?P<arg>[^)]*)\\)",
  "(?P<name>Bash)\\((?P<arg>[^)]*)\\)",
]
catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"   # optional: unknowable user tools
window_turns = 3                      # optional presentation override (last N user turns); default ~3
persist = false                       # opt-in Path-B "Option (C)" coarse activity log; off by default
```

The two boundary markers are **required** when the section is present (an empty
boundary degenerates the stream to one undifferentiated span); `tool_patterns`,
`catchall_pattern`, and `window_turns` are optional. The patterns are **opaque
regex strings** spt-core compiles and runs — model/tool semantics live in the
adapter, never spt-core. Presentation knobs other than `window_turns` (tool-arg
truncation ~25 chars, ANSI stripping, the rolling scan-buffer cap) are spt-core-owned
defaults: the template controls *extraction*, spt-core controls *presentation*.

Two access modes ride the one R-TERM-3 session surface (raw bytes vs parsed digest
= two channels): a **snapshot pull** (`spt digest <id>`) and a **delta-stream**
(`spt digest <id> --follow` — only changed turns are pushed). Local addressing only;
the qualified `[subnet:]id@node` cross-node form is **M4**.

### Session digest — the published digest-record contract (ADR-0008 amendment)

<!-- [doc->REQ-TERM-4] The session digest is a PROJECTION of the endpoint's
normalized session logs, never a PTY-byte parse (the superseded source mechanism).
It has NO manifest seam — it rides the existing `[history]` strategy (Path A
locate+normalize / fetcher, or Path B native store). What IS published here is the
digest-record CONTRACT: the small, fixed, spt-core-owned shape a projectable
adapter targets in each normalized line (or pushes via `api digest-entry`). The
history subsystem stays opaque — it carries each line verbatim; this contract is
the digest's own overlay on that line's content. One schema, no mapping layer. -->

A **projectable** adapter emits its normalized history lines (or pushes digest
entries) in the digest-record contract — a JSON object per record:

```json
{"role": "input", "text": "add a file"}
{"role": "agent", "text": "on it"}
{"role": "tool",  "tool": {"name": "Write", "arg": "src/a.rs"}}
```

- `role` — the closed set `input` | `agent` | `tool` (the source tag).
- `text` — the input / agent span (omitted/empty for a `tool` record).
- `tool` — `{name, arg}`, present **iff** `role == "tool"`. Consecutive `tool`
  records collapse into one sprint.

**Forward-compat:** unknown object fields are ignored; a line that is not a valid
record (malformed JSON, missing/unknown `role`, a `tool` record without its `tool`
object) is **skipped** by the projection, never fatal.

**Presentation is spt-core's** (the last ~3 user turns, tool-arg truncation to ~25
chars); **extraction is the adapter's** (the record contract). No manifest section
configures the digest — declare `[history]` and emit the contract.

Two access modes over the digest control channel: a **snapshot pull** (`spt
endpoint digest <id>` — projected on demand) and a **structured-delta stream**
(`spt endpoint digest <id> --follow` — changed turns pushed; deltas driven by
pulls / `api digest-entry` pushes). Autonomous file-watch freshness is deferred to
the consuming frontend milestone. Local addressing only.


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

### `[pty_digest]` — live activity buffer patterns (ADR-0008)

<!-- [doc->REQ-TERM-4] The adapter supplies the patterns; spt-core only runs them
over the broker PTY byte stream (Path A's "adapter owns the parser" rule, identical
to opaque command templates). This is NOT a built-in per-harness parser — it never
locates or parses the harness transcript. Absent section ⇒ the digest is simply
unavailable for the adapter. -->

```toml
[pty_digest]
input_pattern = "\\nUSER> "          # user-input boundary (opens a span until the next match)
agent_pattern = "\\nAGENT> "         # agent-output boundary (opens a span until the next match)
tool_patterns = [                     # one regex per known tool; name `arg` (+ optional `name`) groups
  "(?P<name>Write)\\((?P<arg>[^)]*)\\)",
  "(?P<name>Bash)\\((?P<arg>[^)]*)\\)",
]
catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"   # optional: unknowable user tools
window_turns = 3                      # optional presentation override (last N user turns); default ~3
persist = false                       # opt-in Path-B "Option (C)" coarse activity log; off by default
```

The two boundary markers are **required** when the section is present (an empty
boundary degenerates the stream to one undifferentiated span); `tool_patterns`,
`catchall_pattern`, and `window_turns` are optional. The patterns are **opaque
regex strings** spt-core compiles and runs — model/tool semantics live in the
adapter, never spt-core. Presentation knobs other than `window_turns` (tool-arg
truncation ~25 chars, ANSI stripping, the rolling scan-buffer cap) are spt-core-owned
defaults: the template controls *extraction*, spt-core controls *presentation*.

Two access modes ride the one R-TERM-3 session surface (raw bytes vs parsed digest
= two channels): a **snapshot pull** (`spt digest <id>`) and a **delta-stream**
(`spt digest <id> --follow` — only changed turns are pushed). Local addressing only;
the qualified `[subnet:]id@node` cross-node form is **M4**.

### Session digest — the published digest-record contract (ADR-0008 amendment)

<!-- [doc->REQ-TERM-4] The session digest is a PROJECTION of the endpoint's
normalized session logs, never a PTY-byte parse (the superseded source mechanism).
It has NO manifest seam — it rides the existing `[history]` strategy (Path A
locate+normalize / fetcher, or Path B native store). What IS published here is the
digest-record CONTRACT: the small, fixed, spt-core-owned shape a projectable
adapter targets in each normalized line (or pushes via `api digest-entry`). The
history subsystem stays opaque — it carries each line verbatim; this contract is
the digest's own overlay on that line's content. One schema, no mapping layer. -->

A **projectable** adapter emits its normalized history lines (or pushes digest
entries) in the digest-record contract — a JSON object per record:

```json
{"role": "input", "text": "add a file"}
{"role": "agent", "text": "on it"}
{"role": "tool",  "tool": {"name": "Write", "arg": "src/a.rs"}}
```

- `role` — the closed set `input` | `agent` | `tool` (the source tag).
- `text` — the input / agent span (omitted/empty for a `tool` record).
- `tool` — `{name, arg}`, present **iff** `role == "tool"`. Consecutive `tool`
  records collapse into one sprint.

**Forward-compat:** unknown object fields are ignored; a line that is not a valid
record (malformed JSON, missing/unknown `role`, a `tool` record without its `tool`
object) is **skipped** by the projection, never fatal.

**Presentation is spt-core's** (the last ~3 user turns, tool-arg truncation to ~25
chars); **extraction is the adapter's** (the record contract). No manifest section
configures the digest — declare `[history]` and emit the contract.

Two access modes over the digest control channel: a **snapshot pull** (`spt
endpoint digest <id>` — projected on demand) and a **structured-delta stream**
(`spt endpoint digest <id> --follow` — changed turns pushed; deltas driven by
pulls / `api digest-entry` pushes). Autonomous file-watch freshness is deferred to
the consuming frontend milestone. Local addressing only.

parent_ancestor_name = "claude"    # process-tree anchor when session_id absent

-### `[pty_digest]` — live activity buffer patterns (ADR-0008)

+### Session digest — the published digest-record contract (ADR-0008 amendment)

-<!-- [doc->REQ-TERM-4] The adapter supplies the patterns; spt-core only runs them

-over the broker PTY byte stream (Path A's "adapter owns the parser" rule, identical

-to opaque command templates). This is NOT a built-in per-harness parser — it never

-locates or parses the harness transcript. Absent section ⇒ the digest is simply

+<!-- [doc->REQ-TERM-4] The session digest is a PROJECTION of the endpoint's

+normalized session logs, never a PTY-byte parse (the superseded source mechanism).

+It has NO manifest seam — it rides the existing `[history]` strategy (Path A

+locate+normalize / fetcher, or Path B native store). What IS published here is the

+digest-record CONTRACT: the small, fixed, spt-core-owned shape a projectable

+adapter targets in each normalized line (or pushes via `api digest-entry`). The

+history subsystem stays opaque — it carries each line verbatim; this contract is

+the digest's own overlay on that line's content. One schema, no mapping layer. -->

-input_pattern = "\\nUSER> "          # user-input boundary (opens a span until the next match)

-agent_pattern = "\\nAGENT> "         # agent-output boundary (opens a span until the next match)

-tool_patterns = [                     # one regex per known tool; name `arg` (+ optional `name`) groups

-catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"   # optional: unknowable user tools

-window_turns = 3                      # optional presentation override (last N user turns); default ~3

-persist = false                       # opt-in Path-B "Option (C)" coarse activity log; off by default

+A **projectable** adapter emits its normalized history lines (or pushes digest

+entries) in the digest-record contract — a JSON object per record:

+{"role": "input", "text": "add a file"}

+{"role": "tool",  "tool": {"name": "Write", "arg": "src/a.rs"}}

-The two boundary markers are **required** when the section is present (an empty

-boundary degenerates the stream to one undifferentiated span); `tool_patterns`,

-`catchall_pattern`, and `window_turns` are optional. The patterns are **opaque

-regex strings** spt-core compiles and runs — model/tool semantics live in the

-adapter, never spt-core. Presentation knobs other than `window_turns` (tool-arg

-truncation ~25 chars, ANSI stripping, the rolling scan-buffer cap) are spt-core-owned

-defaults: the template controls *extraction*, spt-core controls *presentation*.

+- `role` — the closed set `input` | `agent` | `tool` (the source tag).

+- `text` — the input / agent span (omitted/empty for a `tool` record).

+- `tool` — `{name, arg}`, present **iff** `role == "tool"`. Consecutive `tool`

-Two access modes ride the one R-TERM-3 session surface (raw bytes vs parsed digest

-= two channels): a **snapshot pull** (`spt digest <id>`) and a **delta-stream**

-(`spt digest <id> --follow` — only changed turns are pushed). Local addressing only;

-the qualified `[subnet:]id@node` cross-node form is **M4**.

+**Forward-compat:** unknown object fields are ignored; a line that is not a valid

+record (malformed JSON, missing/unknown `role`, a `tool` record without its `tool`

+object) is **skipped** by the projection, never fatal.

+**Presentation is spt-core's** (the last ~3 user turns, tool-arg truncation to ~25

+chars); **extraction is the adapter's** (the record contract). No manifest section

+configures the digest — declare `[history]` and emit the contract.

+Two access modes over the digest control channel: a **snapshot pull** (`spt

+endpoint digest <id>` — projected on demand) and a **structured-delta stream**

+(`spt endpoint digest <id> --follow` — changed turns pushed; deltas driven by

+pulls / `api digest-entry` pushes). Autonomous file-watch freshness is deferred to

+the consuming frontend milestone. Local addressing only.


Update CONTEXT.md — discharge the implementation-note debt + record the deferred autonomous-freshness:


- **Implementation note:** `spt-daemon::digest` (the `DigestEngine` + manifest `PtyDigest` pattern seam) implements the superseded PTY-parse shape; reconciliation onto the log source is pending milestone work, as is the `docs/MANIFEST.md` pattern-seam schema revision.

- **Source mechanism reconciled (M9, ADR-0008 amendment made real):** the PTY-parse engine is **retired** — the digest is now an **on-demand projection** of the endpoint's normalized records (`spt-term::projection` over the published digest-record contract; `spt-daemon::digest::project_endpoint_digest` resolves the `[history]` source or the `api digest-entry` `digest.log` sink). No manifest seam (`[pty_digest]` is gone); the digest rides `[history]`. **Deferred (tracked, no consumer yet):** the *autonomous file-watch freshness* nudge and the Option-C persistence re-home land with the consuming-frontend milestone (Shell M10 / GUI pane) that actually reads the live stream — today the delta-stream contract ships and its deltas are driven by pulls / `api digest-entry` pushes.


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

- **Topology-independent.** Because the source is logs, the digest works for harness-hosted endpoints too — Claude Code's per-session JSONL is Path A's worked example. Freshness = file-watch + adapter hook nudges; harnesses with no usable logs push entries directly via **`api digest-entry`** (the Path-B-style door). Near-realtime tailing needs an *incremental* normalize story (design-phase concern: Path A's normalize-command was shaped for occasional pulls).

- **Two access modes.** **Snapshot pull** — `spt endpoint digest <[subnet:]id[@node]>` returns the current structured buffer (a Shell/CLI/GUI on-demand render; feeds the frontend's "latest session output" pane; a Gateway's agent-window). **Structured-delta stream** — a subscriber (live Shell pane, GUI, Gateway) receives only *changes* (new turn, new tool entry, collapse update), keyed to log records, for resource-efficient near-realtime reflection. **Access is address-gated** — fetch/subscribe is allowed for anyone who can address the endpoint (visible + resolvable per the resolution policy), the same gate as messaging.

- **"Option (C)" logging surface:** the digest may also be *persisted* (opt-in per-endpoint) as a coarse activity log appended to the native history store (Path B), on-boundary or periodic — off by default.

-- **Implementation note:** `spt-daemon::digest` (the `DigestEngine` + manifest `PtyDigest` pattern seam) implements the superseded PTY-parse shape; reconciliation onto the log source is pending milestone work, as is the `docs/MANIFEST.md` pattern-seam schema revision.

+- **Source mechanism reconciled (M9, ADR-0008 amendment made real):** the PTY-parse engine is **retired** — the digest is now an **on-demand projection** of the endpoint's normalized records (`spt-term::projection` over the published digest-record contract; `spt-daemon::digest::project_endpoint_digest` resolves the `[history]` source or the `api digest-entry` `digest.log` sink). No manifest seam (`[pty_digest]` is gone); the digest rides `[history]`. **Deferred (tracked, no consumer yet):** the *autonomous file-watch freshness* nudge and the Option-C persistence re-home land with the consuming-frontend milestone (Shell M10 / GUI pane) that actually reads the live stream — today the delta-stream contract ships and its deltas are driven by pulls / `api digest-entry` pushes.

_Avoid_: "PTY digest" (the superseded source mechanism); parsing agent content out of PTY bytes anywhere.


Let me check the workspace test result:


Bash

cat "C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\c570fe63-c413-4a17-bb14-ee75e02a53bd\tasks\b7m8d6rpn.output" | tail -8

Check workspace test result


(Bash completed with no output)


cd C:/Users/decid/Documents/projects/spt-core && f="C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/c570fe63-c413-4a17-bb14-ee75e02a53bd/tasks/b7m8d6rpn.output"; grep -E "error\[|panicked|FAILED|EXIT=|REQ-TERM-4" "$f" | head; echo "---tail---"; tail -3 "$f"

Inspect workspace test output file


---tail---


cd C:/Users/decid/Documents/projects/spt-core && f="C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/c570fe63-c413-4a17-bb14-ee75e02a53bd/tasks/b7m8d6rpn.output"; wc -l < "$f"; tail -15 "$f"

Read workspace test output tail


Test still running (cargo test --workspace is slow). Continue docs meanwhile. Read the ROADMAP M9 region to add the DEFERRED line:


44
45	- **M7 — Subnet & quickstart UX. ✅ delivered (2026-06-06** — planned 2026-06-05, task breakdown [`M7-PLAN.md`](./M7-PLAN.md)). Subnet setup is now a first-class, guided, secure product surface and the public quickstart is honest about personas. Shipped: the **join ceremony's product surface** (always-on daemon pairing responder + guided `spt subnet join` CLI initiator over the rig-proven SPAKE2 wire) · the `spt subnet` noun namespace (`status [NAME] [--nodes]` / `create` / `show-code` / `join`; `spt pair` deleted) · **elevation-gated membership mutations** (ADR-0005 amendment) · **node labels** (hostname-default, gossiped, addressable in `@node` qualifiers, refuse-on-ambiguity) · `spt poll` removed (`ready` + `ready --once`) · **`spt how-to <topic>`** in-binary agent guidance · quickstart rewritten as human-pairs / agents-message prompt blocks · README/docs-site sweep + `quickstart_e2e` + the twohost ladder's product-surface join rung. **Acceptance ran on REAL hardware:** fresh two-machine setup via `subnet create` + the user's own fully-guided join (elevation prompts honored), `status --nodes` labeled+online both sides, cross-machine `SENT(WAN)` both directions (HFENDULEAM ↔ gravity-linux). The run surfaced two publish-blocking field bugs, **both fixed at closeout**: elevated daemon spawn → the de-elevation seam (KNOWN-HAZARDS 5.7) and immortal ghost registry rows → silent-peer eviction (KNOWN-HAZARDS 4.10), plus refusals now rendering copy-paste qualified targets. Harvest (Linux elevation model, NTP-first clock, firewall registration at install, post-join address seeding, trust-prune verb, M8 nounification spec) → `docs/DEFERRED.md`. **Next:** `spt-claude-code` scoping (the v1 acceptance proof, own repo).
46
47	- **✅ Broker/brain process-isolation restoration — delivered (2026-06-11).** Design: [`docs/BROKER-BRAIN-SPLIT-RESTORATION.md`](./docs/BROKER-BRAIN-SPLIT-RESTORATION.md) (verified-with-amendments by `doyle`); decision: **ADR-0018**; build plan: [`RESTORATION-PLAN.md`](./RESTORATION-PLAN.md) (D1–D7). Corrects an unintended regression — the broker/brain split (ADR-0004) was specced + spiked as two *processes*, but the production daemon runs the broker as an in-process *thread* (`daemon.rs:165-170`), so `spt update apply` swaps the binary on disk yet never restarts the running code. Every release to date needed a manual fleet daemon bounce (paid 3× on v0.3.2; `enlyzeam` reproduced the `\r`-corruption for ~a day with the fix on disk). Restore the two-process model: broker = always-up per-machine anchor (seed-lock + liveness + brain supervisor) spawning the brain as a child; update = brain snapshot+self-exit → broker respawns from the swapped binary → re-attach; durable absolute-deadline loop timing; broker cursor-of-record with all-session resume; readiness-gated auto-rollback to last-known-good (two-phase applied record + the no-pre-ready-migration invariant); broker-owned generation custody; the §B-adjudicated direct-call→versioned-IPC-verb surface (net near-free, shellwake stays brain, new-brain × old-broker N-1 compat test). Re-points the regression-masked REQ-DAEMON-2 / REQ-UPD-3 `int` evidence to a productionized SPIKE-01/03 process-level survival E2E; mints `REQ-HAZARD-BROKER-PROCESS-ISOLATION` + `REQ-HAZARD-ROLLBACK-STATE-COMPAT` (KNOWN-HAZARDS 6.7/6.8). **Sequenced before `spt-claude-code`** (operator 2026-06-09): the *last* release needing a manual bounce, so every adapter-era release rolls seamlessly and the adapter lands on the final topology; changes daemon internals only, leaving the M8-frozen CLI/api surface untouched. **Delivery (D1–D7 + field-hardening across v0.4.0–v0.4.2):** the two-process model shipped in **v0.4.0** (public 2026-06-10); the D7 process-level survival E2E (`brain_survive.rs`) + N-1 gate landed and re-pointed the regression-masked REQ-DAEMON-2/UPD-3 `int`. The **v0.4.1** fleet-verify proved Windows seamless (hfenduleam: brain pid rolls, broker held, `exe_hash` flips, no manual bounce) but surfaced a **Linux-only** respawn bug — the resident broker respawned the brain via per-spawn `current_exe()`, which on Linux follows the `apply` rename to `.old-N` and ran OLD bytes under an `applied` record (caught by the bytes-level `exe_hash` assert; `REQ-HAZARD-BRAIN-RESPAWN-PATH` / KNOWN-HAZARDS 6.11). **v0.4.2** (public 2026-06-11) fixed it: respawn from the canonical path captured at broker start + a promotion **bytes-gate** (a wrong-bytes trial auto-rolls-back instead of recording success), with a real in-place-rename `int` E2E (`brain_respawn_rename.rs`) that fails on Linux pre-fix and is CI-gated green on both runners. **Acceptance:** Windows live-seamless proven twice (hfenduleam 0.4.0→0.4.1→0.4.2, broker pid held throughout); Linux proven by the CI-gated in-place-rename E2E on the kitsubito runner; the whole fleet (kitsubito/enlyzeam/hfenduleam) runs v0.4.2 on fixed brokers, after the project's **final two manual bounces** (kitsubito + the enlyzeam v0.3.2 catch-up). The live Linux *field*-seamless rides the next real release (operator decision 2026-06-11: close on the CI-gated E2E + fixed-broker fleet rather than a synthetic debug rollout). KNOWN-HAZARDS 6.7/6.8 closed out. **Next:** `spt-claude-code` — the v1 acceptance proof on the final topology, its own repo.
48
49	- **🔜 NEXT milestone (planned 2026-06-12) — M9: Adapter customization & session surfaces.** Product of the two-day **Gateway grill** (operator + `doyle`; CONTEXT.md mints @ `8dc22ff`/`bd469b9`; ADR-0008 amended; the lecturn device design parked in the sibling `spt-lecturn/` repo for a later build). Shell-free, independently-shippable features: **adapter profiles** (sparse leaf-replace overlays on the parent manifest; shipped + local sources, local surviving adapter updates; composite `<adapter>:<profile>` addressing; tighten-only consent floors; `create-profile`/`delete-profile`) · **adapter strings** (`[strings]` manifest KV, overlay-resolved, data-only; `get-string`/`set-string`) · **keyword hints** (`[hints]` + `spt api hint`, once-per-session, one per message — adapters teach their surface exactly-when-relevant) · **`user-msg` type** (user-authority messages, identity-gated per the KH 7.3/7.5 posture; the daemon re-stamps impostors) · **Gateway endpoint-type acceptance** (the open type system honoring a human-backed downstream type end-to-end) · **`live-role.md`** (durable broad-purpose statement in `tracked/`, injected first at start transitions, `spt endpoint role` the sole writer) · the **session-digest log-source revision** (ADR-0008 amendment: digest = a projection of normalized session logs, never PTY parsing; `api digest-entry`; REQ-TERM-4 re-point — restoration-style honest re-evidence). Task breakdown: [`M9-PLAN.md`](./M9-PLAN.md). First beneficiaries: `spt-claude-code` (profiles/hints/digest are its enablers) and the future lecturn build.
50	- **Queued after M9 (each its own plan+vet cycle):** **M10 — Shell-substrate extensions** — the M5 shell mechanism already shipped; this adds the **drive channel** (owner→shell ephemeral latest-wins continuous control), the **shell tunnel** (reliable-ordered link-bound stream for opaque protocols, USB/IP-class), **per-capability approval gates** (class-keyed grants), and the **Gateway-as-owner code audit**. **M11 — Remote attach (`spt rc`)** over the already-built R-TERM substrate (controller/viewer split with controller-exclusive resize, `kick`/`--take` handoff, consent-gated input; the xterm.js bridge client = the GUI terminal pane and the future Android-gateway shape). `spt-claude-code` scoping remains on deck alongside.
51
52	> _Roadmap note: this milestone list is behind reality — M8 (CLI nounification + acceptance) and the v0.3.x release line shipped after M7 but are tracked in commits/CHANGELOG, not itemized here. A full roadmap reconciliation is a separate docs pass._
53
54	## De-risk spikes (do early — these can invalidate the architecture)
55
56	1. **Broker/brain no-terminate handoff** — prove a PTY child + open socket survive a daemon-*logic* restart (FD-passing or stable-broker shim). The hardest invariant in the design (ADR-0004).
57	2. **Iroh + mDNS smoke test** — two nodes, separate NATs, one message end-to-end; measure direct-vs-relay + binary size (research brief §5). Validates the whole networking premise.
58	3. **ConPTY + `portable-pty`** — host and drive a real session in a broker PTY on Windows + Linux.
59	4. **Manifest-driven lifecycle** — a manifest drives one real Claude Code session through spawn → bind → commune → signoff, proving harness-independence works in practice.
60
61	## Immediate next steps

- **M7 — Subnet & quickstart UX. ✅ delivered (2026-06-06** — planned 2026-06-05, task breakdown [`M7-PLAN.md`](./M7-PLAN.md)). Subnet setup is now a first-class, guided, secure product surface and the public quickstart is honest about personas. Shipped: the **join ceremony's product surface** (always-on daemon pairing responder + guided `spt subnet join` CLI initiator over the rig-proven SPAKE2 wire) · the `spt subnet` noun namespace (`status [NAME] [--nodes]` / `create` / `show-code` / `join`; `spt pair` deleted) · **elevation-gated membership mutations** (ADR-0005 amendment) · **node labels** (hostname-default, gossiped, addressable in `@node` qualifiers, refuse-on-ambiguity) · `spt poll` removed (`ready` + `ready --once`) · **`spt how-to <topic>`** in-binary agent guidance · quickstart rewritten as human-pairs / agents-message prompt blocks · README/docs-site sweep + `quickstart_e2e` + the twohost ladder's product-surface join rung. **Acceptance ran on REAL hardware:** fresh two-machine setup via `subnet create` + the user's own fully-guided join (elevation prompts honored), `status --nodes` labeled+online both sides, cross-machine `SENT(WAN)` both directions (HFENDULEAM ↔ gravity-linux). The run surfaced two publish-blocking field bugs, **both fixed at closeout**: elevated daemon spawn → the de-elevation seam (KNOWN-HAZARDS 5.7) and immortal ghost registry rows → silent-peer eviction (KNOWN-HAZARDS 4.10), plus refusals now rendering copy-paste qualified targets. Harvest (Linux elevation model, NTP-first clock, firewall registration at install, post-join address seeding, trust-prune verb, M8 nounification spec) → `docs/DEFERRED.md`. **Next:** `spt-claude-code` scoping (the v1 acceptance proof, own repo).

- **✅ Broker/brain process-isolation restoration — delivered (2026-06-11).** Design: [`docs/BROKER-BRAIN-SPLIT-RESTORATION.md`](./docs/BROKER-BRAIN-SPLIT-RESTORATION.md) (verified-with-amendments by `doyle`); decision: **ADR-0018**; build plan: [`RESTORATION-PLAN.md`](./RESTORATION-PLAN.md) (D1–D7). Corrects an unintended regression — the broker/brain split (ADR-0004) was specced + spiked as two *processes*, but the production daemon runs the broker as an in-process *thread* (`daemon.rs:165-170`), so `spt update apply` swaps the binary on disk yet never restarts the running code. Every release to date needed a manual fleet daemon bounce (paid 3× on v0.3.2; `enlyzeam` reproduced the `\r`-corruption for ~a day with the fix on disk). Restore the two-process model: broker = always-up per-machine anchor (seed-lock + liveness + brain supervisor) spawning the brain as a child; update = brain snapshot+self-exit → broker respawns from the swapped binary → re-attach; durable absolute-deadline loop timing; broker cursor-of-record with all-session resume; readiness-gated auto-rollback to last-known-good (two-phase applied record + the no-pre-ready-migration invariant); broker-owned generation custody; the §B-adjudicated direct-call→versioned-IPC-verb surface (net near-free, shellwake stays brain, new-brain × old-broker N-1 compat test). Re-points the regression-masked REQ-DAEMON-2 / REQ-UPD-3 `int` evidence to a productionized SPIKE-01/03 process-level survival E2E; mints `REQ-HAZARD-BROKER-PROCESS-ISOLATION` + `REQ-HAZARD-ROLLBACK-STATE-COMPAT` (KNOWN-HAZARDS 6.7/6.8). **Sequenced before `spt-claude-code`** (operator 2026-06-09): the *last* release needing a manual bounce, so every adapter-era release rolls seamlessly and the adapter lands on the final topology; changes daemon internals only, leaving the M8-frozen CLI/api surface untouched. **Delivery (D1–D7 + field-hardening across v0.4.0–v0.4.2):** the two-process model shipped in **v0.4.0** (public 2026-06-10); the D7 process-level survival E2E (`brain_survive.rs`) + N-1 gate landed and re-pointed the regression-masked REQ-DAEMON-2/UPD-3 `int`. The **v0.4.1** fleet-verify proved Windows seamless (hfenduleam: brain pid rolls, broker held, `exe_hash` flips, no manual bounce) but surfaced a **Linux-only** respawn bug — the resident broker respawned the brain via per-spawn `current_exe()`, which on Linux follows the `apply` rename to `.old-N` and ran OLD bytes under an `applied` record (caught by the bytes-level `exe_hash` assert; `REQ-HAZARD-BRAIN-RESPAWN-PATH` / KNOWN-HAZARDS 6.11). **v0.4.2** (public 2026-06-11) fixed it: respawn from the canonical path captured at broker start + a promotion **bytes-gate** (a wrong-bytes trial auto-rolls-back instead of recording success), with a real in-place-rename `int` E2E (`brain_respawn_rename.rs`) that fails on Linux pre-fix and is CI-gated green on both runners. **Acceptance:** Windows live-seamless proven twice (hfenduleam 0.4.0→0.4.1→0.4.2, broker pid held throughout); Linux proven by the CI-gated in-place-rename E2E on the kitsubito runner; the whole fleet (kitsubito/enlyzeam/hfenduleam) runs v0.4.2 on fixed brokers, after the project's **final two manual bounces** (kitsubito + the enlyzeam v0.3.2 catch-up). The live Linux *field*-seamless rides the next real release (operator decision 2026-06-11: close on the CI-gated E2E + fixed-broker fleet rather than a synthetic debug rollout). KNOWN-HAZARDS 6.7/6.8 closed out. **Next:** `spt-claude-code` — the v1 acceptance proof on the final topology, its own repo.

- **🔜 NEXT milestone (planned 2026-06-12) — M9: Adapter customization & session surfaces.** Product of the two-day **Gateway grill** (operator + `doyle`; CONTEXT.md mints @ `8dc22ff`/`bd469b9`; ADR-0008 amended; the lecturn device design parked in the sibling `spt-lecturn/` repo for a later build). Shell-free, independently-shippable features: **adapter profiles** (sparse leaf-replace overlays on the parent manifest; shipped + local sources, local surviving adapter updates; composite `<adapter>:<profile>` addressing; tighten-only consent floors; `create-profile`/`delete-profile`) · **adapter strings** (`[strings]` manifest KV, overlay-resolved, data-only; `get-string`/`set-string`) · **keyword hints** (`[hints]` + `spt api hint`, once-per-session, one per message — adapters teach their surface exactly-when-relevant) · **`user-msg` type** (user-authority messages, identity-gated per the KH 7.3/7.5 posture; the daemon re-stamps impostors) · **Gateway endpoint-type acceptance** (the open type system honoring a human-backed downstream type end-to-end) · **`live-role.md`** (durable broad-purpose statement in `tracked/`, injected first at start transitions, `spt endpoint role` the sole writer) · the **session-digest log-source revision** (ADR-0008 amendment: digest = a projection of normalized session logs, never PTY parsing; `api digest-entry`; REQ-TERM-4 re-point — restoration-style honest re-evidence). Task breakdown: [`M9-PLAN.md`](./M9-PLAN.md). First beneficiaries: `spt-claude-code` (profiles/hints/digest are its enablers) and the future lecturn build.
- **Queued after M9 (each its own plan+vet cycle):** **M10 — Shell-substrate extensions** — the M5 shell mechanism already shipped; this adds the **drive channel** (owner→shell ephemeral latest-wins continuous control), the **shell tunnel** (reliable-ordered link-bound stream for opaque protocols, USB/IP-class), **per-capability approval gates** (class-keyed grants), and the **Gateway-as-owner code audit**. **M11 — Remote attach (`spt rc`)** over the already-built R-TERM substrate (controller/viewer split with controller-exclusive resize, `kick`/`--take` handoff, consent-gated input; the xterm.js bridge client = the GUI terminal pane and the future Android-gateway shape). `spt-claude-code` scoping remains on deck alongside.

> _Roadmap note: this milestone list is behind reality — M8 (CLI nounification + acceptance) and the v0.3.x release line shipped after M7 but are tracked in commits/CHANGELOG, not itemized here. A full roadmap reconciliation is a separate docs pass._

## De-risk spikes (do early — these can invalidate the architecture)

1. **Broker/brain no-terminate handoff** — prove a PTY child + open socket survive a daemon-*logic* restart (FD-passing or stable-broker shim). The hardest invariant in the design (ADR-0004).
2. **Iroh + mDNS smoke test** — two nodes, separate NATs, one message end-to-end; measure direct-vs-relay + binary size (research brief §5). Validates the whole networking premise.
3. **ConPTY + `portable-pty`** — host and drive a real session in a broker PTY on Windows + Linux.
4. **Manifest-driven lifecycle** — a manifest drives one real Claude Code session through spawn → bind → commune → signoff, proving harness-independence works in practice.

## Immediate next steps


There's a `docs/DEFERRED.md` (the canonical home for deferred items). Add the deferred autonomous-freshness there. Read its tail:


cd C:/Users/decid/Documents/projects/spt-core && wc -l docs/DEFERRED.md 2>/dev/null && tail -20 docs/DEFERRED.md 2>/dev/null || echo "NO DEFERRED.md"

Check DEFERRED.md


43 docs/DEFERRED.md
| ~~**Non-admin daemon spawn (both OSes)**~~ **SHIPPED 2026-06-06** (KNOWN-HAZARDS 5.7, REQ-HAZARD-ELEVATED-DAEMON-SPAWN): de-elevated spawn at `spawn_detached` (Windows: UAC linked token via `CreateProcessWithTokenW`; Linux: drop child to SUDO_UID/GID) + `Daemon::run` entry guard + the unix `spt` main-entry sudo drop (whole process re-anchored to the invoker's universe, elevation kept PROVEN for the gated commands). Remaining Linux follow-up = the install-symlink + default-account election row below (M8) | M7 acceptance run (2026-06-06) | The membership-implies-reachability activation made elevated commands daemon-spawners — correct goal, wrong token | DONE (real elevated-spawn verification on both OSes rides the next acceptance pass) |
| ~~**Ghost registry row eviction**~~ **SHIPPED 2026-06-06** (KNOWN-HAZARDS 4.10, REQ-HAZARD-REGISTRY-GHOST-ROWS): silent-peer decay — rows from a node unheard for `registry_evict_after_ms` (default 300s ≈ 10 pump cadences) evict on the registry pump tick, snapshots rewritten; own rows never decay. STALE trust rows for a dead identity still need a manual prune verb (below) | M7 acceptance run (2026-06-06) | Needs a decay/eviction policy (e.g. drop rows unrefreshed for N pump cadences; trust-row pruning verb) | DONE (fleet cleanup: 09ef831e ghost rows on both BIGNET nodes decay once the new build deploys) |
| Trust-row prune verb (`spt subnet prune <node>` or similar): registry GHOST rows now decay (4.10), but the dead identity's TRUST rows persist by design (trust is a user decision) — they cost dead dials every pump tick and clutter status views. BIGNET carries two such rows (09ef831e, both sides) | Ghost-eviction fix (2026-06-06) | Trust mutation = security surface; wants the elevation gate + the M8 noun shapes | M8 CLI nounification |
| Elevated spt-hosted endpoints — a consented elevation satellite: the broker lives inside the always-unelevated daemon (KH 5.7), so spt-hosted children are unelevated by construction; there is NO silent unelevated→elevated path on either OS (that is UAC/sudo's whole point). An agent needing admin powers is harness-hosted-elevated today (works: TCP listeners + talk-down pipes). The spt-hosted form needs a per-grant CONSENTED elevation: UAC prompt / polkit spawns a small elevated PTY-host satellite holding only the elevated children, linked back to the unelevated daemon — rides the REQ-CONSENT-2 escalation framework (allow-once/always) + the reserved-but-refusing capability pattern | Elevated-endpoint design grill (user 2026-06-06) | Real elevation-granting UX + a second privileged process = its own deliverable; the consent framework it rides already ships | First spt-hosted agent that needs admin powers (or the instantiate-anywhere milestone) |
| ~~AMBIGUOUS render~~ **SHIPPED 2026-06-06** (rode the ghost-eviction fix): resolution refusals render valid copy-paste targets at the wansend boundary (`render_refusal`) — labels preferred, key-prefix on label collision, `subnet:id` for cross-subnet | M7 acceptance run (user 2026-06-06) | Pure render fix at the wansend/CLI boundary; carries into the M8 nounification sweep | DONE |
| Post-join address seeding: the pairing ceremony holds a live, authenticated connection to the peer, but the peer pumps then re-find each other from scratch via id-only discovery (fresh node ids must propagate to n0 DNS first) — observed live as ~1 min to first `status --nodes` convergence after a join. Seed the pump's address cache from the ceremony connection (both sides) for instant first sync | M7 acceptance run (2026-06-06) | Convergence is correct, just slow on first contact; needs an addr-cache seam the pumps consult before the resolver | M8 polish |
| Clock source priority for TOTP/rendezvous: query a well-known world clock (NTP) FIRST, fall back to local time — machines with skewed local clocks currently derive wrong steps/rendezvous tokens silently | M7 acceptance grill (user 2026-06-06) | Needs an NTP client dep + offset caching + offline fallback semantics; ±1 window covers small skew today | First field report of clock-skew join failures, or M8 polish |
| CLI nounification (M8 candidate; ratified shape from the 2026-06-06 grill): `spt endpoint` namespace absorbs fork/suspend/wake/shutdown/rename/stop/digest + `access` (per-endpoint store — NOT subnet-scoped) + `description` (ex-`resources` blurb; bare = show, `set` = author); `spt endpoint list [--local\|--subnet <name>]` = merged listing, default all-subnets grouped by subnet, SELF endpoint pinned distinctly at top (bare `spt endpoint` = this list); agent HOT PATH stays flat (`send`/`ring`/`ready`/`whoami`/`how-to`); `notify` → `spt subnet notify`; `notif` stays top-level (user-recall, cross-subnet); `spt daemon stop\|status` noun (hidden `daemon` → `daemon run`); agent-endpoint `shutdown` keeps its name | M7 grill (2026-06-06) | Second surface-breaking reorg mid-acceptance; adapter window still open immediately after M7 closes | M8 planning — mint REQs first (rule 3) |
| ~~Node label for an endpoint-less node (REQ-SUBNET-3 gap)~~ **SHIPPED 2026-06-08** (option 2 — node-level presence datum): a peer with **zero endpoints** rendered as a bare key-prefix in `status --nodes` because `node_label` only rode endpoint `Instance` rows. FIX: a node-LEVEL carrier — `SubnetRegistry.node_labels` (node→label) + a `NodeLabelUpdate` feed record riding the existing registry replication stream as an untagged `RegistryFeedRecord` variant (instance bytes unchanged → mixed-version fleet safe, old peers skip the new lines). Merged under the same epoch lease, evicted with the node, gated identically to instance feeds. The pump emits one label record per served subnet regardless of endpoint count; `node_status_rows` reads it to NAME an endpoint-less peer without counting it as an endpoint (`[0/0]` holds, no liveness effect). Chose option 2 over option 1 (trust-pin label at pairing) to avoid touching the SPAKE2 ceremony + un-dormanting warn-on-change (which couples with the REQ-SUBNET-7 machine_id re-pair overwrite) — option 2 is also live-updating on hostname change | M8 criterion-5 acceptance run (HFENDULEAM↔kitsubito, 2026-06-07; HF at `[0/0]` showed bare uuid to kitsubito) | Cosmetic; deferred per user decision 2026-06-07, fixed 2026-06-08 (user picked option 2) | DONE (real-hardware verify rides the next acceptance/deploy pass) |
| **Instant + never-seen peer hostname via pairing-time capture (REQ-SUBNET-3 follow-up; the deferred "option 1")**: the shipped node-level label (option 2) is GOSSIP-ONLY, so a peer's name is learned only from a pump round while it is alive. Two residual gaps remain after the eviction fix below: (a) ~1 gossip cadence to appear after a join (the joiner trust-pins by pubkey with `label=None`; the hostname arrives next pump round — enlyzeam↔kitsubito converged to `ENLYZEAM` after a cadence, fine but not instant); (b) a peer that stops WITHIN a cadence of pairing is never named (its label never gossiped). FIX: capture the peer hostname **at pairing → the persistent trust-pin label**. Needs (1) BIDIRECTIONAL pairing intro — the joiner→responder `NodeIntro` (label+machine_id) already exists; add responder→joiner so BOTH sides capture each other's hostname; (2) wire `trust.record`'s `label` = peer hostname at both pin sites (`pairing/wire.rs` ~324 responder, ~435 initiator, both currently `None`); (3) handle the warn-on-change coupling with the REQ-SUBNET-7 machine_id re-pair overwrite (a legit machine reinstall = same machine_id, new key → repin, not a silent fail). **NOTE — the OFFLINE-loses-name half was a real bug, FIXED 2026-06-08** (not this deferred row): a *learned* node_label was being silence-decayed by `evict_nodes`, so an offline trusted member lost its name; `evict_nodes` now keeps labels (silence ≠ forget) and only an explicit `spt subnet prune` drops them (`evict_node_labels`). This row is the remaining INSTANT + NEVER-SEEN half | enlyzeam/kitsubito SPT_DEV diagnosis (user 2026-06-08) | The instant/never-seen half of REQ-SUBNET-3; pairs with the "post-join address seeding" row (same ceremony-connection seam, seed label + address both sides) — touches SPAKE2, so its own focused milestone | Next session — mint REQ for the trust-pin-label capture + bidirectional intro |
| ~~Subnet-scoped liveness probe (REQ-SUBNET-5 detach gap)~~ **SHIPPED 2026-06-08** (REQ-SUBNET-5 int stage): `subnet status --nodes` showed a DETACHED peer as **online** because liveness fell to `Probe` → a raw transport dial by node-pubkey, which succeeded on the peer's still-bound daemon endpoint (one ALPN `spt-core/net/0` serves all subnets, so the dial was subnet-blind). FIX: a new `ServeProbeRecord` (spt-net `serveprobe`) + daemon `serveprobe` handler/requester ask the peer "serving subnet X?", answered from its own `AttachmentStore::is_detached` ∧ membership (the same signal the pump/responder gate on); the dispatcher classifies it by the `serve_probe` first-line field; `wansend::probe_node_serving` dials (proves reachable) THEN asks, so a detached-but-alive peer reads offline within a cadence. Probe LATENCY had shipped separately 2026-06-07 (`run_bounded` 2.5s + "Checking remote nodes…" notice). int evidence: `dispatcher_serves_a_subnet_serve_probe` (loopback E2E) | M8 criterion-5 acceptance run (HFENDULEAM↔kitsubito, 2026-06-07) | Real liveness-model fix on accepted M7 hybrid-liveness; deferred to keep acceptance moving (user decision 2026-06-07) | DONE (criterion-5 detach-flip now GREEN; real-hardware re-verify rides the next acceptance/deploy pass) |
| **Subnet full-mesh visibility (PLANNED MILESTONE — `SUBNET-MESH-PLAN.md`)**: non-directly-paired members never see each other — a subnet is currently the PAIRING GRAPH, not a mesh (A↔B, C↔B; B offline → A and C invisible; even with B online, B doesn't relay). Root: pairwise TOFU trust (`trust.rs`) + own-rows-only gossip / no transitive relay (`peerloop.rs`, KH 4.10/7.5) + no member roster in the pairing seed transfer. FIX = member-AUTHENTICATED subnet data (seed-MAC and/or per-node signatures) unlocking transitive trust (seed-holder = member = trusted) + transitive gossip relay + roster propagation; MUST re-solve eviction/epoch-lease under relay (supersedes KH 4.10's "no transitive gossip" safety assumption). ADR-level; mint REQ-MESH-* first. Folds in the deferred pairing-time hostname capture + post-join address seeding (same roster/ceremony seam) | A→B→C invisibility test (user 2026-06-08) — confirmed two-gap design limitation, not a code defect | **DESIGN RATIFIED 2026-06-08** (grill): symmetric **seed-proof membership** + **roster-only relay** (rows stay own-authored, no third-party relay → KH 4.10/7.5 PRESERVED, not superseded). Full design = **ADR-0017**; reqs = **REQ-MESH-1..6** (inactive); plan = `SUBNET-MESH-PLAN.md`; glossary = CONTEXT §Pairing & trust. Folds in the pairing-time hostname-capture + post-join address-seeding rows (roster fields). Next: plan-phase over REQ-MESH-* | Next milestone — design done 2026-06-08; build pending plan-phase |
| `xtask debug-converge` — automated convergence watcher for debug rollout (the REQ-UPD-6 first-slice item ADR-0016 names): watch every expected debug-pinned reachable lab node until all report the target version applied, else a per-node timeout table (Offline / StagedAwaitingConsent / BlockedByBrokerResources / Rejected{reason}). Activates REQ-UPD-6's `int` stage. Full build plan: `docs/DEBUG-CONVERGE-PLAN.md` | REQ-UPD-6 first slice (2026-06-06) | The first slice ships manual "Apply and observe" (`DEBUG-ROLLOUT.md`); the watcher's one genuinely new surface is a node status-query wire leg (no-fetch `{channel, applied_version, last_outcome}` over the existing update handshake) — assembly otherwise | First multi-node debug rollout where hand-walking nodes is the bottleneck, or M8 update polish |
| **Origin-source update bootstrap (`spt update fetch` — pull the latest signed release from GitHub into the existing verify→stage→apply pipeline)**: today update DISCOVERY is **peer-only** (REQ-UPD-1: a daemon pulls offers from roster peers via `request_update`; `propagate.rs`/`peerloop.rs`). There is NO origin fetch, so the **first node in a fleet — or any isolated/solo node — cannot get a new release without a peer that already staged it** (the fleet converges by gossip, but only *after* a maintainer seeds the first node via the release-publish path). The installer DOES fetch from GitHub (`github.com/SaberMage/spt-releases/releases/latest/download/<asset>` + `SHA256SUMS`, `installer/install.sh:57-73`) but only at install time, outside the daemon's signed-release gate, and never re-runs. FIX = a new **origin-fetch leg** that downloads the per-platform artifact + its `<asset>.release.json` (the `SignedRelease` metadata) from the release origin and feeds them through the **SAME** `plan_verified` gate (`verify_metadata` two-key signature/channel/expiry/rollback + `verify_artifact` SHA-256, `update.rs:157`/`release.rs`), then `cache.stage()` — after which the EXISTING consent-notif / `spt update apply` flow is unchanged. So it reuses ~all the machinery; it only adds an HTTPS GET from the origin as an alternate SOURCE to the P2P `request_update` pull. Surface: `spt update fetch [--channel <ch>] [--tag vX.Y.Z]` (default: latest on the node's pinned channel); optionally a daemon-config `origin_check` cadence so a lone node self-bootstraps alongside P2P, then re-propagates to peers normally. **Key design note:** the daemon has **zero HTTP surface today** — all transport is iroh QUIC; `reqwest` is only a transitive dep (via iroh), not reachable. This leg needs a deliberate HTTPS-client decision (add `reqwest`/a minimal client as a direct dep, vs shell out to `curl`/`Invoke-WebRequest` like the installer — fragile). Honor the channel pin (`release-keys.json`) + the monotonic rollback floor; configurable repo (mirror `SPT_INSTALL_REPO`). The two-key signed-release anchor keeps the GitHub transport **untrusted-but-verified** (strictly stronger than the installer's HTTPS+SHA256SUMS-only first fetch). Mint **REQ-UPD-7** (origin-source fetch) first. | Update subsystem v1 (ADR-0016, REQ-UPD family) — P2P-pull only by design | v1 update flow is peer-propagation by design (seed one node, the fleet converges by gossip); origin-fetch is additive and the maintainer seed path covered bootstrap so far. No HTTP client is wired into the daemon yet | First isolated / first-in-fleet node that must update with no peer to pull from; or making `spt update` self-sufficient for solo / single-node users (user flagged the gap 2026-06-08) |
| **Test broker socket-bind hardening (CI-flake removal)**: `applyhost` tests' `served_broker` helper does `Broker::bind(name).expect("bind broker")` (`crates/spt-daemon/src/applyhost.rs:293`) where `name` = `spt-daemon-d7b-{pid}-{seq}.sock`. Flakes intermittently on the **kitsubito** self-hosted Linux runner (likely AF_UNIX path collision / stale socket file under the runner's reused workspace — Windows named pipes don't hit it). Cost the v0.3.2 release a full red CI run + a `--failed` rerun (2026-06-09); same code passed clean on rerun → flake, not a logic bug. FIX = make the test bind deterministic: unlink-before-bind, OR abstract-namespace socket (Linux `@`-prefixed, no filesystem entry), OR per-test `TempDir` isolation so each bind has a guaranteed-fresh path. Applies to every `served_broker`/socket-name test helper, not just applyhost | Triaged during v0.3.2 release (2026-06-09) — flake, shipped over it | Non-blocking: a real flake that taxes CI (one red cycle + rerun per hit) but never gates a correct build; hardening is test-infra work, not product. Held to keep the v0.3.2 release moving | **Restoration D1/D7** (`RESTORATION-PLAN.md`) — D1/D7 add real process-spawn tests on the kitsubito runner; fix the bind determinism there rather than fighting flakes through the milestone (M8 is complete) |
| **`spt update fetch` single→set shape-upgrade at same version (REQ-UPD-8 transitional trap)**: `cmd_update_fetch` sets the rollback floor = `staged_version` (the monotonic counter), and the gate is a plain `candidate > floor`. A node that fetched+staged version N as a platform **single** (pre-0.3.2 fetch behavior) can never re-fetch version N as a multi-platform **set** — same counter → `Rollback{current:N, candidate:N}`. Bites the **seed-node bootstrap**: the first node to origin-`fetch` a release applies the single for its OWN platform, then can't re-stage the same version as a set → has no Windows/other-platform artifact to SERVE peers over P2P. Surfaced live in the v0.3.2 cross-platform rig verify (2026-06-09): kitsubito staged+applied the v6 single, then its v6-set re-fetch was refused; unblock was a manual `releases/` cache-clear before re-fetch. Counter vs semver is working as designed (counter = trust spine, semver = cosmetic) — the gap is purely shape-blind: the floor compares version only, ignoring single-vs-set. FIX options: (a) allow a same-version re-fetch when staged shape is `single` and an available `set` is strictly-richer (shape-upgrade allowance), or (b) seed-server flow stages the SET before applying its own single, or (c) a `--force`/`--restage` flag. NOTE — only bites the one migration hop onto 0.3.2; once a node is on 0.3.2 with a fresh cache, future releases are vN+1 and stage clean. Mint a REQ before fixing | v0.3.2 cross-platform rig verify (2026-06-09; user-flagged via session watch) | Transitional-only: the workaround (cache-clear before staging the set) is reliable and was used to complete the v0.3.2 fleet roll; a code fix only saves the manual clear on same-version shape upgrades, which won't recur after the 0.3.2 hop | A future release that again changes staged-artifact SHAPE at a same/non-incrementing counter, or making `spt update fetch` self-sufficient for solo seed-nodes (pairs with the origin-source bootstrap row above) |
| **On-disk `current_exe` reconcile after auto-rollback (the D6-2 reboot residual)**: readiness-gated auto-rollback (ADR-0018 Q7, restoration D6-2) is **record-driven selection** — after a rollback the broker's supervisor keeps spawning the last-known-good `.old-N` binary, chosen from the durable `RolledBack` record, so the rescue survives a reboot for free **with no file rename at the failure instant** (the brittle path the design rejected). But the bytes on disk at `current_exe` are STILL the quarantined bad version. The supervisor rescues the **brain** (it never execs `current_exe` while a `RolledBack` record stands), but an OS-service / autostart / `ensure_running` relaunch execs `current_exe` **as the broker** — and **if the bad binary panics before broker bind, the node is down until manual intervention** (record-driven selection cannot save a binary that won't boot). FIX = a calm-path reconcile: on the next clean `apply` (or a broker self-reconcile once stable), rename the good bytes over `current_exe` so the on-disk seat matches the running selection. Deliberately NOT a failure-time rename | Restoration D6-2 (ADR-0018 Q7, open-call-3, 2026-06-10) — record-driven selection rescues the brain; the on-disk seat reconcile defers | The reboot-survives-via-record path is correct NOW for the common case (broker boots, supervisor re-selects the good binary); the residual only bites when the broker process itself re-execs the quarantined `current_exe` and that binary won't even bind. Failure-time rename is the brittle path the design rejected; the calm-path reconcile needs the next clean-apply seam | First field rollback where the broker (not just the brain) restarts onto the quarantined on-disk bytes — or the D7 process-survival proof exercising a broker re-exec after rollback |
| **Quarantine operator override (`spt update apply --force` — the note-i escape hatch)**: D6-2's quarantine guard refuses re-applying a version that auto-rolled-back on this node (the `RolledBack` record is the marker; clears naturally when N+1 stages). But a version whose failure was **environmental** (a transient boot condition, not bad bytes) leaves the node **permanently allergic** to a genuinely-good version with no escape but staging a newer one. FIX = an explicit operator override (`spt update apply --force`, or a `spt update unquarantine <version>`) that clears the `RolledBack` marker and re-opens the apply for that version — a deliberate, operator-gated act, never automatic | Restoration D6-2 (ADR-0018 Q7, note-i, 2026-06-10) — quarantine ships; the override defers | The natural N+1 clear covers the common "the next release fixes it" path; the override only matters when the SAME version must be retried after an environmental fix. Operator-gated re-apply is a small CLI + record-clear, deferrable to the alarm/adapter era | First node permanently allergic to a genuinely-good version (environmental rollback), or the M8 update-CLI polish |
| **Broker-side bound on brain-waiting QUIC ops (the pump-IPC-deadline B-half)**: the brain-side deadline (REQ-HAZARD-PUMP-IPC-DEADLINE, KNOWN-HAZARDS 7.6) stops the single-threaded pump wedging when a peer black-holes, by bounding the pump's brain-IPC reads and escalating a TimedOut to a supervised restart. But the ROOT cause is broker-side: the broker's `net_open_stream` / `net_stream_send` / `net_dial` handlers make the brain wait on a QUIC operation to a dead peer with no bound of their own, so the brain only escapes via its own deadline (a 30s stall + a full pump restart each time). FIX = the broker must never make a brain wait unbounded on a QUIC op — bound those handlers broker-side (a QUIC-level timeout that returns an Error event promptly), so a black-holed peer fails the single `peer_step` cleanly (ordinary per-peer abort + redial) without poisoning the client or forcing a pump restart. The broker is RESIDENT by design (CONTEXT §"broker update frequency: rare"), so this cannot reach the deployed fleet without a broker-update path — hence deferred to the next planned broker-update batch rather than riding the brain-only v0.4.1 roll the A-half ships on. **Added motivation (Windows, KH 7.6):** the A-half's reader-thread carrier leaks ONE `pump-ipc-reader` thread per wedge event — on a supervised restart the old reader is left blocked in `read_frame` holding its `RecvHalf`, and interprocess split halves share one OS handle (closes only when BOTH drop), so on a named pipe the broker may never signal the disconnect and the old reader can park indefinitely (especially while the broker itself is wedged on the dead-peer QUIC await — the very thing B fixes). Bounded (one thread per actual wedge, not per restart round — the post-restart conn cache is fresh, so the dead peer is re-dialed, not re-wedged on a cached dead conn), but B is the real cure: bounding the broker's QUIC await makes the brain's read return promptly, so the reader thread is never left parked | Pump-stall diagnosis 2026-06-11 (doyle ruling A-now / B-deferred) | The brain-side A-half fixes the fleet NOW via the brain-only update machinery (v0.4.1 candidate) and makes the wedge self-heal in 5s instead of 2.2h; B is the deeper correctness fix but only reachable through the rare broker-update path. Mint a REQ (broker-side QUIC-op bound) before building | Next planned broker-update batch (folds in with any other resident-broker change) |
| **Durable in-daemon alarm scheduler (one-shot deadline machinery — the Q4/V3 deferral)**: the broker/brain restoration (ADR-0018 Q4) fixes the *rule* for one-shot scheduled events — persist the absolute `target-time` at creation; every brain start (update *or* crash) reads it and fires-if-due; **never reset on crash** (a user's "remind me at 3pm" is a commitment that must outlive any restart). But it deliberately does **not** build the machinery: the spt-core daemon has **no one-shot consumer today** — `alarm` exists only as an event *shape* (`spt-proto`) + relay handling (`psyrelay.rs:93`) + a test fixture; the actual "wait until target, fire the TIMED PULSE" timer lives in the **legacy owl listener in-memory** (BROKER-BRAIN-SPLIT-RESTORATION §7). Building the one-shot deadline machinery in the restoration milestone would ship **untested dead code** (violates activate-don't-pre-fail). FIX = port alarms into the daemon as a durable scheduler that rides the Q4 one-shot rule (persist target-time at creation, fire-if-due on every brain start, survive both crash and update). The periodic-loop half of Q4 (anchor+interval derive) DOES ship in restoration D5; only the one-shot machinery defers here. | Restoration milestone D5 (ADR-0018 Q4/V3, 2026-06-09) — rule fixed, machinery deferred | The daemon has no one-shot consumer to exercise it; the periodic-deadline mechanism (the phase-significant pulse loop) ships in D5 and proves out the disk-anchored-deadline pattern the alarm port will reuse | The alarm port — porting the legacy in-memory alarm timer into the daemon as a durable scheduler (first real one-shot consumer); reuses the D5 disk-deadline pattern + the Q4 one-shot rule |


<task-notification>
<task-id>b7m8d6rpn</task-id>
<tool-use-id>toolu_01BwrM7r8srS8iZtJvFMo8hP</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\c570fe63-c413-4a17-bb14-ee75e02a53bd\tasks\b7m8d6rpn.output</output-file>
<status>completed</status>
<summary>Background command "Workspace tests + traceable REQ-TERM-4" completed (exit code 0)</summary>
</task-notification>


DEFERRED.md is a table. Append the deferred autonomous-freshness + Option-C re-home row:


cd C:/Users/decid/Documents/projects/spt-core && cat >> docs/DEFERRED.md <<'ROW'
| **Autonomous session-digest freshness + Option-C re-home (M9 ADR-0008 amendment deferral)**: M9 re-founded the digest as an ON-DEMAND projection of normalized session logs (`spt-term::projection`; `spt-daemon::digest::project_endpoint_digest`) — snapshot-pull and the structured-delta-stream CONTRACT ship, deltas driven by pulls / `api digest-entry` pushes. Two pieces deferred, both because they have **no consumer yet** (Shell=M10, GUI/frontend pane, Gateway agent-window — none exist): (a) the **autonomous file-watch→publish freshness nudge** (a watcher that re-projects + publishes when the log grows on its own, vs today's pull/push-driven publishes) — CONTEXT.md "session digest" anticipates this as "an incremental normalize story (design-phase concern)"; (b) the **Option-C opt-in persistence re-home** (the byte-feed engine drove per-turn persistence off `pty_digest.persist`, now deleted; re-homing it onto a `[history]`/digest config seam now = a manifest surface with nothing reading it). Building either now = a speculative realtime subsystem with no reader (the M9-PLAN §9 "no new realtime subsystem" watch-item). | M9 Wave 3 / G3 (doyle ruling 2026-06-13) — snapshot-pull is the G3 bar, delta-stream contract ships | Both ride a live consumer: the digest works topology-independently TODAY (pull projects real content; a subscriber follows and gets push-driven deltas). Autonomous freshness only matters when a frontend pane streams it continuously; Option-C only matters when something reads the coarse activity log. Deferring honors CONTEXT.md's anticipated staging, not a scope cut | The **consuming-frontend milestone** that streams the digest live (Shell M10 drive/sensory pane, the GUI "latest session output" pane, or the Gateway agent-window) — wire the file-watch nudge + re-home Option-C onto a digest config seam then, with a real reader to exercise both |
ROW
echo "appended"; tail -1 docs/DEFERRED.md | head -c 120

Append deferred digest-freshness row


appended
| **Autonomous session-digest freshness + Option-C re-home (M9 ADR-0008 amendment deferral)**: M9 re-founded the digest


Prompt:
<task-notification>
<task-id>b7m8d6rpn</task-id>
<tool-use-id>toolu_01BwrM7r8srS8iZtJvFMo8hP</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\c570fe63-c413-4a17-bb14-ee75e02a53bd\tasks\b7m8d6rpn.output</output-file>
<status>completed</status>
<summary>Background command "Workspace tests + traceable REQ-TERM-4" completed (exit code 0)</summary>
</task-notification>


Workspace test completed exit 0. Confirm no failures + traceable result:


Read full workspace test output


=== traceable ===
       Peer address seeding, both cold starts: durable peer-addrs.json (identity dir) maps peer pubkey → last-known dialable address; the pump's resolver consults it FIRST with id-only discovery fallback on miss or dial failure (a stale addr never strands a peer); written by the pairing ceremony (both sides, from the live connection) and by the pump on successful connect; post-join first sync and post-restart resync converge in seconds, not ~1 min (M8 decisions 14, 20)
       Net-bind boot-race resilience: a daemon that comes up net-less (NetHost::start failed — e.g. the systemd unit autostarted before the network/DNS stack was ready, `Failed to create an address lookup service`) must SELF-HEAL — retry the net bring-up in the background with capped backoff and, on success, attach net to the broker + spawn the dispatcher/peer-pump (which today are gated on `net_up` at boot and so never start, leaving the node silently unreachable until a manual restart — kitsubito 2026-06-08). Status surfaces the net-less state honestly (a net-less broker renders as 'no connection', not only a pump-STALLED line with a bogus pre-boot heartbeat age). The installer's autostart unit waits for the network (`Wants=/After=network-online.target`) as belt-and-suspenders.
       A surfaced concurrent context pair is durably preserved (both versions, tracked artifacts) until a strictly dominating write clears it; no reconcile failure path discards an unmerged version (6.6, ADR-0013)
       Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)
       Stale registry entries degrade to fallback, never hard-fail (4.3)
       cross-node Gateway user-msg honored via advertised endpoint_type: a user-msg from a Gateway-typed origin survives the receive_wan funnel as user-msg (vs the fail-closed re-stamp), keyed on the QUIC-handshake-proven origin node (never wire `from`). Trust boundary = subnet membership (operator-ratified 2026-06-13); no defense against an in-subnet member forging the type. Instance.endpoint_type is an additive serde-default field extending REQ-INST-7's data model. Absent/unknown type → re-stamp (N-1 rollout grace)
       NTP TOTP offset: the pairing ceremony queries NTP at ceremony time (both sides) and applies the derived offset to the TOTP calculation in-process only; system-clock fallback when NTP is unreachable (offline LAN pairing unaffected — NTP failure never blocks a pairing that succeeds today); never sets the OS clock; no background sync loop (M8 decision 18; field trigger: enlyzeam clock >1 min off exceeds the ±1 window)
  [OK] REQ-TERM-4  required: [impl, unit, int]  stages: +doc +impl +unit +int
       Platform-safe `spt update fetch` + apply platform-guard (v0.3.1 cross-OS brick fix): `spt update fetch` stages the signed multi-platform `SignedUpdateSet` (`update-set.json` + every platform artifact it names), never a platform-blind single `SignedRelease`, so local apply selects `current_platform()` and P2P re-serve lets each peer select ITS own platform. Defense-in-depth: `apply_staged` REFUSES a staged single-release artifact unless it is platform-stamped for THIS node (an unstamped pre-v0.3.2 single, or a single stamped for another OS, fail-safe refuses — the guard that alone prevents the v0.3.1 brick where a Linux ELF was applied as `spt.exe`). UX: a friendly post-apply message (`Updated spt-core to vX.Y.Z.` + changelog URL) driven by an additive `product_version` metadata field, with a release-counter fallback when absent.
EXIT=0


REQ-TERM-4 `[OK]` with all four stages from the new evidence; workspace tests pass. Now finish the docs: ADR-0008 status, CHANGELOG, the docs-site digest/manifest pages (G3 sweep), and the plan status. ADR-0008 first:


Edit

accepted (2026-06-01) · **source mechanism superseded by the 2026-06-12 amendment below** (product surface stands)

accepted (2026-06-01) · **source mechanism superseded by the 2026-06-12 amendment below** (product surface stands) · **implementation reconciled onto the log source in M9 (2026-06-13)** — the PTY-parse engine + `pty_digest` seam are retired; the digest is an on-demand projection of normalized session-log records (the implementation debt below is discharged)


The file C:\Users\decid\Documents\projects\spt-core\docs\adr\0008-live-activity-buffer-pty-digest.md has been updated successfully. (file state is current in your context — no need to Read it back)

# Live activity buffer (PTY digest)

## Status

accepted (2026-06-01) · **source mechanism superseded by the 2026-06-12 amendment below** (product surface stands)

## Context

We want a rolling, human-glanceable "what is this agent doing right now" view — the last ~N user turns + agent output between, with tool-usage sprints collapsed to a single `used: …` line — that a Shell, CLI, or GUI pane can render. The obvious implementation (spt-core parses the live PTY byte stream) looks like it **contradicts the history-subsystem decision** (CONTEXT §history subsystem), which deliberately refused to build per-harness parsers ("unbounded, version-fragile maintenance burden") and pushed parsing into adapters (Path A) or a native store the adapter writes (Path B). A future reader would reasonably flag the apparent reversal. This ADR records why it is *not* one, and the shape chosen.

## Decision

Add a **live activity buffer (PTY digest)** as a **distinct artifact** — not scrollback (raw bytes, R-TERM-2), not transcript history (full conversation behind communes/resume, Paths A/B). Its purpose is at-a-glance current activity, not replay.

The parsing-boundary distinction that resolves the tension:
- **The adapter supplies the patterns; spt-core only runs them.** The manifest provides `input_pattern` + `agent_pattern` (each opens a span capturing everything, newlines included, until the next pattern match of any kind) and `tool_pattern` (a **list** — one per tool, plus a final **catchall** for user-installed tools, capturing tool-name + arg fragment). This is exactly Path A's "adapter owns the parser" rule and the "spt-core owns the mechanism, adapter owns the pattern" principle (identical to opaque command templates) — **not** a built-in per-harness parser. The rejected maintenance burden stays in the adapter.
- **It parses the live PTY byte stream, not the harness transcript.** A light rolling regex over bytes the broker already holds is a categorically different artifact from locating + parsing version-fragile harness transcript files (the thing Path A/B exist to avoid).

Shape:
- **Broker-PTY-dependent → spt-hosted-primary.** Needs the broker-owned PTY (topology 2). **Harness-hosted (topology 1) is capability-gated:** an adapter that can feed spt-core the stream (e.g. a `pi`-style harness) opts in via a declared capability; harnesses that own their process tree opaquely (Claude Code) cannot, and the digest is simply unavailable there.
- **Structured, source-tagged output** (input / agent / collapsed-tool entries) so consumers render richly. Window depth N (~3) is a config knob; tool-arg truncation (~25 chars) is spt-core-controlled *presentation* (the template controls *extraction*).
- **Two access modes over one transport.** The digest is a parsed projection of the same PTY stream the broker streams for R-TERM-3, so: **snapshot pull** (`spt digest <[subnet:]id[@node]>`) and a **broker-pushed structured-delta stream** (subscribe; pushes only changes for near-realtime reflection). The delta-stream is a structured overlay riding the **same R-TERM-3 byte-stream substrate** (raw vs parsed = two channels on the one session surface). Access is **address-gated** (same as messaging; respects ADR-0006 visibility).
- **Optional "Option (C)" persistence:** opt-in per-endpoint, append digests to the native history store (Path B) as a coarse activity log — a third logging surface, off by default, not a replacement for A/B.

## Consequences

- New v1 requirement to register: the live activity buffer + the `input_pattern`/`agent_pattern`/`tool_pattern[]` manifest seam + `spt digest` (pull) + the digest delta-stream (push). Lands with **M3** (needs the spt-term broker + PTY); Shell consumption is M5; the GUI pane is the frontend milestone; persistence is M3+.
- The manifest schema (`docs/MANIFEST.md`) gains the `pty_digest` seam; the capability-declaration seam gains the harness-hosted stream-feed opt-in.
- Does **not** reverse the history-subsystem decision — the no-built-in-parser rule still holds; the digest honors it by running adapter-supplied patterns over the live byte stream rather than parsing harness transcripts.
- Builds on R-TERM-1/2/3 (broker, session surface, byte-stream) and ADR-0006 (address-gating).

## Amendment — source mechanism superseded: normalized session logs, never PTY parsing (2026-06-12)

**What changes:** the digest's *source*. The original decision parsed the live PTY byte stream; the digest is now a **rolling tail-projection of the endpoint's normalized session logs** — the same normalized history the echo-commune consumes (history subsystem Path A locate+normalize / Path B native store). One pipeline, two consumers. Harnesses with no usable logs push entries directly via a new **`api digest-entry`** verb.

**Why (the Gateway grill, 2026-06-12):** the PTY byte stream of an alternate-screen TUI is a **rendering protocol, not a content log**. Scroll and resize trigger full repaints — the same content re-enters the stream and corrupts delta computation — and semantic turn boundaries drown in repaint traffic. The original §Decision rationale ("a light rolling regex over bytes ≠ fragile transcript parsing") proved **backwards in practice**: the byte stream is the *more* fragile surface, and transcript parsing carries **zero new burden** because Paths A/B already pay for it — the digest just consumes their output. The governing split: **render surfaces read the PTY** (R-TERM raw byte ring, unchanged); **content surfaces read the logs**.

**What stands (the product surface, unchanged):** the formula (N-turn window, collapsed tool sprints, presentation-vs-extraction split), structured source-tagged output, both access modes (snapshot pull + structured-delta stream, deltas now keyed to log records), address-gating, Option-C persistence. The no-built-in-parser rule **still holds** — normalization remains adapter-owned (Path A) or adapter-pushed (Path B).

**Improved consequence:** the digest becomes **topology-independent** — harness-hosted endpoints (Claude Code's per-session JSONL is Path A's worked example) get digests too; the original "unavailable under opaque harnesses" limitation dissolves. Freshness = file-watch + adapter hook nudges; a near-realtime **incremental** normalize story is design-phase work for the implementing milestone.

**Implementation debt recorded:** `spt-daemon::digest` (`DigestEngine` + the manifest `pty_digest` pattern seam) implements the superseded shape; reconciliation + the `docs/MANIFEST.md` schema revision land with the implementing milestone.

-accepted (2026-06-01) · **source mechanism superseded by the 2026-06-12 amendment below** (product surface stands)

+accepted (2026-06-01) · **source mechanism superseded by the 2026-06-12 amendment below** (product surface stands) · **implementation reconciled onto the log source in M9 (2026-06-13)** — the PTY-parse engine + `pty_digest` seam are retired; the digest is an on-demand projection of normalized session-log records (the implementation debt below is discharged)


**Implementation debt recorded:** `spt-daemon::digest` (`DigestEngine` + the manifest `pty_digest` pattern seam) implements the superseded shape; reconciliation + the `docs/MANIFEST.md` schema revision land with the implementing milestone.

**Implementation debt recorded:** `spt-daemon::digest` (`DigestEngine` + the manifest `pty_digest` pattern seam) implements the superseded shape; reconciliation + the `docs/MANIFEST.md` schema revision land with the implementing milestone.

**Debt discharged (M9, 2026-06-13):** the PTY-parse engine (`spt-term`'s `DigestParser` + `spt-daemon`'s byte-feed `DigestEngine`) and the `[pty_digest]` manifest seam are **retired**. The digest is now an **on-demand projection** of the endpoint's normalized session-log records: `spt-term::projection` folds the published **digest-record contract** (the spt-core-owned `{role, text, tool}` overlay an adapter targets in its `[history]` lines, or pushes via `api digest-entry`) into the same structured `Digest`; `spt-daemon::digest::project_endpoint_digest` projects on demand at the snapshot-pull / reproject handlers and `DigestHub::project_and_publish` drives the delta-stream. Topology-independent (a Claude-Code-hosted endpoint's Path A JSONL projects with no broker PTY — proven by the CC-fixture E2E). The **autonomous file-watch freshness nudge** and **Option-C persistence re-home** are deferred to the consuming-frontend milestone (`docs/DEFERRED.md`) — the delta-stream contract ships, driven by pull / `api digest-entry` publishes. REQ-TERM-4 re-titled + its evidence re-pointed onto the projection in the same change.