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

//! drop-file ingest (M2b T4, KNOWN-HAZARDS 6.4).
//!
//! The harness-invoked **mind** (a Psyche / echo session) and the **operator**
//! drop context into the manifest's watched `commune_dir` / `signoff_dir` as
//! `<id>-commune.md` / `<id>-signoff.md`. spt-core is the **single writer**: it
//! ingests each drop into the perch's context store and then **deletes** it. The
//! mind is read-only on drop files; it never deletes (6.4 — the removed-delete
//! fix). The write is precedence-guarded (6.5, [`crate::context`]) because a
//! dropped commune is an LLM snapshot.
//
// Clean-room over the M2a seams; the watch/poll cadence is the pulse (T5) — this
// module is the per-pass ingest mechanism it drives.
// [impl->REQ-HAZARD-DROP-FILE-SINGLE-WRITER]
// [impl->REQ-API-3]

use crate::context::{write_context, WriteOutcome, WriteSource};
use spt_store::perch::{resolve_perch_path, ParentHint};
use std::path::{Path, PathBuf};

/// Fixed drop-file suffixes (spt-core owns the filenames; the adapter owns only
/// the directory — MANIFEST §watched dirs).
pub const COMMUNE_SUFFIX: &str = "-commune.md";
pub const SIGNOFF_SUFFIX: &str = "-signoff.md";
/// The per-perch context store file ingested drops fold into.
pub const CONTEXT_FILE: &str = "context.md";

/// Which kind of drop was ingested.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DropKind {
    Commune,
    Signoff,
}

/// One ingested drop: its kind, the guarded-write outcome, and the (now deleted)
/// source path.
#[derive(Debug, Clone)]
pub struct Ingested {
    pub kind: DropKind,
    pub outcome: WriteOutcome,
    pub drop_path: PathBuf,
}

/// The perch's context store path.
pub fn context_path(id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(id, hint).join(CONTEXT_FILE)
}

/// Ingest any `<id>-commune.md` / `<id>-signoff.md` present in `drop_dir` for
/// `id`: fold each into the perch's context store (precedence-guarded, llm
/// source) and **delete** it (spt-core single-writer; the mind never deletes).
///
/// A drop is deleted whether its write `Written` or `Suppressed` — both mean
/// "consumed" (a suppressed drop was a stale snapshot, correctly discarded). A
/// read/write *error* leaves the drop in place to retry on the next pass.
pub fn ingest_drops(
    drop_dir: &Path,
    id: &str,
    hint: ParentHint<'_>,
    now_ms: u64,
    window_ms: u64,
) -> std::io::Result<Vec<Ingested>> {
    let ctx = context_path(id, hint);
    if let Some(parent) = ctx.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let mut out = Vec::new();
    for (kind, suffix) in [
        (DropKind::Commune, COMMUNE_SUFFIX),
        (DropKind::Signoff, SIGNOFF_SUFFIX),
    ] {
        let drop_path = drop_dir.join(format!("{id}{suffix}"));
        if !drop_path.exists() {
            continue;
        }
        let body = std::fs::read_to_string(&drop_path)?;
        // A dropped commune/signoff is an LLM-authored snapshot.
        let outcome = write_context(&ctx, &body, WriteSource::Llm, now_ms, window_ms)?;
        // [impl->REQ-HAZARD-DROP-FILE-SINGLE-WRITER] spt-core deletes the drop
        // after consuming it; the mind never does.
        std::fs::remove_file(&drop_path)?;
        out.push(Ingested {
            kind,
            outcome,
            drop_path,
        });
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
    use crate::test_support::with_home;

    #[test] // [unit->REQ-HAZARD-DROP-FILE-SINGLE-WRITER] a dropped commune is ingested + deleted by spt-core.
    fn commune_drop_is_ingested_and_deleted() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let drop = drops.path().join("doyle-commune.md");
            std::fs::write(&drop, "Source: echo-commune\nthe brief").unwrap();

            let ingested = ingest_drops(drops.path(), "doyle", ParentHint::Infer, 1000, 60_000)
                .expect("ingest");
            assert_eq!(ingested.len(), 1);
            assert_eq!(ingested[0].kind, DropKind::Commune);
            assert!(matches!(ingested[0].outcome, WriteOutcome::Written { .. }));

            // spt-core deleted the drop (single-writer ingest→delete).
            assert!(!drop.exists(), "drop must be deleted after ingest");
            // Content folded into the perch's context store.
            let ctx = context_path("doyle", ParentHint::Infer);
            assert!(std::fs::read_to_string(&ctx).unwrap().contains("the brief"));
        });
    }

    #[test] // [unit->REQ-API-3] commune + signoff drops both ingest in one pass.
    fn both_drops_ingested() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            std::fs::write(drops.path().join("doyle-commune.md"), "c").unwrap();
            std::fs::write(drops.path().join("doyle-signoff.md"), "s").unwrap();
            let ingested = ingest_drops(drops.path(), "doyle", ParentHint::Infer, 1000, 60_000)
                .expect("ingest");
            let kinds: Vec<DropKind> = ingested.iter().map(|i| i.kind).collect();
            assert!(kinds.contains(&DropKind::Commune) && kinds.contains(&DropKind::Signoff));
            assert!(!drops.path().join("doyle-commune.md").exists());
            assert!(!drops.path().join("doyle-signoff.md").exists());
        });
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] a stale commune within a direct window is suppressed but still consumed.
    fn stale_commune_suppressed_but_deleted() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            // Seed a fresh direct write into the context store first.
            let ctx = context_path("doyle", ParentHint::Infer);
            std::fs::create_dir_all(ctx.parent().unwrap()).unwrap();
            write_context(&ctx, "DIRECT", WriteSource::Direct, 1000, DEFAULT_PROTECTION_WINDOW_MS)
                .unwrap();

            // A commune drop landing within the window must be suppressed.
            let drop = drops.path().join("doyle-commune.md");
            std::fs::write(&drop, "STALE COMMUNE").unwrap();
            let ingested =
                ingest_drops(drops.path(), "doyle", ParentHint::Infer, 1500, DEFAULT_PROTECTION_WINDOW_MS)
                    .expect("ingest");
            assert!(matches!(ingested[0].outcome, WriteOutcome::Suppressed { .. }));
            // Still consumed (deleted) and the direct write preserved.
            assert!(!drop.exists());
            let after = std::fs::read_to_string(&ctx).unwrap();
            assert!(after.contains("DIRECT") && !after.contains("STALE COMMUNE"));
        });
    }

    #[test] // no drops present ⇒ a clean empty pass, no error.
    fn no_drops_is_empty() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let ingested =
                ingest_drops(drops.path(), "doyle", ParentHint::Infer, 1000, 60_000).expect("ingest");
            assert!(ingested.is_empty());
        });
    }
}


Edit

pub mod echo;
pub mod history;
pub mod psyche;

#[cfg(test)]
mod test_support;

pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};

pub mod context;
pub mod echo;
pub mod history;
pub mod ingest;
pub mod psyche;

#[cfg(test)]
mod test_support;

pub use context::{write_context, WriteOutcome, WriteSource};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use ingest::{ingest_drops, DropKind, Ingested};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};


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

pub mod context;
pub mod echo;
pub mod history;
pub mod ingest;
pub mod psyche;

#[cfg(test)]
mod test_support;

pub use context::{write_context, WriteOutcome, WriteSource};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use ingest::{ingest_drops, DropKind, Ingested};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};

//! spt-core live-agent lifecycle executor (M2b).
//!
//! `spt-live` *executes* the lifecycle seams that `spt-runtime` (M2a) only
//! declared and parsed. It is the fifth layer in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> spt-runtime -> spt-live`); the `spt`
//! binary's `api listen` runner hosts it.
//!
//! Unlike `spt-runtime`, `spt-live` is **not** part of the public SDK surface
//! (R-ARCH-2 is proto/runtime/msg) — it is the daemon-bound executor M3
//! consolidates into `spt-daemon`. Keep every seam thin so that move is a
//! re-host, not a rewrite (mirror M2a's seed-file discipline).
//!
//! Subsystems land across M2b (see `M2b-PLAN.md`):
//! - **history** (T1, [`REQ-SEAM-HISTORY`]) — the three `[history]` strategies
//!   feeding the echo-commune + resume briefs.
//! - **spawn-psyche** (T2, `REQ-SEAM-PSYCHE`) — the nested `<id>-psyche` perch.
//! - **echo-commune** (T3) — the cheap-model brief consuming history.
//! - **drop-file ingest** (T4) — single-writer commune/signoff ingest (6.4/6.5).
//! - **pulse** (T5) — the interim heartbeat in the `api listen` runner.
//! - **signoff/boundary** (T6) — the orphan/grace ordering invariants (1.1/3.2/3.3).
//! - **resume** (T7, `REQ-SEAM-RESUME`) — fresh-with-preload + continue-existing.
//
// The interim no-daemon model holds (decision 2026-06-01): the Psyche is a real
// subprocess and the `api listen` process hosts the pulse/ingest loops. M3 moves
// both into the daemon. See `M2b-PLAN.md` §Interim-model note.

pub mod echo;
pub mod history;
pub mod psyche;

#[cfg(test)]
mod test_support;

pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};

+pub use context::{write_context, WriteOutcome, WriteSource};

+pub use ingest::{ingest_drops, DropKind, Ingested};


Bash

Test spt-live with context + ingest


test history::tests::fetcher_timeout_errors ... ok

test result: ok. 23 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s

   Doc-tests spt_live

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


23 green. Check current REQ-API-3 stages before activating T4 hazards.


grep -n -A2 'id = "REQ-API-3"' traceable-reqs.toml; echo "---"; grep -n -A2 'id = "REQ-HAZARD-DROP-FILE-SINGLE-WRITER"\|id = "REQ-HAZARD-DIRECT-WRITE-PRECEDENCE"' traceable-reqs.toml

Check REQ-API-3 + hazard stages


109:id = "REQ-API-3"
110-title = "commune/signoff are file-drops, not commands"
111-required_stages = ["impl", "unit"]
---
432:id = "REQ-HAZARD-DROP-FILE-SINGLE-WRITER"
433-title = "Drop files are daemon-owned single-writer (6.4)"
434-required_stages = []
--
436:id = "REQ-HAZARD-DIRECT-WRITE-PRECEDENCE"
437-title = "Direct-write precedence marker (with node id) guards stale overwrite (6.5)"
438-required_stages = []


Activate the two T4 hazards (impl+unit). REQ-API-3 stays (already impl+unit; int lands at T9).


Edit

id = "REQ-HAZARD-DROP-FILE-SINGLE-WRITER"
title = "Drop files are daemon-owned single-writer (6.4)"
required_stages = []

id = "REQ-HAZARD-DROP-FILE-SINGLE-WRITER"
title = "Drop files are daemon-owned single-writer (6.4)"
required_stages = ["impl", "unit"]


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-HAZARD-DROP-FILE-SINGLE-WRITER"
title = "Drop files are daemon-owned single-writer (6.4)"
required_stages = []

id = "REQ-HAZARD-DROP-FILE-SINGLE-WRITER"
title = "Drop files are daemon-owned single-writer (6.4)"
required_stages = ["impl", "unit"]

title = "Direct-write precedence marker (with node id) guards stale overwrite (6.5)"


id = "REQ-HAZARD-DIRECT-WRITE-PRECEDENCE"
title = "Direct-write precedence marker (with node id) guards stale overwrite (6.5)"
required_stages = []

id = "REQ-HAZARD-DIRECT-WRITE-PRECEDENCE"
title = "Direct-write precedence marker (with node id) guards stale overwrite (6.5)"
required_stages = ["impl", "unit"]


id = "REQ-HAZARD-DIRECT-WRITE-PRECEDENCE"
title = "Direct-write precedence marker (with node id) guards stale overwrite (6.5)"
required_stages = []

id = "REQ-HAZARD-DIRECT-WRITE-PRECEDENCE"
title = "Direct-write precedence marker (with node id) guards stale overwrite (6.5)"
required_stages = ["impl", "unit"]

# --- Added 2026-05-31 from Stage A red-team + Spike #1 (inactive until M-activation) ---


T0 — Scaffold spt-live crate

Add crates/spt-live (deps spt-proto/store/msg/runtime + serde); add to workspace members; acyclic layering …→spt-runtime→spt-live; check green.

T1 — History subsystem

fetch_history over three [history] strategies (fetcher/locate_normalize/native, reuse T7 history-log); normalized records; timeout (5.3) never hangs. REQ-SEAM-HISTORY impl+unit.

T2 — spawn-psyche seam

Spawn Psyche from [session.psyche_init] as nested <id>-psyche perch via ManifestRuntime; compose spt-core-owned $psyche_prompt; set recursion_guard_env. REQ-SEAM-PSYCHE; REQ-HAZARD-SUBPROCESS-TIMEOUT.

T3 — echo-commune seam

Spawn [session.echo_commune] consuming T1 history; recursion-guard; write commune drop-file tagged project-primary/live-conservative (Source:). REQ-SEAM-HISTORY; REQ-HAZARD-DROP-FILE-SINGLE-WRITER.

T4 — drop-file ingest

Watch manifest commune/signoff dirs; ingest <id>-commune.md/<id>-signoff.md; spt-core single-writer (ingest→delete); direct-write precedence marker. REQ-API-3 (int); HAZARD 6.4 + 6.5.

T5 — pulse heartbeat

Interim loop in api listen runner (period = global config knob, per-endpoint override) driving drop-file polling + pending-echo fires + idle checks. Default no new req (mechanism under REQ-SEAM-PSYCHE).

T6 — signoff + boundary lifecycle

Graceful signoff runs echo BEFORE INIT_SIGNOFF (3.3); grace recheck binds still_gone before any write (1.1); stale-signoff sweep on start (3.2); boundary authors Self-resume commune. Ordering-assertion tests.

T7 — resume-session seam

fresh-with-preload (cleared session + $psyche-context) and continue-existing (resume $session_id); wire into spawn + boundary re-entry; wrong/missing key errors before spawn. REQ-SEAM-RESUME.

T8 — api listen lifecycle wiring

Extend M2a relay runner to host LiveAgent lifecycle: spawn Psyche (T2), run pulse (T5), ingest drops (T4), fire echoes (T3), graceful signoff (T6). Keep thin. REQ-SEAM-PSYCHE, REQ-API-2.

T9 — LiveAgent E2E

Extend adapters/mock/manifest.toml with psyche_init/resume/echo_commune/signoff + [history] native + commune/signoff dirs; tests/ integration proving full lifecycle harness-agnostic. REQ-SEAM-PSYCHE/HISTORY/API-3 int.

T10 — Activation sweep

Activate M2b reqs + hazards; REQ-EP-1/2 live-evidence; check green + CI; amend ROADMAP (M2b delivered) + CONTEXT notes; author M3 plan stub.


Bash

cargo clippy -p spt-live --all-targets -- -D warnings 2>&1 | tail -3; echo "=== TRACE ==="; traceable-reqs check > /dev/null 2>&1; echo "check_exit=$?"

Clippy + traceable check for T4


    Checking spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.34s
=== TRACE ===
check_exit=0


git add -A && git commit -q -F - <<'EOF'
feat(spt-live): M2b T4 — drop-file ingest (single-writer + precedence guard)

ingest_drops reads any <id>-commune.md / <id>-signoff.md from the watched dir,
folds each into the perch's context store, and deletes it: spt-core is the
single writer (6.4 — the mind is read-only, never deletes). A read/write error
leaves the drop to retry next pass; a consumed drop (written OR suppressed) is
deleted.

context.rs lands the local 6.5 precedence guard: every context write carries a
`<!-- spt:source=direct|llm spt:routed_at_ms=… -->` marker. A direct write
always proceeds; an llm write within the protection window of a recent direct
write is suppressed + logged, so a stale LLM snapshot never clobbers a fresher
direct write. decide() is the pure, exhaustively-tested core; node-identity +
the distributed newest-and-newer-than-mine generalization stay M4 (ADR-0003).

A dropped commune/signoff is an llm-sourced write, so ingest runs through the
guard. 10 unit tests: marker round-trip, all four decide() branches, guarded
file write, ingest+delete, both-drops pass, stale-but-consumed, empty pass.
REQ-HAZARD-DROP-FILE-SINGLE-WRITER + REQ-HAZARD-DIRECT-WRITE-PRECEDENCE
activated [impl,unit]; REQ-API-3 impl/unit evidence extended (int at T9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git log --oneline -1

Commit T4


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 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-live/src/context.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
91380ab feat(spt-live): M2b T4 — drop-file ingest (single-writer + precedence guard)


Updated task #5 status


5 shipped (T0–T4), halfway. T5 — pulse. Check the M2a echo-gate (`.more-done`) sentinel + any global config knob.


grep -rn "more-done\|more_done\|echo.gate\|echo_gate\|gate\|sentinel\|capsule\|idle" crates/spt/src/api/delivery.rs | head -30; echo "=== CONFIG ==="; grep -rn "config\|Config\|knob\|pulse\|period\|interval" crates/spt-store/src/*.rs crates/spt/src/*.rs docs/CONTEXT.md 2>/dev/null | grep -iv "configuration of\|//!" | head -20

Find echo-gate sentinel + config knob


1://! Delivery + activity: `api state`, `api echo-gate`, `api poll`, and
4://! Activity state lives in the perch as **spt-core-owned sentinels** (CONTEXT
5://! §activity/idle detection) — the adapter only *calls* the commands; it never
6://! names or writes the sentinels (MANIFEST §What is NOT in the manifest):
7://! - `.idle`      — the activity/idle sentinel (`api state busy|idle`).
8://! - `.more-done` — the echo-commune gate (armed by `api state idle` unless
9://!   `--no-gate`; togglable directly via `api echo-gate set|clear`). This is the
11://!   the gate so a later relay/poll surfaces it instead (CONTEXT §hook output).
29:/// The activity/idle sentinel filename within a perch dir.
30:const IDLE_SENTINEL: &str = ".idle";
31:/// The echo-commune gate sentinel (`.more-done`-equiv; modern spt couples it to
32:/// the idle transition).
33:const ECHO_GATE_SENTINEL: &str = ".more-done";
35:fn sentinel_path(id: &str, name: &str) -> std::path::PathBuf {
39:/// Set/clear the `.idle` sentinel for an activity transition. Idle additionally
40:/// arms the echo-gate unless `no_gate` (CONTEXT §api state).
42:pub fn cmd_state(id: &str, state: &str, no_gate: bool) -> i32 {
43:    let idle = sentinel_path(id, IDLE_SENTINEL);
45:        "idle" => {
46:            if let Err(e) = std::fs::write(&idle, "") {
50:            if !no_gate {
51:                let _ = std::fs::write(sentinel_path(id, ECHO_GATE_SENTINEL), "");
53:            eprintln!("IDLE:{id}{}", if no_gate { " (no-gate)" } else { " (gate-armed)" });
57:            // Removing a non-existent sentinel is fine (idempotent transition).
58:            let _ = std::fs::remove_file(&idle);
63:            eprintln!("USAGE: state must be busy|idle, got {other}");
69:/// `api echo-gate set|clear` — explicit control of the echo-commune gate,
70:/// independent of idle (CONTEXT §api state).
72:pub fn cmd_echo_gate(id: &str, action: &str) -> i32 {
73:    let gate = sentinel_path(id, ECHO_GATE_SENTINEL);
=== CONFIG ===
crates/spt-store/src/perch.rs:32:/// override (`$SPT_HOME`) is the relocation knob either way.


69	/// `api echo-gate set|clear` — explicit control of the echo-commune gate,
70	/// independent of idle (CONTEXT §api state).
71	// [impl->REQ-SEAM-ACTIVITY]
72	pub fn cmd_echo_gate(id: &str, action: &str) -> i32 {
73	    let gate = sentinel_path(id, ECHO_GATE_SENTINEL);
74	    match action {
75	        "set" => {
76	            if let Err(e) = std::fs::write(&gate, "") {
77	                eprintln!("ECHO_GATE_FAIL:{id}: {e}");
78	                return EXIT_REFUSED;
79	            }
80	            eprintln!("ECHO_GATE_SET:{id}");
81	            0
82	        }
83	        "clear" => {
84	            let _ = std::fs::remove_file(&gate);
85	            eprintln!("ECHO_GATE_CLEAR:{id}");
86	            0
87	        }
88	        other => {
89	            eprintln!("USAGE: echo-gate must be set|clear, got {other}");
90	            EXIT_REFUSED
91	        }
92	    }
93	}
94
95	/// Whether the idle sentinel is set (consumed by `cmd_poll`'s inject selection).
96	pub fn is_idle(id: &str) -> bool {
97	    sentinel_path(id, IDLE_SENTINEL).exists()
98	}
99	/// Whether the echo-gate is armed. A reader for the M2b echo-commune trigger;
100	/// exercised by the activity tests today.
101	#[allow(dead_code)] // consumed by the M2b echo-commune path
102	pub fn echo_gate_armed(id: &str) -> bool {
103	    sentinel_path(id, ECHO_GATE_SENTINEL).exists()
104	}
105
106	/// Resolve the inject methods for an activity state from the manifest's
107	/// `[inject]` table, **dropping `pty`** (PTY injection is M3 — `spt-term`). With
108	/// no manifest or no configured methods, default to `hook` (the universally-
109	/// available channel). REQ-SEAM-INJECT.
110	// [impl->REQ-SEAM-INJECT]
111	pub fn resolve_inject_methods(manifest: Option<&Manifest>, idle: bool) -> Vec<InjectMethod> {
112	    let configured = manifest.and_then(|m| m.inject.as_ref()).map(|inj| {
113	        if idle { &inj.idle } else { &inj.activity }
114	    });
115	    let mut methods: Vec<InjectMethod> = configured
116	        .map(|v| {
117	            v.iter()
118	                .copied()
119	                .filter(|m| *m != InjectMethod::Pty) // PTY = M3
120	                .collect()
121	        })
122	        .unwrap_or_default();
123	    if methods.is_empty() {
124	        methods.push(InjectMethod::Hook);
125	    }
126	    methods
127	}
128
129	/// `api poll <id>` — drain the spool (hook-channel delivery) and print each
130	/// message framed to stdout. Non-deferred only unless `include_deferred`.
131	// [impl->REQ-API-2]
132	pub fn cmd_poll(id: &str, include_deferred: bool, manifest: Option<&Manifest>) -> i32 {
133	    // Inject-method selection is resolved (hook/relay) even though the M2a poll
134	    // channel is the hook drain; PTY delivery lands with the broker in M3.
135	    let _methods = resolve_inject_methods(manifest, is_idle(id));
136	    for msg in poll_drain(id, include_deferred) {
137	        emit(&msg);
138	    }

/// `api echo-gate set|clear` — explicit control of the echo-commune gate,
/// independent of idle (CONTEXT §api state).
// [impl->REQ-SEAM-ACTIVITY]
pub fn cmd_echo_gate(id: &str, action: &str) -> i32 {
    let gate = sentinel_path(id, ECHO_GATE_SENTINEL);
    match action {
        "set" => {
            if let Err(e) = std::fs::write(&gate, "") {
                eprintln!("ECHO_GATE_FAIL:{id}: {e}");
                return EXIT_REFUSED;
            }
            eprintln!("ECHO_GATE_SET:{id}");
            0
        }
        "clear" => {
            let _ = std::fs::remove_file(&gate);
            eprintln!("ECHO_GATE_CLEAR:{id}");
            0
        }
        other => {
            eprintln!("USAGE: echo-gate must be set|clear, got {other}");
            EXIT_REFUSED
        }
    }
}

/// Whether the idle sentinel is set (consumed by `cmd_poll`'s inject selection).
pub fn is_idle(id: &str) -> bool {
    sentinel_path(id, IDLE_SENTINEL).exists()
}
/// Whether the echo-gate is armed. A reader for the M2b echo-commune trigger;
/// exercised by the activity tests today.
#[allow(dead_code)] // consumed by the M2b echo-commune path
pub fn echo_gate_armed(id: &str) -> bool {
    sentinel_path(id, ECHO_GATE_SENTINEL).exists()
}

/// Resolve the inject methods for an activity state from the manifest's
/// `[inject]` table, **dropping `pty`** (PTY injection is M3 — `spt-term`). With
/// no manifest or no configured methods, default to `hook` (the universally-
/// available channel). REQ-SEAM-INJECT.
// [impl->REQ-SEAM-INJECT]
pub fn resolve_inject_methods(manifest: Option<&Manifest>, idle: bool) -> Vec<InjectMethod> {
    let configured = manifest.and_then(|m| m.inject.as_ref()).map(|inj| {
        if idle { &inj.idle } else { &inj.activity }
    });
    let mut methods: Vec<InjectMethod> = configured
        .map(|v| {
            v.iter()
                .copied()
                .filter(|m| *m != InjectMethod::Pty) // PTY = M3
                .collect()
        })
        .unwrap_or_default();
    if methods.is_empty() {
        methods.push(InjectMethod::Hook);
    }
    methods
}

/// `api poll <id>` — drain the spool (hook-channel delivery) and print each
/// message framed to stdout. Non-deferred only unless `include_deferred`.
// [impl->REQ-API-2]
pub fn cmd_poll(id: &str, include_deferred: bool, manifest: Option<&Manifest>) -> i32 {
    // Inject-method selection is resolved (hook/relay) even though the M2a poll
    // channel is the hook drain; PTY delivery lands with the broker in M3.
    let _methods = resolve_inject_methods(manifest, is_idle(id));
    for msg in poll_drain(id, include_deferred) {
        emit(&msg);
    }


Echo-gate sentinel is a perch file. To honor single-path-source-of-truth (6.1), move the sentinel names into spt-store and have both spt + spt-live consume them. Read delivery.rs top.


20	// [impl->REQ-API-2]
21
22	use spt_runtime::manifest::InjectMethod;
23	use spt_runtime::Manifest;
24	use spt_store::perch::{self, ParentHint};
25	use spt_store::spool;
26
27	use super::EXIT_REFUSED;
28
29	/// The activity/idle sentinel filename within a perch dir.
30	const IDLE_SENTINEL: &str = ".idle";
31	/// The echo-commune gate sentinel (`.more-done`-equiv; modern spt couples it to
32	/// the idle transition).
33	const ECHO_GATE_SENTINEL: &str = ".more-done";
34
35	fn sentinel_path(id: &str, name: &str) -> std::path::PathBuf {
36	    perch::resolve_perch_path(id, ParentHint::Infer).join(name)
37	}
38
39	/// Set/clear the `.idle` sentinel for an activity transition. Idle additionally
40	/// arms the echo-gate unless `no_gate` (CONTEXT §api state).
41	// [impl->REQ-SEAM-ACTIVITY]
42	pub fn cmd_state(id: &str, state: &str, no_gate: bool) -> i32 {
43	    let idle = sentinel_path(id, IDLE_SENTINEL);
44	    match state {
45	        "idle" => {
46	            if let Err(e) = std::fs::write(&idle, "") {
47	                eprintln!("STATE_FAIL:{id}: {e}");
48	                return EXIT_REFUSED;
49	            }
50	            if !no_gate {
51	                let _ = std::fs::write(sentinel_path(id, ECHO_GATE_SENTINEL), "");
52	            }
53	            eprintln!("IDLE:{id}{}", if no_gate { " (no-gate)" } else { " (gate-armed)" });
54	            0
55	        }
56	        "busy" => {
57	            // Removing a non-existent sentinel is fine (idempotent transition).
58	            let _ = std::fs::remove_file(&idle);
59	            eprintln!("BUSY:{id}");

// [impl->REQ-API-2]

use spt_runtime::manifest::InjectMethod;
use spt_runtime::Manifest;
use spt_store::perch::{self, ParentHint};
use spt_store::spool;

use super::EXIT_REFUSED;

/// The activity/idle sentinel filename within a perch dir.
const IDLE_SENTINEL: &str = ".idle";
/// The echo-commune gate sentinel (`.more-done`-equiv; modern spt couples it to
/// the idle transition).
const ECHO_GATE_SENTINEL: &str = ".more-done";

fn sentinel_path(id: &str, name: &str) -> std::path::PathBuf {
    perch::resolve_perch_path(id, ParentHint::Infer).join(name)
}

/// Set/clear the `.idle` sentinel for an activity transition. Idle additionally
/// arms the echo-gate unless `no_gate` (CONTEXT §api state).
// [impl->REQ-SEAM-ACTIVITY]
pub fn cmd_state(id: &str, state: &str, no_gate: bool) -> i32 {
    let idle = sentinel_path(id, IDLE_SENTINEL);
    match state {
        "idle" => {
            if let Err(e) = std::fs::write(&idle, "") {
                eprintln!("STATE_FAIL:{id}: {e}");
                return EXIT_REFUSED;
            }
            if !no_gate {
                let _ = std::fs::write(sentinel_path(id, ECHO_GATE_SENTINEL), "");
            }
            eprintln!("IDLE:{id}{}", if no_gate { " (no-gate)" } else { " (gate-armed)" });
            0
        }
        "busy" => {
            // Removing a non-existent sentinel is fine (idempotent transition).
            let _ = std::fs::remove_file(&idle);
            eprintln!("BUSY:{id}");


Add sentinel consts + resolvers to spt-store (single source), refactor delivery.rs to consume them.


Edit

/// `<resolved_perch>/inbox`.
pub fn resolve_inbox_dir(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join("inbox")
}

/// `<resolved_perch>/inbox`.
pub fn resolve_inbox_dir(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join("inbox")
}

/// The activity/idle sentinel filename within a perch dir (spt-core-owned; the
/// adapter never names it — MANIFEST §What is NOT in the manifest).
pub const IDLE_SENTINEL: &str = ".idle";
/// The echo-commune gate sentinel filename (`.more-done`-equiv; the idle
/// transition arms it). spt-core-owned.
pub const ECHO_GATE_SENTINEL: &str = ".more-done";

/// `<resolved_perch>/.idle` — the activity/idle sentinel.
pub fn resolve_idle_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join(IDLE_SENTINEL)
}

/// `<resolved_perch>/.more-done` — the echo-commune gate sentinel. The single
/// path source (6.1) shared by the `api` surface (which arms it) and the M2b
/// pulse (which reads + clears it).
pub fn resolve_echo_gate_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join(ECHO_GATE_SENTINEL)
}


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

/// `<resolved_perch>/inbox`.
pub fn resolve_inbox_dir(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join("inbox")
}

/// The activity/idle sentinel filename within a perch dir (spt-core-owned; the
/// adapter never names it — MANIFEST §What is NOT in the manifest).
pub const IDLE_SENTINEL: &str = ".idle";
/// The echo-commune gate sentinel filename (`.more-done`-equiv; the idle
/// transition arms it). spt-core-owned.
pub const ECHO_GATE_SENTINEL: &str = ".more-done";

/// `<resolved_perch>/.idle` — the activity/idle sentinel.
pub fn resolve_idle_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join(IDLE_SENTINEL)
}

/// `<resolved_perch>/.more-done` — the echo-commune gate sentinel. The single
/// path source (6.1) shared by the `api` surface (which arms it) and the M2b
/// pulse (which reads + clears it).
pub fn resolve_echo_gate_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join(ECHO_GATE_SENTINEL)
}

+/// The activity/idle sentinel filename within a perch dir (spt-core-owned; the

+/// adapter never names it — MANIFEST §What is NOT in the manifest).

+pub const IDLE_SENTINEL: &str = ".idle";

+/// The echo-commune gate sentinel filename (`.more-done`-equiv; the idle

+/// transition arms it). spt-core-owned.

+pub const ECHO_GATE_SENTINEL: &str = ".more-done";

+/// `<resolved_perch>/.idle` — the activity/idle sentinel.

+pub fn resolve_idle_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {

+    resolve_perch_path(child_id, hint).join(IDLE_SENTINEL)

+/// `<resolved_perch>/.more-done` — the echo-commune gate sentinel. The single

+/// path source (6.1) shared by the `api` surface (which arms it) and the M2b

+pub fn resolve_echo_gate_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {

+    resolve_perch_path(child_id, hint).join(ECHO_GATE_SENTINEL)

/// `<perch_path>/info.json` from an already-resolved path.

pub fn info_file_at(perch_path: &Path) -> PathBuf {


use spt_store::perch::{self, ParentHint};
use spt_store::spool;

use super::EXIT_REFUSED;

/// The activity/idle sentinel filename within a perch dir.
const IDLE_SENTINEL: &str = ".idle";
/// The echo-commune gate sentinel (`.more-done`-equiv; modern spt couples it to
/// the idle transition).
const ECHO_GATE_SENTINEL: &str = ".more-done";

fn sentinel_path(id: &str, name: &str) -> std::path::PathBuf {
    perch::resolve_perch_path(id, ParentHint::Infer).join(name)
}

use spt_store::perch::{self, ParentHint, ECHO_GATE_SENTINEL, IDLE_SENTINEL};
use spt_store::spool;

use super::EXIT_REFUSED;

fn sentinel_path(id: &str, name: &str) -> std::path::PathBuf {
    perch::resolve_perch_path(id, ParentHint::Infer).join(name)
}


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

use spt_store::perch::{self, ParentHint, ECHO_GATE_SENTINEL, IDLE_SENTINEL};
use spt_store::spool;

use super::EXIT_REFUSED;

fn sentinel_path(id: &str, name: &str) -> std::path::PathBuf {
    perch::resolve_perch_path(id, ParentHint::Infer).join(name)
}

//! Delivery + activity: `api state`, `api echo-gate`, `api poll`, and
//! inject-method selection.
//!
//! Activity state lives in the perch as **spt-core-owned sentinels** (CONTEXT
//! §activity/idle detection) — the adapter only *calls* the commands; it never
//! names or writes the sentinels (MANIFEST §What is NOT in the manifest):
//! - `.idle`      — the activity/idle sentinel (`api state busy|idle`).
//! - `.more-done` — the echo-commune gate (armed by `api state idle` unless
//!   `--no-gate`; togglable directly via `api echo-gate set|clear`). This is the
//!   `can_inject = false` fallback path: a hook that can't inject context arms
//!   the gate so a later relay/poll surfaces it instead (CONTEXT §hook output).
//!
//! `api poll` is the **short-lived hook-channel drain** (distinct from
//! `api listen`'s long-running relay): it drains the spool and prints each
//! message framed to stdout. The inject *method* (hook/relay; PTY is M3) is
//! resolved from the manifest's `[inject]` table.
//
// [impl->REQ-SEAM-ACTIVITY]
// [impl->REQ-SEAM-INJECT]
// [impl->REQ-API-2]

use spt_runtime::manifest::InjectMethod;
use spt_runtime::Manifest;
use spt_store::perch::{self, ParentHint};
use spt_store::spool;

use super::EXIT_REFUSED;

/// The activity/idle sentinel filename within a perch dir.
const IDLE_SENTINEL: &str = ".idle";
/// The echo-commune gate sentinel (`.more-done`-equiv; modern spt couples it to
/// the idle transition).
const ECHO_GATE_SENTINEL: &str = ".more-done";

fn sentinel_path(id: &str, name: &str) -> std::path::PathBuf {
    perch::resolve_perch_path(id, ParentHint::Infer).join(name)
}

/// Set/clear the `.idle` sentinel for an activity transition. Idle additionally
/// arms the echo-gate unless `no_gate` (CONTEXT §api state).
// [impl->REQ-SEAM-ACTIVITY]
pub fn cmd_state(id: &str, state: &str, no_gate: bool) -> i32 {
    let idle = sentinel_path(id, IDLE_SENTINEL);
    match state {
        "idle" => {
            if let Err(e) = std::fs::write(&idle, "") {
                eprintln!("STATE_FAIL:{id}: {e}");
                return EXIT_REFUSED;
            }
            if !no_gate {
                let _ = std::fs::write(sentinel_path(id, ECHO_GATE_SENTINEL), "");
            }
            eprintln!("IDLE:{id}{}", if no_gate { " (no-gate)" } else { " (gate-armed)" });
            0
        }
        "busy" => {
            // Removing a non-existent sentinel is fine (idempotent transition).
            let _ = std::fs::remove_file(&idle);
            eprintln!("BUSY:{id}");
            0
        }
        other => {
            eprintln!("USAGE: state must be busy|idle, got {other}");
            EXIT_REFUSED
        }
    }
}

/// `api echo-gate set|clear` — explicit control of the echo-commune gate,
/// independent of idle (CONTEXT §api state).
// [impl->REQ-SEAM-ACTIVITY]
pub fn cmd_echo_gate(id: &str, action: &str) -> i32 {
    let gate = sentinel_path(id, ECHO_GATE_SENTINEL);
    match action {
        "set" => {
            if let Err(e) = std::fs::write(&gate, "") {
                eprintln!("ECHO_GATE_FAIL:{id}: {e}");
                return EXIT_REFUSED;
            }
            eprintln!("ECHO_GATE_SET:{id}");
            0
        }
        "clear" => {
            let _ = std::fs::remove_file(&gate);
            eprintln!("ECHO_GATE_CLEAR:{id}");
            0
        }
        other => {
            eprintln!("USAGE: echo-gate must be set|clear, got {other}");
            EXIT_REFUSED
        }
    }
}

/// Whether the idle sentinel is set (consumed by `cmd_poll`'s inject selection).
pub fn is_idle(id: &str) -> bool {
    sentinel_path(id, IDLE_SENTINEL).exists()
}
/// Whether the echo-gate is armed. A reader for the M2b echo-commune trigger;
/// exercised by the activity tests today.
#[allow(dead_code)] // consumed by the M2b echo-commune path
pub fn echo_gate_armed(id: &str) -> bool {
    sentinel_path(id, ECHO_GATE_SENTINEL).exists()
}

/// Resolve the inject methods for an activity state from the manifest's
/// `[inject]` table, **dropping `pty`** (PTY injection is M3 — `spt-term`). With
/// no manifest or no configured methods, default to `hook` (the universally-
/// available channel). REQ-SEAM-INJECT.
// [impl->REQ-SEAM-INJECT]
pub fn resolve_inject_methods(manifest: Option<&Manifest>, idle: bool) -> Vec<InjectMethod> {
    let configured = manifest.and_then(|m| m.inject.as_ref()).map(|inj| {
        if idle { &inj.idle } else { &inj.activity }
    });
    let mut methods: Vec<InjectMethod> = configured
        .map(|v| {
            v.iter()
                .copied()
                .filter(|m| *m != InjectMethod::Pty) // PTY = M3
                .collect()
        })
        .unwrap_or_default();
    if methods.is_empty() {
        methods.push(InjectMethod::Hook);
    }
    methods
}

/// `api poll <id>` — drain the spool (hook-channel delivery) and print each
/// message framed to stdout. Non-deferred only unless `include_deferred`.
// [impl->REQ-API-2]
pub fn cmd_poll(id: &str, include_deferred: bool, manifest: Option<&Manifest>) -> i32 {
    // Inject-method selection is resolved (hook/relay) even though the M2a poll
    // channel is the hook drain; PTY delivery lands with the broker in M3.
    let _methods = resolve_inject_methods(manifest, is_idle(id));
    for msg in poll_drain(id, include_deferred) {
        emit(&msg);
    }
    0
}

/// Drain the spool for `id`, returning the messages (oldest first). Separated
/// from `cmd_poll` so it is unit-testable without capturing stdout.
pub fn poll_drain(id: &str, include_deferred: bool) -> Vec<String> {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let drained = if include_deferred {
        spool::drain_all_at(&perch_path)
    } else {
        spool::drain_non_deferred_at(&perch_path)
    };
    drained.unwrap_or_default()
}

fn emit(msg: &str) {
    use std::io::Write;
    print!("{msg}");
    let _ = std::io::stdout().flush();
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testutil::isolated_home;
    use spt_store::info::{self, InfoJson};

    fn establish(id: &str) {
        let path = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&path).unwrap();
        info::write_info(
            &path,
            &InfoJson::new(id, "0", std::process::id(), "sid", "live_agent"),
        )
        .unwrap();
    }

    #[test] // [unit->REQ-SEAM-ACTIVITY] idle writes .idle + arms the gate; busy clears .idle.
    fn idle_busy_transitions_manage_sentinels() {
        let _h = isolated_home();
        establish("alice");
        assert_eq!(cmd_state("alice", "idle", false), 0);
        assert!(is_idle("alice"));
        assert!(echo_gate_armed("alice"), "idle arms the echo-gate by default");

        assert_eq!(cmd_state("alice", "busy", false), 0);
        assert!(!is_idle("alice"));
        // The gate persists across busy (cleared explicitly / on echo fire).
        assert!(echo_gate_armed("alice"));
    }

    #[test] // [unit->REQ-SEAM-ACTIVITY] --no-gate suppresses the idle↔gate coupling.
    fn idle_no_gate_does_not_arm_gate() {
        let _h = isolated_home();
        establish("alice");
        assert_eq!(cmd_state("alice", "idle", true), 0);
        assert!(is_idle("alice"));
        assert!(!echo_gate_armed("alice"));
    }

    #[test] // [unit->REQ-SEAM-ACTIVITY] echo-gate set/clear is independent of idle.
    fn echo_gate_explicit_toggle() {
        let _h = isolated_home();
        establish("alice");
        assert_eq!(cmd_echo_gate("alice", "set"), 0);
        assert!(echo_gate_armed("alice"));
        assert_eq!(cmd_echo_gate("alice", "clear"), 0);
        assert!(!echo_gate_armed("alice"));
        assert!(!is_idle("alice"), "echo-gate must not touch the idle sentinel");
    }

    #[test] // [unit->REQ-API-2] poll drains spooled messages; deferred only with the flag.
    fn poll_drains_non_deferred_by_default() {
        let _h = isolated_home();
        establish("alice");
        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        spool::spool_message_at(&perch_path, "bob", "live one").unwrap();
        spool::spool_message_deferred_at(&perch_path, "bob", "deferred one").unwrap();

        let got = poll_drain("alice", false);
        assert_eq!(got.len(), 1, "deferred row must not drain without the flag");
        assert!(got[0].contains("live one"));

        // The deferred row survives and drains only with the flag.
        let got2 = poll_drain("alice", true);
        assert_eq!(got2.len(), 1);
        assert!(got2[0].contains("deferred one"));
    }

    #[test] // [unit->REQ-SEAM-INJECT] inject resolution drops PTY (M3) and defaults to hook.
    fn inject_resolution_drops_pty_defaults_hook() {
        // No manifest → default hook.
        assert_eq!(resolve_inject_methods(None, true), vec![InjectMethod::Hook]);

        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[inject]\nidle=[\"pty\",\"hook\"]\nactivity=[\"hook\"]\n",
        )
        .unwrap();
        // idle drops pty, keeps hook.
        assert_eq!(resolve_inject_methods(Some(&m), true), vec![InjectMethod::Hook]);
        assert_eq!(resolve_inject_methods(Some(&m), false), vec![InjectMethod::Hook]);
    }

    #[test] // [unit->REQ-SEAM-INJECT] an all-pty config still yields a usable channel.
    fn inject_all_pty_falls_back_to_hook() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[inject]\nidle=[\"pty\"]\n",
        )
        .unwrap();
        assert_eq!(resolve_inject_methods(Some(&m), true), vec![InjectMethod::Hook]);
    }
}

+use spt_store::perch::{self, ParentHint, ECHO_GATE_SENTINEL, IDLE_SENTINEL};

-/// The activity/idle sentinel filename within a perch dir.

-/// The echo-commune gate sentinel (`.more-done`-equiv; modern spt couples it to

-const ECHO_GATE_SENTINEL: &str = ".more-done";


Now the pulse module.


Updated task #6 status


Write

//! pulse heartbeat (M2b T5) — the interim per-tick driver.
//!
//! In the no-daemon interim model, the long-running `api listen` process
//! (T8) hosts a pulse loop. Each tick does the lightweight, recurring work:
//! - **poll the drop dirs** → fold any commune/signoff drops into context
//!   (single-writer ingest→delete — T4);
//! - **fire the pending echo** → if the echo-gate (`.more-done`, armed by
//!   `api state idle`) is set, signal that an echo is due and **clear the gate**
//!   so it fires *exactly once*;
//! - surface the **idle** flag for the caller's capsule/idle decisions.
//!
//! The pulse is deliberately thin — it decides *when* an echo is due and
//! guarantees once-only; the heavy echo run ([`crate::echo::run_echo_commune`])
//! is the caller's (T8), which holds the runtime + history + commune dir. Keep
//! all of this behind this one module so M3's daemon re-host (Psyche-as-loop,
//! daemon-owned period) is a localized swap, not a rewrite (mirrors M2a's
//! seed-file discipline).
//
// Interim only: the durable pulse loop + its global/per-endpoint period config
// belong to the M3 daemon (CONTEXT R-DAEMON-1). No dedicated REQ — the pulse is
// throwaway glue under REQ-SEAM-PSYCHE (see M2b-PLAN §New requirements).

use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
use crate::ingest::{ingest_drops, Ingested};
use spt_store::perch::{resolve_echo_gate_file, resolve_idle_file, ParentHint};
use std::path::Path;
use std::time::Duration;

/// Interim default pulse period. The durable, configurable knob (global +
/// per-endpoint override) is the M3 daemon's; this is the interim constant.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Pulse tuning. `period` paces the `api listen` loop; `protection_window_ms`
/// is threaded into the drop-file precedence guard (6.5).
#[derive(Debug, Clone, Copy)]
pub struct PulseConfig {
    pub period: Duration,
    pub protection_window_ms: u64,
}

impl Default for PulseConfig {
    fn default() -> Self {
        PulseConfig {
            period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
        }
    }
}

/// What one tick observed/did. `echo_due` means the gate was armed and has now
/// been cleared — the caller should fire exactly one echo-commune.
#[derive(Debug, Clone)]
pub struct TickOutcome {
    pub ingested: Vec<Ingested>,
    pub echo_due: bool,
    pub idle: bool,
}

/// Run one pulse tick for `id`: ingest drops from `drop_dir`, then read+clear
/// the echo-gate (once-only fire) and the idle flag. `now_ms` stamps the
/// precedence markers of any ingested writes.
pub fn tick(
    id: &str,
    hint: ParentHint<'_>,
    drop_dir: &Path,
    now_ms: u64,
    cfg: &PulseConfig,
) -> std::io::Result<TickOutcome> {
    let ingested = ingest_drops(drop_dir, id, hint.clone(), now_ms, cfg.protection_window_ms)?;
    let echo_due = take_echo_gate(id, hint.clone());
    let idle = resolve_idle_file(id, hint).exists();
    Ok(TickOutcome {
        ingested,
        echo_due,
        idle,
    })
}

/// Read-and-clear the echo-gate: returns whether it was armed, removing the
/// sentinel so the echo fires exactly once. Clearing a missing gate is a no-op.
pub fn take_echo_gate(id: &str, hint: ParentHint<'_>) -> bool {
    let gate = resolve_echo_gate_file(id, hint);
    if gate.exists() {
        // Best-effort clear; if removal fails the next tick re-fires (at-least-
        // once degrades safely — a duplicate echo is harmless, a lost one less so).
        let _ = std::fs::remove_file(&gate);
        true
    } else {
        false
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::with_home;
    use spt_store::perch::resolve_perch_path;

    fn arm_gate(id: &str) {
        let perch = resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&perch).unwrap();
        std::fs::write(resolve_echo_gate_file(id, ParentHint::Infer), "").unwrap();
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] an armed echo-gate fires exactly once, then clears.
    fn echo_gate_fires_exactly_once() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            arm_gate("doyle");

            let t1 = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
                .expect("tick");
            assert!(t1.echo_due, "armed gate should fire");

            // Second tick: gate cleared, no re-fire.
            let t2 = tick("doyle", ParentHint::Infer, drops.path(), 2000, &PulseConfig::default())
                .expect("tick");
            assert!(!t2.echo_due, "gate must clear after one fire");
        });
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] a tick polls + ingests the drop dir.
    fn tick_ingests_drops() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();

            let t = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
                .expect("tick");
            assert_eq!(t.ingested.len(), 1);
            assert!(!t.echo_due, "no gate armed");
            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
        });
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] idle sentinel surfaces in the tick outcome.
    fn tick_reports_idle() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let perch = resolve_perch_path("doyle", ParentHint::Infer);
            std::fs::create_dir_all(&perch).unwrap();
            std::fs::write(resolve_idle_file("doyle", ParentHint::Infer), "").unwrap();

            let t = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
                .expect("tick");
            assert!(t.idle);
        });
    }
}


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

//! pulse heartbeat (M2b T5) — the interim per-tick driver.
//!
//! In the no-daemon interim model, the long-running `api listen` process
//! (T8) hosts a pulse loop. Each tick does the lightweight, recurring work:
//! - **poll the drop dirs** → fold any commune/signoff drops into context
//!   (single-writer ingest→delete — T4);
//! - **fire the pending echo** → if the echo-gate (`.more-done`, armed by
//!   `api state idle`) is set, signal that an echo is due and **clear the gate**
//!   so it fires *exactly once*;
//! - surface the **idle** flag for the caller's capsule/idle decisions.
//!
//! The pulse is deliberately thin — it decides *when* an echo is due and
//! guarantees once-only; the heavy echo run ([`crate::echo::run_echo_commune`])
//! is the caller's (T8), which holds the runtime + history + commune dir. Keep
//! all of this behind this one module so M3's daemon re-host (Psyche-as-loop,
//! daemon-owned period) is a localized swap, not a rewrite (mirrors M2a's
//! seed-file discipline).
//
// Interim only: the durable pulse loop + its global/per-endpoint period config
// belong to the M3 daemon (CONTEXT R-DAEMON-1). No dedicated REQ — the pulse is
// throwaway glue under REQ-SEAM-PSYCHE (see M2b-PLAN §New requirements).

use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
use crate::ingest::{ingest_drops, Ingested};
use spt_store::perch::{resolve_echo_gate_file, resolve_idle_file, ParentHint};
use std::path::Path;
use std::time::Duration;

/// Interim default pulse period. The durable, configurable knob (global +
/// per-endpoint override) is the M3 daemon's; this is the interim constant.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Pulse tuning. `period` paces the `api listen` loop; `protection_window_ms`
/// is threaded into the drop-file precedence guard (6.5).
#[derive(Debug, Clone, Copy)]
pub struct PulseConfig {
    pub period: Duration,
    pub protection_window_ms: u64,
}

impl Default for PulseConfig {
    fn default() -> Self {
        PulseConfig {
            period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
        }
    }
}

/// What one tick observed/did. `echo_due` means the gate was armed and has now
/// been cleared — the caller should fire exactly one echo-commune.
#[derive(Debug, Clone)]
pub struct TickOutcome {
    pub ingested: Vec<Ingested>,
    pub echo_due: bool,
    pub idle: bool,
}

/// Run one pulse tick for `id`: ingest drops from `drop_dir`, then read+clear
/// the echo-gate (once-only fire) and the idle flag. `now_ms` stamps the
/// precedence markers of any ingested writes.
pub fn tick(
    id: &str,
    hint: ParentHint<'_>,
    drop_dir: &Path,
    now_ms: u64,
    cfg: &PulseConfig,
) -> std::io::Result<TickOutcome> {
    let ingested = ingest_drops(drop_dir, id, hint.clone(), now_ms, cfg.protection_window_ms)?;
    let echo_due = take_echo_gate(id, hint.clone());
    let idle = resolve_idle_file(id, hint).exists();
    Ok(TickOutcome {
        ingested,
        echo_due,
        idle,
    })
}

/// Read-and-clear the echo-gate: returns whether it was armed, removing the
/// sentinel so the echo fires exactly once. Clearing a missing gate is a no-op.
pub fn take_echo_gate(id: &str, hint: ParentHint<'_>) -> bool {
    let gate = resolve_echo_gate_file(id, hint);
    if gate.exists() {
        // Best-effort clear; if removal fails the next tick re-fires (at-least-
        // once degrades safely — a duplicate echo is harmless, a lost one less so).
        let _ = std::fs::remove_file(&gate);
        true
    } else {
        false
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::with_home;
    use spt_store::perch::resolve_perch_path;

    fn arm_gate(id: &str) {
        let perch = resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&perch).unwrap();
        std::fs::write(resolve_echo_gate_file(id, ParentHint::Infer), "").unwrap();
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] an armed echo-gate fires exactly once, then clears.
    fn echo_gate_fires_exactly_once() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            arm_gate("doyle");

            let t1 = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
                .expect("tick");
            assert!(t1.echo_due, "armed gate should fire");

            // Second tick: gate cleared, no re-fire.
            let t2 = tick("doyle", ParentHint::Infer, drops.path(), 2000, &PulseConfig::default())
                .expect("tick");
            assert!(!t2.echo_due, "gate must clear after one fire");
        });
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] a tick polls + ingests the drop dir.
    fn tick_ingests_drops() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();

            let t = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
                .expect("tick");
            assert_eq!(t.ingested.len(), 1);
            assert!(!t.echo_due, "no gate armed");
            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
        });
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] idle sentinel surfaces in the tick outcome.
    fn tick_reports_idle() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let perch = resolve_perch_path("doyle", ParentHint::Infer);
            std::fs::create_dir_all(&perch).unwrap();
            std::fs::write(resolve_idle_file("doyle", ParentHint::Infer), "").unwrap();

            let t = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
                .expect("tick");
            assert!(t.idle);
        });
    }
}


Edit

pub mod context;
pub mod echo;
pub mod history;
pub mod ingest;
pub mod psyche;

#[cfg(test)]
mod test_support;

pub use context::{write_context, WriteOutcome, WriteSource};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use ingest::{ingest_drops, DropKind, Ingested};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};

pub mod context;
pub mod echo;
pub mod history;
pub mod ingest;
pub mod psyche;
pub mod pulse;

#[cfg(test)]
mod test_support;

pub use context::{write_context, WriteOutcome, WriteSource};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use ingest::{ingest_drops, DropKind, Ingested};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
pub use pulse::{tick, PulseConfig, TickOutcome};


pub mod context;
pub mod echo;
pub mod history;
pub mod ingest;
pub mod psyche;
pub mod pulse;

#[cfg(test)]
mod test_support;

pub use context::{write_context, WriteOutcome, WriteSource};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use ingest::{ingest_drops, DropKind, Ingested};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
pub use pulse::{tick, PulseConfig, TickOutcome};

//! spt-core live-agent lifecycle executor (M2b).
//!
//! `spt-live` *executes* the lifecycle seams that `spt-runtime` (M2a) only
//! declared and parsed. It is the fifth layer in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> spt-runtime -> spt-live`); the `spt`
//! binary's `api listen` runner hosts it.
//!
//! Unlike `spt-runtime`, `spt-live` is **not** part of the public SDK surface
//! (R-ARCH-2 is proto/runtime/msg) — it is the daemon-bound executor M3
//! consolidates into `spt-daemon`. Keep every seam thin so that move is a
//! re-host, not a rewrite (mirror M2a's seed-file discipline).
//!
//! Subsystems land across M2b (see `M2b-PLAN.md`):
//! - **history** (T1, [`REQ-SEAM-HISTORY`]) — the three `[history]` strategies
//!   feeding the echo-commune + resume briefs.
//! - **spawn-psyche** (T2, `REQ-SEAM-PSYCHE`) — the nested `<id>-psyche` perch.
//! - **echo-commune** (T3) — the cheap-model brief consuming history.
//! - **drop-file ingest** (T4) — single-writer commune/signoff ingest (6.4/6.5).
//! - **pulse** (T5) — the interim heartbeat in the `api listen` runner.
//! - **signoff/boundary** (T6) — the orphan/grace ordering invariants (1.1/3.2/3.3).
//! - **resume** (T7, `REQ-SEAM-RESUME`) — fresh-with-preload + continue-existing.
//
// The interim no-daemon model holds (decision 2026-06-01): the Psyche is a real
// subprocess and the `api listen` process hosts the pulse/ingest loops. M3 moves
// both into the daemon. See `M2b-PLAN.md` §Interim-model note.

pub mod context;
pub mod echo;
pub mod history;
pub mod ingest;
pub mod psyche;

#[cfg(test)]
mod test_support;

pub use context::{write_context, WriteOutcome, WriteSource};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use ingest::{ingest_drops, DropKind, Ingested};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};

pub mod history;
pub mod ingest;
pub mod psyche;
+pub mod pulse;
#[cfg(test)]
mod test_support;

+pub use pulse::{tick, PulseConfig, TickOutcome};


Bash

cargo test -p spt-live -p spt 2>&1 | grep -E "test result:|error\[|warning:|FAILED" | grep -v "0 passed; 0 failed" | tail -20

Test spt-live + spt (delivery refactor)


test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.67s
test result: ok. 26 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s


cargo test -p spt-live 2>&1 | grep "test result"; echo "=== CLIPPY ==="; cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -3; echo "=== TRACE ==="; traceable-reqs check > /dev/null 2>&1; echo "check_exit=$?"

spt-live count + workspace clippy + check


test result: ok. 26 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
=== CLIPPY ===
    Checking spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Checking mock-adapter v0.0.0 (C:\Users\decid\Documents\projects\spt-core\adapters\mock)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.94s
=== TRACE ===
check_exit=0


26 spt-live tests green, clippy clean, check 0. Commit T5.


git add -A && git commit -q -F - <<'EOF'
feat(spt-live): M2b T5 — pulse heartbeat (interim per-tick driver)

pulse::tick is the thin recurring driver the api listen loop (T8) runs each
period: ingest drops from the watched dir (single-writer, T4), read+clear the
echo-gate (.more-done) so a pending echo fires exactly once, and surface the
idle flag for capsule/idle decisions. The pulse decides WHEN an echo is due and
guarantees once-only; the heavy run_echo_commune stays the caller's (it holds
runtime + history + commune dir). Interim period is a constant (M3's daemon owns
the durable global/per-endpoint knob); no dedicated REQ — throwaway glue under
REQ-SEAM-PSYCHE per the plan.

Single-path source (6.1): the .idle / .more-done sentinel names + resolvers move
to spt-store::perch (resolve_idle_file / resolve_echo_gate_file), now shared by
the api surface (arms the gate) and the pulse (reads+clears it). delivery.rs
drops its local consts for the spt-store ones. 3 unit tests: gate fires once
then clears, tick ingests drops, idle surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git log --oneline -1

Commit T5


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-store/src/perch.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/api/delivery.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
5d085e8 feat(spt-live): M2b T5 — pulse heartbeat (interim per-tick driver)


6 shipped. T6 — the load-bearing ordering invariants. Read hazards 1.1, 3.2, 3.3 + M2a's boundary.


Read hazards 1.1, 3.2, 3.3


### 1.1 Phantom INIT_SIGNOFF after grace period
- **Failure:** orphan teardown enqueues INIT_SIGNOFF before the grace-period recheck; a transient Self recovery (binary handoff, brief stale poll) makes the recheck pass-as-alive, but the signoff was already spooled and drains on the next iteration → teardown despite a live Self.
- **Invariant:** grace-period wait MUST complete *before* composing/delivering INIT_SIGNOFF; the recheck must bind `still_gone` before any envelope write.
- **spt-core mapping:** in-daemon now (no separate wrapper), but the ordering invariant is identical — orphan/teardown logic must re-evaluate liveness after the grace wait, not before enqueue.
- **Sister cite:** `src/live/wrapper/orphan.rs:201-259` (sleep@209 precedes compose@231-251); tests T-grace-recovery:576, T-still-gone-recheck:618.

### 1.2 Poll-rewrite race & info.json mid-write reads
...
### 3.2 Stale signoff sentinel must not kill a fresh start
- **Failure:** a leftover `.claude/<id>-signoff.md` from a prior session is read by a fresh listener as a live signoff → immediate teardown.
- **Invariant:** on every listener/daemon spawn, sweep stale signoff sentinels; signoff files are write-once per generation.
- **spt-core mapping:** same sweep on daemon (re)start per hosted instance.
- **Sister cite:** CHANGELOG v1.11.20; `src/owl/cleanup.rs:97`.

### 3.3 Orphan teardown fires echo-commune BEFORE INIT_SIGNOFF
- **Failure:** teardown delivers INIT_SIGNOFF without first saving the final context delta → Psyche signoff lacks the context-save summary.
- **Invariant:** on orphan path, synchronously run the echo-commune (final delta) before composing INIT_SIGNOFF; skip only if the session_id is missing.
- **spt-core mapping:** the daemon runs psyche/pulse loops in-process; ordering invariant identical.
- **Sister cite:** `src/live/wrapper/orphan.rs:175-199`; tests A-H:333-565.

---

## 4. Wire / transport

### 4.1 Envelope HTML-entity codec ordering — `&` decoded LAST
- **Failure:** decoding `&`-entity before the others double-decodes nested entities (`&amp;lt;` → wrong result).
- **Invariant:** ENCODE order amp→first … `<br>`→last; DECODE order `<br>`→first … amp→**last** (`<`,`>`,`"`, then `&`). One sole decode site (at the LLM/stdin boundary); the parser never decodes.
- **spt-core mapping:** `spt-proto` owns the envelope grammar (public SDK, semver + wire-version). This codec contract is a copy-verbatim commodity item (ADR-0001) and a public-API conformance test.
- **Sister cite:** `src/owl/poll.rs:1-73`; `src/common/envelope.rs`.

### 4.2 Two-slice envelope parser is panic-free and tolerant
- **Failure:** malformed envelope (unclosed/misordered/nested tags) panics or drops output.
- **Invariant:** tags case-sensitive, all optional; no tags → whole body to live slot; unclosed → None for that tag; out-of-order → both still extracted; nested unknown tags preserved verbatim; zero `unwrap` on parsed text.
- **spt-core mapping:** `spt-proto` parser; property-test the robustness rules.
- **Sister cite:** `src/common/envelope.rs:64-92`; tests 99-207.

### 4.3 Registry stale-entry cleanup precedes lookup
- **Failure:** sender resolves a dead process's stale TCP port → delivery to wrong/dead listener.
- **Invariant:** clean stale entries (dead PID) before/at lookup; spool fallback is the safe path on TCP miss.
- **spt-core mapping:** now spans the **subnet registry** (ADR-0003) — eventually-consistent across nodes. Cross-node staleness is expected; resolution policy (local → most-recent → `id@node`) must degrade to spool/relay fallback on stale hits, and never hard-fail on a stale remote entry.
- **Sister cite:** `src/common/registry.rs:62-78`; `src/owl/send.rs`.

### 4.4 Deferred rows survive poll drain
- **Failure:** poll `drain_all` flushes a deferred (spool-only) message meant for a hook consumer → message lost.
- **Invariant:** deferred rows are never flushed by the event-stream drain; only `drain_non_deferred_*` / `peek_all` touch them.
- **Sister cite:** CHANGELOG v1.11.20; `src/common/spool.rs`. (See also 1.4.)

### 4.5 Inbox legacy compat must not double-deliver
- **Failure:** message surfaced via both spool (durable) and legacy inbox files → duplicate or racing delivery.
- **Invariant:** spool is the sole read path at poll time; inbox is write-for-compat only and never read.
- **spt-core mapping:** clean-room — likely drop the legacy inbox entirely. If kept for any compat, preserve "never read at drain time."
- **Sister cite:** `src/common/inbox.rs`.

---

## 5. Platform-specific

### 5.1 Windows PID recycling false positives
- **Failure:** recycled PID reads alive for the wrong process → orphan misclassification.
- **Invariant:** anchor liveness on the stable parent/harness PID; minimal info.json for supervisor-owned perches; mtime grace window (≥60s) masks transient mismatches.
- **spt-core mapping:** broker-held handles supersede PID polling for spt-hosted sessions; keep the grace window for harness-hosted.
- **Sister cite:** `src/live/wrapper/orphan.rs:141-161`; `src/common/list_filter.rs:168-175`.

### 5.2 Windows EBUSY on atomic rename
- **Failure:** `fs::rename` fails while a handle is (recently) held → registry/marketplace update fails.
- **Invariant:** tmp-write + atomic-rename with retry/backoff; best-effort side-fail; tolerate transient EBUSY.
- **spt-core mapping:** all on-disk state writes (registry, trust store, spool checkpoints) use this pattern. Self-update binary swap on Windows especially.
- **Sister cite:** CHANGELOG "EBUSY"; `src/common/owlery.rs` atomic_write.

### 5.3 Git/subprocess timeout stamping
- **Failure:** a hung subprocess (git on slow net) blocks the supervisor indefinitely.
- **Invariant:** every metadata-producing subprocess has a timeout; timeout yields `None` + rate-limited stderr, never a hang.
- **spt-core mapping:** generalize to all manifest-declared harness invocations (delegated commands, adapter updates) — timeouts mandatory.
- **Sister cite:** `src/common/git.rs`.

### 5.4 Windows UNC prefix in serialized paths
- **Failure:** canonicalized `\\?\C:\...` serializes to `//?/C:/...` and fails `read_to_string`.
- **Invariant:** strip the `\\?\` UNC prefix after backslash→forward-slash conversion; serialized path attrs must be directly consumable.
- **spt-core mapping:** any path crossing the wire (file-drop EVENTs, off-node file transfer per ADR-0003) needs canonical normalization at the `spt-proto` boundary.
- **Sister cite:** `src/common/owlery.rs:377-384`.

### 5.5 ConPTY withholds output until DSR is answered  `[REQ-HAZARD-CONPTY-DSR]`
- **Failure:** a broker reading a ConPTY master sees only the 4-byte startup query `ESC [ 6 n` and then nothing — the child looks hung/silent but is producing output normally. ConPTY blocks all child stdout until the terminal answers the cursor-position query.
- **Invariant:** every ConPTY reader auto-answers DSR (`ESC [ 6 n` → write `ESC [ 1;1 R`, or a real cursor position) on the PTY writer. Secondary: a ConPTY master does not EOF while the writer is held, so read loops drain on a thread and never gate exit on a blocking `read()`.
- **spt-core mapping:** `spt-term` broker PTY reader (ADR-0004). Brand-new to spt-core — not in the sister project (it never hosted ConPTY directly).
- **Source:** Spike #1 (`docs/spikes/SPIKE-01-broker-handoff.md`); reproduced with both a Rust child and `cmd.exe`.

---

## 6. Documented regressions (non-obvious invariants)

### 6.1 No flat/nested perch siblings; resolver-routed paths
- **Failure:** mixed flat + nested perch layouts confuse which perch is live; cascade-wipe risk.
- **Invariant:** one path resolver; never create divergent siblings.
- **spt-core mapping:** clean greenfield layout from day one (no migration window) — pick one structure, route everything through the registry. Storage layout deferred to design phase but this single-source-of-truth rule is binding.
- **Sister cite:** `src/common/perch_path.rs`; CHANGELOG Phase 25.4.

### 6.2 Soft-cleanup preserves state, removes `ready`
- **Failure:** hard-deleting a perch on cleanup loses spool (incl. stored signoff) needed for offline recovery.
- **Invariant:** soft-stop removes only the `ready`/online marker; preserves info + spool + dir. Hard-delete only on explicit operator action.
- **spt-core mapping:** instance offline-state recovery depends on this; carries to the daemon's stop path.
- **Sister cite:** `src/owl/stop.rs`.

### 6.3 Cascade-wipe guard: never delete a parent hosting non-empty children
- **Failure:** `doctor --fix` deletes a top-level perch that still hosts in-flight nested Worker/Psyche perches.
- **Invariant:** before hard-delete, check for non-empty nested children; if present, soft-clean only and surface the path.
- **spt-core mapping:** any destructive maintenance command must check for live child instances first.
- **Sister cite:** CHANGELOG v1.11.20 Phase 35.1.

### 6.4 Drop files are single-writer (supervisor-owned), read-only for the mind
- **Failure:** the Psyche LLM deletes a commune drop file the wrapper is concurrently reading → race, lost commune.
- **Invariant:** drop files (`<id>-commune.md`) are supervisor-owned single-writer; the LLM is read-only; only the supervisor (or explicit operator) deletes them.
- **spt-core mapping:** the daemon is the single writer; the harness-invoked mind never mutates drop files. Bake the ownership into the runtime contract.
- **Sister cite:** CHANGELOG v1.11.7; `src/live/context.rs:615` (removed delete).

### 6.5 Direct-write precedence guard against stale LLM overwrites
- **Failure:** the LLM emits an older snapshot that clobbers a fresher direct write to a context file.
- **Invariant:** every context write carries a source+timestamp precedence marker; LLM writes within a protection window after a recent direct write are suppressed (logged); direct writes always proceed.
- **spt-core mapping:** cross-node Psyche sync (ADR-0003) makes this multi-writer across machines — the precedence marker must include node identity, and the freshness rule (newest-and-newer-than-mine, per the cross-instance context-freshness feature) is the distributed generalization of this guard. Highest-value carryover for the sync design.
- **Sister cite:** CHANGELOG v1.11.6; `src/owl/echo_commune.rs`.

---

## 7. Boundary & delivery integrity (added 2026-05-31 — Stage A red-team)

These were absent from the sister-project harvest; codex surfaced them as load-bearing gaps for spt-core's new daemon/network surface.

### 7.1 Local `api` mutation auth  `[REQ-HAZARD-LOCAL-API-AUTH]`
- **Failure:** any local process calls `spt api bind|state|session-end|history-log|poll` and binds, ends, injects, or spoofs the state of an endpoint it does not own. Local untrusted processes are explicitly in scope (shells, third-party adapters).
- **Invariant:** every `api` *mutation* is authenticated to an endpoint/session — per-endpoint link token or OS-credential binding. An unrelated local process cannot bind, inject, end, or spoof state.
- **spt-core mapping:** the `api` subcommand surface (PRD R-API-*) + broker IPC.
- **Source:** codex Stage A #13 (`docs/reviews/STAGE-A-codex-redteam.md`).

### 7.2 Idempotent delivery across brain restart  `[REQ-HAZARD-RESTART-IDEMPOTENT]`
- **Failure:** broker queues, spool rows, PTY injection, remote streams, and file transfers cross the broker↔brain boundary; a brain restart duplicates or drops a side effect.
- **Invariant:** every side effect crossing the boundary carries a durable ID + replay rule; replay after restart is exactly-once / idempotent. Crash the brain before/after spool write, before/after PTY write, mid-transfer, mid-registry update — no dup, no drop.
- **spt-core mapping:** broker↔brain IPC (ADR-0004), self-update handoff.
- **Source:** codex Stage A #14.

---

## Conformance checklist (condensed)

| # | Invariant | spt-core surface |
|---|---|---|
| 1.1 | Grace wait precedes INIT_SIGNOFF | daemon teardown |
| 1.4/4.4 | Deferred rows excluded from event-stream drain | daemon spool drain |
| 2.1/5.1 | Stable PID/broker-handle over ephemeral PID | liveness detection |
| 2.3 | Handoff argv/IPC version-tolerant (newer brain ↔ older broker) | broker↔brain IPC, self-update |
| 2.4 | gen_start = now() on cold-start + handoff | per-instance generation |
| 3.1 | Ephemeral perch cleanup on all exit paths | `ring` (RAII guard) |
| 3.3 | Echo-commune before INIT_SIGNOFF | daemon psyche loop |
| 4.1 | Envelope decode order, `&` last | spt-proto (public, wire-versioned) |
| 4.2 | Parser panic-free + tolerant | spt-proto |
| 4.3 | Stale registry entries → fallback, never hard-fail | subnet registry resolution |
| 5.2 | tmp-write + atomic-rename + retry (EBUSY) | all state writes, binary swap |
| 5.3 | Timeout every harness subprocess | manifest invocations |
| 5.4 | Strip UNC prefix on serialized paths | spt-proto path normalization |
| 5.5 | ConPTY reader answers DSR (`ESC[6n`) | spt-term broker PTY reader |
| 6.1 | Single path/registry source of truth | storage layout |
| 6.4 | Drop files supervisor-owned single-writer | runtime contract |
| 6.5 | Direct-write precedence marker (+ node id) | cross-node Psyche sync |
| 7.1 | Local `api` mutation authenticated to endpoint | api surface / broker IPC |
| 7.2 | Idempotent delivery across brain restart | broker↔brain IPC |


Read M2a's boundary + session-end to align T6's boundary resume-commune.


grep -n "boundary\|Boundary\|session.end\|session_end\|INIT_SIGNOFF\|signoff\|resume" crates/spt/src/api/reporting.rs crates/spt/src/api/mod.rs | head -30

Find boundary + session-end in M2a


crates/spt/src/api/reporting.rs:1://! Reporting + boundary surface: `api boundary`, `api session-end`,
crates/spt/src/api/reporting.rs:3://! for commune/signoff file-drops and adapter env aliases.
crates/spt/src/api/reporting.rs:5://! - **boundary** (REQ-API-2) — rebind the perch to the rotated `session_id`
crates/spt/src/api/reporting.rs:7://!   boundary). The Self-resume commune authoring is M2b; M2a rebinds only.
crates/spt/src/api/reporting.rs:8://! - **session-end** (REQ-HAZARD-SOFT-CLEANUP / CASCADE-WIPE-GUARD) — soft
crates/spt/src/api/reporting.rs:16://! - **commune/signoff are file-drops, not commands** (REQ-API-3): resolved from
crates/spt/src/api/reporting.rs:34:/// `api boundary <clear|compact> <id> --to-session-id <sid>` — rebind the perch
crates/spt/src/api/reporting.rs:37:pub fn cmd_boundary(id: &str, mode: &str, to_session: &str) -> i32 {
crates/spt/src/api/reporting.rs:39:        eprintln!("USAGE: boundary requires a non-empty --to-session-id");
crates/spt/src/api/reporting.rs:57:/// `api session-end <id> [--erase]` — soft teardown by default; `--erase`
crates/spt/src/api/reporting.rs:61:pub fn cmd_session_end(id: &str, erase: bool) -> i32 {
crates/spt/src/api/reporting.rs:163:/// The commune/signoff **file-drop** paths — proof these are file-drops, not
crates/spt/src/api/reporting.rs:165:/// dirs; the filename is spt-core-fixed (`<id>-commune.md` / `<id>-signoff.md`).
crates/spt/src/api/reporting.rs:174:    let signoff = session
crates/spt/src/api/reporting.rs:175:        .and_then(|s| s.signoff_dir.as_ref())
crates/spt/src/api/reporting.rs:176:        .map(|dir| PathBuf::from(dir).join(format!("{id}-signoff.md")));
crates/spt/src/api/reporting.rs:177:    (commune, signoff)
crates/spt/src/api/reporting.rs:230:    #[test] // [unit->REQ-API-2] boundary rebinds the session id, keeps identity+anchor.
crates/spt/src/api/reporting.rs:231:    fn boundary_rebinds_keeping_anchor() {
crates/spt/src/api/reporting.rs:234:        assert_eq!(cmd_boundary("alice", "compact", "sid-new"), 0);
crates/spt/src/api/reporting.rs:241:    #[test] // [unit->REQ-API-2] boundary on a missing perch is refused.
crates/spt/src/api/reporting.rs:242:    fn boundary_missing_perch_refused() {
crates/spt/src/api/reporting.rs:244:        assert_eq!(cmd_boundary("ghost", "clear", "sid"), EXIT_REFUSED);
crates/spt/src/api/reporting.rs:247:    #[test] // [unit->REQ-HAZARD-SOFT-CLEANUP] soft session-end preserves info+spool.
crates/spt/src/api/reporting.rs:248:    fn session_end_soft_preserves_state() {
crates/spt/src/api/reporting.rs:251:        assert_eq!(cmd_session_end("alice", false), 0);
crates/spt/src/api/reporting.rs:258:    fn session_end_erase_wipes_childless_perch() {
crates/spt/src/api/reporting.rs:261:        assert_eq!(cmd_session_end("alice", true), 0);
crates/spt/src/api/reporting.rs:267:    fn session_end_erase_refused_with_live_children() {
crates/spt/src/api/reporting.rs:271:        assert_eq!(cmd_session_end("alice", true), EXIT_REFUSED);


Clear. Build T6: `graceful_signoff` (ordering 1.1+3.3), `sweep_stale_signoff` (3.2), `write_resume_commune` (boundary).


Updated task #7 status


Write

//! signoff + boundary lifecycle (M2b T6) — the load-bearing ordering invariants.
//!
//! These are the sister project's most expensive bugs; we **copy the ordering**
//! and clean-room the wiring (the concrete echo/compose/deliver effects are the
//! caller's — T8). Three invariants:
//!
//! - **3.3 echo-before-signoff** ([`graceful_signoff`]): synchronously run the
//!   echo-commune (final context delta) *before* composing INIT_SIGNOFF; skip
//!   the echo only when `session_id` is missing.
//! - **1.1 grace-before-signoff** ([`graceful_signoff`]): the grace wait
//!   completes and the recheck binds `still_gone` *before* any signoff write —
//!   a Self that transiently recovered (binary handoff, brief stale poll) is
//!   **not** torn down.
//! - **3.2 stale-signoff sweep** ([`sweep_stale_signoff`]): on every listener
//!   start, sweep a leftover `<id>-signoff.md` so a prior generation's sentinel
//!   never reads as a live signoff and kills a fresh start.
//!
//! Plus the **boundary Self-resume commune** ([`write_resume_commune`]): M2a's
//! `api boundary` rebinds the rotated session; M2b authors the resume commune
//! the re-entry (T7) preloads.
//
// M2b implements the GRACEFUL teardown paths (spt shutdown / spt suspend /
// boundary). Orphan *crash* detection after an unexpected harness death needs
// the always-on supervisor — that orphan-watch is M3, and it reuses this exact
// ordering. Copy the ordering, not the CC-coupled wiring.
// [impl->REQ-HAZARD-ECHO-BEFORE-SIGNOFF]
// [impl->REQ-HAZARD-GRACE-BEFORE-SIGNOFF]
// [impl->REQ-HAZARD-STALE-SIGNOFF-SENTINEL]

use crate::context::{write_context, WriteOutcome, WriteSource};
use crate::echo::stamp_provenance;
use crate::ingest::context_path;
use spt_store::perch::ParentHint;
use std::path::Path;
use std::time::Duration;

/// Provenance tag for a Self-authored boundary commune (strong live signal —
/// `docs/CONTEXT-MEMORY.md` commune-source asymmetry).
pub const SOURCE_SELF_COMMUNE: &str = "self-commune";

/// Outcome of a graceful signoff attempt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SignoffOutcome {
    /// Whether the echo-commune ran (false ⇒ skipped: no session_id).
    pub echo_ran: bool,
    /// Whether the signoff was composed+delivered (false ⇒ the Self recovered
    /// across the grace wait and was spared).
    pub signed_off: bool,
}

/// Run the graceful signoff sequence with the load-bearing ordering baked in.
///
/// The concrete effects are injected so the invariant is testable independent of
/// the echo/compose wiring:
/// - `echo` — run the final-delta echo-commune (3.3);
/// - `recheck_still_gone` — re-evaluate liveness *after* the grace wait (1.1);
/// - `compose_deliver` — compose + deliver INIT_SIGNOFF.
///
/// Order is fixed: `echo` (if `session_id`) → grace wait → `recheck_still_gone`
/// → `compose_deliver` (only if still gone). The recheck binds `still_gone`
/// before any signoff write; the echo precedes the signoff compose.
pub fn graceful_signoff<E>(
    session_id: Option<&str>,
    grace: Duration,
    echo: impl FnOnce() -> Result<(), E>,
    recheck_still_gone: impl FnOnce() -> bool,
    compose_deliver: impl FnOnce() -> Result<(), E>,
) -> Result<SignoffOutcome, E> {
    // 3.3 — echo (final context delta) BEFORE any signoff compose; skip only on
    // a missing session_id.
    let echo_ran = if session_id.is_some() {
        echo()?;
        true
    } else {
        false
    };

    // 1.1 — grace wait, THEN bind still_gone, BEFORE any signoff write.
    if !grace.is_zero() {
        std::thread::sleep(grace);
    }
    let still_gone = recheck_still_gone();

    let signed_off = if still_gone {
        compose_deliver()?;
        true
    } else {
        // The Self recovered across the grace window — never sign off a live Self.
        false
    };

    Ok(SignoffOutcome {
        echo_ran,
        signed_off,
    })
}

/// Sweep a stale `<id>-signoff.md` from `signoff_dir` on listener start (3.2).
/// Returns whether one was removed. Signoff files are write-once per
/// generation; a leftover from a prior session must never read as a live signoff.
pub fn sweep_stale_signoff(signoff_dir: &Path, id: &str) -> std::io::Result<bool> {
    let stale = signoff_dir.join(format!("{id}-signoff.md"));
    match std::fs::remove_file(&stale) {
        Ok(()) => {
            eprintln!("SIGNOFF_SWEEP:{id} (removed stale sentinel)");
            Ok(true)
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
        Err(e) => Err(e),
    }
}

/// Author the **Self-resume commune** at a boundary: stamp `Source: self-commune`
/// provenance and write it (precedence-guarded) into the perch's context store
/// for the re-entry (T7) to preload. The Self authored it, so it is an llm write.
pub fn write_resume_commune(
    id: &str,
    hint: ParentHint<'_>,
    body: &str,
    now_ms: u64,
    window_ms: u64,
) -> std::io::Result<WriteOutcome> {
    let ctx = context_path(id, hint);
    if let Some(parent) = ctx.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let content = stamp_provenance(SOURCE_SELF_COMMUNE, body);
    write_context(&ctx, &content, WriteSource::Llm, now_ms, window_ms)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
    use crate::test_support::with_home;
    use std::cell::RefCell;

    #[test] // [unit->REQ-HAZARD-ECHO-BEFORE-SIGNOFF] echo runs BEFORE the signoff compose.
    fn echo_precedes_signoff_compose() {
        let log: RefCell<Vec<&str>> = RefCell::new(Vec::new());
        let out = graceful_signoff::<()>(
            Some("sid-1"),
            Duration::ZERO,
            || {
                log.borrow_mut().push("echo");
                Ok(())
            },
            || {
                log.borrow_mut().push("recheck");
                true
            },
            || {
                log.borrow_mut().push("compose");
                Ok(())
            },
        )
        .unwrap();
        assert_eq!(out, SignoffOutcome { echo_ran: true, signed_off: true });
        // Ordering: echo and recheck both strictly precede compose.
        assert_eq!(*log.borrow(), ["echo", "recheck", "compose"]);
    }

    #[test] // [unit->REQ-HAZARD-GRACE-BEFORE-SIGNOFF] a recovered Self is NOT signed off.
    fn recovered_self_is_not_signed_off() {
        let composed = RefCell::new(false);
        let out = graceful_signoff::<()>(
            Some("sid-1"),
            Duration::ZERO,
            || Ok(()),
            || false, // recheck: the Self came back alive
            || {
                *composed.borrow_mut() = true;
                Ok(())
            },
        )
        .unwrap();
        assert_eq!(out, SignoffOutcome { echo_ran: true, signed_off: false });
        assert!(!*composed.borrow(), "must not compose signoff for a live Self");
    }

    #[test] // [unit->REQ-HAZARD-ECHO-BEFORE-SIGNOFF] a missing session_id skips the echo.
    fn missing_session_skips_echo() {
        let echoed = RefCell::new(false);
        let out = graceful_signoff::<()>(
            None,
            Duration::ZERO,
            || {
                *echoed.borrow_mut() = true;
                Ok(())
            },
            || true,
            || Ok(()),
        )
        .unwrap();
        assert!(!out.echo_ran && !*echoed.borrow(), "no echo without a session_id");
        assert!(out.signed_off, "signoff still proceeds when still gone");
    }

    #[test] // [unit->REQ-HAZARD-STALE-SIGNOFF-SENTINEL] a stale signoff sentinel is swept on start.
    fn stale_signoff_swept() {
        let dir = tempfile::tempdir().unwrap();
        let stale = dir.path().join("doyle-signoff.md");
        std::fs::write(&stale, "old signoff").unwrap();

        assert!(sweep_stale_signoff(dir.path(), "doyle").unwrap(), "should sweep one");
        assert!(!stale.exists(), "stale sentinel must be gone");
        // A fresh start with none present sweeps nothing, no error.
        assert!(!sweep_stale_signoff(dir.path(), "doyle").unwrap());
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] boundary writes a provenance-tagged resume commune.
    fn boundary_writes_resume_commune() {
        with_home(|_| {
            let o = write_resume_commune(
                "doyle",
                ParentHint::Infer,
                "where I left off",
                1000,
                DEFAULT_PROTECTION_WINDOW_MS,
            )
            .unwrap();
            assert!(matches!(o, WriteOutcome::Written { .. }));
            let ctx = context_path("doyle", ParentHint::Infer);
            let written = std::fs::read_to_string(&ctx).unwrap();
            assert!(written.contains("Source: self-commune"), "provenance: {written:?}");
            assert!(written.contains("where I left off"));
        });
    }
}


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

//! signoff + boundary lifecycle (M2b T6) — the load-bearing ordering invariants.
//!
//! These are the sister project's most expensive bugs; we **copy the ordering**
//! and clean-room the wiring (the concrete echo/compose/deliver effects are the
//! caller's — T8). Three invariants:
//!
//! - **3.3 echo-before-signoff** ([`graceful_signoff`]): synchronously run the
//!   echo-commune (final context delta) *before* composing INIT_SIGNOFF; skip
//!   the echo only when `session_id` is missing.
//! - **1.1 grace-before-signoff** ([`graceful_signoff`]): the grace wait
//!   completes and the recheck binds `still_gone` *before* any signoff write —
//!   a Self that transiently recovered (binary handoff, brief stale poll) is
//!   **not** torn down.
//! - **3.2 stale-signoff sweep** ([`sweep_stale_signoff`]): on every listener
//!   start, sweep a leftover `<id>-signoff.md` so a prior generation's sentinel
//!   never reads as a live signoff and kills a fresh start.
//!
//! Plus the **boundary Self-resume commune** ([`write_resume_commune`]): M2a's
//! `api boundary` rebinds the rotated session; M2b authors the resume commune
//! the re-entry (T7) preloads.
//
// M2b implements the GRACEFUL teardown paths (spt shutdown / spt suspend /
// boundary). Orphan *crash* detection after an unexpected harness death needs
// the always-on supervisor — that orphan-watch is M3, and it reuses this exact
// ordering. Copy the ordering, not the CC-coupled wiring.
// [impl->REQ-HAZARD-ECHO-BEFORE-SIGNOFF]
// [impl->REQ-HAZARD-GRACE-BEFORE-SIGNOFF]
// [impl->REQ-HAZARD-STALE-SIGNOFF-SENTINEL]

use crate::context::{write_context, WriteOutcome, WriteSource};
use crate::echo::stamp_provenance;
use crate::ingest::context_path;
use spt_store::perch::ParentHint;
use std::path::Path;
use std::time::Duration;

/// Provenance tag for a Self-authored boundary commune (strong live signal —
/// `docs/CONTEXT-MEMORY.md` commune-source asymmetry).
pub const SOURCE_SELF_COMMUNE: &str = "self-commune";

/// Outcome of a graceful signoff attempt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SignoffOutcome {
    /// Whether the echo-commune ran (false ⇒ skipped: no session_id).
    pub echo_ran: bool,
    /// Whether the signoff was composed+delivered (false ⇒ the Self recovered
    /// across the grace wait and was spared).
    pub signed_off: bool,
}

/// Run the graceful signoff sequence with the load-bearing ordering baked in.
///
/// The concrete effects are injected so the invariant is testable independent of
/// the echo/compose wiring:
/// - `echo` — run the final-delta echo-commune (3.3);
/// - `recheck_still_gone` — re-evaluate liveness *after* the grace wait (1.1);
/// - `compose_deliver` — compose + deliver INIT_SIGNOFF.
///
/// Order is fixed: `echo` (if `session_id`) → grace wait → `recheck_still_gone`
/// → `compose_deliver` (only if still gone). The recheck binds `still_gone`
/// before any signoff write; the echo precedes the signoff compose.
pub fn graceful_signoff<E>(
    session_id: Option<&str>,
    grace: Duration,
    echo: impl FnOnce() -> Result<(), E>,
    recheck_still_gone: impl FnOnce() -> bool,
    compose_deliver: impl FnOnce() -> Result<(), E>,
) -> Result<SignoffOutcome, E> {
    // 3.3 — echo (final context delta) BEFORE any signoff compose; skip only on
    // a missing session_id.
    let echo_ran = if session_id.is_some() {
        echo()?;
        true
    } else {
        false
    };

    // 1.1 — grace wait, THEN bind still_gone, BEFORE any signoff write.
    if !grace.is_zero() {
        std::thread::sleep(grace);
    }
    let still_gone = recheck_still_gone();

    let signed_off = if still_gone {
        compose_deliver()?;
        true
    } else {
        // The Self recovered across the grace window — never sign off a live Self.
        false
    };

    Ok(SignoffOutcome {
        echo_ran,
        signed_off,
    })
}

/// Sweep a stale `<id>-signoff.md` from `signoff_dir` on listener start (3.2).
/// Returns whether one was removed. Signoff files are write-once per
/// generation; a leftover from a prior session must never read as a live signoff.
pub fn sweep_stale_signoff(signoff_dir: &Path, id: &str) -> std::io::Result<bool> {
    let stale = signoff_dir.join(format!("{id}-signoff.md"));
    match std::fs::remove_file(&stale) {
        Ok(()) => {
            eprintln!("SIGNOFF_SWEEP:{id} (removed stale sentinel)");
            Ok(true)
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
        Err(e) => Err(e),
    }
}

/// Author the **Self-resume commune** at a boundary: stamp `Source: self-commune`
/// provenance and write it (precedence-guarded) into the perch's context store
/// for the re-entry (T7) to preload. The Self authored it, so it is an llm write.
pub fn write_resume_commune(
    id: &str,
    hint: ParentHint<'_>,
    body: &str,
    now_ms: u64,
    window_ms: u64,
) -> std::io::Result<WriteOutcome> {
    let ctx = context_path(id, hint);
    if let Some(parent) = ctx.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let content = stamp_provenance(SOURCE_SELF_COMMUNE, body);
    write_context(&ctx, &content, WriteSource::Llm, now_ms, window_ms)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
    use crate::test_support::with_home;
    use std::cell::RefCell;

    #[test] // [unit->REQ-HAZARD-ECHO-BEFORE-SIGNOFF] echo runs BEFORE the signoff compose.
    fn echo_precedes_signoff_compose() {
        let log: RefCell<Vec<&str>> = RefCell::new(Vec::new());
        let out = graceful_signoff::<()>(
            Some("sid-1"),
            Duration::ZERO,
            || {
                log.borrow_mut().push("echo");
                Ok(())
            },
            || {
                log.borrow_mut().push("recheck");
                true
            },
            || {
                log.borrow_mut().push("compose");
                Ok(())
            },
        )
        .unwrap();
        assert_eq!(out, SignoffOutcome { echo_ran: true, signed_off: true });
        // Ordering: echo and recheck both strictly precede compose.
        assert_eq!(*log.borrow(), ["echo", "recheck", "compose"]);
    }

    #[test] // [unit->REQ-HAZARD-GRACE-BEFORE-SIGNOFF] a recovered Self is NOT signed off.
    fn recovered_self_is_not_signed_off() {
        let composed = RefCell::new(false);
        let out = graceful_signoff::<()>(
            Some("sid-1"),
            Duration::ZERO,
            || Ok(()),
            || false, // recheck: the Self came back alive
            || {
                *composed.borrow_mut() = true;
                Ok(())
            },
        )
        .unwrap();
        assert_eq!(out, SignoffOutcome { echo_ran: true, signed_off: false });
        assert!(!*composed.borrow(), "must not compose signoff for a live Self");
    }

    #[test] // [unit->REQ-HAZARD-ECHO-BEFORE-SIGNOFF] a missing session_id skips the echo.
    fn missing_session_skips_echo() {
        let echoed = RefCell::new(false);
        let out = graceful_signoff::<()>(
            None,
            Duration::ZERO,
            || {
                *echoed.borrow_mut() = true;
                Ok(())
            },
            || true,
            || Ok(()),
        )
        .unwrap();
        assert!(!out.echo_ran && !*echoed.borrow(), "no echo without a session_id");
        assert!(out.signed_off, "signoff still proceeds when still gone");
    }

    #[test] // [unit->REQ-HAZARD-STALE-SIGNOFF-SENTINEL] a stale signoff sentinel is swept on start.
    fn stale_signoff_swept() {
        let dir = tempfile::tempdir().unwrap();
        let stale = dir.path().join("doyle-signoff.md");
        std::fs::write(&stale, "old signoff").unwrap();

        assert!(sweep_stale_signoff(dir.path(), "doyle").unwrap(), "should sweep one");
        assert!(!stale.exists(), "stale sentinel must be gone");
        // A fresh start with none present sweeps nothing, no error.
        assert!(!sweep_stale_signoff(dir.path(), "doyle").unwrap());
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] boundary writes a provenance-tagged resume commune.
    fn boundary_writes_resume_commune() {
        with_home(|_| {
            let o = write_resume_commune(
                "doyle",
                ParentHint::Infer,
                "where I left off",
                1000,
                DEFAULT_PROTECTION_WINDOW_MS,
            )
            .unwrap();
            assert!(matches!(o, WriteOutcome::Written { .. }));
            let ctx = context_path("doyle", ParentHint::Infer);
            let written = std::fs::read_to_string(&ctx).unwrap();
            assert!(written.contains("Source: self-commune"), "provenance: {written:?}");
            assert!(written.contains("where I left off"));
        });
    }
}


Edit

pub mod psyche;
pub mod pulse;

#[cfg(test)]
mod test_support;

pub use context::{write_context, WriteOutcome, WriteSource};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use ingest::{ingest_drops, DropKind, Ingested};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
pub use pulse::{tick, PulseConfig, TickOutcome};

pub mod psyche;
pub mod pulse;
pub mod signoff;

#[cfg(test)]
mod test_support;

pub use context::{write_context, WriteOutcome, WriteSource};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use ingest::{ingest_drops, DropKind, Ingested};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
pub use pulse::{tick, PulseConfig, TickOutcome};
pub use signoff::{
    graceful_signoff, sweep_stale_signoff, write_resume_commune, SignoffOutcome,
};