Bash

git add W3-GATE-VERDICT.md && git commit -q -m "$(cat <<'EOF'
docs(w3): design-gate VERDICT — approved with binding refinements

Verified todlando's load-bearing grounding (BrainLifecycle thread-moved
IPC-unreachable @livehost.rs:400; broker sessions map as registry; sha256_hex;
scoped-reap brain-start-only). APPROVED: broker-registry reuse, Arc<RwLock>
manifest-refresh handle (w/ minimal-borrow + teardown cautions), psyche own-copy
(.live-bin, copy-on-hash-diff), stop-path scoped-reap, sha256 CRC atomic swap,
KIND_ADAPTER_APPLY broker IPC. Confirmed new REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP.
Binding: tightened partial-failure swap ordering + re-resolve translation path
from refreshed manifest + ADR-0025 amendment.

Co-authored by: doyle
EOF
)" && git push origin v0.13.2-adapter-packaging 2>&1 | tail -2

Commit + push W3 gate verdict


warning: in the working copy of 'W3-GATE-VERDICT.md', LF will be replaced by CRLF the next time Git touches it
To https://github.com/SaberMage/spt-core.git
   19b58b1..fe86990  v0.13.2-adapter-packaging -> v0.13.2-adapter-packaging


"$OWL" send todlando doyle <<'EOF'
W3 DESIGN-GATE: APPROVED to build. Read W3-GATE-VERDICT.md (committed @fe86990, repo root) — full confirmations + binding refinements.

I verified your load-bearing grounding against the live tree myself: livehost.rs:400 BrainLifecycle IS moved into the std::thread::spawn(move||run_pulse_loop) closure (IPC-unreachable, only `stop` flag in) — confirmed, ADR-0025 step 3 genuinely not implementable as written; broker sessions map + HostedSession{endpoint,translation} @853/1112 confirmed ("lives in the broker, a brain crash must not lose"); sha256_hex @603 + psyche_orphan_should_reap @595 (brain-start-only @654) confirmed. Strong grounded analysis — exactly what the design-gate was for.

HEADLINE CONFIRMS: broker sessions map IS the registry (no parallel struct); Arc<RwLock> manifest-refresh handle is PRIMARY (with cautions — minimal read-lock scope, never held across spawn/blocking; broker-side handle removed on teardown + re-registered on brain restart; reload-on-dirty-signal is the documented fallback); psyche own-copy at <perch>/.live-bin (copy-only-when-hash-differs so re-spawn stays cheap; translation stays in-install-dir, justified); stop-path scoped-reap verbatim fail-safe-decline; sha256 CRC extract-to-temp atomic per-file swap; KIND_ADAPTER_APPLY on the BROKER not seedmap. Brain-parity locked.

NEW REQ-HAZARD CONFIRMED: mint REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP, stages [doc,impl,unit,int], doc = a KNOWN-HAZARDS.md row (rule 4) + the ADR-0025 amendment. Shape is in the verdict.

BINDING REFINEMENTS (don't skip): (1) partial-failure — stage all <file>.new first then one tight rename loop; mid-loop fail → best-effort rollback + restart OLD child + report failed, NEVER stopped-without-restart; (2) translation restart re-resolves path from the REFRESHED manifest (flag 5); (3) write the ADR-0025 amendment (third axis: ephemeral-but-install-dir-locking → own-copy; fix the 2 errors; mark accepted).

BUILD ORDER (I gate incrementally): W3a+W3b batch → W3c (gate carefully — touches hardened brain) → (a)+(c) → W3d (gate carefully) → W3e (ship-blocker, I gate vs perri's exact 4-step repro + Windows-lock regression). Reviewable sub-commits, push each (PR #29 CI both runners). Ping me at each gate point (batch W3a+W3b). I loop perri to validate once W3e lands. Go.
EOF
echo "---sent exit $?---"

Send W3 build go-ahead to todlando


➜ SENT:todlando
---sent exit 0---


W3 build go-ahead sent. While todlando builds W3a+W3b, confirm CI settled green on the W2 code (doc pushes keep superseding runs — I want at least one green run covering W2):


gh run list --branch v0.13.2-adapter-packaging --limit 6 2>&1 | head -8

CI run status


queued		v0.13.2 — adapter packaging + live update (W1–W5)	ci	v0.13.2-adapter-packaging	pull_request	27987198730	25s	2026-06-22T22:05:38Z
completed	cancelled	v0.13.2 — adapter packaging + live update (W1–W5)	ci	v0.13.2-adapter-packaging	pull_request	27986994079	4m4s	2026-06-22T22:01:52Z
completed	cancelled	v0.13.2 — adapter packaging + live update (W1–W5)	ci	v0.13.2-adapter-packaging	pull_request	27986586161	8m25s	2026-06-22T21:53:56Z
completed	cancelled	v0.13.2 — adapter packaging + live update (W1–W5)	ci	v0.13.2-adapter-packaging	pull_request	27986287729	6m5s	2026-06-22T21:48:08Z
completed	success	v0.13.2 — adapter packaging + live update (W1–W5)	ci	v0.13.2-adapter-packaging	pull_request	27985369653	12m41s	2026-06-22T21:30:40Z


CI note: only the W1-state run completed green (12m41s, both runners); every run carrying W2 code got auto-cancelled by the rapid succession of doc pushes (brief→proposal→verdict). W2's code is small + daemon-runtime-free (low CI risk); the **authoritative both-runners gate is the final pre-release run** (per the ci-fires-on-push lessons), which I'll confirm green before deployah cuts. Not blocking on intermediate churn.

**Status — v0.13.2:**

| Wave | State |
|------|-------|
| W1 multi-platform `.spt` | ✅ GATE-PASS + CI green both runners |
| W2 gh transport | ✅ GATE-PASS (CI rides final run) |
| **W3 keystone** | 🔨 **design APPROVED** @fe86990 — todlando building incrementally (W3a+W3b → W3c → a+c → W3d → W3e). I gate each sub-commit |
| W4 / W5 / release | queued |

**W3 design-gate paid off:** caught that ADR-0025 step 3 ("re-clone into running BrainLifecycle") is unimplementable (thread-local, IPC-unreachable) → corrected to a broker-registered `Arc<RwLock>` refresh handle. Confirmed the psyche-lock 3-part fix (own-copy primary + stop-path reap + atomic CRC swap), minted `REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP`, bound the partial-failure ordering + manifest re-resolve + ADR-0025 amendment.

Waiting on todlando's W3a+W3b batch. perri looped in for ship-acceptance once W3e lands. Reachable on `doyle`.


<task-notification>
<task-id>b3ok5tzh4</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/9" id="c3aa5b0c" type="msg" from="todlando">W3a+W3b batch DONE @b49d4a0 (pushed). Foundations only — no live apply wired (that's W3d); the new broker query helpers are #[allow(dead_code)] until then. Ready for your batch gate.

W3a — broker sessions map IS the per-endpoint resident registry (no parallel struct, per your verdict):
- SpawnReq{adapte</EVENT-PART>
<EVENT-PART seq="2/9" id="c3aa5b0c">r, install_dir} (additive serde-default, N-1-safe) -&gt; HostedSession{adapter, install_dir}. launch_harness_brokered_in gains install_dir, sourced in cmd_endpoint_run from the adapter registry source_dir.
- broker queries resident_translations_for_endpoint (children to cycle) + endpoints_running_adapter (apply targets) over the existing ma</EVENT-PART>
<EVENT-PART seq="3/9" id="c3aa5b0c">p; pure select_endpoints_running_adapter selector (unit-testable w/o real HostedSessions).
- ~13 SpawnReq literals across crates updated for the 2 additive fields; launch_harness_brokered_in now 8 args (#[allow(clippy::too_many_arguments)] — a struct would obscure the bringup call; flag if you'd rather a params struct).

W3b — new</EVENT-PART>
<EVENT-PART seq="4/9" id="c3aa5b0c"> crc_swap module:
- plan_crc_swap: content-hash diff via existing release::sha256_hex (NO new dep) — only files whose bytes differ; identical files (and their still-running binaries) untouched.
- apply_crc_swap: stage-all-&lt;file&gt;.new -&gt; ONE tight rename loop -&gt; roll back committed renames on a mid-loop failure -&gt; NEVER st</EVENT-PART>
<EVENT-PART seq="5/9" id="c3aa5b0c">rand install half-new (your binding partial-failure refinement). Replaces remove_dir_all+blanket-extract (the os-error-5 path).

EVIDENCE:
- REQ-ADAPTER-LIVE-UPDATE activated [&quot;doc&quot;,&quot;impl&quot;,&quot;unit&quot;] — int deliberately deferred to W3e (the no-mocks keystone) so each W3 sub-commit stays CI-green; I'll bump </EVENT-PART>
<EVENT-PART seq="6/9" id="c3aa5b0c">to add int at W3e. (New REQ-HAZARD-STOP-PATH-PSYCHE-ORPHAN-REAP NOT minted yet — its impl is the (a) sub-commit, rule 5.)
- 5 units: 4 crc_swap (only-changed; nested recurse; swap+new-file+identical-untouched+no-litter; rollback-never-strands) + 1 selector. NOTE on the rollback test: your brief's exact failure mechanism (rename .new over </EVENT-PART>
<EVENT-PART seq="7/9" id="c3aa5b0c">a non-empty dir) ISN'T reachable — apply moves the target aside to .old FIRST so the dest is always vacant; the test-engineer forced the failure at the move-aside step instead (seeded .old as a non-empty dir → ENOTEMPTY/os-5 after swap-1 committed). Still proves never-strand. The impl is more careful than the brief assumed.

GATES (r</EVENT-PART>
<EVENT-PART seq="8/9" id="c3aa5b0c">an myself): clippy --workspace --all-targets -D warnings clean; traceable exit 0 (288/288, REQ +doc+impl+unit -int); xtask drift OK; 5 units pass (filtered — daemon-lifecycle lib tests deadlock on this contended host,
...(truncated)</event>
</task-notification>


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


W3a+W3b batch done @b49d4a0 (foundations, no live apply yet). Gate it — fetch + read, focus on the safety-critical crc_swap module:


git pull --ff-only --quiet 2>&1 | tail -1; echo "===HEAD==="; git log --oneline -2 | cat; echo "===diff stat fe86990..b49d4a0==="; git diff --stat fe86990..b49d4a0 | cat

Fetch W3a+W3b + diff stat


===HEAD===
b49d4a0 feat(daemon): W3a resident-children registry + W3b CRC-gated swap (ADR-0025)
fe86990 docs(w3): design-gate VERDICT — approved with binding refinements
===diff stat fe86990..b49d4a0===
 crates/spt-daemon/src/applyhost.rs              |   2 +
 crates/spt-daemon/src/broker.rs                 | 101 ++++++
 crates/spt-daemon/src/crc_swap.rs               | 393 ++++++++++++++++++++++++
 crates/spt-daemon/src/harnesshost.rs            |   6 +
 crates/spt-daemon/src/lib.rs                    |   1 +
 crates/spt-daemon/src/livehost.rs               |   2 +
 crates/spt-daemon/src/msg.rs                    |  14 +
 crates/spt-daemon/src/shellhost.rs              |   2 +
 crates/spt-daemon/tests/attach.rs               |   8 +-
 crates/spt-daemon/tests/brain_swap.rs           |   2 +
 crates/spt-daemon/tests/broker.rs               |   8 +
 crates/spt-daemon/tests/budget.rs               |   2 +
 crates/spt-daemon/tests/daemon_e2e.rs           |   2 +
 crates/spt-daemon/tests/dispatch.rs             |   4 +
 crates/spt-daemon/tests/driven_by_selfheal.rs   |   2 +
 crates/spt-daemon/tests/handoff.rs              |   2 +
 crates/spt-daemon/tests/idempotent.rs           |   2 +
 crates/spt-daemon/tests/inject_control_wedge.rs |   6 +
 crates/spt-daemon/tests/input_ack_deadlock.rs   |   2 +
 crates/spt-daemon/tests/resume.rs               |   2 +
 crates/spt-daemon/tests/twohost.rs              |   2 +
 crates/spt/src/cli.rs                           |  10 +-
 crates/spt/tests/brain_survive.rs               |   2 +
 traceable-reqs.toml                             |   2 +-
 24 files changed, 575 insertions(+), 4 deletions(-)


1	//! W3b — content-hash-gated atomic per-file swap for a live adapter update
2	//! (ADR-0025, REQ-ADAPTER-LIVE-UPDATE).
3	//!
4	//! The legacy `spt adapter update` path does `remove_dir_all(install_dir)` then a
5	//! blanket extract — which fails on Windows with *"Access is denied (os error 5)"*
6	//! the moment a single install-dir binary is still running (the field bug). This
7	//! module replaces that with a **content-diff swap**: extract the staged archive
8	//! to a temp tree, replace in the install dir **only** the files whose content
9	//! hash differs (so identical files — and their still-running binaries — are
10	//! never touched), and commit via **stage-all-`.new`-then-one-tight-rename-loop**
11	//! so a mid-swap failure never strands the install half-new (doyle W3 gate,
12	//! binding partial-failure refinement).
13	//!
14	//! CRC here is plain content hashing (reuse [`crate::release::sha256_hex`], no new
15	//! dep) — "replace only what changed", not cryptographic integrity (that is the
16	//! archive signature, ADR-0024 / REQ-UPD-9).
17
18	use crate::release::sha256_hex;
19	use std::path::{Path, PathBuf};
20
21	/// One planned file replacement discovered by [`plan_crc_swap`].
22	#[derive(Debug, Clone, PartialEq, Eq)]
23	pub struct PlannedSwap {
24	    /// Path relative to the tree root (for diagnostics / determinism).
25	    pub rel: PathBuf,
26	    /// The staged source file (`<staging>/<rel>`).
27	    pub staged: PathBuf,
28	    /// The install-dir target (`<install_dir>/<rel>`).
29	    pub target: PathBuf,
30	}
31
32	/// Walk `staging` and return the files whose content differs from their
33	/// `install_dir` counterpart — or that are absent in `install_dir`. Files whose
34	/// content is byte-identical are skipped, so a still-running unchanged binary is
35	/// never disturbed. Read-only: does not mutate either tree. The returned plan is
36	/// sorted by `rel` for deterministic apply + testing.
37	// [impl->REQ-ADAPTER-LIVE-UPDATE]
38	pub fn plan_crc_swap(staging: &Path, install_dir: &Path) -> std::io::Result<Vec<PlannedSwap>> {
39	    let mut plan = Vec::new();
40	    collect_diffs(staging, staging, install_dir, &mut plan)?;
41	    plan.sort_by(|a, b| a.rel.cmp(&b.rel));
42	    Ok(plan)
43	}
44
45	/// Recurse `dir` (a subtree of `root`), comparing each file against its
46	/// `install_dir`-relative counterpart.
47	fn collect_diffs(
48	    root: &Path,
49	    dir: &Path,
50	    install_dir: &Path,
51	    plan: &mut Vec<PlannedSwap>,
52	) -> std::io::Result<()> {
53	    for entry in std::fs::read_dir(dir)? {
54	        let entry = entry?;
55	        let path = entry.path();
56	        if path.is_dir() {
57	            collect_diffs(root, &path, install_dir, plan)?;
58	            continue;
59	        }
60	        let rel = path
61	            .strip_prefix(root)
62	            .expect("path is under root")
63	            .to_path_buf();
64	        let target = install_dir.join(&rel);
65	        let differs = match std::fs::read(&target) {
66	            Ok(existing) => sha256_hex(&std::fs::read(&path)?) != sha256_hex(&existing),
67	            // Absent (or unreadable) target ⇒ a new/changed file to place.
68	            Err(_) => true,
69	        };
70	        if differs {
71	            plan.push(PlannedSwap {
72	                rel,
73	                staged: path,
74	                target,
75	            });
76	        }
77	    }
78	    Ok(())
79	}
80
81	/// Apply a [`plan_crc_swap`] result with a never-strand commit protocol:
82	///
83	/// 1. **Stage** — copy every change to `<target>.new` (creating parent dirs).
84	/// 2. **Commit** — one tight loop: for each change, move the existing original to
85	///    `<target>.old` (if present) then rename `<target>.new` → `<target>`.
86	/// 3. **Cleanup** — delete the `.old` originals once the whole loop committed.
87	///
88	/// On ANY failure in the commit loop, the already-committed renames are **rolled
89	/// back** (restore each `<target>.old` over `<target>`) and the error is
90	/// returned — so the install dir is never left a half-new mix that a restarted
91	/// new-expecting child would choke on. The caller (W3d) restarts the OLD resident
92	/// child and reports the update failed.
93	// [impl->REQ-ADAPTER-LIVE-UPDATE]
94	pub fn apply_crc_swap(plan: &[PlannedSwap]) -> std::io::Result<()> {
95	    // Phase 1 — stage every new file beside its target.
96	    for swap in plan {
97	        if let Some(parent) = swap.target.parent() {
98	            std::fs::create_dir_all(parent)?;
99	        }
100	        std::fs::copy(&swap.staged, new_path(&swap.target))?;
101	    }
102
103	    // Phase 2 — tight commit loop, tracking what to roll back on failure.
104	    // `committed` holds (target, had_original) for each rename that landed.
105	    let mut committed: Vec<(&Path, bool)> = Vec::new();
106	    for swap in plan {
107	        let target = swap.target.as_path();
108	        let had_original = target.exists();
109	        if had_original {
110	            if let Err(e) = std::fs::rename(target, old_path(target)) {
111	                rollback(&committed);
112	                cleanup_new(plan);
113	                return Err(e);
114	            }
115	        }
116	        if let Err(e) = std::fs::rename(new_path(target), target) {
117	            // This one didn't land: undo its own `.old` move, then prior commits.
118	            if had_original {
119	                let _ = std::fs::rename(old_path(target), target);
120	            }
121	            rollback(&committed);
122	            cleanup_new(plan);
123	            return Err(e);
124	        }
125	        committed.push((target, had_original));
126	    }
127
128	    // Phase 3 — whole loop committed: drop the saved originals.
129	    for (target, had_original) in &committed {
130	        if *had_original {
131	            let _ = std::fs::remove_file(old_path(target));
132	        }
133	    }
134	    Ok(())
135	}
136
137	/// `<target>.new` — the staged replacement, in the target's own dir (same fs ⇒
138	/// the rename is atomic).
139	fn new_path(target: &Path) -> PathBuf {
140	    sibling(target, "new")
141	}
142
143	/// `<target>.old` — the displaced original, kept until the whole loop commits so
144	/// a mid-loop failure can restore it.
145	fn old_path(target: &Path) -> PathBuf {
146	    sibling(target, "old")
147	}
148
149	fn sibling(target: &Path, suffix: &str) -> PathBuf {
150	    let mut name = target.file_name().unwrap_or_default().to_os_string();
151	    name.push(".");
152	    name.push(suffix);
153	    target.with_file_name(name)
154	}
155
156	/// Restore each already-committed rename: move `<target>` back to `<target>.new`
157	/// and the saved `<target>.old` back over `<target>`. Best-effort (a rollback is
158	/// already an error path; nothing more we can do but try).
159	fn rollback(committed: &[(&Path, bool)]) {
160	    for (target, had_original) in committed.iter().rev() {
161	        let _ = std::fs::rename(target, new_path(target));
162	        if *had_original {
163	            let _ = std::fs::rename(old_path(target), target);
164	        }
165	    }
166	}
167
168	/// Remove any leftover `<target>.new` staging files (failure cleanup).
169	fn cleanup_new(plan: &[PlannedSwap]) {
170	    for swap in plan {
171	        let _ = std::fs::remove_file(new_path(&swap.target));
172	    }
173	}
174
175	#[cfg(test)]
176	mod tests {
177	    use super::*;
178	    use std::fs;
179	    use std::path::Path;
180	    use tempfile::tempdir;
181
182	    /// Write `bytes` to `path`, creating parent dirs. Test helper.
183	    fn write(path: &Path, bytes: &[u8]) {
184	        if let Some(parent) = path.parent() {
185	            fs::create_dir_all(parent).unwrap();
186	        }
187	        fs::write(path, bytes).unwrap();
188	    }
189
190	    /// The `rel` paths a plan covers, sorted (the plan already sorts; collect for
191	    /// readable assertions).
192	    fn rels(plan: &[PlannedSwap]) -> Vec<PathBuf> {
193	        plan.iter().map(|s| s.rel.clone()).collect()
194	    }
195
196	    /// True iff `dir` (recursively) contains any leftover `.new` / `.old` swap
197	    /// litter — the never-strand cleanup invariant.
198	    fn has_swap_litter(dir: &Path) -> bool {
199	        let mut found = false;
200	        fn walk(dir: &Path, found: &mut bool) {
201	            for entry in fs::read_dir(dir).unwrap() {
202	                let path = entry.unwrap().path();
203	                if path.is_dir() {
204	                    walk(&path, found);
205	                    continue;
206	                }
207	                if let Some(ext) = path.extension() {
208	                    if ext == "new" || ext == "old" {
209	                        *found = true;
210	                    }
211	                }
212	            }
213	        }
214	        walk(dir, &mut found);
215	        found
216	    }
217
218	    /// `plan_crc_swap` selects ONLY the files whose content differs (fileB) or
219	    /// that are absent from the install dir (fileC) — a byte-identical file
220	    /// (fileA) is skipped so its still-running binary is never disturbed — and
221	    /// the result is sorted by `rel`.
222	    // [unit->REQ-ADAPTER-LIVE-UPDATE]
223	    #[test]
224	    fn plan_selects_only_changed() {
225	        let staging = tempdir().unwrap();
226	        let install = tempdir().unwrap();
227	        let (s, i) = (staging.path(), install.path());
228
229	        // fileA: identical in both → skipped.
230	        write(&s.join("fileA"), b"same-bytes");
231	        write(&i.join("fileA"), b"same-bytes");
232	        // fileB: present in both but DIFFERENT bytes → selected.
233	        write(&s.join("fileB"), b"new-content");
234	        write(&i.join("fileB"), b"old-content");
235	        // fileC: present only in staging (absent in install) → selected.
236	        write(&s.join("fileC"), b"brand-new");
237
238	        let plan = plan_crc_swap(s, i).unwrap();
239
240	        assert_eq!(
241	            rels(&plan),
242	            vec![PathBuf::from("fileB"), PathBuf::from("fileC")],
243	            "only the changed (B) and absent (C) files, sorted; identical A skipped"
244	        );
245	        // The staged/target paths point where they should.
246	        let b = plan.iter().find(|p| p.rel == Path::new("fileB")).unwrap();
247	        assert_eq!(b.staged, s.join("fileB"));
248	        assert_eq!(b.target, i.join("fileB"));
249	    }
250
251	    /// The walk recurses into nested directories and reports the correct nested
252	    /// `rel` (so a changed file deep in the tree is found, not just top-level).
253	    // [unit->REQ-ADAPTER-LIVE-UPDATE]
254	    #[test]
255	    fn plan_recurses_nested_dirs() {
256	        let staging = tempdir().unwrap();
257	        let install = tempdir().unwrap();
258	        let (s, i) = (staging.path(), install.path());
259
260	        // A changed file three levels deep; the install counterpart differs.
261	        write(&s.join("sub").join("dir").join("x"), b"changed");
262	        write(&i.join("sub").join("dir").join("x"), b"original");
263
264	        let plan = plan_crc_swap(s, i).unwrap();
265
266	        let want_rel: PathBuf = ["sub", "dir", "x"].iter().collect();
267	        assert_eq!(rels(&plan), vec![want_rel.clone()], "nested changed file found");
268	        let only = &plan[0];
269	        assert_eq!(only.staged, s.join(&want_rel));
270	        assert_eq!(only.target, i.join(&want_rel));
271	    }
272
273	    /// `apply_crc_swap` replaces the changed file's bytes, materializes a brand-new
274	    /// file (including a not-yet-existing nested parent dir), leaves a byte-identical
275	    /// file untouched, and leaves NO `.new`/`.old` litter behind on success.
276	    // [unit->REQ-ADAPTER-LIVE-UPDATE]
277	    #[test]
278	    fn apply_swaps_changed_creates_new_untouched_identical() {
279	        let staging = tempdir().unwrap();
280	        let install = tempdir().unwrap();
281	        let (s, i) = (staging.path(), install.path());
282
283	        // changed: differs → will be swapped to staged bytes.
284	        write(&s.join("changed"), b"NEW");
285	        write(&i.join("changed"), b"OLD");
286	        // identical: same bytes → must remain byte-unchanged and not be in the plan.
287	        write(&s.join("identical"), b"STABLE");
288	        write(&i.join("identical"), b"STABLE");
289	        // fresh under a nested parent that does NOT yet exist in install → created.
290	        let fresh_rel: PathBuf = ["newdir", "fresh"].iter().collect();
291	        write(&s.join(&fresh_rel), b"FRESH");
292	        assert!(!i.join("newdir").exists(), "precondition: nested parent absent");
293
294	        let plan = plan_crc_swap(s, i).unwrap();
295	        assert_eq!(
296	            rels(&plan),
297	            vec![PathBuf::from("changed"), fresh_rel.clone()],
298	            "plan covers changed + fresh, not identical"
299	        );
300
301	        apply_crc_swap(&plan).unwrap();
302
303	        assert_eq!(fs::read(i.join("changed")).unwrap(), b"NEW", "changed got new bytes");
304	        assert_eq!(
305	            fs::read(i.join(&fresh_rel)).unwrap(),
306	            b"FRESH",
307	            "fresh file materialized with its nested parent dir created"
308	        );
309	        assert_eq!(
310	            fs::read(i.join("identical")).unwrap(),
311	            b"STABLE",
312	            "identical file byte-unchanged (never touched)"
313	        );
314	        assert!(
315	            !has_swap_litter(i),
316	            "no .new/.old staging files remain after a successful apply"
317	        );
318	    }
319
320	    /// Never-strand invariant: a mid-loop rename failure rolls back the renames
321	    /// that already committed. We hand-craft a two-swap plan where the FIRST
322	    /// target is a normal file (it will commit) and the SECOND swap is forced to
323	    /// fail by pre-occupying its `<target>.old` slot with an existing NON-EMPTY
324	    /// DIRECTORY. The impl moves the existing target out to `<target>.old` before
325	    /// renaming `<target>.new` in; `rename(<second_target>, <second>.old)` over a
326	    /// non-empty dir errors on both Windows (PermissionDenied / os error 5) and
327	    /// Linux (ENOTEMPTY), so the failure lands AFTER swap 1 has committed —
328	    /// exercising the rollback. After the Err, the first target must hold its
329	    /// ORIGINAL bytes (rollback restored it, NOT the staged bytes) and no
330	    /// `.new`/`.old` litter may remain.
331	    // [unit->REQ-ADAPTER-LIVE-UPDATE]
332	    #[test]
333	    fn apply_rollback_never_strands() {
334	        let staging = tempdir().unwrap();
335	        let install = tempdir().unwrap();
336	        let (s, i) = (staging.path(), install.path());
337
338	        // Swap 1: a normal file that WILL commit (staged "AFTER" over original "BEFORE").
339	        let first_staged = s.join("first.src");
340	        write(&first_staged, b"AFTER");
341	        let first_target = i.join("first");
342	        write(&first_target, b"BEFORE");
343
344	        // Swap 2: an ordinary existing target file, BUT its `.old` displacement
345	        // slot is pre-occupied by a non-empty directory so the impl's
346	        // `rename(second, second.old)` (the "move original aside" step) fails —
347	        // landing the error mid-loop, after swap 1 committed.
348	        let second_staged = s.join("second.src");
349	        write(&second_staged, b"second-bytes");
350	        let second_target = i.join("second");
351	        write(&second_target, b"second-original");
352	        let second_old = i.join("second.old");
353	        write(&second_old.join("occupant"), b"makes-it-non-empty");
354	        assert!(second_old.is_dir(), "precondition: <second>.old is a non-empty dir");
355
356	        let plan = vec![
357	            PlannedSwap {
358	                rel: PathBuf::from("first"),
359	                staged: first_staged,
360	                target: first_target.clone(),
361	            },
362	            PlannedSwap {
363	                rel: PathBuf::from("second"),
364	                staged: second_staged,
365	                target: second_target.clone(),
366	            },
367	        ];
368
369	        let result = apply_crc_swap(&plan);
370
371	        assert!(result.is_err(), "mid-loop rename-over-non-empty-dir must error");
372	        assert_eq!(
373	            fs::read(&first_target).unwrap(),
374	            b"BEFORE",
375	            "rollback restored the first target's ORIGINAL bytes (not the staged AFTER)"
376	        );
377	        // The second swap never committed; its target file is untouched.
378	        assert_eq!(
379	            fs::read(&second_target).unwrap(),
380	            b"second-original",
381	            "the failing swap's target is left as it was — never half-applied"
382	        );
383	        // `cleanup_new` removed every staged `.new` file (the impl litter). We
384	        // assert on the concrete `.new` slots rather than the broad walk, since
385	        // the test fixture deliberately pre-seeds a non-empty `second.old` dir
386	        // (NOT impl litter) to force the failure.
387	        assert!(!new_path(&first_target).exists(), "first.new cleaned up");
388	        assert!(!new_path(&second_target).exists(), "second.new cleaned up");
389	        // And no stray `first.old` (swap-1's displaced original was restored by
390	        // rollback, not left behind).
391	        assert!(!old_path(&first_target).exists(), "first.old restored, not stranded");
392	    }
393	}
394

//! W3b — content-hash-gated atomic per-file swap for a live adapter update
//! (ADR-0025, REQ-ADAPTER-LIVE-UPDATE).
//!
//! The legacy `spt adapter update` path does `remove_dir_all(install_dir)` then a
//! blanket extract — which fails on Windows with *"Access is denied (os error 5)"*
//! the moment a single install-dir binary is still running (the field bug). This
//! module replaces that with a **content-diff swap**: extract the staged archive
//! to a temp tree, replace in the install dir **only** the files whose content
//! hash differs (so identical files — and their still-running binaries — are
//! never touched), and commit via **stage-all-`.new`-then-one-tight-rename-loop**
//! so a mid-swap failure never strands the install half-new (doyle W3 gate,
//! binding partial-failure refinement).
//!
//! CRC here is plain content hashing (reuse [`crate::release::sha256_hex`], no new
//! dep) — "replace only what changed", not cryptographic integrity (that is the
//! archive signature, ADR-0024 / REQ-UPD-9).

use crate::release::sha256_hex;
use std::path::{Path, PathBuf};

/// One planned file replacement discovered by [`plan_crc_swap`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlannedSwap {
    /// Path relative to the tree root (for diagnostics / determinism).
    pub rel: PathBuf,
    /// The staged source file (`<staging>/<rel>`).
    pub staged: PathBuf,
    /// The install-dir target (`<install_dir>/<rel>`).
    pub target: PathBuf,
}

/// Walk `staging` and return the files whose content differs from their
/// `install_dir` counterpart — or that are absent in `install_dir`. Files whose
/// content is byte-identical are skipped, so a still-running unchanged binary is
/// never disturbed. Read-only: does not mutate either tree. The returned plan is
/// sorted by `rel` for deterministic apply + testing.
// [impl->REQ-ADAPTER-LIVE-UPDATE]
pub fn plan_crc_swap(staging: &Path, install_dir: &Path) -> std::io::Result<Vec<PlannedSwap>> {
    let mut plan = Vec::new();
    collect_diffs(staging, staging, install_dir, &mut plan)?;
    plan.sort_by(|a, b| a.rel.cmp(&b.rel));
    Ok(plan)
}

/// Recurse `dir` (a subtree of `root`), comparing each file against its
/// `install_dir`-relative counterpart.
fn collect_diffs(
    root: &Path,
    dir: &Path,
    install_dir: &Path,
    plan: &mut Vec<PlannedSwap>,
) -> std::io::Result<()> {
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        if path.is_dir() {
            collect_diffs(root, &path, install_dir, plan)?;
            continue;
        }
        let rel = path
            .strip_prefix(root)
            .expect("path is under root")
            .to_path_buf();
        let target = install_dir.join(&rel);
        let differs = match std::fs::read(&target) {
            Ok(existing) => sha256_hex(&std::fs::read(&path)?) != sha256_hex(&existing),
            // Absent (or unreadable) target ⇒ a new/changed file to place.
            Err(_) => true,
        };
        if differs {
            plan.push(PlannedSwap {
                rel,
                staged: path,
                target,
            });
        }
    }
    Ok(())
}

/// Apply a [`plan_crc_swap`] result with a never-strand commit protocol:
///
/// 1. **Stage** — copy every change to `<target>.new` (creating parent dirs).
/// 2. **Commit** — one tight loop: for each change, move the existing original to
///    `<target>.old` (if present) then rename `<target>.new` → `<target>`.
/// 3. **Cleanup** — delete the `.old` originals once the whole loop committed.
///
/// On ANY failure in the commit loop, the already-committed renames are **rolled
/// back** (restore each `<target>.old` over `<target>`) and the error is
/// returned — so the install dir is never left a half-new mix that a restarted
/// new-expecting child would choke on. The caller (W3d) restarts the OLD resident
/// child and reports the update failed.
// [impl->REQ-ADAPTER-LIVE-UPDATE]
pub fn apply_crc_swap(plan: &[PlannedSwap]) -> std::io::Result<()> {
    // Phase 1 — stage every new file beside its target.
    for swap in plan {
        if let Some(parent) = swap.target.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::copy(&swap.staged, new_path(&swap.target))?;
    }

    // Phase 2 — tight commit loop, tracking what to roll back on failure.
    // `committed` holds (target, had_original) for each rename that landed.
    let mut committed: Vec<(&Path, bool)> = Vec::new();
    for swap in plan {
        let target = swap.target.as_path();
        let had_original = target.exists();
        if had_original {
            if let Err(e) = std::fs::rename(target, old_path(target)) {
                rollback(&committed);
                cleanup_new(plan);
                return Err(e);
            }
        }
        if let Err(e) = std::fs::rename(new_path(target), target) {
            // This one didn't land: undo its own `.old` move, then prior commits.
            if had_original {
                let _ = std::fs::rename(old_path(target), target);
            }
            rollback(&committed);
            cleanup_new(plan);
            return Err(e);
        }
        committed.push((target, had_original));
    }

    // Phase 3 — whole loop committed: drop the saved originals.
    for (target, had_original) in &committed {
        if *had_original {
            let _ = std::fs::remove_file(old_path(target));
        }
    }
    Ok(())
}

/// `<target>.new` — the staged replacement, in the target's own dir (same fs ⇒
/// the rename is atomic).
fn new_path(target: &Path) -> PathBuf {
    sibling(target, "new")
}

/// `<target>.old` — the displaced original, kept until the whole loop commits so
/// a mid-loop failure can restore it.
fn old_path(target: &Path) -> PathBuf {
    sibling(target, "old")
}

fn sibling(target: &Path, suffix: &str) -> PathBuf {
    let mut name = target.file_name().unwrap_or_default().to_os_string();
    name.push(".");
    name.push(suffix);
    target.with_file_name(name)
}

/// Restore each already-committed rename: move `<target>` back to `<target>.new`
/// and the saved `<target>.old` back over `<target>`. Best-effort (a rollback is
/// already an error path; nothing more we can do but try).
fn rollback(committed: &[(&Path, bool)]) {
    for (target, had_original) in committed.iter().rev() {
        let _ = std::fs::rename(target, new_path(target));
        if *had_original {
            let _ = std::fs::rename(old_path(target), target);
        }
    }
}

/// Remove any leftover `<target>.new` staging files (failure cleanup).
fn cleanup_new(plan: &[PlannedSwap]) {
    for swap in plan {
        let _ = std::fs::remove_file(new_path(&swap.target));
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::Path;
    use tempfile::tempdir;

    /// Write `bytes` to `path`, creating parent dirs. Test helper.
    fn write(path: &Path, bytes: &[u8]) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, bytes).unwrap();
    }

    /// The `rel` paths a plan covers, sorted (the plan already sorts; collect for
    /// readable assertions).
    fn rels(plan: &[PlannedSwap]) -> Vec<PathBuf> {
        plan.iter().map(|s| s.rel.clone()).collect()
    }

    /// True iff `dir` (recursively) contains any leftover `.new` / `.old` swap
    /// litter — the never-strand cleanup invariant.
    fn has_swap_litter(dir: &Path) -> bool {
        let mut found = false;
        fn walk(dir: &Path, found: &mut bool) {
            for entry in fs::read_dir(dir).unwrap() {
                let path = entry.unwrap().path();
                if path.is_dir() {
                    walk(&path, found);
                    continue;
                }
                if let Some(ext) = path.extension() {
                    if ext == "new" || ext == "old" {
                        *found = true;
                    }
                }
            }
        }
        walk(dir, &mut found);
        found
    }

    /// `plan_crc_swap` selects ONLY the files whose content differs (fileB) or
    /// that are absent from the install dir (fileC) — a byte-identical file
    /// (fileA) is skipped so its still-running binary is never disturbed — and
    /// the result is sorted by `rel`.
    // [unit->REQ-ADAPTER-LIVE-UPDATE]
    #[test]
    fn plan_selects_only_changed() {
        let staging = tempdir().unwrap();
        let install = tempdir().unwrap();
        let (s, i) = (staging.path(), install.path());

        // fileA: identical in both → skipped.
        write(&s.join("fileA"), b"same-bytes");
        write(&i.join("fileA"), b"same-bytes");
        // fileB: present in both but DIFFERENT bytes → selected.
        write(&s.join("fileB"), b"new-content");
        write(&i.join("fileB"), b"old-content");
        // fileC: present only in staging (absent in install) → selected.
        write(&s.join("fileC"), b"brand-new");

        let plan = plan_crc_swap(s, i).unwrap();

        assert_eq!(
            rels(&plan),
            vec![PathBuf::from("fileB"), PathBuf::from("fileC")],
            "only the changed (B) and absent (C) files, sorted; identical A skipped"
        );
        // The staged/target paths point where they should.
        let b = plan.iter().find(|p| p.rel == Path::new("fileB")).unwrap();
        assert_eq!(b.staged, s.join("fileB"));
        assert_eq!(b.target, i.join("fileB"));
    }

    /// The walk recurses into nested directories and reports the correct nested
    /// `rel` (so a changed file deep in the tree is found, not just top-level).
    // [unit->REQ-ADAPTER-LIVE-UPDATE]
    #[test]
    fn plan_recurses_nested_dirs() {
        let staging = tempdir().unwrap();
        let install = tempdir().unwrap();
        let (s, i) = (staging.path(), install.path());

        // A changed file three levels deep; the install counterpart differs.
        write(&s.join("sub").join("dir").join("x"), b"changed");
        write(&i.join("sub").join("dir").join("x"), b"original");

        let plan = plan_crc_swap(s, i).unwrap();

        let want_rel: PathBuf = ["sub", "dir", "x"].iter().collect();
        assert_eq!(rels(&plan), vec![want_rel.clone()], "nested changed file found");
        let only = &plan[0];
        assert_eq!(only.staged, s.join(&want_rel));
        assert_eq!(only.target, i.join(&want_rel));
    }

    /// `apply_crc_swap` replaces the changed file's bytes, materializes a brand-new
    /// file (including a not-yet-existing nested parent dir), leaves a byte-identical
    /// file untouched, and leaves NO `.new`/`.old` litter behind on success.
    // [unit->REQ-ADAPTER-LIVE-UPDATE]
    #[test]
    fn apply_swaps_changed_creates_new_untouched_identical() {
        let staging = tempdir().unwrap();
        let install = tempdir().unwrap();
        let (s, i) = (staging.path(), install.path());

        // changed: differs → will be swapped to staged bytes.
        write(&s.join("changed"), b"NEW");
        write(&i.join("changed"), b"OLD");
        // identical: same bytes → must remain byte-unchanged and not be in the plan.
        write(&s.join("identical"), b"STABLE");
        write(&i.join("identical"), b"STABLE");
        // fresh under a nested parent that does NOT yet exist in install → created.
        let fresh_rel: PathBuf = ["newdir", "fresh"].iter().collect();
        write(&s.join(&fresh_rel), b"FRESH");
        assert!(!i.join("newdir").exists(), "precondition: nested parent absent");

        let plan = plan_crc_swap(s, i).unwrap();
        assert_eq!(
            rels(&plan),
            vec![PathBuf::from("changed"), fresh_rel.clone()],
            "plan covers changed + fresh, not identical"
        );

        apply_crc_swap(&plan).unwrap();

        assert_eq!(fs::read(i.join("changed")).unwrap(), b"NEW", "changed got new bytes");
        assert_eq!(
            fs::read(i.join(&fresh_rel)).unwrap(),
            b"FRESH",
            "fresh file materialized with its nested parent dir created"
        );
        assert_eq!(
            fs::read(i.join("identical")).unwrap(),
            b"STABLE",
            "identical file byte-unchanged (never touched)"
        );
        assert!(
            !has_swap_litter(i),
            "no .new/.old staging files remain after a successful apply"
        );
    }

    /// Never-strand invariant: a mid-loop rename failure rolls back the renames
    /// that already committed. We hand-craft a two-swap plan where the FIRST
    /// target is a normal file (it will commit) and the SECOND swap is forced to
    /// fail by pre-occupying its `<target>.old` slot with an existing NON-EMPTY
    /// DIRECTORY. The impl moves the existing target out to `<target>.old` before
    /// renaming `<target>.new` in; `rename(<second_target>, <second>.old)` over a
    /// non-empty dir errors on both Windows (PermissionDenied / os error 5) and
    /// Linux (ENOTEMPTY), so the failure lands AFTER swap 1 has committed —
    /// exercising the rollback. After the Err, the first target must hold its
    /// ORIGINAL bytes (rollback restored it, NOT the staged bytes) and no
    /// `.new`/`.old` litter may remain.
    // [unit->REQ-ADAPTER-LIVE-UPDATE]
    #[test]
    fn apply_rollback_never_strands() {
        let staging = tempdir().unwrap();
        let install = tempdir().unwrap();
        let (s, i) = (staging.path(), install.path());

        // Swap 1: a normal file that WILL commit (staged "AFTER" over original "BEFORE").
        let first_staged = s.join("first.src");
        write(&first_staged, b"AFTER");
        let first_target = i.join("first");
        write(&first_target, b"BEFORE");

        // Swap 2: an ordinary existing target file, BUT its `.old` displacement
        // slot is pre-occupied by a non-empty directory so the impl's
        // `rename(second, second.old)` (the "move original aside" step) fails —
        // landing the error mid-loop, after swap 1 committed.
        let second_staged = s.join("second.src");
        write(&second_staged, b"second-bytes");
        let second_target = i.join("second");
        write(&second_target, b"second-original");
        let second_old = i.join("second.old");
        write(&second_old.join("occupant"), b"makes-it-non-empty");
        assert!(second_old.is_dir(), "precondition: <second>.old is a non-empty dir");

        let plan = vec![
            PlannedSwap {
                rel: PathBuf::from("first"),
                staged: first_staged,
                target: first_target.clone(),
            },
            PlannedSwap {
                rel: PathBuf::from("second"),
                staged: second_staged,
                target: second_target.clone(),
            },
        ];

        let result = apply_crc_swap(&plan);

        assert!(result.is_err(), "mid-loop rename-over-non-empty-dir must error");
        assert_eq!(
            fs::read(&first_target).unwrap(),
            b"BEFORE",
            "rollback restored the first target's ORIGINAL bytes (not the staged AFTER)"
        );
        // The second swap never committed; its target file is untouched.
        assert_eq!(
            fs::read(&second_target).unwrap(),
            b"second-original",
            "the failing swap's target is left as it was — never half-applied"
        );
        // `cleanup_new` removed every staged `.new` file (the impl litter). We
        // assert on the concrete `.new` slots rather than the broad walk, since
        // the test fixture deliberately pre-seeds a non-empty `second.old` dir
        // (NOT impl litter) to force the failure.
        assert!(!new_path(&first_target).exists(), "first.new cleaned up");
        assert!(!new_path(&second_target).exists(), "second.new cleaned up");
        // And no stray `first.old` (swap-1's displaced original was restored by
        // rollback, not left behind).
        assert!(!old_path(&first_target).exists(), "first.old restored, not stranded");
    }
}


`crc_swap.rs` is well-engineered. Tracing the safety-critical `apply_crc_swap`:
- **Windows-safe refinement** — todlando moves `target→.old` FIRST (vacating the dest) THEN `.new→target`. This is *more correct than my brief*: `std::fs::rename` over an existing file FAILS on Windows; my naive "rename .new over target" would've broken on the exact platform we're fixing. Good catch by the executor.
- **Rollback traced**: both failure points (move-aside fail, .new→target fail) restore this-swap + all prior committed in LIFO order, cleanup_new removes litter. Never-strand holds. ✓
- Plan/apply split clean (plan read-only/testable). 4 non-vacuous fs tests + selector.

Now broker queries + msg.rs + the SpawnReq additive fields + traceable:


git diff fe86990..b49d4a0 -- crates/spt-daemon/src/broker.rs crates/spt-daemon/src/msg.rs crates/spt-daemon/src/harnesshost.rs traceable-reqs.toml crates/spt-daemon/src/lib.rs | cat

Read broker queries, msg, harnesshost, traceable diff


diff --git a/crates/spt-daemon/src/broker.rs b/crates/spt-daemon/src/broker.rs
index c3db0ce..29dd9e4 100644
--- a/crates/spt-daemon/src/broker.rs
+++ b/crates/spt-daemon/src/broker.rs
@@ -870,6 +870,13 @@ struct HostedSession {
     /// The translation-binary attachment (Layer C), or `None` for a session with
     /// no `[message-idle-translation-binary]` — then inbound delivery raw-injects.
     translation: Option<Arc<Translation>>,
+    /// The adapter name owning this session (W3a, ADR-0025) — empty for a
+    /// legacy/test/shell spawn. A live adapter update finds every live session
+    /// whose `adapter` matches to cycle its resident children.
+    adapter: String,
+    /// The adapter's install dir for this session (W3a) — `None` if unknown. The
+    /// apply target for a live update's CRC swap.
+    install_dir: Option<std::path::PathBuf>,
 }

 /// One record queued to a session's dedicated PTY input-writer thread — the raw
@@ -1131,6 +1138,27 @@ pub struct Broker {
     brain_restart: std::sync::OnceLock<Arc<crate::brainproc::BrainRestart>>,
 }

+/// The distinct `(endpoint, install_dir)` pairs whose session runs `adapter`,
+/// from per-session `(endpoint, adapter, install_dir)` rows. Sessions with no
+/// recorded install dir are dropped (a live update needs a target). Pure (no
+/// lock, no I/O) so the live-update endpoint-selection is unit-testable without
+/// constructing real `HostedSession`s. (W3a, ADR-0025)
+// [impl->REQ-ADAPTER-LIVE-UPDATE]
+fn select_endpoints_running_adapter(
+    rows: impl IntoIterator<Item = (String, String, Option<std::path::PathBuf>)>,
+    adapter: &str,
+) -> Vec<(String, std::path::PathBuf)> {
+    let mut out: Vec<(String, std::path::PathBuf)> = rows
+        .into_iter()
+        .filter_map(|(endpoint, adp, dir)| {
+            (adp == adapter).then_some(dir.map(|d| (endpoint, d)))?
+        })
+        .collect();
+    out.sort();
+    out.dedup();
+    out
+}
+
 impl Broker {
     /// Bind the broker's listening endpoint at the namespaced `name`, with the
     /// effect journal at the default path (`<spt_home>/daemon-effects.log`).
@@ -1246,6 +1274,36 @@ impl Broker {
         self.sessions.lock().unwrap().len()
     }

+    /// The live **resident adapter children** (translation binaries) for
+    /// `endpoint` — the processes a live adapter update (W3d) stops and restarts
+    /// so the install dir's locked binaries free up. The broker `sessions` map IS
+    /// the per-endpoint resident registry (W3a, ADR-0025): each `HostedSession`
+    /// already carries its `endpoint` + lifecycle-managed `translation` child.
+    // [impl->REQ-ADAPTER-LIVE-UPDATE]
+    #[allow(dead_code)] // consumed by W3d `dispatch_adapter_apply` (next sub-commit)
+    fn resident_translations_for_endpoint(&self, endpoint: &str) -> Vec<Arc<Translation>> {
+        let map = self.sessions.lock().unwrap();
+        map.values()
+            .filter(|s| s.endpoint == endpoint)
+            .filter_map(|s| s.translation.clone())
+            .collect()
+    }
+
+    /// The live endpoints running `adapter`, each with its install dir — the apply
+    /// targets for a live adapter update (W3d decides daemon-apply vs CLI-direct
+    /// from whether this is empty). Distinct, sorted. (W3a, ADR-0025)
+    // [impl->REQ-ADAPTER-LIVE-UPDATE]
+    #[allow(dead_code)] // consumed by W3d `dispatch_adapter_apply` (next sub-commit)
+    fn endpoints_running_adapter(&self, adapter: &str) -> Vec<(String, std::path::PathBuf)> {
+        let rows: Vec<_> = {
+            let map = self.sessions.lock().unwrap();
+            map.values()
+                .map(|s| (s.endpoint.clone(), s.adapter.clone(), s.install_dir.clone()))
+                .collect()
+        };
+        select_endpoints_running_adapter(rows, adapter)
+    }
+
     /// The child pid of a hosted session, if present (tests assert it is stable
     /// across brain restarts).
     pub fn session_pid(&self, id: u64) -> Option<u32> {
@@ -1588,6 +1646,8 @@ impl Broker {
                 log,
                 endpoint: req.endpoint.clone(),
                 translation,
+                adapter: req.adapter.clone(),
+                install_dir: req.install_dir.clone().map(std::path::PathBuf::from),
             },
         );

@@ -2930,4 +2990,45 @@ mod tests {
         drop(send);
         drop(log);
     }
+
+    /// W3a endpoint selection (REQ-ADAPTER-LIVE-UPDATE, ADR-0025): from the
+    /// `(endpoint, adapter, install_dir)` session rows, `select_endpoints_running_adapter`
+    /// returns the DISTINCT, SORTED `(endpoint, install_dir)` pairs whose adapter
+    /// matches AND whose install_dir is `Some` — the set of live install trees a
+    /// CRC swap must be applied to. Two sessions on the same endpoint+dir collapse
+    /// to one (dedup); a different endpoint is its own entry; a non-matching adapter
+    /// is excluded; a matching adapter with NO install_dir (`None`) is excluded
+    /// (nothing to swap). This is the input gate to [`crate::crc_swap`].
+    // [unit->REQ-ADAPTER-LIVE-UPDATE]
+    #[test]
+    fn select_endpoints_running_adapter_filters_dedups_sorts() {
+        use std::path::PathBuf;
+        let dir_a = PathBuf::from("/install/ep-a");
+        let dir_b = PathBuf::from("/install/ep-b");
+        // Rows deliberately unsorted + with a duplicate so the assert proves
+        // sort+dedup, not just incidental ordering.
+        let rows = vec![
+            // ep-b matches (sorts AFTER ep-a despite appearing first).
+            ("ep-b".to_string(), "claude-spt".to_string(), Some(dir_b.clone())),
+            // ep-a, two sessions, SAME endpoint+dir → must dedup to one entry.
+            ("ep-a".to_string(), "claude-spt".to_string(), Some(dir_a.clone())),
+            ("ep-a".to_string(), "claude-spt".to_string(), Some(dir_a.clone())),
+            // Non-matching adapter → excluded entirely.
+            ("ep-c".to_string(), "codex-spt".to_string(), Some(PathBuf::from("/install/ep-c"))),
+            // Matching adapter but NO install_dir → excluded (nothing to swap).
+            ("ep-d".to_string(), "claude-spt".to_string(), None),
+        ];
+
+        let got = select_endpoints_running_adapter(rows, "claude-spt");
+
+        assert_eq!(
+            got,
+            vec![
+                ("ep-a".to_string(), dir_a),
+                ("ep-b".to_string(), dir_b),
+            ],
+            "distinct sorted (endpoint, install_dir) for the matching adapter only; \
+             dup ep-a collapsed, codex-spt + the None-dir ep-d both excluded"
+        );
+    }
 }
diff --git a/crates/spt-daemon/src/harnesshost.rs b/crates/spt-daemon/src/harnesshost.rs
index 8f20d55..e8bd866 100644
--- a/crates/spt-daemon/src/harnesshost.rs
+++ b/crates/spt-daemon/src/harnesshost.rs
@@ -181,6 +181,7 @@ pub struct PreparedSpawn {
 // [impl->REQ-HOST-RUN-1]
 // [impl->REQ-HOST-RUN-2]
 // [impl->REQ-SESSION-RESUME-TEMPLATE]
+#[allow(clippy::too_many_arguments)] // bringup inputs (W3a added install_dir); a struct would obscure the call
 pub fn launch_harness_brokered_in(
     broker_name: &str,
     endpoint_id: &str,
@@ -189,6 +190,9 @@ pub fn launch_harness_brokered_in(
     manifest: &Manifest,
     cwd: Option<&str>,
     is_resume: bool,
+    // The adapter's install dir (W3a, ADR-0025) — recorded on the broker session
+    // so a live adapter update can target this endpoint's resident children.
+    install_dir: Option<&str>,
 ) -> Result<HarnessSpawned, String> {
     let PreparedSpawn {
         tokens,
@@ -208,6 +212,8 @@ pub fn launch_harness_brokered_in(
             cwd: cwd.map(str::to_string),
             env,
             translation_binary,
+            adapter: adapter_name.to_string(),
+            install_dir: install_dir.map(str::to_string),
         })
         .map_err(|e| format!("broker spawn: {e}"))?;
     Ok(HarnessSpawned { session_id, pid })
diff --git a/crates/spt-daemon/src/lib.rs b/crates/spt-daemon/src/lib.rs
index 07c8bdd..f5588a6 100644
--- a/crates/spt-daemon/src/lib.rs
+++ b/crates/spt-daemon/src/lib.rs
@@ -113,6 +113,7 @@ pub mod broker;
 pub mod codec;
 pub mod config;
 pub mod consent;
+pub mod crc_swap;
 pub mod daemon;
 pub mod deadline;
 pub mod deelevate;
diff --git a/crates/spt-daemon/src/msg.rs b/crates/spt-daemon/src/msg.rs
index f027b3b..20813e2 100644
--- a/crates/spt-daemon/src/msg.rs
+++ b/crates/spt-daemon/src/msg.rs
@@ -219,6 +219,20 @@ pub struct SpawnReq {
     /// omits it and the session simply runs without a translation binary.
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub translation_binary: Option<String>,
+    /// The adapter name owning this session (W3a, ADR-0025). The broker records
+    /// it on the `HostedSession` so a live adapter update can find every live
+    /// session running adapter X to cycle its resident children. Empty = unknown
+    /// (a legacy/test/shell spawn not tied to a registered adapter). Additive +
+    /// serde-defaulted: an N-1 brain/broker omits it.
+    // [impl->REQ-ADAPTER-LIVE-UPDATE]
+    #[serde(default, skip_serializing_if = "String::is_empty")]
+    pub adapter: String,
+    /// The adapter's install dir for this session (W3a, ADR-0025) — where its
+    /// binaries live and a live update swaps files. `None` = unknown (legacy/test).
+    /// Additive + serde-defaulted.
+    // [impl->REQ-ADAPTER-LIVE-UPDATE]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub install_dir: Option<String>,
 }

 /// `spawned` payload — the broker-minted session id + the child's OS pid.
diff --git a/traceable-reqs.toml b/traceable-reqs.toml
index 31542f8..c0bd6cd 100644
--- a/traceable-reqs.toml
+++ b/traceable-reqs.toml
@@ -1341,7 +1341,7 @@ required_stages = ["doc", "impl", "unit", "int"]  # doc = ADR-0024 + CONTEXT "ad
 [[requirements]]
 id = "REQ-ADAPTER-LIVE-UPDATE"
 title = "An adapter update is live and daemon-coordinated (the adapter analog of brain self-update, ADR-0004): for an endpoint with a running RESIDENT adapter binary (today the `[message-idle-translation-binary]`), the CLI keeps fetch+verify and hands the APPLY to the daemon over IPC, which per affected endpoint (1) STOPS the resident binary -> releases the OS file lock (fixes the Windows 'Access denied (os error 5)' overwrite failure), (2) swaps on disk ONLY files whose CRC differs from the staged archive (unchanged files + their still-running binaries untouched), (3) RE-CLONES the new on-disk manifest into the running `BrainLifecycle` (the in-memory manifest is cached at bringup and otherwise goes stale -> binaries+manifest back on the same page), (4) RESTARTS the resident binary from the new files. An endpoint NOT running -> CLI swaps directly (no lock, no cache). Only the resident class is cycled; ephemeral adapter binaries (Psyche loop, `[digest]` extractor, `[session.*]` runners, hooks) self-heal on next spawn and are excluded. The daemon keeps a per-endpoint registry of resident adapter children. (ADR-0025, v0.13.2)"
-required_stages = ["doc"]  # doc = ADR-0025 + CONTEXT "resident adapter binary". impl/unit/int activate at the v0.13.2 build.
+required_stages = ["doc", "impl", "unit"]  # doc=ADR-0025+CONTEXT. impl/unit live from W3a+W3b (registry + CRC swap). `int` added at W3e (the no-mocks keystone), kept off until then so each W3 sub-commit stays CI-green.
 [[requirements]]
 id = "REQ-ADAPTER-UPDATE-MESSAGE"
 title = "An adapter manifest may declare `[update].message` — a plain (multi-line) human notice surfaced to stdout, markdown-rendered (the v0.13.0 helpfmt prose path), ONLY when `spt adapter update` actually APPLIES an update (version changed), not on a no-op. Read from the newly-installed manifest; avenue-agnostic (gh_release/delegated/file_pull). No `{key}` substitution. Use: an adapter telling the operator a post-update action, e.g. spt-claude-code's \"run `/reload-plugins` in any ongoing sessions\". (v0.13.2)"


W1 — Multi-platform .spt packaging (ADR-0024)

REQ-ADAPTER-MULTIPLATFORM-SPT. Fat archive: shared manifest.toml+strings/ at root, binaries under per-target-triple subdirs. Extract shared root + only current_platform() triple, flatten into install_dir. Mode detection on known-triple subdir; legacy flat = back-compat. min_spt_core_version>=0.13.2 forward gate. --asset optional default adapter.spt. Gate: unit+int(real fixture)+doc. Executor builds, doyle gates.

W2 — gh transport for private repos

REQ-ADAPTER-GH-TRANSPORT. transport = https|gh|auto (default auto) on gh_release avenue + add --release. gh shells gh release download/gh api (private path; honors OAuth+GH_TOKEN, no token custody). --gh/--https force. auto = prefer gh if installed+authed else https. Files: fetch path (http_get_bytes, gh_latest_release_version, stage_gh_release_archive), Update struct transport field. Gate: unit (decision+command shape); int HITL/skip. doc=manifest.md+CONTEXT.

W3 — Live daemon-coordinated update + CRC swap (ADR-0025) KEYSTONE

REQ-ADAPTER-LIVE-UPDATE (ADR-0025). W3a resident-children registry; W3b CRC-gated swap (std hash, no new dep); W3c manifest refresh into running BrainLifecycle; W3d daemon-apply IPC (stop resident→swap→refresh→restart; not-running→CLI direct); W3e int keystone NO mocks (real spt-hosted endpoint + running translation binary; Windows locked-binary regression). Bounded stop/restart (join_bounded, never 240s backstop). Brain never restarts. Partial-failure safe.

PSYCHE-LOCK RESOLUTION (doyle design, absorbs task#7 / perri F-010×F-015 — ADR-0025's resident/ephemeral split assumed psyche=in-daemon but it's a separate install-dir binary that locks the target):
- PRIMARY (c): hosted psyche + any daemon-spawned long-lived install-dir binary runs from a PER-ENDPOINT PRIVATE COPY, never the shared install-dir binary → removes the lock vector entirely; consistent with ephemeral-self-heals-on-next-spawn (next spawn copies updated binary).
- SUPPORTING (a): close F-010 orphan reap on endpoint-stop/agent-death (extend v0.12.0 ID-specific scoped-reap to the stop path) — fixes the lingering-orphan leak independent of updates. Check existing psyche-reap REQ before minting new.
- BACKSTOP (b): extract-to-temp + per-file atomic swap so one busy file never fails the whole update (composes with W3b). NOTE (b) alone can't replace a running locked .exe on Windows — (c)/(a) are primary.
W3 int absorbs perri's 4-step repro (task#7) as the NO-mocks acceptance. May need ADR-0025 amendment for the separate-process-psyche vector + possibly a new REQ for the stop-path orphan reap. Author full W3 design brief after W2 gates.

W4 — update-message

REQ-ADAPTER-UPDATE-MESSAGE. [update].message plain multi-line, markdown-rendered (helpfmt prose path), ONLY when update actually applied (version changed), not no-op. Read from newly-installed manifest, avenue-agnostic. Files: Update struct message:Option<String>; cmd_adapter_update after successful apply→helpfmt::render+print. Gate: unit (rendered on applied, NOT no-op). doc=manifest.md+CONTEXT. No int.

W5 — *-proof --dir/--manifest override (F-011)

REQ-ADAPTER-PROOF-DIR-OVERRIDE. --dir <path>/--manifest <file> on digest-proof+translate-proof → proof DEV binary vs on-disk manifest+dir without full extracted install. Files: cmd_adapter_digest_proof + cmd_adapter_translate_proof accept --dir/--manifest, resolve from there not registry::resolve_option. Gate: unit (override resolves dev manifest+dir); int (mirror proof ints with --dir). doc=proof --help+patterns. May ship v0.13.2 or separate batch.

Release — cut v0.13.2 (deployah role)

After all waves gate-pass + CI green BOTH runners: verify release counter against spt-releases metadata at cut (v0.13.1 lesson, expect 29). Author version bump (workspace.package.version 0.13.1→0.13.2 + CHANGELOG section=release body + Cargo.lock first-party lines). Push bump→CI green both→tag→sign+publish. Coordinate publish ping to perri (drops claude-spt F-014 per-OS stopgap; F-011 closed). W3 int + Windows-lock regression = ship-blockers.

W3 design input — adapter psyche binary locks shared install-dir (perri F-010×F-015)

perri live repro 2026-06-22 (spt 0.13.1, claude-spt 0.6.0, Win): claude-spt-psyche.exe (pid 181272, --id wall-a-psyche) ran DETACHED from shared install-dir, parent (152200) DEAD = orphan (~4h), held binary lock → adapter update/add --release failed (tar exit Some(1)). endpoint stop wall-a set alive=false but did NOT reap the detached psyche.

ROOT MECHANICS: psyche is detached per manifest [session.psyche_init] detach=true (Stdio::null, unsupervised), hosted by daemon livehost off the live_agent online status. It runs from the SHARED install dir (lock surface = manifest.toml + claude-spt-digest.exe + claude-spt-psyche.exe + cc-spt-idle-translate.exe + strings/).

LOAD-BEARING ON W3: ADR-0025 resident/ephemeral split assumed psyche=daemon-hosted in-process (ADR-0004), EXCLUDED from cycling. But it's a SEPARATE install-dir binary → unsupervised locker W3's resident-only stop/restart won't release. Fixes compose: (c) psyche-from-own-copy not shared dir = cleanest structural kill, removes vector independent of reap timing; (a) orphan reap on stop/agent-death = F-010 root close; (b) extract-to-temp+atomic-swap = F-015 backstop (one locked file ≠ whole-update fail).

W3 INT FIXTURE (perri spec, deterministic, NO mocks): 1. bring up claude-spt live agent wall-X → daemon spawns detached psyche from install dir; 2. kill psyche PARENT (livehost/agent) → parent-dead orphan; 3. spt endpoint stop wall-X → alive=false BUT psyche survives (reap gap); 4. spt adapter update claude-spt → tar overwrites locked psyche.exe → exit 1 → whole update fails. PASS post-W3: step 3 reaps orphan AND/OR psyche never ran from shared target AND/OR locked-file extract no longer fails whole update. perri re-runs this exact repro as the ship acceptance test on doyle ping.


All clean:
- **broker.rs**: `HostedSession +adapter +install_dir`; `select_endpoints_running_adapter` pure selector (the `(adp==adapter).then_some(dir.map(...))?` correctly drops non-match AND matching-but-None-dir); queries `#[allow(dead_code)]` until W3d (proper for foundations); wired at session insert. Selector test non-vacuous (unsorted+dup+non-match+None).
- **msg.rs / harnesshost.rs**: `SpawnReq` additive serde-default N-1-safe, threaded through. 8-arg `launch_harness_brokered_in` with `#[allow]` + justification — accept (a params-struct refactor mid-keystone churns the hardened bringup callsite; future cleanup, not now).
- **traceable**: `REQ-ADAPTER-LIVE-UPDATE → [doc,impl,unit]`, int deferred to W3e — sound (keeps sub-commits CI-green; int needs the no-mocks keystone). I'll hold this to int-activated+satisfied before release.

Run my gates (filter to lib unit tests — avoids the lifecycle tests that deadlock on this box):


cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -2; echo "===CLIPPY EXIT=${PIPESTATUS[0]}==="
echo "===crc_swap + selector units==="; cargo test -p spt-daemon --lib crc_swap 2>&1 | grep -E "test crc_swap|test result"; cargo test -p spt-daemon --lib select_endpoints 2>&1 | grep -E "test .*select|test result"
echo "===traceable==="; "C:/Users/decid/AppData/Local/Programs/traceable-reqs/traceable-reqs" check >/tmp/t3.txt 2>&1; echo "EXIT=$?"; grep "REQ-ADAPTER-LIVE-UPDATE" /tmp/t3.txt | head -1

Clippy + crc_swap/selector units + traceable


    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.59s
===CLIPPY EXIT=0===
===crc_swap + selector units===
test crc_swap::tests::plan_selects_only_changed ... ok
test crc_swap::tests::apply_rollback_never_strands ... ok
test crc_swap::tests::plan_recurses_nested_dirs ... ok
test crc_swap::tests::apply_swaps_changed_creates_new_untouched_identical ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 358 filtered out; finished in 0.07s
test broker::tests::select_endpoints_running_adapter_filters_dedups_sorts ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 361 filtered out; finished in 0.00s
===traceable===
EXIT=0
  [OK] REQ-ADAPTER-LIVE-UPDATE  required: [doc, impl, unit]  stages: +doc +impl +unit -int


**W3a+W3b = GATE-PASS** @b49d4a0. clippy EXIT 0, 4 crc_swap + selector units pass, traceable EXIT 0 (`+doc+impl+unit -int`). Report + advance to W3c:


"$OWL" send todlando doyle <<'EOF'
W3a+W3b GATE-PASS @b49d4a0. Read the full diff + re-ran gates myself: clippy --workspace --all-targets -D warnings EXIT 0, 4 crc_swap units + the selector unit PASS, traceable EXIT 0 (REQ-ADAPTER-LIVE-UPDATE +doc+impl+unit, int correctly deferred to W3e).

Specifically verified:
- crc_swap apply protocol: your move-target→.old-FIRST-then-.new→target is the correct WINDOWS-SAFE refinement of my brief — std::fs::rename over an existing file FAILS on Windows, so my naive "rename .new over target" would've broken on the exact platform we're fixing. Good catch. Rollback traced clean at both failure points (LIFO restore + cleanup_new, never-strand holds).
- select_endpoints_running_adapter: the (adp==adapter).then_some(dir.map(..))? correctly drops both non-match AND matching-but-None-dir; sort+dedup proven by the unsorted+dup+None test.
- SpawnReq{adapter,install_dir} additive serde-default N-1-safe; broker queries #[allow(dead_code)] appropriate (W3d consumes). HostedSession wired at insert.
- 8-arg launch_harness_brokered_in: #[allow(too_many_arguments)] ACCEPTED — a params-struct refactor mid-keystone churns the hardened bringup callsite; leave it (note it as a future cleanup, don't do it now).

PROCEED W3c — the Arc<RwLock> manifest-refresh handle. This is the one I gate CAREFULLY (it touches the v0.13.0-hardened brain). Hold to the verdict cautions: (i) every read-site read()→clone→DROP the guard before any spawn/blocking/pulse-body work (comment each of the 4 sites proving the scope); (ii) broker-side handle-map entry inserted at host, REMOVED on session/host teardown, RE-REGISTERED on brain restart (no dangling Arc at a dead brain's state); (iii) if the borrow-scoping gets gnarly, the reload-on-dirty-signal fallback is allowed — say so in the commit with one line why. refresh_manifest(&self, new, install_dir) via interior mutability, callable through the cloned Arc. Brain-parity: PTY/broker session/OutputLog untouched.

Push W3c solo; ping me, I gate before (a)+(c). Go.
EOF
echo "---sent exit $?---"

Report W3a+W3b pass + advance to W3c


**W3a+W3b GATE-PASS** @b49d4a0 — foundations clean (registry reuse + Windows-safe atomic CRC swap). Advanced todlando to **W3c** (the `Arc<RwLock>` manifest-refresh handle — the careful one, touches the hardened brain; bound to the verdict cautions).

**v0.13.2 progress:**

| Wave | State |
|------|-------|
| W1 / W2 | ✅ GATE-PASS |
| W3a registry + W3b CRC swap | ✅ GATE-PASS @b49d4a0 |
| W3c manifest-refresh handle | 🔨 building (gate carefully) |
| W3 (a)+(c), W3d, W3e | queued |
| W4 / W5 / release | queued |

Incremental gating working well — caught + credited todlando's Windows-safe `rename` refinement (my brief's naive version would've broken on the target platform). Waiting on W3c. perri on deck for ship-acceptance once W3e lands. Reachable on `doyle`.


<task-notification>
<task-id>b3ok5tzh4</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/10" id="7d0b9e98" type="msg" from="todlando">W3c — STOP-AND-FLAG before I wire the trigger. The gated design's registration mechanism is invalidated by a PROCESS boundary the design+verdict (and my proposal's 4-agent sweep) missed. Need your call before I touch the hardened pulse loop.

VERIFIED FINDING: the brain is a SEPARATE OS PROCESS, not a thread.</EVENT-PART>
<EVENT-PART seq="2/10" id="7d0b9e98">
- brainproc.rs:9 &quot;`spt daemon brain` child... connects over the broker socket&quot;; :934 Command::new(exe) re-execs `daemon brain`; :269 &quot;no shared Arc&lt;Broker&gt;&quot;; run_brain:230 calls spawn_live_host. So live-host + BrainLifecycle + ManifestCell + run_pulse_loop ALL live in the `spt daemon brain` CHILD process; the brok</EVENT-PART>
<EVENT-PART seq="3/10" id="7d0b9e98">er lives in the `daemon run` process. (Confirmed independently earlier this session: PID 201620 `daemon brain --generation 2` vs PID 179068 `daemon run --detached` — distinct PIDs.)
- CONSEQUENCE: &quot;brain REGISTERS its Arc&lt;RwLock&gt; ManifestCell handle into the broker-side per-endpoint map&quot; (W3-DESIGN-PROPOSAL §2 + your verd</EVENT-PART>
<EVENT-PART seq="4/10" id="7d0b9e98">ict §confirm-2, caution ii) is IMPOSSIBLE — an Arc cannot span processes. The Explore sweep's own &quot;Option B shared Arc to both&quot; is self-contradictory (it noted the socket yet proposed a shared Arc). My proposal assumed same-process; that was the error.

WHAT STANDS: the ManifestCell core (lifecycle.rs) is still correct + val</EVENT-PART>
<EVENT-PART seq="5/10" id="7d0b9e98">uable — BrainLifecycle.manifest/runtime now behind Arc&lt;RwLock&gt; (ManifestRuntime: Clone), 4 read sites snapshot read-&gt;clone-&gt;DROP before spawn (your caution i, commented at each), refresh(&amp;self) via interior mutability. This lets the BRAIN refresh its OWN manifest in place without a thread/endpoint restart. Compiles clean. Onl</EVENT-PART>
<EVENT-PART seq="6/10" id="7d0b9e98">y the cross-process TRIGGER is wrong.

PROPOSED CORRECTION (your caution iii, but forced by the process split, not borrow-gnarliness): RELOAD-ON-DIRTY in the brain. The brain's pulse loop (run_pulse_loop, 5s cadence) hashes the on-disk manifest (reuse sha256_hex over install_dir's manifest.toml); on a change vs last-seen, re-read it + ce</EVENT-PART>
<EVENT-PART seq="7/10" id="7d0b9e98">ll.refresh(new, install_dir) — entirely in-process, no cross-process handle. This is the ONLY architecturally-viable mechanism given broker!=brain process. Decisions for your gate: (a) hash-vs-mtime (I propose hash — robust); (b) check every pulse vs every N (I propose every pulse — one cheap file hash per 5s); (c) where the manifest fil</EVENT-PART>
<EVENT-PART seq="8/10" id="7d0b9e98">e is (install_dir/manifest.toml, the gh_release _github/&lt;safe&gt; home).

W3d RIPPLE (so you have the full picture): the apply spans BOTH processes — stop translation child (broker, daemon proc, KIN
...(truncated)</event>
</task-notification>


todlando hit STOP-AND-FLAG on a real architectural finding: **the brain is a separate OS process, not a thread** — so the `Arc<RwLock>` handle can't span the broker↔brain process boundary. The "brain registers its Arc into the broker-side map" (my verdict caution ii) is impossible.

This is correct, and I have firsthand proof from earlier this session: when I cleared the file lock I saw **PID 179068 `daemon run`** (broker) vs **PID 201620 `daemon brain --generation 2`** — distinct processes. I missed connecting that in my verdict despite seeing it. Good STOP-AND-FLAG — exactly why incremental gating + a thinking executor matters.

todlando's correction = **reload-on-dirty in the brain** (the fallback I named in caution iii, now *forced* by the architecture, not borrow-gnarliness): the brain's pulse loop hashes on-disk `manifest.toml`, reloads on change. Entirely in-process. The disk IS the cross-process signal — elegant, and the atomic per-file swap (W3b) makes the poll torn-read-safe. Let me rule:


"$OWL" send todlando doyle <<'EOF'
RULING: APPROVED — reload-on-dirty is the correct mechanism. Your STOP-AND-FLAG is right and the finding is load-bearing. I have firsthand proof: earlier this session clearing the build lock I saw PID 179068 `daemon run` (broker) vs PID 201620 `daemon brain --generation 2` — DISTINCT processes. An Arc cannot span them; "broker registers the brain's Arc handle" (my verdict caution ii) is impossible. I missed connecting that despite seeing the PIDs — my gate error, your catch. This is exactly why we gate incrementally.

DECISIONS (your gate asks):
(a) HASH not mtime — APPROVED. sha256_hex over install_dir/manifest.toml. mtime is unreliable across a swap/fs; hash is robust. (The W3b atomic rename means the brain hashes either the whole old or whole new file, never a torn read — no race.)
(b) EVERY PULSE — APPROVED. manifest.toml is tiny; one hash per 5s is negligible. Simpler than a counter; skip the every-N.
(c) install_dir/manifest.toml (the gh_release _github/<safe> home) — CONFIRMED that's where it lives.

CONSISTENCY (binding): re-read + parse via the SAME Manifest load path the bringup used (a gh_release adapter is pointer-mode — manifest read LIVE from source_dir), so the reloaded manifest matches how it was originally loaded — no divergent parse. cell.refresh(new, install_dir) rebuilds runtime via ManifestRuntime::with_install_dir.

WHAT STANDS: your ManifestCell core (Arc<RwLock>, 4 read-sites read→clone→DROP, refresh(&self) interior-mutability) is correct + keep it — it's what lets the brain refresh its OWN manifest in place. Only the cross-process trigger changes: poll-disk-hash, not registered-Arc.

W3d RIPPLE (acknowledged): the apply spans both processes — broker (daemon proc) stops/restarts the translation child + does the CRC swap on disk; the brain (separate proc) self-heals its manifest via this poll on its next pulse (≤5s, eventually-consistent — the CLI reports success on the broker-side swap+restart, doesn't wait for the brain poll). No cross-process handle anywhere. Send the rest of your W3d ripple when you get to it.

DOCS: the ADR-0025 amendment must now also capture the broker≠brain PROCESS boundary + reload-on-dirty (a THIRD correction, alongside psyche-not-in-daemon + re-clone-not-implementable). Note it corrects my verdict caution ii too.

Proceed: finish W3c as reload-on-dirty (the ManifestCell + the pulse-loop poll), push solo, ping me. Then (a)+(c). Good work flagging instead of forcing.
EOF
echo "---sent exit $?---"

Rule on W3c reload-on-dirty correction


➜ SENT:todlando
---sent exit 0---


Ruling sent. Record the architectural correction in the build-drive memory (significant: brain≠broker process; manifest refresh = poll-disk-hash, not cross-process Arc):


Edit

**W3 design HOLE (perri F-010×F-015 live repro, 2026-06-22):**

**W3c ARCHITECTURAL correction (todlando STOP-AND-FLAG, gated 2026-06-22):** the BRAIN is a SEPARATE OS PROCESS, not a thread (PID `daemon brain` ≠ PID `daemon run`/broker; brainproc re-execs `spt daemon brain` connecting over the broker socket). So ADR-0025's "broker registers the brain's Arc<RwLock> manifest handle" is IMPOSSIBLE — an Arc can't span processes (my W3 verdict caution-ii error; I'd even SEEN the distinct PIDs clearing the build lock and missed it). CORRECTION = reload-on-dirty IN the brain: the pulse loop (5s) sha256_hex's install_dir/manifest.toml, reloads on change via cell.refresh. Disk IS the cross-process signal; W3b atomic rename makes the poll torn-read-safe. ManifestCell core (Arc<RwLock> manifest/runtime, read→clone→drop, refresh(&self)) STANDS — only the trigger changed. ADR-0025 needs a THIRD correction-axis for this. Lesson: verify thread-vs-process boundary before any "shared handle" design.

**W3 design HOLE (perri F-010×F-015 live repro, 2026-06-22):**


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

**W3c ARCHITECTURAL correction (todlando STOP-AND-FLAG, gated 2026-06-22):** the BRAIN is a SEPARATE OS PROCESS, not a thread (PID `daemon brain` ≠ PID `daemon run`/broker; brainproc re-execs `spt daemon brain` connecting over the broker socket). So ADR-0025's "broker registers the brain's Arc<RwLock> manifest handle" is IMPOSSIBLE — an Arc can't span processes (my W3 verdict caution-ii error; I'd even SEEN the distinct PIDs clearing the build lock and missed it). CORRECTION = reload-on-dirty IN the brain: the pulse loop (5s) sha256_hex's install_dir/manifest.toml, reloads on change via cell.refresh. Disk IS the cross-process signal; W3b atomic rename makes the poll torn-read-safe. ManifestCell core (Arc<RwLock> manifest/runtime, read→clone→drop, refresh(&self)) STANDS — only the trigger changed. ADR-0025 needs a THIRD correction-axis for this. Lesson: verify thread-vs-process boundary before any "shared handle" design.

**W3 design HOLE (perri F-010×F-015 live repro, 2026-06-22):**

---
name: v0132-build-drive
description: "v0.13.2 adapter-packaging BUILD phase — wave status, dispatch mechanics, and the W3 psyche-lock design hole"
metadata:
  node_type: memory
  type: project
  originSessionId: 09dbfcc7-8716-4b1a-84fd-5da14fa3859e
---

Driving v0.13.2 (adapter packaging + live update) build→release this session (operator 2026-06-22: "run the build, todlando executes, you gate, deployah publishes, see it through"). doyle = orchestrator/gater.

**Branch:** `v0.13.2-adapter-packaging`, reconciled with main @ `69c975f` (merged main in — ADR-0023 + REQ-EP-8/9 preserved, v0.13.2 REQs intact), pushed. traceable EXIT=0.

**Dispatch mechanics (corrected):** todlando/deployah/perri are LIVE agents on the LEGACY owl network — dispatch via `"$OWL" send <target> doyle` (message on stdin), NOT subagents and NOT spt-core endpoints (spt roster here = only wall-* test perches). `$OWL` = owl.exe in the cplugs cache. `"$OWL" list` shows them ACTIVE.

**Build order:** W1 multi-platform .spt (ADR-0024) → W2 gh transport → W3 live-update KEYSTONE (ADR-0025, gate vs REAL daemon+dummy-harness, NO mocks) → W4 update-message → W5 proof --dir. W2 sequential after W1 (shared cli.rs fetch/extract hotspot). Release counter = verify vs spt-releases metadata at cut (expect 29; v0.13.1 lesson).

**File-lock gotcha:** a stale DEV daemon (target/debug/spt.exe, `daemon run --detached`) can lock the build artifact (os error 5). Installed live daemon (AppData\Local\spt-core\bin) hosts wall-* + psyche — NEVER touch. Scoped PID-tree kill of the DEV daemon only is safe ([[no-machinewide-killon-shared-runner]]).

**W3 design HOLE (perri F-010×F-015 live repro, 2026-06-22):** the adapter PSYCHE binary (claude-spt-psyche.exe) runs DETACHED from the SHARED install-dir; orphaned (parent dead) it locks the dir → bricks `adapter update`/`add --release` (tar exit 1). ADR-0025 assumed psyche=daemon-hosted in-process (ADR-0004) and EXCLUDED it from resident cycling — but it's a separate install-dir binary W3's resident-only stop/restart won't release. W3 must absorb: (a) orphan-psyche reap on stop/agent-death [F-010, candidate REQ], (c) psyche-from-own-copy not shared dir [structural], (b) extract-to-temp+atomic-swap [F-015]. perri's repro = the W3 NO-mocks int fixture + her ship acceptance test. Tracked task #7 blocks W3.

+**W3c ARCHITECTURAL correction (todlando STOP-AND-FLAG, gated 2026-06-22):** the BRAIN is a SEPARATE OS PROCESS, not a thread (PID `daemon brain` ≠ PID `daemon run`/broker; brainproc re-execs `spt daemon brain` connecting over the broker socket). So ADR-0025's "broker registers the brain's Arc<RwLock> manifest handle" is IMPOSSIBLE — an Arc can't span processes (my W3 verdict caution-ii error; I'd even SEEN the distinct PIDs clearing the build lock and missed it). CORRECTION = reload-on-dirty IN the brain: the pulse loop (5s) sha256_hex's install_dir/manifest.toml, reloads on change via cell.refresh. Disk IS the cross-process signal; W3b atomic rename makes the poll torn-read-safe. ManifestCell core (Arc<RwLock> manifest/runtime, read→clone→drop, refresh(&self)) STANDS — only the trigger changed. ADR-0025 needs a THIRD correction-axis for this. Lesson: verify thread-vs-process boundary before any "shared handle" design.


Ruling sent + memory updated. This is the design-gate paying off twice now:

**W3c hit a real architectural wall — caught before wasted build.** The brain is a **separate OS process** from the broker (I'd literally seen `daemon brain` PID 201620 ≠ `daemon run` PID 179068 when clearing the lock, and missed connecting it in my verdict). An `Arc` can't span processes, so the "broker registers the brain's manifest handle" design was impossible. todlando stopped and flagged instead of forcing it.

**Corrected mechanism: reload-on-dirty** — the brain's 5s pulse hashes on-disk `manifest.toml`, reloads on change. The disk *is* the cross-process signal; W3b's atomic rename makes the poll torn-read-safe. Approved with: hash (not mtime), every-pulse, same-load-path consistency. The `ManifestCell` core stands; only the trigger changed. ADR-0025 gets a third correction-axis.

| Wave | State |
|------|-------|
| W1 / W2 / W3a+W3b | ✅ GATE-PASS |
| W3c manifest-refresh | 🔨 rebuilding as reload-on-dirty (architecture-forced) |
| W3 (a)+(c) / W3d / W3e | queued |
| W4 / W5 / release | queued |

Incremental gating + a thinking executor just averted an unbuildable design. Waiting on W3c. Reachable on `doyle`.


<task-notification>
<task-id>b3ok5tzh4</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/8" id="378d4040" type="msg" from="perri">spt-core 0.13.x REGRESSION — `spt endpoint run` bringup is broken on any MULTI-SUBNET node. Operator hit it; root-caused + reproduced.

SYMPTOM: `spt endpoint run --adapter claude-spt --id wall-a` →
  ENDPOINT_RUN:wall-a … pid=84404
  ENDPOINT_RUN_ONLINE_TIMEOUT:wall-a: harness did not come online with</EVENT-PART>
<EVENT-PART seq="2/8" id="378d4040">in 25s
claude spawns fine, but NO perch ever binds → ENDPOINTS:0.

ROOT CAUSE: the adapter SessionStart bind hook runs (verbatim):
  spt api --adapter claude-spt bind &quot;$SPT_ENDPOINT_ID&quot; --set-session-id &quot;$sid&quot;
On 0.13.x that now REFUSES on a multi-subnet node:
  HOME_REFUSED: this node holds 2 subnets (S</EVENT-PART>
<EVENT-PART seq="3/8" id="378d4040">PT_DEV, BIGNET) — pass --subnet &lt;name&gt; (home is assigned at creation, no guessing)
The hook swallows it (|| true) → silent no-perch → the 25s online timeout.

THE TRAP: the spt-HOSTED bringup gives the adapter NO way to supply --subnet:
 - `spt endpoint run` has NO --subnet flag (checked --help).
 - the broker inject</EVENT-PART>
<EVENT-PART seq="4/8" id="378d4040">s $SPT_ENDPOINT_ID but NO home-subnet env.
 - bind doesn't inherit the home from the broker/endpoint-run context.
So an spt-hosted adapter on a 2+-subnet node CANNOT satisfy bind. Same class as F-013 (bringup can't satisfy bind from available inputs).

CONFIRMED both directions (disposable ids, cleaned up):
 - `bind sptc-diag-1`</EVENT-PART>
<EVENT-PART seq="5/8" id="378d4040"> via endpoint run → online-timeout, no perch.
 - manual `api bind sptc-diag-2 --set-session-id …` (no --subnet) → HOME_REFUSED.
 - manual `api bind sptc-diag-3 --set-session-id … --subnet SPT_DEV` → BOUND:sptc-diag-3 (works).
So bind WORKS with --subnet, FAILS without on multi-subnet. Node: SPT_DEV + BIGNET.

FIX OPTI</EVENT-PART>
<EVENT-PART seq="6/8" id="378d4040">ONS (your call):
 (a) broker-spawned bind inherits the home subnet from the endpoint-run/broker context (cleanest — the broker created the endpoint, bind shouldn't demand --subnet when broker-parented).
 (b) endpoint run injects $SPT_ENDPOINT_SUBNET (sibling to $SPT_ENDPOINT_ID) so the hook passes --subnet &quot;$SPT_ENDPOINT_SUBNET&qu</EVENT-PART>
<EVENT-PART seq="7/8" id="378d4040">ot;.
 (c) endpoint run gains --subnet + threads it to bind.

Single-subnet nodes are unaffected (bind defaults fine) — only bites 2+-subnet. Likely worked pre-0.13.x. I can apply a temporary local hook patch (hardcode --subnet) to unblock the operator meanwhile — but that's node-specific; the real fix is one of a/b/c. Want a findi</EVENT-PART>
<EVENT-PART seq="8/8" id="378d4040">ngs entry / the full repro script?</EVENT-PART></event>
</task-notification>


W2 — gh transport for private repos

W4 — update-message

Release — cut v0.13.2 (deployah role)


/grill-with-docs perri's report


perri reports a **separate regression** (not v0.13.2 scope): `spt endpoint run` bringup broken on multi-subnet nodes. The SessionStart bind hook hits `HOME_REFUSED` (2 subnets, needs `--subnet`), the hook swallows it (`|| true`) → silent no-perch → 25s timeout. Operator hit it. Same class as F-013 (bringup can't satisfy bind from available inputs). Quick grounding before I reply + scope:


CHANGELOG.md:360:  - `spt notify …` moved to `spt subnet notify [message] [--target <subnet>]`. With no `--target`, it sends to your home subnet; if you have no home subnet and don't pass `--target`, it refuses rather than guessing.
CONTEXT.md:491:[Omitted long matching line]
CONTEXT.md:548:[Omitted long matching line]
CONTEXT.md:577:[Omitted long matching line]
CONTEXT.md:596:**home subnet (per endpoint)**:
CONTEXT.md:597:[Omitted long matching line]
CONTEXT.md:604:[Omitted long matching line]
CONTEXT.md:655:[Omitted long matching line]
CONTEXT.md:705:[Omitted long matching line]
CONTEXT.md:780:**What "installed" comprises:** the `spt` binary + the broker + the `$SPT_HOME` root (holds `node.key`, trust store, registry, spools, in-memory-seed fallback state). **First run is idempotent + interactive-optional:** generates the node identity and starts the daemon unattended; pairing (and subnet naming) is a separate explicit step.
CONTEXT.md:785:[Omitted long matching line]
CONTEXT.md:810:[Omitted long matching line]
crates\xtask\src\main.rs:1015:        eprintln!("usage: xtask debug-converge --version <u64> [--nodes <hex,…>] [--subnet <name>] [--timeout <secs>] [--poll <secs>] [--home <path>]");
M4-D2f-PLAN.md:12:ran `create_subnet("home")` pairs a second node via one code; the joiner ends with the
M4-D9-PLAN.md:47:- D9-5 `e69f59a`+`0b30dea` — immutable home subnet (`spt_store::home`:
M4-D9-PLAN.md:51:  setter) + `sync_subnets = [home]` creation seeding + `spt fork` (one-time copy
M4-D9-PLAN.md:103:Authored against M4-PLAN §D9, ADR-0003 (red-team #9), ADR-0010 (home subnet / fork),
M4-D9-PLAN.md:241:5. **D9-5 — REQ-INST-15 immutable home subnet + `spt fork` → `[doc, impl, unit]`.**
M4-D9-PLAN.md:246:   - Creation seeds `sync_subnets = [home]` (the `visibility.rs` documented seam, now
M4-PLAN.md:247:- **D9c** REQ-INST-14 (resource advertisement) + REQ-INST-15 (immutable home subnet + `spt
crates\spt-net\src\net\wanmsg.rs:105:            subnet: Some("home".into()),
crates\spt-net\src\net\wanmsg.rs:171:                subnet: Some("home".into()),
M8-ACCEPTANCE-STEPS.md:176:A fresh subnet `ACCEPT` between the two throwaway homes.
M8-ACCEPTANCE-STEPS.md:339:run subnet leave ACCEPT 2>/dev/null; rm -rf /home/reavus/spt-accept-home
M8-ACCEPTANCE-STEPS.md:343:& $spt subnet leave ACCEPT 2>$null; Remove-Item -Recurse -Force "$env:SPT_HOME"

[Showing results with pagination = limit: 25]


Bash

sed -n '596,605p' CONTEXT.md; echo "=== HOME_REFUSED / bind home in code ==="; grep -rn "holds.*subnet\|home is assigned\|pass --subnet\|fn cmd.*bind\|--subnet" crates/spt/src/ 2>/dev/null | grep -i "subnet\|bind" | head -20

Read home-subnet model + bind home code


**home subnet (per endpoint)**:
The single subnet that anchors an endpoint's *defaults*: the default **sync** scope (subnet-exclusive mind replication — *Cross-node Psyche sync*) and the subnet that qualifies its **bare name**. **Assigned at creation:** the node's **sole** subnet automatically; on a **multi-subnet** node it **must be specified** at creation (no silent guess — mirrors the *resolution policy*'s refuse-and-qualify); on an **unpaired** node the endpoint is local-only until first join, when home is set. Distinct from advertisement: identity is **node-global and advertised into *every* subnet the node belongs to** per the visibility defaults — home subnet is only the default-scope *anchor*, not a limit on where the endpoint is visible or addressable. **Home is immutable** — there is **no re-home** (ADR-0010). To place an agent's mind in a different subnet, **fork** it (see *`spt endpoint fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).

<!-- [doc->REQ-INST-14] -->
**resource advertisement (subnet resource registry)**:
A per-endpoint **free-text blurb** describing the services/functions the endpoint can serve — an agent **yellow-pages** for service discovery, distinct from *capability declaration* (machine-readable, which endpoint *types* a node hosts) and from *endpoint visibility* (whether it's addressable at all). **Both-authored + mutable:** config seeds a default; the agent refines its own at runtime (`spt endpoint description set …`). It is **not a separate registry** — it is a field on the endpoint record and a **projection** of the subnet registry (`(id, node, resources-blurb)`), surfaced as the "subnet resource registry" view. **Gated by the access layers:** an endpoint excluded from subnet S (visibility) — or, later, one whose access whitelist (*endpoint access whitelist*) excludes the viewer's node — never appears in that view; discovery leaks nothing a viewer couldn't reach. Synced as **registry data, not context** (directory metadata, not the agent's mind). M4 (needs the distributed registry).

**resolution policy**:
How bare `ling` resolves when multiple instances exist. Rule: local-node instance if present → else most-recently-active → explicit override always available. **Two qualifier syntaxes**, combinable: **subnet-qualified** `home:ling` and **node-qualified** `ling@hfenduleam` (→ `home:ling@hfenduleam`). **Multi-subnet ambiguity rule:** under node-global identity (endpoint ID below), a node on several subnets may see two *distinct* endpoints that share a bare name (a `home:ling` and an unrelated `work:ling`). When a bare `id` is ambiguous across the visible subnets, resolution **refuses and forces qualification** (by subnet or node) rather than guessing. Within a single subnet, bare ids stay unique (join-time collision check, below).

=== HOME_REFUSED / bind home in code ===
crates/spt/src/api/startup.rs:432:pub fn cmd_bind(
crates/spt/src/cli.rs:233:    /// is a thin alias and must see its own perch). `--subnet` filters the
crates/spt/src/cli.rs:835:/// subcommand verbs and positional subnet names — never `--subnet` flags
crates/spt/src/cli.rs:1494:/// `spt fork <src> <new_id> --subnet <target>` — the cross-subnet move
crates/spt/src/cli.rs:1756:/// `--subnet` filters. This node's LOCAL perches are ALWAYS merged in as a
crates/spt/src/cli.rs:3570:    /// Several subnets and no `--subnet` — refuse rather than guess.
crates/spt/src/cli.rs:3628:    // No flag: auto-select iff the node holds exactly one subnet (same no-guessing
crates/spt/src/cli.rs:3696:                "AMBIGUOUS_SUBNET: this node holds {} subnets ({}) — name one: `spt subnet show-code <name>`",
crates/spt/src/cli.rs:4980:         rotate the seed (the machine still holds it). Use `spt subnet revoke` \
crates/spt/src/cli.rs:5246:        eprintln!("ALREADY_MEMBER:{name} — this machine already holds the subnet");
crates/spt/src/cli.rs:5383:                eprintln!("JOIN_FAIL:{name}: the answering machine holds no subnet by that name");
crates/spt/src/cli.rs:8627:            vec!["spt", "fork", "a", "b", "--subnet", "s"],
crates/spt/src/cli.rs:8678:            parse(&["spt", "endpoint", "list", "--subnet", "home", "--detail"])
crates/spt/src/cli.rs:8712:                "--subnet",
crates/spt/src/cli.rs:8726:            "--subnet required"
crates/spt/src/cli.rs:8936:            parse(&["spt", "notif", "list", "--subnet", "home"])
crates/spt/src/cli.rs:10801:    // positional names, no --subnet stutter (M7 plan decision 3).
crates/spt/src/cli.rs:11692:    // [unit->REQ-PAIR-3] several subnets + no --subnet refuses (no guessing); naming