1	//! The inbound half of the harness contract: the `spt api <cmd>` surface a
2	//! harness's hooks fire to mutate on-disk SPT state. Every call carries
3	//! `--adapter <name>` (the `adapter_name`), and every *mutation* is gated by
4	//! [`auth`] (REQ-HAZARD-LOCAL-API-AUTH).
5	//!
6	//! Interim no-daemon model (decision 2026-06-01): these commands mutate the
7	//! perch/spool/registry directly via spt-store/spt-msg, mirroring M1's
8	//! standalone listener. M3's `spt-daemon` swaps the on-disk seed + standalone
9	//! `listen` for an in-memory daemon record — the surface here stays thin so
10	//! that is a move, not a rewrite.
11	//!
12	//! Exit codes (superset of the messaging surface): `0` ok · `1` runtime refusal
13	//! (auth refused, no endpoint, bad manifest) · `2` usage (clap).
14	//
15	// [impl->REQ-API-1]
16
17	pub mod auth;
18	pub mod delivery;
19	pub mod live;
20	pub mod reporting;
21	pub mod startup;
22	pub mod worker;
23
24	use clap::{Args, Subcommand};
25	use std::path::PathBuf;
26
27	use spt_runtime::Manifest;
28
29	use auth::{authenticate, AuthResult, Proof};
30
31	/// Exit code for a runtime refusal (auth/no-endpoint).
32	pub const EXIT_REFUSED: i32 = 1;
33
34	/// The `api` command group. `--adapter` is required on every invocation; an
35	/// optional `--manifest` is parsed for commands that need the adapter contract
36	/// (spawn roles, capability advertisement).
37	#[derive(Args)]
38	pub struct ApiArgs {
39	    /// adapter_name — the calling harness adapter. Required on every `api` call.
40	    #[arg(long)]
41	    adapter: String,
42	    /// Path to the adapter's runtime manifest (when the command needs it).
43	    #[arg(long)]
44	    manifest: Option<PathBuf>,
45	    #[command(subcommand)]
46	    cmd: ApiCmd,
47	}
48
49	/// The inbound surface. Lifecycle-execution commands (psyche/commune) are M2b;
50	/// these are the M2a harness-contract entry points.
51	#[derive(Subcommand)]
52	pub enum ApiCmd {
53	    /// Harness-hosted startup: record an ephemeral seed keyed by parent pid.
54	    Seed {
55	        #[arg(long)]
56	        pid: u32,
57	        #[arg(long)]
58	        session_id: String,
59	    },
60	    /// Consume a seed and hold the perch + relay loop (blocks).
61	    Listen {
62	        id: String,
63	        /// Override the parent-pid anchor (defaults to the self-discovered PPID).
64	        #[arg(long)]
65	        parent_pid: Option<u32>,
66	        /// Drain backlog + one receive cycle, then exit (testability).
67	        #[arg(long)]
68	        once: bool,
69	        /// Home subnet for a NEW endpoint (required on a multi-subnet
70	        /// node — home is assigned at creation, never guessed).
71	        #[arg(long)]
72	        subnet: Option<String>,
73	    },
74	    /// Post-spawn bind of a session to its perch.
75	    Bind {
76	        id: String,
77	        /// The session id discovered post-spawn, written into the perch record.
78	        #[arg(long = "set-session-id")]
79	        bind_session: Option<String>,
80	        /// Home subnet for a NEW endpoint (see `listen`).
81	        #[arg(long)]
82	        subnet: Option<String>,
83	        /// The endpoint type tag (info.json `state`). Defaults to `live_agent`
84	        /// (the agent host); a non-agent endpoint — e.g. a `gateway` — binds
85	        /// with its own open-type tag (REQ-EP-1/REQ-EP-6). A revive keeps the
86	        /// prior type unless this overrides it.
87	        #[arg(long = "type", default_value = "live_agent")]
88	        endpoint_type: String,
89	        #[command(flatten)]
90	        auth: AuthFlags,
91	    },
92	    /// Shell-binary bind: the type=Shell flavor of `bind`. Resolves
93	    /// the instance **by link token alone** (the spawn template carries only
94	    /// `{link_token}` — "owner from the link") and flips it online. The
95	    /// credential IS the auth: no token, no bind.
96	    BindShell {
97	        /// The link token the broker minted at launch.
98	        #[arg(long = "link")]
99	        link_token: String,
100	    },
101	    /// Set activity state busy|idle (also arms the echo-gate sentinel).
102	    State {
103	        #[arg(value_parser = ["busy", "idle"])]
104	        state: String,
105	        id: String,
106	        #[arg(long)]
107	        no_gate: bool,
108	        #[command(flatten)]
109	        auth: AuthFlags,
110	    },
111	    /// Manage the echo-gate sentinel directly.
112	    EchoGate {
113	        #[arg(value_parser = ["set", "clear"])]
114	        action: String,
115	        id: String,
116	        #[command(flatten)]
117	        auth: AuthFlags,
118	    },
119	    /// Drain delivered messages (hook channel). With `--link` this is
120	    /// the shell-flavored relay drain: the link token is the auth, and
121	    /// the drained rows are the
122	    /// shell's MAC-stamped command/text/file frames.
123	    Poll {
124	        id: String,
125	        #[arg(long)]
126	        include_deferred: bool,
127	        /// Shell link token (the relay command-receipt drain).
128	        #[arg(long)]
129	        link: Option<String>,
130	        #[command(flatten)]
131	        auth: AuthFlags,
132	    },
133	    /// Create a nested worker perch under a parent.
134	    WorkerStart {
135	        parent: String,
136	        id: String,
137	        #[command(flatten)]
138	        auth: AuthFlags,
139	    },
140	    /// Tear down a worker perch.
141	    WorkerStop {
142	        id: String,
143	        #[command(flatten)]
144	        auth: AuthFlags,
145	    },
146	    /// Drain a worker perch's messages.
147	    WorkerPoll {
148	        id: String,
149	        #[command(flatten)]
150	        auth: AuthFlags,
151	    },
152	    /// Rebind the perch to a new session_id, preserving identity (a context clear/compact boundary).
153	    Boundary {
154	        #[arg(value_parser = ["clear", "compact"])]
155	        mode: String,
156	        id: String,
157	        /// The new session id to rebind the perch to.
158	        #[arg(long = "to-session-id")]
159	        to_session: String,
160	        #[command(flatten)]
161	        auth: AuthFlags,
162	    },
163	    /// Soft teardown (spool/history preserved); `--erase` hard-wipes.
164	    SessionEnd {
165	        id: String,
166	        #[arg(long)]
167	        erase: bool,
168	        #[command(flatten)]
169	        auth: AuthFlags,
170	    },
171	    /// Report user/agent presence at this endpoint.
172	    Presence {
173	        id: String,
174	        #[command(flatten)]
175	        auth: AuthFlags,
176	    },
177	    /// Report which node (if any) is remote-driving this endpoint.
178	    DrivenBy {
179	        id: String,
180	        #[command(flatten)]
181	        auth: AuthFlags,
182	    },
183	    /// Append normalized history (body on stdin) to the native history store.
184	    HistoryLog {
185	        id: String,
186	        #[command(flatten)]
187	        auth: AuthFlags,
188	    },
189	    /// Emit a Shell sensory payload to the owner's **live** session.
190	    /// REST-only by definition: never spooled, dropped with a diagnostic when
191	    /// the owner isn't live. The link token is the auth.
192	    Emit {
193	        id: String,
194	        #[arg(long)]
195	        r#type: String,
196	        /// The sensory payload (descriptive text / encoded blob reference).
197	        payload: String,
198	        /// Shell link token (the per-link credential from launch).
199	        #[arg(long)]
200	        link: String,
201	    },
202	    /// Print the adapter's declared capability (`hostable_types`).
203	    Capability,
204	    /// Keyword hints: the full user message arrives on **stdin**; emit at most
205	    /// one matched hint line (declaration order, first unseen wins) for the
206	    /// adapter's context channel. The per-session seen-set fires each hint once
207	    /// per `--session` (a `/clear` = a new session = re-armed). Needs `--manifest`.
208	    Hint {
209	        /// The harness session id keying the once-per-session seen-set.
210	        #[arg(long)]
211	        session: String,
212	    },
213	    /// Graceful live-agent signoff: run the final context save BEFORE
214	    /// teardown, then soft-stop. The `spt shutdown` lifecycle path.
215	    Shutdown {
216	        id: String,
217	        #[command(flatten)]
218	        auth: AuthFlags,
219	    },
220	    /// A shell suspends its linked owner directly, bypassing agent comms
221	    /// — gated by the
222	    /// manifest `can_shutdown` pre-consent grant, fail-closed. The firing
223	    /// shell cascades offline with its siblings, by design.
224	    OwnerShutdown {
225	        /// The shell instance id (must match the link token's instance).
226	        id: String,
227	        /// Shell link token (the per-link credential from launch).
228	        #[arg(long)]
229	        link: String,
230	    },
231	}
232
233	/// Auth proof flags shared by every mutating subcommand.
234	#[derive(Args)]
235	pub struct AuthFlags {
236	    /// Capability token proving association to the target perch.
237	    #[arg(long)]
238	    token: Option<String>,
239	    /// Session id proving association (matches the perch's info.json).
240	    #[arg(long)]
241	    session_id: Option<String>,
242	}
243
244	impl AuthFlags {
245	    fn proof(&self) -> Proof {
246	        Proof::resolve(self.token.clone(), self.session_id.clone())
247	    }
248	}
249
250	/// Resolved per-call context shared by handlers.
251	pub struct Ctx {
252	    /// The `adapter_name` carried on this call.
253	    pub adapter: String,
254	    /// Parsed manifest, when `--manifest` was supplied (and valid).
255	    pub manifest: Option<Manifest>,
256	}
257
258	/// Auto-start the per-machine daemon if absent (REQ-DAEMON-3). Best-effort: a
259	/// start failure is surfaced but does not abort commands that don't need the
260	/// daemon (e.g. `capability`); the seed/listen path re-checks and fails loudly if
261	/// the daemon is genuinely unreachable.
262	// [impl->REQ-DAEMON-3]
263	fn ensure_daemon() {
264	    if let Err(e) = spt_daemon::ensure_running() {
265	        eprintln!("DAEMON_START_WARN: {e}");
266	    }
267	}
268
269	/// Parse + dispatch the `api` group. Returns the process exit code.
270	pub fn run(args: ApiArgs) -> i32 {
271	    // Any api invocation brings the daemon up if absent — the reliable anchor
272	    // every harness hook shares (REQ-DAEMON-3).
273	    ensure_daemon();
274
275	    // Resolve the manifest up front so a bad path/content fails fast and
276	    // uniformly, before any state mutation.
277	    let manifest = match &args.manifest {
278	        Some(path) => match load_manifest(path, &args.adapter) {
279	            Ok(m) => Some(m),
280	            Err(code) => return code,
281	        },
282	        None => None,
283	    };
284	    let ctx = Ctx {
285	        adapter: args.adapter,
286	        manifest,
287	    };
288
289	    match args.cmd {
290	        ApiCmd::Capability => reporting::cmd_capability(&ctx),
291	        ApiCmd::Hint { session } => reporting::cmd_hint(&ctx, &session),
292	        // Harness-hosted startup (T4). Seed establishes; listen consumes+binds;
293	        // bind is the post-spawn establishing call (intrinsic auth).
294	        ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),
295	        ApiCmd::Listen {
296	            id,
297	            parent_pid,
298	            once,
299	            subnet,
300	        } => startup::cmd_listen(
301	            &id,
302	            parent_pid,
303	            once,
304	            ctx.manifest.as_ref(),
305	            subnet.as_deref(),
306	        ),
307	        ApiCmd::BindShell { link_token } => {
308	            // The local-link handshake's receiving end (CONTEXT §local-link
309	            // authentication): possession of the minted token is the bind
310	            // credential; the perch found by it names owner + instance.
311	            // [impl->REQ-SHELL-1]
312	            match spt_daemon::shellhost::bind_shell_by_token(
313	                &spt_store::perch::owlery_dir(),
314	                &link_token,
315	            ) {
316	                Some((owner, shell_id)) => {
317	                    eprintln!("BOUND_SHELL:{shell_id} owner={owner} status=online");
318	                    0
319	                }
320	                None => {
321	                    eprintln!("BIND_SHELL_REFUSED: no instance holds this link token");
322	                    EXIT_REFUSED
323	                }
324	            }
325	        }
326	        ApiCmd::Bind {
327	            id,
328	            bind_session,
329	            subnet,
330	            endpoint_type,
331	            auth,
332	        } => {
333	            // The bind path's adapter source is the api group's own
334	            // --adapter (no seed on this path) — the adapter-at-creation rule.
335	            let adapter = Some(ctx.adapter.as_str()).filter(|a| !a.is_empty());
336	            startup::cmd_bind(
337	                &id,
338	                bind_session,
339	                auth.session_id,
340	                adapter,
341	                subnet.as_deref(),
342	                &endpoint_type,
343	            )
344	        }
345	        // Delivery + activity (T5), gated by auth.
346	        ApiCmd::State {
347	            state,
348	            id,
349	            no_gate,
350	            auth,
351	        } => gated(&id, &auth, |id| delivery::cmd_state(id, &state, no_gate)),
352	        ApiCmd::EchoGate { action, id, auth } => {
353	            gated(&id, &auth, |id| delivery::cmd_echo_gate(id, &action))
354	        }
355	        ApiCmd::Poll {
356	            id,
357	            include_deferred,
358	            link,
359	            auth,
360	        } => match link {
361	            // Shell relay drain: the link token IS the auth (no perch record
362	            // to authenticate against — shells carry ShellInfo, not InfoJson).
363	            // [impl->REQ-SHELL-1]
364	            Some(token) => delivery::cmd_poll_shell(&id, &token),
365	            None => gated(&id, &auth, |id| {
366	                delivery::cmd_poll(id, include_deferred, ctx.manifest.as_ref())
367	            }),
368	        },
369	        // Worker perches (T6). worker-start authenticates against the parent.
370	        ApiCmd::WorkerStart { parent, id, auth } => gated(&parent, &auth, |parent| {
371	            worker::cmd_worker_start(parent, &id)
372	        }),
373	        ApiCmd::WorkerStop { id, auth } => gated(&id, &auth, worker::cmd_worker_stop),
374	        ApiCmd::WorkerPoll { id, auth } => {
375	            gated(&id, &auth, |id| worker::cmd_worker_poll(id, false))
376	        }
377	        // Reporting + boundary (T7), gated by auth.
378	        ApiCmd::Boundary {
379	            mode,
380	            id,
381	            to_session,
382	            auth,
383	        } => gated(&id, &auth, |id| {
384	            reporting::cmd_boundary(id, &mode, &to_session)
385	        }),
386	        ApiCmd::SessionEnd { id, erase, auth } => {
387	            gated(&id, &auth, |id| reporting::cmd_session_end(id, erase))
388	        }
389	        ApiCmd::Presence { id, auth } => gated(&id, &auth, reporting::cmd_presence),
390	        ApiCmd::DrivenBy { id, auth } => gated(&id, &auth, reporting::cmd_driven_by),
391	        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
392	        ApiCmd::Shutdown { id, auth } => {
393	            let manifest = ctx.manifest.as_ref();
394	            gated(&id, &auth, |id| live::cmd_shutdown(id, manifest))
395	        }
396	        ApiCmd::Emit {
397	            id,
398	            r#type,
399	            payload,
400	            link,
401	        } => reporting::cmd_emit(&id, &r#type, &payload, &link),
402	        ApiCmd::OwnerShutdown { id, link } => reporting::cmd_owner_shutdown(&id, &link),
403	    }
404	}
405
406	/// Load + validate the adapter's runtime manifest and resolve the `--adapter`
407	/// option through the merged view. `--adapter` is a `<adapter>[:profile]`
408	/// composite (M9 survey #4/#12): the **parent** segment must match the file's
409	/// `adapter.name` (never the whole composite — a profile is not a separate
410	/// adapter), and the `:profile` overlay (shipped from the file's own
411	/// `[profiles]`, local from `adapters_dir`) is applied so every downstream
412	/// advertisement — hostable_types, `[env]`, `[session.notif]` (#5) — reflects
413	/// the profile. Returns an exit code on failure (already reported to stderr).
414	fn load_manifest(path: &PathBuf, adapter: &str) -> Result<Manifest, i32> {
415	    let text = std::fs::read_to_string(path).map_err(|e| {
416	        eprintln!("MANIFEST_READ:{}: {e}", path.display());
417	        EXIT_REFUSED
418	    })?;
419	    let manifest = Manifest::from_toml_str(&text).map_err(|e| {
420	        eprintln!("MANIFEST_INVALID:{e}");
421	        EXIT_REFUSED
422	    })?;
423	    let parent = spt_runtime::profile::split_option(adapter).0;
424	    if manifest.adapter.name != parent {
425	        eprintln!(
426	            "ADAPTER_MISMATCH: --adapter {adapter} but manifest declares {}",
427	            manifest.adapter.name
428	        );
429	        return Err(EXIT_REFUSED);
430	    }
431	    let adapters_dir = spt_store::perch::adapters_dir();
432	    spt_runtime::registry::resolve_option_onto(&manifest, &adapters_dir, adapter).map_err(|e| {
433	        eprintln!("PROFILE_UNRESOLVED: --adapter {adapter}: {e}");
434	        EXIT_REFUSED
435	    })
436	}
437
438	/// Run `f` only if the proof authenticates against `target_id`; otherwise print
439	/// the refusal and return [`EXIT_REFUSED`].
440	// [impl->REQ-HAZARD-LOCAL-API-AUTH]
441	fn gated<F: FnOnce(&str) -> i32>(target_id: &str, auth: &AuthFlags, f: F) -> i32 {
442	    match authenticate(target_id, &auth.proof()) {
443	        AuthResult::Ok => f(target_id),
444	        AuthResult::NoEndpoint => {
445	            eprintln!("NO_ENDPOINT:{target_id} has no perch to authenticate against");
446	            EXIT_REFUSED
447	        }
448	        AuthResult::Refused => {
449	            eprintln!("AUTH_REFUSED:{target_id} (need --token or matching --session-id)");
450	            EXIT_REFUSED
451	        }
452	    }
453	}
454
455	#[cfg(test)]
456	mod tests {
457	    use super::*;
458	    use clap::Parser;
459
460	    /// A tiny parent Cli wrapping just the api group, for parse tests.
461	    #[derive(Parser)]
462	    struct Probe {
463	        #[command(flatten)]
464	        api: ApiArgs,
465	    }
466
467	    fn parse(args: &[&str]) -> Result<Probe, clap::Error> {
468	        Probe::try_parse_from(args)
469	    }
470
471	    #[test] // [unit->REQ-API-1] adapter_name is mandatory on every api call.
472	    fn adapter_is_required() {
473	        assert!(parse(&["spt", "capability"]).is_err());
474	        assert!(parse(&["spt", "--adapter", "mock", "capability"]).is_ok());
475	    }
476
477	    // [unit->REQ-MANIFEST-2] --adapter is a `<adapter>[:profile]` composite: the
478	    // PARENT segment must match the file's adapter.name, and a shipped profile
479	    // overlays the runtime manifest so the advertised view (here hostable_types)
480	    // reflects the profile. Unknown profile + wrong parent both refuse.
481	    #[test]
482	    fn load_manifest_resolves_composite_adapter_option() {
483	        let _h = crate::testutil::isolated_home();
484	        let dir = spt_store::perch::spt_home().join("mh");
485	        std::fs::create_dir_all(&dir).unwrap();
486	        let path = dir.join("manifest.toml");
487	        std::fs::write(
488	            &path,
489	            "[adapter]\nname = \"mock-h\"\nkind = \"harness\"\nversion = \"1\"\n\
490	             min_spt_core_version = \"0\"\nhostable_types = [\"LiveAgent\"]\n\n\
491	             [profiles.full]\n[profiles.full.adapter]\nhostable_types = [\"LiveAgent\", \"Shell\"]\n",
492	        )
493	        .unwrap();
494
495	        // Bare: the parent's own advertisement.
496	        let bare = load_manifest(&path, "mock-h").expect("bare resolves");
497	        assert_eq!(bare.adapter.hostable_types, vec!["LiveAgent"]);
498
499	        // Composite: the profile overlay is what's advertised.
500	        let prof = load_manifest(&path, "mock-h:full").expect("profile resolves");
501	        assert_eq!(prof.adapter.hostable_types, vec!["LiveAgent", "Shell"]);
502
503	        // Wrong parent segment refuses (a profile is not a separate adapter).
504	        assert_eq!(load_manifest(&path, "other"), Err(EXIT_REFUSED));
505	        // Unknown profile refuses (no shipped name, no local file).
506	        assert_eq!(load_manifest(&path, "mock-h:ghost"), Err(EXIT_REFUSED));
507	    }
508
509	    #[test] // [unit->REQ-API-1] the surface parses its documented subcommands.
510	    fn surface_parses() {
511	        assert!(parse(&[
512	            "spt",
513	            "--adapter",
514	            "m",
515	            "seed",
516	            "--pid",
517	            "10",
518	            "--session-id",
519	            "s"
520	        ])
521	        .is_ok());
522	        assert!(parse(&["spt", "--adapter", "m", "listen", "alice"]).is_ok());
523	        assert!(parse(&["spt", "--adapter", "m", "state", "idle", "alice"]).is_ok());
524	        assert!(parse(&[
525	            "spt",
526	            "--adapter",
527	            "m",
528	            "poll",
529	            "alice",
530	            "--include-deferred"
531	        ])
532	        .is_ok());
533	        assert!(parse(&["spt", "--adapter", "m", "poll", "shell-0", "--link", "cafe"]).is_ok());
534	        assert!(parse(&[
535	            "spt",
536	            "--adapter",
537	            "m",
538	            "emit",
539	            "shell-0",
540	            "--type",
541	            "bumped",
542	            "wall",
543	            "--link",
544	            "cafe"
545	        ])
546	        .is_ok());
547	        assert!(
548	            parse(&[
549	                "spt",
550	                "--adapter",
551	                "m",
552	                "emit",
553	                "shell-0",
554	                "--type",
555	                "bumped",
556	                "wall"
557	            ])
558	            .is_err(),
559	            "emit requires the link credential"
560	        );
561	        assert!(parse(&["spt", "--adapter", "m", "worker-start", "alice", "alice-w1"]).is_ok());
562	        assert!(parse(&[
563	            "spt",
564	            "--adapter",
565	            "m",
566	            "boundary",
567	            "compact",
568	            "alice",
569	            "--to-session-id",
570	            "s2"
571	        ])
572	        .is_ok());
573	        assert!(parse(&["spt", "--adapter", "m", "session-end", "alice", "--erase"]).is_ok());
574	    }
575
576	    #[test] // [unit->REQ-API-1] closed-vocabulary args reject bad values.
577	    fn state_value_is_validated() {
578	        assert!(parse(&["spt", "--adapter", "m", "state", "sideways", "alice"]).is_err());
579	        assert!(parse(&[
580	            "spt",
581	            "--adapter",
582	            "m",
583	            "boundary",
584	            "explode",
585	            "alice",
586	            "--to-session-id",
587	            "s"
588	        ])
589	        .is_err());
590	    }
591	}
592

//! The inbound half of the harness contract: the `spt api <cmd>` surface a
//! harness's hooks fire to mutate on-disk SPT state. Every call carries
//! `--adapter <name>` (the `adapter_name`), and every *mutation* is gated by
//! [`auth`] (REQ-HAZARD-LOCAL-API-AUTH).
//!
//! Interim no-daemon model (decision 2026-06-01): these commands mutate the
//! perch/spool/registry directly via spt-store/spt-msg, mirroring M1's
//! standalone listener. M3's `spt-daemon` swaps the on-disk seed + standalone
//! `listen` for an in-memory daemon record — the surface here stays thin so
//! that is a move, not a rewrite.
//!
//! Exit codes (superset of the messaging surface): `0` ok · `1` runtime refusal
//! (auth refused, no endpoint, bad manifest) · `2` usage (clap).
//
// [impl->REQ-API-1]

pub mod auth;
pub mod delivery;
pub mod live;
pub mod reporting;
pub mod startup;
pub mod worker;

use clap::{Args, Subcommand};
use std::path::PathBuf;

use spt_runtime::Manifest;

use auth::{authenticate, AuthResult, Proof};

/// Exit code for a runtime refusal (auth/no-endpoint).
pub const EXIT_REFUSED: i32 = 1;

/// The `api` command group. `--adapter` is required on every invocation; an
/// optional `--manifest` is parsed for commands that need the adapter contract
/// (spawn roles, capability advertisement).
#[derive(Args)]
pub struct ApiArgs {
    /// adapter_name — the calling harness adapter. Required on every `api` call.
    #[arg(long)]
    adapter: String,
    /// Path to the adapter's runtime manifest (when the command needs it).
    #[arg(long)]
    manifest: Option<PathBuf>,
    #[command(subcommand)]
    cmd: ApiCmd,
}

/// The inbound surface. Lifecycle-execution commands (psyche/commune) are M2b;
/// these are the M2a harness-contract entry points.
#[derive(Subcommand)]
pub enum ApiCmd {
    /// Harness-hosted startup: record an ephemeral seed keyed by parent pid.
    Seed {
        #[arg(long)]
        pid: u32,
        #[arg(long)]
        session_id: String,
    },
    /// Consume a seed and hold the perch + relay loop (blocks).
    Listen {
        id: String,
        /// Override the parent-pid anchor (defaults to the self-discovered PPID).
        #[arg(long)]
        parent_pid: Option<u32>,
        /// Drain backlog + one receive cycle, then exit (testability).
        #[arg(long)]
        once: bool,
        /// Home subnet for a NEW endpoint (required on a multi-subnet
        /// node — home is assigned at creation, never guessed).
        #[arg(long)]
        subnet: Option<String>,
    },
    /// Post-spawn bind of a session to its perch.
    Bind {
        id: String,
        /// The session id discovered post-spawn, written into the perch record.
        #[arg(long = "set-session-id")]
        bind_session: Option<String>,
        /// Home subnet for a NEW endpoint (see `listen`).
        #[arg(long)]
        subnet: Option<String>,
        /// The endpoint type tag (info.json `state`). Defaults to `live_agent`
        /// (the agent host); a non-agent endpoint — e.g. a `gateway` — binds
        /// with its own open-type tag (REQ-EP-1/REQ-EP-6). A revive keeps the
        /// prior type unless this overrides it.
        #[arg(long = "type", default_value = "live_agent")]
        endpoint_type: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Shell-binary bind: the type=Shell flavor of `bind`. Resolves
    /// the instance **by link token alone** (the spawn template carries only
    /// `{link_token}` — "owner from the link") and flips it online. The
    /// credential IS the auth: no token, no bind.
    BindShell {
        /// The link token the broker minted at launch.
        #[arg(long = "link")]
        link_token: String,
    },
    /// Set activity state busy|idle (also arms the echo-gate sentinel).
    State {
        #[arg(value_parser = ["busy", "idle"])]
        state: String,
        id: String,
        #[arg(long)]
        no_gate: bool,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Manage the echo-gate sentinel directly.
    EchoGate {
        #[arg(value_parser = ["set", "clear"])]
        action: String,
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Drain delivered messages (hook channel). With `--link` this is
    /// the shell-flavored relay drain: the link token is the auth, and
    /// the drained rows are the
    /// shell's MAC-stamped command/text/file frames.
    Poll {
        id: String,
        #[arg(long)]
        include_deferred: bool,
        /// Shell link token (the relay command-receipt drain).
        #[arg(long)]
        link: Option<String>,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Create a nested worker perch under a parent.
    WorkerStart {
        parent: String,
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Tear down a worker perch.
    WorkerStop {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Drain a worker perch's messages.
    WorkerPoll {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Rebind the perch to a new session_id, preserving identity (a context clear/compact boundary).
    Boundary {
        #[arg(value_parser = ["clear", "compact"])]
        mode: String,
        id: String,
        /// The new session id to rebind the perch to.
        #[arg(long = "to-session-id")]
        to_session: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Soft teardown (spool/history preserved); `--erase` hard-wipes.
    SessionEnd {
        id: String,
        #[arg(long)]
        erase: bool,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Report user/agent presence at this endpoint.
    Presence {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Report which node (if any) is remote-driving this endpoint.
    DrivenBy {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Append normalized history (body on stdin) to the native history store.
    HistoryLog {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Emit a Shell sensory payload to the owner's **live** session.
    /// REST-only by definition: never spooled, dropped with a diagnostic when
    /// the owner isn't live. The link token is the auth.
    Emit {
        id: String,
        #[arg(long)]
        r#type: String,
        /// The sensory payload (descriptive text / encoded blob reference).
        payload: String,
        /// Shell link token (the per-link credential from launch).
        #[arg(long)]
        link: String,
    },
    /// Print the adapter's declared capability (`hostable_types`).
    Capability,
    /// Keyword hints: the full user message arrives on **stdin**; emit at most
    /// one matched hint line (declaration order, first unseen wins) for the
    /// adapter's context channel. The per-session seen-set fires each hint once
    /// per `--session` (a `/clear` = a new session = re-armed). Needs `--manifest`.
    Hint {
        /// The harness session id keying the once-per-session seen-set.
        #[arg(long)]
        session: String,
    },
    /// Graceful live-agent signoff: run the final context save BEFORE
    /// teardown, then soft-stop. The `spt shutdown` lifecycle path.
    Shutdown {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// A shell suspends its linked owner directly, bypassing agent comms
    /// — gated by the
    /// manifest `can_shutdown` pre-consent grant, fail-closed. The firing
    /// shell cascades offline with its siblings, by design.
    OwnerShutdown {
        /// The shell instance id (must match the link token's instance).
        id: String,
        /// Shell link token (the per-link credential from launch).
        #[arg(long)]
        link: String,
    },
}

/// Auth proof flags shared by every mutating subcommand.
#[derive(Args)]
pub struct AuthFlags {
    /// Capability token proving association to the target perch.
    #[arg(long)]
    token: Option<String>,
    /// Session id proving association (matches the perch's info.json).
    #[arg(long)]
    session_id: Option<String>,
}

impl AuthFlags {
    fn proof(&self) -> Proof {
        Proof::resolve(self.token.clone(), self.session_id.clone())
    }
}

/// Resolved per-call context shared by handlers.
pub struct Ctx {
    /// The `adapter_name` carried on this call.
    pub adapter: String,
    /// Parsed manifest, when `--manifest` was supplied (and valid).
    pub manifest: Option<Manifest>,
}

/// Auto-start the per-machine daemon if absent (REQ-DAEMON-3). Best-effort: a
/// start failure is surfaced but does not abort commands that don't need the
/// daemon (e.g. `capability`); the seed/listen path re-checks and fails loudly if
/// the daemon is genuinely unreachable.
// [impl->REQ-DAEMON-3]
fn ensure_daemon() {
    if let Err(e) = spt_daemon::ensure_running() {
        eprintln!("DAEMON_START_WARN: {e}");
    }
}

/// Parse + dispatch the `api` group. Returns the process exit code.
pub fn run(args: ApiArgs) -> i32 {
    // Any api invocation brings the daemon up if absent — the reliable anchor
    // every harness hook shares (REQ-DAEMON-3).
    ensure_daemon();

    // Resolve the manifest up front so a bad path/content fails fast and
    // uniformly, before any state mutation.
    let manifest = match &args.manifest {
        Some(path) => match load_manifest(path, &args.adapter) {
            Ok(m) => Some(m),
            Err(code) => return code,
        },
        None => None,
    };
    let ctx = Ctx {
        adapter: args.adapter,
        manifest,
    };

    match args.cmd {
        ApiCmd::Capability => reporting::cmd_capability(&ctx),
        ApiCmd::Hint { session } => reporting::cmd_hint(&ctx, &session),
        // Harness-hosted startup (T4). Seed establishes; listen consumes+binds;
        // bind is the post-spawn establishing call (intrinsic auth).
        ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),
        ApiCmd::Listen {
            id,
            parent_pid,
            once,
            subnet,
        } => startup::cmd_listen(
            &id,
            parent_pid,
            once,
            ctx.manifest.as_ref(),
            subnet.as_deref(),
        ),
        ApiCmd::BindShell { link_token } => {
            // The local-link handshake's receiving end (CONTEXT §local-link
            // authentication): possession of the minted token is the bind
            // credential; the perch found by it names owner + instance.
            // [impl->REQ-SHELL-1]
            match spt_daemon::shellhost::bind_shell_by_token(
                &spt_store::perch::owlery_dir(),
                &link_token,
            ) {
                Some((owner, shell_id)) => {
                    eprintln!("BOUND_SHELL:{shell_id} owner={owner} status=online");
                    0
                }
                None => {
                    eprintln!("BIND_SHELL_REFUSED: no instance holds this link token");
                    EXIT_REFUSED
                }
            }
        }
        ApiCmd::Bind {
            id,
            bind_session,
            subnet,
            endpoint_type,
            auth,
        } => {
            // The bind path's adapter source is the api group's own
            // --adapter (no seed on this path) — the adapter-at-creation rule.
            let adapter = Some(ctx.adapter.as_str()).filter(|a| !a.is_empty());
            startup::cmd_bind(
                &id,
                bind_session,
                auth.session_id,
                adapter,
                subnet.as_deref(),
                &endpoint_type,
            )
        }
        // Delivery + activity (T5), gated by auth.
        ApiCmd::State {
            state,
            id,
            no_gate,
            auth,
        } => gated(&id, &auth, |id| delivery::cmd_state(id, &state, no_gate)),
        ApiCmd::EchoGate { action, id, auth } => {
            gated(&id, &auth, |id| delivery::cmd_echo_gate(id, &action))
        }
        ApiCmd::Poll {
            id,
            include_deferred,
            link,
            auth,
        } => match link {
            // Shell relay drain: the link token IS the auth (no perch record
            // to authenticate against — shells carry ShellInfo, not InfoJson).
            // [impl->REQ-SHELL-1]
            Some(token) => delivery::cmd_poll_shell(&id, &token),
            None => gated(&id, &auth, |id| {
                delivery::cmd_poll(id, include_deferred, ctx.manifest.as_ref())
            }),
        },
        // Worker perches (T6). worker-start authenticates against the parent.
        ApiCmd::WorkerStart { parent, id, auth } => gated(&parent, &auth, |parent| {
            worker::cmd_worker_start(parent, &id)
        }),
        ApiCmd::WorkerStop { id, auth } => gated(&id, &auth, worker::cmd_worker_stop),
        ApiCmd::WorkerPoll { id, auth } => {
            gated(&id, &auth, |id| worker::cmd_worker_poll(id, false))
        }
        // Reporting + boundary (T7), gated by auth.
        ApiCmd::Boundary {
            mode,
            id,
            to_session,
            auth,
        } => gated(&id, &auth, |id| {
            reporting::cmd_boundary(id, &mode, &to_session)
        }),
        ApiCmd::SessionEnd { id, erase, auth } => {
            gated(&id, &auth, |id| reporting::cmd_session_end(id, erase))
        }
        ApiCmd::Presence { id, auth } => gated(&id, &auth, reporting::cmd_presence),
        ApiCmd::DrivenBy { id, auth } => gated(&id, &auth, reporting::cmd_driven_by),
        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
        ApiCmd::Shutdown { id, auth } => {
            let manifest = ctx.manifest.as_ref();
            gated(&id, &auth, |id| live::cmd_shutdown(id, manifest))
        }
        ApiCmd::Emit {
            id,
            r#type,
            payload,
            link,
        } => reporting::cmd_emit(&id, &r#type, &payload, &link),
        ApiCmd::OwnerShutdown { id, link } => reporting::cmd_owner_shutdown(&id, &link),
    }
}

/// Load + validate the adapter's runtime manifest and resolve the `--adapter`
/// option through the merged view. `--adapter` is a `<adapter>[:profile]`
/// composite (M9 survey #4/#12): the **parent** segment must match the file's
/// `adapter.name` (never the whole composite — a profile is not a separate
/// adapter), and the `:profile` overlay (shipped from the file's own
/// `[profiles]`, local from `adapters_dir`) is applied so every downstream
/// advertisement — hostable_types, `[env]`, `[session.notif]` (#5) — reflects
/// the profile. Returns an exit code on failure (already reported to stderr).
fn load_manifest(path: &PathBuf, adapter: &str) -> Result<Manifest, i32> {
    let text = std::fs::read_to_string(path).map_err(|e| {
        eprintln!("MANIFEST_READ:{}: {e}", path.display());
        EXIT_REFUSED
    })?;
    let manifest = Manifest::from_toml_str(&text).map_err(|e| {
        eprintln!("MANIFEST_INVALID:{e}");
        EXIT_REFUSED
    })?;
    let parent = spt_runtime::profile::split_option(adapter).0;
    if manifest.adapter.name != parent {
        eprintln!(
            "ADAPTER_MISMATCH: --adapter {adapter} but manifest declares {}",
            manifest.adapter.name
        );
        return Err(EXIT_REFUSED);
    }
    let adapters_dir = spt_store::perch::adapters_dir();
    spt_runtime::registry::resolve_option_onto(&manifest, &adapters_dir, adapter).map_err(|e| {
        eprintln!("PROFILE_UNRESOLVED: --adapter {adapter}: {e}");
        EXIT_REFUSED
    })
}

/// Run `f` only if the proof authenticates against `target_id`; otherwise print
/// the refusal and return [`EXIT_REFUSED`].
// [impl->REQ-HAZARD-LOCAL-API-AUTH]
fn gated<F: FnOnce(&str) -> i32>(target_id: &str, auth: &AuthFlags, f: F) -> i32 {
    match authenticate(target_id, &auth.proof()) {
        AuthResult::Ok => f(target_id),
        AuthResult::NoEndpoint => {
            eprintln!("NO_ENDPOINT:{target_id} has no perch to authenticate against");
            EXIT_REFUSED
        }
        AuthResult::Refused => {
            eprintln!("AUTH_REFUSED:{target_id} (need --token or matching --session-id)");
            EXIT_REFUSED
        }
    }
}

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

    /// A tiny parent Cli wrapping just the api group, for parse tests.
    #[derive(Parser)]
    struct Probe {
        #[command(flatten)]
        api: ApiArgs,
    }

    fn parse(args: &[&str]) -> Result<Probe, clap::Error> {
        Probe::try_parse_from(args)
    }

    #[test] // [unit->REQ-API-1] adapter_name is mandatory on every api call.
    fn adapter_is_required() {
        assert!(parse(&["spt", "capability"]).is_err());
        assert!(parse(&["spt", "--adapter", "mock", "capability"]).is_ok());
    }

    // [unit->REQ-MANIFEST-2] --adapter is a `<adapter>[:profile]` composite: the
    // PARENT segment must match the file's adapter.name, and a shipped profile
    // overlays the runtime manifest so the advertised view (here hostable_types)
    // reflects the profile. Unknown profile + wrong parent both refuse.
    #[test]
    fn load_manifest_resolves_composite_adapter_option() {
        let _h = crate::testutil::isolated_home();
        let dir = spt_store::perch::spt_home().join("mh");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("manifest.toml");
        std::fs::write(
            &path,
            "[adapter]\nname = \"mock-h\"\nkind = \"harness\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\nhostable_types = [\"LiveAgent\"]\n\n\
             [profiles.full]\n[profiles.full.adapter]\nhostable_types = [\"LiveAgent\", \"Shell\"]\n",
        )
        .unwrap();

        // Bare: the parent's own advertisement.
        let bare = load_manifest(&path, "mock-h").expect("bare resolves");
        assert_eq!(bare.adapter.hostable_types, vec!["LiveAgent"]);

        // Composite: the profile overlay is what's advertised.
        let prof = load_manifest(&path, "mock-h:full").expect("profile resolves");
        assert_eq!(prof.adapter.hostable_types, vec!["LiveAgent", "Shell"]);

        // Wrong parent segment refuses (a profile is not a separate adapter).
        assert_eq!(load_manifest(&path, "other"), Err(EXIT_REFUSED));
        // Unknown profile refuses (no shipped name, no local file).
        assert_eq!(load_manifest(&path, "mock-h:ghost"), Err(EXIT_REFUSED));
    }

    #[test] // [unit->REQ-API-1] the surface parses its documented subcommands.
    fn surface_parses() {
        assert!(parse(&[
            "spt",
            "--adapter",
            "m",
            "seed",
            "--pid",
            "10",
            "--session-id",
            "s"
        ])
        .is_ok());
        assert!(parse(&["spt", "--adapter", "m", "listen", "alice"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "state", "idle", "alice"]).is_ok());
        assert!(parse(&[
            "spt",
            "--adapter",
            "m",
            "poll",
            "alice",
            "--include-deferred"
        ])
        .is_ok());
        assert!(parse(&["spt", "--adapter", "m", "poll", "shell-0", "--link", "cafe"]).is_ok());
        assert!(parse(&[
            "spt",
            "--adapter",
            "m",
            "emit",
            "shell-0",
            "--type",
            "bumped",
            "wall",
            "--link",
            "cafe"
        ])
        .is_ok());
        assert!(
            parse(&[
                "spt",
                "--adapter",
                "m",
                "emit",
                "shell-0",
                "--type",
                "bumped",
                "wall"
            ])
            .is_err(),
            "emit requires the link credential"
        );
        assert!(parse(&["spt", "--adapter", "m", "worker-start", "alice", "alice-w1"]).is_ok());
        assert!(parse(&[
            "spt",
            "--adapter",
            "m",
            "boundary",
            "compact",
            "alice",
            "--to-session-id",
            "s2"
        ])
        .is_ok());
        assert!(parse(&["spt", "--adapter", "m", "session-end", "alice", "--erase"]).is_ok());
    }

    #[test] // [unit->REQ-API-1] closed-vocabulary args reject bad values.
    fn state_value_is_validated() {
        assert!(parse(&["spt", "--adapter", "m", "state", "sideways", "alice"]).is_err());
        assert!(parse(&[
            "spt",
            "--adapter",
            "m",
            "boundary",
            "explode",
            "alice",
            "--to-session-id",
            "s"
        ])
        .is_err());
    }
}


C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-182-    },
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-183-    /// Append normalized history (body on stdin) to the native history store.
crates\spt\src\api\mod.rs:184:    HistoryLog {
crates\spt\src\api\mod.rs-185-        id: String,
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-186-        #[command(flatten)]
--
crates\spt\src\api\mod.rs-389-        ApiCmd::Presence { id, auth } => gated(&id, &auth, reporting::cmd_presence),
crates\spt\src\api\mod.rs-390-        ApiCmd::DrivenBy { id, auth } => gated(&id, &auth, reporting::cmd_driven_by),
crates\spt\src\api\mod.rs:391:        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
crates\spt\src\api\mod.rs-392-        ApiCmd::Shutdown { id, auth } => {
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs-393-            let manifest = ctx.manifest.as_ref();
--
crates\spt\src\api\reporting.rs-1-\! Reporting + boundary surface: `api boundary`, `api session-end`,
crates\spt\src\api\reporting.rs:2://! `api presence`, `api history-log`, `api emit`, plus the M2a *declarations*
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-3-//! for commune/signoff file-drops and adapter env aliases.
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-4-//!
--
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-11-//! - **presence** (REQ-API-2) — record `(endpoint, ts)`; no content (CONTEXT
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-12-//!   §presence is privacy-preserving).
crates\spt\src\api\reporting.rs:13://! - **history-log** (REQ-API-2) — Path-B native store: append a normalized
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-14-//!   record to the perch's history log.
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-15-//! - **emit** (REQ-API-2) — Shell sensory push; a stub here (concrete Shells M5).
--
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-228-}
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-229-
crates\spt\src\api\reporting.rs:230:/// `api history-log <id>` — Path-B ingest: read a normalized record from stdin
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-231-/// and append it to the native store.
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-232-// [impl->REQ-API-2]
crates\spt\src\api\reporting.rs:233:pub fn cmd_history_log(id: &str) -> i32 {
crates\spt\src\api\reporting.rs-234-    use std::io::Read;
crates\spt\src\api\reporting.rs-235-    let mut record = String::new();
crates\spt\src\api\reporting.rs-236-    let _ = std::io::stdin().read_to_string(&mut record);
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-237-    if record.trim().is_empty() {
crates\spt\src\api\reporting.rs:238:        eprintln!("EMPTY_RECORD: history-log read nothing on stdin");
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-239-        return EXIT_REFUSED;
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-240-    }
--
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-658-    }
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-659-
crates\spt\src\api\reporting.rs:660:    #[test] // [unit->REQ-API-2] history-log appends to the Path-B native store.
crates\spt\src\api\reporting.rs:661:    fn history_log_appends() {
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-662-        let _h = isolated_home();
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\reporting.rs-663-        establish("alice", "sid");


18:spt-daemon = { path = "../spt-daemon" }
66:spt-daemon = { path = "../spt-daemon" }


`spt` deps `spt-daemon` but not `spt-term` directly. The door validates against the published contract via `record_to_tagged`, so add the direct dep. Add the store sink (`digest.log`, separate from the opaque transcript), the `api digest-entry` handler, and wire dispatch.


12	[dependencies]
13	spt-proto = { path = "../spt-proto" }
14	spt-store = { path = "../spt-store" }
15	spt-msg = { path = "../spt-msg" }
16	spt-runtime = { path = "../spt-runtime" }
17	spt-live = { path = "../spt-live" }
18	spt-daemon = { path = "../spt-daemon" }
19	# The reference binary ships the WAN transport on by default (ADR-0002). D2g reuses its
20	# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing
21	# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.
22	spt-net = { path = "../spt-net" }
23	# `wrap_help`: wrap long help text to the terminal width with a hanging indent

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
# The reference binary ships the WAN transport on by default (ADR-0002). D2g reuses its
# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing
# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.
spt-net = { path = "../spt-net" }
# `wrap_help`: wrap long help text to the terminal width with a hanging indent


Edit

spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }

spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
# The `api digest-entry` push door validates the inbound record against the
# published digest-record contract (`spt_term::record_to_tagged`) — REQ-TERM-4.
spt-term = { path = "../spt-term" }


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

spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
# The `api digest-entry` push door validates the inbound record against the
# published digest-record contract (`spt_term::record_to_tagged`) — REQ-TERM-4.
spt-term = { path = "../spt-term" }

[package]
name = "spt"
version.workspace = true
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
# The reference binary ships the WAN transport on by default (ADR-0002). D2g reuses its
# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing
# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.
spt-net = { path = "../spt-net" }
# `wrap_help`: wrap long help text to the terminal width with a hanging indent
# (without it clap emits unwrapped lines and the terminal hard-wraps to col 0).
clap = { version = "4", features = ["derive", "wrap_help"] }
# The WAN send leg (D9-1-4) serializes the dial address + reads registry
# snapshots - same pinned shape as spt-daemon's.
serde_json = { version = "1", features = ["preserve_order"] }
# Terminal QR render for `spt subnet create` (M7 plan decision 11): the
# otpauth:// URI as unicode half-blocks. default-features off drops the
# image/svg renderers — the unicode renderer is all the CLI needs.
qrcode = { version = "0.14", default-features = false }
# Local-timezone rendering of the pairing code's expiry instant (std has no
# local tz). Clock feature only — no serde, no wasm.
chrono = { version = "0.4", default-features = false, features = ["clock"] }
# `spt update fetch` (REQ-UPD-7): HTTPS GET of the signed release from the
# GitHub origin. Blocking client + rustls (no OpenSSL); already in the tree
# transitively (iroh), so no new vendor. The fetch runs in the CLI, NOT the
# daemon — the daemon keeps zero HTTP surface (iroh QUIC only).
reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] }

# OS privilege-elevation probe for the node-bound code fetch (REQ-PAIR-6). Only the
# binary pulls the OS deps; the core stays dep-minimal. Matches the workspace's existing
# libc/windows-sys usage (spt-msg, spt-store) rather than the heavyweight `windows`.
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
    "Win32_Foundation",
    "Win32_Security",
    "Win32_System_Threading",
] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"
# install_e2e stages the built binary as a release asset (SHA256SUMS) for the
# D2 installer-script rung — same sha2 the workspace already carries.
sha2 = "0.10"
# The D7-1 process-level survival int-test (brain_survive.rs) drives the
# production supervisor + an in-process broker/NetHost while spawning the REAL
# `spt daemon brain` child (CARGO_BIN_EXE_spt). Integration tests don't inherit
# the lib's normal deps, so the daemon/net/identity surfaces it touches directly
# are re-declared here (the spt-daemon dev-dep pattern).
spt-daemon = { path = "../spt-daemon" }
spt-net = { path = "../spt-net" }
spt-proto = { path = "../spt-proto" }

+# The `api digest-entry` push door validates the inbound record against the

+# published digest-record contract (`spt_term::record_to_tagged`) — REQ-TERM-4.


Now the store sink — generalize `append_record`, add `append_digest_entry` → `digest.log`:


//! Two callers append here today: `api history-log` (an adapter pushing a
//! normalized conversation record, REQ-API-2) and the M3b PTY-digest's opt-in
//! "Option (C)" persistence (a coarse activity-log record per completed turn,
//! ADR-0008 / REQ-TERM-4). Both want the same primitive, and it must live below
//! the `spt` binary (the daemon, which is below `spt`, persists digests) — so it
//! sits here in the store layer, the lowest common home.
// [impl->REQ-API-2]

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

/// Append a single normalized `record` to `<perch_path>/history.log`, creating
/// the perch dir + file if absent. A trailing newline is ensured (the store is
/// one record per line). Returns the number of bytes written (record + newline).
pub fn append_record(perch_path: &Path, record: &str) -> std::io::Result<usize> {
    std::fs::create_dir_all(perch_path)?;
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(perch_path.join("history.log"))?;
    let line = if record.ends_with('\n') {
        record.to_string()
    } else {
        format!("{record}\n")
    };
    f.write_all(line.as_bytes())?;
    Ok(line.len())
}

//! Callers append here today: `api history-log` (an adapter pushing a
//! normalized conversation record, REQ-API-2), the digest's opt-in "Option (C)"
//! persistence (a coarse activity-log record per completed turn, ADR-0008 /
//! REQ-TERM-4), and `api digest-entry` (a log-less adapter pushing a digest-record
//! to the **separate** `digest.log` the projection tails — REQ-TERM-4). All want
//! the same append primitive, and it must live below the `spt` binary (the
//! daemon, which is below `spt`, persists digests) — so it sits here in the store
//! layer, the lowest common home.
// [impl->REQ-API-2]

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

/// Append a single normalized `record` to `<perch_path>/history.log` — the opaque
/// transcript store echo/resume consume. Returns the bytes written.
pub fn append_record(perch_path: &Path, record: &str) -> std::io::Result<usize> {
    append_named(perch_path, "history.log", record)
}

/// Append a single digest-record (the published contract line) to
/// `<perch_path>/digest.log` — the log-less-adapter digest source the projection
/// tails (`api digest-entry`, ADR-0008 amendment). Kept **separate** from
/// `history.log` so pushed digest records never pollute the opaque transcript the
/// echo-commune/resume pipeline reads. Returns the bytes written.
// [impl->REQ-TERM-4]
pub fn append_digest_entry(perch_path: &Path, record: &str) -> std::io::Result<usize> {
    append_named(perch_path, "digest.log", record)
}

/// Append one newline-delimited `record` to `<perch_path>/<name>`, creating the
/// perch dir + file if absent. A trailing newline is ensured (one record per
/// line). Returns the number of bytes written (record + newline).
fn append_named(perch_path: &Path, name: &str, record: &str) -> std::io::Result<usize> {
    std::fs::create_dir_all(perch_path)?;
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(perch_path.join(name))?;
    let line = if record.ends_with('\n') {
        record.to_string()
    } else {
        format!("{record}\n")
    };
    f.write_all(line.as_bytes())?;
    Ok(line.len())
}


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

//! Callers append here today: `api history-log` (an adapter pushing a
//! normalized conversation record, REQ-API-2), the digest's opt-in "Option (C)"
//! persistence (a coarse activity-log record per completed turn, ADR-0008 /
//! REQ-TERM-4), and `api digest-entry` (a log-less adapter pushing a digest-record
//! to the **separate** `digest.log` the projection tails — REQ-TERM-4). All want
//! the same append primitive, and it must live below the `spt` binary (the
//! daemon, which is below `spt`, persists digests) — so it sits here in the store
//! layer, the lowest common home.
// [impl->REQ-API-2]

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

/// Append a single normalized `record` to `<perch_path>/history.log` — the opaque
/// transcript store echo/resume consume. Returns the bytes written.
pub fn append_record(perch_path: &Path, record: &str) -> std::io::Result<usize> {
    append_named(perch_path, "history.log", record)
}

/// Append a single digest-record (the published contract line) to
/// `<perch_path>/digest.log` — the log-less-adapter digest source the projection
/// tails (`api digest-entry`, ADR-0008 amendment). Kept **separate** from
/// `history.log` so pushed digest records never pollute the opaque transcript the
/// echo-commune/resume pipeline reads. Returns the bytes written.
// [impl->REQ-TERM-4]
pub fn append_digest_entry(perch_path: &Path, record: &str) -> std::io::Result<usize> {
    append_named(perch_path, "digest.log", record)
}

/// Append one newline-delimited `record` to `<perch_path>/<name>`, creating the
/// perch dir + file if absent. A trailing newline is ensured (one record per
/// line). Returns the number of bytes written (record + newline).
fn append_named(perch_path: &Path, name: &str, record: &str) -> std::io::Result<usize> {
    std::fs::create_dir_all(perch_path)?;
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(perch_path.join(name))?;
    let line = if record.ends_with('\n') {
        record.to_string()
    } else {
        format!("{record}\n")
    };
    f.write_all(line.as_bytes())?;
    Ok(line.len())
}

//! Path-B native history store — the append-only `history.log` under a perch.
//!
//! Path B (CONTEXT §history subsystem) is the *native* transcript strategy: the
//! adapter (or spt-core itself) pushes already-normalized records and spt-core
//! stores them, rather than locating + parsing a version-fragile harness
//! transcript. The store is deliberately trivial — one newline-delimited record
//! per line, append-only — so it is a commodity store operation, not logic.
//!
//! Two callers append here today: `api history-log` (an adapter pushing a
//! normalized conversation record, REQ-API-2) and the M3b PTY-digest's opt-in
//! "Option (C)" persistence (a coarse activity-log record per completed turn,
//! ADR-0008 / REQ-TERM-4). Both want the same primitive, and it must live below
//! the `spt` binary (the daemon, which is below `spt`, persists digests) — so it
//! sits here in the store layer, the lowest common home.
// [impl->REQ-API-2]

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

/// Append a single normalized `record` to `<perch_path>/history.log`, creating
/// the perch dir + file if absent. A trailing newline is ensured (the store is
/// one record per line). Returns the number of bytes written (record + newline).
pub fn append_record(perch_path: &Path, record: &str) -> std::io::Result<usize> {
    std::fs::create_dir_all(perch_path)?;
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(perch_path.join("history.log"))?;
    let line = if record.ends_with('\n') {
        record.to_string()
    } else {
        format!("{record}\n")
    };
    f.write_all(line.as_bytes())?;
    Ok(line.len())
}

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

    // [unit->REQ-API-2] records append one-per-line, file auto-created.
    #[test]
    fn appends_one_record_per_line() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        append_record(&perch, "{\"role\":\"user\"}").unwrap();
        append_record(&perch, "{\"role\":\"assistant\"}\n").unwrap();
        let log = std::fs::read_to_string(perch.join("history.log")).unwrap();
        assert_eq!(log.lines().count(), 2, "two records, two lines");
        assert!(log.lines().all(|l| !l.is_empty()));
    }
}

-//! Two callers append here today: `api history-log` (an adapter pushing a

-//! normalized conversation record, REQ-API-2) and the M3b PTY-digest's opt-in

-//! "Option (C)" persistence (a coarse activity-log record per completed turn,

-//! ADR-0008 / REQ-TERM-4). Both want the same primitive, and it must live below

-//! the `spt` binary (the daemon, which is below `spt`, persists digests) — so it

-//! sits here in the store layer, the lowest common home.

+//! Callers append here today: `api history-log` (an adapter pushing a

+//! normalized conversation record, REQ-API-2), the digest's opt-in "Option (C)"

+//! persistence (a coarse activity-log record per completed turn, ADR-0008 /

+//! REQ-TERM-4), and `api digest-entry` (a log-less adapter pushing a digest-record

+//! to the **separate** `digest.log` the projection tails — REQ-TERM-4). All want

+//! the same append primitive, and it must live below the `spt` binary (the

+//! daemon, which is below `spt`, persists digests) — so it sits here in the store

-/// Append a single normalized `record` to `<perch_path>/history.log`, creating

-/// the perch dir + file if absent. A trailing newline is ensured (the store is

-/// one record per line). Returns the number of bytes written (record + newline).

+/// Append a single normalized `record` to `<perch_path>/history.log` — the opaque

+/// transcript store echo/resume consume. Returns the bytes written.

+    append_named(perch_path, "history.log", record)

+/// Append a single digest-record (the published contract line) to

+/// `<perch_path>/digest.log` — the log-less-adapter digest source the projection

+/// tails (`api digest-entry`, ADR-0008 amendment). Kept **separate** from

+/// `history.log` so pushed digest records never pollute the opaque transcript the

+/// echo-commune/resume pipeline reads. Returns the bytes written.

+pub fn append_digest_entry(perch_path: &Path, record: &str) -> std::io::Result<usize> {

+    append_named(perch_path, "digest.log", record)

+/// Append one newline-delimited `record` to `<perch_path>/<name>`, creating the

+/// perch dir + file if absent. A trailing newline is ensured (one record per

+/// line). Returns the number of bytes written (record + newline).

+fn append_named(perch_path: &Path, name: &str, record: &str) -> std::io::Result<usize> {

-        .open(perch_path.join("history.log"))?;


    // [unit->REQ-API-2] records append one-per-line, file auto-created.
    #[test]
    fn appends_one_record_per_line() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        append_record(&perch, "{\"role\":\"user\"}").unwrap();
        append_record(&perch, "{\"role\":\"assistant\"}\n").unwrap();
        let log = std::fs::read_to_string(perch.join("history.log")).unwrap();
        assert_eq!(log.lines().count(), 2, "two records, two lines");
        assert!(log.lines().all(|l| !l.is_empty()));
    }
}

    // [unit->REQ-API-2] records append one-per-line, file auto-created.
    #[test]
    fn appends_one_record_per_line() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        append_record(&perch, "{\"role\":\"user\"}").unwrap();
        append_record(&perch, "{\"role\":\"assistant\"}\n").unwrap();
        let log = std::fs::read_to_string(perch.join("history.log")).unwrap();
        assert_eq!(log.lines().count(), 2, "two records, two lines");
        assert!(log.lines().all(|l| !l.is_empty()));
    }

    // [unit->REQ-TERM-4] digest entries land in a SEPARATE digest.log, never the
    // transcript history.log (no cross-pollution of the opaque transcript store).
    #[test]
    fn digest_entries_go_to_a_separate_log() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        append_digest_entry(&perch, "{\"role\":\"input\",\"text\":\"hi\"}").unwrap();
        append_digest_entry(&perch, "{\"role\":\"agent\",\"text\":\"yo\"}").unwrap();
        let digest = std::fs::read_to_string(perch.join("digest.log")).unwrap();
        assert_eq!(digest.lines().count(), 2);
        assert!(
            !perch.join("history.log").exists(),
            "digest entries must not touch the transcript store"
        );
    }
}


    // [unit->REQ-API-2] records append one-per-line, file auto-created.
    #[test]
    fn appends_one_record_per_line() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        append_record(&perch, "{\"role\":\"user\"}").unwrap();
        append_record(&perch, "{\"role\":\"assistant\"}\n").unwrap();
        let log = std::fs::read_to_string(perch.join("history.log")).unwrap();
        assert_eq!(log.lines().count(), 2, "two records, two lines");
        assert!(log.lines().all(|l| !l.is_empty()));
    }

    // [unit->REQ-TERM-4] digest entries land in a SEPARATE digest.log, never the
    // transcript history.log (no cross-pollution of the opaque transcript store).
    #[test]
    fn digest_entries_go_to_a_separate_log() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        append_digest_entry(&perch, "{\"role\":\"input\",\"text\":\"hi\"}").unwrap();
        append_digest_entry(&perch, "{\"role\":\"agent\",\"text\":\"yo\"}").unwrap();
        let digest = std::fs::read_to_string(perch.join("digest.log")).unwrap();
        assert_eq!(digest.lines().count(), 2);
        assert!(
            !perch.join("history.log").exists(),
            "digest entries must not touch the transcript store"
        );
    }
}

//! Path-B native history store — the append-only `history.log` under a perch.
//!
//! Path B (CONTEXT §history subsystem) is the *native* transcript strategy: the
//! adapter (or spt-core itself) pushes already-normalized records and spt-core
//! stores them, rather than locating + parsing a version-fragile harness
//! transcript. The store is deliberately trivial — one newline-delimited record
//! per line, append-only — so it is a commodity store operation, not logic.
//!
//! Callers append here today: `api history-log` (an adapter pushing a
//! normalized conversation record, REQ-API-2), the digest's opt-in "Option (C)"
//! persistence (a coarse activity-log record per completed turn, ADR-0008 /
//! REQ-TERM-4), and `api digest-entry` (a log-less adapter pushing a digest-record
//! to the **separate** `digest.log` the projection tails — REQ-TERM-4). All want
//! the same append primitive, and it must live below the `spt` binary (the
//! daemon, which is below `spt`, persists digests) — so it sits here in the store
//! layer, the lowest common home.
// [impl->REQ-API-2]

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

/// Append a single normalized `record` to `<perch_path>/history.log` — the opaque
/// transcript store echo/resume consume. Returns the bytes written.
pub fn append_record(perch_path: &Path, record: &str) -> std::io::Result<usize> {
    append_named(perch_path, "history.log", record)
}

/// Append a single digest-record (the published contract line) to
/// `<perch_path>/digest.log` — the log-less-adapter digest source the projection
/// tails (`api digest-entry`, ADR-0008 amendment). Kept **separate** from
/// `history.log` so pushed digest records never pollute the opaque transcript the
/// echo-commune/resume pipeline reads. Returns the bytes written.
// [impl->REQ-TERM-4]
pub fn append_digest_entry(perch_path: &Path, record: &str) -> std::io::Result<usize> {
    append_named(perch_path, "digest.log", record)
}

/// Append one newline-delimited `record` to `<perch_path>/<name>`, creating the
/// perch dir + file if absent. A trailing newline is ensured (one record per
/// line). Returns the number of bytes written (record + newline).
fn append_named(perch_path: &Path, name: &str, record: &str) -> std::io::Result<usize> {
    std::fs::create_dir_all(perch_path)?;
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(perch_path.join(name))?;
    let line = if record.ends_with('\n') {
        record.to_string()
    } else {
        format!("{record}\n")
    };
    f.write_all(line.as_bytes())?;
    Ok(line.len())
}

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

    // [unit->REQ-API-2] records append one-per-line, file auto-created.
    #[test]
    fn appends_one_record_per_line() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        append_record(&perch, "{\"role\":\"user\"}").unwrap();
        append_record(&perch, "{\"role\":\"assistant\"}\n").unwrap();
        let log = std::fs::read_to_string(perch.join("history.log")).unwrap();
        assert_eq!(log.lines().count(), 2, "two records, two lines");
        assert!(log.lines().all(|l| !l.is_empty()));
    }
}

+    // [unit->REQ-TERM-4] digest entries land in a SEPARATE digest.log, never the

+    // transcript history.log (no cross-pollution of the opaque transcript store).

+    fn digest_entries_go_to_a_separate_log() {

+        let dir = tempfile::tempdir().unwrap();

+        let perch = dir.path().join("perch");

+        append_digest_entry(&perch, "{\"role\":\"input\",\"text\":\"hi\"}").unwrap();

+        append_digest_entry(&perch, "{\"role\":\"agent\",\"text\":\"yo\"}").unwrap();

+        let digest = std::fs::read_to_string(perch.join("digest.log")).unwrap();

+        assert_eq!(digest.lines().count(), 2);

+            !perch.join("history.log").exists(),

+            "digest entries must not touch the transcript store"


Now the `api digest-entry` handler in reporting.rs, with a testable core:


/// `api history-log <id>` — Path-B ingest: read a normalized record from stdin
/// and append it to the native store.
// [impl->REQ-API-2]
pub fn cmd_history_log(id: &str) -> i32 {
    use std::io::Read;
    let mut record = String::new();
    let _ = std::io::stdin().read_to_string(&mut record);
    if record.trim().is_empty() {
        eprintln!("EMPTY_RECORD: history-log read nothing on stdin");
        return EXIT_REFUSED;
    }
    match append_history(id, record.trim_end()) {
        Ok(n) => {
            eprintln!("HISTORY_LOGGED:{id}:{n}");
            0
        }
        Err(e) => {
            eprintln!("HISTORY_FAIL:{id}: {e}");
            EXIT_REFUSED
        }
    }
}

/// `api history-log <id>` — Path-B ingest: read a normalized record from stdin
/// and append it to the native store.
// [impl->REQ-API-2]
pub fn cmd_history_log(id: &str) -> i32 {
    use std::io::Read;
    let mut record = String::new();
    let _ = std::io::stdin().read_to_string(&mut record);
    if record.trim().is_empty() {
        eprintln!("EMPTY_RECORD: history-log read nothing on stdin");
        return EXIT_REFUSED;
    }
    match append_history(id, record.trim_end()) {
        Ok(n) => {
            eprintln!("HISTORY_LOGGED:{id}:{n}");
            0
        }
        Err(e) => {
            eprintln!("HISTORY_FAIL:{id}: {e}");
            EXIT_REFUSED
        }
    }
}

/// `api digest-entry <id>` — the **log-less-adapter push door** (ADR-0008
/// amendment, REQ-TERM-4): read ONE pre-formed digest-record (the published
/// digest-record contract JSON line) from stdin and append it to the perch's
/// digest native store (`digest.log`), the source the projection tails. Harnesses
/// with no usable session logs feed the digest this way (the Path-B-style door).
/// The record is **validated against the published contract at the door** — a
/// malformed/unknown-role line is refused with a diagnostic (unlike the
/// tail-projection, which leniently skips, the push door rejects bad input early).
// [impl->REQ-TERM-4]
pub fn cmd_digest_entry(id: &str) -> i32 {
    use std::io::Read;
    let mut record = String::new();
    let _ = std::io::stdin().read_to_string(&mut record);
    ingest_digest_entry(id, record.trim())
}

/// The testable core of [`cmd_digest_entry`] (stdin split out): validate `record`
/// against the published digest-record contract, then append it to the perch's
/// `digest.log`. Empty / contract-invalid input is refused.
// [impl->REQ-TERM-4]
fn ingest_digest_entry(id: &str, record: &str) -> i32 {
    if record.is_empty() {
        eprintln!("EMPTY_RECORD: digest-entry read nothing on stdin");
        return EXIT_REFUSED;
    }
    if spt_term::record_to_tagged(record).is_none() {
        eprintln!(
            "BAD_DIGEST_RECORD:{id}: not a valid digest-record \
             (need a JSON object with role ∈ {{input, agent, tool}})"
        );
        return EXIT_REFUSED;
    }
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    match spt_store::history::append_digest_entry(&perch_path, record) {
        Ok(n) => {
            eprintln!("DIGEST_ENTRY_LOGGED:{id}:{n}");
            0
        }
        Err(e) => {
            eprintln!("DIGEST_ENTRY_FAIL:{id}: {e}");
            EXIT_REFUSED
        }
    }
}


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

/// `api history-log <id>` — Path-B ingest: read a normalized record from stdin
/// and append it to the native store.
// [impl->REQ-API-2]
pub fn cmd_history_log(id: &str) -> i32 {
    use std::io::Read;
    let mut record = String::new();
    let _ = std::io::stdin().read_to_string(&mut record);
    if record.trim().is_empty() {
        eprintln!("EMPTY_RECORD: history-log read nothing on stdin");
        return EXIT_REFUSED;
    }
    match append_history(id, record.trim_end()) {
        Ok(n) => {
            eprintln!("HISTORY_LOGGED:{id}:{n}");
            0
        }
        Err(e) => {
            eprintln!("HISTORY_FAIL:{id}: {e}");
            EXIT_REFUSED
        }
    }
}

/// `api digest-entry <id>` — the **log-less-adapter push door** (ADR-0008
/// amendment, REQ-TERM-4): read ONE pre-formed digest-record (the published
/// digest-record contract JSON line) from stdin and append it to the perch's
/// digest native store (`digest.log`), the source the projection tails. Harnesses
/// with no usable session logs feed the digest this way (the Path-B-style door).
/// The record is **validated against the published contract at the door** — a
/// malformed/unknown-role line is refused with a diagnostic (unlike the
/// tail-projection, which leniently skips, the push door rejects bad input early).
// [impl->REQ-TERM-4]
pub fn cmd_digest_entry(id: &str) -> i32 {
    use std::io::Read;
    let mut record = String::new();
    let _ = std::io::stdin().read_to_string(&mut record);
    ingest_digest_entry(id, record.trim())
}

/// The testable core of [`cmd_digest_entry`] (stdin split out): validate `record`
/// against the published digest-record contract, then append it to the perch's
/// `digest.log`. Empty / contract-invalid input is refused.
// [impl->REQ-TERM-4]
fn ingest_digest_entry(id: &str, record: &str) -> i32 {
    if record.is_empty() {
        eprintln!("EMPTY_RECORD: digest-entry read nothing on stdin");
        return EXIT_REFUSED;
    }
    if spt_term::record_to_tagged(record).is_none() {
        eprintln!(
            "BAD_DIGEST_RECORD:{id}: not a valid digest-record \
             (need a JSON object with role ∈ {{input, agent, tool}})"
        );
        return EXIT_REFUSED;
    }
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    match spt_store::history::append_digest_entry(&perch_path, record) {
        Ok(n) => {
            eprintln!("DIGEST_ENTRY_LOGGED:{id}:{n}");
            0
        }
        Err(e) => {
            eprintln!("DIGEST_ENTRY_FAIL:{id}: {e}");
            EXIT_REFUSED
        }
    }
}

+/// `api digest-entry <id>` — the **log-less-adapter push door** (ADR-0008

+/// amendment, REQ-TERM-4): read ONE pre-formed digest-record (the published

+/// digest-record contract JSON line) from stdin and append it to the perch's

+/// digest native store (`digest.log`), the source the projection tails. Harnesses

+/// with no usable session logs feed the digest this way (the Path-B-style door).

+/// The record is **validated against the published contract at the door** — a

+/// malformed/unknown-role line is refused with a diagnostic (unlike the

+/// tail-projection, which leniently skips, the push door rejects bad input early).

+pub fn cmd_digest_entry(id: &str) -> i32 {

+    let _ = std::io::stdin().read_to_string(&mut record);

+    ingest_digest_entry(id, record.trim())

+/// The testable core of [`cmd_digest_entry`] (stdin split out): validate `record`

+/// against the published digest-record contract, then append it to the perch's

+/// `digest.log`. Empty / contract-invalid input is refused.

+fn ingest_digest_entry(id: &str, record: &str) -> i32 {

+        eprintln!("EMPTY_RECORD: digest-entry read nothing on stdin");

+    if spt_term::record_to_tagged(record).is_none() {

+            "BAD_DIGEST_RECORD:{id}: not a valid digest-record \

+             (need a JSON object with role ∈ {{input, agent, tool}})"

+    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);

+    match spt_store::history::append_digest_entry(&perch_path, record) {

+            eprintln!("DIGEST_ENTRY_LOGGED:{id}:{n}");

+            eprintln!("DIGEST_ENTRY_FAIL:{id}: {e}");

/// `api emit <shell-id> --type <t> <payload> --link <token>` — a Shell pushes

/// a sensory payload to its owner's **live** session (M5-D3c, CONTEXT §Shell

/// model: sensory is REST-only). The link token is the auth and names the


//! - **history-log** (REQ-API-2) — Path-B native store: append a normalized
//!   record to the perch's history log.

//! - **history-log** (REQ-API-2) — Path-B native store: append a normalized
//!   record to the perch's history log.
//! - **digest-entry** (REQ-TERM-4) — the log-less-adapter push door: append a
//!   published-contract digest-record to the perch's separate `digest.log`, the
//!   source the session-digest projection tails (ADR-0008 amendment).


//! - **history-log** (REQ-API-2) — Path-B native store: append a normalized
//!   record to the perch's history log.
//! - **digest-entry** (REQ-TERM-4) — the log-less-adapter push door: append a
//!   published-contract digest-record to the perch's separate `digest.log`, the
//!   source the session-digest projection tails (ADR-0008 amendment).

//!   record to the perch's history log.

+//! - **digest-entry** (REQ-TERM-4) — the log-less-adapter push door: append a

+//!   published-contract digest-record to the perch's separate `digest.log`, the

+//!   source the session-digest projection tails (ADR-0008 amendment).

//! - **emit** (REQ-API-2) — Shell sensory push; a stub here (concrete Shells M5).

//! - **commune/signoff are file-drops, not commands** (REQ-API-3): resolved from

//!   the manifest's watched dirs with spt-core-fixed filenames.


Now wire the `DigestEntry` subcommand + dispatch in mod.rs:


    /// Append normalized history (body on stdin) to the native history store.
    HistoryLog {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },

    /// Append normalized history (body on stdin) to the native history store.
    HistoryLog {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Push one digest-record (the published contract JSON line, on stdin) for a
    /// log-less adapter — appended to the perch's digest store, tailed by the
    /// session-digest projection (ADR-0008 amendment, REQ-TERM-4).
    DigestEntry {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },


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

    /// Append normalized history (body on stdin) to the native history store.
    HistoryLog {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Push one digest-record (the published contract JSON line, on stdin) for a
    /// log-less adapter — appended to the perch's digest store, tailed by the
    /// session-digest projection (ADR-0008 amendment, REQ-TERM-4).
    DigestEntry {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },

+    /// Push one digest-record (the published contract JSON line, on stdin) for a

+    /// log-less adapter — appended to the perch's digest store, tailed by the

+    /// session-digest projection (ADR-0008 amendment, REQ-TERM-4).

/// Emit a Shell sensory payload to the owner's **live** session.

/// REST-only by definition: never spooled, dropped with a diagnostic when

/// the owner isn't live. The link token is the auth.


ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),

        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
        ApiCmd::DigestEntry { id, auth } => gated(&id, &auth, reporting::cmd_digest_entry),


        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
        ApiCmd::DigestEntry { id, auth } => gated(&id, &auth, reporting::cmd_digest_entry),

ApiCmd::Presence { id, auth } => gated(&id, &auth, reporting::cmd_presence),

ApiCmd::DrivenBy { id, auth } => gated(&id, &auth, reporting::cmd_driven_by),

+        ApiCmd::DigestEntry { id, auth } => gated(&id, &auth, reporting::cmd_digest_entry),

gated(&id, &auth, |id| live::cmd_shutdown(id, manifest))


Add the reporting unit test + a parse test:


    #[test] // [unit->REQ-API-2] presence records the endpoint, no content.
    fn presence_records_endpoint() {

    #[test] // [unit->REQ-TERM-4] the digest-entry door validates against the
            // published contract and appends valid records to the SEPARATE
            // digest.log; empty + contract-invalid input is refused (and never
            // touches the transcript history.log).
    fn digest_entry_validates_and_appends_to_digest_log() {
        let _h = isolated_home();
        establish("alice", "sid");
        let perch = perch::resolve_perch_path("alice", ParentHint::Infer);

        // A valid published-contract record appends.
        assert_eq!(
            ingest_digest_entry("alice", r#"{"role":"input","text":"hi"}"#),
            0
        );
        assert_eq!(
            ingest_digest_entry("alice", r#"{"role":"tool","tool":{"name":"Write","arg":"a"}}"#),
            0
        );
        let log = std::fs::read_to_string(perch.join("digest.log")).unwrap();
        assert_eq!(log.lines().count(), 2);
        assert!(!perch.join("history.log").exists(), "transcript untouched");

        // Empty + contract-invalid (unknown role, non-JSON) are refused.
        assert_eq!(ingest_digest_entry("alice", ""), EXIT_REFUSED);
        assert_eq!(
            ingest_digest_entry("alice", r#"{"role":"whisper","text":"x"}"#),
            EXIT_REFUSED
        );
        assert_eq!(ingest_digest_entry("alice", "not json"), EXIT_REFUSED);
        // No refused record leaked into the store.
        let log = std::fs::read_to_string(perch.join("digest.log")).unwrap();
        assert_eq!(log.lines().count(), 2, "refused records were not appended");
    }

    #[test] // [unit->REQ-API-2] presence records the endpoint, no content.
    fn presence_records_endpoint() {


    #[test] // [unit->REQ-TERM-4] the digest-entry door validates against the
            // published contract and appends valid records to the SEPARATE
            // digest.log; empty + contract-invalid input is refused (and never
            // touches the transcript history.log).
    fn digest_entry_validates_and_appends_to_digest_log() {
        let _h = isolated_home();
        establish("alice", "sid");
        let perch = perch::resolve_perch_path("alice", ParentHint::Infer);

        // A valid published-contract record appends.
        assert_eq!(
            ingest_digest_entry("alice", r#"{"role":"input","text":"hi"}"#),
            0
        );
        assert_eq!(
            ingest_digest_entry("alice", r#"{"role":"tool","tool":{"name":"Write","arg":"a"}}"#),
            0
        );
        let log = std::fs::read_to_string(perch.join("digest.log")).unwrap();
        assert_eq!(log.lines().count(), 2);
        assert!(!perch.join("history.log").exists(), "transcript untouched");

        // Empty + contract-invalid (unknown role, non-JSON) are refused.
        assert_eq!(ingest_digest_entry("alice", ""), EXIT_REFUSED);
        assert_eq!(
            ingest_digest_entry("alice", r#"{"role":"whisper","text":"x"}"#),
            EXIT_REFUSED
        );
        assert_eq!(ingest_digest_entry("alice", "not json"), EXIT_REFUSED);
        // No refused record leaked into the store.
        let log = std::fs::read_to_string(perch.join("digest.log")).unwrap();
        assert_eq!(log.lines().count(), 2, "refused records were not appended");
    }

    #[test] // [unit->REQ-API-2] presence records the endpoint, no content.
    fn presence_records_endpoint() {

+    #[test] // [unit->REQ-TERM-4] the digest-entry door validates against the

+            // published contract and appends valid records to the SEPARATE

+            // digest.log; empty + contract-invalid input is refused (and never

+            // touches the transcript history.log).

+    fn digest_entry_validates_and_appends_to_digest_log() {

+        let perch = perch::resolve_perch_path("alice", ParentHint::Infer);

+        // A valid published-contract record appends.

+            ingest_digest_entry("alice", r#"{"role":"input","text":"hi"}"#),

+            ingest_digest_entry("alice", r#"{"role":"tool","tool":{"name":"Write","arg":"a"}}"#),

+        let log = std::fs::read_to_string(perch.join("digest.log")).unwrap();

+        assert_eq!(log.lines().count(), 2);

+        assert!(!perch.join("history.log").exists(), "transcript untouched");

+        // Empty + contract-invalid (unknown role, non-JSON) are refused.

+        assert_eq!(ingest_digest_entry("alice", ""), EXIT_REFUSED);

+            ingest_digest_entry("alice", r#"{"role":"whisper","text":"x"}"#),

+        assert_eq!(ingest_digest_entry("alice", "not json"), EXIT_REFUSED);

+        // No refused record leaked into the store.

+        assert_eq!(log.lines().count(), 2, "refused records were not appended");


        assert!(parse(&["spt", "--adapter", "m", "session-end", "alice", "--erase"]).is_ok());
    }

        assert!(parse(&["spt", "--adapter", "m", "session-end", "alice", "--erase"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "digest-entry", "alice"]).is_ok());
    }


        assert!(parse(&["spt", "--adapter", "m", "session-end", "alice", "--erase"]).is_ok());
    }

        assert!(parse(&["spt", "--adapter", "m", "session-end", "alice", "--erase"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "digest-entry", "alice"]).is_ok());
    }

+        assert!(parse(&["spt", "--adapter", "m", "digest-entry", "alice"]).is_ok());

#[test] // [unit->REQ-API-1] closed-vocabulary args reject bad values.


Build + test the touched crates:


Bash

cd C:/Users/decid/Documents/projects/spt-core && cargo test -p spt-store --lib history 2>&1 | tail -5 && echo "===SPT api===" && cargo test -p spt --lib api::reporting 2>&1 | tail -8

Test store history + spt api reporting


test history::tests::appends_one_record_per_line ... ok
test history::tests::digest_entries_go_to_a_separate_log ... ok

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

===SPT api===
error: no library targets found in package `spt`


`spt` is a binary crate — test via the bin target:


cd C:/Users/decid/Documents/projects/spt-core && cargo test -p spt --bin spt api::reporting::tests::digest_entry api::mod 2>&1 | tail -12

Run spt bin digest-entry + api parse tests


error: unexpected argument 'api::mod' found

Usage: cargo.exe test [OPTIONS] [TESTNAME] [-- [ARGS]...]

For more information, try '--help'.


cd C:/Users/decid/Documents/projects/spt-core && cargo test -p spt --bin spt -- api:: 2>&1 | tail -20

Run spt bin api tests


test api::reporting::tests::session_end_erase_wipes_childless_perch ... ok
test api::reporting::tests::session_end_soft_preserves_state ... ok
test api::startup::tests::bind_assigns_home_and_revive_preserves_it ... ok
test api::startup::tests::bind_with_type_establishes_a_gateway_endpoint ... ok
test api::startup::tests::empty_session_seed_refused ... ok
test api::startup::tests::live_perch_under_other_session_conflicts ... ok
test api::startup::tests::missing_seed_refused ... ok
test api::startup::tests::post_spawn_bind_establishes_perch ... ok
test api::startup::tests::rebind_same_session_ok ... ok
test api::startup::tests::stale_seed_with_dead_anchor_refused ... ok
test api::startup::tests::valid_seed_binds_live_perch ... ok
test api::tests::load_manifest_resolves_composite_adapter_option ... ok
test api::worker::tests::cascade_guard_tracks_live_children ... ok
test api::worker::tests::guard_clears_only_when_all_children_stopped ... ok
test api::worker::tests::worker_poll_drains_spool ... ok
test api::worker::tests::worker_poll_holds_deferred_while_resting ... ok
test api::worker::tests::worker_start_nests_under_parent ... ok

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


46/46, 0 fail. Clippy on the touched crates:


cd C:/Users/decid/Documents/projects/spt-core && cargo clippy -p spt-store -p spt --all-targets -- -D warnings 2>&1 | tail -8

Clippy on spt-store + spt


    Checking spt-msg v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
    Checking spt-runtime v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime)
    Checking spt-net v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Checking spt-term v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Checking spt-live v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Checking spt-daemon v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Checking spt v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 7.88s


Clippy clean. traceable check + commit 2:


cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check >/dev/null 2>&1; echo "traceable EXIT=$?" && git add crates/spt/Cargo.toml crates/spt-store/src/history.rs crates/spt/src/api/reporting.rs crates/spt/src/api/mod.rs && git commit -q -m "$(cat <<'EOF'
feat(api): digest-entry push door for log-less adapters (T7a)

The Path-B-style door a harness with no usable session logs uses to feed the
session digest directly (ADR-0008 amendment / CONTEXT.md "session digest").

- `spt_store::history::append_digest_entry` — appends a digest-record to the
  perch's SEPARATE `digest.log` (not the transcript `history.log`), so pushed
  digest records never pollute the opaque transcript the echo-commune/resume
  pipeline consumes. `append_record` now delegates to a shared `append_named`.
- `api digest-entry <id>` — reads one digest-record (the published contract JSON
  line) from stdin, validates it at the door via `spt_term::record_to_tagged`
  (a malformed / unknown-role line is refused — the push door rejects early,
  unlike the tail-projection's lenient skip), and appends it. Auth-gated like
  the other reporting verbs.

The projection (T7b brain wiring) tails `digest.log` as the log-less source.

// [impl->REQ-TERM-4] // [unit->REQ-TERM-4]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EOF
)" && echo "--- commit 2 done ---" && git log --oneline -3

Commit T7a digest-entry door


traceable EXIT=0
--- commit 2 done ---
6033326 feat(api): digest-entry push door for log-less adapters (T7a)
82ead73 feat(term): session-digest log projection primitive + published record contract (T7a)
714f6b8 docs(m9): Wave 3 / G3 JIT plan — digest log-projection (doyle-vetted)


T7a landed clean: projection primitive + digest-entry door, all green, traceable EXIT=0. T7b is the heavy atomic commit (engine re-core + brain re-wire + retire + REQ re-point — one commit per restoration discipline). Before designing it, I need to see how the digest feed is currently driven and how a harness-hosted (broker-less) endpoint would get a digest.


crates\spt-daemon\src\brain.rs:37:use crate::digesthub::DigestHub;
crates\spt-daemon\src\brain.rs:760:    pub fn run_digest_feed(
crates\spt-daemon\src\brain.rs:763:        hub: &DigestHub,
crates\spt-daemon\src\digesthub.rs:82:pub struct DigestHub {
crates\spt-daemon\src\digesthub.rs:86:impl DigestHub {
crates\spt-daemon\src\digesthub.rs:89:        DigestHub {
crates\spt-daemon\src\digesthub.rs:97:    /// feed loop after each [`DigestEngine::feed`](crate::digest::DigestEngine::feed).
crates\spt-daemon\src\digesthub.rs:146:pub fn serve_digest_control(name: &str, hub: Arc<DigestHub>) -> io::Result<()> {
crates\spt-daemon\src\digesthub.rs:159:fn handle_conn<C: Read + Write>(mut conn: C, hub: &DigestHub) -> io::Result<()> {
crates\spt-daemon\src\digesthub.rs:368:        let hub = DigestHub::new();
crates\spt-daemon\src\digesthub.rs:386:        let hub = DigestHub::new();
crates\spt-daemon\src\digesthub.rs:415:        let hub = DigestHub::new();
crates\spt-daemon\src\daemon.rs:27:use crate::digesthub::{self, DigestHub};
crates\spt-daemon\src\daemon.rs:209:        let digest_hub = Arc::new(DigestHub::new());
crates\spt-daemon\src\daemon.rs:211:            let hub = Arc::clone(&digest_hub);
crates\spt-daemon\src\daemon.rs:213:                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
crates\spt-daemon\src\digest.rs:20://! [`DigestEngine::feed`] returns a [`DigestUpdate`] only when the digest changed,
crates\spt-daemon\src\digest.rs:49:use crate::digesthub::DigestHub;
crates\spt-daemon\src\digest.rs:92:    pub fn from_manifest(seam: &PtyDigest) -> Result<Self, DigestError> {
crates\spt-daemon\src\digest.rs:219:/// ([`crate::brain::Brain::run_digest_feed`]) and any in-process daemon feed
crates\spt-daemon\src\digest.rs:222:pub fn ingest_chunk(engine: &mut DigestEngine, hub: &DigestHub, endpoint_id: &str, bytes: &[u8]) {
crates\spt-daemon\src\digest.rs:262:    fn from_manifest_feeds_and_snapshots() {
crates\spt-daemon\src\digest.rs:263:        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
crates\spt-daemon\src\digest.rs:281:        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
crates\spt-daemon\src\digest.rs:293:        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
crates\spt-daemon\src\digest.rs:308:        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
crates\spt-daemon\src\digest.rs:324:        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
crates\spt-daemon\src\digest.rs:350:        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
crates\spt-daemon\src\digest.rs:367:        let mut e = DigestEngine::from_manifest(&s).expect("compile");
crates\spt-daemon\src\digest.rs:394:        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // persist=false
crates\spt-daemon\src\lib.rs:180:    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
crates\spt-daemon\src\lib.rs:181:    update_to_json, DigestHub,


`run_digest_feed` shows only its definition in spt-daemon — check if it's actually wired to a running session anywhere, and how `spt endpoint digest` currently resolves. This decides whether T7b is a re-wire or a fresh wire.


Grep

run_digest_feed|endpoint digest|cmd_digest|EndpointDigest|Digest \{|fn cmd_endpoint|pull_snapshot|digest_socket