1	//! Reporting + boundary surface: `api boundary`, `api session-end`,
2	//! `api presence`, `api history-log`, `api emit`, plus the M2a *declarations*
3	//! for commune/signoff file-drops and adapter env aliases.
4	//!
5	//! - **boundary** (REQ-API-2) — rebind the perch to the rotated `session_id`
6	//!   while keeping the stable identity + `parent_pid` anchor (CONTEXT §api
7	//!   boundary). The Self-resume commune authoring is M2b; M2a rebinds only.
8	//! - **session-end** (REQ-HAZARD-SOFT-CLEANUP / CASCADE-WIPE-GUARD) — soft
9	//!   teardown by default (preserve perch + spool + history); `--erase`
10	//!   hard-wipes, but never a parent that still hosts live workers.
11	//! - **presence** (REQ-API-2) — record `(endpoint, ts)`; no content (CONTEXT
12	//!   §presence is privacy-preserving).
13	//! - **history-log** (REQ-API-2) — Path-B native store: append a normalized
14	//!   record to the perch's history log.
15	//! - **emit** (REQ-API-2) — Shell sensory push; a stub here (concrete Shells M5).
16	//! - **commune/signoff are file-drops, not commands** (REQ-API-3): resolved from
17	//!   the manifest's watched dirs with spt-core-fixed filenames.
18	//! - **env aliases** (REQ-START-4): the adapter declares `$SPT`/`$OWL`/`$LIVE`
19	//!   in `[env]`; spt-core resolves the injections (it supplies the subcommands).
20	//
21	// [impl->REQ-API-2]
22
23	use std::path::PathBuf;
24
25	use spt_runtime::manifest::EnvDirection;
26	use spt_runtime::Manifest;
27	use spt_store::info::{self};
28	use spt_store::perch::{self, ParentHint};
29	use spt_store::registry;
30
31	use super::worker;
32	use super::{Ctx, EXIT_REFUSED};
33
34	/// `api boundary <clear|compact> <id> --to-session-id <sid>` — rebind the perch
35	/// to the rotated session id, preserving identity + parent_pid anchor. A
36	/// boundary is also a **reported resurface point** (ADR-0007/D8b): undismissed
37	/// notifs the endpoint hasn't seen surface into its spool here.
38	// [impl->REQ-API-2]
39	pub fn cmd_boundary(id: &str, mode: &str, to_session: &str) -> i32 {
40	    if to_session.is_empty() {
41	        eprintln!("USAGE: boundary requires a non-empty --to-session-id");
42	        return EXIT_REFUSED;
43	    }
44	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
45	    let Some(mut rec) = info::read_info(&perch_path) else {
46	        eprintln!("NO_ENDPOINT:{id} has no perch to rebind");
47	        return EXIT_REFUSED;
48	    };
49	    // Keep id / started / parent_pid / cwd / state; rotate only the session id.
50	    rec.session_id = to_session.to_string();
51	    if let Err(e) = info::write_info(&perch_path, &rec) {
52	        eprintln!("BOUNDARY_FAIL:{id}: {e}");
53	        return EXIT_REFUSED;
54	    }
55	    eprintln!("BOUNDARY:{mode}:{id} -> {to_session}");
56	    resurface_notifs(id);
57	    0
58	}
59
60	/// Resurface undismissed notifs to `id` at a reported boundary (D8b — the
61	/// `api boundary` and new-session points; the wake edge rides D9). Best-effort
62	/// by design: a notif problem must never fail the boundary/bind itself.
63	// [impl->REQ-NOTIF-1]
64	pub(super) fn resurface_notifs(id: &str) {
65	    resurface_notifs_with(id, None)
66	}
67
68	/// [`resurface_notifs`] with the adapter manifest: when it declares
69	/// `[session.notif]` (ADR-0007's `notif_command` seam, REQ-NOTIF-2), each
70	/// surfaced notif also renders endpoint-native — spawned detached,
71	/// best-effort, combinable with the agent-surface delivery.
72	// [impl->REQ-NOTIF-2]
73	pub(super) fn resurface_notifs_with(id: &str, manifest: Option<&Manifest>) {
74	    // The agent's context knows its shells (M5-D3d, CONTEXT §Agent
75	    // awareness): the same reported boundary that resurfaces notifs injects
76	    // one **deferred** shell-context row — hook-channel background context
77	    // that never wakes a listener (the live event-stream drain skips
78	    // deferred rows by design). Empty discovery (no instances, nothing
79	    // instantiable) injects nothing; best-effort like the notifs — a context
80	    // problem must never fail the boundary itself.
81	    // [impl->REQ-EP-5]
82	    let registered = spt_runtime::registry::registered(&perch::adapters_dir());
83	    // The other-node arm (M5-D8c) joins from the gossiped registry snapshots
84	    // when the node identity is in hand; identity-less homes degrade to the
85	    // local-only render.
86	    let regs = spt_daemon::presence::load_registry_snapshots(
87	        &spt_daemon::presence::registry_snapshot_dir(),
88	    );
89	    let ctx = match spt_store::nodeid::load_or_create() {
90	        Ok(identity) => spt_daemon::shelldisc::shell_context_across(
91	            &perch::owlery_dir(),
92	            id,
93	            &registered,
94	            &regs,
95	            &identity.public_key().to_hex(),
96	        ),
97	        Err(_) => spt_daemon::shelldisc::shell_context(&perch::owlery_dir(), id, &registered),
98	    };
99	    if let Some(ctx) = ctx {
100	        let _ = spt_msg::deliver::send_deferred(id, "spt-shells", &ctx);
101	    }
102	    let Ok(store) = spt_store::notif::NotifStore::open() else {
103	        return;
104	    };
105	    let policy = spt_daemon::NotifSurfacePolicy::load();
106	    let now_ms = std::time::SystemTime::now()
107	        .duration_since(std::time::UNIX_EPOCH)
108	        .map(|d| d.as_millis() as u64)
109	        .unwrap_or(0);
110	    match spt_daemon::resurface_at_boundary(
111	        &store,
112	        id,
113	        &policy,
114	        &perch::owlery_dir(),
115	        now_ms,
116	        spt_daemon::SUPPRESSION_WINDOW_MS,
117	    ) {
118	        Ok(outcomes) => {
119	            let surfaced: Vec<&String> = outcomes
120	                .iter()
121	                .filter_map(|o| match o {
122	                    spt_daemon::ResurfaceOutcome::Surfaced { notif_id, .. } => Some(notif_id),
123	                    _ => None,
124	                })
125	                .collect();
126	            if !surfaced.is_empty() {
127	                eprintln!("NOTIFS_RESURFACED:{id}:{}", surfaced.len());
128	            }
129	            // The endpoint-native render (opt-in seam).
130	            if let Some(m) = manifest.filter(|m| m.session.notif.is_some()) {
131	                let runtime = spt_runtime::ManifestRuntime::new(m.clone());
132	                for notif_id in surfaced {
133	                    if let Ok(Some(row)) = store.get(notif_id) {
134	                        match spt_daemon::notif::spawn_notif_command(&runtime, &row) {
135	                            Ok(pid) => eprintln!("NOTIF_COMMAND:{notif_id} pid={pid}"),
136	                            Err(e) => eprintln!("NOTIF_COMMAND_FAIL:{notif_id}: {e}"),
137	                        }
138	                    }
139	                }
140	            }
141	        }
142	        Err(e) => eprintln!("NOTIF_RESURFACE_SKIP:{id}: {e}"),
143	    }
144	}
145
146	/// `api session-end <id> [--erase]` — soft teardown by default; `--erase`
147	/// hard-wipes, guarded against cascade-wipe (6.3).
148	// [impl->REQ-HAZARD-SOFT-CLEANUP]
149	// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]
150	pub fn cmd_session_end(id: &str, erase: bool) -> i32 {
151	    let owlery = perch::owlery_dir();
152	    if !erase {
153	        // Soft (6.2): remove the ready marker + unregister; preserve spool/info.
154	        let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
155	        let _ = registry::unregister_address(id, &owlery);
156	        eprintln!("SESSION_END:{id} (soft; spool + history preserved)");
157	        return 0;
158	    }
159	    // Erase: refuse to recursively delete a parent that still hosts live
160	    // workers — soft-clean and surface their paths instead (6.3).
161	    let live = worker::live_child_paths(id);
162	    if !live.is_empty() {
163	        let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
164	        eprintln!(
165	            "CASCADE_GUARD:{id} hosts {} live child perch(es) — soft-cleaned, not erased:",
166	            live.len()
167	        );
168	        for p in live {
169	            eprintln!("  {}", p.display());
170	        }
171	        return EXIT_REFUSED;
172	    }
173	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
174	    let _ = registry::unregister_address(id, &owlery);
175	    if let Err(e) = std::fs::remove_dir_all(&perch_path) {
176	        eprintln!("SESSION_END_FAIL:{id}: {e}");
177	        return EXIT_REFUSED;
178	    }
179	    eprintln!("SESSION_END:{id} (erased)");
180	    0
181	}
182
183	/// `<spt_home>/presence.json`.
184	fn presence_path() -> PathBuf {
185	    perch::spt_home().join("presence.json")
186	}
187
188	/// `api presence <id>` — record that the user interacted at `id` (endpoint +
189	/// timestamp; no content — privacy-preserving).
190	// [impl->REQ-API-2]
191	pub fn cmd_presence(id: &str) -> i32 {
192	    let datum = format!(
193	        "{{\"last_active_endpoint\":\"{id}\",\"ts\":\"{}\"}}",
194	        now_stamp()
195	    );
196	    if let Err(e) = std::fs::write(presence_path(), datum) {
197	        eprintln!("PRESENCE_FAIL:{id}: {e}");
198	        return EXIT_REFUSED;
199	    }
200	    eprintln!("PRESENCE:{id}");
201	    0
202	}
203
204	/// `api driven-by <id>` — report which node (if any) is remote-driving this
205	/// endpoint's session, read from the daemon-stamped `driven_by` marker on the
206	/// perch's `info.json` (REQ-REACH-1: detection is a daemon fact — the agent
207	/// queries it rather than guessing from session heuristics). Prints
208	/// `DRIVEN_BY:<id>:<node-hex>` while net-driven, `DRIVEN_BY:<id>:-` when
209	/// locally driven.
210	// [impl->REQ-REACH-1]
211	pub fn cmd_driven_by(id: &str) -> i32 {
212	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
213	    let Some(rec) = info::read_info(&perch_path) else {
214	        eprintln!("NO_ENDPOINT:{id} has no perch");
215	        return EXIT_REFUSED;
216	    };
217	    println!("DRIVEN_BY:{id}:{}", rec.driven_by.as_deref().unwrap_or("-"));
218	    0
219	}
220
221	/// Append a normalized record to the perch's Path-B native history store,
222	/// returning bytes written. Thin wrapper over the store-layer primitive
223	/// ([`spt_store::history::append_record`]) — shared with the M3b digest's opt-in
224	/// persistence so the append behavior lives in exactly one place.
225	pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
226	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
227	    spt_store::history::append_record(&perch_path, record)
228	}
229
230	/// `api history-log <id>` — Path-B ingest: read a normalized record from stdin
231	/// and append it to the native store.
232	// [impl->REQ-API-2]
233	pub fn cmd_history_log(id: &str) -> i32 {
234	    use std::io::Read;
235	    let mut record = String::new();
236	    let _ = std::io::stdin().read_to_string(&mut record);
237	    if record.trim().is_empty() {
238	        eprintln!("EMPTY_RECORD: history-log read nothing on stdin");
239	        return EXIT_REFUSED;
240	    }
241	    match append_history(id, record.trim_end()) {
242	        Ok(n) => {
243	            eprintln!("HISTORY_LOGGED:{id}:{n}");
244	            0
245	        }
246	        Err(e) => {
247	            eprintln!("HISTORY_FAIL:{id}: {e}");
248	            EXIT_REFUSED
249	        }
250	    }
251	}
252
253	/// `api emit <shell-id> --type <t> <payload> --link <token>` — a Shell pushes
254	/// a sensory payload to its owner's **live** session (M5-D3c, CONTEXT §Shell
255	/// model: sensory is REST-only). The link token is the auth and names the
256	/// instance ("owner from the link"); the type must be in the manifest's
257	/// `[shell.sensory].types`. **Never spooled by construction** — there is no
258	/// spool call on this path: a live TCP delivery is the only success, anything
259	/// else drops the payload with a diagnostic (exit 0 — a drop is the channel's
260	/// defined offline behavior, not a failure).
261	// [impl->REQ-SHELL-1]
262	// [impl->REQ-API-2]
263	pub fn cmd_emit(id: &str, sensory_type: &str, payload: &str, link: &str) -> i32 {
264	    let owlery = perch::owlery_dir();
265	    let Some((owner, shell_id)) = spt_daemon::shellhost::find_shell_by_token(&owlery, link) else {
266	        eprintln!("EMIT_REFUSED:{id}: no instance holds this link token");
267	        return EXIT_REFUSED;
268	    };
269	    if shell_id != id {
270	        eprintln!("EMIT_REFUSED:{id}: the link token belongs to {shell_id}");
271	        return EXIT_REFUSED;
272	    }
273	    // The sensory vocabulary resolves from the manifest by adapter_name —
274	    // capabilities are never duplicated on the perch.
275	    let shell_perch = perch::resolve_shell_perch_path_in(&owlery, &owner, &shell_id);
276	    let Some(info) = spt_store::shellinfo::read_shell_info(&shell_perch) else {
277	        eprintln!("EMIT_REFUSED:{id}: shell perch record unreadable");
278	        return EXIT_REFUSED;
279	    };
280	    // The sensory vocabulary resolves from the **merged view**: adapter_name is
281	    // the stored composite, so a profile's [shell.sensory] overlay applies.
282	    let adapters_dir = perch::adapters_dir();
283	    let shell = spt_runtime::registry::resolve_option(&adapters_dir, &info.adapter_name)
284	        .ok()
285	        .filter(|(_, m)| m.adapter.kind == spt_runtime::manifest::AdapterKind::Shell)
286	        .and_then(|(_, m)| m.shell);
287	    let Some(shell) = shell else {
288	        eprintln!(
289	            "EMIT_REFUSED:{id}: adapter {} not registered",
290	            info.adapter_name
291	        );
292	        return EXIT_REFUSED;
293	    };
294	    if !shell.sensory.types.iter().any(|t| t == sensory_type) {
295	        eprintln!(
296	            "SENSORY_TYPE_UNKNOWN:{sensory_type}: the shell's sensory vocabulary is [{}]",
297	            shell.sensory.types.join(", ")
298	        );
299	        return EXIT_REFUSED;
300	    }
301	    let frame = spt_daemon::shellchan::compose_sensory_frame(&shell_id, sensory_type, payload);
302	    // Live-or-drop: TCP to the owner's live listener is the only delivery.
303	    if spt_msg::deliver::is_online(&owner)
304	        && spt_msg::deliver::deliver_tcp(&owner, "", &frame, &owlery)
305	    {
306	        eprintln!("SENSORY_DELIVERED:{shell_id} -> {owner} type={sensory_type}");
307	    } else {
308	        eprintln!(
309	            "SENSORY_DROPPED:{shell_id} type={sensory_type} (owner {owner} not live — \
310	             sensory is never spooled)"
311	        );
312	    }
313	    0
314	}
315
316	/// `api owner-shutdown <shell-id> --link <token>` — a Shell suspends its
317	/// linked owner directly, bypassing agent comms (M5-D4d, CONTEXT §Shell
318	/// model machinery surface). The link token is the auth and names the
319	/// instance ("owner from the link"); the gate is the `can_shutdown`
320	/// pre-consent grant (`owner-shutdown × shell-id × this-node`, authored at
321	/// spawn from the manifest flag) — **fail-closed, no escalation**: the flag
322	/// IS the consent, so an ungranted ask refuses instead of prompting. The
323	/// suspend edge runs the full transition host (echo gate + the D4a shell
324	/// cascade — the firing shell goes offline with its siblings, by design).
325	// [impl->REQ-SHELL-2]
326	pub fn cmd_owner_shutdown(id: &str, link: &str) -> i32 {
327	    use spt_daemon::grants::{self, GrantDecision};
328	    let owlery = perch::owlery_dir();
329	    let Some((owner, shell_id)) = spt_daemon::shellhost::find_shell_by_token(&owlery, link) else {
330	        eprintln!("OWNER_SHUTDOWN_REFUSED:{id}: no instance holds this link token");
331	        return EXIT_REFUSED;
332	    };
333	    if shell_id != id {
334	        eprintln!("OWNER_SHUTDOWN_REFUSED:{id}: the link token belongs to {shell_id}");
335	        return EXIT_REFUSED;
336	    }
337	    let Some(node) = spt_store::nodeid::load_existing().map(|i| i.public_key().to_hex()) else {
338	        eprintln!("OWNER_SHUTDOWN_REFUSED:{shell_id}: no node identity — no grant can exist");
339	        return EXIT_REFUSED;
340	    };
341	    match grants::check(&owlery, grants::CAP_OWNER_SHUTDOWN, &shell_id, &node, None) {
342	        GrantDecision::Allowed => {}
343	        _ => {
344	            eprintln!(
345	                "OWNER_SHUTDOWN_REFUSED:{shell_id}: can_shutdown not granted — the shell \
346	                 manifest's can_shutdown flag is the only authoring path (no escalation)"
347	            );
348	            return EXIT_REFUSED;
349	        }
350	    }
351	    match spt_daemon::daemon_rest_event(&owner, spt_daemon::RestEvent::Suspend, None) {
352	        Ok(Some(report)) => {
353	            eprintln!(
354	                "OWNER_SHUTDOWN:{owner} by {shell_id} ({:?} -> {:?})",
355	                report.from, report.to
356	            );
357	            0
358	        }
359	        Ok(None) => {
360	            eprintln!("OWNER_SHUTDOWN_NOOP:{owner} (already resting)");
361	            0
362	        }
363	        Err(e) => {
364	            eprintln!("OWNER_SHUTDOWN_FAIL:{owner}: {e}");
365	            EXIT_REFUSED
366	        }
367	    }
368	}
369
370	/// The commune/signoff **file-drop** paths — proof these are file-drops, not
371	/// commands (REQ-API-3). Dir comes from the manifest's `[session]` watched
372	/// dirs; the filename is spt-core-fixed (`<id>-commune.md` / `<id>-signoff.md`).
373	/// M2a resolves them; M2b ingests them.
374	// [impl->REQ-API-3]
375	#[allow(dead_code)] // M2a declares the file-drop contract; M2b's ingest consumes it
376	pub fn resolve_filedrops(
377	    manifest: Option<&Manifest>,
378	    id: &str,
379	) -> (Option<PathBuf>, Option<PathBuf>) {
380	    let session = manifest.map(|m| &m.session);
381	    let commune = session
382	        .and_then(|s| s.commune_dir.as_ref())
383	        .map(|dir| PathBuf::from(dir).join(format!("{id}-commune.md")));
384	    let signoff = session
385	        .and_then(|s| s.signoff_dir.as_ref())
386	        .map(|dir| PathBuf::from(dir).join(format!("{id}-signoff.md")));
387	    (commune, signoff)
388	}
389
390	/// The env injections spt-core supplies from the adapter's `[env]` table — the
391	/// channel through which the adapter's `$SPT`/`$OWL`/`$LIVE` aliases reach a
392	/// session. Only `inject` directives with a value participate (REQ-START-4).
393	// [impl->REQ-START-4]
394	#[allow(dead_code)] // M2a resolves the alias injections; the spawn-env wiring is M2b
395	pub fn env_injections(manifest: Option<&Manifest>) -> Vec<(String, String)> {
396	    let Some(m) = manifest else { return Vec::new() };
397	    m.env
398	        .iter()
399	        .filter(|(_, v)| v.direction == EnvDirection::Inject)
400	        .filter_map(|(name, v)| v.value.clone().map(|val| (name.clone(), val)))
401	        .collect()
402	}
403
404	/// `api capability` — advertise the adapter's declared `hostable_types`
405	/// (resolved by adapter_name from the manifest). REQ-SEAM-CAPABILITY.
406	// [impl->REQ-SEAM-CAPABILITY]
407	pub fn cmd_capability(ctx: &Ctx) -> i32 {
408	    let Some(manifest) = &ctx.manifest else {
409	        eprintln!("NO_MANIFEST: capability needs --manifest");
410	        return EXIT_REFUSED;
411	    };
412	    for ty in &manifest.adapter.hostable_types {
413	        println!("{ty}");
414	    }
415	    0
416	}
417
418	/// A stable, process-independent seen-set key for one hint (hashing its
419	/// keywords, text, and regex flag). `DefaultHasher` uses fixed keys, so the
420	/// digest is deterministic across the short-lived `api hint` processes that
421	/// share the set.
422	fn hint_key(h: &spt_runtime::manifest::Hint) -> String {
423	    use std::hash::{Hash, Hasher};
424	    let mut hasher = std::collections::hash_map::DefaultHasher::new();
425	    h.keywords.hash(&mut hasher);
426	    h.text.hash(&mut hasher);
427	    h.regex.hash(&mut hasher);
428	    format!("{:016x}", hasher.finish())
429	}
430
431	/// `api hint --session <id>` — the keyword-hint surface (REQ-MANIFEST-4,
432	/// CONTEXT §keyword hints). The full user message arrives on stdin; emit at most
433	/// ONE hint line — the first hint (declaration order) whose keyword matches AND
434	/// that this session has not seen — then mark it seen so it fires once per
435	/// session. A `/clear` mints a new `--session`, naturally re-arming every hint.
436	/// Hints ride the merged view, so a profile's `[[hints]]` overlay is what fires.
437	// [impl->REQ-MANIFEST-4]
438	pub fn cmd_hint(ctx: &Ctx, session: &str) -> i32 {
439	    let Some(manifest) = &ctx.manifest else {
440	        eprintln!("NO_MANIFEST: hint needs --manifest");
441	        return EXIT_REFUSED;
442	    };
443	    if manifest.hints.is_empty() {
444	        return 0; // nothing declared — a silent no-op for the hook
445	    }
446	    let message = std::io::read_to_string(std::io::stdin()).unwrap_or_default();
447	    if message.is_empty() {
448	        return 0;
449	    }
450	    if let Some(line) = select_and_mark_hint(manifest, session, &message) {
451	        println!("{line}");
452	    }
453	    0
454	}
455
456	/// The once-per-session / one-per-message hint selection (the testable core of
457	/// [`cmd_hint`], stdin + stdout split out): scan `manifest.hints` in declaration
458	/// order for the first hint whose keyword matches `message` AND that `session`
459	/// has not yet seen; on a hit, mark it seen (durably) and return the formatted
460	/// line. Already-seen hits are skipped so a repeated keyword never permanently
461	/// blocks a later, distinct hint.
462	// [impl->REQ-MANIFEST-4]
463	fn select_and_mark_hint(
464	    manifest: &Manifest,
465	    session: &str,
466	    message: &str,
467	) -> Option<String> {
468	    let dir = perch::session_dir(session);
469	    let seen_file = dir.join("hints-seen");
470	    let mut seen: std::collections::HashSet<String> = std::fs::read_to_string(&seen_file)
471	        .map(|s| s.lines().map(str::to_string).collect())
472	        .unwrap_or_default();
473
474	    for hint in &manifest.hints {
475	        let key = hint_key(hint);
476	        if seen.contains(&key) {
477	            continue; // already fired this session
478	        }
479	        if let Some(kw) = hint.matched_keyword(message) {
480	            let line = format!(
481	                "keyword hint for SPT adapter {}: \"{kw}\"-->{}",
482	                manifest.adapter.name, hint.text
483	            );
484	            seen.insert(key);
485	            let mut rows: Vec<&str> = seen.iter().map(String::as_str).collect();
486	            rows.sort_unstable();
487	            let _ = std::fs::create_dir_all(&dir);
488	            let _ = spt_store::atomic::atomic_write_string(&seen_file, &rows.join("\n"));
489	            return Some(line);
490	        }
491	    }
492	    None
493	}
494
495	fn now_stamp() -> String {
496	    std::time::SystemTime::now()
497	        .duration_since(std::time::UNIX_EPOCH)
498	        .map(|d| d.as_secs().to_string())
499	        .unwrap_or_else(|_| "0".to_string())
500	}
501
502	#[cfg(test)]
503	mod tests {
504	    use super::*;
505	    use crate::testutil::isolated_home;
506	    use spt_store::info::InfoJson;
507
508	    fn establish(id: &str, session: &str) {
509	        let path = perch::resolve_perch_path(id, ParentHint::Infer);
510	        std::fs::create_dir_all(&path).unwrap();
511	        let mut rec = InfoJson::new(id, "0", std::process::id(), session, "live_agent");
512	        rec.parent_pid = Some(4242);
513	        info::write_info(&path, &rec).unwrap();
514	        std::fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "").unwrap();
515	    }
516
517	    #[test] // [unit->REQ-API-2] boundary rebinds the session id, keeps identity+anchor.
518	    fn boundary_rebinds_keeping_anchor() {
519	        let _h = isolated_home();
520	        establish("alice", "sid-old");
521	        assert_eq!(cmd_boundary("alice", "compact", "sid-new"), 0);
522	        let rec = info::read_info(&perch::resolve_perch_path("alice", ParentHint::Infer)).unwrap();
523	        assert_eq!(rec.session_id, "sid-new");
524	        assert_eq!(rec.parent_pid, Some(4242), "parent anchor preserved");
525	        assert_eq!(rec.id, "alice");
526	    }
527
528	    #[test] // [unit->REQ-API-2] boundary on a missing perch is refused.
529	    fn boundary_missing_perch_refused() {
530	        let _h = isolated_home();
531	        assert_eq!(cmd_boundary("ghost", "clear", "sid"), EXIT_REFUSED);
532	    }
533
534	    #[test] // [unit->REQ-NOTIF-1] `api boundary` is a reported resurface point
535	            // (ADR-0007/D8b): an undismissed notif in a member subnet surfaces into
536	            // the endpoint's spool as the typed notify envelope; the next boundary is
537	            // seen-suppressed (delivers nothing again — dismissal is the only ack).
538	    fn boundary_resurfaces_undismissed_notifs() {
539	        let _h = isolated_home();
540	        establish("alice", "sid-old");
541	        // This node is a member of `home` (the resurface scope).
542	        let mut subnets = spt_store::subnet::SubnetStore::load();
543	        subnets.create_subnet("home").unwrap();
544	        subnets.save().unwrap();
545	        // One undismissed notif in the canonical store.
546	        let store = spt_store::notif::NotifStore::open().unwrap();
547	        let mut epochs = spt_store::epoch::EpochSource::load();
548	        store
549	            .produce("cafe", &mut epochs, "home", "agent", "issuer", "build done")
550	            .unwrap();
551
552	        assert_eq!(cmd_boundary("alice", "clear", "sid-new"), 0);
553	        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
554	        let rows = spt_store::spool::drain_all_at(&perch_path).unwrap();
555	        assert_eq!(rows.len(), 1);
556	        assert!(
557	            rows[0].contains("<EVENT type=\"notify\" from=\"issuer\" notif_id=\"cafe:1\"")
558	                && rows[0].contains("build done"),
559	            "typed notify envelope surfaced: {}",
560	            rows[0]
561	        );
562
563	        // Second boundary: alice has seen it — nothing re-delivered.
564	        assert_eq!(cmd_boundary("alice", "compact", "sid-3"), 0);
565	        assert!(spt_store::spool::drain_all_at(&perch_path)
566	            .unwrap()
567	            .is_empty());
568	    }
569
570	    #[test] // [unit->REQ-EP-5] a reported boundary injects ONE deferred
571	            // shell-context row (M5-D3d agent awareness): the live event-stream
572	            // drain skips it — hook-channel background context, never a listener
573	            // wake — while a hook drain delivers it; empty discovery injects
574	            // nothing at all.
575	    fn boundary_injects_deferred_shell_context() {
576	        let _h = isolated_home();
577	        establish("alice", "sid");
578	        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
579
580	        // Empty discovery (no instances, nothing instantiable): no row.
581	        assert_eq!(cmd_boundary("alice", "clear", "s2"), 0);
582	        assert_eq!(spt_store::spool::pending_count_at(&perch_path).unwrap(), 0);
583
584	        // A discoverable shell adapter + an owned instance.
585	        let src = perch::spt_home().join("srcs").join("s-src");
586	        std::fs::create_dir_all(&src).unwrap();
587	        std::fs::write(
588	            src.join("manifest.toml"),
589	            "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1\"\n\
590	             min_spt_core_version = \"0\"\n\n[shell]\nspawn = 'prog {link_token}'\n\
591	             broadcast = \"subnet\"\n",
592	        )
593	        .unwrap();
594	        spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
595	        spt_store::shellinfo::spawn_record(
596	            &perch::owlery_dir(),
597	            "alice",
598	            "mock-shell",
599	            Some("Scout"),
600	        )
601	        .unwrap();
602
603	        assert_eq!(cmd_boundary("alice", "compact", "s3"), 0);
604	        // The live event-stream drain skips the deferred row…
605	        assert!(spt_store::spool::drain_non_deferred_at(&perch_path)
606	            .unwrap()
607	            .is_empty());
608	        // …while the hook drain delivers exactly one row with both sections.
609	        let all = spt_store::spool::drain_all_at(&perch_path).unwrap();
610	        assert_eq!(all.len(), 1, "{all:?}");
611	        assert!(
612	            all[0].contains("Scout → mock-shell-0 (mock-shell), offline"),
613	            "{}",
614	            all[0]
615	        );
616	        assert!(
617	            all[0].contains("instantiable shell adapters on this node"),
618	            "{}",
619	            all[0]
620	        );
621	    }
622
623	    #[test] // [unit->REQ-HAZARD-SOFT-CLEANUP] soft session-end preserves info+spool.
624	    fn session_end_soft_preserves_state() {
625	        let _h = isolated_home();
626	        establish("alice", "sid");
627	        assert_eq!(cmd_session_end("alice", false), 0);
628	        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
629	        assert!(perch_path.join("info.json").exists(), "info preserved");
630	        assert!(
631	            !perch::resolve_ready_file("alice", ParentHint::Infer).exists(),
632	            "ready removed"
633	        );
634	    }
635
636	    #[test] // [unit->REQ-HAZARD-SOFT-CLEANUP] --erase hard-wipes a childless perch.
637	    fn session_end_erase_wipes_childless_perch() {
638	        let _h = isolated_home();
639	        establish("alice", "sid");
640	        assert_eq!(cmd_session_end("alice", true), 0);
641	        assert!(!perch::resolve_perch_path("alice", ParentHint::Infer).exists());
642	    }
643
644	    #[test] // [unit->REQ-HAZARD-CASCADE-WIPE-GUARD] --erase refuses a parent with
645	            // live workers; soft-cleans instead.
646	    fn session_end_erase_refused_with_live_children() {
647	        let _h = isolated_home();
648	        establish("alice", "sid");
649	        worker::cmd_worker_start("alice", "alice-w1");
650	        assert_eq!(cmd_session_end("alice", true), EXIT_REFUSED);
651	        // Parent dir (and the live child) survive the refused erase.
652	        assert!(perch::resolve_perch_path("alice", ParentHint::Infer)
653	            .join("info.json")
654	            .exists());
655	        assert!(perch::resolve_perch_path("alice-w1", ParentHint::Infer)
656	            .join("info.json")
657	            .exists());
658	    }
659
660	    #[test] // [unit->REQ-API-2] history-log appends to the Path-B native store.
661	    fn history_log_appends() {
662	        let _h = isolated_home();
663	        establish("alice", "sid");
664	        append_history("alice", "{\"role\":\"user\",\"text\":\"hi\"}").unwrap();
665	        append_history("alice", "{\"role\":\"assistant\",\"text\":\"yo\"}").unwrap();
666	        let log = perch::resolve_perch_path("alice", ParentHint::Infer).join("history.log");
667	        let content = std::fs::read_to_string(log).unwrap();
668	        assert_eq!(content.lines().count(), 2);
669	        assert!(content.contains("\"hi\""));
670	    }
671
672	    #[test] // [unit->REQ-API-2] presence records the endpoint, no content.
673	    fn presence_records_endpoint() {
674	        let _h = isolated_home();
675	        assert_eq!(cmd_presence("alice"), 0);
676	        let datum = std::fs::read_to_string(presence_path()).unwrap();
677	        assert!(datum.contains("alice"));
678	        assert!(datum.contains("ts"));
679	    }
680
681	    #[test] // [unit->REQ-REACH-1] driven-by reads the daemon-stamped remote-drive
682	            // marker: "-" while locally driven, the node hex while net-driven, refused
683	            // without a perch (the agent's detection query, not a guess).
684	    fn driven_by_reads_daemon_marker() {
685	        let _h = isolated_home();
686	        assert_eq!(cmd_driven_by("ghost"), EXIT_REFUSED, "no perch → refused");
687
688	        establish("alice", "sid");
689	        assert_eq!(cmd_driven_by("alice"), 0, "locally driven reads clean");
690
691	        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
692	        info::set_driven_by(&perch_path, Some("cafe1234")).unwrap();
693	        assert_eq!(cmd_driven_by("alice"), 0);
694	        assert_eq!(
695	            info::read_info(&perch_path).unwrap().driven_by.as_deref(),
696	            Some("cafe1234"),
697	            "the marker the command reports"
698	        );
699	    }
700
701	    #[test] // [unit->REQ-API-3] commune/signoff resolve as file-drops with fixed names.
702	    fn filedrops_are_fixed_named_files() {
703	        let m = Manifest::from_toml_str(
704	            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session]\ncommune_dir=\".claude\"\nsignoff_dir=\".claude\"\n",
705	        )
706	        .unwrap();
707	        let (commune, signoff) = resolve_filedrops(Some(&m), "alice");
708	        assert_eq!(
709	            commune.unwrap(),
710	            PathBuf::from(".claude").join("alice-commune.md")
711	        );
712	        assert_eq!(
713	            signoff.unwrap(),
714	            PathBuf::from(".claude").join("alice-signoff.md")
715	        );
716	        // No manifest → no file-drops resolved.
717	        assert_eq!(resolve_filedrops(None, "alice"), (None, None));
718	    }
719
720	    #[test] // [unit->REQ-SEAM-CAPABILITY] capability lists the adapter's hostable_types.
721	    fn capability_lists_hostable_types() {
722	        let m = Manifest::from_toml_str(
723	            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\nhostable_types=[\"LiveAgent\",\"Worker\"]\n",
724	        )
725	        .unwrap();
726	        let ctx = Ctx {
727	            adapter: "m".into(),
728	            manifest: Some(m),
729	        };
730	        assert_eq!(cmd_capability(&ctx), 0);
731	        // No manifest → refused.
732	        let bare = Ctx {
733	            adapter: "m".into(),
734	            manifest: None,
735	        };
736	        assert_eq!(cmd_capability(&bare), EXIT_REFUSED);
737	    }
738
739	    // [unit->REQ-MANIFEST-4] the hint surface: first matching hint in declaration
740	    // order fires (one per message); a hint fires once per session (the seen-set);
741	    // a new session re-arms; a non-matching message emits nothing.
742	    #[test]
743	    fn hint_once_per_session_one_per_message() {
744	        let _h = isolated_home();
745	        let m = Manifest::from_toml_str(
746	            "[adapter]\nname=\"claude-spt\"\nversion=\"1\"\nmin_spt_core_version=\"0\"\n\n\
747	             [[hints]]\nkeywords=[\"send\"]\ntext=\"use $OWL send\"\n\n\
748	             [[hints]]\nkeywords=[\"alarm\"]\ntext=\"use new-alarm\"\n",
749	        )
750	        .unwrap();
751
752	        // A message hitting BOTH keywords emits only the FIRST (declaration order).
753	        let first = select_and_mark_hint(&m, "sess-A", "how to send and set an alarm");
754	        assert_eq!(
755	            first.as_deref(),
756	            Some("keyword hint for SPT adapter claude-spt: \"send\"-->use $OWL send")
757	        );
758	        // Same session, same keyword again → already seen → the NEXT unseen match
759	        // (the alarm hint) fires instead; the seen 'send' hint never repeats.
760	        let second = select_and_mark_hint(&m, "sess-A", "send and alarm again");
761	        assert_eq!(
762	            second.as_deref(),
763	            Some("keyword hint for SPT adapter claude-spt: \"alarm\"-->use new-alarm")
764	        );
765	        // Both now seen this session → nothing left to fire.
766	        assert!(select_and_mark_hint(&m, "sess-A", "send an alarm").is_none());
767
768	        // A different session re-arms every hint (the /clear path).
769	        assert_eq!(
770	            select_and_mark_hint(&m, "sess-B", "please send").as_deref(),
771	            Some("keyword hint for SPT adapter claude-spt: \"send\"-->use $OWL send")
772	        );
773	        // A message matching nothing emits nothing.
774	        assert!(select_and_mark_hint(&m, "sess-C", "unrelated chatter").is_none());
775	    }
776
777	    #[test] // [unit->REQ-START-4] env aliases are resolved from the [env] inject table.
778	    fn env_aliases_resolved_from_manifest() {
779	        let m = Manifest::from_toml_str(
780	            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[env.OWL_SESSION_ID]\ndirection=\"inject\"\nvalue=\"{session_id}\"\n\n[env.READONLY]\ndirection=\"read\"\n",
781	        )
782	        .unwrap();
783	        let injections = env_injections(Some(&m));
784	        assert_eq!(
785	            injections,
786	            vec![("OWL_SESSION_ID".to_string(), "{session_id}".to_string())]
787	        );
788	    }
789	}
790

//! Reporting + boundary surface: `api boundary`, `api session-end`,
//! `api presence`, `api history-log`, `api emit`, plus the M2a *declarations*
//! for commune/signoff file-drops and adapter env aliases.
//!
//! - **boundary** (REQ-API-2) — rebind the perch to the rotated `session_id`
//!   while keeping the stable identity + `parent_pid` anchor (CONTEXT §api
//!   boundary). The Self-resume commune authoring is M2b; M2a rebinds only.
//! - **session-end** (REQ-HAZARD-SOFT-CLEANUP / CASCADE-WIPE-GUARD) — soft
//!   teardown by default (preserve perch + spool + history); `--erase`
//!   hard-wipes, but never a parent that still hosts live workers.
//! - **presence** (REQ-API-2) — record `(endpoint, ts)`; no content (CONTEXT
//!   §presence is privacy-preserving).
//! - **history-log** (REQ-API-2) — Path-B native store: append a normalized
//!   record to the perch's history log.
//! - **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.
//! - **env aliases** (REQ-START-4): the adapter declares `$SPT`/`$OWL`/`$LIVE`
//!   in `[env]`; spt-core resolves the injections (it supplies the subcommands).
//
// [impl->REQ-API-2]

use std::path::PathBuf;

use spt_runtime::manifest::EnvDirection;
use spt_runtime::Manifest;
use spt_store::info::{self};
use spt_store::perch::{self, ParentHint};
use spt_store::registry;

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

/// `api boundary <clear|compact> <id> --to-session-id <sid>` — rebind the perch
/// to the rotated session id, preserving identity + parent_pid anchor. A
/// boundary is also a **reported resurface point** (ADR-0007/D8b): undismissed
/// notifs the endpoint hasn't seen surface into its spool here.
// [impl->REQ-API-2]
pub fn cmd_boundary(id: &str, mode: &str, to_session: &str) -> i32 {
    if to_session.is_empty() {
        eprintln!("USAGE: boundary requires a non-empty --to-session-id");
        return EXIT_REFUSED;
    }
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let Some(mut rec) = info::read_info(&perch_path) else {
        eprintln!("NO_ENDPOINT:{id} has no perch to rebind");
        return EXIT_REFUSED;
    };
    // Keep id / started / parent_pid / cwd / state; rotate only the session id.
    rec.session_id = to_session.to_string();
    if let Err(e) = info::write_info(&perch_path, &rec) {
        eprintln!("BOUNDARY_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("BOUNDARY:{mode}:{id} -> {to_session}");
    resurface_notifs(id);
    0
}

/// Resurface undismissed notifs to `id` at a reported boundary (D8b — the
/// `api boundary` and new-session points; the wake edge rides D9). Best-effort
/// by design: a notif problem must never fail the boundary/bind itself.
// [impl->REQ-NOTIF-1]
pub(super) fn resurface_notifs(id: &str) {
    resurface_notifs_with(id, None)
}

/// [`resurface_notifs`] with the adapter manifest: when it declares
/// `[session.notif]` (ADR-0007's `notif_command` seam, REQ-NOTIF-2), each
/// surfaced notif also renders endpoint-native — spawned detached,
/// best-effort, combinable with the agent-surface delivery.
// [impl->REQ-NOTIF-2]
pub(super) fn resurface_notifs_with(id: &str, manifest: Option<&Manifest>) {
    // The agent's context knows its shells (M5-D3d, CONTEXT §Agent
    // awareness): the same reported boundary that resurfaces notifs injects
    // one **deferred** shell-context row — hook-channel background context
    // that never wakes a listener (the live event-stream drain skips
    // deferred rows by design). Empty discovery (no instances, nothing
    // instantiable) injects nothing; best-effort like the notifs — a context
    // problem must never fail the boundary itself.
    // [impl->REQ-EP-5]
    let registered = spt_runtime::registry::registered(&perch::adapters_dir());
    // The other-node arm (M5-D8c) joins from the gossiped registry snapshots
    // when the node identity is in hand; identity-less homes degrade to the
    // local-only render.
    let regs = spt_daemon::presence::load_registry_snapshots(
        &spt_daemon::presence::registry_snapshot_dir(),
    );
    let ctx = match spt_store::nodeid::load_or_create() {
        Ok(identity) => spt_daemon::shelldisc::shell_context_across(
            &perch::owlery_dir(),
            id,
            &registered,
            &regs,
            &identity.public_key().to_hex(),
        ),
        Err(_) => spt_daemon::shelldisc::shell_context(&perch::owlery_dir(), id, &registered),
    };
    if let Some(ctx) = ctx {
        let _ = spt_msg::deliver::send_deferred(id, "spt-shells", &ctx);
    }
    let Ok(store) = spt_store::notif::NotifStore::open() else {
        return;
    };
    let policy = spt_daemon::NotifSurfacePolicy::load();
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);
    match spt_daemon::resurface_at_boundary(
        &store,
        id,
        &policy,
        &perch::owlery_dir(),
        now_ms,
        spt_daemon::SUPPRESSION_WINDOW_MS,
    ) {
        Ok(outcomes) => {
            let surfaced: Vec<&String> = outcomes
                .iter()
                .filter_map(|o| match o {
                    spt_daemon::ResurfaceOutcome::Surfaced { notif_id, .. } => Some(notif_id),
                    _ => None,
                })
                .collect();
            if !surfaced.is_empty() {
                eprintln!("NOTIFS_RESURFACED:{id}:{}", surfaced.len());
            }
            // The endpoint-native render (opt-in seam).
            if let Some(m) = manifest.filter(|m| m.session.notif.is_some()) {
                let runtime = spt_runtime::ManifestRuntime::new(m.clone());
                for notif_id in surfaced {
                    if let Ok(Some(row)) = store.get(notif_id) {
                        match spt_daemon::notif::spawn_notif_command(&runtime, &row) {
                            Ok(pid) => eprintln!("NOTIF_COMMAND:{notif_id} pid={pid}"),
                            Err(e) => eprintln!("NOTIF_COMMAND_FAIL:{notif_id}: {e}"),
                        }
                    }
                }
            }
        }
        Err(e) => eprintln!("NOTIF_RESURFACE_SKIP:{id}: {e}"),
    }
}

/// `api session-end <id> [--erase]` — soft teardown by default; `--erase`
/// hard-wipes, guarded against cascade-wipe (6.3).
// [impl->REQ-HAZARD-SOFT-CLEANUP]
// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]
pub fn cmd_session_end(id: &str, erase: bool) -> i32 {
    let owlery = perch::owlery_dir();
    if !erase {
        // Soft (6.2): remove the ready marker + unregister; preserve spool/info.
        let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
        let _ = registry::unregister_address(id, &owlery);
        eprintln!("SESSION_END:{id} (soft; spool + history preserved)");
        return 0;
    }
    // Erase: refuse to recursively delete a parent that still hosts live
    // workers — soft-clean and surface their paths instead (6.3).
    let live = worker::live_child_paths(id);
    if !live.is_empty() {
        let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
        eprintln!(
            "CASCADE_GUARD:{id} hosts {} live child perch(es) — soft-cleaned, not erased:",
            live.len()
        );
        for p in live {
            eprintln!("  {}", p.display());
        }
        return EXIT_REFUSED;
    }
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let _ = registry::unregister_address(id, &owlery);
    if let Err(e) = std::fs::remove_dir_all(&perch_path) {
        eprintln!("SESSION_END_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("SESSION_END:{id} (erased)");
    0
}

/// `<spt_home>/presence.json`.
fn presence_path() -> PathBuf {
    perch::spt_home().join("presence.json")
}

/// `api presence <id>` — record that the user interacted at `id` (endpoint +
/// timestamp; no content — privacy-preserving).
// [impl->REQ-API-2]
pub fn cmd_presence(id: &str) -> i32 {
    let datum = format!(
        "{{\"last_active_endpoint\":\"{id}\",\"ts\":\"{}\"}}",
        now_stamp()
    );
    if let Err(e) = std::fs::write(presence_path(), datum) {
        eprintln!("PRESENCE_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("PRESENCE:{id}");
    0
}

/// `api driven-by <id>` — report which node (if any) is remote-driving this
/// endpoint's session, read from the daemon-stamped `driven_by` marker on the
/// perch's `info.json` (REQ-REACH-1: detection is a daemon fact — the agent
/// queries it rather than guessing from session heuristics). Prints
/// `DRIVEN_BY:<id>:<node-hex>` while net-driven, `DRIVEN_BY:<id>:-` when
/// locally driven.
// [impl->REQ-REACH-1]
pub fn cmd_driven_by(id: &str) -> i32 {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let Some(rec) = info::read_info(&perch_path) else {
        eprintln!("NO_ENDPOINT:{id} has no perch");
        return EXIT_REFUSED;
    };
    println!("DRIVEN_BY:{id}:{}", rec.driven_by.as_deref().unwrap_or("-"));
    0
}

/// Append a normalized record to the perch's Path-B native history store,
/// returning bytes written. Thin wrapper over the store-layer primitive
/// ([`spt_store::history::append_record`]) — shared with the M3b digest's opt-in
/// persistence so the append behavior lives in exactly one place.
pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    spt_store::history::append_record(&perch_path, record)
}

/// `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 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
/// instance ("owner from the link"); the type must be in the manifest's
/// `[shell.sensory].types`. **Never spooled by construction** — there is no
/// spool call on this path: a live TCP delivery is the only success, anything
/// else drops the payload with a diagnostic (exit 0 — a drop is the channel's
/// defined offline behavior, not a failure).
// [impl->REQ-SHELL-1]
// [impl->REQ-API-2]
pub fn cmd_emit(id: &str, sensory_type: &str, payload: &str, link: &str) -> i32 {
    let owlery = perch::owlery_dir();
    let Some((owner, shell_id)) = spt_daemon::shellhost::find_shell_by_token(&owlery, link) else {
        eprintln!("EMIT_REFUSED:{id}: no instance holds this link token");
        return EXIT_REFUSED;
    };
    if shell_id != id {
        eprintln!("EMIT_REFUSED:{id}: the link token belongs to {shell_id}");
        return EXIT_REFUSED;
    }
    // The sensory vocabulary resolves from the manifest by adapter_name —
    // capabilities are never duplicated on the perch.
    let shell_perch = perch::resolve_shell_perch_path_in(&owlery, &owner, &shell_id);
    let Some(info) = spt_store::shellinfo::read_shell_info(&shell_perch) else {
        eprintln!("EMIT_REFUSED:{id}: shell perch record unreadable");
        return EXIT_REFUSED;
    };
    // The sensory vocabulary resolves from the **merged view**: adapter_name is
    // the stored composite, so a profile's [shell.sensory] overlay applies.
    let adapters_dir = perch::adapters_dir();
    let shell = spt_runtime::registry::resolve_option(&adapters_dir, &info.adapter_name)
        .ok()
        .filter(|(_, m)| m.adapter.kind == spt_runtime::manifest::AdapterKind::Shell)
        .and_then(|(_, m)| m.shell);
    let Some(shell) = shell else {
        eprintln!(
            "EMIT_REFUSED:{id}: adapter {} not registered",
            info.adapter_name
        );
        return EXIT_REFUSED;
    };
    if !shell.sensory.types.iter().any(|t| t == sensory_type) {
        eprintln!(
            "SENSORY_TYPE_UNKNOWN:{sensory_type}: the shell's sensory vocabulary is [{}]",
            shell.sensory.types.join(", ")
        );
        return EXIT_REFUSED;
    }
    let frame = spt_daemon::shellchan::compose_sensory_frame(&shell_id, sensory_type, payload);
    // Live-or-drop: TCP to the owner's live listener is the only delivery.
    if spt_msg::deliver::is_online(&owner)
        && spt_msg::deliver::deliver_tcp(&owner, "", &frame, &owlery)
    {
        eprintln!("SENSORY_DELIVERED:{shell_id} -> {owner} type={sensory_type}");
    } else {
        eprintln!(
            "SENSORY_DROPPED:{shell_id} type={sensory_type} (owner {owner} not live — \
             sensory is never spooled)"
        );
    }
    0
}

/// `api owner-shutdown <shell-id> --link <token>` — a Shell suspends its
/// linked owner directly, bypassing agent comms (M5-D4d, CONTEXT §Shell
/// model machinery surface). The link token is the auth and names the
/// instance ("owner from the link"); the gate is the `can_shutdown`
/// pre-consent grant (`owner-shutdown × shell-id × this-node`, authored at
/// spawn from the manifest flag) — **fail-closed, no escalation**: the flag
/// IS the consent, so an ungranted ask refuses instead of prompting. The
/// suspend edge runs the full transition host (echo gate + the D4a shell
/// cascade — the firing shell goes offline with its siblings, by design).
// [impl->REQ-SHELL-2]
pub fn cmd_owner_shutdown(id: &str, link: &str) -> i32 {
    use spt_daemon::grants::{self, GrantDecision};
    let owlery = perch::owlery_dir();
    let Some((owner, shell_id)) = spt_daemon::shellhost::find_shell_by_token(&owlery, link) else {
        eprintln!("OWNER_SHUTDOWN_REFUSED:{id}: no instance holds this link token");
        return EXIT_REFUSED;
    };
    if shell_id != id {
        eprintln!("OWNER_SHUTDOWN_REFUSED:{id}: the link token belongs to {shell_id}");
        return EXIT_REFUSED;
    }
    let Some(node) = spt_store::nodeid::load_existing().map(|i| i.public_key().to_hex()) else {
        eprintln!("OWNER_SHUTDOWN_REFUSED:{shell_id}: no node identity — no grant can exist");
        return EXIT_REFUSED;
    };
    match grants::check(&owlery, grants::CAP_OWNER_SHUTDOWN, &shell_id, &node, None) {
        GrantDecision::Allowed => {}
        _ => {
            eprintln!(
                "OWNER_SHUTDOWN_REFUSED:{shell_id}: can_shutdown not granted — the shell \
                 manifest's can_shutdown flag is the only authoring path (no escalation)"
            );
            return EXIT_REFUSED;
        }
    }
    match spt_daemon::daemon_rest_event(&owner, spt_daemon::RestEvent::Suspend, None) {
        Ok(Some(report)) => {
            eprintln!(
                "OWNER_SHUTDOWN:{owner} by {shell_id} ({:?} -> {:?})",
                report.from, report.to
            );
            0
        }
        Ok(None) => {
            eprintln!("OWNER_SHUTDOWN_NOOP:{owner} (already resting)");
            0
        }
        Err(e) => {
            eprintln!("OWNER_SHUTDOWN_FAIL:{owner}: {e}");
            EXIT_REFUSED
        }
    }
}

/// The commune/signoff **file-drop** paths — proof these are file-drops, not
/// commands (REQ-API-3). Dir comes from the manifest's `[session]` watched
/// dirs; the filename is spt-core-fixed (`<id>-commune.md` / `<id>-signoff.md`).
/// M2a resolves them; M2b ingests them.
// [impl->REQ-API-3]
#[allow(dead_code)] // M2a declares the file-drop contract; M2b's ingest consumes it
pub fn resolve_filedrops(
    manifest: Option<&Manifest>,
    id: &str,
) -> (Option<PathBuf>, Option<PathBuf>) {
    let session = manifest.map(|m| &m.session);
    let commune = session
        .and_then(|s| s.commune_dir.as_ref())
        .map(|dir| PathBuf::from(dir).join(format!("{id}-commune.md")));
    let signoff = session
        .and_then(|s| s.signoff_dir.as_ref())
        .map(|dir| PathBuf::from(dir).join(format!("{id}-signoff.md")));
    (commune, signoff)
}

/// The env injections spt-core supplies from the adapter's `[env]` table — the
/// channel through which the adapter's `$SPT`/`$OWL`/`$LIVE` aliases reach a
/// session. Only `inject` directives with a value participate (REQ-START-4).
// [impl->REQ-START-4]
#[allow(dead_code)] // M2a resolves the alias injections; the spawn-env wiring is M2b
pub fn env_injections(manifest: Option<&Manifest>) -> Vec<(String, String)> {
    let Some(m) = manifest else { return Vec::new() };
    m.env
        .iter()
        .filter(|(_, v)| v.direction == EnvDirection::Inject)
        .filter_map(|(name, v)| v.value.clone().map(|val| (name.clone(), val)))
        .collect()
}

/// `api capability` — advertise the adapter's declared `hostable_types`
/// (resolved by adapter_name from the manifest). REQ-SEAM-CAPABILITY.
// [impl->REQ-SEAM-CAPABILITY]
pub fn cmd_capability(ctx: &Ctx) -> i32 {
    let Some(manifest) = &ctx.manifest else {
        eprintln!("NO_MANIFEST: capability needs --manifest");
        return EXIT_REFUSED;
    };
    for ty in &manifest.adapter.hostable_types {
        println!("{ty}");
    }
    0
}

/// A stable, process-independent seen-set key for one hint (hashing its
/// keywords, text, and regex flag). `DefaultHasher` uses fixed keys, so the
/// digest is deterministic across the short-lived `api hint` processes that
/// share the set.
fn hint_key(h: &spt_runtime::manifest::Hint) -> String {
    use std::hash::{Hash, Hasher};
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    h.keywords.hash(&mut hasher);
    h.text.hash(&mut hasher);
    h.regex.hash(&mut hasher);
    format!("{:016x}", hasher.finish())
}

/// `api hint --session <id>` — the keyword-hint surface (REQ-MANIFEST-4,
/// CONTEXT §keyword hints). The full user message arrives on stdin; emit at most
/// ONE hint line — the first hint (declaration order) whose keyword matches AND
/// that this session has not seen — then mark it seen so it fires once per
/// session. A `/clear` mints a new `--session`, naturally re-arming every hint.
/// Hints ride the merged view, so a profile's `[[hints]]` overlay is what fires.
// [impl->REQ-MANIFEST-4]
pub fn cmd_hint(ctx: &Ctx, session: &str) -> i32 {
    let Some(manifest) = &ctx.manifest else {
        eprintln!("NO_MANIFEST: hint needs --manifest");
        return EXIT_REFUSED;
    };
    if manifest.hints.is_empty() {
        return 0; // nothing declared — a silent no-op for the hook
    }
    let message = std::io::read_to_string(std::io::stdin()).unwrap_or_default();
    if message.is_empty() {
        return 0;
    }
    if let Some(line) = select_and_mark_hint(manifest, session, &message) {
        println!("{line}");
    }
    0
}

/// The once-per-session / one-per-message hint selection (the testable core of
/// [`cmd_hint`], stdin + stdout split out): scan `manifest.hints` in declaration
/// order for the first hint whose keyword matches `message` AND that `session`
/// has not yet seen; on a hit, mark it seen (durably) and return the formatted
/// line. Already-seen hits are skipped so a repeated keyword never permanently
/// blocks a later, distinct hint.
// [impl->REQ-MANIFEST-4]
fn select_and_mark_hint(
    manifest: &Manifest,
    session: &str,
    message: &str,
) -> Option<String> {
    let dir = perch::session_dir(session);
    let seen_file = dir.join("hints-seen");
    let mut seen: std::collections::HashSet<String> = std::fs::read_to_string(&seen_file)
        .map(|s| s.lines().map(str::to_string).collect())
        .unwrap_or_default();

    for hint in &manifest.hints {
        let key = hint_key(hint);
        if seen.contains(&key) {
            continue; // already fired this session
        }
        if let Some(kw) = hint.matched_keyword(message) {
            let line = format!(
                "keyword hint for SPT adapter {}: \"{kw}\"-->{}",
                manifest.adapter.name, hint.text
            );
            seen.insert(key);
            let mut rows: Vec<&str> = seen.iter().map(String::as_str).collect();
            rows.sort_unstable();
            let _ = std::fs::create_dir_all(&dir);
            let _ = spt_store::atomic::atomic_write_string(&seen_file, &rows.join("\n"));
            return Some(line);
        }
    }
    None
}

fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

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

    fn establish(id: &str, session: &str) {
        let path = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&path).unwrap();
        let mut rec = InfoJson::new(id, "0", std::process::id(), session, "live_agent");
        rec.parent_pid = Some(4242);
        info::write_info(&path, &rec).unwrap();
        std::fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "").unwrap();
    }

    #[test] // [unit->REQ-API-2] boundary rebinds the session id, keeps identity+anchor.
    fn boundary_rebinds_keeping_anchor() {
        let _h = isolated_home();
        establish("alice", "sid-old");
        assert_eq!(cmd_boundary("alice", "compact", "sid-new"), 0);
        let rec = info::read_info(&perch::resolve_perch_path("alice", ParentHint::Infer)).unwrap();
        assert_eq!(rec.session_id, "sid-new");
        assert_eq!(rec.parent_pid, Some(4242), "parent anchor preserved");
        assert_eq!(rec.id, "alice");
    }

    #[test] // [unit->REQ-API-2] boundary on a missing perch is refused.
    fn boundary_missing_perch_refused() {
        let _h = isolated_home();
        assert_eq!(cmd_boundary("ghost", "clear", "sid"), EXIT_REFUSED);
    }

    #[test] // [unit->REQ-NOTIF-1] `api boundary` is a reported resurface point
            // (ADR-0007/D8b): an undismissed notif in a member subnet surfaces into
            // the endpoint's spool as the typed notify envelope; the next boundary is
            // seen-suppressed (delivers nothing again — dismissal is the only ack).
    fn boundary_resurfaces_undismissed_notifs() {
        let _h = isolated_home();
        establish("alice", "sid-old");
        // This node is a member of `home` (the resurface scope).
        let mut subnets = spt_store::subnet::SubnetStore::load();
        subnets.create_subnet("home").unwrap();
        subnets.save().unwrap();
        // One undismissed notif in the canonical store.
        let store = spt_store::notif::NotifStore::open().unwrap();
        let mut epochs = spt_store::epoch::EpochSource::load();
        store
            .produce("cafe", &mut epochs, "home", "agent", "issuer", "build done")
            .unwrap();

        assert_eq!(cmd_boundary("alice", "clear", "sid-new"), 0);
        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        let rows = spt_store::spool::drain_all_at(&perch_path).unwrap();
        assert_eq!(rows.len(), 1);
        assert!(
            rows[0].contains("<EVENT type=\"notify\" from=\"issuer\" notif_id=\"cafe:1\"")
                && rows[0].contains("build done"),
            "typed notify envelope surfaced: {}",
            rows[0]
        );

        // Second boundary: alice has seen it — nothing re-delivered.
        assert_eq!(cmd_boundary("alice", "compact", "sid-3"), 0);
        assert!(spt_store::spool::drain_all_at(&perch_path)
            .unwrap()
            .is_empty());
    }

    #[test] // [unit->REQ-EP-5] a reported boundary injects ONE deferred
            // shell-context row (M5-D3d agent awareness): the live event-stream
            // drain skips it — hook-channel background context, never a listener
            // wake — while a hook drain delivers it; empty discovery injects
            // nothing at all.
    fn boundary_injects_deferred_shell_context() {
        let _h = isolated_home();
        establish("alice", "sid");
        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);

        // Empty discovery (no instances, nothing instantiable): no row.
        assert_eq!(cmd_boundary("alice", "clear", "s2"), 0);
        assert_eq!(spt_store::spool::pending_count_at(&perch_path).unwrap(), 0);

        // A discoverable shell adapter + an owned instance.
        let src = perch::spt_home().join("srcs").join("s-src");
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(
            src.join("manifest.toml"),
            "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = 'prog {link_token}'\n\
             broadcast = \"subnet\"\n",
        )
        .unwrap();
        spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
        spt_store::shellinfo::spawn_record(
            &perch::owlery_dir(),
            "alice",
            "mock-shell",
            Some("Scout"),
        )
        .unwrap();

        assert_eq!(cmd_boundary("alice", "compact", "s3"), 0);
        // The live event-stream drain skips the deferred row…
        assert!(spt_store::spool::drain_non_deferred_at(&perch_path)
            .unwrap()
            .is_empty());
        // …while the hook drain delivers exactly one row with both sections.
        let all = spt_store::spool::drain_all_at(&perch_path).unwrap();
        assert_eq!(all.len(), 1, "{all:?}");
        assert!(
            all[0].contains("Scout → mock-shell-0 (mock-shell), offline"),
            "{}",
            all[0]
        );
        assert!(
            all[0].contains("instantiable shell adapters on this node"),
            "{}",
            all[0]
        );
    }

    #[test] // [unit->REQ-HAZARD-SOFT-CLEANUP] soft session-end preserves info+spool.
    fn session_end_soft_preserves_state() {
        let _h = isolated_home();
        establish("alice", "sid");
        assert_eq!(cmd_session_end("alice", false), 0);
        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        assert!(perch_path.join("info.json").exists(), "info preserved");
        assert!(
            !perch::resolve_ready_file("alice", ParentHint::Infer).exists(),
            "ready removed"
        );
    }

    #[test] // [unit->REQ-HAZARD-SOFT-CLEANUP] --erase hard-wipes a childless perch.
    fn session_end_erase_wipes_childless_perch() {
        let _h = isolated_home();
        establish("alice", "sid");
        assert_eq!(cmd_session_end("alice", true), 0);
        assert!(!perch::resolve_perch_path("alice", ParentHint::Infer).exists());
    }

    #[test] // [unit->REQ-HAZARD-CASCADE-WIPE-GUARD] --erase refuses a parent with
            // live workers; soft-cleans instead.
    fn session_end_erase_refused_with_live_children() {
        let _h = isolated_home();
        establish("alice", "sid");
        worker::cmd_worker_start("alice", "alice-w1");
        assert_eq!(cmd_session_end("alice", true), EXIT_REFUSED);
        // Parent dir (and the live child) survive the refused erase.
        assert!(perch::resolve_perch_path("alice", ParentHint::Infer)
            .join("info.json")
            .exists());
        assert!(perch::resolve_perch_path("alice-w1", ParentHint::Infer)
            .join("info.json")
            .exists());
    }

    #[test] // [unit->REQ-API-2] history-log appends to the Path-B native store.
    fn history_log_appends() {
        let _h = isolated_home();
        establish("alice", "sid");
        append_history("alice", "{\"role\":\"user\",\"text\":\"hi\"}").unwrap();
        append_history("alice", "{\"role\":\"assistant\",\"text\":\"yo\"}").unwrap();
        let log = perch::resolve_perch_path("alice", ParentHint::Infer).join("history.log");
        let content = std::fs::read_to_string(log).unwrap();
        assert_eq!(content.lines().count(), 2);
        assert!(content.contains("\"hi\""));
    }

    #[test] // [unit->REQ-API-2] presence records the endpoint, no content.
    fn presence_records_endpoint() {
        let _h = isolated_home();
        assert_eq!(cmd_presence("alice"), 0);
        let datum = std::fs::read_to_string(presence_path()).unwrap();
        assert!(datum.contains("alice"));
        assert!(datum.contains("ts"));
    }

    #[test] // [unit->REQ-REACH-1] driven-by reads the daemon-stamped remote-drive
            // marker: "-" while locally driven, the node hex while net-driven, refused
            // without a perch (the agent's detection query, not a guess).
    fn driven_by_reads_daemon_marker() {
        let _h = isolated_home();
        assert_eq!(cmd_driven_by("ghost"), EXIT_REFUSED, "no perch → refused");

        establish("alice", "sid");
        assert_eq!(cmd_driven_by("alice"), 0, "locally driven reads clean");

        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        info::set_driven_by(&perch_path, Some("cafe1234")).unwrap();
        assert_eq!(cmd_driven_by("alice"), 0);
        assert_eq!(
            info::read_info(&perch_path).unwrap().driven_by.as_deref(),
            Some("cafe1234"),
            "the marker the command reports"
        );
    }

    #[test] // [unit->REQ-API-3] commune/signoff resolve as file-drops with fixed names.
    fn filedrops_are_fixed_named_files() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session]\ncommune_dir=\".claude\"\nsignoff_dir=\".claude\"\n",
        )
        .unwrap();
        let (commune, signoff) = resolve_filedrops(Some(&m), "alice");
        assert_eq!(
            commune.unwrap(),
            PathBuf::from(".claude").join("alice-commune.md")
        );
        assert_eq!(
            signoff.unwrap(),
            PathBuf::from(".claude").join("alice-signoff.md")
        );
        // No manifest → no file-drops resolved.
        assert_eq!(resolve_filedrops(None, "alice"), (None, None));
    }

    #[test] // [unit->REQ-SEAM-CAPABILITY] capability lists the adapter's hostable_types.
    fn capability_lists_hostable_types() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\nhostable_types=[\"LiveAgent\",\"Worker\"]\n",
        )
        .unwrap();
        let ctx = Ctx {
            adapter: "m".into(),
            manifest: Some(m),
        };
        assert_eq!(cmd_capability(&ctx), 0);
        // No manifest → refused.
        let bare = Ctx {
            adapter: "m".into(),
            manifest: None,
        };
        assert_eq!(cmd_capability(&bare), EXIT_REFUSED);
    }

    // [unit->REQ-MANIFEST-4] the hint surface: first matching hint in declaration
    // order fires (one per message); a hint fires once per session (the seen-set);
    // a new session re-arms; a non-matching message emits nothing.
    #[test]
    fn hint_once_per_session_one_per_message() {
        let _h = isolated_home();
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"claude-spt\"\nversion=\"1\"\nmin_spt_core_version=\"0\"\n\n\
             [[hints]]\nkeywords=[\"send\"]\ntext=\"use $OWL send\"\n\n\
             [[hints]]\nkeywords=[\"alarm\"]\ntext=\"use new-alarm\"\n",
        )
        .unwrap();

        // A message hitting BOTH keywords emits only the FIRST (declaration order).
        let first = select_and_mark_hint(&m, "sess-A", "how to send and set an alarm");
        assert_eq!(
            first.as_deref(),
            Some("keyword hint for SPT adapter claude-spt: \"send\"-->use $OWL send")
        );
        // Same session, same keyword again → already seen → the NEXT unseen match
        // (the alarm hint) fires instead; the seen 'send' hint never repeats.
        let second = select_and_mark_hint(&m, "sess-A", "send and alarm again");
        assert_eq!(
            second.as_deref(),
            Some("keyword hint for SPT adapter claude-spt: \"alarm\"-->use new-alarm")
        );
        // Both now seen this session → nothing left to fire.
        assert!(select_and_mark_hint(&m, "sess-A", "send an alarm").is_none());

        // A different session re-arms every hint (the /clear path).
        assert_eq!(
            select_and_mark_hint(&m, "sess-B", "please send").as_deref(),
            Some("keyword hint for SPT adapter claude-spt: \"send\"-->use $OWL send")
        );
        // A message matching nothing emits nothing.
        assert!(select_and_mark_hint(&m, "sess-C", "unrelated chatter").is_none());
    }

    #[test] // [unit->REQ-START-4] env aliases are resolved from the [env] inject table.
    fn env_aliases_resolved_from_manifest() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[env.OWL_SESSION_ID]\ndirection=\"inject\"\nvalue=\"{session_id}\"\n\n[env.READONLY]\ndirection=\"read\"\n",
        )
        .unwrap();
        let injections = env_injections(Some(&m));
        assert_eq!(
            injections,
            vec![("OWL_SESSION_ID".to_string(), "{session_id}".to_string())]
        );
    }
}


`api` pattern clear: stdin record → `append_history` → store. For `api digest-entry` (log-less push door): validate the record parses as the published contract, append to a digest-specific native sink the projection reads. Let me see the `api` dispatch wiring + whether `spt` deps `spt-term`.