    // Context-store arm FIRST (the a-<id>/p- branch ripple, D6a): it has its
    // own collision check (an existing a-<new> mind refuses — never merge two
    // minds), and refusing here leaves the perch tree untouched.
    // [impl->REQ-INST-11]
    if let Ok(cs) = spt_store::contextstore::ContextStore::open_or_init() {
        if let Err(e) = cs.rename_endpoint(old_id, new_id) {
            eprintln!("ERROR: context rename: {e}");
            return 1;
        }
    }
    match spt_store::rename::rename_perch_in(&perch::owlery_dir(), old_id, new_id) {
        Ok(rippled) => {
            for id in &rippled {
                println!("RENAMED:{id}");
            }
            eprintln!("renamed {old_id} -> {new_id} ({} perch(es))", rippled.len());
            0
        }
        Err(e) => {
            eprintln!("ERROR: {e}");
            1
        }
    }
}

    // Context-store collision pre-check (an existing a-<new> mind refuses —
    // never merge two minds) BEFORE the perch arm mutates anything; the actual
    // branch ripple runs after the perch arm's own refusals (live/collision)
    // have passed, so a refusal on either side leaves the other untouched.
    // [impl->REQ-INST-11]
    let cs = spt_store::contextstore::ContextStore::open_or_init().ok();
    if let Some(cs) = &cs {
        match cs.branch_store().has_branch(&spt_store::contextstore::agent_branch(new_id)) {
            Ok(true) => {
                eprintln!("ERROR: context branch already exists: a-{new_id}");
                return 1;
            }
            Ok(false) => {}
            Err(e) => {
                eprintln!("ERROR: context store: {e}");
                return 1;
            }
        }
    }
    match spt_store::rename::rename_perch_in(&perch::owlery_dir(), old_id, new_id) {
        Ok(rippled) => {
            if let Some(cs) = &cs {
                // The a-/p- branch ripple (D6a). The collision was pre-checked;
                // a residual failure here is surfaced with the recovery hint.
                if let Err(e) = cs.rename_endpoint(old_id, new_id) {
                    eprintln!(
                        "ERROR: perches renamed but context rename failed: {e}\n\
                         recover: rerun `spt rename {old_id} {new_id}` after fixing the cause \
                         (perch arm will report NotFound and the context arm will retry)"
                    );
                    return 1;
                }
            }
            for id in &rippled {
                println!("RENAMED:{id}");
            }
            eprintln!("renamed {old_id} -> {new_id} ({} perch(es))", rippled.len());
            0
        }
        Err(e) => {
            eprintln!("ERROR: {e}");
            1
        }
    }
}

-    // Context-store arm FIRST (the a-<id>/p- branch ripple, D6a): it has its

-    // own collision check (an existing a-<new> mind refuses — never merge two

-    // minds), and refusing here leaves the perch tree untouched.

+    // Context-store collision pre-check (an existing a-<new> mind refuses —

+    // never merge two minds) BEFORE the perch arm mutates anything; the actual

+    // branch ripple runs after the perch arm's own refusals (live/collision)

+    // have passed, so a refusal on either side leaves the other untouched.

-    if let Ok(cs) = spt_store::contextstore::ContextStore::open_or_init() {

-        if let Err(e) = cs.rename_endpoint(old_id, new_id) {

-            eprintln!("ERROR: context rename: {e}");

+    let cs = spt_store::contextstore::ContextStore::open_or_init().ok();

+        match cs.branch_store().has_branch(&spt_store::contextstore::agent_branch(new_id)) {

+                eprintln!("ERROR: context branch already exists: a-{new_id}");

+                eprintln!("ERROR: context store: {e}");

+                // The a-/p- branch ripple (D6a). The collision was pre-checked;

+                // a residual failure here is surfaced with the recovery hint.

+                if let Err(e) = cs.rename_endpoint(old_id, new_id) {

+                        "ERROR: perches renamed but context rename failed: {e}\n\

+                         recover: rerun `spt rename {old_id} {new_id}` after fixing the cause \

+                         (perch arm will report NotFound and the context arm will retry)"


Edit

        Err(e) => {
            eprintln!("ERROR: {e}");
            1
        }
    }
}

        Err(spt_store::rename::RenameDirError::NotFound(_))
            if cs
                .as_ref()
                .and_then(|cs| {
                    cs.branch_store()
                        .has_branch(&spt_store::contextstore::agent_branch(old_id))
                        .ok()
                })
                .unwrap_or(false) =>
        {
            // No perch under the old id but a tracked mind exists — the retry
            // path after a half-completed rename: finish the context arm.
            match cs.as_ref().expect("guard checked").rename_endpoint(old_id, new_id) {
                Ok(()) => {
                    eprintln!("renamed {old_id} -> {new_id} (context only; no perch)");
                    0
                }
                Err(e) => {
                    eprintln!("ERROR: context rename: {e}");
                    1
                }
            }
        }
        Err(e) => {
            eprintln!("ERROR: {e}");
            1
        }
    }
}


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

        Err(spt_store::rename::RenameDirError::NotFound(_))
            if cs
                .as_ref()
                .and_then(|cs| {
                    cs.branch_store()
                        .has_branch(&spt_store::contextstore::agent_branch(old_id))
                        .ok()
                })
                .unwrap_or(false) =>
        {
            // No perch under the old id but a tracked mind exists — the retry
            // path after a half-completed rename: finish the context arm.
            match cs.as_ref().expect("guard checked").rename_endpoint(old_id, new_id) {
                Ok(()) => {
                    eprintln!("renamed {old_id} -> {new_id} (context only; no perch)");
                    0
                }
                Err(e) => {
                    eprintln!("ERROR: context rename: {e}");
                    1
                }
            }
        }
        Err(e) => {
            eprintln!("ERROR: {e}");
            1
        }
    }
}

eprintln!("renamed {old_id} -> {new_id} ({} perch(es))", rippled.len());

+        Err(spt_store::rename::RenameDirError::NotFound(_))

+                        .has_branch(&spt_store::contextstore::agent_branch(old_id))

+            // No perch under the old id but a tracked mind exists — the retry

+            // path after a half-completed rename: finish the context arm.

+            match cs.as_ref().expect("guard checked").rename_endpoint(old_id, new_id) {

+                    eprintln!("renamed {old_id} -> {new_id} (context only; no perch)");

+                    eprintln!("ERROR: context rename: {e}");


crates\spt\src\api\live.rs:16:use spt_live::{Ingested, PsycheHandle, SignoffOutcome};
crates\spt\src\api\live.rs:49:    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<(Vec<Ingested>, bool), String> {


Bash

cd C:/Users/decid/Documents/projects/spt-core && cargo build --workspace 2>&1 | grep -E "^error|warning: unused" | head -20; echo BUILD_DONE

Build workspace


cd C:/Users/decid/Documents/projects/spt-core && cargo test --workspace --quiet 2>&1 | grep -E "test result|FAILED|panicked|error\[" | head -30

Run full workspace tests


error[E0433]: failed to resolve: use of undeclared type `ParentHint`


cd C:/Users/decid/Documents/projects/spt-core && cargo test --workspace --no-run 2>&1 | grep -B3 -A6 "E0433\|E0061\|E0599" | head -50

Locate test compile errors


   Compiling spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
   Compiling spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
error[E0433]: failed to resolve: use of undeclared type `ParentHint`
   --> crates\spt-live\src\resume.rs:224:78
    |
224 |             let perch = spt_store::perch::resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"));
    |                                                                              ^^^^^^^^^^ use of undeclared type `ParentHint`
    |
    = note: enum `crate::pulse::tests::ParentHint` exists but is inaccessible
--
128 +     use spt_store::perch::ParentHint;
    |

For more information about this error, try `rustc --explain E0433`.
error: could not compile `spt-live` (lib test) due to 1 previous error
warning: build failed, waiting for other jobs to finish...


Edit

let perch = spt_store::perch::resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"));

            let perch = spt_store::perch::resolve_perch_path(
                "doyle-psyche",
                spt_store::perch::ParentHint::Explicit("doyle"),
            );


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

            let perch = spt_store::perch::resolve_perch_path(
                "doyle-psyche",
                spt_store::perch::ParentHint::Explicit("doyle"),
            );

// No perch was bound — the error preceded any spawn.

-            let perch = spt_store::perch::resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"));

+            let perch = spt_store::perch::resolve_perch_path(

+                spt_store::perch::ParentHint::Explicit("doyle"),

assert!(!perch.join("info.json").exists(), "must not spawn/bind on missing key");


Bash

cd C:/Users/decid/Documents/projects/spt-core && cargo test --workspace --quiet 2>&1 | grep -E "test result|FAILED|panicked|error\[|failures:" | head -40

Run full workspace tests


test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 56 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
live_agent_lifecycle_e2e --- FAILED
failures:
thread 'live_agent_lifecycle_e2e' (6768) panicked at crates\spt\tests\contract_e2e.rs:279:39:
failures:
test result: FAILED. 3 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.48s


D6a-1: BranchStore + ContextStore + project_id in spt-store

gitrun.rs (bounded git, 5.3), branchstore.rs (git-CLI BranchStore: bare seed, ensure branch/worktree, commit=checkpoint/tip=resume, REQ-STORE-1 activate), contextstore.rs (tracked/ two-tier layout, a-/p- branches), project.rs (project_id from git remote / folder fallback), perch.rs tracked paths, STORAGE.md seed-repo note. Push CI-green.

D6a-2: two-tier ingest routing + rename arm in spt-live

ingest.rs routes commune slices (parse_two_slice) into ContextStore tier files (precedence-guarded + committed); lifecycle wiring (project dir → project_id); spt rename context-branch arm (a-<old>→a-<new>); REQ-INST-2 activate [impl,unit]. Push CI-green.

D6b: vector merge + Psyche reconcile + D7.5a driver

VersionVector + marker v2 (node+vector), per-file merge fn, conflict artifacts (REQ-HAZARD-CONFLICT-BOTH-PRESERVED register+activate), reconciler election, bounded stdout-captured Psyche turn driver (D7.5a pulled forward), reconcile flow + validation + retry + event seam.

D6c: bundle sync over broker streams + gates

SyncRequest/bundle-chunk records, pull-based ref-scoped (synced(E,S)+visibility server-side fail-closed), fetch+driver merge+two-parent commits, triggers (write-notify, activation freshness feed, anti-entropy), loopback two-daemon tests, FAULT-MATRIX rows. REQ-NET-3 + REQ-INST-5 activate.


240	    let anchor = std::process::id().to_string();
241
242	    // Seed the startup record keyed by the anchor pid.
243	    let seed = Command::new(&spt_bin)
244	        .args(["api", "--adapter", "mock", "seed", "--pid", &anchor, "--session-id", session_id])
245	        .env("SPT_HOME", home.path())
246	        .status()
247	        .expect("run api seed");
248	    assert!(seed.success(), "api seed exit: {seed:?}");
249
250	    // Pre-drop a commune so the pulse tick in `listen --once` ingests it.
251	    std::fs::write(drops.path().join(format!("{id}-commune.md")), "Source: self-commune\nmid-session note").unwrap();
252
253	    // Listen (live-capable manifest): binds, spawns the Psyche, runs one pulse
254	    // tick (ingests the drop), and exits.
255	    let listen = Command::new(&spt_bin)
256	        .args([
257	            "api", "--adapter", "mock", "--manifest", &manifest_arg,
258	            "listen", id, "--parent-pid", &anchor, "--once",
259	        ])
260	        .env("SPT_HOME", home.path())
261	        .output()
262	        .expect("run api listen");
263	    let listen_err = String::from_utf8_lossy(&listen.stderr);
264	    assert!(listen.status.success(), "api listen exit: {:?}\n{listen_err}", listen.status);
265
266	    // 1. The Psyche spawned as a nested perch.
267	    assert!(listen_err.contains("PSYCHE_SPAWNED:"), "psyche must spawn: {listen_err}");
268	    let psyche_perch = perch::resolve_perch_path("live-e2e-psyche", ParentHint::Explicit(id));
269	    let prec = spt_store::info::read_info(&psyche_perch).expect("psyche info.json");
270	    assert_eq!(prec.state, "psyche", "nested perch must be a psyche endpoint");
271
272	    // 2. The commune drop was ingested + deleted by spt-core (single-writer).
273	    assert!(
274	        !drops.path().join(format!("{id}-commune.md")).exists(),
275	        "commune drop must be ingested+deleted"
276	    );
277	    let ctx = perch::resolve_perch_path(id, ParentHint::Infer).join("context.md");
278	    assert!(
279	        std::fs::read_to_string(&ctx).unwrap().contains("mid-session note"),
280	        "ingested commune must land in the context store"
281	    );
282
283	    // 3. Graceful shutdown fires the echo-commune BEFORE teardown (3.3).
284	    let shutdown = Command::new(&spt_bin)
285	        .args([
286	            "api", "--adapter", "mock", "--manifest", &manifest_arg,
287	            "shutdown", id, "--session-id", session_id,
288	        ])
289	        .env("SPT_HOME", home.path())
290	        .output()
291	        .expect("run api shutdown");
292	    let sh_err = String::from_utf8_lossy(&shutdown.stderr);
293	    assert!(shutdown.status.success(), "api shutdown exit: {:?}\n{sh_err}", shutdown.status);
294	    assert!(sh_err.contains("echo_ran=true"), "echo must run on signoff: {sh_err}");
295	    assert!(sh_err.contains("signed_off=true"), "graceful signoff must complete: {sh_err}");
296
297	    // The echo wrote a fresh commune drop (Source: echo-commune) before teardown.
298	    let echo_drop = std::fs::read_to_string(drops.path().join(format!("{id}-commune.md")))
299	        .expect("echo wrote a commune drop");
300	    assert!(echo_drop.contains("Source: echo-commune"), "echo provenance: {echo_drop:?}");
301	    // Soft teardown removed the online marker (6.2).
302	    assert!(
303	        !perch::resolve_ready_file(id, ParentHint::Infer).exists(),
304	        "shutdown must soft-stop (remove the ready marker)"
305	    );
306	}
307
308	/// Cold-start auto-start (REQ-DAEMON-3) + the in-memory seed handoff (REQ-START-3)
309	/// through a **real** spawned daemon: with no daemon pre-running, a cold
310	/// `spt api seed` transparently spins up `spt daemon`, the seed lands in its
311	/// in-memory map (no file), and a follow-up `api listen` TAKEs it and binds —
312	/// proving the cross-invocation handoff rides the daemon, not a seed file.
313	#[test]
314	fn cold_api_call_autostarts_daemon_and_handoff_is_in_memory() {
315	    // [int->REQ-DAEMON-3]
316	    // [int->REQ-START-3]
317	    let _serial = E2E_LOCK.lock().unwrap_or_else(|e| e.into_inner());
318	    let home = tempfile::tempdir().unwrap();
319	    std::env::set_var("SPT_HOME", home.path());

    let anchor = std::process::id().to_string();

    // Seed the startup record keyed by the anchor pid.
    let seed = Command::new(&spt_bin)
        .args(["api", "--adapter", "mock", "seed", "--pid", &anchor, "--session-id", session_id])
        .env("SPT_HOME", home.path())
        .status()
        .expect("run api seed");
    assert!(seed.success(), "api seed exit: {seed:?}");

    // Pre-drop a commune so the pulse tick in `listen --once` ingests it.
    std::fs::write(drops.path().join(format!("{id}-commune.md")), "Source: self-commune\nmid-session note").unwrap();

    // Listen (live-capable manifest): binds, spawns the Psyche, runs one pulse
    // tick (ingests the drop), and exits.
    let listen = Command::new(&spt_bin)
        .args([
            "api", "--adapter", "mock", "--manifest", &manifest_arg,
            "listen", id, "--parent-pid", &anchor, "--once",
        ])
        .env("SPT_HOME", home.path())
        .output()
        .expect("run api listen");
    let listen_err = String::from_utf8_lossy(&listen.stderr);
    assert!(listen.status.success(), "api listen exit: {:?}\n{listen_err}", listen.status);

    // 1. The Psyche spawned as a nested perch.
    assert!(listen_err.contains("PSYCHE_SPAWNED:"), "psyche must spawn: {listen_err}");
    let psyche_perch = perch::resolve_perch_path("live-e2e-psyche", ParentHint::Explicit(id));
    let prec = spt_store::info::read_info(&psyche_perch).expect("psyche info.json");
    assert_eq!(prec.state, "psyche", "nested perch must be a psyche endpoint");

    // 2. The commune drop was ingested + deleted by spt-core (single-writer).
    assert!(
        !drops.path().join(format!("{id}-commune.md")).exists(),
        "commune drop must be ingested+deleted"
    );
    let ctx = perch::resolve_perch_path(id, ParentHint::Infer).join("context.md");
    assert!(
        std::fs::read_to_string(&ctx).unwrap().contains("mid-session note"),
        "ingested commune must land in the context store"
    );

    // 3. Graceful shutdown fires the echo-commune BEFORE teardown (3.3).
    let shutdown = Command::new(&spt_bin)
        .args([
            "api", "--adapter", "mock", "--manifest", &manifest_arg,
            "shutdown", id, "--session-id", session_id,
        ])
        .env("SPT_HOME", home.path())
        .output()
        .expect("run api shutdown");
    let sh_err = String::from_utf8_lossy(&shutdown.stderr);
    assert!(shutdown.status.success(), "api shutdown exit: {:?}\n{sh_err}", shutdown.status);
    assert!(sh_err.contains("echo_ran=true"), "echo must run on signoff: {sh_err}");
    assert!(sh_err.contains("signed_off=true"), "graceful signoff must complete: {sh_err}");

    // The echo wrote a fresh commune drop (Source: echo-commune) before teardown.
    let echo_drop = std::fs::read_to_string(drops.path().join(format!("{id}-commune.md")))
        .expect("echo wrote a commune drop");
    assert!(echo_drop.contains("Source: echo-commune"), "echo provenance: {echo_drop:?}");
    // Soft teardown removed the online marker (6.2).
    assert!(
        !perch::resolve_ready_file(id, ParentHint::Infer).exists(),
        "shutdown must soft-stop (remove the ready marker)"
    );
}

/// Cold-start auto-start (REQ-DAEMON-3) + the in-memory seed handoff (REQ-START-3)
/// through a **real** spawned daemon: with no daemon pre-running, a cold
/// `spt api seed` transparently spins up `spt daemon`, the seed lands in its
/// in-memory map (no file), and a follow-up `api listen` TAKEs it and binds —
/// proving the cross-invocation handoff rides the daemon, not a seed file.
#[test]
fn cold_api_call_autostarts_daemon_and_handoff_is_in_memory() {
    // [int->REQ-DAEMON-3]
    // [int->REQ-START-3]
    let _serial = E2E_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());


Edit

    let ctx = perch::resolve_perch_path(id, ParentHint::Infer).join("context.md");
    assert!(
        std::fs::read_to_string(&ctx).unwrap().contains("mid-session note"),
        "ingested commune must land in the context store"
    );

    // (D6a) an untagged commune routes whole to the LIVE tier of the tracked
    // two-tier context store — the per-perch flat context.md is gone.
    let ctx = spt_store::contextstore::live_context_file(
        &home.path().join("tracked"),
        id,
    );
    assert!(
        std::fs::read_to_string(&ctx).unwrap().contains("mid-session note"),
        "ingested commune must land in the live context tier"
    );


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

    let ctx = perch::resolve_perch_path(id, ParentHint::Infer).join("context.md");
    assert!(
        std::fs::read_to_string(&ctx).unwrap().contains("mid-session note"),
        "ingested commune must land in the context store"
    );

    // (D6a) an untagged commune routes whole to the LIVE tier of the tracked
    // two-tier context store — the per-perch flat context.md is gone.
    let ctx = spt_store::contextstore::live_context_file(
        &home.path().join("tracked"),
        id,
    );
    assert!(
        std::fs::read_to_string(&ctx).unwrap().contains("mid-session note"),
        "ingested commune must land in the live context tier"
    );

-    let ctx = perch::resolve_perch_path(id, ParentHint::Infer).join("context.md");

+    // (D6a) an untagged commune routes whole to the LIVE tier of the tracked

+    // two-tier context store — the per-perch flat context.md is gone.

+    let ctx = spt_store::contextstore::live_context_file(

-        "ingested commune must land in the context store"

+        "ingested commune must land in the live context tier"

// 3. Graceful shutdown fires the echo-commune BEFORE teardown (3.3).


Bash

cd C:/Users/decid/Documents/projects/spt-core && cargo test --workspace --quiet 2>&1 | grep -E "test result|FAILED|panicked|error\[|failures:" | head -40

Re-run workspace tests


test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 56 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.29s
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.04s
test result: ok. 85 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.15s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.65s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.19s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.17s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.30s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s
test result: ok. 36 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.73s
test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.18s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.22s
test result: ok. 78 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.28s
test result: ok. 53 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 21 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s
test result: ok. 102 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.58s
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.01s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


All green. Clippy + REQ-INST-2 activation:


cd C:/Users/decid/Documents/projects/spt-core && cargo clippy --workspace --all-targets 2>&1 | grep -E "^warning|^error" | head -10; echo CLIPPY_DONE

Clippy workspace


Edit

id = "REQ-INST-2"
title = "Per-node files, synced Psyche mind"
required_stages = []

id = "REQ-INST-2"
title = "Per-node files, synced Psyche mind"
required_stages = ["impl", "unit"]  # activated M4-D6a: the mind now lives in the tracked/ two-tier store (spt-store::contextstore over BranchStore), physically separate from never-synced node-local perches/ — spt_live::ingest::route_slices routes commune drops' <live-context>/<project-context> slices into a-<id>/p-<project_id> branch files (untagged body → live, the parser fallback; precedence-guarded 6.5; checkpoint commit per written tier); signoff::write_resume_commune stamps Self provenance per slice through the same router; resume::download_psyche_context composes the two tiers back into the two-slice envelope (pure read, never mints store state; other-project download sees live only). Daemon lifecycle derives project_id from the commune dir's parent (cwd fallback). The cross-node SYNC of these branches = D6c (REQ-NET-3/REQ-INST-5); int = D9 two-host. unit = per-tier routing/no-leak + untagged→live + suppressed-consumed + resume-commune both-tiers + download compose/cross-project-isolation + e2e listen→ingest→live-tier


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

id = "REQ-INST-2"
title = "Per-node files, synced Psyche mind"
required_stages = []

id = "REQ-INST-2"
title = "Per-node files, synced Psyche mind"
required_stages = ["impl", "unit"]  # activated M4-D6a: the mind now lives in the tracked/ two-tier store (spt-store::contextstore over BranchStore), physically separate from never-synced node-local perches/ — spt_live::ingest::route_slices routes commune drops' <live-context>/<project-context> slices into a-<id>/p-<project_id> branch files (untagged body → live, the parser fallback; precedence-guarded 6.5; checkpoint commit per written tier); signoff::write_resume_commune stamps Self provenance per slice through the same router; resume::download_psyche_context composes the two tiers back into the two-slice envelope (pure read, never mints store state; other-project download sees live only). Daemon lifecycle derives project_id from the commune dir's parent (cwd fallback). The cross-node SYNC of these branches = D6c (REQ-NET-3/REQ-INST-5); int = D9 two-host. unit = per-tier routing/no-leak + untagged→live + suppressed-consumed + resume-commune both-tiers + download compose/cross-project-isolation + e2e listen→ingest→live-tier

+required_stages = ["impl", "unit"]  # activated M4-D6a: the mind now lives in the tracked/ two-tier store (spt-store::contextstore over BranchStore), physically separate from never-synced node-local perches/ — spt_live::ingest::route_slices routes commune drops' <live-context>/<project-context> slices into a-<id>/p-<project_id> branch files (untagged body → live, the parser fallback; precedence-guarded 6.5; checkpoint commit per written tier); signoff::write_resume_commune stamps Self provenance per slice through the same router; resume::download_psyche_context composes the two tiers back into the two-slice envelope (pure read, never mints store state; other-project download sees live only). Daemon lifecycle derives project_id from the commune dir's parent (cwd fallback). The cross-node SYNC of these branches = D6c (REQ-NET-3/REQ-INST-5); int = D9 two-host. unit = per-tier routing/no-leak + untagged→live + suppressed-consumed + resume-commune both-tiers + download compose/cross-project-isolation + e2e listen→ingest→live-tier

title = "Dormant (warm) / suspended (cold) resting states"


Bash

cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check > /dev/null 2>&1; echo "exit=$?"

Traceability check


cd C:/Users/decid/Documents/projects/spt-core && git add -A && git commit -m "feat(live): M4-D6a two-tier context routing + rename context arm (REQ-INST-2)

The mind moves from the per-perch flat context.md into the tracked/ two-tier
store (D6a-1's ContextStore); the flat file is gone (6.1 — one context
location).

- ingest.rs: route_two_slice/route_slices — commune drops parse with the
  two-slice envelope grammar; <live-context> → a-<id> live-context.md,
  <project-context> → p-<project_id> <id>/project-context.md; untagged body
  routes whole to live (parser fallback). Precedence-guarded per tier (6.5);
  BranchStore checkpoint commit per written tier; suppressed writes commit
  nothing. Ingested now carries per-tier writes.
- signoff.rs: write_resume_commune stamps Source: self-commune per slice and
  routes through the same router (Self communes honor tags).
- resume.rs: download_psyche_context composes both tiers back into the
  two-slice envelope (pure read, never mints store state); a different
  project's download sees live only (no cross-project leak).
- daemon lifecycle: project_id derived once from the commune dir's parent
  (cwd fallback); pulse ingest routes under it.
- contextstore.rs: pure read-path fns (live_context_file/project_context_file,
  6.1) + rename_endpoint — a- branch+worktree move, p- agent-dir ripple with
  one rename commit per project, collision refuses (never merge two minds).
- cli.rs cmd_rename: context arm wired — collision pre-check before the perch
  arm, ripple after it, context-only retry path for half-completed renames.
- REQ-INST-2 activated [impl, unit].

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

Commit + push D6a-2


warning: in the working copy of 'crates/spt-daemon/src/lifecycle.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-live/src/ingest.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-live/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-live/src/pulse.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-live/src/resume.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-live/src/signoff.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/contextstore.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/tests/contract_e2e.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 1ef9802] feat(live): M4-D6a two-tier context routing + rename context arm (REQ-INST-2)
 10 files changed, 481 insertions(+), 96 deletions(-)
To https://github.com/SaberMage/spt-core.git
   8b42452..1ef9802  dev-freeform -> dev-freeform


cd C:/Users/decid/Documents/projects/spt-core && for i in 1 2 3 4 5 6 7; do sleep 60; out=$(gh run list --branch dev-freeform --limit 1 --json conclusion,status,headSha); echo "$out"; if echo "$out" | grep -q '"status":"completed"'; then break; fi; done

Poll CI for 1ef9802


Command running in background with ID: b333pvg32. 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\b333pvg32.output. You will be notified when it completes. To check interim output, use Read on that file path.


55	/// The precedence marker stamped as a context file's first line.
56	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57	pub struct Marker {
58	    pub source: WriteSource,
59	    pub routed_at_ms: u64,
60	}
61
62	impl Marker {
63	    /// Render the marker as its HTML-comment first line (no trailing newline).
64	    pub fn render(&self) -> String {
65	        format!(
66	            "<!-- spt:source={} spt:routed_at_ms={} -->",
67	            self.source.as_str(),
68	            self.routed_at_ms
69	        )
70	    }
71
72	    /// Parse a marker from a context file's first line. Returns `None` if the
73	    /// line isn't a well-formed marker (an unmarked / hand-edited file ⇒ no
74	    /// prior precedence, so any write proceeds).
75	    pub fn parse(line: &str) -> Option<Marker> {
76	        let inner = line.trim().strip_prefix("<!--")?.strip_suffix("-->")?.trim();
77	        let mut source = None;
78	        let mut routed_at_ms = None;
79	        for tok in inner.split_whitespace() {
80	            if let Some(v) = tok.strip_prefix("spt:source=") {
81	                source = WriteSource::from_str(v);
82	            } else if let Some(v) = tok.strip_prefix("spt:routed_at_ms=") {
83	                routed_at_ms = v.parse::<u64>().ok();
84	            }
85	        }
86	        Some(Marker {
87	            source: source?,
88	            routed_at_ms: routed_at_ms?,
89	        })
90	    }
91	}
92
93	/// Result of a guarded write.
94	#[derive(Debug, Clone, PartialEq, Eq)]
95	pub enum WriteOutcome {
96	    /// The write landed; the new marker timestamp.
97	    Written { routed_at_ms: u64 },
98	    /// An llm write was suppressed within the protection window of a direct
99	    /// write; the file is unchanged.
100	    Suppressed { direct_at_ms: u64 },
101	}
102
103	/// Decide whether a write proceeds, given the existing marker (if any). Pure —
104	/// the testable core of the 6.5 guard. A direct write always wins; an llm write
105	/// is suppressed only when the *existing* marker is a direct write still inside
106	/// the protection window.
107	pub fn decide(
108	    existing: Option<Marker>,
109	    source: WriteSource,
110	    now_ms: u64,
111	    window_ms: u64,
112	) -> WriteOutcome {
113	    if source == WriteSource::Direct {
114	        return WriteOutcome::Written { routed_at_ms: now_ms };
115	    }
116	    if let Some(m) = existing {
117	        if m.source == WriteSource::Direct && now_ms.saturating_sub(m.routed_at_ms) < window_ms {
118	            return WriteOutcome::Suppressed {
119	                direct_at_ms: m.routed_at_ms,
120	            };
121	        }
122	    }
123	    WriteOutcome::Written { routed_at_ms: now_ms }
124	}
125
126	/// Read the leading precedence marker of a context file, if present.
127	pub fn read_marker(path: &Path) -> Option<Marker> {
128	    let s = std::fs::read_to_string(path).ok()?;
129	    Marker::parse(s.lines().next().unwrap_or(""))
130	}
131
132	/// Apply a precedence-guarded write to a context file. On `Written`, the file is
133	/// atomically replaced with `marker-line + body`; on `Suppressed`, the file is
134	/// left untouched and the suppression is logged (the body is discarded as the
135	/// stale snapshot it is).
136	pub fn write_context(
137	    path: &Path,
138	    body: &str,
139	    source: WriteSource,
140	    now_ms: u64,
141	    window_ms: u64,
142	) -> std::io::Result<WriteOutcome> {
143	    let existing = read_marker(path);
144	    let outcome = decide(existing, source, now_ms, window_ms);
145	    match outcome {
146	        WriteOutcome::Written { routed_at_ms } => {
147	            let marker = Marker { source, routed_at_ms };
148	            let content = format!("{}\n{}", marker.render(), body);
149	            atomic_write_string(path, &content)?;
150	        }
151	        WriteOutcome::Suppressed { direct_at_ms } => {
152	            // [impl->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] stale llm overwrite
153	            // within the window: suppress + log, never clobber the fresher write.
154	            eprintln!(
155	                "PRECEDENCE_SUPPRESS:{} (llm write within {}ms of direct write @ {})",
156	                path.display(),
157	                window_ms,
158	                direct_at_ms
159	            );
160	        }
161	    }
162	    Ok(outcome)
163	}
164
165	#[cfg(test)]
166	mod tests {
167	    use super::*;
168
169	    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] marker round-trips.
170	    fn marker_round_trips() {
171	        let m = Marker { source: WriteSource::Llm, routed_at_ms: 1717200000000 };
172	        assert_eq!(Marker::parse(&m.render()), Some(m));
173	        assert_eq!(Marker::parse("just some text"), None);
174	    }
175
176	    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] a direct write always proceeds.
177	    fn direct_always_writes() {
178	        let prior = Some(Marker { source: WriteSource::Direct, routed_at_ms: 1000 });
179	        assert_eq!(
180	            decide(prior, WriteSource::Direct, 1001, 60_000),
181	            WriteOutcome::Written { routed_at_ms: 1001 }
182	        );
183	    }
184
185	    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] llm within the window of a direct write is suppressed.
186	    fn llm_within_window_suppressed() {
187	        let prior = Some(Marker { source: WriteSource::Direct, routed_at_ms: 1000 });
188	        assert_eq!(
189	            decide(prior, WriteSource::Llm, 1500, 60_000),
190	            WriteOutcome::Suppressed { direct_at_ms: 1000 }
191	        );
192	    }
193
194	    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] llm past the window proceeds.
195	    fn llm_past_window_writes() {
196	        let prior = Some(Marker { source: WriteSource::Direct, routed_at_ms: 1000 });
197	        assert_eq!(
198	            decide(prior, WriteSource::Llm, 61_001, 60_000),
199	            WriteOutcome::Written { routed_at_ms: 61_001 }
200	        );
201	    }
202
203	    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] llm over a prior llm write proceeds (no direct to protect).
204	    fn llm_over_llm_writes() {
205	        let prior = Some(Marker { source: WriteSource::Llm, routed_at_ms: 1000 });
206	        assert_eq!(
207	            decide(prior, WriteSource::Llm, 1001, 60_000),
208	            WriteOutcome::Written { routed_at_ms: 1001 }
209	        );
210	    }
211
212	    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] guarded file write honors the decision.
213	    fn write_context_suppresses_stale_llm() {
214	        let dir = tempfile::tempdir().unwrap();

/// The precedence marker stamped as a context file's first line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Marker {
    pub source: WriteSource,
    pub routed_at_ms: u64,
}

impl Marker {
    /// Render the marker as its HTML-comment first line (no trailing newline).
    pub fn render(&self) -> String {
        format!(
            "<!-- spt:source={} spt:routed_at_ms={} -->",
            self.source.as_str(),
            self.routed_at_ms
        )
    }

    /// Parse a marker from a context file's first line. Returns `None` if the
    /// line isn't a well-formed marker (an unmarked / hand-edited file ⇒ no
    /// prior precedence, so any write proceeds).
    pub fn parse(line: &str) -> Option<Marker> {
        let inner = line.trim().strip_prefix("<!--")?.strip_suffix("-->")?.trim();
        let mut source = None;
        let mut routed_at_ms = None;
        for tok in inner.split_whitespace() {
            if let Some(v) = tok.strip_prefix("spt:source=") {
                source = WriteSource::from_str(v);
            } else if let Some(v) = tok.strip_prefix("spt:routed_at_ms=") {
                routed_at_ms = v.parse::<u64>().ok();
            }
        }
        Some(Marker {
            source: source?,
            routed_at_ms: routed_at_ms?,
        })
    }
}

/// Result of a guarded write.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WriteOutcome {
    /// The write landed; the new marker timestamp.
    Written { routed_at_ms: u64 },
    /// An llm write was suppressed within the protection window of a direct
    /// write; the file is unchanged.
    Suppressed { direct_at_ms: u64 },
}

/// Decide whether a write proceeds, given the existing marker (if any). Pure —
/// the testable core of the 6.5 guard. A direct write always wins; an llm write
/// is suppressed only when the *existing* marker is a direct write still inside
/// the protection window.
pub fn decide(
    existing: Option<Marker>,
    source: WriteSource,
    now_ms: u64,
    window_ms: u64,
) -> WriteOutcome {
    if source == WriteSource::Direct {
        return WriteOutcome::Written { routed_at_ms: now_ms };
    }
    if let Some(m) = existing {
        if m.source == WriteSource::Direct && now_ms.saturating_sub(m.routed_at_ms) < window_ms {
            return WriteOutcome::Suppressed {
                direct_at_ms: m.routed_at_ms,
            };
        }
    }
    WriteOutcome::Written { routed_at_ms: now_ms }
}

/// Read the leading precedence marker of a context file, if present.
pub fn read_marker(path: &Path) -> Option<Marker> {
    let s = std::fs::read_to_string(path).ok()?;
    Marker::parse(s.lines().next().unwrap_or(""))
}

/// Apply a precedence-guarded write to a context file. On `Written`, the file is
/// atomically replaced with `marker-line + body`; on `Suppressed`, the file is
/// left untouched and the suppression is logged (the body is discarded as the
/// stale snapshot it is).
pub fn write_context(
    path: &Path,
    body: &str,
    source: WriteSource,
    now_ms: u64,
    window_ms: u64,
) -> std::io::Result<WriteOutcome> {
    let existing = read_marker(path);
    let outcome = decide(existing, source, now_ms, window_ms);
    match outcome {
        WriteOutcome::Written { routed_at_ms } => {
            let marker = Marker { source, routed_at_ms };
            let content = format!("{}\n{}", marker.render(), body);
            atomic_write_string(path, &content)?;
        }
        WriteOutcome::Suppressed { direct_at_ms } => {
            // [impl->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] stale llm overwrite
            // within the window: suppress + log, never clobber the fresher write.
            eprintln!(
                "PRECEDENCE_SUPPRESS:{} (llm write within {}ms of direct write @ {})",
                path.display(),
                window_ms,
                direct_at_ms
            );
        }
    }
    Ok(outcome)
}

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

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] marker round-trips.
    fn marker_round_trips() {
        let m = Marker { source: WriteSource::Llm, routed_at_ms: 1717200000000 };
        assert_eq!(Marker::parse(&m.render()), Some(m));
        assert_eq!(Marker::parse("just some text"), None);
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] a direct write always proceeds.
    fn direct_always_writes() {
        let prior = Some(Marker { source: WriteSource::Direct, routed_at_ms: 1000 });
        assert_eq!(
            decide(prior, WriteSource::Direct, 1001, 60_000),
            WriteOutcome::Written { routed_at_ms: 1001 }
        );
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] llm within the window of a direct write is suppressed.
    fn llm_within_window_suppressed() {
        let prior = Some(Marker { source: WriteSource::Direct, routed_at_ms: 1000 });
        assert_eq!(
            decide(prior, WriteSource::Llm, 1500, 60_000),
            WriteOutcome::Suppressed { direct_at_ms: 1000 }
        );
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] llm past the window proceeds.
    fn llm_past_window_writes() {
        let prior = Some(Marker { source: WriteSource::Direct, routed_at_ms: 1000 });
        assert_eq!(
            decide(prior, WriteSource::Llm, 61_001, 60_000),
            WriteOutcome::Written { routed_at_ms: 61_001 }
        );
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] llm over a prior llm write proceeds (no direct to protect).
    fn llm_over_llm_writes() {
        let prior = Some(Marker { source: WriteSource::Llm, routed_at_ms: 1000 });
        assert_eq!(
            decide(prior, WriteSource::Llm, 1001, 60_000),
            WriteOutcome::Written { routed_at_ms: 1001 }
        );
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] guarded file write honors the decision.
    fn write_context_suppresses_stale_llm() {
        let dir = tempfile::tempdir().unwrap();


D6b-1 build: `contextmark` in spt-store (marker v2 + vector + merge decision):


Write

//! The context precedence marker (KNOWN-HAZARDS 6.5, distributed form) — every
//! context file's first line, and the **merge authority** for cross-node sync
//! (ADR-0013).
//!
//! ```text
//! <!-- spt:source=llm spt:routed_at_ms=1717200000000 spt:node=ab12… spt:vector=ab12…:3,ee34…:5 -->
//! ```
//!
//! - `source` + `routed_at_ms` — the local 6.5 guard's inputs (direct beats
//!   stale llm inside the protection window). `routed_at_ms` is **wall-clock
//!   and never orders a merge** — it is at most the human tiebreaker hint
//!   inside a surfaced conflict.
//! - `node` — the authoring node (Ed25519 pubkey hex), added for cross-node.
//! - `vector` — a per-node **version vector**: one monotonic epoch per node
//!   ([`crate::epoch::EpochSource`], the same counter behind the D3b registry
//!   lease — one ordering source serves both, by design). The vector is the
//!   ordering authority: dominate→accept, dominated→drop, concurrent→surface.
//!
//! v1 markers (no `node`/`vector`) parse as an **empty vector** (⊥): any real
//! vector dominates them, and two ⊥ writes with differing content are
//! **concurrent** (surfaced, never silently newest-wins).
//!
//! Lives in spt-store (not spt-live) so the D6c sync path — which sits in the
//! net/daemon layers — and the live-agent write path share one parser; the
//! layering forbids net→live edges (R-ARCH-1).
// [impl->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE]

use std::collections::BTreeMap;

/// Who authored a context write. `Direct` = spt-core / operator / sync
/// propagation (authoritative, fresh); `Llm` = a model snapshot (possibly
/// stale) — the case the local 6.5 guard protects against.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteSource {
    Direct,
    Llm,
}

impl WriteSource {
    pub fn as_str(self) -> &'static str {
        match self {
            WriteSource::Direct => "direct",
            WriteSource::Llm => "llm",
        }
    }
    pub fn from_str(s: &str) -> Option<WriteSource> {
        match s {
            "direct" => Some(WriteSource::Direct),
            "llm" => Some(WriteSource::Llm),
            _ => None,
        }
    }
}

/// A per-node version vector: `node id → that node's epoch at write time`.
/// Empty = ⊥ (a legacy/v1 write with no causal history).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VersionVector(pub BTreeMap<String, u64>);

/// How two vectors relate — the merge-ordering verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VectorOrder {
    /// Identical histories.
    Equal,
    /// `self` strictly dominates `other` (knows everything it knows, and more).
    Dominates,
    /// `self` is strictly dominated by `other`.
    Dominated,
    /// Neither dominates — concurrent writes.
    Concurrent,
}

impl VersionVector {
    /// Render as the marker token value: `node:epoch,…` sorted by node
    /// (BTreeMap order — deterministic). Empty vector renders empty.
    pub fn render(&self) -> String {
        self.0
            .iter()
            .map(|(n, e)| format!("{n}:{e}"))
            .collect::<Vec<_>>()
            .join(",")
    }

    /// Parse the marker token value. Undecodable entries are skipped (tolerant,
    /// 4.2 posture) — a corrupt vector degrades toward ⊥, which can only make
    /// merges *more* conservative (conflict-surfacing), never silently lossy.
    pub fn parse(s: &str) -> VersionVector {
        let mut map = BTreeMap::new();
        for entry in s.split(',') {
            if let Some((node, epoch)) = entry.rsplit_once(':') {
                if let Ok(e) = epoch.trim().parse::<u64>() {
                    if !node.trim().is_empty() {
                        map.insert(node.trim().to_string(), e);
                    }
                }
            }
        }
        VersionVector(map)
    }

    /// Compare two vectors for merge ordering.
    pub fn compare(&self, other: &VersionVector) -> VectorOrder {
        let mut self_ahead = false;
        let mut other_ahead = false;
        for (n, &e) in &self.0 {
            match other.0.get(n) {
                Some(&oe) if oe > e => other_ahead = true,
                Some(&oe) if oe < e => self_ahead = true,
                Some(_) => {}
                None => self_ahead = true,
            }
        }
        for n in other.0.keys() {
            if !self.0.contains_key(n) {
                other_ahead = true;
            }
        }
        match (self_ahead, other_ahead) {
            (false, false) => VectorOrder::Equal,
            (true, false) => VectorOrder::Dominates,
            (false, true) => VectorOrder::Dominated,
            (true, true) => VectorOrder::Concurrent,
        }
    }

    /// The pointwise maximum of two vectors — the merge parent a reconciled
    /// write starts from (ADR-0013: `join(vA, vB)` + a bump dominates both).
    pub fn join(&self, other: &VersionVector) -> VersionVector {
        let mut out = self.0.clone();
        for (n, &e) in &other.0 {
            let slot = out.entry(n.clone()).or_insert(0);
            if e > *slot {
                *slot = e;
            }
        }
        VersionVector(out)
    }

    /// Advance `node`'s entry to `epoch` (the author's fresh
    /// [`EpochSource`](crate::epoch::EpochSource) stamp).
    pub fn bump(&mut self, node: &str, epoch: u64) {
        let slot = self.0.entry(node.to_string()).or_insert(0);
        if epoch > *slot {
            *slot = epoch;
        }
    }
}

/// The full precedence marker — a context file's first line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Marker {
    pub source: WriteSource,
    /// Wall-clock stamp — the local-window guard input + the human tiebreaker
    /// hint. NEVER a merge-ordering input.
    pub routed_at_ms: u64,
    /// Authoring node id (pubkey hex); `None` on a pre-identity / v1 write.
    pub node: Option<String>,
    /// The write's causal history; empty = ⊥ (v1/legacy).
    pub vector: VersionVector,
}

impl Marker {
    /// Render as the HTML-comment first line (no trailing newline). v2 tokens
    /// are emitted only when present, so pre-identity writes render exactly the
    /// v1 shape.
    pub fn render(&self) -> String {
        let mut s = format!(
            "<!-- spt:source={} spt:routed_at_ms={}",
            self.source.as_str(),
            self.routed_at_ms
        );
        if let Some(node) = &self.node {
            s.push_str(&format!(" spt:node={node}"));
        }
        if !self.vector.0.is_empty() {
            s.push_str(&format!(" spt:vector={}", self.vector.render()));
        }
        s.push_str(" -->");
        s
    }

    /// Parse a marker line. `None` if the line isn't a well-formed marker (an
    /// unmarked / hand-edited file ⇒ no prior precedence, any write proceeds).
    /// v1 lines (no node/vector tokens) parse with `node=None`, vector ⊥.
    pub fn parse(line: &str) -> Option<Marker> {
        let inner = line.trim().strip_prefix("<!--")?.strip_suffix("-->")?.trim();
        let mut source = None;
        let mut routed_at_ms = None;
        let mut node = None;
        let mut vector = VersionVector::default();
        for tok in inner.split_whitespace() {
            if let Some(v) = tok.strip_prefix("spt:source=") {
                source = WriteSource::from_str(v);
            } else if let Some(v) = tok.strip_prefix("spt:routed_at_ms=") {
                routed_at_ms = v.parse::<u64>().ok();
            } else if let Some(v) = tok.strip_prefix("spt:node=") {
                node = Some(v.to_string());
            } else if let Some(v) = tok.strip_prefix("spt:vector=") {
                vector = VersionVector::parse(v);
            }
        }
        Some(Marker {
            source: source?,
            routed_at_ms: routed_at_ms?,
            node,
            vector,
        })
    }

    /// Parse the marker off a context file's **content** (first line).
    pub fn parse_content(content: &str) -> Option<Marker> {
        Marker::parse(content.lines().next().unwrap_or(""))
    }
}

/// The cross-node merge verdict for one file (ADR-0013).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeDecision {
    /// Incoming strictly dominates (or local has no marker at all): take it.
    AcceptIncoming,
    /// Incoming is dominated or identical: drop it (stale / already applied).
    DropIncoming,
    /// Concurrent — surface as an explicit conflict; NEVER silent newest-wins.
    Conflict,
}

/// Decide the merge for one file from its two markers. Pure — the testable
/// core of the distributed 6.5 guard. `local = None` (no file / no marker)
/// accepts any incoming. Equal vectors (including two ⊥s) with **identical
/// content hashes** should be short-circuited by the caller before calling
/// this; here equal/⊥-⊥ falls to `Conflict` precisely because differing
/// content with no causal order must surface, never auto-pick.
// [impl->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE]
pub fn decide_merge(local: Option<&Marker>, incoming: &Marker) -> MergeDecision {
    let Some(local) = local else {
        return MergeDecision::AcceptIncoming;
    };
    match incoming.vector.compare(&local.vector) {
        VectorOrder::Dominates => MergeDecision::AcceptIncoming,
        VectorOrder::Dominated => MergeDecision::DropIncoming,
        VectorOrder::Equal | VectorOrder::Concurrent => MergeDecision::Conflict,
    }
}

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

    fn vv(entries: &[(&str, u64)]) -> VersionVector {
        VersionVector(entries.iter().map(|(n, e)| (n.to_string(), *e)).collect())
    }
    fn marker(vector: VersionVector) -> Marker {
        Marker { source: WriteSource::Llm, routed_at_ms: 1000, node: Some("a".into()), vector }
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] v2 marker round-trips; v1 lines parse with ⊥ vector.
    fn marker_round_trips_v1_and_v2() {
        let m = Marker {
            source: WriteSource::Llm,
            routed_at_ms: 1717200000000,
            node: Some("ab12".into()),
            vector: vv(&[("ab12", 3), ("ee34", 5)]),
        };
        assert_eq!(Marker::parse(&m.render()), Some(m));

        // v1 line: no node/vector tokens.
        let v1 = Marker::parse("<!-- spt:source=direct spt:routed_at_ms=1000 -->").unwrap();
        assert_eq!(v1.node, None);
        assert!(v1.vector.0.is_empty(), "v1 parses as ⊥");
        // And a pre-identity marker renders exactly the v1 shape.
        let pre = Marker {
            source: WriteSource::Direct,
            routed_at_ms: 1000,
            node: None,
            vector: VersionVector::default(),
        };
        assert_eq!(pre.render(), "<!-- spt:source=direct spt:routed_at_ms=1000 -->");
        assert_eq!(Marker::parse("just some text"), None);
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] vector ordering: dominate / dominated / equal / concurrent.
    fn vector_compare_covers_all_orders() {
        let base = vv(&[("a", 2), ("b", 1)]);
        assert_eq!(vv(&[("a", 3), ("b", 1)]).compare(&base), VectorOrder::Dominates);
        assert_eq!(vv(&[("a", 1), ("b", 1)]).compare(&base), VectorOrder::Dominated);
        assert_eq!(base.compare(&base.clone()), VectorOrder::Equal);
        // a-ahead vs b-ahead: concurrent.
        assert_eq!(vv(&[("a", 3)]).compare(&vv(&[("b", 9)])), VectorOrder::Concurrent);
        // Any real vector dominates ⊥.
        assert_eq!(vv(&[("a", 1)]).compare(&VersionVector::default()), VectorOrder::Dominates);
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] join+bump strictly dominates both parents (the reconcile stamp).
    fn join_bump_dominates_both_parents() {
        let a = vv(&[("a", 3), ("c", 1)]);
        let b = vv(&[("b", 9), ("c", 2)]);
        let mut merged = a.join(&b);
        merged.bump("a", 4);
        assert_eq!(merged.compare(&a), VectorOrder::Dominates);
        assert_eq!(merged.compare(&b), VectorOrder::Dominates);
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] merge verdicts: dominate→accept, dominated/equal-history→drop is NOT silent for concurrent — concurrent + ⊥⊥ surface.
    fn merge_decisions() {
        let local = marker(vv(&[("a", 2)]));
        assert_eq!(
            decide_merge(Some(&local), &marker(vv(&[("a", 3)]))),
            MergeDecision::AcceptIncoming
        );
        assert_eq!(
            decide_merge(Some(&local), &marker(vv(&[("a", 1)]))),
            MergeDecision::DropIncoming
        );
        assert_eq!(
            decide_merge(Some(&local), &marker(vv(&[("b", 7)]))),
            MergeDecision::Conflict,
            "concurrent must surface"
        );
        // Two ⊥ writes (legacy) with differing content: surface, never newest-wins.
        let legacy_local = marker(VersionVector::default());
        assert_eq!(
            decide_merge(Some(&legacy_local), &marker(VersionVector::default())),
            MergeDecision::Conflict
        );
        // No local marker at all: accept.
        assert_eq!(decide_merge(None, &local), MergeDecision::AcceptIncoming);
        // Wall-clock plays NO part: an older-clock dominating vector still wins.
        let old_clock_dominating = Marker { routed_at_ms: 1, ..marker(vv(&[("a", 5)])) };
        assert_eq!(
            decide_merge(Some(&local), &old_clock_dominating),
            MergeDecision::AcceptIncoming
        );
    }

    #[test] // tolerant vector parse degrades toward ⊥, never panics.
    fn vector_parse_tolerant() {
        assert_eq!(VersionVector::parse("a:1,b:2"), vv(&[("a", 1), ("b", 2)]));
        assert_eq!(VersionVector::parse("garbage,,a:nope,:5, b : 7 "), vv(&[("b", 7)]));
        assert_eq!(VersionVector::parse(""), VersionVector::default());
    }
}


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

//! The context precedence marker (KNOWN-HAZARDS 6.5, distributed form) — every
//! context file's first line, and the **merge authority** for cross-node sync
//! (ADR-0013).
//!
//! ```text
//! <!-- spt:source=llm spt:routed_at_ms=1717200000000 spt:node=ab12… spt:vector=ab12…:3,ee34…:5 -->
//! ```
//!
//! - `source` + `routed_at_ms` — the local 6.5 guard's inputs (direct beats
//!   stale llm inside the protection window). `routed_at_ms` is **wall-clock
//!   and never orders a merge** — it is at most the human tiebreaker hint
//!   inside a surfaced conflict.
//! - `node` — the authoring node (Ed25519 pubkey hex), added for cross-node.
//! - `vector` — a per-node **version vector**: one monotonic epoch per node
//!   ([`crate::epoch::EpochSource`], the same counter behind the D3b registry
//!   lease — one ordering source serves both, by design). The vector is the
//!   ordering authority: dominate→accept, dominated→drop, concurrent→surface.
//!
//! v1 markers (no `node`/`vector`) parse as an **empty vector** (⊥): any real
//! vector dominates them, and two ⊥ writes with differing content are
//! **concurrent** (surfaced, never silently newest-wins).
//!
//! Lives in spt-store (not spt-live) so the D6c sync path — which sits in the
//! net/daemon layers — and the live-agent write path share one parser; the
//! layering forbids net→live edges (R-ARCH-1).
// [impl->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE]

use std::collections::BTreeMap;

/// Who authored a context write. `Direct` = spt-core / operator / sync
/// propagation (authoritative, fresh); `Llm` = a model snapshot (possibly
/// stale) — the case the local 6.5 guard protects against.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteSource {
    Direct,
    Llm,
}

impl WriteSource {
    pub fn as_str(self) -> &'static str {
        match self {
            WriteSource::Direct => "direct",
            WriteSource::Llm => "llm",
        }
    }
    pub fn from_str(s: &str) -> Option<WriteSource> {
        match s {
            "direct" => Some(WriteSource::Direct),
            "llm" => Some(WriteSource::Llm),
            _ => None,
        }
    }
}

/// A per-node version vector: `node id → that node's epoch at write time`.
/// Empty = ⊥ (a legacy/v1 write with no causal history).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VersionVector(pub BTreeMap<String, u64>);

/// How two vectors relate — the merge-ordering verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VectorOrder {
    /// Identical histories.
    Equal,
    /// `self` strictly dominates `other` (knows everything it knows, and more).
    Dominates,
    /// `self` is strictly dominated by `other`.
    Dominated,
    /// Neither dominates — concurrent writes.
    Concurrent,
}

impl VersionVector {
    /// Render as the marker token value: `node:epoch,…` sorted by node
    /// (BTreeMap order — deterministic). Empty vector renders empty.
    pub fn render(&self) -> String {
        self.0
            .iter()
            .map(|(n, e)| format!("{n}:{e}"))
            .collect::<Vec<_>>()
            .join(",")
    }

    /// Parse the marker token value. Undecodable entries are skipped (tolerant,
    /// 4.2 posture) — a corrupt vector degrades toward ⊥, which can only make
    /// merges *more* conservative (conflict-surfacing), never silently lossy.
    pub fn parse(s: &str) -> VersionVector {
        let mut map = BTreeMap::new();
        for entry in s.split(',') {
            if let Some((node, epoch)) = entry.rsplit_once(':') {
                if let Ok(e) = epoch.trim().parse::<u64>() {
                    if !node.trim().is_empty() {
                        map.insert(node.trim().to_string(), e);
                    }
                }
            }
        }
        VersionVector(map)
    }

    /// Compare two vectors for merge ordering.
    pub fn compare(&self, other: &VersionVector) -> VectorOrder {
        let mut self_ahead = false;
        let mut other_ahead = false;
        for (n, &e) in &self.0 {
            match other.0.get(n) {
                Some(&oe) if oe > e => other_ahead = true,
                Some(&oe) if oe < e => self_ahead = true,
                Some(_) => {}
                None => self_ahead = true,
            }
        }
        for n in other.0.keys() {
            if !self.0.contains_key(n) {
                other_ahead = true;
            }
        }
        match (self_ahead, other_ahead) {
            (false, false) => VectorOrder::Equal,
            (true, false) => VectorOrder::Dominates,
            (false, true) => VectorOrder::Dominated,
            (true, true) => VectorOrder::Concurrent,
        }
    }

    /// The pointwise maximum of two vectors — the merge parent a reconciled
    /// write starts from (ADR-0013: `join(vA, vB)` + a bump dominates both).
    pub fn join(&self, other: &VersionVector) -> VersionVector {
        let mut out = self.0.clone();
        for (n, &e) in &other.0 {
            let slot = out.entry(n.clone()).or_insert(0);
            if e > *slot {
                *slot = e;
            }
        }
        VersionVector(out)
    }

    /// Advance `node`'s entry to `epoch` (the author's fresh
    /// [`EpochSource`](crate::epoch::EpochSource) stamp).
    pub fn bump(&mut self, node: &str, epoch: u64) {
        let slot = self.0.entry(node.to_string()).or_insert(0);
        if epoch > *slot {
            *slot = epoch;
        }
    }
}

/// The full precedence marker — a context file's first line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Marker {
    pub source: WriteSource,
    /// Wall-clock stamp — the local-window guard input + the human tiebreaker
    /// hint. NEVER a merge-ordering input.
    pub routed_at_ms: u64,
    /// Authoring node id (pubkey hex); `None` on a pre-identity / v1 write.
    pub node: Option<String>,
    /// The write's causal history; empty = ⊥ (v1/legacy).
    pub vector: VersionVector,
}

impl Marker {
    /// Render as the HTML-comment first line (no trailing newline). v2 tokens
    /// are emitted only when present, so pre-identity writes render exactly the
    /// v1 shape.
    pub fn render(&self) -> String {
        let mut s = format!(
            "<!-- spt:source={} spt:routed_at_ms={}",
            self.source.as_str(),
            self.routed_at_ms
        );
        if let Some(node) = &self.node {
            s.push_str(&format!(" spt:node={node}"));
        }
        if !self.vector.0.is_empty() {
            s.push_str(&format!(" spt:vector={}", self.vector.render()));
        }
        s.push_str(" -->");
        s
    }

    /// Parse a marker line. `None` if the line isn't a well-formed marker (an
    /// unmarked / hand-edited file ⇒ no prior precedence, any write proceeds).
    /// v1 lines (no node/vector tokens) parse with `node=None`, vector ⊥.
    pub fn parse(line: &str) -> Option<Marker> {
        let inner = line.trim().strip_prefix("<!--")?.strip_suffix("-->")?.trim();
        let mut source = None;
        let mut routed_at_ms = None;
        let mut node = None;
        let mut vector = VersionVector::default();
        for tok in inner.split_whitespace() {
            if let Some(v) = tok.strip_prefix("spt:source=") {
                source = WriteSource::from_str(v);
            } else if let Some(v) = tok.strip_prefix("spt:routed_at_ms=") {
                routed_at_ms = v.parse::<u64>().ok();
            } else if let Some(v) = tok.strip_prefix("spt:node=") {
                node = Some(v.to_string());
            } else if let Some(v) = tok.strip_prefix("spt:vector=") {
                vector = VersionVector::parse(v);
            }
        }
        Some(Marker {
            source: source?,
            routed_at_ms: routed_at_ms?,
            node,
            vector,
        })
    }

    /// Parse the marker off a context file's **content** (first line).
    pub fn parse_content(content: &str) -> Option<Marker> {
        Marker::parse(content.lines().next().unwrap_or(""))
    }
}

/// The cross-node merge verdict for one file (ADR-0013).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeDecision {
    /// Incoming strictly dominates (or local has no marker at all): take it.
    AcceptIncoming,
    /// Incoming is dominated or identical: drop it (stale / already applied).
    DropIncoming,
    /// Concurrent — surface as an explicit conflict; NEVER silent newest-wins.
    Conflict,
}

/// Decide the merge for one file from its two markers. Pure — the testable
/// core of the distributed 6.5 guard. `local = None` (no file / no marker)
/// accepts any incoming. Equal vectors (including two ⊥s) with **identical
/// content hashes** should be short-circuited by the caller before calling
/// this; here equal/⊥-⊥ falls to `Conflict` precisely because differing
/// content with no causal order must surface, never auto-pick.
// [impl->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE]
pub fn decide_merge(local: Option<&Marker>, incoming: &Marker) -> MergeDecision {
    let Some(local) = local else {
        return MergeDecision::AcceptIncoming;
    };
    match incoming.vector.compare(&local.vector) {
        VectorOrder::Dominates => MergeDecision::AcceptIncoming,
        VectorOrder::Dominated => MergeDecision::DropIncoming,
        VectorOrder::Equal | VectorOrder::Concurrent => MergeDecision::Conflict,
    }
}

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

    fn vv(entries: &[(&str, u64)]) -> VersionVector {
        VersionVector(entries.iter().map(|(n, e)| (n.to_string(), *e)).collect())
    }
    fn marker(vector: VersionVector) -> Marker {
        Marker { source: WriteSource::Llm, routed_at_ms: 1000, node: Some("a".into()), vector }
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] v2 marker round-trips; v1 lines parse with ⊥ vector.
    fn marker_round_trips_v1_and_v2() {
        let m = Marker {
            source: WriteSource::Llm,
            routed_at_ms: 1717200000000,
            node: Some("ab12".into()),
            vector: vv(&[("ab12", 3), ("ee34", 5)]),
        };
        assert_eq!(Marker::parse(&m.render()), Some(m));

        // v1 line: no node/vector tokens.
        let v1 = Marker::parse("<!-- spt:source=direct spt:routed_at_ms=1000 -->").unwrap();
        assert_eq!(v1.node, None);
        assert!(v1.vector.0.is_empty(), "v1 parses as ⊥");
        // And a pre-identity marker renders exactly the v1 shape.
        let pre = Marker {
            source: WriteSource::Direct,
            routed_at_ms: 1000,
            node: None,
            vector: VersionVector::default(),
        };
        assert_eq!(pre.render(), "<!-- spt:source=direct spt:routed_at_ms=1000 -->");
        assert_eq!(Marker::parse("just some text"), None);
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] vector ordering: dominate / dominated / equal / concurrent.
    fn vector_compare_covers_all_orders() {
        let base = vv(&[("a", 2), ("b", 1)]);
        assert_eq!(vv(&[("a", 3), ("b", 1)]).compare(&base), VectorOrder::Dominates);
        assert_eq!(vv(&[("a", 1), ("b", 1)]).compare(&base), VectorOrder::Dominated);
        assert_eq!(base.compare(&base.clone()), VectorOrder::Equal);
        // a-ahead vs b-ahead: concurrent.
        assert_eq!(vv(&[("a", 3)]).compare(&vv(&[("b", 9)])), VectorOrder::Concurrent);
        // Any real vector dominates ⊥.
        assert_eq!(vv(&[("a", 1)]).compare(&VersionVector::default()), VectorOrder::Dominates);
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] join+bump strictly dominates both parents (the reconcile stamp).
    fn join_bump_dominates_both_parents() {
        let a = vv(&[("a", 3), ("c", 1)]);
        let b = vv(&[("b", 9), ("c", 2)]);
        let mut merged = a.join(&b);
        merged.bump("a", 4);
        assert_eq!(merged.compare(&a), VectorOrder::Dominates);
        assert_eq!(merged.compare(&b), VectorOrder::Dominates);
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] merge verdicts: dominate→accept, dominated/equal-history→drop is NOT silent for concurrent — concurrent + ⊥⊥ surface.
    fn merge_decisions() {
        let local = marker(vv(&[("a", 2)]));
        assert_eq!(
            decide_merge(Some(&local), &marker(vv(&[("a", 3)]))),
            MergeDecision::AcceptIncoming
        );
        assert_eq!(
            decide_merge(Some(&local), &marker(vv(&[("a", 1)]))),
            MergeDecision::DropIncoming
        );
        assert_eq!(
            decide_merge(Some(&local), &marker(vv(&[("b", 7)]))),
            MergeDecision::Conflict,
            "concurrent must surface"
        );
        // Two ⊥ writes (legacy) with differing content: surface, never newest-wins.
        let legacy_local = marker(VersionVector::default());
        assert_eq!(
            decide_merge(Some(&legacy_local), &marker(VersionVector::default())),
            MergeDecision::Conflict
        );
        // No local marker at all: accept.
        assert_eq!(decide_merge(None, &local), MergeDecision::AcceptIncoming);
        // Wall-clock plays NO part: an older-clock dominating vector still wins.
        let old_clock_dominating = Marker { routed_at_ms: 1, ..marker(vv(&[("a", 5)])) };
        assert_eq!(
            decide_merge(Some(&local), &old_clock_dominating),
            MergeDecision::AcceptIncoming
        );
    }

    #[test] // tolerant vector parse degrades toward ⊥, never panics.
    fn vector_parse_tolerant() {
        assert_eq!(VersionVector::parse("a:1,b:2"), vv(&[("a", 1), ("b", 2)]));
        assert_eq!(VersionVector::parse("garbage,,a:nope,:5, b : 7 "), vv(&[("b", 7)]));
        assert_eq!(VersionVector::parse(""), VersionVector::default());
    }
}


1	//! `node.key` — the node's durable Ed25519 identity (`docs/STORAGE.md`).
2	//!
3	//! The keypair seed *is* the node's stable name: the iroh `EndpointId` is this
4	//! key's public half (D1), trust rows and registry rows reference it, and the
5	//! broker's network endpoint binds as it (D4). So it must be created once and
6	//! loaded forever — [`load_or_create`] generates + persists a seed only when no
7	//! file exists.
8	//!
9	//! A present-but-corrupt file is an **error, never a silent regeneration**:
10	//! regenerating would silently change the node's name, orphaning every trust
11	//! store entry and registry row that references the old key. The operator must
12	//! decide (restore the file, or deliberately delete it to mint a new identity).
13	//!
14	//! Stored as the 32-byte seed in lowercase hex — per-node secret material under
15	//! [`identity_dir`](crate::perch::identity_dir), never git-tracked, never synced.
16	// [impl->REQ-NODE-IDENTITY]
17
18	use std::io;
19	use std::path::Path;
20
21	use spt_proto::identity::{Identity, SEED_LEN};
22
23	use crate::atomic::atomic_write_string;
24	use crate::perch::node_key_file;
25
26	/// Failures loading/creating the node identity.
27	#[derive(Debug)]
28	pub enum NodeIdError {
29	    /// Reading or writing `node.key` failed (message preserved).
30	    Io(String),
31	    /// `node.key` exists but does not parse as a 64-char hex seed. Deliberately
32	    /// NOT auto-regenerated — see the module docs.
33	    Corrupt,
34	}
35
36	impl std::fmt::Display for NodeIdError {
37	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38	        match self {
39	            NodeIdError::Io(e) => write!(f, "node.key io: {e}"),
40	            NodeIdError::Corrupt => write!(
41	                f,
42	                "node.key exists but is corrupt; restore it (or delete it to \
43	                 deliberately mint a NEW node identity — old trust/registry rows \
44	                 will not recognize this node)"
45	            ),
46	        }
47	    }
48	}
49
50	impl std::error::Error for NodeIdError {}
51
52	/// Load the node identity from the canonical `node.key`, generating + persisting
53	/// a fresh one iff the file is absent. The stable-name contract: every call on
54	/// the same home yields the same identity.
55	pub fn load_or_create() -> Result<Identity, NodeIdError> {
56	    load_or_create_at(&node_key_file())
57	}
58
59	/// [`load_or_create`] against an explicit path (the testable core).
60	pub fn load_or_create_at(path: &Path) -> Result<Identity, NodeIdError> {
61	    match std::fs::read_to_string(path) {
62	        Ok(s) => parse_seed_hex(s.trim())
63	            .map(|seed| Identity::from_seed(&seed))
64	            .ok_or(NodeIdError::Corrupt),
65	        Err(e) if e.kind() == io::ErrorKind::NotFound => {
66	            let id = Identity::generate();
67	            if let Some(parent) = path.parent() {
68	                if !parent.as_os_str().is_empty() {
69	                    std::fs::create_dir_all(parent)
70	                        .map_err(|e| NodeIdError::Io(e.to_string()))?;
71	                }
72	            }
73	            // Atomic write so a crash mid-create can't leave a torn (= corrupt,
74	            // = node-bricking) seed file.
75	            atomic_write_string(path, &seed_to_hex(&id.seed()))
76	                .map_err(|e| NodeIdError::Io(e.to_string()))?;
77	            Ok(id)
78	        }
79	        Err(e) => Err(NodeIdError::Io(e.to_string())),
80	    }
81	}
82
83	fn seed_to_hex(seed: &[u8; SEED_LEN]) -> String {
84	    let mut s = String::with_capacity(SEED_LEN * 2);
85	    for b in seed {
86	        s.push_str(&format!("{b:02x}"));
87	    }
88	    s
89	}
90
91	fn parse_seed_hex(s: &str) -> Option<[u8; SEED_LEN]> {
92	    if s.len() != SEED_LEN * 2 || !s.is_ascii() {
93	        return None;
94	    }
95	    let mut seed = [0u8; SEED_LEN];
96	    for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
97	        let hex = std::str::from_utf8(chunk).ok()?;
98	        seed[i] = u8::from_str_radix(hex, 16).ok()?;
99	    }
100	    Some(seed)
101	}
102
103	#[cfg(test)]
104	mod tests {
105	    use super::*;
106
107	    // [unit->REQ-NODE-IDENTITY] first call creates + persists; every later call
108	    // loads the SAME identity — the stable-name contract a daemon restart relies on.
109	    #[test]
110	    fn create_then_load_is_stable() {
111	        let dir = tempfile::tempdir().unwrap();
112	        let path = dir.path().join("identity").join("node.key");
113	        let first = load_or_create_at(&path).expect("create");
114	        assert!(path.exists(), "node.key persisted");
115	        let second = load_or_create_at(&path).expect("load");
116	        assert_eq!(
117	            first.public_key(),
118	            second.public_key(),
119	            "same identity across loads"
120	        );
121	    }
122
123	    // [unit->REQ-NODE-IDENTITY] a corrupt node.key errors — it is NEVER silently
124	    // regenerated (regeneration would change the node's name and orphan trust rows).
125	    #[test]
126	    fn corrupt_key_errors_never_regenerates() {
127	        let dir = tempfile::tempdir().unwrap();
128	        let path = dir.path().join("node.key");
129	        std::fs::write(&path, "not a seed").unwrap();
130	        assert!(matches!(load_or_create_at(&path), Err(NodeIdError::Corrupt)));
131	        // The corrupt file is left untouched for the operator.
132	        assert_eq!(std::fs::read_to_string(&path).unwrap(), "not a seed");
133	    }
134
135	    // [unit->REQ-NODE-IDENTITY] the persisted form round-trips the exact seed
136	    // (lowercase hex, trailing-whitespace tolerant on read).
137	    #[test]
138	    fn seed_hex_round_trips() {
139	        let dir = tempfile::tempdir().unwrap();
140	        let path = dir.path().join("node.key");
141	        let id = load_or_create_at(&path).expect("create");
142	        let on_disk = std::fs::read_to_string(&path).unwrap();
143	        assert_eq!(on_disk.len(), SEED_LEN * 2, "bare 64-char hex");
144	        std::fs::write(&path, format!("{on_disk}\n")).unwrap(); // editor-added newline
145	        let again = load_or_create_at(&path).expect("load trims");
146	        assert_eq!(id.public_key(), again.public_key());
147	    }
148	}
149

//! `node.key` — the node's durable Ed25519 identity (`docs/STORAGE.md`).
//!
//! The keypair seed *is* the node's stable name: the iroh `EndpointId` is this
//! key's public half (D1), trust rows and registry rows reference it, and the
//! broker's network endpoint binds as it (D4). So it must be created once and
//! loaded forever — [`load_or_create`] generates + persists a seed only when no
//! file exists.
//!
//! A present-but-corrupt file is an **error, never a silent regeneration**:
//! regenerating would silently change the node's name, orphaning every trust
//! store entry and registry row that references the old key. The operator must
//! decide (restore the file, or deliberately delete it to mint a new identity).
//!
//! Stored as the 32-byte seed in lowercase hex — per-node secret material under
//! [`identity_dir`](crate::perch::identity_dir), never git-tracked, never synced.
// [impl->REQ-NODE-IDENTITY]

use std::io;
use std::path::Path;

use spt_proto::identity::{Identity, SEED_LEN};

use crate::atomic::atomic_write_string;
use crate::perch::node_key_file;

/// Failures loading/creating the node identity.
#[derive(Debug)]
pub enum NodeIdError {
    /// Reading or writing `node.key` failed (message preserved).
    Io(String),
    /// `node.key` exists but does not parse as a 64-char hex seed. Deliberately
    /// NOT auto-regenerated — see the module docs.
    Corrupt,
}

impl std::fmt::Display for NodeIdError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NodeIdError::Io(e) => write!(f, "node.key io: {e}"),
            NodeIdError::Corrupt => write!(
                f,
                "node.key exists but is corrupt; restore it (or delete it to \
                 deliberately mint a NEW node identity — old trust/registry rows \
                 will not recognize this node)"
            ),
        }
    }
}

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

/// Load the node identity from the canonical `node.key`, generating + persisting
/// a fresh one iff the file is absent. The stable-name contract: every call on
/// the same home yields the same identity.
pub fn load_or_create() -> Result<Identity, NodeIdError> {
    load_or_create_at(&node_key_file())
}

/// [`load_or_create`] against an explicit path (the testable core).
pub fn load_or_create_at(path: &Path) -> Result<Identity, NodeIdError> {
    match std::fs::read_to_string(path) {
        Ok(s) => parse_seed_hex(s.trim())
            .map(|seed| Identity::from_seed(&seed))
            .ok_or(NodeIdError::Corrupt),
        Err(e) if e.kind() == io::ErrorKind::NotFound => {
            let id = Identity::generate();
            if let Some(parent) = path.parent() {
                if !parent.as_os_str().is_empty() {
                    std::fs::create_dir_all(parent)
                        .map_err(|e| NodeIdError::Io(e.to_string()))?;
                }
            }
            // Atomic write so a crash mid-create can't leave a torn (= corrupt,
            // = node-bricking) seed file.
            atomic_write_string(path, &seed_to_hex(&id.seed()))
                .map_err(|e| NodeIdError::Io(e.to_string()))?;
            Ok(id)
        }
        Err(e) => Err(NodeIdError::Io(e.to_string())),
    }
}

fn seed_to_hex(seed: &[u8; SEED_LEN]) -> String {
    let mut s = String::with_capacity(SEED_LEN * 2);
    for b in seed {
        s.push_str(&format!("{b:02x}"));
    }
    s
}

fn parse_seed_hex(s: &str) -> Option<[u8; SEED_LEN]> {
    if s.len() != SEED_LEN * 2 || !s.is_ascii() {
        return None;
    }
    let mut seed = [0u8; SEED_LEN];
    for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
        let hex = std::str::from_utf8(chunk).ok()?;
        seed[i] = u8::from_str_radix(hex, 16).ok()?;
    }
    Some(seed)
}

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

    // [unit->REQ-NODE-IDENTITY] first call creates + persists; every later call
    // loads the SAME identity — the stable-name contract a daemon restart relies on.
    #[test]
    fn create_then_load_is_stable() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("node.key");
        let first = load_or_create_at(&path).expect("create");
        assert!(path.exists(), "node.key persisted");
        let second = load_or_create_at(&path).expect("load");
        assert_eq!(
            first.public_key(),
            second.public_key(),
            "same identity across loads"
        );
    }

    // [unit->REQ-NODE-IDENTITY] a corrupt node.key errors — it is NEVER silently
    // regenerated (regeneration would change the node's name and orphan trust rows).
    #[test]
    fn corrupt_key_errors_never_regenerates() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("node.key");
        std::fs::write(&path, "not a seed").unwrap();
        assert!(matches!(load_or_create_at(&path), Err(NodeIdError::Corrupt)));
        // The corrupt file is left untouched for the operator.
        assert_eq!(std::fs::read_to_string(&path).unwrap(), "not a seed");
    }

    // [unit->REQ-NODE-IDENTITY] the persisted form round-trips the exact seed
    // (lowercase hex, trailing-whitespace tolerant on read).
    #[test]
    fn seed_hex_round_trips() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("node.key");
        let id = load_or_create_at(&path).expect("create");
        let on_disk = std::fs::read_to_string(&path).unwrap();
        assert_eq!(on_disk.len(), SEED_LEN * 2, "bare 64-char hex");
        std::fs::write(&path, format!("{on_disk}\n")).unwrap(); // editor-added newline
        let again = load_or_create_at(&path).expect("load trims");
        assert_eq!(id.public_key(), again.public_key());
    }
}


Grep

pub fn public_key|pub fn public_key_hex|hex


2://! and stable public-key ↔ string (hex) conversion.
10://! Backed by `ed25519-dalek` v2. A node's stable name is the lowercase hex of
25:/// Errors from parsing keys/signatures or decoding hex.
28:    /// Hex string had an odd length or a non-hex digit.
39:            IdentityError::BadHex => write!(f, "invalid hex"),
81:    pub fn public_key(&self) -> PublicKey {
96:            .field("public_key", &self.public_key().to_hex())
119:    /// Lowercase 64-char hex — the canonical stable string form (a node's name).
121:    pub fn to_hex(&self) -> String {
122:        encode_hex(&self.to_bytes())
125:    /// Parse a 64-char hex string back into a public key. Errors on bad hex,
128:    pub fn from_hex(s: &str) -> Result<PublicKey, IdentityError> {
129:        let bytes = decode_hex(s)?;
152:        f.write_str(&self.to_hex())
157:// Minimal hex codec (clean-room; avoids a hex crate dep for 32/64-byte keys)

[Showing results with pagination = limit: 15]


Add non-minting read to nodeid.rs:


Edit

/// Load the node identity from the canonical `node.key`, generating + persisting
/// a fresh one iff the file is absent. The stable-name contract: every call on
/// the same home yields the same identity.
pub fn load_or_create() -> Result<Identity, NodeIdError> {
    load_or_create_at(&node_key_file())
}

/// Load the node identity from the canonical `node.key`, generating + persisting
/// a fresh one iff the file is absent. The stable-name contract: every call on
/// the same home yields the same identity.
pub fn load_or_create() -> Result<Identity, NodeIdError> {
    load_or_create_at(&node_key_file())
}

/// **Non-minting** read: the node identity iff `node.key` already exists and
/// parses; absent (or corrupt) → `None`. For read-side consumers — the context
/// marker stamp, the access gate's same-node check — that must never mint an
/// identity as a side effect (creation belongs to the daemon boot path). A
/// corrupt key degrades to `None` here (the write proceeds pre-identity); the
/// loud corrupt-key error stays with [`load_or_create`].
pub fn load_existing() -> Option<Identity> {
    load_existing_at(&node_key_file())
}

/// [`load_existing`] against an explicit path (the testable core).
pub fn load_existing_at(path: &Path) -> Option<Identity> {
    std::fs::read_to_string(path)
        .ok()
        .and_then(|s| parse_seed_hex(s.trim()))
        .map(|seed| Identity::from_seed(&seed))
}


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

/// Load the node identity from the canonical `node.key`, generating + persisting
/// a fresh one iff the file is absent. The stable-name contract: every call on
/// the same home yields the same identity.
pub fn load_or_create() -> Result<Identity, NodeIdError> {
    load_or_create_at(&node_key_file())
}

/// **Non-minting** read: the node identity iff `node.key` already exists and
/// parses; absent (or corrupt) → `None`. For read-side consumers — the context
/// marker stamp, the access gate's same-node check — that must never mint an
/// identity as a side effect (creation belongs to the daemon boot path). A
/// corrupt key degrades to `None` here (the write proceeds pre-identity); the
/// loud corrupt-key error stays with [`load_or_create`].
pub fn load_existing() -> Option<Identity> {
    load_existing_at(&node_key_file())
}

/// [`load_existing`] against an explicit path (the testable core).
pub fn load_existing_at(path: &Path) -> Option<Identity> {
    std::fs::read_to_string(path)
        .ok()
        .and_then(|s| parse_seed_hex(s.trim()))
        .map(|seed| Identity::from_seed(&seed))
}

//! `node.key` — the node's durable Ed25519 identity (`docs/STORAGE.md`).
//!
//! The keypair seed *is* the node's stable name: the iroh `EndpointId` is this
//! key's public half (D1), trust rows and registry rows reference it, and the
//! broker's network endpoint binds as it (D4). So it must be created once and
//! loaded forever — [`load_or_create`] generates + persists a seed only when no
//! file exists.
//!
//! A present-but-corrupt file is an **error, never a silent regeneration**:
//! regenerating would silently change the node's name, orphaning every trust
//! store entry and registry row that references the old key. The operator must
//! decide (restore the file, or deliberately delete it to mint a new identity).
//!
//! Stored as the 32-byte seed in lowercase hex — per-node secret material under
//! [`identity_dir`](crate::perch::identity_dir), never git-tracked, never synced.
// [impl->REQ-NODE-IDENTITY]

use std::io;
use std::path::Path;

use spt_proto::identity::{Identity, SEED_LEN};

use crate::atomic::atomic_write_string;
use crate::perch::node_key_file;

/// Failures loading/creating the node identity.
#[derive(Debug)]
pub enum NodeIdError {
    /// Reading or writing `node.key` failed (message preserved).
    Io(String),
    /// `node.key` exists but does not parse as a 64-char hex seed. Deliberately
    /// NOT auto-regenerated — see the module docs.
    Corrupt,
}

impl std::fmt::Display for NodeIdError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NodeIdError::Io(e) => write!(f, "node.key io: {e}"),
            NodeIdError::Corrupt => write!(
                f,
                "node.key exists but is corrupt; restore it (or delete it to \
                 deliberately mint a NEW node identity — old trust/registry rows \
                 will not recognize this node)"
            ),
        }
    }
}

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

/// Load the node identity from the canonical `node.key`, generating + persisting
/// a fresh one iff the file is absent. The stable-name contract: every call on
/// the same home yields the same identity.
pub fn load_or_create() -> Result<Identity, NodeIdError> {
    load_or_create_at(&node_key_file())
}

/// [`load_or_create`] against an explicit path (the testable core).
pub fn load_or_create_at(path: &Path) -> Result<Identity, NodeIdError> {
    match std::fs::read_to_string(path) {
        Ok(s) => parse_seed_hex(s.trim())
            .map(|seed| Identity::from_seed(&seed))
            .ok_or(NodeIdError::Corrupt),
        Err(e) if e.kind() == io::ErrorKind::NotFound => {
            let id = Identity::generate();
            if let Some(parent) = path.parent() {
                if !parent.as_os_str().is_empty() {
                    std::fs::create_dir_all(parent)
                        .map_err(|e| NodeIdError::Io(e.to_string()))?;
                }
            }
            // Atomic write so a crash mid-create can't leave a torn (= corrupt,
            // = node-bricking) seed file.
            atomic_write_string(path, &seed_to_hex(&id.seed()))
                .map_err(|e| NodeIdError::Io(e.to_string()))?;
            Ok(id)
        }
        Err(e) => Err(NodeIdError::Io(e.to_string())),
    }
}

fn seed_to_hex(seed: &[u8; SEED_LEN]) -> String {
    let mut s = String::with_capacity(SEED_LEN * 2);
    for b in seed {
        s.push_str(&format!("{b:02x}"));
    }
    s
}

fn parse_seed_hex(s: &str) -> Option<[u8; SEED_LEN]> {
    if s.len() != SEED_LEN * 2 || !s.is_ascii() {
        return None;
    }
    let mut seed = [0u8; SEED_LEN];
    for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
        let hex = std::str::from_utf8(chunk).ok()?;
        seed[i] = u8::from_str_radix(hex, 16).ok()?;
    }
    Some(seed)
}

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

    // [unit->REQ-NODE-IDENTITY] first call creates + persists; every later call
    // loads the SAME identity — the stable-name contract a daemon restart relies on.
    #[test]
    fn create_then_load_is_stable() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("node.key");
        let first = load_or_create_at(&path).expect("create");
        assert!(path.exists(), "node.key persisted");
        let second = load_or_create_at(&path).expect("load");
        assert_eq!(
            first.public_key(),
            second.public_key(),
            "same identity across loads"
        );
    }

    // [unit->REQ-NODE-IDENTITY] a corrupt node.key errors — it is NEVER silently
    // regenerated (regeneration would change the node's name and orphan trust rows).
    #[test]
    fn corrupt_key_errors_never_regenerates() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("node.key");
        std::fs::write(&path, "not a seed").unwrap();
        assert!(matches!(load_or_create_at(&path), Err(NodeIdError::Corrupt)));
        // The corrupt file is left untouched for the operator.
        assert_eq!(std::fs::read_to_string(&path).unwrap(), "not a seed");
    }

    // [unit->REQ-NODE-IDENTITY] the persisted form round-trips the exact seed
    // (lowercase hex, trailing-whitespace tolerant on read).
    #[test]
    fn seed_hex_round_trips() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("node.key");
        let id = load_or_create_at(&path).expect("create");
        let on_disk = std::fs::read_to_string(&path).unwrap();
        assert_eq!(on_disk.len(), SEED_LEN * 2, "bare 64-char hex");
        std::fs::write(&path, format!("{on_disk}\n")).unwrap(); // editor-added newline
        let again = load_or_create_at(&path).expect("load trims");
        assert_eq!(id.public_key(), again.public_key());
    }
}

+/// **Non-minting** read: the node identity iff `node.key` already exists and

+/// parses; absent (or corrupt) → `None`. For read-side consumers — the context

+/// marker stamp, the access gate's same-node check — that must never mint an

+/// identity as a side effect (creation belongs to the daemon boot path). A

+/// corrupt key degrades to `None` here (the write proceeds pre-identity); the

+/// loud corrupt-key error stays with [`load_or_create`].

+pub fn load_existing() -> Option<Identity> {

+/// [`load_existing`] against an explicit path (the testable core).

+pub fn load_existing_at(path: &Path) -> Option<Identity> {

+        .and_then(|s| parse_seed_hex(s.trim()))

+        .map(|seed| Identity::from_seed(&seed))


Bash

cd C:/Users/decid/Documents/projects/spt-core && grep -n "pub fn epoch_file" -A6 crates/spt-store/src/perch.rs && grep -n "contextmark\|pub mod" crates/spt-store/src/lib.rs

Check epoch_file + lib modules


106:pub fn epoch_file() -> PathBuf {
107-    identity_dir().join("epoch")
108-}
109-
110-/// The per-`(endpoint, subnet)` visibility + sync-membership config file:
111-/// `<spt_home>/identity/visibility.json` (ADR-0006 §5/§6, D3e). The owning node's
112-/// advertisement policy — node-local, never git-tracked/synced (other nodes see only
14:pub mod access;
15:pub mod atomic;
16:pub mod branchstore;
17:pub mod contextstore;
19:pub mod epoch;
20:pub mod gitrun;
21:pub mod history;
22:pub mod info;
23:pub mod liveness;
24:pub mod nodeid;
25:pub mod perch;
26:pub mod proc;
27:pub mod project;
28:pub mod registry;
29:pub mod rename;
30:pub mod seed;
31:pub mod spool;
32:pub mod subnet;
33:pub mod trust;
34:pub mod visibility;
35:pub mod xfer;


Edit

pub mod branchstore;
pub mod contextstore;

pub mod branchstore;
pub mod contextmark;
pub mod contextstore;


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

pub mod branchstore;
pub mod contextmark;
pub mod contextstore;

//! `spt-store` — the persistence commodity layer for spt-core.
//!
//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
//! types to disk (spool, registry, info.json) through a single atomic-write
//! primitive. Stable on-disk formats are copied verbatim from the sister
//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
//!
// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
// depends only on spt-proto (one layer down), never the reverse — the
// dependency edge is one-way, so the workspace graph stays acyclic.
// [impl->REQ-ARCH-1]

pub mod access;
pub mod atomic;
pub mod branchstore;
pub mod contextstore;
mod db;
pub mod epoch;
pub mod gitrun;
pub mod history;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod perch;
pub mod proc;
pub mod project;
pub mod registry;
pub mod rename;
pub mod seed;
pub mod spool;
pub mod subnet;
pub mod trust;
pub mod visibility;
pub mod xfer;

pub mod access;
pub mod atomic;
pub mod branchstore;
+pub mod contextmark;
pub mod contextstore;
mod db;
pub mod epoch;