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

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

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

+    /// The endpoint's **immutable home subnet** (D9-5, REQ-INST-15 —

+    /// ADR-0010): assigned at creation ([`crate::home::assign_home`]) or by

+    /// first-join adoption ([`crate::home::adopt_for_unset`]), then never

+    /// changed — **no setter exists**; `spt fork` is the cross-subnet move.

+    /// Absent ⇒ local-only (unpaired at creation, awaiting first join) or a

+    /// pre-D9-5 record. Creation seams must **carry this forward** on a

+    /// re-bind (a revive rewrites `info.json`; home survives by contract).

+    #[serde(default, skip_serializing_if = "Option::is_none")]

+    /// The adapter chosen at creation (D9-5, REQ-INST-15 — the

+    /// adapter-at-creation rule): recorded when a creation/bind path knows

+    /// it, changed **only** via launch/resume-under-new (the bind paths are

+    /// the sole writers; no standalone setter). Absent ⇒ created by an

+    /// adapterless surface (`spt ready`) or a pre-D9-5 record.


Edit

            rest_state: None,
            dormant_since_ms: None,
            auto_suspend_after_ms: None,
            resources: None,
        }
    }
}

            rest_state: None,
            dormant_since_ms: None,
            auto_suspend_after_ms: None,
            resources: None,
            home_subnet: None,
            adapter: None,
        }
    }
}


            rest_state: None,
            dormant_since_ms: None,
            auto_suspend_after_ms: None,
            resources: None,
        }
    }
}

            rest_state: None,
            dormant_since_ms: None,
            auto_suspend_after_ms: None,
            resources: None,
            home_subnet: None,
            adapter: None,
        }
    }
}


            rest_state: None,
            dormant_since_ms: None,
            auto_suspend_after_ms: None,
            resources: None,
        };
        write_info(d.path(), &info).unwrap();

            rest_state: None,
            dormant_since_ms: None,
            auto_suspend_after_ms: None,
            resources: None,
            home_subnet: None,
            adapter: None,
        };
        write_info(d.path(), &info).unwrap();


            rest_state: None,
            dormant_since_ms: None,
            auto_suspend_after_ms: None,
            resources: None,
        };
        write_info(d.path(), &info).unwrap();

            rest_state: None,
            dormant_since_ms: None,
            auto_suspend_after_ms: None,
            resources: None,
            home_subnet: None,
            adapter: None,
        };
        write_info(d.path(), &info).unwrap();


14:pub mod access;
15:pub mod atomic;
16:pub mod branchstore;
17:pub mod contextmark;
18:pub mod contextstore;
20:pub mod epoch;
21:pub mod gitrun;
22:pub mod history;
23:pub mod info;
24:pub mod liveness;
25:pub mod nodeid;
26:pub mod notif;
27:pub mod perch;
28:pub mod proc;
29:pub mod project;
30:pub mod registry;
31:pub mod rename;
32:pub mod seed;
33:pub mod spool;
34:pub mod subnet;
35:pub mod syncmerge;
36:pub mod trust;
37:pub mod visibility;
38:pub mod xfer;


14	pub mod access;
15	pub mod atomic;
16	pub mod branchstore;
17	pub mod contextmark;
18	pub mod contextstore;
19	mod db;
20	pub mod epoch;
21	pub mod gitrun;
22	pub mod history;
23	pub mod info;
24	pub mod liveness;
25	pub mod nodeid;

pub mod access;
pub mod atomic;
pub mod branchstore;
pub mod contextmark;
pub mod contextstore;
mod db;
pub mod epoch;
pub mod gitrun;
pub mod history;
pub mod info;
pub mod liveness;
pub mod nodeid;


pub mod gitrun;
pub mod history;
pub mod info;

pub mod gitrun;
pub mod history;
pub mod home;
pub mod info;


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

pub mod gitrun;
pub mod history;
pub mod home;
pub mod info;

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

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

pub mod epoch;
pub mod gitrun;
pub mod history;
+pub mod home;
pub mod info;
pub mod liveness;
pub mod nodeid;


Read startup.rs cmd_bind/cmd_listen:


1	//! Harness-hosted startup (interim no-daemon): `api seed` → `api listen` → bind,
2	//! plus the post-spawn `api bind`.
3	//!
4	//! Flow (CONTEXT §Startup flows, harness-hosted):
5	//! 1. The harness's SessionStart hook fires `api seed --pid <parent_pid>
6	//!    --session-id <sid>` → the seed is stored in the **daemon's in-memory map**
7	//!    keyed by the stable parent pid (REQ-START-3; no file — B6 retired the
8	//!    `spt_store::seed` file bridge).
9	//! 2. The agent runs its `$SPT listen <id>` alias → `api listen` self-discovers
10	//!    its parent pid, TAKEs the seed from the daemon, validates it against
11	//!    pid-recycle (KNOWN-HAZARDS 5.1), binds the perch skeleton→live, and relays.
12	//! 3. Alternatively a just-spawned binary fires `api bind <id>` to associate
13	//!    itself post-spawn (REQ-SEAM-POSTSPAWN) — establishing the live perch on
14	//!    first contact, or flipping an existing skeleton.
15	//
16	// [impl->REQ-START-2]
17	// [impl->REQ-START-3]
18	// [impl->REQ-SEAM-POSTSPAWN]
19
20	use std::time::Duration;
21
22	use spt_msg::listener::PollListener;
23	use spt_store::info::{self, InfoJson};
24	use spt_store::perch::{self, ParentHint};
25	use spt_store::proc;
26	use spt_store::seed::Seed;
27	use spt_store::spool;
28
29	use super::auth;
30	use super::{Ctx, EXIT_REFUSED};
31
32	/// Poll window for the relay loop — bounds each blocking accept so the loop
33	/// stays responsive (mirrors M1's listener cadence).
34	const RELAY_POLL: Duration = Duration::from_millis(200);
35
36	/// `api seed` — record the ephemeral startup seed keyed by parent pid. No prior
37	/// perch exists, so this call is not auth-gated (it *establishes* the binding
38	/// material the gated calls later authenticate against).
39	///
40	/// REQ-START-3: the seed is PUT into the daemon's in-memory map over the
41	/// seed-control IPC — **no file is written**. The daemon is auto-started if
42	/// absent (REQ-DAEMON-3).
43	// [impl->REQ-START-3]
44	pub fn cmd_seed(ctx: &Ctx, parent_pid: u32, session_id: String) -> i32 {
45	    if session_id.is_empty() {
46	        eprintln!("USAGE: seed requires a non-empty --session-id");
47	        return EXIT_REFUSED;
48	    }
49	    let seed = Seed {
50	        parent_pid,
51	        session_id,
52	        adapter: ctx.adapter.clone(),
53	        cwd: std::env::current_dir()
54	            .ok()
55	            .map(|p| p.to_string_lossy().replace('\\', "/")),
56	        created: now_stamp(),
57	    };
58	    if let Err(e) = spt_daemon::ensure_running() {
59	        eprintln!("SEED_FAIL:{parent_pid}: daemon unavailable: {e}");
60	        return EXIT_REFUSED;
61	    }
62	    match spt_daemon::put_seed(&spt_daemon::seed_socket_name(), &seed) {
63	        Ok(()) => {
64	            eprintln!("SEEDED:{parent_pid}");
65	            0
66	        }
67	        Err(e) => {
68	            eprintln!("SEED_FAIL:{parent_pid}: {e}");
69	            EXIT_REFUSED
70	        }
71	    }
72	}
73
74	/// Why a seed-driven bind was refused. Each variant is a distinct diagnostic so
75	/// a stale/recycled seed never silently binds the wrong session.
76	#[derive(Debug, PartialEq, Eq)]
77	pub enum BindError {
78	    NoSeed(u32),
79	    /// The seed's anchor process is dead → recycled/abandoned (5.1).
80	    StaleSeed(u32),
81	    EmptySession,
82	    /// A *live* perch already holds this id under a different session (5.1).
83	    Conflict { id: String, held: String },
84	    Io(String),
85	}
86
87	/// Consume the seed for `parent_pid` and bind/revive the perch skeleton→live.
88	/// Pure of any listener I/O so it is unit-testable without spawning processes.
89	/// Returns the perch's capability token on success.
90	///
91	/// REQ-START-3: the seed is TAKEn (consume-once) from the daemon's in-memory map
92	/// — the take itself consumes it, so a stale/conflicting seed is spent and the
93	/// harness re-seeds on the next SessionStart rather than leaving it to re-bind.
94	// [impl->REQ-START-2]
95	// [impl->REQ-START-3]

//! Harness-hosted startup (interim no-daemon): `api seed` → `api listen` → bind,
//! plus the post-spawn `api bind`.
//!
//! Flow (CONTEXT §Startup flows, harness-hosted):
//! 1. The harness's SessionStart hook fires `api seed --pid <parent_pid>
//!    --session-id <sid>` → the seed is stored in the **daemon's in-memory map**
//!    keyed by the stable parent pid (REQ-START-3; no file — B6 retired the
//!    `spt_store::seed` file bridge).
//! 2. The agent runs its `$SPT listen <id>` alias → `api listen` self-discovers
//!    its parent pid, TAKEs the seed from the daemon, validates it against
//!    pid-recycle (KNOWN-HAZARDS 5.1), binds the perch skeleton→live, and relays.
//! 3. Alternatively a just-spawned binary fires `api bind <id>` to associate
//!    itself post-spawn (REQ-SEAM-POSTSPAWN) — establishing the live perch on
//!    first contact, or flipping an existing skeleton.
//
// [impl->REQ-START-2]
// [impl->REQ-START-3]
// [impl->REQ-SEAM-POSTSPAWN]

use std::time::Duration;

use spt_msg::listener::PollListener;
use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::proc;
use spt_store::seed::Seed;
use spt_store::spool;

use super::auth;
use super::{Ctx, EXIT_REFUSED};

/// Poll window for the relay loop — bounds each blocking accept so the loop
/// stays responsive (mirrors M1's listener cadence).
const RELAY_POLL: Duration = Duration::from_millis(200);

/// `api seed` — record the ephemeral startup seed keyed by parent pid. No prior
/// perch exists, so this call is not auth-gated (it *establishes* the binding
/// material the gated calls later authenticate against).
///
/// REQ-START-3: the seed is PUT into the daemon's in-memory map over the
/// seed-control IPC — **no file is written**. The daemon is auto-started if
/// absent (REQ-DAEMON-3).
// [impl->REQ-START-3]
pub fn cmd_seed(ctx: &Ctx, parent_pid: u32, session_id: String) -> i32 {
    if session_id.is_empty() {
        eprintln!("USAGE: seed requires a non-empty --session-id");
        return EXIT_REFUSED;
    }
    let seed = Seed {
        parent_pid,
        session_id,
        adapter: ctx.adapter.clone(),
        cwd: std::env::current_dir()
            .ok()
            .map(|p| p.to_string_lossy().replace('\\', "/")),
        created: now_stamp(),
    };
    if let Err(e) = spt_daemon::ensure_running() {
        eprintln!("SEED_FAIL:{parent_pid}: daemon unavailable: {e}");
        return EXIT_REFUSED;
    }
    match spt_daemon::put_seed(&spt_daemon::seed_socket_name(), &seed) {
        Ok(()) => {
            eprintln!("SEEDED:{parent_pid}");
            0
        }
        Err(e) => {
            eprintln!("SEED_FAIL:{parent_pid}: {e}");
            EXIT_REFUSED
        }
    }
}

/// Why a seed-driven bind was refused. Each variant is a distinct diagnostic so
/// a stale/recycled seed never silently binds the wrong session.
#[derive(Debug, PartialEq, Eq)]
pub enum BindError {
    NoSeed(u32),
    /// The seed's anchor process is dead → recycled/abandoned (5.1).
    StaleSeed(u32),
    EmptySession,
    /// A *live* perch already holds this id under a different session (5.1).
    Conflict { id: String, held: String },
    Io(String),
}

/// Consume the seed for `parent_pid` and bind/revive the perch skeleton→live.
/// Pure of any listener I/O so it is unit-testable without spawning processes.
/// Returns the perch's capability token on success.
///
/// REQ-START-3: the seed is TAKEn (consume-once) from the daemon's in-memory map
/// — the take itself consumes it, so a stale/conflicting seed is spent and the
/// harness re-seeds on the next SessionStart rather than leaving it to re-bind.
// [impl->REQ-START-2]
// [impl->REQ-START-3]


Seed carries adapter already. Read the rest of startup.rs:


Error: File does not exist. Note: your current working directory is C:\Users\decid\Documents\projects\spt-core.


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

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

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

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

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

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


Updated task #6 status


154	/// `api listen <id>` — consume the seed, bind the perch, then run the relay
155	/// loop (heir to `$LIVE start`'s Monitor-bound poll loop). `--once` drains and
156	/// exits (testability); otherwise blocks streaming messages to stdout.
157	///
158	/// When the manifest is **live-capable** (declares `[session.psyche_init]`), the
159	/// loop also hosts the LiveAgent lifecycle over the M2b `spt-live` seams: sweep
160	/// stale signoff on start (3.2), spawn the Psyche (T2), and run a pulse tick
161	/// each cycle (drop ingest + echo fires — T3/T4/T5). The graceful signoff (T6)
162	/// is driven by the shutdown path; M3's daemon re-hosts all of it.
163	// [impl->REQ-START-2]
164	// [impl->REQ-SEAM-PSYCHE]
165	pub fn cmd_listen(
166	    id: &str,
167	    parent_pid_override: Option<u32>,
168	    once: bool,
169	    manifest: Option<&spt_runtime::Manifest>,
170	) -> i32 {
171	    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
172	    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
173	        eprintln!("INVALID_ID:{id}: {e}");
174	        return EXIT_REFUSED;
175	    }
176	    let parent_pid = match parent_pid_override.or_else(proc::parent_pid) {
177	        Some(p) => p,
178	        None => {
179	            eprintln!("NO_PARENT_PID: could not resolve the parent pid anchor");
180	            return EXIT_REFUSED;
181	        }
182	    };
183	    let token = match bind_from_seed(id, parent_pid) {
184	        Ok(t) => t,
185	        Err(e) => {
186	            report_bind_error(&e);
187	            return EXIT_REFUSED;
188	        }
189	    };
190	    eprintln!("BOUND:{id} token={token}");
191
192	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
193	    let listener = match PollListener::bind_blocking(id, RELAY_POLL) {
194	        Ok(l) => l,
195	        Err(e) => {
196	            eprintln!("LISTEN_FAIL:{id}: {e}");
197	            return EXIT_REFUSED;
198	        }
199	    };
200
201	    // The bound session id (from the seed) — fills the lifecycle templates.
202	    let session_id = info::read_info(&perch_path)
203	        .map(|i| i.session_id)
204	        .filter(|s| !s.is_empty());
205
206	    // Live-agent lifecycle host (interim; M3's daemon re-hosts). Sweep a stale
207	    // signoff sentinel (3.2) then spawn the Psyche companion (T2) before READY.
208	    let live = super::live::LiveHost::new_opt(manifest, id);
209	    if let Some(host) = &live {
210	        host.sweep_on_start();
211	        match host.spawn_psyche(session_id.as_deref(), None) {
212	            Ok(p) => eprintln!("PSYCHE_SPAWNED:{} pid={}", p.id, p.pid),
213	            Err(e) => eprintln!("PSYCHE_SPAWN_FAIL:{id}: {e}"),
214	        }
215	    }
216
217	    eprintln!("READY:{id}");
218
219	    // New-session-start is a reported resurface boundary (ADR-0007/D8b):
220	    // undismissed notifs surface into the spool before the backlog drain, so
221	    // they ride this very first drain — and render endpoint-native when the
222	    // manifest declares [session.notif]. Best-effort — never fails the listen.
223	    super::reporting::resurface_notifs_with(id, manifest);
224
225	    // Drain the offline backlog first (non-deferred only; deferred rows wait
226	    // for the hook channel).
227	    for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
228	        emit(&msg);
229	    }
230	    // One pulse tick: ingest drops + fire a pending echo (T4/T3/T5).
231	    let pulse = |host: &Option<super::live::LiveHost>| {
232	        if let Some(h) = host {
233	            if let Err(e) = h.pulse_tick(session_id.as_deref()) {
234	                eprintln!("PULSE_FAIL:{id}: {e}");
235	            }
236	        }
237	    };
238	    if once {
239	        if let Some(msg) = listener.check_message_blocking() {
240	            emit(&msg);
241	        }
242	        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
243	            emit(&msg);
244	        }
245	        pulse(&live);
246	        return 0;
247	    }
248	    loop {
249	        if let Some(msg) = listener.check_message_blocking() {
250	            emit(&msg);
251	        }
252	        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
253	            emit(&msg);
254	        }
255	        pulse(&live);
256	    }
257	}
258
259	/// `api bind <id>` — post-spawn boot bind (REQ-SEAM-POSTSPAWN). Establishes the
260	/// live perch on first contact, or flips an existing skeleton, keyed to the
261	/// session id the binary reports. Auth is intrinsic: an existing *live* perch
262	/// under a different session id is refused (you can only bind your own).
263	// [impl->REQ-SEAM-POSTSPAWN]
264	pub fn cmd_bind(id: &str, set_session_id: Option<String>, proof_session: Option<String>) -> i32 {
265	    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
266	    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
267	        eprintln!("INVALID_ID:{id}: {e}");
268	        return EXIT_REFUSED;
269	    }
270	    let session_id = set_session_id
271	        .or(proof_session)
272	        .or_else(|| std::env::var("OWL_SESSION_ID").ok())
273	        .filter(|s| !s.is_empty());
274	    let Some(session_id) = session_id else {
275	        eprintln!("USAGE: bind needs --set-session-id (or $OWL_SESSION_ID)");
276	        return EXIT_REFUSED;
277	    };
278	    // The session's own anchor: the process that fired this hook (the session).
279	    let parent_pid = proc::parent_pid().unwrap_or_else(std::process::id);
280	    match establish_perch(id, &session_id, parent_pid) {
281	        Ok(token) => {
282	            eprintln!("BOUND:{id} token={token}");
283	            // A post-spawn bind is a new-session-start — a reported resurface

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

    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let listener = match PollListener::bind_blocking(id, RELAY_POLL) {
        Ok(l) => l,
        Err(e) => {
            eprintln!("LISTEN_FAIL:{id}: {e}");
            return EXIT_REFUSED;
        }
    };

    // The bound session id (from the seed) — fills the lifecycle templates.
    let session_id = info::read_info(&perch_path)
        .map(|i| i.session_id)
        .filter(|s| !s.is_empty());

    // Live-agent lifecycle host (interim; M3's daemon re-hosts). Sweep a stale
    // signoff sentinel (3.2) then spawn the Psyche companion (T2) before READY.
    let live = super::live::LiveHost::new_opt(manifest, id);
    if let Some(host) = &live {
        host.sweep_on_start();
        match host.spawn_psyche(session_id.as_deref(), None) {
            Ok(p) => eprintln!("PSYCHE_SPAWNED:{} pid={}", p.id, p.pid),
            Err(e) => eprintln!("PSYCHE_SPAWN_FAIL:{id}: {e}"),
        }
    }

    eprintln!("READY:{id}");

    // New-session-start is a reported resurface boundary (ADR-0007/D8b):
    // undismissed notifs surface into the spool before the backlog drain, so
    // they ride this very first drain — and render endpoint-native when the
    // manifest declares [session.notif]. Best-effort — never fails the listen.
    super::reporting::resurface_notifs_with(id, manifest);

    // Drain the offline backlog first (non-deferred only; deferred rows wait
    // for the hook channel).
    for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
        emit(&msg);
    }
    // One pulse tick: ingest drops + fire a pending echo (T4/T3/T5).
    let pulse = |host: &Option<super::live::LiveHost>| {
        if let Some(h) = host {
            if let Err(e) = h.pulse_tick(session_id.as_deref()) {
                eprintln!("PULSE_FAIL:{id}: {e}");
            }
        }
    };
    if once {
        if let Some(msg) = listener.check_message_blocking() {
            emit(&msg);
        }
        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
            emit(&msg);
        }
        pulse(&live);
        return 0;
    }
    loop {
        if let Some(msg) = listener.check_message_blocking() {
            emit(&msg);
        }
        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
            emit(&msg);
        }
        pulse(&live);
    }
}

/// `api bind <id>` — post-spawn boot bind (REQ-SEAM-POSTSPAWN). Establishes the
/// live perch on first contact, or flips an existing skeleton, keyed to the
/// session id the binary reports. Auth is intrinsic: an existing *live* perch
/// under a different session id is refused (you can only bind your own).
// [impl->REQ-SEAM-POSTSPAWN]
pub fn cmd_bind(id: &str, set_session_id: Option<String>, proof_session: Option<String>) -> i32 {
    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
        eprintln!("INVALID_ID:{id}: {e}");
        return EXIT_REFUSED;
    }
    let session_id = set_session_id
        .or(proof_session)
        .or_else(|| std::env::var("OWL_SESSION_ID").ok())
        .filter(|s| !s.is_empty());
    let Some(session_id) = session_id else {
        eprintln!("USAGE: bind needs --set-session-id (or $OWL_SESSION_ID)");
        return EXIT_REFUSED;
    };
    // The session's own anchor: the process that fired this hook (the session).
    let parent_pid = proc::parent_pid().unwrap_or_else(std::process::id);
    match establish_perch(id, &session_id, parent_pid) {
        Ok(token) => {
            eprintln!("BOUND:{id} token={token}");
            // A post-spawn bind is a new-session-start — a reported resurface


Read ready.rs start + report_bind_error:


1	//! Ready-agent lifecycle: register a perch, listen, drain backlog, tear down.
2	//!
3	//! Clean-room re-expression of the sister `owl/poll.rs` startup + `setup.rs`
4	//! against the spt-store API, scoped to the M1 loopback model. The harness
5	//! coupling that bloats the sister `poll.rs` (stream EVENT emission, EVENT-PART
6	//! chunking, alarms, handoff, Psyche) is **not** M1 — it lands in M2/M3. M1's
7	//! ready agent is the minimal participant: a perch + a TCP listener + a spool
8	//! drain.
9	//!
10	//! Startup ([`ReadyAgent::start`]) is the REQ-MSG-3 contract:
11	//! 1. Create the perch dir; write `info.json` (`ready_agent` endpoint) + the
12	//!    `ready` marker.
13	//! 2. Bind a loopback listener and register its address (via [`PollListener`]).
14	//! 3. Drain the **non-deferred** spool backlog so messages that arrived while
15	//!    the agent was offline are delivered on startup. Deferred rows are left
16	//!    for the hook channel (REQ-HAZARD-DEFERRED-SURVIVE-DRAIN, covered M0).
17	//!
18	//! Teardown ([`ReadyAgent::soft_cleanup`]) honors REQ-HAZARD-SOFT-CLEANUP (6.2):
19	//! it removes **only** the `ready` marker and unregisters the address. The
20	//! spool and `info.json` are preserved so a restart resumes its backlog.
21	// [impl->REQ-MSG-3]
22
23	use spt_store::info::{self, InfoJson};
24	use spt_store::perch::{self, ParentHint};
25	use spt_store::spool;
26	use std::fs;
27	use std::path::PathBuf;
28	use std::time::Duration;
29
30	use crate::listener::PollListener;
31
32	/// Default poll window for [`ReadyAgent::recv`]; bounds each blocking accept so
33	/// the caller stays responsive (and can re-check spool / shut down between calls).
34	const RECV_POLL: Duration = Duration::from_millis(200);
35
36	/// A running ready agent: an owned perch + a registered loopback listener.
37	pub struct ReadyAgent {
38	    id: String,
39	    listener: PollListener,
40	    perch_path: PathBuf,
41	    closed: bool,
42	}
43
44	impl ReadyAgent {
45	    /// Register the perch (info.json + ready marker + listener address) and
46	    /// drain the offline backlog. Returns the agent and any backlog messages
47	    /// (oldest first) that were spooled while it was down.
48	    // [impl->REQ-MSG-3]
49	    pub fn start(id: &str) -> Result<(ReadyAgent, Vec<String>), String> {
50	        // [impl->REQ-HAZARD-ID-CHARSET] guard the id charset at the creation seam.
51	        spt_proto::id::validate_endpoint_id(id).map_err(|e| e.to_string())?;
52	        let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
53	        fs::create_dir_all(&perch_path)
54	            .map_err(|e| format!("Failed to create perch dir: {}", e))?;
55
56	        // info.json first, so the record exists by the time the address is live.
57	        let rec = InfoJson::new(id, &now_stamp(), std::process::id(), &session_id(), "ready_agent");
58	        info::write_info(&perch_path, &rec)
59	            .map_err(|e| format!("Failed to write info.json: {}", e))?;
60
61	        // ready marker — the liveness signal senders check.
62	        fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "")
63	            .map_err(|e| format!("Failed to write ready marker: {}", e))?;
64
65	        // Bind + register the listener address. Blocking with a short poll so
66	        // recv() stays responsive.
67	        let listener = PollListener::bind_blocking(id, RECV_POLL)?;
68
69	        // Drain the offline backlog (non-deferred only; deferred rows survive
70	        // for the hook channel).
71	        let backlog = spool::drain_non_deferred_at(&perch_path)
72	            .map_err(|e| format!("Failed to drain spool backlog: {}", e))?;
73
74	        Ok((
75	            ReadyAgent {
76	                id: id.to_string(),
77	                listener,
78	                perch_path,
79	                closed: false,
80	            },
81	            backlog,
82	        ))
83	    }
84
85	    /// The agent's id.
86	    pub fn id(&self) -> &str {
87	        &self.id
88	    }
89
90	    /// Wait up to one poll window for a live TCP message. Returns `None` on
91	    /// timeout (call again to keep listening).
92	    pub fn recv(&self) -> Option<String> {
93	        self.listener.check_message_blocking()
94	    }
95
96	    /// Drain any non-deferred spool rows that accrued since startup (e.g. a
97	    /// sender that spooled because it raced the listener bind, or a hook-channel
98	    /// arrival). Oldest first.
99	    pub fn drain_spool(&self) -> Vec<String> {
100	        spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default()
101	    }
102
103	    /// Soft teardown (6.2): remove the `ready` marker and unregister the
104	    /// address. The spool and `info.json` are **preserved** so a later restart
105	    /// resumes its backlog. Idempotent.
106	    // [impl->REQ-MSG-3]
107	    // [impl->REQ-HAZARD-SOFT-CLEANUP]
108	    pub fn soft_cleanup(&mut self) {
109	        let _ = fs::remove_file(perch::resolve_ready_file(&self.id, ParentHint::Infer));
110	        self.listener.close();
111	        self.closed = true;
112	    }
113	}
114
115	impl Drop for ReadyAgent {
116	    fn drop(&mut self) {
117	        if !self.closed {
118	            self.soft_cleanup();
119	        }
120	    }
121	}
122
123	/// Coarse creation timestamp (epoch seconds as a string). Opaque to readers —
124	/// `info.json` treats `started` as a producer-format string.
125	fn now_stamp() -> String {
126	    std::time::SystemTime::now()
127	        .duration_since(std::time::UNIX_EPOCH)
128	        .map(|d| d.as_secs().to_string())
129	        .unwrap_or_else(|_| "0".to_string())
130	}
131
132	/// Harness session id: honor `OWL_SESSION_ID` if set, else synthesize a
133	/// process-unique fallback. (M2 wires the real harness session id.)
134	fn session_id() -> String {
135	    if let Ok(s) = std::env::var("OWL_SESSION_ID") {
136	        if !s.is_empty() {
137	            return s;
138	        }
139	    }
140	    let nanos = std::time::SystemTime::now()
141	        .duration_since(std::time::UNIX_EPOCH)
142	        .map(|d| d.as_nanos())
143	        .unwrap_or(0);
144	    format!("sess-{}-{}", std::process::id(), nanos)
145	}
146
147	#[cfg(test)]
148	mod tests {
149	    use super::*;
150	    use crate::deliver;
151
152	    fn unique_id(prefix: &str) -> String {
153	        let nanos = std::time::SystemTime::now()
154	            .duration_since(std::time::UNIX_EPOCH)
155	            .map(|d| d.as_nanos())
156	            .unwrap_or(0);
157	        format!("{}-{}-{}", prefix, std::process::id(), nanos)
158	    }
159
160	    // [unit->REQ-MSG-3] start registers a reachable, resolvable perch.
161	    #[test]
162	    fn start_registers_resolvable_perch() {
163	        crate::test_home::isolate();
164	        let id = unique_id("test-ready-start");
165	        let (agent, backlog) = ReadyAgent::start(&id).unwrap();
166	        assert!(backlog.is_empty());
167
168	        // Resolvable + online from a sender's point of view.
169	        assert!(deliver::is_online(&id));
170	        assert!(deliver::perch_exists(&id));
171
172	        // info.json + ready marker on disk.
173	        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
174	        assert!(info::read_info(&perch_path).is_some());
175	        assert!(perch::resolve_ready_file(&id, ParentHint::Infer).exists());
176	        drop(agent);
177	    }
178
179	    // [unit->REQ-MSG-3] backlog spooled while offline drains on startup.
180	    #[test]
181	    fn backlog_drains_on_start() {
182	        crate::test_home::isolate();
183	        let owlery = perch::owlery_dir();
184	        let id = unique_id("test-ready-backlog");
185	        // Pre-create an offline perch and spool a message to it.
186	        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
187	        fs::create_dir_all(&perch_path).unwrap();
188	        let rec = InfoJson::new(&id, "now", std::process::id(), "sess", "ready_agent");
189	        info::write_info(&perch_path, &rec).unwrap();
190	        assert_eq!(deliver::deliver(&id, "sender", "while you slept", &owlery), deliver::DeliveryPath::Spool);
191
192	        // Starting drains it.
193	        let (_agent, backlog) = ReadyAgent::start(&id).unwrap();
194	        assert_eq!(backlog.len(), 1);
195	        assert!(backlog[0].contains("while you slept"));
196	    }
197
198	    // [unit->REQ-MSG-3] a live TCP message is received via recv().
199	    #[test]
200	    fn recv_gets_live_tcp_message() {
201	        crate::test_home::isolate();
202	        let owlery = perch::owlery_dir();
203	        let id = unique_id("test-ready-recv");
204	        let (agent, _) = ReadyAgent::start(&id).unwrap();
205
206	        let owlery2 = owlery.clone();
207	        let target = id.clone();
208	        let h = std::thread::spawn(move || {
209	            std::thread::sleep(Duration::from_millis(30));
210	            deliver::deliver(&target, "peer", "hello live", &owlery2)
211	        });
212
213	        let mut got = None;
214	        for _ in 0..25 {
215	            if let Some(m) = agent.recv() {
216	                got = Some(m);
217	                break;
218	            }
219	        }
220	        assert_eq!(h.join().unwrap(), deliver::DeliveryPath::Tcp);
221	        assert_eq!(got.as_deref(), Some("__REPLY_TO__:peer\nhello live\n"));
222	        drop(agent);
223	    }
224
225	    // [unit->REQ-HAZARD-SOFT-CLEANUP] soft_cleanup removes only the ready marker;
226	    // spool (deferred row) and info.json are preserved.
227	    #[test]
228	    fn soft_cleanup_preserves_spool_and_info() {
229	        crate::test_home::isolate();
230	        let id = unique_id("test-ready-soft");
231	        let (mut agent, _) = ReadyAgent::start(&id).unwrap();
232	        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
233
234	        // A deferred row survives the startup drain — use it to prove the spool
235	        // is preserved across cleanup.
236	        spool::spool_message_deferred_at(&perch_path, "hooker", "deferred body").unwrap();
237	        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);
238
239	        agent.soft_cleanup();
240
241	        // ready marker gone...
242	        assert!(!perch::resolve_ready_file(&id, ParentHint::Infer).exists());
243	        // ...but spool row + info.json preserved.
244	        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);
245	        assert!(info::read_info(&perch_path).is_some());
246	    }
247	}
248

//! Ready-agent lifecycle: register a perch, listen, drain backlog, tear down.
//!
//! Clean-room re-expression of the sister `owl/poll.rs` startup + `setup.rs`
//! against the spt-store API, scoped to the M1 loopback model. The harness
//! coupling that bloats the sister `poll.rs` (stream EVENT emission, EVENT-PART
//! chunking, alarms, handoff, Psyche) is **not** M1 — it lands in M2/M3. M1's
//! ready agent is the minimal participant: a perch + a TCP listener + a spool
//! drain.
//!
//! Startup ([`ReadyAgent::start`]) is the REQ-MSG-3 contract:
//! 1. Create the perch dir; write `info.json` (`ready_agent` endpoint) + the
//!    `ready` marker.
//! 2. Bind a loopback listener and register its address (via [`PollListener`]).
//! 3. Drain the **non-deferred** spool backlog so messages that arrived while
//!    the agent was offline are delivered on startup. Deferred rows are left
//!    for the hook channel (REQ-HAZARD-DEFERRED-SURVIVE-DRAIN, covered M0).
//!
//! Teardown ([`ReadyAgent::soft_cleanup`]) honors REQ-HAZARD-SOFT-CLEANUP (6.2):
//! it removes **only** the `ready` marker and unregisters the address. The
//! spool and `info.json` are preserved so a restart resumes its backlog.
// [impl->REQ-MSG-3]

use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;

use crate::listener::PollListener;

/// Default poll window for [`ReadyAgent::recv`]; bounds each blocking accept so
/// the caller stays responsive (and can re-check spool / shut down between calls).
const RECV_POLL: Duration = Duration::from_millis(200);

/// A running ready agent: an owned perch + a registered loopback listener.
pub struct ReadyAgent {
    id: String,
    listener: PollListener,
    perch_path: PathBuf,
    closed: bool,
}

impl ReadyAgent {
    /// Register the perch (info.json + ready marker + listener address) and
    /// drain the offline backlog. Returns the agent and any backlog messages
    /// (oldest first) that were spooled while it was down.
    // [impl->REQ-MSG-3]
    pub fn start(id: &str) -> Result<(ReadyAgent, Vec<String>), String> {
        // [impl->REQ-HAZARD-ID-CHARSET] guard the id charset at the creation seam.
        spt_proto::id::validate_endpoint_id(id).map_err(|e| e.to_string())?;
        let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
        fs::create_dir_all(&perch_path)
            .map_err(|e| format!("Failed to create perch dir: {}", e))?;

        // info.json first, so the record exists by the time the address is live.
        let rec = InfoJson::new(id, &now_stamp(), std::process::id(), &session_id(), "ready_agent");
        info::write_info(&perch_path, &rec)
            .map_err(|e| format!("Failed to write info.json: {}", e))?;

        // ready marker — the liveness signal senders check.
        fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "")
            .map_err(|e| format!("Failed to write ready marker: {}", e))?;

        // Bind + register the listener address. Blocking with a short poll so
        // recv() stays responsive.
        let listener = PollListener::bind_blocking(id, RECV_POLL)?;

        // Drain the offline backlog (non-deferred only; deferred rows survive
        // for the hook channel).
        let backlog = spool::drain_non_deferred_at(&perch_path)
            .map_err(|e| format!("Failed to drain spool backlog: {}", e))?;

        Ok((
            ReadyAgent {
                id: id.to_string(),
                listener,
                perch_path,
                closed: false,
            },
            backlog,
        ))
    }

    /// The agent's id.
    pub fn id(&self) -> &str {
        &self.id
    }

    /// Wait up to one poll window for a live TCP message. Returns `None` on
    /// timeout (call again to keep listening).
    pub fn recv(&self) -> Option<String> {
        self.listener.check_message_blocking()
    }

    /// Drain any non-deferred spool rows that accrued since startup (e.g. a
    /// sender that spooled because it raced the listener bind, or a hook-channel
    /// arrival). Oldest first.
    pub fn drain_spool(&self) -> Vec<String> {
        spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default()
    }

    /// Soft teardown (6.2): remove the `ready` marker and unregister the
    /// address. The spool and `info.json` are **preserved** so a later restart
    /// resumes its backlog. Idempotent.
    // [impl->REQ-MSG-3]
    // [impl->REQ-HAZARD-SOFT-CLEANUP]
    pub fn soft_cleanup(&mut self) {
        let _ = fs::remove_file(perch::resolve_ready_file(&self.id, ParentHint::Infer));
        self.listener.close();
        self.closed = true;
    }
}

impl Drop for ReadyAgent {
    fn drop(&mut self) {
        if !self.closed {
            self.soft_cleanup();
        }
    }
}

/// Coarse creation timestamp (epoch seconds as a string). Opaque to readers —
/// `info.json` treats `started` as a producer-format string.
fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

/// Harness session id: honor `OWL_SESSION_ID` if set, else synthesize a
/// process-unique fallback. (M2 wires the real harness session id.)
fn session_id() -> String {
    if let Ok(s) = std::env::var("OWL_SESSION_ID") {
        if !s.is_empty() {
            return s;
        }
    }
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    format!("sess-{}-{}", std::process::id(), nanos)
}

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

    fn unique_id(prefix: &str) -> String {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        format!("{}-{}-{}", prefix, std::process::id(), nanos)
    }

    // [unit->REQ-MSG-3] start registers a reachable, resolvable perch.
    #[test]
    fn start_registers_resolvable_perch() {
        crate::test_home::isolate();
        let id = unique_id("test-ready-start");
        let (agent, backlog) = ReadyAgent::start(&id).unwrap();
        assert!(backlog.is_empty());

        // Resolvable + online from a sender's point of view.
        assert!(deliver::is_online(&id));
        assert!(deliver::perch_exists(&id));

        // info.json + ready marker on disk.
        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
        assert!(info::read_info(&perch_path).is_some());
        assert!(perch::resolve_ready_file(&id, ParentHint::Infer).exists());
        drop(agent);
    }

    // [unit->REQ-MSG-3] backlog spooled while offline drains on startup.
    #[test]
    fn backlog_drains_on_start() {
        crate::test_home::isolate();
        let owlery = perch::owlery_dir();
        let id = unique_id("test-ready-backlog");
        // Pre-create an offline perch and spool a message to it.
        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
        fs::create_dir_all(&perch_path).unwrap();
        let rec = InfoJson::new(&id, "now", std::process::id(), "sess", "ready_agent");
        info::write_info(&perch_path, &rec).unwrap();
        assert_eq!(deliver::deliver(&id, "sender", "while you slept", &owlery), deliver::DeliveryPath::Spool);

        // Starting drains it.
        let (_agent, backlog) = ReadyAgent::start(&id).unwrap();
        assert_eq!(backlog.len(), 1);
        assert!(backlog[0].contains("while you slept"));
    }

    // [unit->REQ-MSG-3] a live TCP message is received via recv().
    #[test]
    fn recv_gets_live_tcp_message() {
        crate::test_home::isolate();
        let owlery = perch::owlery_dir();
        let id = unique_id("test-ready-recv");
        let (agent, _) = ReadyAgent::start(&id).unwrap();

        let owlery2 = owlery.clone();
        let target = id.clone();
        let h = std::thread::spawn(move || {
            std::thread::sleep(Duration::from_millis(30));
            deliver::deliver(&target, "peer", "hello live", &owlery2)
        });

        let mut got = None;
        for _ in 0..25 {
            if let Some(m) = agent.recv() {
                got = Some(m);
                break;
            }
        }
        assert_eq!(h.join().unwrap(), deliver::DeliveryPath::Tcp);
        assert_eq!(got.as_deref(), Some("__REPLY_TO__:peer\nhello live\n"));
        drop(agent);
    }

    // [unit->REQ-HAZARD-SOFT-CLEANUP] soft_cleanup removes only the ready marker;
    // spool (deferred row) and info.json are preserved.
    #[test]
    fn soft_cleanup_preserves_spool_and_info() {
        crate::test_home::isolate();
        let id = unique_id("test-ready-soft");
        let (mut agent, _) = ReadyAgent::start(&id).unwrap();
        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);

        // A deferred row survives the startup drain — use it to prove the spool
        // is preserved across cleanup.
        spool::spool_message_deferred_at(&perch_path, "hooker", "deferred body").unwrap();
        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);

        agent.soft_cleanup();

        // ready marker gone...
        assert!(!perch::resolve_ready_file(&id, ParentHint::Infer).exists());
        // ...but spool row + info.json preserved.
        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);
        assert!(info::read_info(&perch_path).is_some());
    }
}


/// First-join adoption (CONTEXT: "on an unpaired node the endpoint is

/// Resolve a perch record's **creation-time fields** at a bind/establish
/// seam — the one shared body behind every creation surface (`spt ready`,
/// `api listen`/`bind`), so the rules can't drift between them:
///
/// - **NEW perch** (`prior` = None): home assignment is **hard** —
///   auto-if-one / ask-if-many (refuse-and-qualify, the creation must not
///   proceed on a guess) / local-only-if-none — and the adapter records the
///   creating surface's. A resolved home seeds `sync_subnets = [home]`.
/// - **REVIVE** (`prior` = Some): the durable creation-time fields **carry
///   forward** — a re-bind rewrites `info.json`, and home (immutable), the
///   resource blurb, and the auto-suspend override are user/creation state a
///   restart must never wipe. An explicit `adapter` on the call wins
///   (resume-under-new is the sanctioned adapter change); an unhomed legacy
///   record adopts best-effort iff unambiguous (sole subnet), never refusing
///   a revive.
///
/// Loads + persists the canonical visibility store for the scope seed.
// [impl->REQ-INST-15]
pub fn stamp_creation_fields(
    rec: &mut info::InfoJson,
    prior: Option<&info::InfoJson>,
    explicit_subnet: Option<&str>,
    adapter: Option<&str>,
) -> Result<(), HomeError> {
    let seed_scope = |id: &str, h: &str| {
        let mut vis = VisibilityStore::load();
        seed_endpoint_scopes(&mut vis, id, h);
        let _ = vis.save();
    };
    match prior {
        Some(p) => {
            rec.home_subnet = p.home_subnet.clone();
            rec.adapter = adapter.map(str::to_string).or_else(|| p.adapter.clone());
            rec.resources = p.resources.clone();
            rec.auto_suspend_after_ms = p.auto_suspend_after_ms;
            if rec.home_subnet.is_none() {
                // Legacy/unpaired-created record: adopt only the unambiguous
                // case — a revive must not start refusing.
                if let Ok(HomeAssignment::Home(h)) =
                    assign_home(&SubnetStore::load(), explicit_subnet)
                {
                    rec.home_subnet = Some(h.clone());
                    seed_scope(&rec.id, &h);
                }
            }
            Ok(())
        }
        None => {
            rec.adapter = adapter.map(str::to_string);
            match assign_home(&SubnetStore::load(), explicit_subnet)? {
                HomeAssignment::Home(h) => {
                    rec.home_subnet = Some(h.clone());
                    seed_scope(&rec.id, &h);
                }
                HomeAssignment::LocalOnly => {}
            }
            Ok(())
        }
    }
}

/// First-join adoption (CONTEXT: "on an unpaired node the endpoint is


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

/// Resolve a perch record's **creation-time fields** at a bind/establish
/// seam — the one shared body behind every creation surface (`spt ready`,
/// `api listen`/`bind`), so the rules can't drift between them:
///
/// - **NEW perch** (`prior` = None): home assignment is **hard** —
///   auto-if-one / ask-if-many (refuse-and-qualify, the creation must not
///   proceed on a guess) / local-only-if-none — and the adapter records the
///   creating surface's. A resolved home seeds `sync_subnets = [home]`.
/// - **REVIVE** (`prior` = Some): the durable creation-time fields **carry
///   forward** — a re-bind rewrites `info.json`, and home (immutable), the
///   resource blurb, and the auto-suspend override are user/creation state a
///   restart must never wipe. An explicit `adapter` on the call wins
///   (resume-under-new is the sanctioned adapter change); an unhomed legacy
///   record adopts best-effort iff unambiguous (sole subnet), never refusing
///   a revive.
///
/// Loads + persists the canonical visibility store for the scope seed.
// [impl->REQ-INST-15]
pub fn stamp_creation_fields(
    rec: &mut info::InfoJson,
    prior: Option<&info::InfoJson>,
    explicit_subnet: Option<&str>,
    adapter: Option<&str>,
) -> Result<(), HomeError> {
    let seed_scope = |id: &str, h: &str| {
        let mut vis = VisibilityStore::load();
        seed_endpoint_scopes(&mut vis, id, h);
        let _ = vis.save();
    };
    match prior {
        Some(p) => {
            rec.home_subnet = p.home_subnet.clone();
            rec.adapter = adapter.map(str::to_string).or_else(|| p.adapter.clone());
            rec.resources = p.resources.clone();
            rec.auto_suspend_after_ms = p.auto_suspend_after_ms;
            if rec.home_subnet.is_none() {
                // Legacy/unpaired-created record: adopt only the unambiguous
                // case — a revive must not start refusing.
                if let Ok(HomeAssignment::Home(h)) =
                    assign_home(&SubnetStore::load(), explicit_subnet)
                {
                    rec.home_subnet = Some(h.clone());
                    seed_scope(&rec.id, &h);
                }
            }
            Ok(())
        }
        None => {
            rec.adapter = adapter.map(str::to_string);
            match assign_home(&SubnetStore::load(), explicit_subnet)? {
                HomeAssignment::Home(h) => {
                    rec.home_subnet = Some(h.clone());
                    seed_scope(&rec.id, &h);
                }
                HomeAssignment::LocalOnly => {}
            }
            Ok(())
        }
    }
}

/// First-join adoption (CONTEXT: "on an unpaired node the endpoint is

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

use std::path::Path;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+/// Resolve a perch record's **creation-time fields** at a bind/establish

+/// seam — the one shared body behind every creation surface (`spt ready`,

+/// `api listen`/`bind`), so the rules can't drift between them:

+/// - **NEW perch** (`prior` = None): home assignment is **hard** —

+///   auto-if-one / ask-if-many (refuse-and-qualify, the creation must not

+///   proceed on a guess) / local-only-if-none — and the adapter records the

+///   creating surface's. A resolved home seeds `sync_subnets = [home]`.

+/// - **REVIVE** (`prior` = Some): the durable creation-time fields **carry

+///   forward** — a re-bind rewrites `info.json`, and home (immutable), the

+///   resource blurb, and the auto-suspend override are user/creation state a

+///   restart must never wipe. An explicit `adapter` on the call wins

+///   (resume-under-new is the sanctioned adapter change); an unhomed legacy

+///   record adopts best-effort iff unambiguous (sole subnet), never refusing

+/// Loads + persists the canonical visibility store for the scope seed.

+    let seed_scope = |id: &str, h: &str| {

+        let mut vis = VisibilityStore::load();

+        seed_endpoint_scopes(&mut vis, id, h);

+            rec.home_subnet = p.home_subnet.clone();

+            rec.adapter = adapter.map(str::to_string).or_else(|| p.adapter.clone());

+            rec.resources = p.resources.clone();

+            rec.auto_suspend_after_ms = p.auto_suspend_after_ms;

+            if rec.home_subnet.is_none() {

+                // Legacy/unpaired-created record: adopt only the unambiguous

+                // case — a revive must not start refusing.

+                if let Ok(HomeAssignment::Home(h)) =

+                    assign_home(&SubnetStore::load(), explicit_subnet)

+                    rec.home_subnet = Some(h.clone());

+                    seed_scope(&rec.id, &h);

+            rec.adapter = adapter.map(str::to_string);

+            match assign_home(&SubnetStore::load(), explicit_subnet)? {

+                HomeAssignment::Home(h) => {

+                HomeAssignment::LocalOnly => {}


crates\spt-store\src\branchstore.rs:126:    /// Re-point worktree links after a `$SPT_HOME` relocation (the fallback
crates\spt-store\src\perch.rs:24:/// Root of all spt-core on-disk state. Honors `$SPT_HOME`, else the platform
crates\spt-store\src\perch.rs:32:/// override (`$SPT_HOME`) is the relocation knob either way.
crates\spt-store\src\perch.rs:35:    if let Ok(over) = std::env::var("SPT_HOME") {
crates\spt-store\src\perch.rs:354:            let prev = std::env::var("SPT_HOME").ok();
crates\spt-store\src\perch.rs:355:            std::env::set_var("SPT_HOME", tmp);
crates\spt-store\src\perch.rs:362:                Some(v) => std::env::set_var("SPT_HOME", v),
crates\spt-store\src\perch.rs:363:                None => std::env::remove_var("SPT_HOME"),
crates\spt-store\src\perch.rs:471:    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] spt_home is pure: honors SPT_HOME,
crates\spt-store\src\xfer.rs:17://! two ends' files distinct (load-bearing under a shared `SPT_HOME` — the


344
345	    static ENV_LOCK: Mutex<()> = Mutex::new(());
346
347	    struct EnvGuard {
348	        _lock: std::sync::MutexGuard<'static, ()>,
349	        prev: Option<String>,
350	    }
351	    impl EnvGuard {
352	        fn set(tmp: &Path) -> Self {
353	            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
354	            let prev = std::env::var("SPT_HOME").ok();
355	            std::env::set_var("SPT_HOME", tmp);
356	            EnvGuard { _lock: lock, prev }
357	        }
358	    }
359	    impl Drop for EnvGuard {
360	        fn drop(&mut self) {
361	            match &self.prev {
362	                Some(v) => std::env::set_var("SPT_HOME", v),
363	                None => std::env::remove_var("SPT_HOME"),
364	            }
365	        }
366	    }
367
368	    // ---- classification ----
369
370	    // [unit->REQ-HAZARD-WORKER-PATH]
371	    #[test]
372	    fn classification_matrix() {
373	        assert_eq!(PerchKind::classify("dunsen"), PerchKind::Self_);

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    struct EnvGuard {
        _lock: std::sync::MutexGuard<'static, ()>,
        prev: Option<String>,
    }
    impl EnvGuard {
        fn set(tmp: &Path) -> Self {
            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            let prev = std::env::var("SPT_HOME").ok();
            std::env::set_var("SPT_HOME", tmp);
            EnvGuard { _lock: lock, prev }
        }
    }
    impl Drop for EnvGuard {
        fn drop(&mut self) {
            match &self.prev {
                Some(v) => std::env::set_var("SPT_HOME", v),
                None => std::env::remove_var("SPT_HOME"),
            }
        }
    }

    // ---- classification ----

    // [unit->REQ-HAZARD-WORKER-PATH]
    #[test]
    fn classification_matrix() {
        assert_eq!(PerchKind::classify("dunsen"), PerchKind::Self_);


Make stamp_creation_fields pure over injected stores (testable, no env games):


/// Loads + persists the canonical visibility store for the scope seed.
// [impl->REQ-INST-15]
pub fn stamp_creation_fields(
    rec: &mut info::InfoJson,
    prior: Option<&info::InfoJson>,
    explicit_subnet: Option<&str>,
    adapter: Option<&str>,
) -> Result<(), HomeError> {
    let seed_scope = |id: &str, h: &str| {
        let mut vis = VisibilityStore::load();
        seed_endpoint_scopes(&mut vis, id, h);
        let _ = vis.save();
    };
    match prior {
        Some(p) => {
            rec.home_subnet = p.home_subnet.clone();
            rec.adapter = adapter.map(str::to_string).or_else(|| p.adapter.clone());
            rec.resources = p.resources.clone();
            rec.auto_suspend_after_ms = p.auto_suspend_after_ms;
            if rec.home_subnet.is_none() {
                // Legacy/unpaired-created record: adopt only the unambiguous
                // case — a revive must not start refusing.
                if let Ok(HomeAssignment::Home(h)) =
                    assign_home(&SubnetStore::load(), explicit_subnet)
                {
                    rec.home_subnet = Some(h.clone());
                    seed_scope(&rec.id, &h);
                }
            }
            Ok(())
        }
        None => {
            rec.adapter = adapter.map(str::to_string);
            match assign_home(&SubnetStore::load(), explicit_subnet)? {
                HomeAssignment::Home(h) => {
                    rec.home_subnet = Some(h.clone());
                    seed_scope(&rec.id, &h);
                }
                HomeAssignment::LocalOnly => {}
            }
            Ok(())
        }
    }
}

/// Pure over the injected stores (callers load the canonical ones and persist
/// `vis` when this returns `true` — the scope-seed dirty flag).
// [impl->REQ-INST-15]
pub fn stamp_creation_fields(
    rec: &mut info::InfoJson,
    prior: Option<&info::InfoJson>,
    store: &SubnetStore,
    vis: &mut VisibilityStore,
    explicit_subnet: Option<&str>,
    adapter: Option<&str>,
) -> Result<bool, HomeError> {
    match prior {
        Some(p) => {
            rec.home_subnet = p.home_subnet.clone();
            rec.adapter = adapter.map(str::to_string).or_else(|| p.adapter.clone());
            rec.resources = p.resources.clone();
            rec.auto_suspend_after_ms = p.auto_suspend_after_ms;
            if rec.home_subnet.is_none() {
                // Legacy/unpaired-created record: adopt only the unambiguous
                // case — a revive must not start refusing.
                if let Ok(HomeAssignment::Home(h)) = assign_home(store, explicit_subnet) {
                    rec.home_subnet = Some(h.clone());
                    seed_endpoint_scopes(vis, &rec.id, &h);
                    return Ok(true);
                }
            }
            Ok(false)
        }
        None => {
            rec.adapter = adapter.map(str::to_string);
            match assign_home(store, explicit_subnet)? {
                HomeAssignment::Home(h) => {
                    rec.home_subnet = Some(h.clone());
                    seed_endpoint_scopes(vis, &rec.id, &h);
                    Ok(true)
                }
                HomeAssignment::LocalOnly => Ok(false),
            }
        }
    }
}


/// Loads + persists the canonical visibility store for the scope seed.
// [impl->REQ-INST-15]
pub fn stamp_creation_fields(
    rec: &mut info::InfoJson,
    prior: Option<&info::InfoJson>,
    explicit_subnet: Option<&str>,
    adapter: Option<&str>,
) -> Result<(), HomeError> {
    let seed_scope = |id: &str, h: &str| {
        let mut vis = VisibilityStore::load();
        seed_endpoint_scopes(&mut vis, id, h);
        let _ = vis.save();
    };
    match prior {
        Some(p) => {
            rec.home_subnet = p.home_subnet.clone();
            rec.adapter = adapter.map(str::to_string).or_else(|| p.adapter.clone());
            rec.resources = p.resources.clone();
            rec.auto_suspend_after_ms = p.auto_suspend_after_ms;
            if rec.home_subnet.is_none() {
                // Legacy/unpaired-created record: adopt only the unambiguous
                // case — a revive must not start refusing.
                if let Ok(HomeAssignment::Home(h)) =
                    assign_home(&SubnetStore::load(), explicit_subnet)
                {
                    rec.home_subnet = Some(h.clone());
                    seed_scope(&rec.id, &h);
                }
            }
            Ok(())
        }
        None => {
            rec.adapter = adapter.map(str::to_string);
            match assign_home(&SubnetStore::load(), explicit_subnet)? {
                HomeAssignment::Home(h) => {
                    rec.home_subnet = Some(h.clone());
                    seed_scope(&rec.id, &h);
                }
                HomeAssignment::LocalOnly => {}
            }
            Ok(())
        }
    }
}

/// Pure over the injected stores (callers load the canonical ones and persist
/// `vis` when this returns `true` — the scope-seed dirty flag).
// [impl->REQ-INST-15]
pub fn stamp_creation_fields(
    rec: &mut info::InfoJson,
    prior: Option<&info::InfoJson>,
    store: &SubnetStore,
    vis: &mut VisibilityStore,
    explicit_subnet: Option<&str>,
    adapter: Option<&str>,
) -> Result<bool, HomeError> {
    match prior {
        Some(p) => {
            rec.home_subnet = p.home_subnet.clone();
            rec.adapter = adapter.map(str::to_string).or_else(|| p.adapter.clone());
            rec.resources = p.resources.clone();
            rec.auto_suspend_after_ms = p.auto_suspend_after_ms;
            if rec.home_subnet.is_none() {
                // Legacy/unpaired-created record: adopt only the unambiguous
                // case — a revive must not start refusing.
                if let Ok(HomeAssignment::Home(h)) = assign_home(store, explicit_subnet) {
                    rec.home_subnet = Some(h.clone());
                    seed_endpoint_scopes(vis, &rec.id, &h);
                    return Ok(true);
                }
            }
            Ok(false)
        }
        None => {
            rec.adapter = adapter.map(str::to_string);
            match assign_home(store, explicit_subnet)? {
                HomeAssignment::Home(h) => {
                    rec.home_subnet = Some(h.clone());
                    seed_endpoint_scopes(vis, &rec.id, &h);
                    Ok(true)
                }
                HomeAssignment::LocalOnly => Ok(false),
            }
        }
    }
}

///   record adopts best-effort iff unambiguous (sole subnet), never refusing

-/// Loads + persists the canonical visibility store for the scope seed.

+/// Pure over the injected stores (callers load the canonical ones and persist

+/// `vis` when this returns `true` — the scope-seed dirty flag).

-    let seed_scope = |id: &str, h: &str| {

-        let mut vis = VisibilityStore::load();

-        seed_endpoint_scopes(&mut vis, id, h);

-                if let Ok(HomeAssignment::Home(h)) =

-                    assign_home(&SubnetStore::load(), explicit_subnet)

+                if let Ok(HomeAssignment::Home(h)) = assign_home(store, explicit_subnet) {

-                    seed_scope(&rec.id, &h);

+                    seed_endpoint_scopes(vis, &rec.id, &h);

-            match assign_home(&SubnetStore::load(), explicit_subnet)? {

+            match assign_home(store, explicit_subnet)? {

-                HomeAssignment::LocalOnly => {}

+                HomeAssignment::LocalOnly => Ok(false),


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


Add stamp test:


// [unit->REQ-INST-15] first-join adoption: with exactly one subnet, an

    // [unit->REQ-INST-15] the shared creation seam: a NEW perch assigns home
    // hard (multi-subnet refuses) and seeds the sync scope; a REVIVE carries
    // every durable creation-time field forward (home is immutable, the
    // blurb/knobs are user state a re-bind must not wipe) with an explicit
    // adapter winning (resume-under-new).
    #[test]
    fn stamp_creation_fields_news_assign_and_revives_carry_forward() {
        let sole = store_with(&["home"]);
        let mut vis = VisibilityStore::default();

        // NEW on a sole-subnet node: home auto + scope seeded.
        let mut rec = InfoJson::new("ling", "t", 1, "sid", "live_agent");
        let dirty =
            stamp_creation_fields(&mut rec, None, &sole, &mut vis, None, Some("claude-code"))
                .unwrap();
        assert!(dirty, "scope seed wants persisting");
        assert_eq!(rec.home_subnet.as_deref(), Some("home"));
        assert_eq!(rec.adapter.as_deref(), Some("claude-code"));
        assert_eq!(vis.sync_subnets("ling"), Some(&["home".to_string()][..]));

        // NEW on a multi-subnet node refuses without an explicit pick.
        let multi = store_with(&["home", "work"]);
        let mut fresh = InfoJson::new("oak", "t", 1, "sid", "live_agent");
        assert!(matches!(
            stamp_creation_fields(&mut fresh, None, &multi, &mut vis, None, None),
            Err(HomeError::Ambiguous(_))
        ));
        assert!(
            stamp_creation_fields(&mut fresh, None, &multi, &mut vis, Some("work"), None).unwrap()
        );
        assert_eq!(fresh.home_subnet.as_deref(), Some("work"));

        // REVIVE: durable fields carry forward; the new adapter wins; home is
        // NOT reassigned even on a different store.
        let mut prior = rec.clone();
        prior.resources = Some("rust builds".to_string());
        prior.auto_suspend_after_ms = Some(0);
        let mut revived = InfoJson::new("ling", "t2", 2, "sid-2", "live_agent");
        let dirty = stamp_creation_fields(
            &mut revived,
            Some(&prior),
            &multi, // even though the store changed, home is immutable
            &mut vis,
            None,
            Some("codex"),
        )
        .unwrap();
        assert!(!dirty);
        assert_eq!(revived.home_subnet.as_deref(), Some("home"), "home survives the re-bind");
        assert_eq!(revived.adapter.as_deref(), Some("codex"), "resume-under-new changes adapter");
        assert_eq!(revived.resources.as_deref(), Some("rust builds"));
        assert_eq!(revived.auto_suspend_after_ms, Some(0));

        // REVIVE of an unhomed legacy record adopts best-effort: sole subnet
        // adopts; an ambiguous store leaves it unhomed (never refuses).
        let legacy = InfoJson::new("elm", "t", 1, "sid", "ready_agent");
        let mut r1 = legacy.clone();
        stamp_creation_fields(&mut r1, Some(&legacy), &multi, &mut vis, None, None).unwrap();
        assert_eq!(r1.home_subnet, None, "ambiguous: revive proceeds unhomed");
        let mut r2 = legacy.clone();
        stamp_creation_fields(&mut r2, Some(&legacy), &sole, &mut vis, None, None).unwrap();
        assert_eq!(r2.home_subnet.as_deref(), Some("home"), "sole subnet adopts on revive");
    }

    // [unit->REQ-INST-15] first-join adoption: with exactly one subnet, an


    // [unit->REQ-INST-15] the shared creation seam: a NEW perch assigns home
    // hard (multi-subnet refuses) and seeds the sync scope; a REVIVE carries
    // every durable creation-time field forward (home is immutable, the
    // blurb/knobs are user state a re-bind must not wipe) with an explicit
    // adapter winning (resume-under-new).
    #[test]
    fn stamp_creation_fields_news_assign_and_revives_carry_forward() {
        let sole = store_with(&["home"]);
        let mut vis = VisibilityStore::default();

        // NEW on a sole-subnet node: home auto + scope seeded.
        let mut rec = InfoJson::new("ling", "t", 1, "sid", "live_agent");
        let dirty =
            stamp_creation_fields(&mut rec, None, &sole, &mut vis, None, Some("claude-code"))
                .unwrap();
        assert!(dirty, "scope seed wants persisting");
        assert_eq!(rec.home_subnet.as_deref(), Some("home"));
        assert_eq!(rec.adapter.as_deref(), Some("claude-code"));
        assert_eq!(vis.sync_subnets("ling"), Some(&["home".to_string()][..]));

        // NEW on a multi-subnet node refuses without an explicit pick.
        let multi = store_with(&["home", "work"]);
        let mut fresh = InfoJson::new("oak", "t", 1, "sid", "live_agent");
        assert!(matches!(
            stamp_creation_fields(&mut fresh, None, &multi, &mut vis, None, None),
            Err(HomeError::Ambiguous(_))
        ));
        assert!(
            stamp_creation_fields(&mut fresh, None, &multi, &mut vis, Some("work"), None).unwrap()
        );
        assert_eq!(fresh.home_subnet.as_deref(), Some("work"));

        // REVIVE: durable fields carry forward; the new adapter wins; home is
        // NOT reassigned even on a different store.
        let mut prior = rec.clone();
        prior.resources = Some("rust builds".to_string());
        prior.auto_suspend_after_ms = Some(0);
        let mut revived = InfoJson::new("ling", "t2", 2, "sid-2", "live_agent");
        let dirty = stamp_creation_fields(
            &mut revived,
            Some(&prior),
            &multi, // even though the store changed, home is immutable
            &mut vis,
            None,
            Some("codex"),
        )
        .unwrap();
        assert!(!dirty);
        assert_eq!(revived.home_subnet.as_deref(), Some("home"), "home survives the re-bind");
        assert_eq!(revived.adapter.as_deref(), Some("codex"), "resume-under-new changes adapter");
        assert_eq!(revived.resources.as_deref(), Some("rust builds"));
        assert_eq!(revived.auto_suspend_after_ms, Some(0));

        // REVIVE of an unhomed legacy record adopts best-effort: sole subnet
        // adopts; an ambiguous store leaves it unhomed (never refuses).
        let legacy = InfoJson::new("elm", "t", 1, "sid", "ready_agent");
        let mut r1 = legacy.clone();
        stamp_creation_fields(&mut r1, Some(&legacy), &multi, &mut vis, None, None).unwrap();
        assert_eq!(r1.home_subnet, None, "ambiguous: revive proceeds unhomed");
        let mut r2 = legacy.clone();
        stamp_creation_fields(&mut r2, Some(&legacy), &sole, &mut vis, None, None).unwrap();
        assert_eq!(r2.home_subnet.as_deref(), Some("home"), "sole subnet adopts on revive");
    }

    // [unit->REQ-INST-15] first-join adoption: with exactly one subnet, an

+    // [unit->REQ-INST-15] the shared creation seam: a NEW perch assigns home

+    // hard (multi-subnet refuses) and seeds the sync scope; a REVIVE carries

+    // every durable creation-time field forward (home is immutable, the

+    // blurb/knobs are user state a re-bind must not wipe) with an explicit

+    // adapter winning (resume-under-new).

+    fn stamp_creation_fields_news_assign_and_revives_carry_forward() {

+        let sole = store_with(&["home"]);

+        let mut vis = VisibilityStore::default();

+        // NEW on a sole-subnet node: home auto + scope seeded.

+        let mut rec = InfoJson::new("ling", "t", 1, "sid", "live_agent");

+            stamp_creation_fields(&mut rec, None, &sole, &mut vis, None, Some("claude-code"))

+        assert!(dirty, "scope seed wants persisting");

+        assert_eq!(rec.home_subnet.as_deref(), Some("home"));

+        assert_eq!(rec.adapter.as_deref(), Some("claude-code"));

+        assert_eq!(vis.sync_subnets("ling"), Some(&["home".to_string()][..]));

+        // NEW on a multi-subnet node refuses without an explicit pick.

+        let multi = store_with(&["home", "work"]);

+        let mut fresh = InfoJson::new("oak", "t", 1, "sid", "live_agent");

+            stamp_creation_fields(&mut fresh, None, &multi, &mut vis, None, None),

+            Err(HomeError::Ambiguous(_))

+            stamp_creation_fields(&mut fresh, None, &multi, &mut vis, Some("work"), None).unwrap()

+        assert_eq!(fresh.home_subnet.as_deref(), Some("work"));

+        // REVIVE: durable fields carry forward; the new adapter wins; home is

+        // NOT reassigned even on a different store.

+        prior.resources = Some("rust builds".to_string());

+        prior.auto_suspend_after_ms = Some(0);

+        let mut revived = InfoJson::new("ling", "t2", 2, "sid-2", "live_agent");

+        let dirty = stamp_creation_fields(

+            &multi, // even though the store changed, home is immutable

+        assert_eq!(revived.home_subnet.as_deref(), Some("home"), "home survives the re-bind");

+        assert_eq!(revived.adapter.as_deref(), Some("codex"), "resume-under-new changes adapter");

+        assert_eq!(revived.resources.as_deref(), Some("rust builds"));

+        assert_eq!(revived.auto_suspend_after_ms, Some(0));

+        // REVIVE of an unhomed legacy record adopts best-effort: sole subnet

+        // adopts; an ambiguous store leaves it unhomed (never refuses).

+        let legacy = InfoJson::new("elm", "t", 1, "sid", "ready_agent");

+        stamp_creation_fields(&mut r1, Some(&legacy), &multi, &mut vis, None, None).unwrap();

+        assert_eq!(r1.home_subnet, None, "ambiguous: revive proceeds unhomed");

+        stamp_creation_fields(&mut r2, Some(&legacy), &sole, &mut vis, None, None).unwrap();

+        assert_eq!(r2.home_subnet.as_deref(), Some("home"), "sole subnet adopts on revive");

// unhomed Self perch adopts it (+ scope seed); a homed perch is NEVER

// re-homed; a multi-subnet store adopts nothing (no guessing).


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


1	0ea318b completed success
2

0ea318b completed success


Updated task #5 status


    /// Register the perch (info.json + ready marker + listener address) and
    /// drain the offline backlog. Returns the agent and any backlog messages
    /// (oldest first) that were spooled while it was down.
    // [impl->REQ-MSG-3]
    pub fn start(id: &str) -> Result<(ReadyAgent, Vec<String>), String> {
        // [impl->REQ-HAZARD-ID-CHARSET] guard the id charset at the creation seam.
        spt_proto::id::validate_endpoint_id(id).map_err(|e| e.to_string())?;
        let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
        fs::create_dir_all(&perch_path)
            .map_err(|e| format!("Failed to create perch dir: {}", e))?;

        // info.json first, so the record exists by the time the address is live.
        let rec = InfoJson::new(id, &now_stamp(), std::process::id(), &session_id(), "ready_agent");
        info::write_info(&perch_path, &rec)
            .map_err(|e| format!("Failed to write info.json: {}", e))?;

    /// Register the perch (info.json + ready marker + listener address) and
    /// drain the offline backlog. Returns the agent and any backlog messages
    /// (oldest first) that were spooled while it was down.
    // [impl->REQ-MSG-3]
    pub fn start(id: &str) -> Result<(ReadyAgent, Vec<String>), String> {
        Self::start_homed(id, None)
    }

    /// [`start`](Self::start) with an explicit home-subnet pick for a NEW
    /// endpoint (D9-5, REQ-INST-15): home is assigned at creation through the
    /// shared seam ([`spt_store::home::stamp_creation_fields`]) — auto on a
    /// sole-subnet node, `subnet` required on a multi-subnet one
    /// (refuse-and-qualify), local-only when unpaired. A restart carries the
    /// durable creation-time fields forward (home is immutable; the resource
    /// blurb and knobs are user state a re-register must not wipe).
    // [impl->REQ-INST-15]
    pub fn start_homed(
        id: &str,
        subnet: Option<&str>,
    ) -> Result<(ReadyAgent, Vec<String>), String> {
        // [impl->REQ-HAZARD-ID-CHARSET] guard the id charset at the creation seam.
        spt_proto::id::validate_endpoint_id(id).map_err(|e| e.to_string())?;
        let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
        fs::create_dir_all(&perch_path)
            .map_err(|e| format!("Failed to create perch dir: {}", e))?;

        // info.json first, so the record exists by the time the address is live.
        let prior = info::read_info(&perch_path);
        let mut rec =
            InfoJson::new(id, &now_stamp(), std::process::id(), &session_id(), "ready_agent");
        let subnets = spt_store::subnet::SubnetStore::load();
        let mut vis = spt_store::visibility::VisibilityStore::load();
        let dirty =
            spt_store::home::stamp_creation_fields(&mut rec, prior.as_ref(), &subnets, &mut vis, subnet, None)
                .map_err(|e| format!("HOME: {e}"))?;
        if dirty {
            let _ = vis.save();
        }
        info::write_info(&perch_path, &rec)
            .map_err(|e| format!("Failed to write info.json: {}", e))?;