title = "Strip Windows UNC prefix on serialized paths (5.4)"
required_stages = ["impl", "unit"]  # activated M0: spt-store to_forward_slash (T8)
[[requirements]]
id = "REQ-HAZARD-SINGLE-PATH-SOURCE"
title = "Single path/registry source of truth; no layout ambiguity (6.1)"
required_stages = ["impl", "unit"]  # activated M0: spt-store perch resolver (T10)
[[requirements]]
id = "REQ-HAZARD-SOFT-CLEANUP"
title = "Soft-cleanup preserves state, removes only the ready marker (6.2)"
required_stages = ["impl", "unit"]  # activated M1 T3: soft_cleanup removes ready marker only, preserves spool + info.json
[[requirements]]
id = "REQ-HAZARD-CASCADE-WIPE-GUARD"
title = "No hard-delete of a parent hosting non-empty children (6.3)"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-HAZARD-DROP-FILE-SINGLE-WRITER"
title = "Drop files are daemon-owned single-writer (6.4)"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-HAZARD-DIRECT-WRITE-PRECEDENCE"
title = "Direct-write precedence marker (with node id) guards stale overwrite (6.5)"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-HAZARD-CONFLICT-BOTH-PRESERVED"
title = "A surfaced concurrent context pair is durably preserved (both versions, tracked artifacts) until a strictly dominating write clears it; no reconcile failure path discards an unmerged version (6.6, ADR-0013)"
required_stages = ["impl", "unit"]  # activated M4-D6b: ContextStore::record_conflict (tracked .conflicts/ artifacts, SHA-256-prefix named — idempotent across nodes, replicate like context; local working file untouched) + list_conflicts + clear_conflicts (dominating-write-only). unit = both-versions-preserved + idempotent-recording + multi-version-artifacts + resolve-clears-exactly-this-file + clean-no-op
# --- Added 2026-05-31 from Stage A red-team + Spike #1 (inactive until M-activation) ---
[[requirements]]
id = "REQ-HAZARD-DETACHED-PIPE-INHERIT"
title = "Windows detached long-lived children must not inherit a captured caller's pipe: every detach-spawn of an immortal child (daemon, shell binary) runs bInheritHandles=FALSE, or a caller capturing output anywhere up the process chain hangs forever on a pipe that never EOFs — std-handle flag stripping is NOT sufficient (grandparent strays still flow) (5.6)"
required_stages = ["impl", "unit"]  # activated M5-D3e (paid twice within one slice: std-strip guard wedged on a grandparent pipe): daemon::detached_no_inherit shared by spawn_detached + launch_shell; quoting unit + the bounded-capture spawn in shell_e2e.rs as regression
[[requirements]]
id = "REQ-HAZARD-CONPTY-DSR"
title = "ConPTY reader must auto-answer DSR (ESC[6n) or all child output stalls (5.5)"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-HAZARD-LOCAL-API-AUTH"
title = "Every local `api` mutation authenticated to an endpoint/session (codex #13)"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-HAZARD-RESTART-IDEMPOTENT"
title = "Idempotent/exactly-once delivery across brain restart at every broker boundary (codex #14)"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: tests/idempotent.rs crashes the brain before-intent/before-effect/after-effect at the PTY-write boundary → exactly-once (Spike #6)
[[requirements]]
id = "REQ-HAZARD-UPDATE-ROLLBACK"
title = "Self-update rejects version rollback; metadata expiry + adapter content signing (codex #5)"
required_stages = ["impl", "unit"]  # activated M3c-C1: release::verify_metadata enforces monotonic version (rollback), metadata expiry, channel pinning, and key revocation. Adapter content signing is the C3 half (REQ-UPD-5/SEAM-UPDATE)
[[requirements]]
id = "REQ-HAZARD-DAEMON-HOSTED-LIVENESS"
title = "Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E spawns the hosted Psyche, then proves a dead summarizer pid does NOT flip the daemon-online perch offline
# --- Added 2026-06-03 from grill-with-docs (ADR-0012, hazard 7.3); inactive until M4-D7.5 ---
[[requirements]]
id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"
required_stages = ["impl", "unit"]  # activated M4-D7.5: the stdout-captured turn driver (spt-live turn.rs, D6b pull-forward) + the intent parser's structural strip (spt-live outbound.rs) + the daemon relay's re-stamp/constrained-routing boundary (spt-daemon psyrelay.rs, anti-spoof negative test). int = D9 two-host
[[requirements]]
id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"
title = "Per-agent pulse/psyche/echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"
required_stages = ["impl", "unit"]  # activated M5-D3e on the hazard's shell-hosting face: every broker-hosted shell session gets its own drain + exit-waiter threads and every brain connection its own handler thread (broker.rs), proven by the hung-shell isolation test (one owner's hung shell binary stalls neither another owner's stdin delivery nor broker control calls). The daemon multi-agent PULSE fan-out face still pends (D4+/ADR-0004 consolidation): run_pulse_loop is def+test only and each agent drives its own pulse from its own process; M4-D9-2-3 note stands — the resting-edge feed points ARM per-endpoint echo gates (resting::arm_transition_echo) instead of hosting the bounded call, so that face binds at the per-agent-runtime fan-out
# --- Added 2026-06-03 at M4-D2 start (ADR-0005 Stage-A red-team #10/#11/#12); rule 3: register before satisfying, rule 5: activate per sub-task commit ---
[[requirements]]
id = "REQ-HAZARD-PAIR-TRANSCRIPT-BIND"
title = "Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)"
required_stages = ["impl", "unit"]  # activated M4-D2b: pairing::transcript HMAC-SHA256 confirmation MAC over the canonical transcript (domain/role/both-msgs/subnet/epoch/step/both-pubkeys, length-prefixed) + ct_eq verify, fed into the SPAKE2 KDF identity too. Negative tests in pairing::spake cover wrong-code, wrong-subnet, stale-step, stale-epoch, unknown-key-share, reflection (PAKE BadSide), tampered-tag. int = the on-wire MITM/replay E2E at D9
[[requirements]]
id = "REQ-HAZARD-PAIR-SEED-ROTATION"
title = "Removing a node rotates the subnet seed (epoch bump) so an old node/old seed cannot rejoin; trust-store delete alone is NOT revocation because the seed is replicated to every trusted node (ADR-0005 #10)"
required_stages = ["impl", "unit"]  # activated M4-D2c: spt-store::subnet SubnetStore holds N per-subnet TOTP seeds + monotonic epoch; rotate_seed mints fresh seed material + bumps the epoch (the node-removal response), persisted atomically + durable across reload. The epoch is bound into the pairing transcript (REQ-HAZARD-PAIR-TRANSCRIPT-BIND), and the spake stale_epoch/wrong_code tests prove an old seed/epoch can't rejoin. int = on-wire evict-then-rejoin E2E at D9
[[requirements]]
id = "REQ-HAZARD-PAIR-RATE-LIMIT"
title = "Subnet-global pairing rate limit: one active ceremony per subnet, shared attempt counter, exponential backoff — a public pre-trust relay + multiple seed-holders otherwise enables distributed SPAKE2 guessing (and ±1 TOTP window triples the valid-password space) (ADR-0005 #11)"
required_stages = ["impl", "unit"]  # activated M4-D2d: spt-net::pairing::ratelimit PairingRateLimiter — one in-flight ceremony per subnet (Busy), shared consecutive-failure counter, exponential backoff (1s→cap 3600s, abandoned ceremonies reclaimed+charged), per-subnet isolated. The ±1 TOTP window is JUSTIFIED (not dropped): serialization + exp backoff caps the guess rate so 3/1e6 success-per-guess stays out of reach inside a 30s code. int = on-wire distributed-guessing rejection at D2-wire/D9.
# --- Added 2026-06-03 at M4-D5a start (ADR-0009 origin-node gate; rule 3: register before satisfying) ---
[[requirements]]
id = "REQ-HAZARD-WAN-ORIGIN-AUTH"
title = "WAN-inbound origin is transport truth, never payload: the access gate's subject (ADR-0009 origin-node whitelist) is the QUIC handshake-proven remote node id from the broker's conn/stream table — a forged origin/node field inside record bytes is inert (7.5)"
required_stages = ["doc", "impl", "unit"]  # activated M4-D5a: WanMessage carries NO origin field by design (a forged one decodes as an ignored unknown field — spt-net wanmsg unit), and wan::receive_wan takes origin_node from NetStreamInfo.remote_id_hex (tests/wanmsg.rs proves the stream-table origin IS the handshake identity and a forged payload origin is inert end-to-end)

# --- Added 2026-06-04 at M5-D0 (M5-PLAN scope decisions; rule 3: register before satisfying, rule 5: activate per sub-task commit) ---
[[requirements]]
id = "REQ-CONSENT-1"
title = "Consent grant store: capability x subject-agent x target-node rows, enforced at the target node, subnet-settable (replicates as security material near the trust store), revocable; gated-capability ids (remote-exec, instantiate-anywhere) reserved-but-refusing; v1 consumers are the shell spawn gates (CONTEXT Consent & security gates)"
required_stages = ["impl", "unit"]  # activated M5-D1a: spt-store::grants GrantStore (trust/grants.json, default-deny polarity, corrupt-degrades-closed, exact-tuple match incl. qualifier) + spt-daemon::grants gate (reserved-refusal-first decide, target-node-local check front door) + spt grant add/revoke/list CLI (reserved ids refuse authoring). int = the D3d spawn-gate consumer E2E. M5-D9b rule-5: int stays unactivated — the escalation's end-to-end leg needs a real harness session answering a real prompt, which is the downstream plugin's acceptance territory (docs/DEFERRED.md)
[[requirements]]
id = "REQ-CONSENT-2"
title = "Interactive consent escalation: an ungated high-risk action routes a consent prompt to the user's most-recently-active session; allow-once / allow-always (writes a grant) / deny; pre-consent flags (can_shutdown, shell_wake_spawn_anywhere) author grants via manifest/settings (CONTEXT Consent & security gates)"
required_stages = ["impl", "unit"]  # activated M5-D1b: spt-daemon::grants escalation — EscalationAsk lossless body roundtrip over the consent-kind notif (produce_escalation_notif rides produce_and_first_fire to the most-recently-active session) + apply_escalation_answer (allow-once proceeds unpersisted / allow-always writes the exact tuple / deny persists nothing). D1c: the pre-consent flag authoring paths (author_can_shutdown_grant live-now; author_wake_spawn_anywhere_grant flag-shape-only — qualified shell-wake row the reserved gate still refuses until instantiate-anywhere lands). int = the D3d spawn-gate consumer E2E
[[requirements]]
id = "REQ-PRES-1"
title = "Presence resolution: the presence datum (last_active_node, last_active_endpoint, ts) gossiped subnet-wide via the agent-interaction heartbeat (rides registry distribution, visibility-gated) + one first-class most-recently-active resolution API consumed by notif first-fire, update-consent delivery, consent escalation, and shell wake resolution (M5 scope decision 1: resolution only — the PresenceChannel endpoint stays deferred)"
required_stages = ["impl", "unit", "int"]  # activated M5-D6a/b: the presence datum is an ADDITIVE FIELD on the registry Instance (last_active_ms, serde-default skip-if-none — the resources-blurb precedent verbatim), carried by advertise_local from the perch's heartbeat stamp (lifecycle::touch_active — Active seats only, a resting seat never wins recency). No new gossip channel: rides the epoch lease + RegistryUpdate replication, visibility-gated by construction. Wall-clock ms cross-compared ONLY as a routing heuristic — the epoch stays the sole merge-precedence key. unit = lease-ordering (recency never rolls back through a stale merge) + serde forward-compat + advertisement carry (stamped/never-stamped/refresh-epoch-bumped). D6b: spt-daemon::presence MRA API (local live perches ∪ visible routable gossiped rows under one max; PresenceTarget carries the local-perch handle iff the winner is this node); first_fire is the ONE swap point (update-consent + grants escalation both route through it as notif producers — their advisory targets re-resolve at fire time); RemoteTarget = skip-local-surface-unmarked, the row rides existing notif replication and the winner's node surfaces on feed-apply (dispatch::surface_fresh_rows — convergent decisions, no new wire op, seen-marks + suppression window dedup the skew race); resolve_wake's no-local-owner branch forwards the wake via the D5b rest op (WAKE_FORWARDED; cross-node shell link stays D8c). int = loopback two-broker E2E (remote-won notif redirects: produced at A unmarked, surfaced at B via the production dispatcher). Rig [twohost] leg waits for D9a.
[[requirements]]
id = "REQ-SHELL-1"
title = "Shell hosting machinery: shell perch under the owner (type/owner/adapter_name/status/alias), broker-launched binary + api bind local-link handshake, the three channels (command durable, text+file durable + progress-queryable, sensory REST-only never spooled + dropped-unless-owner-live), owner exclusivity (CONTEXT Shell model)"
required_stages = ["impl", "unit", "int"]  # activated M5-D3c; int activated M5-D9b: the D3e/D8 loopback E2Es + the rig cross-node shell drive rung (tests/twohost.rs, run 26998058816) carry the int tags
[[requirements]]
id = "REQ-SHELL-2"
title = "Shell sleep/wake: link-break always closes the binary (pre-close instruction + termination timeout), ephemeral teardown vs persistent offline/relink, wake_command wake-watcher (offline-only, exit-opcode supervision, exponential backoff + give-up), state-keyed wake resolution (dormant/suspended/active-elsewhere; no-reachable refuses — spawn-anywhere branch deferred), spt shutdown owner cascade + api owner-shutdown gated by can_shutdown (CONTEXT Shell sleep/wake)"
required_stages = ["impl", "unit", "int"]  # activated M5-D4a; impl/unit across D4a-D4d, int at the D4 sleep/wake E2E (shell_sleepwake_e2e.rs)
512:id = "REQ-HAZARD-ID-CHARSET"
516:id = "REQ-HAZARD-REGISTRY-STALE-CLEAN"
520:id = "REQ-HAZARD-REGISTRY-CONCURRENT"
524:id = "REQ-HAZARD-REGISTRY-DIR-CREATE"
528:id = "REQ-HAZARD-REGISTRY-EPOCH-LEASE"
532:id = "REQ-HAZARD-DEFERRED-SURVIVE-DRAIN"
536:id = "REQ-HAZARD-INBOX-NO-DOUBLE"
540:id = "REQ-HAZARD-WINDOWS-PID-RECYCLE"
544:id = "REQ-HAZARD-EBUSY-RENAME"
548:id = "REQ-HAZARD-SUBPROCESS-TIMEOUT"
552:id = "REQ-HAZARD-UNC-PATH-STRIP"
556:id = "REQ-HAZARD-SINGLE-PATH-SOURCE"
560:id = "REQ-HAZARD-SOFT-CLEANUP"
564:id = "REQ-HAZARD-CASCADE-WIPE-GUARD"
568:id = "REQ-HAZARD-DROP-FILE-SINGLE-WRITER"
572:id = "REQ-HAZARD-DIRECT-WRITE-PRECEDENCE"
576:id = "REQ-HAZARD-CONFLICT-BOTH-PRESERVED"
581:id = "REQ-HAZARD-DETACHED-PIPE-INHERIT"
585:id = "REQ-HAZARD-CONPTY-DSR"
589:id = "REQ-HAZARD-LOCAL-API-AUTH"
593:id = "REQ-HAZARD-RESTART-IDEMPOTENT"
597:id = "REQ-HAZARD-UPDATE-ROLLBACK"
601:id = "REQ-HAZARD-DAEMON-HOSTED-LIVENESS"
606:id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
610:id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"
615:id = "REQ-HAZARD-PAIR-TRANSCRIPT-BIND"
619:id = "REQ-HAZARD-PAIR-SEED-ROTATION"
621:required_stages = ["impl", "unit"]  # activated M4-D2c: spt-store::subnet SubnetStore holds N per-subnet TOTP seeds + monotonic epoch; rotate_seed mints fresh seed material + bumps the epoch (the node-removal response), persisted atomically + durable across reload. The epoch is bound into the pairing transcript (REQ-HAZARD-PAIR-TRANSCRIPT-BIND), and the spake stale_epoch/wrong_code tests prove an old seed/epoch can't rejoin. int = on-wire evict-then-rejoin E2E at D9
623:id = "REQ-HAZARD-PAIR-RATE-LIMIT"
628:id = "REQ-HAZARD-WAN-ORIGIN-AUTH"


Proceed with bug root cause analysis and fixes


Bash

grep -n "REQ-SUBNET\|REQ-DAEMON-3\|REQ-INST-7\|REQ-NET-1\b\|Added 2026-06" C:/Users/decid/Documents/projects/spt-core/traceable-reqs.toml && wc -l C:/Users/decid/Documents/projects/spt-core/traceable-reqs.toml

Locate subnet/daemon REQs in registry


51:id = "REQ-DAEMON-3"
166:required_stages = []  # rule 5: the data-model split shipped across M4 D3 (registry Instance rows) + D9-5 (home/adapter on the endpoint record); this umbrella req activates when instantiate-anywhere makes the split user-facing (instance minting) — which DEFERS PAST M5 (user decision 2026-06-04, M5-PLAN scope decision 2: consent framework seam only); its concrete slices are tracked by REQ-INST-7..15
188:id = "REQ-INST-7"
256:id = "REQ-NET-1"
278:required_stages = ["impl", "unit"]  # activated M4-D2g: `spt pair show-totp [--subnet <name>|--create-new <name>]` surfaces a subnet's current code + otpauth:// URI off this node (cli.rs decide_show_totp). M7 NOTE: surface moves to `spt subnet show-code [name]` (renamed from show-totp in the M7 UX grill) and creation splits into `spt subnet create <name>` (REQ-SUBNET-1); the gate/resolution semantics here are unchanged. Per-subnet resolution: single auto-selects, multi refuses without --subnet (no guessing), --create-new mints a sole-holder subnet (SubnetStore::create_subnet). Code reuses spt_net TotpSeed (matches the ceremony exactly), never reimplemented. unit = single/multi/create-new/empty/unknown resolution + code-matches-seed. The cross-node fetch (off a *remote* paired node over IPC) is D4/D5; int = two-host at D9.
282:required_stages = ["impl", "unit"]  # activated M4-D2f: SubnetStore::create_subnet names the subnet at creation (sole seed-holder, epoch 1; "naming moves to link-start" ADR-0006); the name rides every ceremony (joiner types it, transcript binds it) and reaches the joiner via D2f seed transfer. unit = wire create_new_then_join_learns_named_subnet. CLI naming prompt is later (daemon wiring, D4+) — delivered by M7 REQ-SUBNET-2 (guided `spt subnet join`); int = two-host at D9.
298:id = "REQ-SUBNET-1"
302:id = "REQ-SUBNET-2"
306:id = "REQ-SUBNET-3"
310:id = "REQ-SUBNET-4"
405:# Added 2026-06-05 (grill `1922fa4`); inactive until M6 execution starts
604:# --- Added 2026-06-03 from grill-with-docs (ADR-0012, hazard 7.3); inactive until M4-D7.5 ---
613:# --- Added 2026-06-03 at M4-D2 start (ADR-0005 Stage-A red-team #10/#11/#12); rule 3: register before satisfying, rule 5: activate per sub-task commit ---
626:# --- Added 2026-06-03 at M4-D5a start (ADR-0009 origin-node gate; rule 3: register before satisfying) ---
632:# --- Added 2026-06-04 at M5-D0 (M5-PLAN scope decisions; rule 3: register before satisfying, rule 5: activate per sub-task commit) ---
652 C:/Users/decid/Documents/projects/spt-core/traceable-reqs.toml


290	title = "Elevation-gated per-subnet code fetch (UAC/root or elevated agent; else authenticator app)"
291	required_stages = ["impl", "unit"]  # activated M4-D2g: elevation.rs probes OS privilege (Linux libc::geteuid()==0; Windows GetTokenInformation(TokenElevation) via windows-sys; cfg-split) — only the binary pulls OS deps. gate_for: Elevated => Show, NotElevated|Unknown => Fallback (fail safe, never leak the code unconfirmed). Refused fetch leaks no code, persists no mint, distinct exit 3, and the fallback names the authenticator app (seed provisioned there at pairing). unit = gate decision both ways + Unknown fallback + no-leak/no-save on refusal; the live OS probe is environment-dependent (manual verification via todlando / D9). The elevated-*agent*-endpoint path (IPC) is D4/D5, not D2g.
292	[[requirements]]
293	id = "REQ-PAIR-7"
294	title = "Subnet icon (inline image metadata, GUI-only consumer)"
295	required_stages = []  # rule 5: GUI milestone (user 2026-06-04) — inline image metadata has no consumer until a GUI exists
296	# ── M7 subnet & quickstart UX (M7-PLAN.md, grilled 2026-06-05; activate at M7 start, rule 5) ──
297	[[requirements]]
298	id = "REQ-SUBNET-1"
299	title = "spt subnet noun namespace: status view (bare + status [NAME] [--nodes]), create (QR/otpauth), show-code; spt pair deleted"
300	required_stages = ["impl", "unit"]  # activated M7 D1 (2026-06-05): namespace + create/show-totp ports land; D2 adds the full status views. M7 D1/D2 (plan decisions 3, 5, 6, 11). Bare `spt subnet` = flagless status (names, paired-node counts, endpoint counts; never epochs/codes; hint footer; zero-subnet explanatory text). `status [NAME] [--nodes]` adds per-node rows (label, online/offline via gossip-recency + probe-stale hybrid, online/total endpoints). `create <NAME>` mints (sole seed-holder) + prints code/otpauth/terminal-QR + join hint. `show-code [name]` (nee show-totp) keeps REQ-PAIR-3/6 semantics + now re-provisions the full QR/expiry material. `spt pair` namespace removed outright (no shim).
301	[[requirements]]
302	id = "REQ-SUBNET-2"
303	title = "Guided join e2e: spt subnet join CLI initiator + always-on daemon pairing responder"
304	required_stages = ["impl", "unit", "int"]  # activated M7 D3 (2026-06-05); int added M7 D5: the twohost ladder's pairing rung IS the product surface now — brain pair-join IPC (role A) against the daemon-hosted always-on responder (role B), replacing the test-harness-driven ceremony as the pairing evidence. M7 D3 (plan decisions 8, 10) — the join ceremony's PRODUCT surface (the SPAKE2 wire is rig-proven but test-only today). Every member daemon hosts the pre-trust pairing ALPN responder ALWAYS-ON (ADR-0005 one-sided-UX intent; the red-team #11 subnet-global rate limiter + one-ceremony-per-subnet is the standing-listener guard). `spt subnet join [NAME] [--code <CODE>]`: prompts for missing pieces, LAN+relay rendezvous, clean wrong-code re-prompt (bounded, limiter surfaced honestly), actionable no-seed-holder hints, seed transfer + trust pinning + REQ-INST-15 home adoption on join. int = twohost ladder gains a product-surface join rung (CLI initiator vs daemon responder) replacing the test-harness-driven ceremony as pairing evidence.
305	[[requirements]]
306	id = "REQ-SUBNET-3"
307	title = "Node labels: hostname-default, gossiped, addressable in @node qualifiers (refuse-on-ambiguity)"
308	required_stages = ["impl", "unit"]  # activated M7 D2 (2026-06-05): label field + hostname stamp + addressable qualifiers land. M7 D2 (plan decision 7). Label defaults to OS hostname, re-checked at daemon startup (hostname change updates it), rides existing registry gossip as an additive serde-default field (old rows parse clean). Pubkey stays the identity; @node qualifiers accept label OR key-prefix; non-unique label resolution refuses-and-qualifies listing candidate key prefixes (CONTEXT.md §node label). Renders `HFENDULEAM (bcead52b…)` in subnet views.
309	[[requirements]]
310	id = "REQ-SUBNET-4"
311	title = "Subnet membership mutations elevation-gated (create = seed reveal; join = trust-boundary enrollment)"
312	required_stages = ["impl", "unit"]  # activated M7 D3 (2026-06-05): create + join gates live (REQ-PAIR-6 machinery, exit 3, gate-first ordering); ADR-0005 amendment recorded. M7 D3 (plan decision 9). `subnet create` and `subnet join` require OS elevation via the existing REQ-PAIR-6 gate machinery (exit 3 on refusal, no leak/no mutation): an unprivileged process must not mint+leak a subnet secret nor enroll the machine into an attacker's subnet. `subnet status` stays ungated (read-only, no secrets). CONTEXT.md §Pairing & trust updated 2026-06-05.
313	[[requirements]]
314	id = "REQ-DOCS-6"
315	title = "spt how-to <topic>: in-binary task-oriented agent instructions (anti-drift; quickstart prompts point agents at it)"
316	required_stages = ["impl", "unit", "int"]  # activated M7 D1 (2026-06-05); int added M7 D5: quickstart_e2e asserts the how-to texts name the commands the published guide's prompt blocks rely on. M7 D1/D4 (plan decision 12). Visible command, Agent-commands help group. Topics v1: `ready` (background-task guidance, --once fallback loop, reply mechanics) + `send` (send/ring, reply-to, SENT vs QUEUED); bare `how-to` lists topics. Single source = the binary — docs-site never duplicates the content (the DOCS-STRATEGY anti-drift posture applied to agent guidance); quickstart prompt blocks instruct agents to run it and follow. int = quickstart_e2e asserts the how-to output names the commands the guide relies on.
317
318	# ── Endpoint access control (ADR-0009, 2026-06-02; activate M4 node-tier) ──
319	[[requirements]]
320	id = "REQ-SEC-1"
321	title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
322	required_stages = ["impl", "unit"]
323	# ── Subnet notifications (ADR-0007, 2026-06-01; activate M4) ──
324	[[requirements]]

title = "Elevation-gated per-subnet code fetch (UAC/root or elevated agent; else authenticator app)"
required_stages = ["impl", "unit"]  # activated M4-D2g: elevation.rs probes OS privilege (Linux libc::geteuid()==0; Windows GetTokenInformation(TokenElevation) via windows-sys; cfg-split) — only the binary pulls OS deps. gate_for: Elevated => Show, NotElevated|Unknown => Fallback (fail safe, never leak the code unconfirmed). Refused fetch leaks no code, persists no mint, distinct exit 3, and the fallback names the authenticator app (seed provisioned there at pairing). unit = gate decision both ways + Unknown fallback + no-leak/no-save on refusal; the live OS probe is environment-dependent (manual verification via todlando / D9). The elevated-*agent*-endpoint path (IPC) is D4/D5, not D2g.
[[requirements]]
id = "REQ-PAIR-7"
title = "Subnet icon (inline image metadata, GUI-only consumer)"
required_stages = []  # rule 5: GUI milestone (user 2026-06-04) — inline image metadata has no consumer until a GUI exists
# ── M7 subnet & quickstart UX (M7-PLAN.md, grilled 2026-06-05; activate at M7 start, rule 5) ──
[[requirements]]
id = "REQ-SUBNET-1"
title = "spt subnet noun namespace: status view (bare + status [NAME] [--nodes]), create (QR/otpauth), show-code; spt pair deleted"
required_stages = ["impl", "unit"]  # activated M7 D1 (2026-06-05): namespace + create/show-totp ports land; D2 adds the full status views. M7 D1/D2 (plan decisions 3, 5, 6, 11). Bare `spt subnet` = flagless status (names, paired-node counts, endpoint counts; never epochs/codes; hint footer; zero-subnet explanatory text). `status [NAME] [--nodes]` adds per-node rows (label, online/offline via gossip-recency + probe-stale hybrid, online/total endpoints). `create <NAME>` mints (sole seed-holder) + prints code/otpauth/terminal-QR + join hint. `show-code [name]` (nee show-totp) keeps REQ-PAIR-3/6 semantics + now re-provisions the full QR/expiry material. `spt pair` namespace removed outright (no shim).
[[requirements]]
id = "REQ-SUBNET-2"
title = "Guided join e2e: spt subnet join CLI initiator + always-on daemon pairing responder"
required_stages = ["impl", "unit", "int"]  # activated M7 D3 (2026-06-05); int added M7 D5: the twohost ladder's pairing rung IS the product surface now — brain pair-join IPC (role A) against the daemon-hosted always-on responder (role B), replacing the test-harness-driven ceremony as the pairing evidence. M7 D3 (plan decisions 8, 10) — the join ceremony's PRODUCT surface (the SPAKE2 wire is rig-proven but test-only today). Every member daemon hosts the pre-trust pairing ALPN responder ALWAYS-ON (ADR-0005 one-sided-UX intent; the red-team #11 subnet-global rate limiter + one-ceremony-per-subnet is the standing-listener guard). `spt subnet join [NAME] [--code <CODE>]`: prompts for missing pieces, LAN+relay rendezvous, clean wrong-code re-prompt (bounded, limiter surfaced honestly), actionable no-seed-holder hints, seed transfer + trust pinning + REQ-INST-15 home adoption on join. int = twohost ladder gains a product-surface join rung (CLI initiator vs daemon responder) replacing the test-harness-driven ceremony as pairing evidence.
[[requirements]]
id = "REQ-SUBNET-3"
title = "Node labels: hostname-default, gossiped, addressable in @node qualifiers (refuse-on-ambiguity)"
required_stages = ["impl", "unit"]  # activated M7 D2 (2026-06-05): label field + hostname stamp + addressable qualifiers land. M7 D2 (plan decision 7). Label defaults to OS hostname, re-checked at daemon startup (hostname change updates it), rides existing registry gossip as an additive serde-default field (old rows parse clean). Pubkey stays the identity; @node qualifiers accept label OR key-prefix; non-unique label resolution refuses-and-qualifies listing candidate key prefixes (CONTEXT.md §node label). Renders `HFENDULEAM (bcead52b…)` in subnet views.
[[requirements]]
id = "REQ-SUBNET-4"
title = "Subnet membership mutations elevation-gated (create = seed reveal; join = trust-boundary enrollment)"
required_stages = ["impl", "unit"]  # activated M7 D3 (2026-06-05): create + join gates live (REQ-PAIR-6 machinery, exit 3, gate-first ordering); ADR-0005 amendment recorded. M7 D3 (plan decision 9). `subnet create` and `subnet join` require OS elevation via the existing REQ-PAIR-6 gate machinery (exit 3 on refusal, no leak/no mutation): an unprivileged process must not mint+leak a subnet secret nor enroll the machine into an attacker's subnet. `subnet status` stays ungated (read-only, no secrets). CONTEXT.md §Pairing & trust updated 2026-06-05.
[[requirements]]
id = "REQ-DOCS-6"
title = "spt how-to <topic>: in-binary task-oriented agent instructions (anti-drift; quickstart prompts point agents at it)"
required_stages = ["impl", "unit", "int"]  # activated M7 D1 (2026-06-05); int added M7 D5: quickstart_e2e asserts the how-to texts name the commands the published guide's prompt blocks rely on. M7 D1/D4 (plan decision 12). Visible command, Agent-commands help group. Topics v1: `ready` (background-task guidance, --once fallback loop, reply mechanics) + `send` (send/ring, reply-to, SENT vs QUEUED); bare `how-to` lists topics. Single source = the binary — docs-site never duplicates the content (the DOCS-STRATEGY anti-drift posture applied to agent guidance); quickstart prompt blocks instruct agents to run it and follow. int = quickstart_e2e asserts the how-to output names the commands the guide relies on.

# ── Endpoint access control (ADR-0009, 2026-06-02; activate M4 node-tier) ──
[[requirements]]
id = "REQ-SEC-1"
title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
required_stages = ["impl", "unit"]
# ── Subnet notifications (ADR-0007, 2026-06-01; activate M4) ──
[[requirements]]


Key find: elevation probe already exists (REQ-PAIR-6). Locate it.


Fix 1: Non-admin daemon spawn (both OSes)

Daemon must always run unelevated in invoking user's universe. Windows: elevated command spawns elevated daemon → pipes deny unelevated clients → later spt spawn attempts get bind Access-denied. Linux: sudo'd command spawns root daemon/root state in user home. Fix: de-elevated spawn (Windows: unelevated shell token; Linux: drop to SUDO_UID) and/or pipe SDDL granting user session.

Fix 2: Ghost registry row eviction

Dead node identity rows persist forever in registry snapshots (no TTL/eviction; only per-(endpoint,node) epoch lease supersedes). Poisons bare-id resolution with phantom AcrossNodes ambiguity. Fix: decay rows unrefreshed for N pump cadences (reuse heard-map from D2); consider trust-prune verb.

Ride-along: AMBIGUOUS render as copy-paste targets

Resolution refusals should print valid qualified targets (sergey@gravity-linux, sergey@03854a12), labels preferred — not AcrossNodes([..hex..]) debug dump. Render fix at wansend/CLI boundary.


1	//! OS privilege-elevation gate for the node-bound pairing-code fetch (REQ-PAIR-6).
2	//!
3	//! Surfacing a subnet's live TOTP code off a trusted node is the *privileged* fetch
4	//! path: it hands out subnet-joining authority, so the process must be elevated
5	//! (Windows UAC / Linux root-or-equivalent). When it is not, we refuse the node-bound
6	//! fetch and point the user at their authenticator app — where the same seed was
7	//! provisioned at pairing (the `otpauth://` URI), giving the code without privilege.
8	//!
9	//! ## Structure (a thin testable seam over an environment-dependent OS call)
10	//!
11	//! [`current`] is the only environment-dependent part: a `cfg`-split OS query that
12	//! cannot run hermetically in CI (the test process's real euid/token is whatever the
13	//! runner happens to be). So the *decision* — does this elevation level show the code
14	//! or fall back? — is factored into [`gate_for`], a pure function unit-tested both
15	//! ways. An unknown/unsupported platform maps to [`Elevation::Unknown`], which
16	//! [`gate_for`] treats as NOT elevated: **fail safe** — never leak the code when we
17	//! cannot positively confirm privilege.
18	// [impl->REQ-PAIR-6]
19
20	/// The process's OS privilege level, as far as we can positively determine it.
21	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22	pub enum Elevation {
23	    /// Confirmed elevated: Windows token is elevated, or Unix euid == 0.
24	    Elevated,
25	    /// Confirmed NOT elevated.
26	    NotElevated,
27	    /// Could not determine (unsupported platform, or the OS query failed). Treated as
28	    /// NOT elevated by [`gate_for`] — we never assume privilege we cannot confirm.
29	    ///
30	    /// Constructed only on Windows (an `OpenProcessToken`/`GetTokenInformation`
31	    /// failure) and on unsupported platforms; on Unix `geteuid` cannot fail, so the
32	    /// variant is unreachable there. `allow(dead_code)` keeps the fail-safe arm in the
33	    /// cross-platform API without a per-target warning.
34	    #[allow(dead_code)]
35	    Unknown,
36	}
37
38	/// Whether the gated fetch may surface the code, or must fall back to the app.
39	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40	pub enum Gate {
41	    /// Elevated — render the code + `otpauth://` URI.
42	    Show,
43	    /// Not elevated (or undeterminable) — refuse, point at the authenticator app.
44	    Fallback,
45	}
46
47	/// The pure gate decision (the testable seam). Only a *positively confirmed* elevation
48	/// shows the code; `NotElevated` and `Unknown` both fall back (fail safe).
49	// [impl->REQ-PAIR-6]
50	pub fn gate_for(elevation: Elevation) -> Gate {
51	    match elevation {
52	        Elevation::Elevated => Gate::Show,
53	        Elevation::NotElevated | Elevation::Unknown => Gate::Fallback,
54	    }
55	}
56
57	/// The fallback guidance shown when the fetch is refused. Names the authenticator app
58	/// explicitly (the seed was provisioned there at pairing via the `otpauth://` URI), so
59	/// the user has an unprivileged path to the same code.
60	pub fn fallback_message(subnet: &str) -> String {
61	    format!(
62	        "ELEVATION_REQUIRED: showing the code for subnet '{subnet}' off this node needs an \
63	         elevated process (run as administrator / root). Without elevation, read the current \
64	         code from your authenticator app instead — the seed was provisioned there at pairing."
65	    )
66	}
67
68	/// Query the OS for this process's elevation level. `cfg`-split; an unsupported
69	/// platform or a failed OS call returns [`Elevation::Unknown`] (→ fail safe).
70	// [impl->REQ-PAIR-6]
71	pub fn current() -> Elevation {
72	    detect()
73	}
74
75	#[cfg(unix)]
76	fn detect() -> Elevation {
77	    // Root (euid 0) is the Unix "elevated" equivalent. `geteuid` cannot fail.
78	    if unsafe { libc::geteuid() } == 0 {
79	        Elevation::Elevated
80	    } else {
81	        Elevation::NotElevated
82	    }
83	}
84
85	#[cfg(windows)]
86	fn detect() -> Elevation {
87	    use core::ffi::c_void;
88	    use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
89	    use windows_sys::Win32::Security::{
90	        GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY,
91	    };
92	    use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
93
94	    // Open the current process token, query TokenElevation. Any failure → Unknown
95	    // (fail safe). GetCurrentProcess returns a pseudo-handle we must NOT close; only
96	    // the opened token handle is released.
97	    unsafe {
98	        let mut token: HANDLE = std::ptr::null_mut();
99	        if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
100	            return Elevation::Unknown;
101	        }
102	        let mut info = TOKEN_ELEVATION { TokenIsElevated: 0 };
103	        let mut ret_len: u32 = 0;
104	        let ok = GetTokenInformation(
105	            token,
106	            TokenElevation,
107	            &mut info as *mut _ as *mut c_void,
108	            std::mem::size_of::<TOKEN_ELEVATION>() as u32,
109	            &mut ret_len,
110	        );
111	        CloseHandle(token);
112	        if ok == 0 {
113	            Elevation::Unknown
114	        } else if info.TokenIsElevated != 0 {
115	            Elevation::Elevated
116	        } else {
117	            Elevation::NotElevated
118	        }
119	    }
120	}
121
122	#[cfg(not(any(unix, windows)))]
123	fn detect() -> Elevation {
124	    // Unsupported platform: we cannot confirm privilege, so refuse to assume it.
125	    Elevation::Unknown
126	}
127
128	#[cfg(test)]
129	mod tests {
130	    use super::*;
131
132	    // [unit->REQ-PAIR-6] only confirmed elevation shows the code; NotElevated AND the
133	    // undeterminable Unknown both fall back (fail safe — no code without proof).
134	    #[test]
135	    fn gate_shows_only_when_confirmed_elevated() {
136	        assert_eq!(gate_for(Elevation::Elevated), Gate::Show);
137	        assert_eq!(gate_for(Elevation::NotElevated), Gate::Fallback);
138	        assert_eq!(gate_for(Elevation::Unknown), Gate::Fallback);
139	    }
140
141	    // [unit->REQ-PAIR-6] the fallback names the authenticator app (the unprivileged
142	    // path to the same code) and the subnet it concerns.
143	    #[test]
144	    fn fallback_message_names_authenticator_app_and_subnet() {
145	        let msg = fallback_message("home fleet");
146	        assert!(msg.contains("authenticator app"));
147	        assert!(msg.contains("home fleet"));
148	    }
149
150	    // The live OS probe is environment-dependent (the test runner's own privilege), so
151	    // we only assert it returns a determinate value here on the supported platforms —
152	    // the show/fallback *decision* is covered hermetically via `gate_for` above. Real
153	    // elevated-vs-not end-to-end verification is manual (todlando / D9).
154	    #[test]
155	    fn current_returns_a_determinate_level_on_supported_platforms() {
156	        let e = current();
157	        if cfg!(any(unix, windows)) {
158	            assert!(matches!(e, Elevation::Elevated | Elevation::NotElevated));
159	        }
160	    }
161	}
162

//! OS privilege-elevation gate for the node-bound pairing-code fetch (REQ-PAIR-6).
//!
//! Surfacing a subnet's live TOTP code off a trusted node is the *privileged* fetch
//! path: it hands out subnet-joining authority, so the process must be elevated
//! (Windows UAC / Linux root-or-equivalent). When it is not, we refuse the node-bound
//! fetch and point the user at their authenticator app — where the same seed was
//! provisioned at pairing (the `otpauth://` URI), giving the code without privilege.
//!
//! ## Structure (a thin testable seam over an environment-dependent OS call)
//!
//! [`current`] is the only environment-dependent part: a `cfg`-split OS query that
//! cannot run hermetically in CI (the test process's real euid/token is whatever the
//! runner happens to be). So the *decision* — does this elevation level show the code
//! or fall back? — is factored into [`gate_for`], a pure function unit-tested both
//! ways. An unknown/unsupported platform maps to [`Elevation::Unknown`], which
//! [`gate_for`] treats as NOT elevated: **fail safe** — never leak the code when we
//! cannot positively confirm privilege.
// [impl->REQ-PAIR-6]

/// The process's OS privilege level, as far as we can positively determine it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Elevation {
    /// Confirmed elevated: Windows token is elevated, or Unix euid == 0.
    Elevated,
    /// Confirmed NOT elevated.
    NotElevated,
    /// Could not determine (unsupported platform, or the OS query failed). Treated as
    /// NOT elevated by [`gate_for`] — we never assume privilege we cannot confirm.
    ///
    /// Constructed only on Windows (an `OpenProcessToken`/`GetTokenInformation`
    /// failure) and on unsupported platforms; on Unix `geteuid` cannot fail, so the
    /// variant is unreachable there. `allow(dead_code)` keeps the fail-safe arm in the
    /// cross-platform API without a per-target warning.
    #[allow(dead_code)]
    Unknown,
}

/// Whether the gated fetch may surface the code, or must fall back to the app.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Gate {
    /// Elevated — render the code + `otpauth://` URI.
    Show,
    /// Not elevated (or undeterminable) — refuse, point at the authenticator app.
    Fallback,
}

/// The pure gate decision (the testable seam). Only a *positively confirmed* elevation
/// shows the code; `NotElevated` and `Unknown` both fall back (fail safe).
// [impl->REQ-PAIR-6]
pub fn gate_for(elevation: Elevation) -> Gate {
    match elevation {
        Elevation::Elevated => Gate::Show,
        Elevation::NotElevated | Elevation::Unknown => Gate::Fallback,
    }
}

/// The fallback guidance shown when the fetch is refused. Names the authenticator app
/// explicitly (the seed was provisioned there at pairing via the `otpauth://` URI), so
/// the user has an unprivileged path to the same code.
pub fn fallback_message(subnet: &str) -> String {
    format!(
        "ELEVATION_REQUIRED: showing the code for subnet '{subnet}' off this node needs an \
         elevated process (run as administrator / root). Without elevation, read the current \
         code from your authenticator app instead — the seed was provisioned there at pairing."
    )
}

/// Query the OS for this process's elevation level. `cfg`-split; an unsupported
/// platform or a failed OS call returns [`Elevation::Unknown`] (→ fail safe).
// [impl->REQ-PAIR-6]
pub fn current() -> Elevation {
    detect()
}

#[cfg(unix)]
fn detect() -> Elevation {
    // Root (euid 0) is the Unix "elevated" equivalent. `geteuid` cannot fail.
    if unsafe { libc::geteuid() } == 0 {
        Elevation::Elevated
    } else {
        Elevation::NotElevated
    }
}

#[cfg(windows)]
fn detect() -> Elevation {
    use core::ffi::c_void;
    use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
    use windows_sys::Win32::Security::{
        GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY,
    };
    use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};

    // Open the current process token, query TokenElevation. Any failure → Unknown
    // (fail safe). GetCurrentProcess returns a pseudo-handle we must NOT close; only
    // the opened token handle is released.
    unsafe {
        let mut token: HANDLE = std::ptr::null_mut();
        if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
            return Elevation::Unknown;
        }
        let mut info = TOKEN_ELEVATION { TokenIsElevated: 0 };
        let mut ret_len: u32 = 0;
        let ok = GetTokenInformation(
            token,
            TokenElevation,
            &mut info as *mut _ as *mut c_void,
            std::mem::size_of::<TOKEN_ELEVATION>() as u32,
            &mut ret_len,
        );
        CloseHandle(token);
        if ok == 0 {
            Elevation::Unknown
        } else if info.TokenIsElevated != 0 {
            Elevation::Elevated
        } else {
            Elevation::NotElevated
        }
    }
}

#[cfg(not(any(unix, windows)))]
fn detect() -> Elevation {
    // Unsupported platform: we cannot confirm privilege, so refuse to assume it.
    Elevation::Unknown
}

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

    // [unit->REQ-PAIR-6] only confirmed elevation shows the code; NotElevated AND the
    // undeterminable Unknown both fall back (fail safe — no code without proof).
    #[test]
    fn gate_shows_only_when_confirmed_elevated() {
        assert_eq!(gate_for(Elevation::Elevated), Gate::Show);
        assert_eq!(gate_for(Elevation::NotElevated), Gate::Fallback);
        assert_eq!(gate_for(Elevation::Unknown), Gate::Fallback);
    }

    // [unit->REQ-PAIR-6] the fallback names the authenticator app (the unprivileged
    // path to the same code) and the subnet it concerns.
    #[test]
    fn fallback_message_names_authenticator_app_and_subnet() {
        let msg = fallback_message("home fleet");
        assert!(msg.contains("authenticator app"));
        assert!(msg.contains("home fleet"));
    }

    // The live OS probe is environment-dependent (the test runner's own privilege), so
    // we only assert it returns a determinate value here on the supported platforms —
    // the show/fallback *decision* is covered hermetically via `gate_for` above. Real
    // elevated-vs-not end-to-end verification is manual (todlando / D9).
    #[test]
    fn current_returns_a_determinate_level_on_supported_platforms() {
        let e = current();
        if cfg!(any(unix, windows)) {
            assert!(matches!(e, Elevation::Elevated | Elevation::NotElevated));
        }
    }
}


Show daemon + spt crate deps


[package]
name = "spt-daemon"
version.workspace = true
edition = "2021"
publish = false
description = "spt-core broker/brain daemon (M3b): the per-machine supervisor process. A stable broker kernel holds the un-transferable resources (PTY masters from spt-term, harness children, sockets) while a restartable brain holds all logic; a versioned local IPC lets a newer brain talk to an older broker so a self-update swaps the brain with zero endpoint interruption (ADR-0004). Internal supervisor (NOT public SDK, R-ARCH-2) — the `spt` binary is a thin CLI over it. B0 lands the crate + the versioned local-IPC transport."

[dependencies]
# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
# crate's position in the workspace layering. Tasks B1–B8 consume them.
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-term = { path = "../spt-term" }
spt-live = { path = "../spt-live" }
# D4 (ADR-0004 §B): the broker owns the live iroh endpoint + QUIC connections so
# they survive a brain restart. spt-net provides the *mechanism* (NetEndpoint);
# the broker hosts the *process* (nethost::NetHost). Default features keep the
# `net` transport on — the daemon is the reference consumer.
spt-net = { path = "../spt-net" }
# The broker is otherwise sync/thread-based; iroh requires tokio, so the broker
# hosts a dedicated runtime on named worker threads (QUIC objects never leave it).
tokio = { version = "1", features = ["rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
# a base64 string in the payload rather than forking the codec for binary.
base64 = "0.22"
# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
# behind one API — the OS-split substrate the DaemonTransport trait wraps,
# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
interprocess = "2"
# Self-update release signing (C1, REQ-UPD-2): pure-Rust Ed25519 — no C/asm
# build deps, clean cross-compile on win+linux (clean-room posture, ADR-0004 §D).
# `rand_core` lets a release-tooling caller mint a keypair; tests use the
# deterministic `from_bytes` ctor (no RNG).
ed25519-dalek = "2"
# SHA-256 binds the signed release metadata to the actual binary bytes
# (artifact digest) so the signature covers the binary, not just the manifest.
sha2 = "0.10"
# Per-frame link auth on the same-node shell channels (CONTEXT local-link
# authentication): HMAC-SHA256 over the token-derived link key (M5-D3b).
hmac = "0.12"

[dev-dependencies]
tempfile = "3"
# Integration tests are separate crates and do not inherit the lib's deps, so the
# B8 digest int-test re-declares the two it touches directly: the manifest seam
# (PtyDigest) and the perch-path/history resolution it asserts persistence against.
spt-runtime = { path = "../spt-runtime" }
spt-store = { path = "../spt-store" }
# The D4a net-broker int-test constructs identities + relay policies directly.
spt-proto = { path = "../spt-proto" }
spt-net = { path = "../spt-net" }
# The D5a wanmsg int-test asserts spool formatting through the spt-msg surface.
spt-msg = { path = "../spt-msg" }
[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"] }

# 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"


C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs-118-        run_git_ok(
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs-119-            &["-C", &self.dir_arg(), "worktree", "add", &path.to_string_lossy(), branch],
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs-120-            None,
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs-121-            None,
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs-122-        )?;
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs-123-        Ok(())
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs-124-    }
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs-125-
crates\spt-store\src\branchstore.rs:126:    /// Re-point worktree links after a `$SPT_HOME` relocation (the fallback
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs-127-    /// for git too old for relative worktree links — STORAGE §Worktree
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs-128-    /// portability). Safe to run any time.
crates\spt-store\src\branchstore.rs-129-    pub fn repair_worktrees(&self) -> std::io::Result<()> {
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs-130-        run_git_ok(&["-C", &self.dir_arg(), "worktree", "repair"], None, None)?;
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs-131-        Ok(())
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs-132-    }
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs-133-
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\branchstore.rs-134-    /// Every local branch name (the sync requester's ref-selection input).
--
crates\spt-store\src\perch.rs-16-\! - `Self_`            → flat: `owlery/<id>/`
crates\spt-store\src\perch.rs-17-\! - `Psyche` \ `Worker`→ nested under parent: `owlery/<parent>/nested/<child>/`
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-18-//!
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-19-//! The single `nested/` namespace holds both Psyche and Worker children — no
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-20-//! divergent sibling layouts (the hazard this module exists to prevent).
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-21-
crates\spt-store\src\perch.rs-22-use std::path::{Path, PathBuf};
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-23-
crates\spt-store\src\perch.rs:24:/// Root of all spt-core on-disk state. Honors `$SPT_HOME`, else the platform
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-25-/// default (`%LOCALAPPDATA%\spt-core` on Windows, `$HOME/.spt-core` on Unix).
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-26-/// **Pure** — reads env, composes a path, creates nothing.
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-27-///
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-28-/// The default lives under `spt-core/`, **deliberately distinct from the modern
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-29-/// spt runtime's `spt/` home**, so a dev build never collides with a running
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-30-/// spt-plugin during the parity build-out. Once spt-core + the spt-plugin reach
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-31-/// full parity with modern spt this may simply stay `spt-core/` for good; the
crates\spt-store\src\perch.rs:32:/// override (`$SPT_HOME`) is the relocation knob either way.
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-33-// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
crates\spt-store\src\perch.rs:34:pub fn spt_home() -> PathBuf {
crates\spt-store\src\perch.rs:35:    if let Ok(over) = std::env::var("SPT_HOME") {
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-36-        if !over.is_empty() {
crates\spt-store\src\perch.rs-37-            return PathBuf::from(over);
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-38-        }
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-39-    }
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-40-    platform_default_spt_home()
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-41-}
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-42-
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-43-#[cfg(windows)]
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-399-
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-400-    struct EnvGuard {
crates\spt-store\src\perch.rs-401-        _lock: std::sync::MutexGuard<'static, ()>,
crates\spt-store\src\perch.rs-402-        prev: Option<String>,
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-403-    }
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-404-    impl EnvGuard {
crates\spt-store\src\perch.rs-405-        fn set(tmp: &Path) -> Self {
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-406-            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crates\spt-store\src\perch.rs:407:            let prev = std::env::var("SPT_HOME").ok();
crates\spt-store\src\perch.rs:408:            std::env::set_var("SPT_HOME", tmp);
crates\spt-store\src\perch.rs-409-            EnvGuard { _lock: lock, prev }
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-410-        }
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\perch.rs-411-    }

[Showing results with pagination = limit: 60]


42
43	#[cfg(windows)]
44	fn platform_default_spt_home() -> PathBuf {
45	    if let Ok(local) = std::env::var("LOCALAPPDATA") {
46	        if !local.is_empty() {
47	            return PathBuf::from(local).join("spt-core");
48	        }
49	    }
50	    let up = std::env::var("USERPROFILE").expect("LOCALAPPDATA or USERPROFILE must be set on Windows");
51	    PathBuf::from(up).join("AppData").join("Local").join("spt-core")
52	}
53
54	#[cfg(unix)]
55	fn platform_default_spt_home() -> PathBuf {
56	    let home = std::env::var("HOME").expect("HOME must be set on Unix");
57	    PathBuf::from(home).join(".spt-core")
58	}
59
60	/// The owlery directory holding all perches: `<spt_home>/owlery/`.
61	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
62	pub fn owlery_dir() -> PathBuf {
63	    spt_home().join("owlery")
64	}
65
66	/// The synced-mind context tree: `<spt_home>/tracked/` (`docs/STORAGE.md`).
67	/// A git-backed two-tier store (`crate::contextstore`) — **durable, synced
68	/// across nodes** (the backup/migrate set), physically separate from the
69	/// never-synced `perches/` runtime. Pure path composition.
70	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
71	pub fn tracked_dir() -> PathBuf {
72	    spt_home().join("tracked")
73	}
74
75	/// Node-local **durable** identity/security material: `<spt_home>/identity/`
76	/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
77	/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
78	/// material (the node keypair + subnet TOTP seeds). Pure path composition.
79	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
80	pub fn identity_dir() -> PathBuf {
81	    spt_home().join("identity")

#[cfg(windows)]
fn platform_default_spt_home() -> PathBuf {
    if let Ok(local) = std::env::var("LOCALAPPDATA") {
        if !local.is_empty() {
            return PathBuf::from(local).join("spt-core");
        }
    }
    let up = std::env::var("USERPROFILE").expect("LOCALAPPDATA or USERPROFILE must be set on Windows");
    PathBuf::from(up).join("AppData").join("Local").join("spt-core")
}

#[cfg(unix)]
fn platform_default_spt_home() -> PathBuf {
    let home = std::env::var("HOME").expect("HOME must be set on Unix");
    PathBuf::from(home).join(".spt-core")
}

/// The owlery directory holding all perches: `<spt_home>/owlery/`.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn owlery_dir() -> PathBuf {
    spt_home().join("owlery")
}

/// The synced-mind context tree: `<spt_home>/tracked/` (`docs/STORAGE.md`).
/// A git-backed two-tier store (`crate::contextstore`) — **durable, synced
/// across nodes** (the backup/migrate set), physically separate from the
/// never-synced `perches/` runtime. Pure path composition.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn tracked_dir() -> PathBuf {
    spt_home().join("tracked")
}

/// Node-local **durable** identity/security material: `<spt_home>/identity/`
/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
/// material (the node keypair + subnet TOTP seeds). Pure path composition.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn identity_dir() -> PathBuf {
    spt_home().join("identity")


Design largely settled. Few more inputs: KNOWN-HAZARDS structure, TrustStore API, cli.rs:3185 context.


grep -n "^## \|^### \|^- \*\*" C:/Users/decid/Documents/projects/spt-core/docs/KNOWN-HAZARDS.md | head -80

Outline KNOWN-HAZARDS.md


9:## 1. Race conditions & ordering
11:### 1.1 Phantom INIT_SIGNOFF after grace period
12:- **Failure:** orphan teardown enqueues INIT_SIGNOFF before the grace-period recheck; a transient Self recovery (binary handoff, brief stale poll) makes the recheck pass-as-alive, but the signoff was already spooled and drains on the next iteration → teardown despite a live Self.
13:- **Invariant:** grace-period wait MUST complete *before* composing/delivering INIT_SIGNOFF; the recheck must bind `still_gone` before any envelope write.
14:- **spt-core mapping:** in-daemon now (no separate wrapper), but the ordering invariant is identical — orphan/teardown logic must re-evaluate liveness after the grace wait, not before enqueue.
15:- **Sister cite:** `src/live/wrapper/orphan.rs:201-259` (sleep@209 precedes compose@231-251); tests T-grace-recovery:576, T-still-gone-recheck:618.
17:### 1.2 Poll-rewrite race & info.json mid-write reads
18:- **Failure:** `info.json` written by the wrapper mid-iteration while a list/classify command reads it → torn read, misclassification.
19:- **Invariant:** consult liveness via the supervisor (`is_wrapper_alive`-equivalent) before any grace gate; reads of state files must tolerate concurrent writes (atomic write + rename, or read-retry).
20:- **spt-core mapping:** the daemon owns both writer and reader → use in-process locking/snapshotting instead of racing on disk. Cross-node registry reads remain eventually-consistent and must tolerate staleness.
21:- **Sister cite:** `src/common/list_filter.rs:100-150`; `src/owl/poll.rs:141`.
23:### 1.3 Stale `index.lock` wedge from prior git crash
24:- **Failure:** crashed git leaves a 0-byte `index.lock` in a psyche tracked worktree; every later commit blocks forever.
25:- **Invariant:** on daemon boot, sweep seed + all agent/project worktrees for stale locks (0 bytes, mtime > 60s) and remove; leave live locks alone.
26:- **spt-core mapping:** cross-node Psyche sync (ADR-0002/0003) replaces git-repo sync, so the *git* lock may disappear — but any equivalent lockfile in the new sync mechanism needs the same stale-sweep on boot.
27:- **Sister cite:** CHANGELOG v1.11.20 "Stale `index.lock`"; `src/common/git.rs`.
29:### 1.4 Deferred spool rows must not leak to the event stream
30:- **Failure:** a hook spools a deferred (spool-only, no TCP wake) notice; startup `drain_all` flushes ALL rows including deferred → event emitted at wrong time/priority.
31:- **Invariant:** startup drain (and idle/timeout TCP-wake sites) use `drain_non_deferred` only; deferred rows are picked up by their intended consumer via `peek`. All drain sites must agree on which rows they flush.
32:- **spt-core mapping:** carries directly — the daemon's spool-drain has the same deferred-vs-immediate distinction.
33:- **Sister cite:** `src/owl/poll.rs:276-316`; `spool::drain_non_deferred_with_metadata`.
35:### 1.5 Worker (working-perch) lifecycle path consistency
36:- **Failure:** subagent-start creates the perch at one path layout; later hooks read it at another → not found; stop-hook scan misses nested perches.
37:- **Invariant:** all Worker/Psyche child-perch path composition routes through one central resolver; no divergent path construction across hooks.
38:- **spt-core mapping:** `Worker` is a day-one endpoint type; the daemon owns the registry, so perch location is a registry lookup, not ad-hoc path math. Single source of truth for instance→location.
39:- **Sister cite:** `src/owl/hook_subagent_start.rs:122-168`; `hook_subagent_stop.rs:15-55`.
43:## 2. Identity & session-binding
45:### 2.1 Parent PID over ephemeral poll PID
46:- **Failure:** orphan check polls an ephemeral listener PID; it dies and is recycled (esp. Windows); a foreign process with the recycled PID reads as alive → false-positive teardown (or false-negative).
47:- **Invariant:** prefer the stable harness-session PID (`parent_pid`) over any ephemeral process PID for liveness; minimal `info.json` for supervisor-owned perches to avoid stale leaks.
48:- **spt-core mapping:** session binding (parent-process-tree anchor) still applies for harness-hosted topology. For spt-hosted sessions the broker holds the child directly → liveness is the broker's held-handle state, more reliable than PID polling.
49:- **Sister cite:** `src/live/wrapper/orphan.rs:141-161`; CHANGELOG v1.11.20.
51:### 2.2 Stdin session_id precedence over env
52:- **Failure:** subagent inherits a stale `OWL_SESSION_ID` env across `/clear`; hook gets two session_ids (fresh stdin, stale env) → wrong-agent binding.
53:- **Invariant:** stdin-provided session_id wins; env is fallback only.
54:- **spt-core mapping:** the harness-contract subcommand surface must define the same precedence for whatever identity fields hooks pass in.
55:- **Sister cite:** CHANGELOG v1.35.1 "IN-05"; `hook_subagent_start.rs:40-51`.
57:### 2.3 Binary-handoff argv schema must stay backward-compatible
58:- **Failure:** old binary spawns new binary with old argv arity; clap rejects before state rehydration → wrapper dies unlogged.
59:- **Invariant:** every newly-added handoff positional has a default; state-file rehydration happens *after* argv parse; defaults survive intermediate versions.
60:- **spt-core mapping:** CRITICAL — self-update (ADR-0004) makes handoff routine. The broker↔brain IPC and any brain-relaunch argv must be versioned and forward/backward tolerant (a newer brain talks to an older broker). This is the single most update-frequency-sensitive invariant.
61:- **Sister cite:** `src/live/wrapper/lifecycle.rs:17-106`; `src/cli.rs` defaults; CHANGELOG v1.11.10.
63:### 2.4 Generation `gen_start` always = now() on cold-start AND handoff
64:- **Failure:** stale gen_start from a rehydrated state file fires time-based discriminators on the new process.
65:- **Invariant:** wall-clock `gen_start` is set to `now()` on both cold-start and handoff; generation counter increments on every start/revive; session UUID captured fresh and carried so the resumed mind distinguishes "same gen continuing" vs "new gen born".
66:- **spt-core mapping:** carries to the daemon's per-instance generation tracking.
67:- **Sister cite:** `src/live/wrapper/lifecycle.rs:70`; `src/common/wrapper_state.rs`.
69:### 2.5 Daemon-hosted endpoints have no dedicated liveness PID
70:- **Failure:** the sister evaluates Psyche/perch liveness via a dedicated process PID — the wrapper's own pid in `info.json`, checked with `is_process_alive`. Under ADR-0004 the Psyche (and any spt-hosted Self) is a **loop inside the daemon**, not a separate process: it holds no dedicated pid, and its `claude`/summarizer subprocess is ephemeral (spawned per pulse/commune, then exits). If a daemon-hosted perch's `info.json` carries the **daemon's** pid, then *every* hosted endpoint shares one pid, and `is_process_alive(pid)` reads "alive" for a torn-down endpoint as long as the daemon runs — while `clean_stale_entries` (dead-pid deletion) can no longer distinguish a dead endpoint from a live one. The 2.1/5.1 liveness models do **not** cover this third category: the Psyche is neither harness-hosted (no `parent_pid` anchor) nor a broker-held PTY child.
71:- **Invariant:** for **daemon-hosted** perches (Psyche; spt-hosted Self), liveness is the **daemon's authoritative in-memory endpoint table + a `status` field** on `info.json` (`online|offline|…`), **never** `is_process_alive(info.pid)`. `info.pid` for a daemon-hosted perch is at most a *hosted-by-daemon* marker (the daemon pid), not a liveness signal; registry stale-clean for these rows keys on the daemon's endpoint table, not per-row pid. This reuses the pattern already specified for **Shells** (`info.json` carries daemon-managed `status`, capability resolved by `adapter_name` — CONTEXT "Shell… Not in the subnet registry") and extends it to daemon-hosted *agent* perches.
72:- **spt-core mapping:** the **M1/M2a interim** model keeps the Psyche/listener a real per-process owner (the `api listen` process), so its per-pid liveness (`deliver::is_online` → `info.read_pid` → `proc::is_process_alive`; `registry::clean_stale_entries`) is correct *interim*. **M3 daemon consolidation replaces it** with daemon-authoritative liveness for hosted perches. Keep the liveness check behind one resolver (mirrors `resolve_address` stale-clean) so the M3 swap is localized — do **not** let the per-pid assumption leak into new call sites.
73:- **Sister cite:** `src/live/wrapper/orphan.rs` (wrapper-pid liveness); `src/common/list_filter.rs:168-175` (pid-classify); spt-core `crates/spt-store/src/{proc.rs,registry.rs}` + `crates/spt-msg/src/deliver.rs::is_online`.
77:## 3. Lifecycle
79:### 3.1 Ephemeral perch cleanup on every `ring` exit path
80:- **Failure:** `ring` creates an ephemeral perch; early-exit paths (no-perch, empty-msg, timeout) skip cleanup → stale dirs accumulate.
81:- **Invariant:** every code path that creates an ephemeral perch cleans it before exit; exception: if the caller already had an active perch, do not treat as ephemeral and do not clean up.
82:- **spt-core mapping:** `ring` semantics carry; the daemon owns ephemeral-perch lifecycle, so a single guaranteed-cleanup (drop guard / RAII) is achievable in-process.
83:- **Sister cite:** `src/owl/ring.rs:58-294`.
85:### 3.2 Stale signoff sentinel must not kill a fresh start
86:- **Failure:** a leftover `.claude/<id>-signoff.md` from a prior session is read by a fresh listener as a live signoff → immediate teardown.
87:- **Invariant:** on every listener/daemon spawn, sweep stale signoff sentinels; signoff files are write-once per generation.
88:- **spt-core mapping:** same sweep on daemon (re)start per hosted instance.
89:- **Sister cite:** CHANGELOG v1.11.20; `src/owl/cleanup.rs:97`.
91:### 3.3 Orphan teardown fires echo-commune BEFORE INIT_SIGNOFF
92:- **Failure:** teardown delivers INIT_SIGNOFF without first saving the final context delta → Psyche signoff lacks the context-save summary.
93:- **Invariant:** on orphan path, synchronously run the echo-commune (final delta) before composing INIT_SIGNOFF; skip only if the session_id is missing.
94:- **spt-core mapping:** the daemon runs psyche/pulse loops in-process; ordering invariant identical.
95:- **Sister cite:** `src/live/wrapper/orphan.rs:175-199`; tests A-H:333-565.
99:## 4. Wire / transport
101:### 4.1 Envelope HTML-entity codec ordering — `&` decoded LAST
102:- **Failure:** decoding `&`-entity before the others double-decodes nested entities (`&amp;lt;` → wrong result).
103:- **Invariant:** ENCODE order amp→first … `<br>`→last; DECODE order `<br>`→first … amp→**last** (`<`,`>`,`"`, then `&`). One sole decode site (at the LLM/stdin boundary); the parser never decodes.
104:- **spt-core mapping:** `spt-proto` owns the envelope grammar (public SDK, semver + wire-version). This codec contract is a copy-verbatim commodity item (ADR-0001) and a public-API conformance test.
105:- **Sister cite:** `src/owl/poll.rs:1-73`; `src/common/envelope.rs`.
107:### 4.2 Two-slice envelope parser is panic-free and tolerant
108:- **Failure:** malformed envelope (unclosed/misordered/nested tags) panics or drops output.
109:- **Invariant:** tags case-sensitive, all optional; no tags → whole body to live slot; unclosed → None for that tag; out-of-order → both still extracted; nested unknown tags preserved verbatim; zero `unwrap` on parsed text.
110:- **spt-core mapping:** `spt-proto` parser; property-test the robustness rules.
111:- **Sister cite:** `src/common/envelope.rs:64-92`; tests 99-207.
113:### 4.3 Registry stale-entry cleanup precedes lookup


No matches found


3150	        E::Vocab(msg) => eprintln!("{verb}_REFUSED:{shell_ref}: {msg}"),
3151	        E::AlreadyOnline => eprintln!(
3152	            "SHELL_ALREADY_ONLINE:{shell_ref}: relink is the online switch — nothing to do"
3153	        ),
3154	        E::Failed(msg) => eprintln!("{verb}_FAIL:{shell_ref}: {msg}"),
3155	    }
3156	    1
3157	}
3158
3159	/// Run a shell-link action against the shell's node (M5-D8c): resolve the
3160	/// OWNER's instance at `@node` (the owner's original seat is registered
3161	/// there), dial, and let the target run the action in its own owlery.
3162	// [impl->REQ-SHELL-2]
3163	fn shell_link_remote(owner: &str, node: &str, shell_ref: &str, action: &str, args: &[String]) -> i32 {
3164	    let target = format!("{owner}@{node}");
3165	    match crate::wansend::wan_shell_link(&target, shell_ref, action, args) {
3166	        crate::wansend::WanShellLinkOutcome::Ok { node, detail } => {
3167	            eprintln!("SHELL_REMOTE:{shell_ref} at {node}: {detail}");
3168	            0
3169	        }
3170	        crate::wansend::WanShellLinkOutcome::NoShell { node } => {
3171	            eprintln!("NO_SHELL:{shell_ref}: no such instance for {owner} at {node}");
3172	            1
3173	        }
3174	        crate::wansend::WanShellLinkOutcome::NoReply { node } => {
3175	            eprintln!(
3176	                "SHELL_REMOTE_REFUSED:{shell_ref}: {node} closed the stream without a reply (access gate)"
3177	            );
3178	            1
3179	        }
3180	        crate::wansend::WanShellLinkOutcome::RemoteFail { node, detail } => {
3181	            eprintln!("SHELL_REMOTE_FAIL:{shell_ref} at {node}: {detail}");
3182	            1
3183	        }
3184	        crate::wansend::WanShellLinkOutcome::Ambiguous(a) => {
3185	            eprintln!("AMBIGUOUS:{target} — qualify the address: {a}");
3186	            1
3187	        }
3188	        crate::wansend::WanShellLinkOutcome::NotFound => {
3189	            eprintln!("NO_ENDPOINT:{target}: no routable remote instance in any visible subnet");
3190	            1
3191	        }
3192	        crate::wansend::WanShellLinkOutcome::Failed(e) => {
3193	            eprintln!("SHELL_REMOTE_FAIL:{shell_ref}: {e}");
3194	            1
3195	        }
3196	    }
3197	}
3198
3199	/// Parse the user's escalation prompt choice — fail-closed: anything that is
3200	/// not an explicit allow is a deny, and `always` is only an allow where
3201	/// persistence is legal (`require_approval = "remembered"`; the per-spawn
3202	/// modes — `always`, `over_cap = "approve"` — suppress it by definition).
3203	// [impl->REQ-CONSENT-2]
3204	fn parse_escalation_choice(input: &str, persist_allowed: bool) -> spt_daemon::grants::EscalationAnswer {
3205	    use spt_daemon::grants::EscalationAnswer;
3206	    match input.trim().to_ascii_lowercase().as_str() {
3207	        "o" | "once" => EscalationAnswer::AllowOnce,
3208	        "a" | "always" if persist_allowed => EscalationAnswer::AllowAlways,
3209	        _ => EscalationAnswer::Deny,

        E::Vocab(msg) => eprintln!("{verb}_REFUSED:{shell_ref}: {msg}"),
        E::AlreadyOnline => eprintln!(
            "SHELL_ALREADY_ONLINE:{shell_ref}: relink is the online switch — nothing to do"
        ),
        E::Failed(msg) => eprintln!("{verb}_FAIL:{shell_ref}: {msg}"),
    }
    1
}

/// Run a shell-link action against the shell's node (M5-D8c): resolve the
/// OWNER's instance at `@node` (the owner's original seat is registered
/// there), dial, and let the target run the action in its own owlery.
// [impl->REQ-SHELL-2]
fn shell_link_remote(owner: &str, node: &str, shell_ref: &str, action: &str, args: &[String]) -> i32 {
    let target = format!("{owner}@{node}");
    match crate::wansend::wan_shell_link(&target, shell_ref, action, args) {
        crate::wansend::WanShellLinkOutcome::Ok { node, detail } => {
            eprintln!("SHELL_REMOTE:{shell_ref} at {node}: {detail}");
            0
        }
        crate::wansend::WanShellLinkOutcome::NoShell { node } => {
            eprintln!("NO_SHELL:{shell_ref}: no such instance for {owner} at {node}");
            1
        }
        crate::wansend::WanShellLinkOutcome::NoReply { node } => {
            eprintln!(
                "SHELL_REMOTE_REFUSED:{shell_ref}: {node} closed the stream without a reply (access gate)"
            );
            1
        }
        crate::wansend::WanShellLinkOutcome::RemoteFail { node, detail } => {
            eprintln!("SHELL_REMOTE_FAIL:{shell_ref} at {node}: {detail}");
            1
        }
        crate::wansend::WanShellLinkOutcome::Ambiguous(a) => {
            eprintln!("AMBIGUOUS:{target} — qualify the address: {a}");
            1
        }
        crate::wansend::WanShellLinkOutcome::NotFound => {
            eprintln!("NO_ENDPOINT:{target}: no routable remote instance in any visible subnet");
            1
        }
        crate::wansend::WanShellLinkOutcome::Failed(e) => {
            eprintln!("SHELL_REMOTE_FAIL:{shell_ref}: {e}");
            1
        }
    }
}

/// Parse the user's escalation prompt choice — fail-closed: anything that is
/// not an explicit allow is a deny, and `always` is only an allow where
/// persistence is legal (`require_approval = "remembered"`; the per-spawn
/// modes — `always`, `over_cap = "approve"` — suppress it by definition).
// [impl->REQ-CONSENT-2]
fn parse_escalation_choice(input: &str, persist_allowed: bool) -> spt_daemon::grants::EscalationAnswer {
    use spt_daemon::grants::EscalationAnswer;
    match input.trim().to_ascii_lowercase().as_str() {
        "o" | "once" => EscalationAnswer::AllowOnce,
        "a" | "always" if persist_allowed => EscalationAnswer::AllowAlways,
        _ => EscalationAnswer::Deny,


grep -n "^## \|^### " C:/Users/decid/Documents/projects/spt-core/docs/KNOWN-HAZARDS.md

All KNOWN-HAZARDS section headers


9:## 1. Race conditions & ordering
11:### 1.1 Phantom INIT_SIGNOFF after grace period
17:### 1.2 Poll-rewrite race & info.json mid-write reads
23:### 1.3 Stale `index.lock` wedge from prior git crash
29:### 1.4 Deferred spool rows must not leak to the event stream
35:### 1.5 Worker (working-perch) lifecycle path consistency
43:## 2. Identity & session-binding
45:### 2.1 Parent PID over ephemeral poll PID
51:### 2.2 Stdin session_id precedence over env
57:### 2.3 Binary-handoff argv schema must stay backward-compatible
63:### 2.4 Generation `gen_start` always = now() on cold-start AND handoff
69:### 2.5 Daemon-hosted endpoints have no dedicated liveness PID
77:## 3. Lifecycle
79:### 3.1 Ephemeral perch cleanup on every `ring` exit path
85:### 3.2 Stale signoff sentinel must not kill a fresh start
91:### 3.3 Orphan teardown fires echo-commune BEFORE INIT_SIGNOFF
99:## 4. Wire / transport
101:### 4.1 Envelope HTML-entity codec ordering — `&` decoded LAST
107:### 4.2 Two-slice envelope parser is panic-free and tolerant
113:### 4.3 Registry stale-entry cleanup precedes lookup
119:### 4.4 Deferred rows survive poll drain
124:### 4.5 Inbox legacy compat must not double-deliver
130:### 4.6 Addressable-id charset reserves the address delimiters
136:### 4.7 Concurrent SQLite openers must not fail with "database is locked"
142:### 4.8 Registry merge ordered by epoch, never wall-clock (red-team #8)
148:### 4.9 SQLite stores must create their parent dir — SQLite won't
156:## 5. Platform-specific
158:### 5.1 Windows PID recycling false positives
164:### 5.2 Windows EBUSY on atomic rename
170:### 5.3 Git/subprocess timeout stamping
176:### 5.4 Windows UNC prefix in serialized paths
182:### 5.5 ConPTY withholds output until DSR is answered  `[REQ-HAZARD-CONPTY-DSR]`
189:### 5.6 Windows detached children inherit a captured caller's pipe  `[REQ-HAZARD-DETACHED-PIPE-INHERIT]`
197:## 6. Documented regressions (non-obvious invariants)
199:### 6.1 No flat/nested perch siblings; resolver-routed paths
205:### 6.2 Soft-cleanup preserves state, removes `ready`
211:### 6.3 Cascade-wipe guard: never delete a parent hosting non-empty children
217:### 6.4 Drop files are single-writer (supervisor-owned), read-only for the mind
223:### 6.5 Direct-write precedence guard against stale LLM overwrites
229:### 6.6 Surfaced context conflicts preserve both versions until dominated
237:## 7. Boundary & delivery integrity (added 2026-05-31 — Stage A red-team)
241:### 7.1 Local `api` mutation auth  `[REQ-HAZARD-LOCAL-API-AUTH]`
247:### 7.2 Idempotent delivery across brain restart  `[REQ-HAZARD-RESTART-IDEMPOTENT]`
253:### 7.3 Psyche outbound capture + sanitization  `[REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`
259:### 7.4 Per-agent pulse/psyche/echo scheduling must not serialize across agents  `[REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]`
265:### 7.5 WAN-inbound origin is transport truth, never payload  `[REQ-HAZARD-WAN-ORIGIN-AUTH]`
274:## Conformance checklist (condensed)


259	### 7.4 Per-agent pulse/psyche/echo scheduling must not serialize across agents  `[REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]`
260	- **Failure:** echo-commune (`run_bounded_stdin`) and the live-Psyche turn driver (D7.5) are **bounded LLM calls that block their calling thread** until the child answers or the timeout fires. Today each agent drives its own pulse from its **own process** (`spt/src/api/{live,startup}.rs`), so blocking is isolated. When the daemon hosts **N per-agent loops** (ADR-0004 target; the `run_pulse_loop` fan-out is currently def+test only), a **single serial driver** that calls these invocations inline lets one agent's slow/hung LLM call **stall every other agent's heartbeat, commune, and reply** — the per-call timeout bounds the worst case *only* when the work is isolated; serial makes the stalls additive.
261	- **Invariant:** each agent's bounded LLM-bearing work (echo-commune summarizer, Psyche turn) runs on its **own thread / off the shared scheduler** — no single-threaded driver iterating all agents may call a blocking invocation inline. One agent's slow/timed-out call must not delay another agent's next tick beyond tolerance.
262	- **spt-core mapping:** the daemon's multi-agent pulse/psyche hosting (ADR-0004, "all Psyche/pulse loops" consolidated); `run_pulse_loop` fan-out; echo-commune + the D7.5 Psyche driver.
263	- **Source:** grill-with-docs 2026-06-03 (forward invariant — the multi-agent fan-out is not yet wired). Distinct from ADR-0002 SERIOUS #6 (crash blast-radius, not scheduling latency).
264
265	### 7.5 WAN-inbound origin is transport truth, never payload  `[REQ-HAZARD-WAN-ORIGIN-AUTH]`
266	<!-- [doc->REQ-HAZARD-WAN-ORIGIN-AUTH] -->
267	- **Failure:** the ADR-0009 access whitelist gates **unsolicited wire inbound by origin node**. If the gate's subject is read from record bytes (an `origin_node`/`from`/`node` field a sender wrote), any sender forges any origin and the whitelist is decoration — same spoof class as 7.3's Psyche-supplied `from=`, now on the cross-node surface.
268	- **Invariant:** the origin the gate (and detection/UX — "node X is driving") consumes is the **QUIC handshake-proven remote node id** (iroh `EndpointId` == Ed25519 node pubkey, the REQ-NET-1 identity binding) read from the **broker's conn/stream table** (`NetStreamInfo::remote_id_hex`), never from payload. Wire records carry **no origin field by design**; a forged one decodes as an ignored unknown field and influences nothing. `from` inside a record is reply-routing metadata only — never an authorization subject.
269	- **spt-core mapping:** `spt_net::net::wanmsg::WanMessage` (no origin field) + `spt_daemon::wan::receive_wan` (origin parameter sourced from the stream table) + every future wire-inbound consumer (D5b attach, D5c transfer, D8 notifs).
270	- **Source:** M4-D5a design (ADR-0009 consequence: the gate must bind to the transport identity).
271
272	---
273
274	## Conformance checklist (condensed)
275
276	| # | Invariant | spt-core surface |
277	|---|---|---|
278	| 1.1 | Grace wait precedes INIT_SIGNOFF | daemon teardown |
279	| 1.4/4.4 | Deferred rows excluded from event-stream drain | daemon spool drain |
280	| 2.1/5.1 | Stable PID/broker-handle over ephemeral PID | liveness detection |
281	| 2.3 | Handoff argv/IPC version-tolerant (newer brain ↔ older broker) | broker↔brain IPC, self-update |
282	| 2.4 | gen_start = now() on cold-start + handoff | per-instance generation |
283	| 3.1 | Ephemeral perch cleanup on all exit paths | `ring` (RAII guard) |
284	| 3.3 | Echo-commune before INIT_SIGNOFF | daemon psyche loop |
285	| 4.1 | Envelope decode order, `&` last | spt-proto (public, wire-versioned) |
286	| 4.2 | Parser panic-free + tolerant | spt-proto |
287	| 4.3 | Stale registry entries → fallback, never hard-fail | subnet registry resolution |
288	| 4.6 | Addressable-id charset reserves `:`/`@` delimiters | `spt_proto::id` at creation seams |
289	| 5.2 | tmp-write + atomic-rename + retry (EBUSY) | all state writes, binary swap |
290	| 5.3 | Timeout every harness subprocess | manifest invocations |
291	| 5.4 | Strip UNC prefix on serialized paths | spt-proto path normalization |
292	| 5.5 | ConPTY reader answers DSR (`ESC[6n`) | spt-term broker PTY reader |
293	| 6.1 | Single path/registry source of truth | storage layout |
294	| 6.4 | Drop files supervisor-owned single-writer | runtime contract |
295	| 6.5 | Direct-write precedence marker (+ node id) | cross-node Psyche sync |
296	| 6.6 | Surfaced conflicts preserve both versions until dominated | context conflict artifacts (ADR-0013) |
297	| 7.1 | Local `api` mutation authenticated to endpoint | api surface / broker IPC |
298	| 7.2 | Idempotent delivery across brain restart | broker↔brain IPC |
299	| 7.3 | Psyche outbound captured + `from=`/target stripped + reply-to-sender / notify-to-own-user | live-Psyche driver / daemon relay (ADR-0012) |
300	| 7.4 | Per-agent pulse/psyche/echo runs off the shared scheduler (no serial blocking across agents) | daemon multi-agent hosting (ADR-0004) |
301	| 7.5 | WAN-inbound origin = QUIC handshake identity from the broker's stream table, never payload bytes | wan receive funnel + every wire-inbound consumer (ADR-0009) |
302

### 7.4 Per-agent pulse/psyche/echo scheduling must not serialize across agents  `[REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]`
- **Failure:** echo-commune (`run_bounded_stdin`) and the live-Psyche turn driver (D7.5) are **bounded LLM calls that block their calling thread** until the child answers or the timeout fires. Today each agent drives its own pulse from its **own process** (`spt/src/api/{live,startup}.rs`), so blocking is isolated. When the daemon hosts **N per-agent loops** (ADR-0004 target; the `run_pulse_loop` fan-out is currently def+test only), a **single serial driver** that calls these invocations inline lets one agent's slow/hung LLM call **stall every other agent's heartbeat, commune, and reply** — the per-call timeout bounds the worst case *only* when the work is isolated; serial makes the stalls additive.
- **Invariant:** each agent's bounded LLM-bearing work (echo-commune summarizer, Psyche turn) runs on its **own thread / off the shared scheduler** — no single-threaded driver iterating all agents may call a blocking invocation inline. One agent's slow/timed-out call must not delay another agent's next tick beyond tolerance.
- **spt-core mapping:** the daemon's multi-agent pulse/psyche hosting (ADR-0004, "all Psyche/pulse loops" consolidated); `run_pulse_loop` fan-out; echo-commune + the D7.5 Psyche driver.
- **Source:** grill-with-docs 2026-06-03 (forward invariant — the multi-agent fan-out is not yet wired). Distinct from ADR-0002 SERIOUS #6 (crash blast-radius, not scheduling latency).

### 7.5 WAN-inbound origin is transport truth, never payload  `[REQ-HAZARD-WAN-ORIGIN-AUTH]`
<!-- [doc->REQ-HAZARD-WAN-ORIGIN-AUTH] -->
- **Failure:** the ADR-0009 access whitelist gates **unsolicited wire inbound by origin node**. If the gate's subject is read from record bytes (an `origin_node`/`from`/`node` field a sender wrote), any sender forges any origin and the whitelist is decoration — same spoof class as 7.3's Psyche-supplied `from=`, now on the cross-node surface.
- **Invariant:** the origin the gate (and detection/UX — "node X is driving") consumes is the **QUIC handshake-proven remote node id** (iroh `EndpointId` == Ed25519 node pubkey, the REQ-NET-1 identity binding) read from the **broker's conn/stream table** (`NetStreamInfo::remote_id_hex`), never from payload. Wire records carry **no origin field by design**; a forged one decodes as an ignored unknown field and influences nothing. `from` inside a record is reply-routing metadata only — never an authorization subject.
- **spt-core mapping:** `spt_net::net::wanmsg::WanMessage` (no origin field) + `spt_daemon::wan::receive_wan` (origin parameter sourced from the stream table) + every future wire-inbound consumer (D5b attach, D5c transfer, D8 notifs).
- **Source:** M4-D5a design (ADR-0009 consequence: the gate must bind to the transport identity).

---

## Conformance checklist (condensed)

| # | Invariant | spt-core surface |
|---|---|---|
| 1.1 | Grace wait precedes INIT_SIGNOFF | daemon teardown |
| 1.4/4.4 | Deferred rows excluded from event-stream drain | daemon spool drain |
| 2.1/5.1 | Stable PID/broker-handle over ephemeral PID | liveness detection |
| 2.3 | Handoff argv/IPC version-tolerant (newer brain ↔ older broker) | broker↔brain IPC, self-update |
| 2.4 | gen_start = now() on cold-start + handoff | per-instance generation |
| 3.1 | Ephemeral perch cleanup on all exit paths | `ring` (RAII guard) |
| 3.3 | Echo-commune before INIT_SIGNOFF | daemon psyche loop |
| 4.1 | Envelope decode order, `&` last | spt-proto (public, wire-versioned) |
| 4.2 | Parser panic-free + tolerant | spt-proto |
| 4.3 | Stale registry entries → fallback, never hard-fail | subnet registry resolution |
| 4.6 | Addressable-id charset reserves `:`/`@` delimiters | `spt_proto::id` at creation seams |
| 5.2 | tmp-write + atomic-rename + retry (EBUSY) | all state writes, binary swap |
| 5.3 | Timeout every harness subprocess | manifest invocations |
| 5.4 | Strip UNC prefix on serialized paths | spt-proto path normalization |
| 5.5 | ConPTY reader answers DSR (`ESC[6n`) | spt-term broker PTY reader |
| 6.1 | Single path/registry source of truth | storage layout |
| 6.4 | Drop files supervisor-owned single-writer | runtime contract |
| 6.5 | Direct-write precedence marker (+ node id) | cross-node Psyche sync |
| 6.6 | Surfaced conflicts preserve both versions until dominated | context conflict artifacts (ADR-0013) |
| 7.1 | Local `api` mutation authenticated to endpoint | api surface / broker IPC |
| 7.2 | Idempotent delivery across brain restart | broker↔brain IPC |
| 7.3 | Psyche outbound captured + `from=`/target stripped + reply-to-sender / notify-to-own-user | live-Psyche driver / daemon relay (ADR-0012) |
| 7.4 | Per-agent pulse/psyche/echo runs off the shared scheduler (no serial blocking across agents) | daemon multi-agent hosting (ADR-0004) |
| 7.5 | WAN-inbound origin = QUIC handshake identity from the broker's stream table, never payload bytes | wan receive funnel + every wire-inbound consumer (ADR-0009) |


Fix 2: Ghost registry row eviction