1	//! The two-tier context store — `tracked/` realized over [`BranchStore`]
2	//! (M4-D6a; STORAGE §tracked/, ADR-0011, ADR-0013).
3	//!
4	//! Layout (the synced *mind*, physically separate from node-local `perches/`):
5	//!
6	//! ```text
7	//! tracked/
8	//!   .seed.git                    bare BranchStore repo (the one true object DB)
9	//!   agents/<id>/                 linked worktree of branch  a-<id>
10	//!     live-context.md            per-agent live context  → syncs to ALL instances
11	//!   projects/<project_id>/       linked worktree of branch  p-<project_id>
12	//!     <id>/project-context.md    per-(agent,project)      → same-project instances
13	//! ```
14	//!
15	//! Branches are the sync/merge unit (`a-`/`p-` refs are what D6c's bundle
16	//! exchange ships); worktrees are the read/write view. Tier routing is the
17	//! CONTEXT-MEMORY two-tier split: the `a-` branch is the agent's mind
18	//! everywhere, the `p-` branch holds every agent's context for one project
19	//! (cross-agent synthesis + query-routing ride that shape).
20	//!
21	//! Writes here are **paths + commits only** — the precedence-marker guard
22	//! (6.5) stays in `spt-live::context`, which writes through the paths this
23	//! store hands out, then calls the commit hook. Single writer per branch
24	//! (active-instance-authoritative; 6.4).
25	// [impl->REQ-STORE-1]
26
27	use std::path::{Path, PathBuf};
28
29	use crate::branchstore::BranchStore;
30	use crate::perch::tracked_dir;
31	use spt_proto::id::validate_endpoint_id;
32
33	/// The per-agent live-context file inside an `a-<id>` worktree.
34	pub const LIVE_CONTEXT_FILE: &str = "live-context.md";
35	/// Per-worktree directory holding surfaced concurrent-write conflict artifacts
36	/// (ADR-0013; tracked, so conflict state replicates).
37	pub const CONFLICT_DIR: &str = ".conflicts";
38	/// The per-(agent,project) context file inside a `p-<project>` worktree,
39	/// nested under the agent id.
40	pub const PROJECT_CONTEXT_FILE: &str = "project-context.md";
41
42	/// The branch carrying `id`'s live context (the per-agent view).
43	pub fn agent_branch(id: &str) -> String {
44	    format!("a-{id}")
45	}
46
47	/// The branch carrying `project_id`'s per-project context (all agents).
48	pub fn project_branch(project_id: &str) -> String {
49	    format!("p-{project_id}")
50	}
51
52	/// Pure path of `id`'s live-context file under a `tracked/` root — the
53	/// **read** form (no branch/worktree creation; readers must not mint store
54	/// state). The single layout authority both the ensure-paths and every reader
55	/// route through (6.1).
56	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
57	pub fn live_context_file(root: &Path, id: &str) -> PathBuf {
58	    root.join("agents").join(id).join(LIVE_CONTEXT_FILE)
59	}
60
61	/// Pure path of the per-(agent,project) context file under a `tracked/` root —
62	/// the **read** form (no creation).
63	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
64	pub fn project_context_file(root: &Path, project_id: &str, id: &str) -> PathBuf {
65	    root.join("projects").join(project_id).join(id).join(PROJECT_CONTEXT_FILE)
66	}
67
68	/// First 8 hex chars of the SHA-256 of `bytes` — the deterministic conflict
69	/// artifact name component (every node minting an artifact for the same
70	/// incoming version produces the same name → recording is idempotent).
71	fn content_hash8(bytes: &[u8]) -> String {
72	    use sha2::{Digest, Sha256};
73	    let digest = Sha256::digest(bytes);
74	    digest[..4].iter().map(|b| format!("{b:02x}")).collect()
75	}
76
77	/// The two-tier context store rooted at a `tracked/` directory.
78	pub struct ContextStore {
79	    store: BranchStore,
80	    root: PathBuf,
81	}
82
83	impl ContextStore {
84	    /// Open (or initialize) the node's canonical store at
85	    /// [`tracked_dir`](crate::perch::tracked_dir).
86	    pub fn open_or_init() -> std::io::Result<ContextStore> {
87	        Self::open_or_init_in(&tracked_dir())
88	    }
89
90	    /// Open (or initialize) a store rooted at an explicit `tracked/` dir —
91	    /// the testable seam.
92	    // [impl->REQ-STORE-1]
93	    pub fn open_or_init_in(root: &Path) -> std::io::Result<ContextStore> {
94	        let store = BranchStore::open_or_init(&root.join(".seed.git"))?;
95	        Ok(ContextStore { store, root: root.to_path_buf() })
96	    }
97
98	    /// The underlying branch-as-KV store (D6c's bundle exchange works on it).
99	    pub fn branch_store(&self) -> &BranchStore {
100	        &self.store
101	    }
102
103	    /// The `tracked/` root this store lives under.
104	    pub fn root(&self) -> &Path {
105	        &self.root
106	    }
107
108	    fn validated(id: &str) -> std::io::Result<()> {
109	        validate_endpoint_id(id)
110	            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))
111	    }
112
113	    /// Ensure the `a-<id>` branch + worktree exist; return the live-context
114	    /// file path inside it (`tracked/agents/<id>/live-context.md`). The id is
115	    /// charset-validated (4.6) — branch names and worktree dirs are derived
116	    /// from it.
117	    // [impl->REQ-STORE-1]
118	    pub fn live_context_path(&self, id: &str) -> std::io::Result<PathBuf> {
119	        Self::validated(id)?;
120	        let file = live_context_file(&self.root, id);
121	        let wt = file.parent().expect("live-context file has a worktree parent");
122	        self.store.ensure_worktree(&agent_branch(id), wt)?;
123	        Ok(file)
124	    }
125
126	    /// Ensure the `p-<project_id>` branch + worktree exist; return the
127	    /// per-(agent,project) context file path inside it
128	    /// (`tracked/projects/<project_id>/<id>/project-context.md`). Both
129	    /// components are charset-validated (4.6; project ids come from
130	    /// [`crate::project::project_id_for_dir`], which emits the same charset).
131	    // [impl->REQ-STORE-1]
132	    pub fn project_context_path(&self, project_id: &str, id: &str) -> std::io::Result<PathBuf> {
133	        Self::validated(project_id)?;
134	        Self::validated(id)?;
135	        let file = project_context_file(&self.root, project_id, id);
136	        let wt = self.root.join("projects").join(project_id);
137	        self.store.ensure_worktree(&project_branch(project_id), &wt)?;
138	        std::fs::create_dir_all(file.parent().expect("project-context file has an agent dir"))?;
139	        Ok(file)
140	    }
141
142	    /// Commit `id`'s live-context worktree — one checkpoint commit on `a-<id>`
143	    /// (no-op when nothing changed). Call after a guarded write through
144	    /// [`live_context_path`](Self::live_context_path).
145	    // [impl->REQ-STORE-1]
146	    pub fn commit_live(&self, id: &str, message: &str) -> std::io::Result<Option<String>> {
147	        Self::validated(id)?;
148	        let wt = self.root.join("agents").join(id);
149	        self.store.commit_in_worktree(&wt, &[], message)
150	    }
151
152	    /// Commit the `p-<project_id>` worktree — one checkpoint commit (no-op
153	    /// when nothing changed).
154	    // [impl->REQ-STORE-1]
155	    pub fn commit_project(&self, project_id: &str, message: &str) -> std::io::Result<Option<String>> {
156	        Self::validated(project_id)?;
157	        let wt = self.root.join("projects").join(project_id);
158	        self.store.commit_in_worktree(&wt, &[], message)
159	    }
160
161	    /// Record a surfaced concurrent-write conflict (ADR-0013, hazard 6.6):
162	    /// persist the **incoming** version as a tracked artifact beside the local
163	    /// file — `.conflicts/<file>.<hash8>.md` inside the same worktree — and
164	    /// checkpoint it. The local working file stays untouched (the active mind
165	    /// is never clobbered by an unresolved conflict), and the artifact
166	    /// replicates like any context file, so conflict state itself syncs.
167	    /// Deterministic content-hash naming makes recording idempotent across
168	    /// nodes — every holder of the same pair mints the same artifact.
169	    // [impl->REQ-HAZARD-CONFLICT-BOTH-PRESERVED]
170	    pub fn record_conflict(
171	        &self,
172	        worktree: &Path,
173	        file_rel: &str,
174	        incoming: &[u8],
175	    ) -> std::io::Result<PathBuf> {
176	        let dir = worktree.join(CONFLICT_DIR);
177	        std::fs::create_dir_all(&dir)?;
178	        let name = format!(
179	            "{}.{}.md",
180	            file_rel.replace(['/', '\\'], "_"),
181	            content_hash8(incoming)
182	        );
183	        let path = dir.join(&name);
184	        if !path.exists() {
185	            crate::atomic::atomic_write_string(&path, &String::from_utf8_lossy(incoming))?;
186	        }
187	        self.store.commit_in_worktree(
188	            worktree,
189	            &[],
190	            &format!("conflict: {file_rel} ({name})"),
191	        )?;
192	        Ok(path)
193	    }
194
195	    /// The surfaced-and-unresolved conflicts in a worktree (artifact paths for
196	    /// `file_rel`, or all when `None`). Empty = no conflicts pending.
197	    // [impl->REQ-HAZARD-CONFLICT-BOTH-PRESERVED]
198	    pub fn list_conflicts(
199	        &self,
200	        worktree: &Path,
201	        file_rel: Option<&str>,
202	    ) -> std::io::Result<Vec<PathBuf>> {
203	        let dir = worktree.join(CONFLICT_DIR);
204	        let mut out = Vec::new();
205	        let Ok(entries) = std::fs::read_dir(&dir) else {
206	            return Ok(out); // no dir = no conflicts
207	        };
208	        let prefix = file_rel.map(|f| format!("{}.", f.replace(['/', '\\'], "_")));
209	        for e in entries.flatten() {
210	            let name = e.file_name().to_string_lossy().to_string();
211	            if prefix.as_ref().is_none_or(|p| name.starts_with(p.as_str())) {
212	                out.push(e.path());
213	            }
214	        }
215	        out.sort();
216	        Ok(out)
217	    }
218
219	    /// Clear `file_rel`'s conflict artifacts after a dominating (reconciled)
220	    /// write landed — `git rm` + one checkpoint commit. A no-op when none
221	    /// exist. Never called by the merge path on anything but a dominating
222	    /// write: an unresolved pair must outlive every failure (hazard 6.6).
223	    // [impl->REQ-HAZARD-CONFLICT-BOTH-PRESERVED]
224	    pub fn clear_conflicts(&self, worktree: &Path, file_rel: &str) -> std::io::Result<usize> {
225	        let paths = self.list_conflicts(worktree, Some(file_rel))?;
226	        if paths.is_empty() {
227	            return Ok(0);
228	        }
229	        for p in &paths {
230	            std::fs::remove_file(p)?;
231	        }
232	        self.store
233	            .commit_in_worktree(worktree, &[], &format!("conflict resolved: {file_rel}"))?;
234	        Ok(paths.len())
235	    }
236
237	    /// The context-store arm of `spt rename <old> <new>` (REQ-INST-11, the
238	    /// D3f-documented seam): move the `a-<old>` branch + its worktree to
239	    /// `a-<new>`, and rename `<old>/` → `<new>/` inside every project worktree
240	    /// (one rename commit per touched project). Refuses when `a-<new>` already
241	    /// exists (never merge two minds); an absent `a-<old>` is a no-op `Ok`
242	    /// (nothing tracked yet — perch-only endpoints rename cleanly).
243	    // [impl->REQ-INST-11]
244	    pub fn rename_endpoint(&self, old_id: &str, new_id: &str) -> std::io::Result<()> {
245	        Self::validated(old_id)?;
246	        Self::validated(new_id)?;
247	        if self.store.has_branch(&agent_branch(new_id))? {
248	            return Err(std::io::Error::new(
249	                std::io::ErrorKind::AlreadyExists,
250	                format!("context branch already exists: {}", agent_branch(new_id)),
251	            ));
252	        }
253
254	        // a- arm: move worktree dir, then rename the branch (git updates the
255	        // worktree's HEAD symref). Skip when the old branch never existed.
256	        if self.store.has_branch(&agent_branch(old_id))? {
257	            let old_wt = self.root.join("agents").join(old_id);
258	            let new_wt = self.root.join("agents").join(new_id);
259	            if old_wt.join(".git").exists() {
260	                crate::gitrun::run_git_ok(
261	                    &[
262	                        "-C",
263	                        &self.root.join(".seed.git").to_string_lossy(),
264	                        "worktree",
265	                        "move",
266	                        &old_wt.to_string_lossy(),
267	                        &new_wt.to_string_lossy(),
268	                    ],
269	                    None,
270	                    None,
271	                )?;
272	            }
273	            crate::gitrun::run_git_ok(
274	                &[
275	                    "-C",
276	                    &self.root.join(".seed.git").to_string_lossy(),
277	                    "branch",
278	                    "-m",
279	                    &agent_branch(old_id),
280	                    &agent_branch(new_id),
281	                ],
282	                None,
283	                None,
284	            )?;
285	        }
286
287	        // p- arm: rename the agent dir inside every project worktree holding one.
288	        let projects = self.root.join("projects");
289	        if let Ok(entries) = std::fs::read_dir(&projects) {
290	            for entry in entries.flatten() {
291	                let wt = entry.path();
292	                if !wt.join(".git").exists() || !wt.join(old_id).exists() {
293	                    continue;
294	                }
295	                crate::gitrun::run_git_ok(&["mv", old_id, new_id], Some(&wt), None)?;
296	                self.store.commit_in_worktree(
297	                    &wt,
298	                    &[],
299	                    &format!("rename {old_id} -> {new_id}"),
300	                )?;
301	            }
302	        }
303	        Ok(())
304	    }
305	}
306
307	#[cfg(test)]
308	mod tests {
309	    use super::*;
310	    use tempfile::tempdir;
311
312	    #[test] // [unit->REQ-STORE-1] tier paths land on the documented layout; commits checkpoint per branch.
313	    fn two_tier_layout_and_commits() {
314	        let tmp = tempdir().unwrap();
315	        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
316
317	        let live = cs.live_context_path("doyle").unwrap();
318	        assert!(live.ends_with(Path::new("agents").join("doyle").join(LIVE_CONTEXT_FILE)));
319	        std::fs::write(&live, "live mind").unwrap();
320	        let c = cs.commit_live("doyle", "live v1").unwrap();
321	        assert!(c.is_some());
322	        assert_eq!(
323	            cs.branch_store().read_at_tip(&agent_branch("doyle"), LIVE_CONTEXT_FILE).unwrap(),
324	            Some(b"live mind".to_vec())
325	        );
326
327	        let proj = cs.project_context_path("spt-core", "doyle").unwrap();
328	        assert!(proj.ends_with(
329	            Path::new("projects").join("spt-core").join("doyle").join(PROJECT_CONTEXT_FILE)
330	        ));
331	        std::fs::write(&proj, "project mind").unwrap();
332	        let c = cs.commit_project("spt-core", "proj v1").unwrap();
333	        assert!(c.is_some());
334	        assert_eq!(
335	            cs.branch_store()
336	                .read_at_tip(&project_branch("spt-core"), "doyle/project-context.md")
337	                .unwrap(),
338	            Some(b"project mind".to_vec())
339	        );
340	    }
341
342	    #[test] // [unit->REQ-STORE-1] the p- branch aggregates multiple agents (cross-agent synthesis shape).
343	    fn project_branch_holds_many_agents() {
344	        let tmp = tempdir().unwrap();
345	        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
346	        for id in ["doyle", "momo"] {
347	            let p = cs.project_context_path("spt-core", id).unwrap();
348	            std::fs::write(&p, format!("{id} ctx")).unwrap();
349	        }
350	        cs.commit_project("spt-core", "both").unwrap().unwrap();
351	        let bs = cs.branch_store();
352	        assert!(bs.read_at_tip(&project_branch("spt-core"), "doyle/project-context.md").unwrap().is_some());
353	        assert!(bs.read_at_tip(&project_branch("spt-core"), "momo/project-context.md").unwrap().is_some());
354	    }
355
356	    #[test] // [unit->REQ-INST-11] rename moves the a- branch + worktree and ripples every p- agent dir; target collision refuses.
357	    fn rename_ripples_both_tiers() {
358	        let tmp = tempdir().unwrap();
359	        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
360	        let live = cs.live_context_path("doyle").unwrap();
361	        std::fs::write(&live, "mind").unwrap();
362	        cs.commit_live("doyle", "v1").unwrap().unwrap();
363	        let proj = cs.project_context_path("spt-core", "doyle").unwrap();
364	        std::fs::write(&proj, "proj mind").unwrap();
365	        cs.commit_project("spt-core", "v1").unwrap().unwrap();
366
367	        cs.rename_endpoint("doyle", "arthur").unwrap();
368
369	        // a- arm: branch + worktree moved, content intact, old gone.
370	        assert!(cs.branch_store().has_branch(&agent_branch("arthur")).unwrap());
371	        assert!(!cs.branch_store().has_branch(&agent_branch("doyle")).unwrap());
372	        assert_eq!(
373	            std::fs::read_to_string(live_context_file(tmp.path(), "arthur")).unwrap(),
374	            "mind"
375	        );
376	        assert!(!tmp.path().join("agents").join("doyle").exists());
377
378	        // p- arm: agent dir renamed inside the project worktree + committed.
379	        assert_eq!(
380	            cs.branch_store()
381	                .read_at_tip(&project_branch("spt-core"), "arthur/project-context.md")
382	                .unwrap(),
383	            Some(b"proj mind".to_vec())
384	        );
385	        assert!(cs
386	            .branch_store()
387	            .read_at_tip(&project_branch("spt-core"), "doyle/project-context.md")
388	            .unwrap()
389	            .is_none());
390
391	        // Collision: an existing target mind refuses (never merge two minds).
392	        cs.live_context_path("momo").unwrap();
393	        let err = cs.rename_endpoint("arthur", "momo").unwrap_err();
394	        assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
395
396	        // Absent source: a perch-only endpoint renames as a clean no-op.
397	        cs.rename_endpoint("ghost", "phantom").unwrap();
398	    }
399
400	    #[test] // [unit->REQ-HAZARD-CONFLICT-BOTH-PRESERVED] a surfaced conflict preserves BOTH versions; only a resolve clears it.
401	    fn conflict_artifacts_preserve_both_versions() {
402	        let tmp = tempdir().unwrap();
403	        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
404	        let live = cs.live_context_path("doyle").unwrap();
405	        std::fs::write(&live, "LOCAL version").unwrap();
406	        cs.commit_live("doyle", "local").unwrap();
407	        let wt = live.parent().unwrap().to_path_buf();
408
409	        // Surface a concurrent incoming: artifact minted, local untouched.
410	        let a1 = cs.record_conflict(&wt, LIVE_CONTEXT_FILE, b"REMOTE version").unwrap();
411	        assert_eq!(std::fs::read_to_string(&a1).unwrap(), "REMOTE version");
412	        assert_eq!(std::fs::read_to_string(&live).unwrap(), "LOCAL version");
413	        // Tracked: the artifact is in the branch tip (replicates like context).
414	        let rel = format!("{CONFLICT_DIR}/{}", a1.file_name().unwrap().to_string_lossy());
415	        assert!(cs.branch_store().read_at_tip(&agent_branch("doyle"), &rel).unwrap().is_some());
416
417	        // Idempotent: same incoming bytes → same artifact, no duplicate.
418	        let a2 = cs.record_conflict(&wt, LIVE_CONTEXT_FILE, b"REMOTE version").unwrap();
419	        assert_eq!(a1, a2);
420	        assert_eq!(cs.list_conflicts(&wt, Some(LIVE_CONTEXT_FILE)).unwrap().len(), 1);
421
422	        // A different incoming version is a second artifact, not an overwrite.
423	        cs.record_conflict(&wt, LIVE_CONTEXT_FILE, b"THIRD version").unwrap();
424	        assert_eq!(cs.list_conflicts(&wt, Some(LIVE_CONTEXT_FILE)).unwrap().len(), 2);
425
426	        // Resolve clears exactly this file's artifacts.
427	        assert_eq!(cs.clear_conflicts(&wt, LIVE_CONTEXT_FILE).unwrap(), 2);
428	        assert!(cs.list_conflicts(&wt, None).unwrap().is_empty());
429	        assert_eq!(cs.clear_conflicts(&wt, LIVE_CONTEXT_FILE).unwrap(), 0, "no-op when clean");
430	    }
431
432	    #[test] // [unit->REQ-HAZARD-ID-CHARSET] a path-smuggling id is refused before any branch/dir derives from it.
433	    fn invalid_id_refused() {
434	        let tmp = tempdir().unwrap();
435	        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
436	        for bad in ["../up", "a/b", "a:b", "a@b", ""] {
437	            assert!(cs.live_context_path(bad).is_err(), "{bad:?} must refuse");
438	        }
439	    }
440	}
441

//! The two-tier context store — `tracked/` realized over [`BranchStore`]
//! (M4-D6a; STORAGE §tracked/, ADR-0011, ADR-0013).
//!
//! Layout (the synced *mind*, physically separate from node-local `perches/`):
//!
//! ```text
//! tracked/
//!   .seed.git                    bare BranchStore repo (the one true object DB)
//!   agents/<id>/                 linked worktree of branch  a-<id>
//!     live-context.md            per-agent live context  → syncs to ALL instances
//!   projects/<project_id>/       linked worktree of branch  p-<project_id>
//!     <id>/project-context.md    per-(agent,project)      → same-project instances
//! ```
//!
//! Branches are the sync/merge unit (`a-`/`p-` refs are what D6c's bundle
//! exchange ships); worktrees are the read/write view. Tier routing is the
//! CONTEXT-MEMORY two-tier split: the `a-` branch is the agent's mind
//! everywhere, the `p-` branch holds every agent's context for one project
//! (cross-agent synthesis + query-routing ride that shape).
//!
//! Writes here are **paths + commits only** — the precedence-marker guard
//! (6.5) stays in `spt-live::context`, which writes through the paths this
//! store hands out, then calls the commit hook. Single writer per branch
//! (active-instance-authoritative; 6.4).
// [impl->REQ-STORE-1]

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

use crate::branchstore::BranchStore;
use crate::perch::tracked_dir;
use spt_proto::id::validate_endpoint_id;

/// The per-agent live-context file inside an `a-<id>` worktree.
pub const LIVE_CONTEXT_FILE: &str = "live-context.md";
/// Per-worktree directory holding surfaced concurrent-write conflict artifacts
/// (ADR-0013; tracked, so conflict state replicates).
pub const CONFLICT_DIR: &str = ".conflicts";
/// The per-(agent,project) context file inside a `p-<project>` worktree,
/// nested under the agent id.
pub const PROJECT_CONTEXT_FILE: &str = "project-context.md";

/// The branch carrying `id`'s live context (the per-agent view).
pub fn agent_branch(id: &str) -> String {
    format!("a-{id}")
}

/// The branch carrying `project_id`'s per-project context (all agents).
pub fn project_branch(project_id: &str) -> String {
    format!("p-{project_id}")
}

/// Pure path of `id`'s live-context file under a `tracked/` root — the
/// **read** form (no branch/worktree creation; readers must not mint store
/// state). The single layout authority both the ensure-paths and every reader
/// route through (6.1).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn live_context_file(root: &Path, id: &str) -> PathBuf {
    root.join("agents").join(id).join(LIVE_CONTEXT_FILE)
}

/// Pure path of the per-(agent,project) context file under a `tracked/` root —
/// the **read** form (no creation).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn project_context_file(root: &Path, project_id: &str, id: &str) -> PathBuf {
    root.join("projects").join(project_id).join(id).join(PROJECT_CONTEXT_FILE)
}

/// First 8 hex chars of the SHA-256 of `bytes` — the deterministic conflict
/// artifact name component (every node minting an artifact for the same
/// incoming version produces the same name → recording is idempotent).
fn content_hash8(bytes: &[u8]) -> String {
    use sha2::{Digest, Sha256};
    let digest = Sha256::digest(bytes);
    digest[..4].iter().map(|b| format!("{b:02x}")).collect()
}

/// The two-tier context store rooted at a `tracked/` directory.
pub struct ContextStore {
    store: BranchStore,
    root: PathBuf,
}

impl ContextStore {
    /// Open (or initialize) the node's canonical store at
    /// [`tracked_dir`](crate::perch::tracked_dir).
    pub fn open_or_init() -> std::io::Result<ContextStore> {
        Self::open_or_init_in(&tracked_dir())
    }

    /// Open (or initialize) a store rooted at an explicit `tracked/` dir —
    /// the testable seam.
    // [impl->REQ-STORE-1]
    pub fn open_or_init_in(root: &Path) -> std::io::Result<ContextStore> {
        let store = BranchStore::open_or_init(&root.join(".seed.git"))?;
        Ok(ContextStore { store, root: root.to_path_buf() })
    }

    /// The underlying branch-as-KV store (D6c's bundle exchange works on it).
    pub fn branch_store(&self) -> &BranchStore {
        &self.store
    }

    /// The `tracked/` root this store lives under.
    pub fn root(&self) -> &Path {
        &self.root
    }

    fn validated(id: &str) -> std::io::Result<()> {
        validate_endpoint_id(id)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))
    }

    /// Ensure the `a-<id>` branch + worktree exist; return the live-context
    /// file path inside it (`tracked/agents/<id>/live-context.md`). The id is
    /// charset-validated (4.6) — branch names and worktree dirs are derived
    /// from it.
    // [impl->REQ-STORE-1]
    pub fn live_context_path(&self, id: &str) -> std::io::Result<PathBuf> {
        Self::validated(id)?;
        let file = live_context_file(&self.root, id);
        let wt = file.parent().expect("live-context file has a worktree parent");
        self.store.ensure_worktree(&agent_branch(id), wt)?;
        Ok(file)
    }

    /// Ensure the `p-<project_id>` branch + worktree exist; return the
    /// per-(agent,project) context file path inside it
    /// (`tracked/projects/<project_id>/<id>/project-context.md`). Both
    /// components are charset-validated (4.6; project ids come from
    /// [`crate::project::project_id_for_dir`], which emits the same charset).
    // [impl->REQ-STORE-1]
    pub fn project_context_path(&self, project_id: &str, id: &str) -> std::io::Result<PathBuf> {
        Self::validated(project_id)?;
        Self::validated(id)?;
        let file = project_context_file(&self.root, project_id, id);
        let wt = self.root.join("projects").join(project_id);
        self.store.ensure_worktree(&project_branch(project_id), &wt)?;
        std::fs::create_dir_all(file.parent().expect("project-context file has an agent dir"))?;
        Ok(file)
    }

    /// Commit `id`'s live-context worktree — one checkpoint commit on `a-<id>`
    /// (no-op when nothing changed). Call after a guarded write through
    /// [`live_context_path`](Self::live_context_path).
    // [impl->REQ-STORE-1]
    pub fn commit_live(&self, id: &str, message: &str) -> std::io::Result<Option<String>> {
        Self::validated(id)?;
        let wt = self.root.join("agents").join(id);
        self.store.commit_in_worktree(&wt, &[], message)
    }

    /// Commit the `p-<project_id>` worktree — one checkpoint commit (no-op
    /// when nothing changed).
    // [impl->REQ-STORE-1]
    pub fn commit_project(&self, project_id: &str, message: &str) -> std::io::Result<Option<String>> {
        Self::validated(project_id)?;
        let wt = self.root.join("projects").join(project_id);
        self.store.commit_in_worktree(&wt, &[], message)
    }

    /// Record a surfaced concurrent-write conflict (ADR-0013, hazard 6.6):
    /// persist the **incoming** version as a tracked artifact beside the local
    /// file — `.conflicts/<file>.<hash8>.md` inside the same worktree — and
    /// checkpoint it. The local working file stays untouched (the active mind
    /// is never clobbered by an unresolved conflict), and the artifact
    /// replicates like any context file, so conflict state itself syncs.
    /// Deterministic content-hash naming makes recording idempotent across
    /// nodes — every holder of the same pair mints the same artifact.
    // [impl->REQ-HAZARD-CONFLICT-BOTH-PRESERVED]
    pub fn record_conflict(
        &self,
        worktree: &Path,
        file_rel: &str,
        incoming: &[u8],
    ) -> std::io::Result<PathBuf> {
        let dir = worktree.join(CONFLICT_DIR);
        std::fs::create_dir_all(&dir)?;
        let name = format!(
            "{}.{}.md",
            file_rel.replace(['/', '\\'], "_"),
            content_hash8(incoming)
        );
        let path = dir.join(&name);
        if !path.exists() {
            crate::atomic::atomic_write_string(&path, &String::from_utf8_lossy(incoming))?;
        }
        self.store.commit_in_worktree(
            worktree,
            &[],
            &format!("conflict: {file_rel} ({name})"),
        )?;
        Ok(path)
    }

    /// The surfaced-and-unresolved conflicts in a worktree (artifact paths for
    /// `file_rel`, or all when `None`). Empty = no conflicts pending.
    // [impl->REQ-HAZARD-CONFLICT-BOTH-PRESERVED]
    pub fn list_conflicts(
        &self,
        worktree: &Path,
        file_rel: Option<&str>,
    ) -> std::io::Result<Vec<PathBuf>> {
        let dir = worktree.join(CONFLICT_DIR);
        let mut out = Vec::new();
        let Ok(entries) = std::fs::read_dir(&dir) else {
            return Ok(out); // no dir = no conflicts
        };
        let prefix = file_rel.map(|f| format!("{}.", f.replace(['/', '\\'], "_")));
        for e in entries.flatten() {
            let name = e.file_name().to_string_lossy().to_string();
            if prefix.as_ref().is_none_or(|p| name.starts_with(p.as_str())) {
                out.push(e.path());
            }
        }
        out.sort();
        Ok(out)
    }

    /// Clear `file_rel`'s conflict artifacts after a dominating (reconciled)
    /// write landed — `git rm` + one checkpoint commit. A no-op when none
    /// exist. Never called by the merge path on anything but a dominating
    /// write: an unresolved pair must outlive every failure (hazard 6.6).
    // [impl->REQ-HAZARD-CONFLICT-BOTH-PRESERVED]
    pub fn clear_conflicts(&self, worktree: &Path, file_rel: &str) -> std::io::Result<usize> {
        let paths = self.list_conflicts(worktree, Some(file_rel))?;
        if paths.is_empty() {
            return Ok(0);
        }
        for p in &paths {
            std::fs::remove_file(p)?;
        }
        self.store
            .commit_in_worktree(worktree, &[], &format!("conflict resolved: {file_rel}"))?;
        Ok(paths.len())
    }

    /// The context-store arm of `spt rename <old> <new>` (REQ-INST-11, the
    /// D3f-documented seam): move the `a-<old>` branch + its worktree to
    /// `a-<new>`, and rename `<old>/` → `<new>/` inside every project worktree
    /// (one rename commit per touched project). Refuses when `a-<new>` already
    /// exists (never merge two minds); an absent `a-<old>` is a no-op `Ok`
    /// (nothing tracked yet — perch-only endpoints rename cleanly).
    // [impl->REQ-INST-11]
    pub fn rename_endpoint(&self, old_id: &str, new_id: &str) -> std::io::Result<()> {
        Self::validated(old_id)?;
        Self::validated(new_id)?;
        if self.store.has_branch(&agent_branch(new_id))? {
            return Err(std::io::Error::new(
                std::io::ErrorKind::AlreadyExists,
                format!("context branch already exists: {}", agent_branch(new_id)),
            ));
        }

        // a- arm: move worktree dir, then rename the branch (git updates the
        // worktree's HEAD symref). Skip when the old branch never existed.
        if self.store.has_branch(&agent_branch(old_id))? {
            let old_wt = self.root.join("agents").join(old_id);
            let new_wt = self.root.join("agents").join(new_id);
            if old_wt.join(".git").exists() {
                crate::gitrun::run_git_ok(
                    &[
                        "-C",
                        &self.root.join(".seed.git").to_string_lossy(),
                        "worktree",
                        "move",
                        &old_wt.to_string_lossy(),
                        &new_wt.to_string_lossy(),
                    ],
                    None,
                    None,
                )?;
            }
            crate::gitrun::run_git_ok(
                &[
                    "-C",
                    &self.root.join(".seed.git").to_string_lossy(),
                    "branch",
                    "-m",
                    &agent_branch(old_id),
                    &agent_branch(new_id),
                ],
                None,
                None,
            )?;
        }

        // p- arm: rename the agent dir inside every project worktree holding one.
        let projects = self.root.join("projects");
        if let Ok(entries) = std::fs::read_dir(&projects) {
            for entry in entries.flatten() {
                let wt = entry.path();
                if !wt.join(".git").exists() || !wt.join(old_id).exists() {
                    continue;
                }
                crate::gitrun::run_git_ok(&["mv", old_id, new_id], Some(&wt), None)?;
                self.store.commit_in_worktree(
                    &wt,
                    &[],
                    &format!("rename {old_id} -> {new_id}"),
                )?;
            }
        }
        Ok(())
    }
}

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

    #[test] // [unit->REQ-STORE-1] tier paths land on the documented layout; commits checkpoint per branch.
    fn two_tier_layout_and_commits() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();

        let live = cs.live_context_path("doyle").unwrap();
        assert!(live.ends_with(Path::new("agents").join("doyle").join(LIVE_CONTEXT_FILE)));
        std::fs::write(&live, "live mind").unwrap();
        let c = cs.commit_live("doyle", "live v1").unwrap();
        assert!(c.is_some());
        assert_eq!(
            cs.branch_store().read_at_tip(&agent_branch("doyle"), LIVE_CONTEXT_FILE).unwrap(),
            Some(b"live mind".to_vec())
        );

        let proj = cs.project_context_path("spt-core", "doyle").unwrap();
        assert!(proj.ends_with(
            Path::new("projects").join("spt-core").join("doyle").join(PROJECT_CONTEXT_FILE)
        ));
        std::fs::write(&proj, "project mind").unwrap();
        let c = cs.commit_project("spt-core", "proj v1").unwrap();
        assert!(c.is_some());
        assert_eq!(
            cs.branch_store()
                .read_at_tip(&project_branch("spt-core"), "doyle/project-context.md")
                .unwrap(),
            Some(b"project mind".to_vec())
        );
    }

    #[test] // [unit->REQ-STORE-1] the p- branch aggregates multiple agents (cross-agent synthesis shape).
    fn project_branch_holds_many_agents() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
        for id in ["doyle", "momo"] {
            let p = cs.project_context_path("spt-core", id).unwrap();
            std::fs::write(&p, format!("{id} ctx")).unwrap();
        }
        cs.commit_project("spt-core", "both").unwrap().unwrap();
        let bs = cs.branch_store();
        assert!(bs.read_at_tip(&project_branch("spt-core"), "doyle/project-context.md").unwrap().is_some());
        assert!(bs.read_at_tip(&project_branch("spt-core"), "momo/project-context.md").unwrap().is_some());
    }

    #[test] // [unit->REQ-INST-11] rename moves the a- branch + worktree and ripples every p- agent dir; target collision refuses.
    fn rename_ripples_both_tiers() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
        let live = cs.live_context_path("doyle").unwrap();
        std::fs::write(&live, "mind").unwrap();
        cs.commit_live("doyle", "v1").unwrap().unwrap();
        let proj = cs.project_context_path("spt-core", "doyle").unwrap();
        std::fs::write(&proj, "proj mind").unwrap();
        cs.commit_project("spt-core", "v1").unwrap().unwrap();

        cs.rename_endpoint("doyle", "arthur").unwrap();

        // a- arm: branch + worktree moved, content intact, old gone.
        assert!(cs.branch_store().has_branch(&agent_branch("arthur")).unwrap());
        assert!(!cs.branch_store().has_branch(&agent_branch("doyle")).unwrap());
        assert_eq!(
            std::fs::read_to_string(live_context_file(tmp.path(), "arthur")).unwrap(),
            "mind"
        );
        assert!(!tmp.path().join("agents").join("doyle").exists());

        // p- arm: agent dir renamed inside the project worktree + committed.
        assert_eq!(
            cs.branch_store()
                .read_at_tip(&project_branch("spt-core"), "arthur/project-context.md")
                .unwrap(),
            Some(b"proj mind".to_vec())
        );
        assert!(cs
            .branch_store()
            .read_at_tip(&project_branch("spt-core"), "doyle/project-context.md")
            .unwrap()
            .is_none());

        // Collision: an existing target mind refuses (never merge two minds).
        cs.live_context_path("momo").unwrap();
        let err = cs.rename_endpoint("arthur", "momo").unwrap_err();
        assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);

        // Absent source: a perch-only endpoint renames as a clean no-op.
        cs.rename_endpoint("ghost", "phantom").unwrap();
    }

    #[test] // [unit->REQ-HAZARD-CONFLICT-BOTH-PRESERVED] a surfaced conflict preserves BOTH versions; only a resolve clears it.
    fn conflict_artifacts_preserve_both_versions() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
        let live = cs.live_context_path("doyle").unwrap();
        std::fs::write(&live, "LOCAL version").unwrap();
        cs.commit_live("doyle", "local").unwrap();
        let wt = live.parent().unwrap().to_path_buf();

        // Surface a concurrent incoming: artifact minted, local untouched.
        let a1 = cs.record_conflict(&wt, LIVE_CONTEXT_FILE, b"REMOTE version").unwrap();
        assert_eq!(std::fs::read_to_string(&a1).unwrap(), "REMOTE version");
        assert_eq!(std::fs::read_to_string(&live).unwrap(), "LOCAL version");
        // Tracked: the artifact is in the branch tip (replicates like context).
        let rel = format!("{CONFLICT_DIR}/{}", a1.file_name().unwrap().to_string_lossy());
        assert!(cs.branch_store().read_at_tip(&agent_branch("doyle"), &rel).unwrap().is_some());

        // Idempotent: same incoming bytes → same artifact, no duplicate.
        let a2 = cs.record_conflict(&wt, LIVE_CONTEXT_FILE, b"REMOTE version").unwrap();
        assert_eq!(a1, a2);
        assert_eq!(cs.list_conflicts(&wt, Some(LIVE_CONTEXT_FILE)).unwrap().len(), 1);

        // A different incoming version is a second artifact, not an overwrite.
        cs.record_conflict(&wt, LIVE_CONTEXT_FILE, b"THIRD version").unwrap();
        assert_eq!(cs.list_conflicts(&wt, Some(LIVE_CONTEXT_FILE)).unwrap().len(), 2);

        // Resolve clears exactly this file's artifacts.
        assert_eq!(cs.clear_conflicts(&wt, LIVE_CONTEXT_FILE).unwrap(), 2);
        assert!(cs.list_conflicts(&wt, None).unwrap().is_empty());
        assert_eq!(cs.clear_conflicts(&wt, LIVE_CONTEXT_FILE).unwrap(), 0, "no-op when clean");
    }

    #[test] // [unit->REQ-HAZARD-ID-CHARSET] a path-smuggling id is refused before any branch/dir derives from it.
    fn invalid_id_refused() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
        for bad in ["../up", "a/b", "a:b", "a@b", ""] {
            assert!(cs.live_context_path(bad).is_err(), "{bad:?} must refuse");
        }
    }
}


Fork mechanics clear (seed-commit copy, both tiers). Check budget rerun:


Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.


ScheduleWakeup

Fallback heartbeat in case the budget-run watcher notification never fires; primary wake is the background task completing.

Prompt: Check the D9-3 budget CI run watcher (task b27lewgwl); if green, push D9-4 (already committed-ready in working tree) and continue D9-5 per M4-D9-PLAN.


Next wakeup scheduled for 06:21:00 (in 1556s). Nothing more to do this turn — the harness re-invokes you when the wakeup fires or a task-notification arrives.


D9-3: dormancy budget measurement + DORMANCY-BUDGET.md + ADR-0003 #9 close

SPT_BUDGET=1 harness (done, uncommitted). HFENDULEAM runs done (shell 7.7MiB, claude 308MiB, zero idle CPU). Remaining: gravity-linux leg (workflow_dispatch budget job on gravity runner), docs/DORMANCY-BUDGET.md (methodology + numbers + policy lock, doc->REQ-INST-3), ADR-0003 #9 amendment, CONTEXT.md policy confirm. CI green.

D9-4: REQ-INST-14 resource advertisement (blurb on Instance + spt resources CLI + projection)

resources: Option<String> on Instance (serde-default, rides RegistryUpdate + lease); resource_projection over visible+routable rows; info.json resources field + set_resources; daemon.json resources_blurb node seed (drop Copy on DaemonConfig); advertise_local fills endpoint-else-node blurb; spt resources set/show/list CLI; CONTEXT doc tag; activate REQ-INST-14 [doc, impl, unit]. CI green.


Waiting on the budget CI run. Wakeup armed as fallback; the task notification is the primary signal.


<task-notification>
<task-id>b27lewgwl</task-id>
<tool-use-id>toolu_01TAno6bsUVxkUrfAsQCAKpL</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\b27lewgwl.output</output-file>
<status>completed</status>
<summary>Background command "Wait for D9-3 finalize budget run" completed (exit code 0)</summary>
</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.


1	6f61a89 completed failure
2

6f61a89 completed failure


Failed again. Diagnose:


PowerShell

$run = (gh run list --branch dev-freeform --limit 1 --json databaseId | ConvertFrom-Json).databaseId; gh run view $run --json jobs --jq '.jobs[] | "\(.name): \(.conclusion) — " + ([.steps[] | select(.conclusion=="failure") | .name] | join(", "))'

Show failed jobs/steps


traceability: success —
test (self-hosted, Windows, hfenduleam): failure — Dormancy budget (real adapter seats, when installed) — windows
test (self-hosted, Linux, gravity): success —


$run = (gh run list --branch dev-freeform --limit 1 --json databaseId | ConvertFrom-Json).databaseId; gh run view $run --log-failed 2>&1 | ForEach-Object { ($_ -split "`t")[-1] } | Select-Object -Last 30

Failed windows budget step log


2026-06-04T12:57:05.8501260Z ##[group]Run if (Get-Command claude -ErrorAction SilentlyContinue) {
2026-06-04T12:57:05.8501628Z if (Get-Command claude -ErrorAction SilentlyContinue) {
2026-06-04T12:57:05.8501849Z   $env:SPT_BUDGET = "1"
2026-06-04T12:57:05.8502042Z   $env:SPT_BUDGET_N = "3"
2026-06-04T12:57:05.8502333Z   $env:SPT_BUDGET_PROGRAM = "claude"
2026-06-04T12:57:05.8502517Z   $env:SPT_BUDGET_SETTLE_MS = "15000"
2026-06-04T12:57:05.8502753Z   cargo test -p spt-daemon --test budget --release -- --nocapture
2026-06-04T12:57:05.8502983Z } else {
2026-06-04T12:57:05.8503192Z   Write-Output "claude not installed on this runner — shell numbers only"
2026-06-04T12:57:05.8503437Z }
2026-06-04T12:57:05.8533108Z shell: C:\WINDOWS\system32\windowspowershell\v1.0\powershell.EXE -command ". '{0}'"
2026-06-04T12:57:05.8533379Z ##[endgroup]
2026-06-04T12:57:06.0885802Z At C:\actions-runner\_work\_temp\6918d8b0-cee5-414b-879d-2205a8e6bf11.ps1:9 char:75
2026-06-04T12:57:06.0886405Z + ... e-Output "claude not installed on this runner â€” shell numbers only"
2026-06-04T12:57:06.0886654Z +                                                                         ~
2026-06-04T12:57:06.0886841Z The string is missing the terminator: ".
2026-06-04T12:57:06.0887629Z At C:\actions-runner\_work\_temp\6918d8b0-cee5-414b-879d-2205a8e6bf11.ps1:8 char:8
2026-06-04T12:57:06.0887865Z + } else {
2026-06-04T12:57:06.0887970Z +        ~
2026-06-04T12:57:06.0888142Z Missing closing '}' in statement block or type definition.
2026-06-04T12:57:06.0888403Z     + CategoryInfo          : ParserError: (:) [], ParseException
2026-06-04T12:57:06.0888653Z     + FullyQualifiedErrorId : TerminatorExpectedAtEndOfString
2026-06-04T12:57:06.0888844Z
2026-06-04T12:57:06.1535891Z ##[error]Process completed with exit code 1.


Edit

          } else {
            Write-Output "claude not installed on this runner — shell numbers only"
          }

          } else {
            # ASCII only: Windows PowerShell 5.1 reads the step's temp script
            # without a BOM as ANSI, so a multibyte dash corrupts the string
            # terminator (the first tagged-run failure mode here).
            Write-Output "claude not installed on this runner - shell numbers only"
          }


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

          } else {
            # ASCII only: Windows PowerShell 5.1 reads the step's temp script
            # without a BOM as ANSI, so a multibyte dash corrupts the string
            # terminator (the first tagged-run failure mode here).
            Write-Output "claude not installed on this runner - shell numbers only"
          }

name: ci

on:
  push:
    branches: [main, dev-freeform]
  pull_request:

# Cancel superseded runs on the same ref.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ---- Rust build + test + lint (cross-platform: exercises both the
  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
  #
  # CI-SELFHOST-PLAN.md: fully self-hosted (2026-06-03) — hfenduleam carries
  # the Windows leg, gravity-linux the Linux leg + traceability. Free minutes;
  # persistent cargo/target = warm builds (the multi-exe iroh link cost is
  # paid once per machine); hung tests debuggable on-box. Both boxes own
  # their rust toolchains (rustup-managed); no toolchain action, no
  # rust-cache (the persistent target dir IS the cache).
  test:
    strategy:
      fail-fast: false
      matrix:
        runner: [[self-hosted, Linux, gravity], [self-hosted, Windows, hfenduleam]]
    runs-on: ${{ matrix.runner }}
    # A wedged test must not block the single self-hosted runner slot (the
    # 2026-06-03 handoff.rs ConPTY stall burned 22 hosted minutes unbounded).
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v5
      - name: Build
        run: cargo build --workspace --all-targets
      - name: Test
        run: cargo test --workspace
      - name: Clippy (deny warnings)
        run: cargo clippy --workspace --all-targets -- -D warnings

      # ---- D9-3 dormancy-budget measurement (ADR-0003 red-team #9) ----
      # Opt-in per push: runs ONLY when the head commit message carries
      # "[budget]" — both rig hosts measure in one tagged push, output lands
      # in the run log and is captured into docs/DORMANCY-BUDGET.md. The
      # harness itself is also env-gated (SPT_BUDGET=1), so an untagged CI
      # run silent-skips it inside the normal test sweep.
      - name: Dormancy budget (shell seats)
        if: contains(github.event.head_commit.message, '[budget]')
        run: cargo test -p spt-daemon --test budget --release -- --nocapture
        env:
          SPT_BUDGET: "1"
      # Two shells: the Windows runner has no bash on PATH (the first tagged
      # run failed on `shell: bash` there), so each OS keeps its native one.
      - name: Dormancy budget (real adapter seats, when installed) — linux
        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Linux'
        shell: bash
        run: |
          if command -v claude >/dev/null; then
            SPT_BUDGET=1 SPT_BUDGET_N=3 SPT_BUDGET_PROGRAM=claude SPT_BUDGET_SETTLE_MS=15000 \
              cargo test -p spt-daemon --test budget --release -- --nocapture
          else
            echo "claude not installed on this runner — shell numbers only"
          fi
      - name: Dormancy budget (real adapter seats, when installed) — windows
        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Windows'
        shell: powershell
        run: |
          if (Get-Command claude -ErrorAction SilentlyContinue) {
            $env:SPT_BUDGET = "1"
            $env:SPT_BUDGET_N = "3"
            $env:SPT_BUDGET_PROGRAM = "claude"
            $env:SPT_BUDGET_SETTLE_MS = "15000"
            cargo test -p spt-daemon --test budget --release -- --nocapture
          } else {
            Write-Output "claude not installed on this runner — shell numbers only"
          }

  # ---- Requirement-traceability hard gate (TRACEABILITY.md layer 1) ----
  traceability:
    runs-on: [self-hosted, Linux, gravity]
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v5
      - name: Install traceable-reqs
        # The self-hosted gravity runner carries a source-built binary in
        # /usr/local/bin (the release asset needs GLIBC 2.39; gravity's
        # Ubuntu 22.04 has 2.35). The download is the portability fallback
        # for any other runner.
        run: |
          if command -v traceable-reqs >/dev/null; then
            ln -sf "$(command -v traceable-reqs)" ./traceable-reqs
          else
            gh release download --repo BigscreenVR/traceable-reqs \
              --pattern '*linux-x86_64' --output traceable-reqs
            chmod +x traceable-reqs
          fi
        env:
          # Cross-org private repo: the default repo-scoped GITHUB_TOKEN cannot
          # read BigscreenVR/traceable-reqs. Supply a PAT with read access to it
          # as the TRACEABLE_REQS_TOKEN repo secret. See README/CI notes.
          GH_TOKEN: ${{ secrets.TRACEABLE_REQS_TOKEN }}
      - name: Coverage gate (hard — fails PR on uncovered activated req)
        run: ./traceable-reqs check --json
      - name: Quality audit (advisory)
        run: ./traceable-reqs lint || true

-            Write-Output "claude not installed on this runner — shell numbers only"

+            # ASCII only: Windows PowerShell 5.1 reads the step's temp script

+            # without a BOM as ANSI, so a multibyte dash corrupts the string

+            # terminator (the first tagged-run failure mode here).

+            Write-Output "claude not installed on this runner - shell numbers only"


PowerShell

git add .github/workflows/ci.yml && git commit -m @'
fix(ci): ASCII-only string in the Windows budget step [budget]

Windows PowerShell 5.1 reads the runner's temp step script as ANSI (no
BOM), so the em-dash in the fallback message decoded as a multibyte
sequence that swallowed the string terminator and failed the parse
before anything ran. ASCII dash; comment pins the failure mode.

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

Commit + push CI step encoding fix


warning: in the working copy of '.github/workflows/ci.yml', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 650c7fb] fix(ci): ASCII-only string in the Windows budget step [budget]
 1 file changed, 4 insertions(+), 1 deletion(-)
To https://github.com/SaberMage/spt-core.git
   6f61a89..650c7fb  dev-freeform -> dev-freeform


Bash

until gh run list --branch dev-freeform --limit 1 --json status,conclusion,headSha --jq '.[0]' | grep -q '"status":"completed"'; do sleep 30; done; gh run list --branch dev-freeform --limit 1 --json status,conclusion,headSha --jq '.[0] | "\(.headSha[0:7]) \(.status) \(.conclusion)"'

Wait for encoding-fix budget run


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


Encoding fix pushed (650c7fb, tagged `[budget]`). Watcher armed — will push D9-4 when this run lands green.


<task-notification>
<task-id>br2wnvknu</task-id>
<tool-use-id>toolu_017mpvy34iQ9rVsbpk1Yf1qX</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\br2wnvknu.output</output-file>
<status>completed</status>
<summary>Background command "Wait for encoding-fix budget run" completed (exit code 0)</summary>
</task-notification>


1	650c7fb completed success
2

650c7fb completed success


FINAL green. Pull the runner-context claude numbers for the record:


PowerShell

$run = (gh run list --branch dev-freeform --limit 1 --json databaseId | ConvertFrom-Json).databaseId; gh run view $run --log 2>&1 | Select-String -Pattern "## dormancy|per-seat avg|claude not installed" | ForEach-Object { ($_.Line -split "`t")[-1] -replace '^.*?Z ','' } | Select-Object -First 10

Extract budget confirmation lines


## dormancy budget — 5 x `sh `
per-seat avg: rss 1.7 MiB · 4 handles/fds · idle cpu 0.000 s/5s
  echo "claude not installed on this runner — shell numbers only"
claude not installed on this runner — shell numbers only
## dormancy budget — 5 x `cmd `
per-seat avg: rss 7.6 MiB · 85 handles/fds · idle cpu 0.000 s/5s
  Write-Output "claude not installed on this runner - shell numbers only"
## dormancy budget — 3 x `claude `
per-seat avg: rss 308.7 MiB · 242 handles/fds · idle cpu 0.005 s/5s


Updated task #4 status


git add -A; git diff --cached --stat | Select-Object -Last 16

Stage D9-4


warning: in the working copy of 'CONTEXT.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/config.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/reconcile.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/registryhost.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/peerloop.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/replicate.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/sync.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/registry.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/replicate.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/wanmsg.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/cli.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/wansend.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs/STORAGE.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
 CONTEXT.md                            |   1 +
 crates/spt-daemon/src/config.rs       |  15 +++-
 crates/spt-daemon/src/reconcile.rs    |   2 +-
 crates/spt-daemon/src/registryhost.rs |  74 ++++++++++++++++-
 crates/spt-daemon/tests/peerloop.rs   |   2 +-
 crates/spt-daemon/tests/replicate.rs  |   4 +-
 crates/spt-daemon/tests/sync.rs       |   4 +-
 crates/spt-net/src/net/registry.rs    | 130 +++++++++++++++++++++++++++++-
 crates/spt-net/src/net/replicate.rs   |   2 +-
 crates/spt-net/src/net/wanmsg.rs      |   2 +-
 crates/spt-store/src/info.rs          |  49 ++++++++++++
 crates/spt/src/cli.rs                 | 147 ++++++++++++++++++++++++++++++++++
 crates/spt/src/wansend.rs             |   2 +-
 docs/STORAGE.md                       |  21 +++++
 traceable-reqs.toml                   |   2 +-
 15 files changed, 439 insertions(+), 18 deletions(-)


git commit -m @'
feat(net,daemon,store,spt): M4-D9-4 resource advertisement (REQ-INST-14 activated)

The agent yellow-pages, exactly as CONTEXT scopes it — a field and a
projection, not a new store:

- registry Instance grows resources: Option<String> (additive
  serde-default; a pre-D9-4 row parses clean). A blurb edit is an
  ordinary epoch-bumped update — it rides the existing lease and
  RegistryUpdate replication untouched; no new record kind, no new merge
  surface.
- resource_projection — the (id, node, status, blurb) view over VISIBLE
  rows only (the same exclusion closure resolution uses, ADR-0006 §6:
  the projection can never list what routing would refuse) with Offline
  rows skipped (a dead instance serves nothing); absent blurbs render
  clean. Status::routable is now pub.
- Both-authored sourcing: the endpoint's own info.json resources
  (spt resources set / set_resources) wins; daemon.json resources_blurb
  seeds the node default for unauthored endpoints (DaemonConfig drops
  Copy for the String field). advertise_local fills endpoint-else-node.
- spt resources set/show/list — set/show on the local perch record; list
  renders the projection off the stale-tolerant registry snapshots, per
  member subnet, visibility-filtered.
- Doc: STORAGE.md registry-snapshots + blurb section (doc evidence);
  CONTEXT entry tagged for cross-reference. The whitelist leg of the
  gate (viewer-node filtering) rides the deferred consent/whitelist
  generalization — visibility is the v1 gate.

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

Commit and push D9-4


[dev-freeform 0ea318b] feat(net,daemon,store,spt): M4-D9-4 resource advertisement (REQ-INST-14 activated)
 15 files changed, 439 insertions(+), 18 deletions(-)
To https://github.com/SaberMage/spt-core.git
   650c7fb..0ea318b  dev-freeform -> dev-freeform


Bash

until gh run list --branch dev-freeform --limit 1 --json status,conclusion,headSha --jq '.[0]' | grep -q '"status":"completed"'; do sleep 30; done; gh run list --branch dev-freeform --limit 1 --json status,conclusion,headSha --jq '.[0] | "\(.headSha[0:7]) \(.status) \(.conclusion)"'

Wait for D9-4 CI run


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


D9-4 pushed (0ea318b), watcher armed. D9-5-1 now — check api startup creation seam:


crates\spt\src\api\worker.rs-40-    if let Err(e) = std::fs::create_dir_all(&perch_path) {
crates\spt\src\api\worker.rs-41-        eprintln!("WORKER_START_FAIL:{id}: {e}");
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\worker.rs-42-        return EXIT_REFUSED;
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\worker.rs-43-    }
crates\spt\src\api\worker.rs:44:    let rec = InfoJson::new(id, &now_stamp(), std::process::id(), "", "worker");
crates\spt\src\api\worker.rs:45:    if let Err(e) = info::write_info(&perch_path, &rec) {
crates\spt\src\api\worker.rs-46-        eprintln!("WORKER_START_FAIL:{id}: {e}");
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\worker.rs-47-        return EXIT_REFUSED;
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\worker.rs-48-    }
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\worker.rs-49-    // ready marker = the live-child signal the cascade-wipe guard checks.
crates\spt\src\api\worker.rs-50-    let _ = std::fs::write(
crates\spt\src\api\worker.rs-51-        perch::resolve_ready_file(id, ParentHint::Explicit(parent)),
--
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\worker.rs-126-
crates\spt\src\api\worker.rs-127-    fn establish_parent(id: &str) {
crates\spt\src\api\worker.rs-128-        let path = perch::resolve_perch_path(id, ParentHint::Infer);
crates\spt\src\api\worker.rs-129-        std::fs::create_dir_all(&path).unwrap();
crates\spt\src\api\worker.rs:130:        info::write_info(
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\worker.rs-131-            &path,
crates\spt\src\api\worker.rs:132:            &InfoJson::new(id, "0", std::process::id(), "sid", "live_agent"),
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\worker.rs-133-        )
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\worker.rs-134-        .unwrap();
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\worker.rs-135-    }
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\worker.rs-136-
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\worker.rs-137-    #[test] // [unit->REQ-API-2] a worker nests under its parent and is addressable.
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\worker.rs-138-    fn worker_start_nests_under_parent() {
--
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\delivery.rs-159-
crates\spt\src\api\delivery.rs-160-    fn establish(id: &str) {
crates\spt\src\api\delivery.rs-161-        let path = perch::resolve_perch_path(id, ParentHint::Infer);
crates\spt\src\api\delivery.rs-162-        std::fs::create_dir_all(&path).unwrap();
crates\spt\src\api\delivery.rs:163:        info::write_info(
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\delivery.rs-164-            &path,
crates\spt\src\api\delivery.rs:165:            &InfoJson::new(id, "0", std::process::id(), "sid", "live_agent"),
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\delivery.rs-166-        )
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\delivery.rs-167-        .unwrap();
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\delivery.rs-168-    }
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\delivery.rs-169-
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\delivery.rs-170-    #[test] // [unit->REQ-SEAM-ACTIVITY] idle writes .idle + arms the gate; busy clears .idle.
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\delivery.rs-171-    fn idle_busy_transitions_manage_sentinels() {
--
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\auth.rs-116-
crates\spt\src\api\auth.rs-117-    fn establish(id: &str, session_id: &str) -> String {
crates\spt\src\api\auth.rs-118-        let path = perch::resolve_perch_path(id, ParentHint::Infer);
crates\spt\src\api\auth.rs-119-        std::fs::create_dir_all(&path).unwrap();
crates\spt\src\api\auth.rs:120:        info::write_info(
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\auth.rs-121-            &path,
crates\spt\src\api\auth.rs:122:            &InfoJson::new(id, "2026-06-01T00:00:00Z", 123, session_id, "ready_agent"),
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\auth.rs-123-        )
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\auth.rs-124-        .unwrap();
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\auth.rs-125-        let token = mint_token();
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\auth.rs-126-        write_token(&path, &token).unwrap();
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\auth.rs-127-        token
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\auth.rs-128-    }
--
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\startup.rs-138-        }
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\startup.rs-139-    }
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\startup.rs-140-
crates\spt\src\api\startup.rs-141-    std::fs::create_dir_all(&perch_path).map_err(|e| BindError::Io(e.to_string()))?;
crates\spt\src\api\startup.rs:142:    let mut rec = InfoJson::new(id, &now_stamp(), std::process::id(), session_id, "live_agent");

[Showing results with pagination = limit: 60]


95	// [impl->REQ-START-3]
96	// [impl->REQ-HAZARD-WINDOWS-PID-RECYCLE]
97	pub fn bind_from_seed(id: &str, parent_pid: u32) -> Result<String, BindError> {
98	    let seed = take_seed_from_daemon(parent_pid)?.ok_or(BindError::NoSeed(parent_pid))?;
99	    if seed.session_id.is_empty() {
100	        return Err(BindError::EmptySession);
101	    }
102	    // Pid-recycle defense (5.1): anchor liveness on the stable parent pid. A
103	    // dead anchor means the harness that wrote the seed is gone — a recycled or
104	    // abandoned seed must never bind.
105	    if !proc::is_process_alive(seed.parent_pid) {
106	        return Err(BindError::StaleSeed(seed.parent_pid));
107	    }
108	    establish_perch(id, &seed.session_id, seed.parent_pid)
109	}
110
111	/// TAKE (consume) the seed for `parent_pid` from the daemon's in-memory map,
112	/// auto-starting the daemon if absent (REQ-DAEMON-3 / REQ-START-3).
113	fn take_seed_from_daemon(parent_pid: u32) -> Result<Option<Seed>, BindError> {
114	    spt_daemon::ensure_running()
115	        .map_err(|e| BindError::Io(format!("daemon unavailable: {e}")))?;
116	    spt_daemon::take_seed(&spt_daemon::seed_socket_name(), parent_pid)
117	        .map_err(|e| BindError::Io(e.to_string()))
118	}
119
120	/// Write the perch record (skeleton→live) + ready marker + capability token.
121	/// Reviving an existing perch keeps its token; a live perch held under a
122	/// different session id is a conflict (pid-recycle / id collision guard).
123	fn establish_perch(id: &str, session_id: &str, parent_pid: u32) -> Result<String, BindError> {
124	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
125
126	    if let Some(existing) = info::read_info(&perch_path) {
127	        let owner_alive = info::read_pid(&perch_path)
128	            .map(proc::is_process_alive)
129	            .unwrap_or(false);
130	        if owner_alive
131	            && !existing.session_id.is_empty()
132	            && existing.session_id != session_id
133	        {
134	            return Err(BindError::Conflict {
135	                id: id.to_string(),
136	                held: existing.session_id,
137	            });
138	        }
139	    }
140
141	    std::fs::create_dir_all(&perch_path).map_err(|e| BindError::Io(e.to_string()))?;
142	    let mut rec = InfoJson::new(id, &now_stamp(), std::process::id(), session_id, "live_agent");
143	    rec.parent_pid = Some(parent_pid);
144	    info::write_info(&perch_path, &rec).map_err(|e| BindError::Io(e.to_string()))?;
145	    std::fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "")
146	        .map_err(|e| BindError::Io(e.to_string()))?;
147
148	    // Keep an existing token across a revive; mint one for a fresh perch.
149	    let token = auth::read_token(&perch_path).unwrap_or_else(auth::mint_token);
150	    auth::write_token(&perch_path, &token).map_err(|e| BindError::Io(e.to_string()))?;
151	    Ok(token)
152	}
153
154	/// `api listen <id>` — consume the seed, bind the perch, then run the relay
155	/// loop (heir to `$LIVE start`'s Monitor-bound poll loop). `--once` drains and
156	/// exits (testability); otherwise blocks streaming messages to stdout.
157	///
158	/// When the manifest is **live-capable** (declares `[session.psyche_init]`), the
159	/// loop also hosts the LiveAgent lifecycle over the M2b `spt-live` seams: sweep
160	/// stale signoff on start (3.2), spawn the Psyche (T2), and run a pulse tick
161	/// each cycle (drop ingest + echo fires — T3/T4/T5). The graceful signoff (T6)
162	/// is driven by the shutdown path; M3's daemon re-hosts all of it.
163	// [impl->REQ-START-2]
164	// [impl->REQ-SEAM-PSYCHE]
165	pub fn cmd_listen(
166	    id: &str,
167	    parent_pid_override: Option<u32>,
168	    once: bool,
169	    manifest: Option<&spt_runtime::Manifest>,
170	) -> i32 {
171	    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
172	    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
173	        eprintln!("INVALID_ID:{id}: {e}");
174	        return EXIT_REFUSED;

// [impl->REQ-START-3]
// [impl->REQ-HAZARD-WINDOWS-PID-RECYCLE]
pub fn bind_from_seed(id: &str, parent_pid: u32) -> Result<String, BindError> {
    let seed = take_seed_from_daemon(parent_pid)?.ok_or(BindError::NoSeed(parent_pid))?;
    if seed.session_id.is_empty() {
        return Err(BindError::EmptySession);
    }
    // Pid-recycle defense (5.1): anchor liveness on the stable parent pid. A
    // dead anchor means the harness that wrote the seed is gone — a recycled or
    // abandoned seed must never bind.
    if !proc::is_process_alive(seed.parent_pid) {
        return Err(BindError::StaleSeed(seed.parent_pid));
    }
    establish_perch(id, &seed.session_id, seed.parent_pid)
}

/// TAKE (consume) the seed for `parent_pid` from the daemon's in-memory map,
/// auto-starting the daemon if absent (REQ-DAEMON-3 / REQ-START-3).
fn take_seed_from_daemon(parent_pid: u32) -> Result<Option<Seed>, BindError> {
    spt_daemon::ensure_running()
        .map_err(|e| BindError::Io(format!("daemon unavailable: {e}")))?;
    spt_daemon::take_seed(&spt_daemon::seed_socket_name(), parent_pid)
        .map_err(|e| BindError::Io(e.to_string()))
}

/// Write the perch record (skeleton→live) + ready marker + capability token.
/// Reviving an existing perch keeps its token; a live perch held under a
/// different session id is a conflict (pid-recycle / id collision guard).
fn establish_perch(id: &str, session_id: &str, parent_pid: u32) -> Result<String, BindError> {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);

    if let Some(existing) = info::read_info(&perch_path) {
        let owner_alive = info::read_pid(&perch_path)
            .map(proc::is_process_alive)
            .unwrap_or(false);
        if owner_alive
            && !existing.session_id.is_empty()
            && existing.session_id != session_id
        {
            return Err(BindError::Conflict {
                id: id.to_string(),
                held: existing.session_id,
            });
        }
    }

    std::fs::create_dir_all(&perch_path).map_err(|e| BindError::Io(e.to_string()))?;
    let mut rec = InfoJson::new(id, &now_stamp(), std::process::id(), session_id, "live_agent");
    rec.parent_pid = Some(parent_pid);
    info::write_info(&perch_path, &rec).map_err(|e| BindError::Io(e.to_string()))?;
    std::fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "")
        .map_err(|e| BindError::Io(e.to_string()))?;

    // Keep an existing token across a revive; mint one for a fresh perch.
    let token = auth::read_token(&perch_path).unwrap_or_else(auth::mint_token);
    auth::write_token(&perch_path, &token).map_err(|e| BindError::Io(e.to_string()))?;
    Ok(token)
}

/// `api listen <id>` — consume the seed, bind the perch, then run the relay
/// loop (heir to `$LIVE start`'s Monitor-bound poll loop). `--once` drains and
/// exits (testability); otherwise blocks streaming messages to stdout.
///
/// When the manifest is **live-capable** (declares `[session.psyche_init]`), the
/// loop also hosts the LiveAgent lifecycle over the M2b `spt-live` seams: sweep
/// stale signoff on start (3.2), spawn the Psyche (T2), and run a pulse tick
/// each cycle (drop ingest + echo fires — T3/T4/T5). The graceful signoff (T6)
/// is driven by the shutdown path; M3's daemon re-hosts all of it.
// [impl->REQ-START-2]
// [impl->REQ-SEAM-PSYCHE]
pub fn cmd_listen(
    id: &str,
    parent_pid_override: Option<u32>,
    once: bool,
    manifest: Option<&spt_runtime::Manifest>,
) -> i32 {
    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
        eprintln!("INVALID_ID:{id}: {e}");
        return EXIT_REFUSED;


68:pub struct SubnetRecord {
95:    pub fn seed_bytes(&self) -> Option<[u8; TOTP_SEED_LEN]> {
102:pub struct SubnetStore {
111:    pub fn load() -> SubnetStore {
118:    pub fn load_from(path: &Path) -> SubnetStore {
126:    pub fn save(&self) -> Result<(), SubnetError> {
133:    pub fn save_to(&self, path: &Path) -> Result<(), SubnetError> {
143:    pub fn find(&self, name: &str) -> Option<&SubnetRecord> {
155:    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
174:    pub fn add_joined(
201:    pub fn rotate_seed(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
217:    pub fn set_hide_new_endpoints(&mut self, name: &str, hide: bool) -> Result<(), SubnetError> {
230:    pub fn remove(&mut self, name: &str) -> bool {


crates\spt\src\cli.rs:929:        return match store.create_subnet(&name) {
crates\spt\src\cli.rs:1311:        store.create_subnet("home").unwrap();
crates\spt\src\cli.rs:1315:        store.create_subnet("work").unwrap();
crates\spt\src\cli.rs:1423:            s.create_subnet(n).expect("create");
crates\spt-store\src\subnet.rs:155:    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
crates\spt-store\src\subnet.rs:174:    pub fn add_joined(
crates\spt-store\src\subnet.rs:281:        let rec = store.create_subnet("home").expect("create");
crates\spt-store\src\subnet.rs:286:            store.create_subnet("home").unwrap_err(),
crates\spt-store\src\subnet.rs:298:        let before = store.create_subnet("home").expect("create").clone();
crates\spt-store\src\subnet.rs:314:    fn add_joined_preserves_seed_and_epoch() {
crates\spt-store\src\subnet.rs:317:        let rec = store.add_joined("work", seed, 5).expect("join").clone();
crates\spt-store\src\subnet.rs:321:            store.add_joined("work", seed, 6).unwrap_err(),
crates\spt-store\src\subnet.rs:335:        store.create_subnet("home").unwrap();
crates\spt-store\src\subnet.rs:336:        store.create_subnet("work").unwrap();
crates\spt-store\src\subnet.rs:355:        assert!(!store.create_subnet("home").unwrap().hide_new_endpoints, "ships OFF");
crates\spt-store\src\subnet.rs:395:        store.create_subnet("home").unwrap();
crates\spt-store\src\subnet.rs:407:        let sa = a.create_subnet("x").unwrap().seed_hex.clone();
crates\spt-store\src\subnet.rs:408:        let sb = b.create_subnet("x").unwrap().seed_hex.clone();
crates\spt-daemon\src\lifecycle.rs:676:            subnets.create_subnet("home").unwrap();
crates\spt-daemon\tests\dispatch.rs:119:        subnets.create_subnet(subnet).expect("create subnet");
crates\spt\src\api\reporting.rs:322:        subnets.create_subnet("home").unwrap();
crates\spt-net\src\net\pairing\wire.rs:378:                    // `add_joined` errors `Exists` for an idempotent re-pair (we already
crates\spt-net\src\net\pairing\wire.rs:380:                    match subnets.add_joined(subnet_name, seed, epoch) {
crates\spt-net\src\net\pairing\wire.rs:985:        let rec = subnets.create_subnet("garage").expect("create-new names the subnet");
crates\spt-net\src\net\pairing\wire.rs:1028:    // existing seed record is KEPT, not overwritten (add_joined's Exists is ignored).

[Showing results with pagination = limit: 25]


Production join sites identified. Write spt-store home module:


Write

//! Immutable home subnet (D9-5, REQ-INST-15 — ADR-0010, CONTEXT §home subnet).
//!
//! The home subnet anchors an endpoint's *defaults*: the default **sync**
//! scope (the creation flow seeds `sync_subnets = [home]` — the
//! [`crate::visibility`] documented seam, now real) and the subnet that
//! qualifies its bare name. **Assigned at creation, immutable for life**:
//!
//! - sole-subnet node → assigned automatically;
//! - multi-subnet node → must be specified (refuse-and-qualify, no silent
//!   guess — the resolution policy's stance);
//! - unpaired node → **local-only** (no home) until the node's *first join*
//!   sets it ([`adopt_for_unset`]).
//!
//! **No setter exists** — immutability by construction (ADR-0010: there is
//! no re-home; `spt fork` is the cross-subnet move). The only write paths
//! are the creation seams (stamping a *fresh* record) and
//! [`adopt_for_unset`], which by contract writes **only where home is
//! absent** — an existing assignment is never touched.
// [impl->REQ-INST-15]

use std::path::Path;

use crate::info;
use crate::perch::{self, ParentHint};
use crate::subnet::SubnetStore;
use crate::visibility::VisibilityStore;

/// Where a fresh endpoint's home lands.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HomeAssignment {
    /// The node resolved a home subnet (sole membership, or the explicit pick).
    Home(String),
    /// Unpaired node: the endpoint is local-only until the first join adopts it.
    LocalOnly,
}

/// Why home assignment refused (the creation must not proceed on a guess).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HomeError {
    /// An explicit `--subnet` named a subnet this node is not a member of.
    NotMember(String),
    /// Several subnets and no explicit pick — refuse-and-qualify. Lists them.
    Ambiguous(Vec<String>),
}

impl std::fmt::Display for HomeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            HomeError::NotMember(name) => {
                write!(f, "not a member of subnet '{name}' — cannot home an endpoint there")
            }
            HomeError::Ambiguous(names) => write!(
                f,
                "this node holds {} subnets ({}) — pass --subnet <name> (home is assigned at \
                 creation, no guessing)",
                names.len(),
                names.join(", ")
            ),
        }
    }
}

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

/// Resolve a fresh endpoint's home against the node's subnet store
/// (CONTEXT §home subnet: auto-if-one / ask-if-many / local-only-if-none).
// [impl->REQ-INST-15]
pub fn assign_home(
    store: &SubnetStore,
    explicit: Option<&str>,
) -> Result<HomeAssignment, HomeError> {
    if let Some(name) = explicit {
        return if store.find(name).is_some() {
            Ok(HomeAssignment::Home(name.to_string()))
        } else {
            Err(HomeError::NotMember(name.to_string()))
        };
    }
    match store.subnets.len() {
        0 => Ok(HomeAssignment::LocalOnly),
        1 => Ok(HomeAssignment::Home(store.subnets[0].name.clone())),
        _ => Err(HomeError::Ambiguous(store.subnets.iter().map(|s| s.name.clone()).collect())),
    }
}

/// Seed a freshly-homed endpoint's default scopes: `sync_subnets = [home]`
/// (ADR-0006 §5 — home is the default sync anchor; the fail-closed
/// "unconfigured syncs nowhere" store finally gets its creation-flow seed).
/// Only when no list is configured yet — an explicit list is user policy and
/// is never clobbered. The caller persists the store.
// [impl->REQ-INST-15]
pub fn seed_endpoint_scopes(vis: &mut VisibilityStore, id: &str, home: &str) {
    if vis.sync_subnets(id).is_none() {
        vis.set_sync_subnets(id, vec![home.to_string()]);
    }
}

/// First-join adoption (CONTEXT: "on an unpaired node the endpoint is
/// local-only until first join, when home is set"): when the node holds
/// **exactly one** subnet, stamp it as home on every Self perch whose record
/// has none, seeding each one's sync scope alongside. Strictly
/// adopt-if-unset — a record with a home is untouched (immutability), and a
/// multi-subnet store adopts nothing (that would be a guess). Returns the
/// adopted endpoint ids; the caller persists `vis`.
///
/// Call sites: the subnet-creation CLI path (immediate) and the daemon's
/// registry advertisement scan (eventually — within one cadence of a pair
/// join, no pairing-protocol threading).
// [impl->REQ-INST-15]
pub fn adopt_for_unset(
    owlery: &Path,
    store: &SubnetStore,
    vis: &mut VisibilityStore,
) -> Vec<String> {
    let [sole] = store.subnets.as_slice() else {
        return Vec::new(); // zero (nothing to adopt) or several (never guess)
    };
    let home = sole.name.clone();
    let mut adopted = Vec::new();
    for id in perch::list_self_perch_ids(owlery) {
        let perch_path = perch::resolve_perch_path_in(owlery, &id, ParentHint::Infer);
        let Some(mut rec) = info::read_info(&perch_path) else { continue };
        if rec.home_subnet.is_some() {
            continue; // immutable — never re-home
        }
        rec.home_subnet = Some(home.clone());
        if info::write_info(&perch_path, &rec).is_ok() {
            seed_endpoint_scopes(vis, &id, &home);
            adopted.push(id);
        }
    }
    adopted
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::info::{read_info, write_info, InfoJson};

    fn store_with(names: &[&str]) -> SubnetStore {
        let mut s = SubnetStore::default();
        for n in names {
            s.create_subnet(n).unwrap();
        }
        s
    }

    // [unit->REQ-INST-15] THE assignment matrix: sole subnet auto-assigns,
    // several refuse-and-qualify (listing them), zero is local-only, an
    // explicit member pick resolves, an explicit non-member refuses.
    #[test]
    fn home_assignment_matrix() {
        let sole = store_with(&["home"]);
        assert_eq!(assign_home(&sole, None), Ok(HomeAssignment::Home("home".into())));

        let multi = store_with(&["home", "work"]);
        assert_eq!(
            assign_home(&multi, None),
            Err(HomeError::Ambiguous(vec!["home".into(), "work".into()])),
            "several subnets must be qualified, never guessed"
        );
        assert_eq!(
            assign_home(&multi, Some("work")),
            Ok(HomeAssignment::Home("work".into())),
            "the explicit pick resolves the ambiguity"
        );
        assert_eq!(
            assign_home(&multi, Some("ghost")),
            Err(HomeError::NotMember("ghost".into()))
        );

        let unpaired = SubnetStore::default();
        assert_eq!(assign_home(&unpaired, None), Ok(HomeAssignment::LocalOnly));
        assert_eq!(
            assign_home(&unpaired, Some("home")),
            Err(HomeError::NotMember("home".into())),
            "an explicit pick still requires membership"
        );
    }

    // [unit->REQ-INST-15] scope seeding sets sync_subnets=[home] exactly once:
    // an explicit user list is never clobbered.
    #[test]
    fn scope_seeding_never_clobbers_user_policy() {
        let mut vis = VisibilityStore::default();
        seed_endpoint_scopes(&mut vis, "ling", "home");
        assert_eq!(vis.sync_subnets("ling"), Some(&["home".to_string()][..]));

        // A second seed (re-bind) is a no-op; so is seeding over user policy.
        seed_endpoint_scopes(&mut vis, "ling", "work");
        assert_eq!(vis.sync_subnets("ling"), Some(&["home".to_string()][..]));
    }

    // [unit->REQ-INST-15] first-join adoption: with exactly one subnet, an
    // unhomed Self perch adopts it (+ scope seed); a homed perch is NEVER
    // re-homed; a multi-subnet store adopts nothing (no guessing).
    #[test]
    fn adoption_stamps_only_unset_and_only_when_unambiguous() {
        let owlery = tempfile::tempdir().unwrap();
        let mut vis = VisibilityStore::default();

        // Two Self perches: one unhomed, one already homed elsewhere.
        for (id, home) in [("fresh", None), ("settled", Some("work"))] {
            let p = perch::resolve_perch_path_in(owlery.path(), id, ParentHint::Infer);
            std::fs::create_dir_all(&p).unwrap();
            let mut rec = InfoJson::new(id, "t", 4242, "sid", "ready_agent");
            rec.home_subnet = home.map(str::to_string);
            write_info(&p, &rec).unwrap();
        }

        // Multi-subnet store: nothing adopts.
        let multi = store_with(&["home", "work"]);
        assert!(adopt_for_unset(owlery.path(), &multi, &mut vis).is_empty());

        // Sole-subnet store: the unhomed perch adopts; the homed one is untouched.
        let sole = store_with(&["home"]);
        let adopted = adopt_for_unset(owlery.path(), &sole, &mut vis);
        assert_eq!(adopted, vec!["fresh".to_string()]);
        let fresh = perch::resolve_perch_path_in(owlery.path(), "fresh", ParentHint::Infer);
        assert_eq!(read_info(&fresh).unwrap().home_subnet.as_deref(), Some("home"));
        assert_eq!(vis.sync_subnets("fresh"), Some(&["home".to_string()][..]), "scope seeded");
        let settled = perch::resolve_perch_path_in(owlery.path(), "settled", ParentHint::Infer);
        assert_eq!(
            read_info(&settled).unwrap().home_subnet.as_deref(),
            Some("work"),
            "home is immutable — adoption never re-homes"
        );

        // Idempotent: a second sweep adopts nothing new.
        assert!(adopt_for_unset(owlery.path(), &sole, &mut vis).is_empty());
    }
}


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

//! Immutable home subnet (D9-5, REQ-INST-15 — ADR-0010, CONTEXT §home subnet).
//!
//! The home subnet anchors an endpoint's *defaults*: the default **sync**
//! scope (the creation flow seeds `sync_subnets = [home]` — the
//! [`crate::visibility`] documented seam, now real) and the subnet that
//! qualifies its bare name. **Assigned at creation, immutable for life**:
//!
//! - sole-subnet node → assigned automatically;
//! - multi-subnet node → must be specified (refuse-and-qualify, no silent
//!   guess — the resolution policy's stance);
//! - unpaired node → **local-only** (no home) until the node's *first join*
//!   sets it ([`adopt_for_unset`]).
//!
//! **No setter exists** — immutability by construction (ADR-0010: there is
//! no re-home; `spt fork` is the cross-subnet move). The only write paths
//! are the creation seams (stamping a *fresh* record) and
//! [`adopt_for_unset`], which by contract writes **only where home is
//! absent** — an existing assignment is never touched.
// [impl->REQ-INST-15]

use std::path::Path;

use crate::info;
use crate::perch::{self, ParentHint};
use crate::subnet::SubnetStore;
use crate::visibility::VisibilityStore;

/// Where a fresh endpoint's home lands.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HomeAssignment {
    /// The node resolved a home subnet (sole membership, or the explicit pick).
    Home(String),
    /// Unpaired node: the endpoint is local-only until the first join adopts it.
    LocalOnly,
}

/// Why home assignment refused (the creation must not proceed on a guess).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HomeError {
    /// An explicit `--subnet` named a subnet this node is not a member of.
    NotMember(String),
    /// Several subnets and no explicit pick — refuse-and-qualify. Lists them.
    Ambiguous(Vec<String>),
}

impl std::fmt::Display for HomeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            HomeError::NotMember(name) => {
                write!(f, "not a member of subnet '{name}' — cannot home an endpoint there")
            }
            HomeError::Ambiguous(names) => write!(
                f,
                "this node holds {} subnets ({}) — pass --subnet <name> (home is assigned at \
                 creation, no guessing)",
                names.len(),
                names.join(", ")
            ),
        }
    }
}

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

/// Resolve a fresh endpoint's home against the node's subnet store
/// (CONTEXT §home subnet: auto-if-one / ask-if-many / local-only-if-none).
// [impl->REQ-INST-15]
pub fn assign_home(
    store: &SubnetStore,
    explicit: Option<&str>,
) -> Result<HomeAssignment, HomeError> {
    if let Some(name) = explicit {
        return if store.find(name).is_some() {
            Ok(HomeAssignment::Home(name.to_string()))
        } else {
            Err(HomeError::NotMember(name.to_string()))
        };
    }
    match store.subnets.len() {
        0 => Ok(HomeAssignment::LocalOnly),
        1 => Ok(HomeAssignment::Home(store.subnets[0].name.clone())),
        _ => Err(HomeError::Ambiguous(store.subnets.iter().map(|s| s.name.clone()).collect())),
    }
}

/// Seed a freshly-homed endpoint's default scopes: `sync_subnets = [home]`
/// (ADR-0006 §5 — home is the default sync anchor; the fail-closed
/// "unconfigured syncs nowhere" store finally gets its creation-flow seed).
/// Only when no list is configured yet — an explicit list is user policy and
/// is never clobbered. The caller persists the store.
// [impl->REQ-INST-15]
pub fn seed_endpoint_scopes(vis: &mut VisibilityStore, id: &str, home: &str) {
    if vis.sync_subnets(id).is_none() {
        vis.set_sync_subnets(id, vec![home.to_string()]);
    }
}

/// First-join adoption (CONTEXT: "on an unpaired node the endpoint is
/// local-only until first join, when home is set"): when the node holds
/// **exactly one** subnet, stamp it as home on every Self perch whose record
/// has none, seeding each one's sync scope alongside. Strictly
/// adopt-if-unset — a record with a home is untouched (immutability), and a
/// multi-subnet store adopts nothing (that would be a guess). Returns the
/// adopted endpoint ids; the caller persists `vis`.
///
/// Call sites: the subnet-creation CLI path (immediate) and the daemon's
/// registry advertisement scan (eventually — within one cadence of a pair
/// join, no pairing-protocol threading).
// [impl->REQ-INST-15]
pub fn adopt_for_unset(
    owlery: &Path,
    store: &SubnetStore,
    vis: &mut VisibilityStore,
) -> Vec<String> {
    let [sole] = store.subnets.as_slice() else {
        return Vec::new(); // zero (nothing to adopt) or several (never guess)
    };
    let home = sole.name.clone();
    let mut adopted = Vec::new();
    for id in perch::list_self_perch_ids(owlery) {
        let perch_path = perch::resolve_perch_path_in(owlery, &id, ParentHint::Infer);
        let Some(mut rec) = info::read_info(&perch_path) else { continue };
        if rec.home_subnet.is_some() {
            continue; // immutable — never re-home
        }
        rec.home_subnet = Some(home.clone());
        if info::write_info(&perch_path, &rec).is_ok() {
            seed_endpoint_scopes(vis, &id, &home);
            adopted.push(id);
        }
    }
    adopted
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::info::{read_info, write_info, InfoJson};

    fn store_with(names: &[&str]) -> SubnetStore {
        let mut s = SubnetStore::default();
        for n in names {
            s.create_subnet(n).unwrap();
        }
        s
    }

    // [unit->REQ-INST-15] THE assignment matrix: sole subnet auto-assigns,
    // several refuse-and-qualify (listing them), zero is local-only, an
    // explicit member pick resolves, an explicit non-member refuses.
    #[test]
    fn home_assignment_matrix() {
        let sole = store_with(&["home"]);
        assert_eq!(assign_home(&sole, None), Ok(HomeAssignment::Home("home".into())));

        let multi = store_with(&["home", "work"]);
        assert_eq!(
            assign_home(&multi, None),
            Err(HomeError::Ambiguous(vec!["home".into(), "work".into()])),
            "several subnets must be qualified, never guessed"
        );
        assert_eq!(
            assign_home(&multi, Some("work")),
            Ok(HomeAssignment::Home("work".into())),
            "the explicit pick resolves the ambiguity"
        );
        assert_eq!(
            assign_home(&multi, Some("ghost")),
            Err(HomeError::NotMember("ghost".into()))
        );

        let unpaired = SubnetStore::default();
        assert_eq!(assign_home(&unpaired, None), Ok(HomeAssignment::LocalOnly));
        assert_eq!(
            assign_home(&unpaired, Some("home")),
            Err(HomeError::NotMember("home".into())),
            "an explicit pick still requires membership"
        );
    }

    // [unit->REQ-INST-15] scope seeding sets sync_subnets=[home] exactly once:
    // an explicit user list is never clobbered.
    #[test]
    fn scope_seeding_never_clobbers_user_policy() {
        let mut vis = VisibilityStore::default();
        seed_endpoint_scopes(&mut vis, "ling", "home");
        assert_eq!(vis.sync_subnets("ling"), Some(&["home".to_string()][..]));

        // A second seed (re-bind) is a no-op; so is seeding over user policy.
        seed_endpoint_scopes(&mut vis, "ling", "work");
        assert_eq!(vis.sync_subnets("ling"), Some(&["home".to_string()][..]));
    }

    // [unit->REQ-INST-15] first-join adoption: with exactly one subnet, an
    // unhomed Self perch adopts it (+ scope seed); a homed perch is NEVER
    // re-homed; a multi-subnet store adopts nothing (no guessing).
    #[test]
    fn adoption_stamps_only_unset_and_only_when_unambiguous() {
        let owlery = tempfile::tempdir().unwrap();
        let mut vis = VisibilityStore::default();

        // Two Self perches: one unhomed, one already homed elsewhere.
        for (id, home) in [("fresh", None), ("settled", Some("work"))] {
            let p = perch::resolve_perch_path_in(owlery.path(), id, ParentHint::Infer);
            std::fs::create_dir_all(&p).unwrap();
            let mut rec = InfoJson::new(id, "t", 4242, "sid", "ready_agent");
            rec.home_subnet = home.map(str::to_string);
            write_info(&p, &rec).unwrap();
        }

        // Multi-subnet store: nothing adopts.
        let multi = store_with(&["home", "work"]);
        assert!(adopt_for_unset(owlery.path(), &multi, &mut vis).is_empty());

        // Sole-subnet store: the unhomed perch adopts; the homed one is untouched.
        let sole = store_with(&["home"]);
        let adopted = adopt_for_unset(owlery.path(), &sole, &mut vis);
        assert_eq!(adopted, vec!["fresh".to_string()]);
        let fresh = perch::resolve_perch_path_in(owlery.path(), "fresh", ParentHint::Infer);
        assert_eq!(read_info(&fresh).unwrap().home_subnet.as_deref(), Some("home"));
        assert_eq!(vis.sync_subnets("fresh"), Some(&["home".to_string()][..]), "scope seeded");
        let settled = perch::resolve_perch_path_in(owlery.path(), "settled", ParentHint::Infer);
        assert_eq!(
            read_info(&settled).unwrap().home_subnet.as_deref(),
            Some("work"),
            "home is immutable — adoption never re-homes"
        );

        // Idempotent: a second sweep adopts nothing new.
        assert!(adopt_for_unset(owlery.path(), &sole, &mut vis).is_empty());
    }
}


TaskCreate

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

D9-5-1: home model (info.json home_subnet+adapter, assign_home matrix, creation-seam stamping + carry-forward on revive, sync_subnets=[home] seed, first-join adoption via CLI create + advertise sweep). D9-5-2: spt fork (ContextStore::fork_endpoint seed-commit copy both tiers, join-time collision vs target snapshot, new perch home=target, --delete-source). ADR-0010 doc tags; activate REQ-INST-15 [doc, impl, unit].


Task #6 created successfully: D9-5: REQ-INST-15 immutable home subnet + spt fork


info.rs fields + lib wiring:


Edit

    /// The endpoint-authored resource-advertisement blurb (D9-4,
    /// REQ-INST-14): the agent's own "what I can serve" line, written via
    /// `spt resources set` ([`set_resources`]) and advertised into every
    /// visible subnet by the daemon's registry scan. Absent ⇒ the node-level
    /// `daemon.json` seed (if any) advertises instead — config seeds the
    /// default, the agent refines its own at runtime (CONTEXT §resource
    /// advertisement, both-authored).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resources: Option<String>,
}

    /// The endpoint-authored resource-advertisement blurb (D9-4,
    /// REQ-INST-14): the agent's own "what I can serve" line, written via
    /// `spt resources set` ([`set_resources`]) and advertised into every
    /// visible subnet by the daemon's registry scan. Absent ⇒ the node-level
    /// `daemon.json` seed (if any) advertises instead — config seeds the
    /// default, the agent refines its own at runtime (CONTEXT §resource
    /// advertisement, both-authored).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resources: Option<String>,
    /// The endpoint's **immutable home subnet** (D9-5, REQ-INST-15 —
    /// ADR-0010): assigned at creation ([`crate::home::assign_home`]) or by
    /// first-join adoption ([`crate::home::adopt_for_unset`]), then never
    /// changed — **no setter exists**; `spt fork` is the cross-subnet move.
    /// Absent ⇒ local-only (unpaired at creation, awaiting first join) or a
    /// pre-D9-5 record. Creation seams must **carry this forward** on a
    /// re-bind (a revive rewrites `info.json`; home survives by contract).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub home_subnet: Option<String>,
    /// The adapter chosen at creation (D9-5, REQ-INST-15 — the
    /// adapter-at-creation rule): recorded when a creation/bind path knows
    /// it, changed **only** via launch/resume-under-new (the bind paths are
    /// the sole writers; no standalone setter). Absent ⇒ created by an
    /// adapterless surface (`spt ready`) or a pre-D9-5 record.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub adapter: Option<String>,
}