# traceable-reqs manifest for spt-core — SEED. # # Authoritative requirement registry: every PRD R-* requirement and every # KNOWN-HAZARDS invariant lives here as a REQ-* id (see docs/TRACEABILITY.md # for the development contract). # # ACTIVATION MODEL: every requirement starts inactive (`required_stages = []`) # so `check` stays green pre-code. A milestone (M0-M5) ACTIVATES its requirements # by setting their real `required_stages`. Validate this seed against the # installed CLI at M0 (the binary isn't installed yet). [scan] # CONTEXT.md is a file root: the glossary is authoritative for meaning and # carries doc-stage tags (e.g. §Installation). installer/ added M6-D2. roots = ["src", "tests", "docs", "crates", "installer", "CONTEXT.md", "docs-site", ".github"] [policy] # Default policy applied at activation: doc + impl + unit. Networking/lifecycle # reqs add "int". Until a req is activated, its per-req `required_stages = []` # keeps it out of the coverage gate. required_stages = [] # ───────────────────────────── Architecture / workspace ───────────────────── [[requirements]] id = "REQ-ARCH-1" title = "Many small acyclically-layered crates" required_stages = ["impl"] # activated M0: spt-proto<-spt-store one-way layering (T14). impl-only: architecture is build-evidenced (cargo rejects cycles); grows as crates are added M1+. [[requirements]] id = "REQ-ARCH-2" title = "Public SDK surface is spt-proto, spt-runtime, spt-msg" required_stages = ["impl"] [[requirements]] id = "REQ-ARCH-3" title = "Wire-protocol version independent of crate semver, N-1 compat window" required_stages = ["impl", "unit"] # activated M0: spt-proto wire version + compat window (T6) [[requirements]] id = "REQ-ARCH-4" title = "Copy-verbatim the commodity layer from the sister project" required_stages = ["impl", "unit"] # activated M0: spt-proto EVENT grammar (T2) # ───────────────────────────── Daemon ─────────────────────────────────────── [[requirements]] id = "REQ-DAEMON-1" title = "One per-machine spt-daemon owning all per-machine state" required_stages = ["impl", "unit", "int"] # int activated M3b-B9: the daemon E2E (spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff) drives the consolidated daemon-hosted lifecycle through the real IPC [[requirements]] id = "REQ-DAEMON-2" title = "Broker/brain split for seamless self-update" required_stages = ["impl", "unit", "int"] # int RE-POINTED at restoration D7-1 (ADR-0018 V5) to the PROCESS-level survival E2E (crates/spt/tests/brain_survive.rs: broker/brain split delivers the seamless update at the process level — a brain-PROCESS restart onto a swapped binary, PTY child + QUIC conn held). Was M3b-B9 daemon_e2e + handoff.rs (the in-process handoff shape — regression-masked per ADR-0018); those tags removed in the same D7-1 commit. The net-ownership facet (netbroker.rs) stays distinct evidence [[requirements]] id = "REQ-DAEMON-3" title = "Any api invocation auto-starts the daemon if absent" required_stages = ["impl", "unit", "int"] [[requirements]] id = "REQ-DAEMON-4" title = "Honor every KNOWN-HAZARDS invariant" required_stages = ["impl", "unit", "int"] # int activated M3b-B9: the daemon E2E proves graceful signoff runs the echo-before-teardown ordering (3.3) and removes the Self's ready marker in the consolidated daemon # ───────────────────────────── Storage ────────────────────────────────────── # git-KV BranchStore substrate (ADR-0011, 2026-06-02; implement M4, optional M3c) [[requirements]] id = "REQ-STORE-1" title = "spt-store::BranchStore (git branch as versioned KV; commit=checkpoint/tip=resume, atomic multi-key, merge-native sync) is the substrate for coarse/durable/audited state (context, registry snapshot+distribution, daemon checkpoint); hot paths (B5 fsync journal) + indexed queries (SQLite spool) excluded (ADR-0011)" required_stages = ["impl", "unit"] # activated M4-D6a: spt_store::branchstore::BranchStore — bare seed repo + linked worktrees (relative-path config, repair fallback), ensure_branch = parentless empty-tree seed (independent KV roots; sync joins with --allow-unrelated-histories at first contact), commit_in_worktree = ONE commit per write incl. atomic multi-key (no-op mints no empty checkpoint), tip/read_at_tip = the recovery read. Git-CLI-backed per ADR-0013 (bounded runner gitrun.rs, hazard 5.3; identity pinned via env, never user config). First consumer: contextstore.rs two-tier tracked/ layout (a-/p- branches) + project.rs project_id derivation (remote-URL slug → toplevel folder → dir name). unit = init-idempotent + commit/read/tip + no-op-no-commit + one-commit-multi-key + independent-roots + tier layout/commits + p-branch-aggregates-agents + invalid-id-refused + url-normalization + derivation ladder # ───────────────────────────── Harness contract — manifest seams ──────────── [[requirements]] id = "REQ-MANIFEST-1" title = "Per-adapter manifest with adapter_name and min_spt_core_version" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-MANIFEST-2" title = "Adapter profiles — sparse leaf-replace overlays (shipped + local), composite : addressing, shadow-refusal, tighten-only consent floors" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-MANIFEST-3" title = "Adapter strings — [strings] KV tree, dot-path get-string resolving through the profile leaf-replace overlay, set-string editing a local profile's [strings] only; data-only (nothing executes a string)" required_stages = ["doc", "impl", "unit"] # activated 2026-06-12: M9-T2 (CONTEXT.md §adapter strings, adapter-string get/set CLI + overlay resolution) [[requirements]] id = "REQ-MANIFEST-4" title = "Keyword hints — [[hints]] {keywords (literal/regex), text}; spt api hint --session emits at most one matched hint per message, once per session (seen-set), declaration-order first match; profiles overlay [[hints]] by leaf-replace" required_stages = ["doc", "impl", "unit"] # activated 2026-06-12: M9-T3 (CONTEXT.md §keyword hints, api hint surface + per-session seen-set) [[requirements]] id = "REQ-MANIFEST-5" title = "File-backed adapter [strings] (M12-W3-T3.1): a [strings] dot-path value MAY be an inline-table FILE POINTER `key = { file = \"rel/path\" }` resolved to the file's contents at get-string time, keeping large bodies (skill-instructions, hint text) out of the manifest. A value-position table with a `file` key IS the pointer form (reserved — cannot double as data). Per-adapter aux storage `adapters//strings/`; pointers resolve relative to it with CONTAINMENT (reject `..`/absolute escaping the dir). UPDATE-SAFETY: a LOCAL profile's file-pointers resolve relative to the user-owned local-profile dir (NOT adapter-shipped strings/, which adapter updates overwrite), or the local profile inlines. Validate-at-register (fail-fast on a bad/escaping/missing pointer) + LAZY read at get-string (live file edits reflect, no re-register) + skip-diagnostics on missing-at-read (no hard-crash, mirrors [digest]). Rides the same leaf-replace profile overlay as the rest of [strings]." required_stages = ["doc", "impl", "unit"] # activated M12-W3 [[requirements]] id = "REQ-MANIFEST-6" title = "Cross-adapter fallback target addressing (M12-W3-T3.2): a cross-adapter fallback target is addressed as `:` (not just a bare adapter_name), resolved through the one composite-addressing resolver (registry::resolve_option) at every adapter-option read site so a fallback may select a shipped/local profile (e.g. a `ccs` profile). CONTEXT.md §cross-adapter-fallback reconciled (\"ccs is a profile; cross-adapter fallback may target :\"). Contract-only this milestone: the node-wide fallback SETTING + its rate-limit invocation are deferred to the consuming milestone (the runtime path does not exist yet); this REQ guarantees the ADDRESSING resolves." required_stages = ["doc", "unit"] # activated M12-W3 (contract-only: no int) [[requirements]] id = "REQ-MANIFEST-7" title = "Adapter-declared shortcut basename (M12-W2 follow-on): an optional `[adapter] shortcut_basename` manifest field names the basename the `spt endpoint run` picker bakes into the generated `-` launcher shortcut (REQ-RUN-SHORTCUT). Absent ⇒ the harness-agnostic default `spt` (→ `spt-`); an adapter sets it to brand its shortcuts (claude-spt → `cc` → `cc-`), so the Claude-Code-ness lives in the PUBLISHED adapter manifest, never hardcoded in spt-core. The picker reads it from the RESOLVED manifest of the selected adapter (registry::resolve_option), falling back to `spt` when absent/empty/unresolvable. Additive + N-1-safe (serde-default Option, omitted from serialization when absent; old manifests parse clean); manifest.schema.json regenerated from the derive (ADR-0001, CI drift-gated). Documented in docs/MANIFEST.md `[adapter]` section + the claude-spt worked example — the adapter-author contract perri builds spt-claude-code against." required_stages = ["doc", "impl", "unit"] # activated M12-W2 follow-on [[requirements]] id = "REQ-MANIFEST-8" title = "[adapter] host_binaries declares the harness executable basenames a kind=\"harness\" adapter hosts agents inside (e.g. host_binaries = [\"claude\"]); bind-time pid→exe-basename match (case-insensitive, .exe-stripped) over the seed's parent_pid selects the candidate adapter set; zero matches → a friendly error naming the binary + the --adapter escape hatch. Additive + N-1-safe: optional Vec, #[serde(default, skip_serializing_if = \"Vec::is_empty\")] (omitted-serialized like shortcut_basename, old manifests parse clean); manifest.schema.json regenerated from the derive (ADR-0001, CI drift-gated). The match-key for ADR-0021 adapter-agnostic bind-time resolution. (v0.9.0)" required_stages = ["doc", "impl", "unit", "int"] # activated v0.9.0 W2: doc = CONTEXT.md §179 host_binaries candidate rule + docs/MANIFEST.md + docs-site harness-contract host_binaries field. impl = the `[adapter] host_binaries` field (manifest.rs, serde-default omit-empty) + spt_store::proc::exe_basename (Win QueryFullProcessImageNameW / Linux /proc/pid/exe) + the resolver's host_binaries candidate filter (spt-runtime resolve). unit = host_binaries_optional_and_n1_safe (manifest) + exe_basename_resolves_current_process/dead_pid_is_none (proc) + no_candidate/normalize tests (resolve). int folds into REQ-START-5's final bringup E2E [[requirements]] id = "REQ-SEAM-SPAWN" title = "spawn-session seam" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-SEAM-POSTSPAWN" title = "post-spawn / api bind seam with boot nonce" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-SEAM-PSYCHE" title = "spawn-psyche seam (fresh + resume templates)" required_stages = ["impl", "unit", "int"] [[requirements]] id = "REQ-SEAM-HISTORY" title = "History subsystem (fetcher / locate-normalize / native store)" required_stages = ["impl", "unit", "int"] [[requirements]] id = "REQ-SEAM-ACTIVITY" title = "Activity/idle reported via api sentinels, not PTY quiescence" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-SEAM-INJECT" title = "inject-input methods configurable per activity-state" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-SEAM-RESUME" title = "resume-session seam (fresh-with-preload / continue-existing)" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-SEAM-CAPABILITY" title = "Hostable endpoint-types capability declaration" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-SEAM-UPDATE" title = "Adapter-update avenue (file-pull / delegated command)" required_stages = ["impl", "unit"] # activated M3c-C3: manifest [update] gains signing_key (file_pull content key) + self_verifies (delegated attest); adapter_update::plan_adapter_update dispatches each avenue (file_pull verified against the adapter key, delegated delegated-or-skipped) # ───────────────────────────── Harness contract — api surface ─────────────── [[requirements]] id = "REQ-API-1" title = "api prefix and adapter_name on every machinery invocation" required_stages = ["impl", "unit", "int"] [[requirements]] id = "REQ-API-2" title = "The api subcommand surface (bind/listen/poll/state/worker/boundary/...)" required_stages = ["impl", "unit", "int"] [[requirements]] id = "REQ-API-3" title = "commune/signoff are file-drops, not commands" required_stages = ["impl", "unit", "int"] [[requirements]] id = "REQ-API-4" title = "api resolves the adapter manifest (+ profile + install dir) from `--adapter name:profile` via the registry when `--manifest` is omitted; `--manifest` becomes an optional OVERRIDE (unregistered / local-dev manifests). Removes the require-both-flags redundancy — a registered adapter's live bringup / digest / capability needs only `--adapter` — and yields the precise install dir (the record's source_dir) rather than the --manifest parent, closing the copy-mode psyche-binary edge (v0.8.0)" required_stages = ["doc", "impl", "unit"] # activate v0.8.0: doc = CONTEXT.md "Manifest resolution from --adapter (since v0.8.0)" §inbound-api-surface. impl = api/mod.rs run() — when args.manifest is None, resolve (record, manifest) via registry::resolve_option(perch::adapters_dir(), &args.adapter) (the same composite name:profile resolution digest.rs + the gh_release update use), setting manifest = Some + install_dir = record.source_dir (PRECISE — supersedes the --manifest-parent approximation for the resolved path, fixing REQ-INSTALL-11's copy-mode psyche edge); args.manifest = Some keeps the explicit-path override (install_dir = its parent). Backward-compatible: an unregistered/dev adapter with no --manifest still yields manifest=None (registry miss), unchanged. unit = registry-resolve-when-manifest-absent (registered name:profile → manifest+source_dir), --manifest-override-wins, unregistered-miss → None. int rides the existing api/contract E2E. # ───────────────────────────── Startup flows ──────────────────────────────── [[requirements]] id = "REQ-START-1" title = "Adapters never resolve SPT_HOME; binary on PATH; api bridging only" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-START-2" title = "Harness-hosted startup: api seed then listen" required_stages = ["impl", "unit", "int"] [[requirements]] id = "REQ-START-3" title = "spt-hosted startup: spawn-session then api bind (no file)" required_stages = ["impl", "unit", "int"] [[requirements]] id = "REQ-START-4" title = "Adapter-injected env aliases (SPT/OWL/LIVE)" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-START-5" title = "Adapter-agnostic harness-hosted seed + bind-time adapter/profile resolution (ADR-0021): `api seed` carries only parent_pid + session_id (+ optional cwd), no --adapter — a pure \"a harness session exists at this pid\" record; --adapter becomes an OPTIONAL override across the whole api group (an explicit name[:profile] for adapter dev, never required). Omitted, listen/poll resolve the owning adapter/profile AT BIND as a pure read against the live registry — never a seed-time snapshot that can drift: seed parent_pid → exe basename → host_binaries candidate set (REQ-MANIFEST-8) → active-profile pointer (REQ-INSTALL-12) primary, else greatest-registered_at_ms candidate base profile (name-asc tie) → friendly zero-match error. Covers BOTH LiveAgent (listen) and ReadyAgent (poll) bringup. Restores legacy parity: `$LIVE start ` → `$SPT listen ` with no mandatory --adapter, one generic SessionStart hook per harness binary. (v0.9.0)" required_stages = ["doc", "impl", "unit", "int"] # activated v0.9.0 W1-W2 (LiveAgent/listen): doc = CONTEXT.md §171/§177-181 (adapter-agnostic seed + bind-time resolution) + docs-site harness-contract/api.md + docs/MANIFEST.md SessionStart hook. impl = adapter-agnostic Seed (seed.rs drops adapter) + cmd_seed + --adapter Option across the api group (api/mod.rs ApiArgs/Ctx/resolve_ctx_manifest) + bind-time resolution in cmd_listen (explicit override else resolve::resolve_bind_adapter) feeding both the info.json adapter stamp (bind_from_seed) and the live_capable manifest. unit = adapter_is_optional (api/mod) + resolve_from_basename/pointer tests (spt-runtime resolve). int (agnostic-seed listen bringup E2E) activates at the final LiveAgent wave. RESIDUAL (doyle ruling C, 2026-06-16): the ReadyAgent/`$SPT ready` path is a STACKED follow-on wave — Cmd::Ready bypasses seeds today, so its seed/resolve parity (CONTEXT §175 end-state intent) is staged separately (activate ready-int when built). api poll stays unchanged (operates on an already-stamped perch, no pid-resolution). # ───────────────────────────── Endpoints / Shells ─────────────────────────── [[requirements]] id = "REQ-EP-1" title = "Day-one endpoint types; open type system" required_stages = ["impl", "unit"] # activated M0: spt-proto endpoint taxonomy (T4) [[requirements]] id = "REQ-EP-2" title = "Agent endpoints vs Shells distinction in the type model" required_stages = ["impl", "unit"] # activated M0: spt-proto endpoint taxonomy (T4) [[requirements]] id = "REQ-EP-3" title = "Messaging payloads carry typed operation commands + file blobs" required_stages = ["impl", "unit"] # activated M0: spt-proto payload model (T7) [[requirements]] id = "REQ-EP-4" title = "PresenceChannel broker endpoint (seam day-one)" required_stages = ["impl", "unit"] # activated M4-D4c: the broker-owned PresenceLog — a seq'd ring of {connected|disconnected, conn_id, remote_id_hex} events mirroring the StreamLog discipline. register_conn appends connected + spawns a closed-watcher (conn.closed() → conn row removed → disconnected appended), so the conn table finally reflects liveness; the brain subscribes via net-presence-subscribe {from_seq} and consumes net-presence-event under the same contiguous/dedup/gap cursor discipline as streams (brain-restart cursor-resume). The full PresenceChannel *endpoint* (agent-visible channel) builds on this broker seam later; int = two-host at D9. [[requirements]] id = "REQ-EP-5" title = "Concrete shell instantiation model: spawn-mints-instance (vs relink/online), registered-on-node permission + broadcast-is-discovery, per-shell require_approval gate, max_instances_per_owner + over_cap, instance aliasing, discovery scope" required_stages = ["impl", "unit", "int"] # activated M5-D3a; int activated M5-D9b: the D3e shell E2E + the real-shell E2E (notify_shell_e2e.rs, CI-hook-driven against the standalone spt-shell-notify adapter) carry the int tags [[requirements]] id = "REQ-EP-6" title = "Gateway type acceptance: a Gateway-typed perch binds (api bind --type, open type system — un-hardcode the live_agent default), advertises/addressable like any endpoint, owns shells (owner validation not agent-family-gated), subscribes to digests, and is the user-msg identity gate's user-backed origin (REQ-MSG-5); in-tree mock-gateway fixture (R-DOCS-2 pattern, no downstream adapter code). Cross-node WAN Gateway-origin (registry endpoint_type trust) tracked by REQ-MSG-6" required_stages = ["doc", "impl", "unit"] # activated 2026-06-13: M9-T5 (CONTEXT.md §Gateway; api bind --type un-hardcode at establish_perch; gateway-owns-shell + local user-backed-origin E2E via mock-gateway fixture; the WAN fail-closed row tested at the receive_wan funnel) [[requirements]] id = "REQ-EP-7" title = "Durable live-role.md: a per-agent broad-purpose statement in tracked/agents// beside live-context.md (replicates with the mind on the same a- branch); renders FIRST at start-transition context injection (role -> live-context -> project-context); SOLE writer `spt endpoint role --overwrite ` — mechanical no-automated-writer guarantee (echo-commune ingest / signoff / Psyche reconcile structurally exclude it). The user-backed-origin hard gate on the writer is a deferred later tightening (rides the user-msg identity plumbing)" required_stages = ["doc", "impl", "unit"] # activated 2026-06-13: M9-T6 (CONTEXT.md §live role; contextstore live_role_file/live_role_path seam; resume::download_psyche_context role-first injection order; spt endpoint role print/--overwrite sole writer; injection-order + no-automated-writer tests; M9-WAVE2-ROLE-WRITERS.md survey) # ── Always-on endpoints (ADR-0023, ratified 2026-06-21; first consumer downstream spt-discord) ── [[requirements]] id = "REQ-EP-8" title = "AlwaysOnEndpoint: a resident, addressable, mindless endpoint whose adapter binary the daemon supervises continuously — register-triggered by an adapter-option's `[always-on]` manifest section, one supervised binary per `[:profile]`, running independent of agent liveness. It self-manages its `#`-addressed channel endpoints via the existing `api bind` (one connection fronts many). The SECOND class of spt-core-boot-launched third-party binary (after the shell wake-watcher); supervision reuses the wake-watcher scaffolding (backoff / give-up latch / one-per-instance lock / orphan-kill / brain-side reconcile) MINUS the offline-only flip — always online, never resting (no dormant/suspended states). Two-way: agents message it; it may call `endpoint wake `, target-side authorized (REQ-INST-3/6 wake resolution + access whitelist + shell_wake_spawn_anywhere — no caller-ownership gate). First consumer downstream: spt-discord." required_stages = [] # rule 5: design ratified 2026-06-21 (grill-with-docs, ADR-0023); UNBUILT — activate when the milestone delivering [always-on] supervision lands. Reuses shellwake.rs scaffolding (REQ-SHELL-2) minus the offline flip; first consumer is the downstream spt-discord adapter (own repo). [[requirements]] id = "REQ-EP-9" title = "`#` always-on address sigil: a reserved LEADING sigil marking an AlwaysOnEndpoint, extending the REQ-INST-10 grammar to `[subnet:]#id[@node]`. Mandatory + bijective — `#name` ⟺ always-on endpoint, bare `name` ⟺ agent endpoint — so the router resolves endpoint class from the address alone, before any registry lookup. Sits ABOVE REQ-HAZARD-ID-CHARSET: the address parser strips the single leading `#` before id validation, so the bare/stored id stays charset-clean and a mid-id `#` remains rejected (the charset contract is unchanged)." required_stages = [] # rule 5: design ratified 2026-06-21 (ADR-0023); UNBUILT — activate with REQ-EP-8's milestone (spt_proto::addr::Address::parse sigil extension + class-discriminated resolve in spt_net registry). REQ-HAZARD-ID-CHARSET is NOT amended (sigil is an address-layer concern). # ───────────────────────────── Instances ──────────────────────────────────── [[requirements]] id = "REQ-INST-1" title = "endpoint ID vs instance split (adapter-agnostic ID)" 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 [[requirements]] id = "REQ-INST-2" title = "Per-node files, synced Psyche mind" required_stages = ["impl", "unit"] # activated M4-D6a: the mind now lives in the tracked/ two-tier store (spt-store::contextstore over BranchStore), physically separate from never-synced node-local perches/ — spt_live::ingest::route_slices routes commune drops' / slices into a-/p- branch files (untagged body → live, the parser fallback; precedence-guarded 6.5; checkpoint commit per written tier); signoff::write_resume_commune stamps Self provenance per slice through the same router; resume::download_psyche_context composes the two tiers back into the two-slice envelope (pure read, never mints store state; other-project download sees live only). Daemon lifecycle derives project_id from the commune dir's parent (cwd fallback). The cross-node SYNC of these branches = D6c (REQ-NET-3/REQ-INST-5); int = D9 two-host. unit = per-tier routing/no-leak + untagged→live + suppressed-consumed + resume-commune both-tiers + download compose/cross-project-isolation + e2e listen→ingest→live-tier [[requirements]] id = "REQ-INST-3" title = "Dormant (warm) / suspended (cold) resting states" required_stages = ["doc", "impl", "unit"] # doc activated M4-D9-3: docs/DORMANCY-BUDGET.md — the measured warm/cold policy lock (ADR-0003 #9 closed: warm default confirmed, zero idle CPU, RSS-only cost; auto-suspend opt-in default OFF). impl/unit activated M4-D9-2-1: spt-daemon::resting — the explicit daemon-owned state machine (pure transition table: detach/attention-shift rest warm, manual suspend rests cold from either live state, opt-in auto-suspend counted from the dormancy-onset anchor, wake re-activates in place; no idle timer by design) + durable info.json rest_state/dormant_since_ms record (anchor moves atomically with the state) + the global→node→endpoint auto-suspend knob chain default OFF (daemon.json node leg, info.json endpoint leg, 0 = explicit endpoint OFF) + registry Status::Suspended (additive, addressable — only Offline is unroutable) + advertise_local follows the machine epoch-bumped. Transition echo/effects = D9-2-2 (REQ-INST-4); doc (measured warm/cold policy) = D9-3 DORMANCY-BUDGET.md [[requirements]] id = "REQ-INST-4" title = "active to dormant/suspended fires a transition echo commune" required_stages = ["impl", "unit"] # activated M4-D9-2-2: resting::apply_event fires the on_rest_edge echo hook at every active→(dormant|suspended) edge BEFORE the flip persists (KH 3.3 echo-before-teardown ordering; echo failure is loud but never wedges the instance active — the signoff_with posture); BrainLifecycle::rest_event binds the hook to the real run_echo_commune machinery (fire_echo), so the final context delta lands in the commune drop dir and syncs to whichever instance activates next. Exactly-once per edge by the pure table's idempotence (replayed events are no-edges); resting→resting fires nothing. unit = echo-once-per-edge (pure + real-summarizer lifecycle e2e) + loud-failure-persists + no-echo-on-suspend-from-dormant [[requirements]] id = "REQ-INST-5" title = "Two-tier context sync (live to all, project to same-project)" required_stages = ["impl", "unit", "int"] # activated M4-D6c-2: scoping is the pull model + the server-side gate (ADR-0013 — requester names a-/p- refs, no new registry fields). spt-daemon::sync::SyncPolicy.allows: a- served only when ∃ subnet S with the handshake-proven origin pinned in the trust store AND synced(id,S) (the D3e gate finally consumed — membership list + visibility, hidden ⟹ not synced, fail-closed on unconfigured lists); p- only to a trusted origin for a hosted project (honest residual: p- tier is branch-granular, a hidden endpoint's per-project slice rides a hosted project's branch — per-file p- filtering post-v1); machinery refs never served. select_refs mirrors the gate requester-side (symmetric scoping). Server re-checks EVERY requested ref — naming is not entitlement. int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — both directions bootstrap-pull a mind the puller never held, registry-derived want-refs, gate served remotely, HFENDULEAM↔gravity). unit = gate decision table + select_refs scoping + loopback two-daemon two-tier E2E (unsynced/hidden refused server-side, tests/sync.rs) [[requirements]] id = "REQ-INST-6" title = "Deferred messages not delivered to dormant/suspended instances" required_stages = ["impl", "unit", "int"] # activated M5-D5a/b: the gate is state-keyed at the hook-channel consumer sites — resting::deferred_held reads the owner's durable rest record at drain time; "held" = (deferred=1) ∧ (Dormant|Suspended); poll_drain + cmd_worker_poll gated (the KH 1.4 "all sites agree" sweep — shellchan peek ungated: no deferred writer targets shell perches; ring's ephemeral perch recordless by construction = legacy fallback). Release is by construction at the wake edge: the persisted Active state stops the narrowing, the unchanged delivered-mark discipline keeps it exactly-once. D5b adds the remote arm: StreamFamily::Rest + resthost request/serve (remote-drive trust class — access_check on the handshake-proven origin, no grant gate; refusal = finish-no-reply), cmd_rest qualified-form lift via wansend::wan_rest. unit = held-while-dormant+suspended / non-deferred-untouched / released-once-on-wake / recordless-flows + wire-record roundtrip + demux. int = loopback two-broker E2E (suspend cross-node, gate holds, replay NO_EDGE, wake cross-node releases exactly-once + wake effects, whitelist negative). doc = DEFERRED.md remote-fork note. Rig [twohost] leg waits for D9a. [[requirements]] id = "REQ-INST-7" title = "Subnet registry + bare-id resolution policy" required_stages = ["impl", "unit", "int"] # activated M4-D3a: spt-net::net::registry — SubnetRegistry { endpoint_id -> [Instance{node,status,epoch}] }, Status{Active,Dormant,Offline}, serde-roundtrippable, merge_instance keeps distinct-node instances as separate rows. The bare-id *resolution policy* (local -> most-recently-active -> id@node, ambiguity refuses) is D3c (REQ-INST-10). Cross-node replication delivered M4-D4d: spt-net::net::replicate — RegistryUpdate {subnet,endpoint_id,Instance} as NDJSON over broker-owned QUIC streams (framing survives chunk coalescing/splitting; corrupt lines skipped, 4.3 posture), apply_update routes by subnet into merge_instance and drops non-member subnets fail-closed (REQ-INST-13 posture); advertise gates (D3d/D3e) run locally BEFORE emission. int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — both sides' perch rows replicate into the other's gated registry over the real wire). unit = serde roundtrip + distinct-nodes + newer-epoch-supersedes + wire convergence/lease-over-the-wire (tests/replicate.rs). [[requirements]] id = "REQ-INST-8" title = "Remote-control mode distinct from local operation" required_stages = ["impl", "unit", "int"] # activated M4-D5b: AttachRecord wire protocol (spt-net::net::attach) + spt-daemon::attach serve/request/input — a byte-stream viewport onto a remote session over broker QUIC streams; compute+files stay on the target node. tests/attach.rs drives a real PTY child on daemon A from daemon B and proves restart survival (worst-case seq-0 re-serve, all journals dedup). int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — A types into B's live PTY across the rig and reads the echo back) # ── Multi-subnet membership model (ADR-0006, 2026-06-01; activate M4) ── [[requirements]] id = "REQ-INST-9" title = "Multi-subnet membership (same-user N subnets; cross-user seam)" required_stages = ["impl", "unit"] # activated M4-D3d: SubnetStore already carries N seeds (D2c/f); D3d adds the join-time bare-id collision check — spt_net::net::registry::SubnetRegistry::join_endpoint refuses (JoinCollision, registry untouched) when a joining endpoint id is already held by a DIFFERENT node in the target subnet (Offline rows still own the name); same-node re-advertise falls through to the merge_instance epoch lease. Per-subnet scope: the same bare name in different subnets is legal (resolver forces qualification, REQ-INST-10). Same-user only; cross-user seam = registry key generalizes to (subnet,user) per ADR-0006, check rides along unchanged. unit = fresh-join insert + other-node refuse-untouched + offline-holder collide + same-node lease fall-through + per-subnet legality. [[requirements]] id = "REQ-INST-10" title = "Qualified addressing [subnet:]id[@node] + ambiguity forces qualification" required_stages = ["impl", "unit"] # activated M4-D3c: spt_proto::addr::Address::parse (grammar, reserved :/@ → unambiguous split, each component charset-validated) + spt_net::net::registry::resolve/resolve_across (policy: @node exact → local wins → sole live → REFUSE on multiple live nodes [AcrossNodes, force @node] / multiple subnets [AcrossSubnets, force subnet:]; Offline never routed). Per-node epochs are NOT cross-node comparable, so no silent most-recently-active guess. unit = addr 4-shape parse/roundtrip/doubled-delim reject + resolve local/sole/refuse/@node-pin/cross-subnet. Cross-node replication feeding the registry = D4; two-host = D9. [[requirements]] id = "REQ-INST-11" title = "spt rename rippled to all instances (collision-checked, 6.5-reconciled)" required_stages = ["impl", "unit"] # activated M4-D3f: three local arms. (1) registry rows: spt_net::net::registry::rename_endpoint — collision-checked against EVERY subnet the endpoint is advertised into FIRST (any row, any status holds the name), then moved all-or-nothing; rows verbatim (relabel, not liveness event); new id charset-validated (:/@ reserved). (2) perch dirs: spt_store::rename::rename_perch_in — Self dir + nested -psyche/-w{N} children + every info.json id; refuses while any perch in the tree is live; local SQLite registry rows deliberately untouched (live refuses ⇒ old rows stale ⇒ 4.3 stale-clean drops). (3) CLI `spt rename ` wires the perch arm. Seams: daemon-held registry wiring = D4; a- context branch = D6 context store; cross-node ripple = authored (node,epoch) op reconciled newest-wins per hazard 6.5 marker at D4/D6. unit = cross-subnet ripple-verbatim + all-or-nothing collision/NotFound/InvalidId/self-noop + perch tree ripple + refusals-untouched + live-child-blocks + CLI parse. [[requirements]] id = "REQ-INST-12" title = "Endpoint visibility per-(endpoint,subnet): excluded semantics, OR-of-defaults + override, gates sync" required_stages = ["impl", "unit"] # activated M4-D3e: spt_store::visibility::VisibilityStore (identity/visibility.json, atomic, degrade-safe) — hidden(E,S,flag) = per-(E,S) override wins both directions, else S.hide_new_endpoints OR E.default_hide (both ship OFF). SubnetRecord.hide_new_endpoints serde-defaulted (legacy subnet.json loads OFF). Enforcement in spt_net::net::registry: excluded = not advertised (advertise_if_visible gates BEFORE the collision check — hidden neither claims nor clashes) AND not routable (resolve_visible/resolve_across_visible refuse even @node pins; exclusion prunes the cross-subnet ambiguity set). unit = OR-of-defaults + override-both-directions + persist/degrade + legacy-load + not-routable + ambiguity-prune + advertise-gate. [[requirements]] id = "REQ-INST-13" title = "Subnet-exclusive sync + per-endpoint subnet-membership list" required_stages = ["impl", "unit"] # activated M4-D3e: VisibilityStore.sync_subnets per-endpoint membership list + synced(E,S,flag) — the D6 replication gate: subnet must be on the list (unconfigured list syncs NOWHERE, fail-closed; creation flow seeds [home] per ADR-0010) AND visibility gates sync (hidden ⟹ not synced even when listed, ADR-0006 §6). Actual mind replication consuming this gate = D6. unit = listed-visible-syncs + unlisted/unconfigured-no + hidden-gates-sync (defaults + override paths). [[requirements]] id = "REQ-INST-14" title = "Resource advertisement (subnet resource registry): free-text blurb, both-authored, registry projection, visibility/whitelist-gated" required_stages = ["doc", "impl", "unit"] # activated M4-D9-4: resources: Option on the registry Instance (additive serde-default, rides RegistryUpdate replication + the epoch lease — a blurb edit is an ordinary epoch-bumped update, NO new record kind/store/merge surface) + resource_projection (the (id,node,status,blurb) yellow-pages view over VISIBLE+routable rows — same exclusion closure as resolution, ADR-0006 §6 leak-free by construction) + both-authored sourcing (info.json resources via spt resources set wins; daemon.json resources_blurb node seed fills the gap) + spt resources set/show/list CLI off the stale-tolerant registry snapshots. The whitelist leg of the gate (viewer-node filtering) rides the deferred consent/whitelist generalization — visibility is the v1 gate exactly as CONTEXT scopes it. doc = CONTEXT §resource advertisement; unit = lease-ordered blurb + pre-D9-4 row parses + hidden-never-listed/offline-skipped/absent-renders-clean + both-authored advertisement + runtime refine epoch-bumps [[requirements]] id = "REQ-INST-15" title = "Immutable home subnet (assigned at creation: auto-if-one/ask-if-many) + spt fork (cross-subnet clone to a new identity, copy-then-diverge, not re-home); adapter chosen at creation from registered hostable adapters, changed only via launch/resume-under-new (ADR-0010)" required_stages = ["doc", "impl", "unit"] # activated M4-D9-5: spt-store::home (assign_home matrix: sole auto / several refuse-and-qualify / unpaired local-only; stamp_creation_fields = the ONE creation seam shared by spt ready + api listen/bind — NEW assigns hard + seeds sync_subnets=[home], REVIVE carries home/adapter/blurb/knobs forward so a re-bind never wipes them; adopt_for_unset = first-join adoption, sole-subnet only, never re-homes; NO setter — immutability by construction) + info.json home_subnet/adapter + ContextStore::fork_endpoint (one-time copy of live+project tiers as parentless seed commits — copied-then-diverged, no shared history) / remove_endpoint (--delete-source, exactly the source) + spt fork CLI (refusal-first gates: same-id, target membership, local + target-registry join-time collision; same-node v1, remote arm = M5 with instantiate-anywhere consent). doc = ADR-0010 delivered note; unit = assignment matrix + stamp news/revives + adoption + fork copies-then-diverges + collision classes + delete-source-exact [[requirements]] id = "REQ-REACH-1" title = "Off-node remote-drive detection + file transfer" required_stages = ["impl", "unit", "int"] # int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — file fetch off B + push back to B across the rig; chunked xfer through the dispatcher gate) [[requirements]] id = "REQ-REACH-2" title = "Remote command execution (deferred, consent-gated)" required_stages = [] # rule 5: deferred by design (PRD: consent-gated remote exec is the highest-risk capability; workarounds exist) — M5+ with the instantiate-anywhere consent model # ───────────────────────────── Local messaging (M1) ───────────────────────── # No PRD R-* covers local message delivery (foundational/implicit). Registered # at M1 start per TRACEABILITY rule 3 (precedent: REQ-NODE-IDENTITY at M0 T5). # Inactive until the M1 task that delivers each lands (rule 5: activate, don't pre-fail). [[requirements]] id = "REQ-MSG-1" title = "Local message delivery: TCP-first to a registered address, spool fallback when offline; id->address via registry (stale-clean first); reply routing (__REPLY_TO__)" required_stages = ["impl", "unit", "int"] # activated M1 T1 (impl+unit); int added T9: killer-quickstart cross-perch E2E [[requirements]] id = "REQ-MSG-2" title = "spt binary CLI surface: send/ring/ready(+--once)/list/stop/whoami, stable arg shapes + exit codes" required_stages = ["impl", "unit"] # activated M1 T6-T8: spt binary, clap CLI, all 7 subcommands wired. M7 amendment (plan decision 2, 2026-06-05): `poll` removed — it duplicated `ready` (same run_listen dispatch); `ready --once` absorbs the drain-then-exit semantics. `spt api poll`/`api worker-poll` (machinery namespace) are unaffected. Pre-1.0, zero users, no deprecation shim. [[requirements]] id = "REQ-MSG-3" title = "Ready-agent lifecycle: register perch (info.json + listener + registry address) on ready, drain spooled backlog on startup, clean teardown" required_stages = ["impl", "unit", "int"] # activated M1 T3 (impl+unit); int added T9: killer-quickstart backlog-drain E2E [[requirements]] id = "REQ-MSG-4" title = "Listener stream stdout emits EVENT envelope lines (sister-format, ADR-0001): parse the __REPLY_TO__ frame, pass pre-formed typed envelopes through verbatim (no double-wrap), compose otherwise, chunk oversized lines into EVENT-PART" required_stages = ["impl", "unit", "int"] # activated 2026-06-06 (envelope-missing fix): spt-msg::emit render seam + both listener loops (spt ready run_listen, spt api listen cmd_listen); int = quickstart E2E asserts the envelope shape on the live + backlog paths. Hook-channel drains (api poll / worker-poll) stay raw-frame by contract. NOTE 2026-06-15: the "hook drains stay raw-frame" clause is REVERSED by REQ-MSG-ENVELOPE / ADR-0020 (poll/worker-poll now compose too). [[requirements]] id = "REQ-MSG-ENVELOPE" title = "The body envelope (spt-proto::event, the ADR-0001 grammar) is the SOLE canonical arriving-message format at EVERY harness arriving-message surface on an AGENT perch — api listen AND api poll/worker-poll, byte-identical (reverses REQ-MSG-4's 'hook drains keep the raw frame by contract'). SCOPE CARVE-OUT: the shell-command relay (api poll --link, cmd_poll_shell) is a distinct internal transport carrying RAW MAC'd stamped frames the shell child consumes verbatim — NOT an arriving-message surface, deliberately EXEMPT from composition (notify_shell_e2e guards this boundary). __REPLY_TO__ — mis-elevated during the clean-room port to a fake ADR-0001 'stable wire format' (spt-msg/wire.rs, lib.rs) — is REMOVED entirely (spool format_row, the spt-msg TCP frame, emit parse_frame); (from, body) carried structurally, composed once at the delivery boundary. No legacy sister-interop (spt-core never required it). Reply-correlation rebinds onto the structural from / attribute (ADR-0009 access-gate + ADR-0012 Psyche/spt-live reply-target). Self-delimiting by construction → finding F-002 (non-self-delimiting multi-message poll) dissolves. ADR-0020." required_stages = ["doc", "impl", "unit", "int"] # doc activated 2026-06-15 (ADR-0020 + amendments to ADR-0009/0012, design crystallized via grill-with-docs, operator-ruled); impl/unit/int activated 2026-06-15 — the multi-crate refactor landed (spt-store/spt-msg/spt/spt-daemon/spt-live). Supersedes the __REPLY_TO__ clauses of REQ-MSG-1 + REQ-MSG-4. [[requirements]] id = "REQ-MSG-5" title = "user-msg envelope kind + daemon identity gate: a Gateway endpoint / the local user's CLI author user-msg (the user's authority); agent-family senders re-stamped to plain msg; identity-gated never payload-trusted (KH 7.3/7.5); wire-additive (N-1 receivers tolerate the new type)" required_stages = ["doc", "impl", "unit"] # activated 2026-06-13: M9-T4 (CONTEXT.md §Gateway user-msg; spt-proto EVENT_TYPE_USER_MSG + compose_user_msg_event + the MsgOrigin identity gate / gate_user_msg_type truth table incl. spoof rows; wire-additive N-1 tolerance fixture). Daemon origination/WAN-ingress wiring + render rides the same activation. [[requirements]] id = "REQ-MSG-6" title = "cross-node Gateway user-msg honored via advertised endpoint_type: a user-msg from a Gateway-typed origin survives the receive_wan funnel as user-msg (vs the fail-closed re-stamp), keyed on the QUIC-handshake-proven origin node (never wire `from`). Trust boundary = subnet membership (operator-ratified 2026-06-13); no defense against an in-subnet member forging the type. Instance.endpoint_type is an additive serde-default field extending REQ-INST-7's data model. Absent/unknown type → re-stamp (N-1 rollout grace)" required_stages = ["doc", "impl", "unit"] # activated 2026-06-13: M9 cross-node Gateway WAN. doc = CONTEXT.md §Gateway posture rewrite (commit 30fea7f); impl = Instance.endpoint_type field (T1) + advertise populate (T2) + receive_wan origin resolve (T3); unit = the origin matrix in wan.rs + advertise-populate assertion (T4). doyle gate PASS 2026-06-13 (PR #9). INT DEFERRED, REQUIRED-BEFORE-LECTURN (tracked gate condition): the T5 twohost HONORED-path int (Gateway advert on A → user-msg honored at B; agent advert → re-stamped) must land before spt-lecturn ships a real cross-node Gateway — the negative path is already E2E-tested (wanmsg funnel), but the positive wire path has no production exerciser until then. Activate `int` when adding it. # ───────────────────────────── Networking / pairing ───────────────────────── [[requirements]] id = "REQ-NODE-IDENTITY" title = "Ed25519 identity primitive: keypair, detached sign/verify, stable pubkey<->hex" required_stages = ["impl", "unit"] # activated M0: spt-proto identity primitive (T5); foundation for R-NET/R-PAIR/SptNode [[requirements]] id = "REQ-NET-1" title = "WAN messaging first-class, behind default-on net feature flag" required_stages = ["impl", "unit", "int"] # activated M4-D1: spt-net NetEndpoint binds an iroh endpoint to the node's own spt-proto Ed25519 identity (EndpointId == node pubkey) behind the default-on `net` feature, with connect/accept over the SPT_NET_ALPN; unit proves identity-binding + a hermetic loopback QUIC bidi echo. int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — a real WAN message lands in the remote perch spool, HFENDULEAM↔gravity over tailscale, [twohost] CI run) [[requirements]] id = "REQ-NET-2" title = "n0 relay default + self-host knob + plain-language disclosure" required_stages = ["impl"] # activated M4-D1: RelayPolicy {N0Default | SelfHosted(RelayMap) | Disabled} maps onto iroh RelayMode (n0 public relays are the zero-config default; self-host is the escape hatch; disabled = LAN/air-gapped + hermetic tests), plus the RELAY_DISCLOSURE plain-language string. Config plumbing — no unit/int stage required [[requirements]] id = "REQ-NET-3" title = "Cross-node Psyche sync over P2P replaces gh-repo-sync" required_stages = ["impl", "unit"] # activated M4-D6c: git-native bundle sync (ADR-0013). D6c-1 store core: BranchStore bundle plumbing (create_bundle ^have incremental w/ unknown-have full fallback + up-to-date skip; fetch_bundle quarantines tips under refs/spt-sync/ — incoming can never clobber refs/heads before the driver rules; merge_commit_in_worktree two-parent join, FF for all-accepted propagation) + spt_store::syncmerge::apply_fetched — per-file vector verdicts (dominate→accept verbatim/dominated→drop/concurrent→record_conflict, hazard 6.6), replicated .conflicts artifacts with resurrection guard (local-dominates-or-equal ⟹ stale echo dropped), dominating accept clears pending artifacts (the only legal clear), drop-only passes still join the DAG (re-pull short-circuits on ancestry). D6c-2 = wire serve/request over broker QUIC + synced/visibility server gate. int = D9 two-host E2E. unit = adopt/incremental-FF/idempotent-repull + dominated-drop-joins-DAG + concurrent-surfaces-both-nodes + reconciled-write-propagates-clears + p-tier-union + legacy-⊥⊥-surfaces [[requirements]] id = "REQ-PAIR-1" title = "TOTP-seeded SPAKE2 pairing" required_stages = ["impl", "unit", "int"] # activated M4-D2-wire: pairing ALPN ceremony driver (spt-net::net::pairing::wire) runs the 4-msg SPAKE2 ceremony over SPT_PAIR_ALPN, prepended by a responder Announce{epoch} RTT (joiner lacks the responder-authoritative seed epoch that Initiator::start binds into msg_a). Responder gates via PairingRateLimiter + ±1 step + SubnetStore seed; both sides write TrustStore.record on confirm. unit = hermetic loopback E2E (RelayPolicy::Disabled) like endpoint.rs. int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — the real ceremony over SPT_PAIR_ALPN cross-host; trust pinned both sides). [[requirements]] id = "REQ-PAIR-2" title = "Local trust store with TOFU + warn-on-change" required_stages = [] # SUPERSEDED by the Mesh-D6 hard cutover (REQ-MESH-5, user decision 2026-06-08): the TrustStore/peers.json TOFU pin + is_trusted gate were DELETED — membership is now the union-merge roster (REQ-MESH-2, seed-proof ∧ ¬tombstoned admission), and warn-on-change was demoted from a gate to a machine_id-anchored awareness notice (REQ-MESH-5). No evidence remains by design; this id is retired, its capability re-homed onto REQ-MESH-2 + REQ-MESH-5. (Was: M4-D2e TrustStore — record()→Pinned/AlreadyTrusted/KeyChanged, is_trusted connect gate, revoke trust-delete.) [[requirements]] id = "REQ-PAIR-3" title = "Fetch current pairing code from any paired node" required_stages = ["impl", "unit"] # activated M4-D2g: `spt pair show-totp [--subnet |--create-new ]` 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 ` (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. [[requirements]] id = "REQ-PAIR-4" title = "Subnet naming on first pairing" 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. # ── Multi-subnet pairing ceremony (ADR-0005 amend, 2026-06-01; activate M4) ── [[requirements]] id = "REQ-PAIR-5" title = "Multi-subnet pairing: subnet-name discovery input, create-new-names-up-front, rendezvous-token hashing" required_stages = ["impl", "unit", "int"] # activated M4-D2f: discovery takes code+subnet-name (the wire Hello carries the name; both are ceremony inputs); rendezvous-token H(name‖TOTP-step) derived in spt-net::net::pairing::rendezvous (TOTP-step = clock-derived window, not seed epoch — D2-wire Q2); seed transfer on join (wire F7 Seed -> SubnetStore::add_joined) makes the joiner a full member/seed-holder. unit = rendezvous tests + wire joiner_becomes_seed_holder/repair_member_keeps_seed. int activated M4-D9-6: two-host rig seed transfer green (tests/twohost.rs — the joiner holds the subnet seed post-ceremony); relay rendezvous routing stays the M5 Q1 deferral. [[requirements]] id = "REQ-PAIR-6" 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 ` 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 ]`: 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. ENDPOINT-LESS GAP CLOSED post-M8-accept (2026-06-08): the per-row node_label only rode endpoint Instance rows, so a node advertising ZERO endpoints rendered a bare key-prefix to peers. Added a node-LEVEL carrier — SubnetRegistry.node_labels (node→label) map + a NodeLabelUpdate feed record riding the same registry replication stream as an untagged RegistryFeedRecord variant (Instance bytes unchanged → mixed-version fleet safe; old peers skip label lines). Merged under the same strictly-greater-epoch lease (merge_node_label), evicted with the node (evict_nodes), gated identically to instance feeds (apply_node_labels: member subnet ∧ trusted origin). The pump (advertise_local) emits one label record per served subnet regardless of endpoint count; node_status_rows reads node_labels() to NAME an endpoint-less peer without counting it as an endpoint ([0/0] holds, no liveness effect). unit += node-level lease/evict/serde + untagged-feed wire-compat + apply gate + endpoint-less render + classify routing [[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 : 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]] id = "REQ-NOTIF-1" title = "Notification primitive: per-subnet replicated spool, seen/dismissed, resurface-at-boundary, subsumes update+consent prompts" required_stages = ["impl", "unit", "int"] # int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — insert on A fires on B; dismiss on B replicates back to A; the update consent notif also surfaced through the same primitive) [[requirements]] id = "REQ-NOTIF-2" title = "spt notify (agent-issued subnet notif) + notif_command manifest seam (harness + shell adapters)" required_stages = ["doc", "impl", "unit", "int"] # int activated M5-D9b: the rig toast rung (tests/twohost.rs — A's notify rendered through B's [session.notif] shell template, run 26998058816 real_mode=true) # ───────────────────────────── Self-update ────────────────────────────────── [[requirements]] id = "REQ-UPD-1" title = "Peer-propagated update over P2P" required_stages = ["impl", "unit", "int"] # activated M4-D7-1: net/update.rs UpdRecord offer-then-fetch wire protocol + relcache.rs staged-release cache (verified-pull ⇒ staged ⇒ servable — the self-heal hop); D7-2 adds the serve/request drivers + loopback propagation E2E. int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — B's staged v6 verified + staged at A under the trusted key, consent-notified) [[requirements]] id = "REQ-UPD-2" title = "All binaries signature-verified before handoff" required_stages = ["impl", "unit"] # activated M3c-C1: release.rs Ed25519 verify-before-handoff (signature + key trust + SHA-256 artifact binding); update::plan_verified is the front door that produces a plan ONLY for a verified release, so an unverified binary never reaches apply_brain_only [[requirements]] id = "REQ-UPD-3" title = "No endpoint process terminates/suspends during self-update" required_stages = ["impl", "unit", "int"] # int RE-POINTED at restoration D7-1 (ADR-0018 V5) to the PROCESS-level survival E2E (crates/spt/tests/brain_survive.rs: no endpoint terminates across a brain-only update at the PROCESS level — a real brain-child respawn onto a swapped on-disk binary, the PTY child's pid unchanged + held-AND-functional + the QUIC conn intact). Was M3c-C0 brain_swap.rs (the in-process Brain::handoff closure shape — regression-masked per ADR-0018); that tag removed in the same D7-1 commit (brain_swap.rs kept as engine-mechanics coverage) [[requirements]] id = "REQ-UPD-4" title = "Update gated on user confirmation by default; opt-in full-auto" required_stages = ["impl", "unit"] # activated M3c-C2: consent::decide is the pure gate (default NeedsConsent, full-auto opt-in via DaemonConfig.full_auto_update); consent::most_recently_active resolves the prompt target over live perches + the new info.json last_active_ms recency stamp (set_last_active, stamped each pulse tick). Interactive prompt UX itself rides the deferred notif/PresenceChannel seam [[requirements]] id = "REQ-UPD-5" title = "spt-core ripple-updates registered adapters" required_stages = ["impl", "unit"] # activated M3c-C3: adapter_update::conduct_ripple conducts self-then-adapters in registration order; file_pull payloads get adapter content signing (verify_signature against the per-adapter manifest key + verify_artifact digest), delegated requires a self_verifies attest [[requirements]] id = "REQ-UPD-6" title = "Platform-targeted update sets and debug rollout: signed multi-platform update metadata, recipient platform selection, channel-scoped monotonic counters, debug-channel opt-in via release-key overlay, local staging plus pull-based peer propagation, and maintainer-only convergence tooling (ADR-0016)" required_stages = ["doc", "impl", "unit", "int"] # activated 2026-06-06: first slice = signed update-set metadata + verification, platform artifact selection, cache/propagation/apply compatibility, maintainer xtask helpers. int activated M8-D4 (decision 19): xtask debug-converge watcher per docs/DEBUG-CONVERGE-PLAN.md — status-only query on the update wire + loopback convergence test [[requirements]] id = "REQ-UPD-7" title = "Origin-source update bootstrap (`spt update fetch`): pull the latest signed release directly from the GitHub release origin (`SaberMage/spt-releases`) — the per-platform artifact + its `.release.json` SignedRelease metadata — and stage it through the EXISTING verify→stage pipeline (the same `plan_verified` gate: two-key signature + channel + monotonic rollback floor + SHA-256), after which the normal consent-notif / `spt update apply` flow is unchanged. Closes the peer-only-discovery gap (REQ-UPD-1): a first-in-fleet / isolated node can update with no peer to pull from. The signed-release anchor keeps the GitHub transport untrusted-but-verified." required_stages = ["impl", "unit"] # activate v0.3.1. impl = `spt update fetch [--channel ] [--tag vX.Y.Z]` (default latest on pinned channel); HTTPS GET of artifact + .release.json from releases/{latest,tag/}/download; feed both through plan_verified then cache.stage(); reuse cmd_update_apply machinery downstream. Add a direct HTTPS client (reqwest, blocking+rustls) on the `spt` CLI crate (fetch runs in the CLI, NOT the daemon — no new daemon HTTP surface); honor SPT_INSTALL_REPO override. unit = URL/asset-name derivation (platform × latest/tag) + verify-then-stage against a fixture release.json (reuse release.rs vectors); rollback floor + channel-pin rejection paths. [[requirements]] id = "REQ-UPD-8" title = "Platform-safe `spt update fetch` + apply platform-guard (v0.3.1 cross-OS brick fix): `spt update fetch` stages the signed multi-platform `SignedUpdateSet` (`update-set.json` + every platform artifact it names), never a platform-blind single `SignedRelease`, so local apply selects `current_platform()` and P2P re-serve lets each peer select ITS own platform. Defense-in-depth: `apply_staged` REFUSES a staged single-release artifact unless it is platform-stamped for THIS node (an unstamped pre-v0.3.2 single, or a single stamped for another OS, fail-safe refuses — the guard that alone prevents the v0.3.1 brick where a Linux ELF was applied as `spt.exe`). UX: a friendly post-apply message (`Updated spt-core to vX.Y.Z.` + changelog URL) driven by an additive `product_version` metadata field, with a release-counter fallback when absent." required_stages = ["impl", "unit"] # activate v0.3.2. impl = fetch set-staging (cli) + apply_staged single platform-guard (applyhost) + ReleaseCache platform stamp (relcache) + product_version metadata (release.rs) + xtask release-publish emits/uploads update-set.json stamped with product_version. unit = (a) fetch update-set.json + per-asset URL/triple derivation; (b) apply REFUSES a platform-mismatched / unstamped single (the regression that bricked hfenduleam) + a matching-stamp single applies; (c) the apply message render (product_version present → "Updated spt-core to vX.Y.Z." + URL; absent → release-counter fallback). [[requirements]] id = "REQ-UPD-9" title = "`gh_release` adapter [update] avenue (optional signing): an adapter declares `[update] avenue = \"gh_release\", repo = \"user/repo\"` (+ optional `asset`, default `adapter.spt`; + optional Ed25519 `signing_key`); spt-core's ripple compares the repo's LATEST GitHub release version against the installed adapter version and, when newer, auto-updates by fetching the release `.spt` archive (the REQ-INSTALL-9 `--release` fetch primitive) → verifies the `.spt` against `signing_key` if declared, else HTTPS+GitHub first-acquisition trust → re-extracts + re-registers the adapter root. Lets a harness adapter ship updates from its own GitHub releases with NO signing tooling or plugin coupling (removes the perri file_pull/delegated avenue blockers). Acquisition-trust mirrors `--release` + the installer first-fetch; does not alter spt-core self-update (REQ-UPD-1..8)." required_stages = ["doc", "impl", "unit"] # activate v0.8.0: doc = CONTEXT.md "adapter update declaration" gh_release avenue + docs/MANIFEST.md [update] gh_release block (the .sig keyed-verify convention). impl = UpdateAvenue::GhRelease variant + `asset` field + gh_release required-fields validation (repo required; asset/signing_key optional) (manifest.rs) + GhRelease→Pointer register mode (registry.rs) + plan_adapter_update stays PURE (gh_release → Skipped::GhReleaseManaged, no fetch) + spt_daemon::verify_detached (raw-bytes Ed25519, fail-closed) + the side-effecting gh_release update layer DRIVEN CLI-SIDE via a new `spt adapter update [name]` command (cli.rs cmd_adapter_update: gh_latest_release_version + version_is_newer + stage→verify→extract→re-register, reusing the REQ-INSTALL-9 fetch/extract primitives already in the spt CLI crate). PLACEMENT: CLI-side honors REQ-UPD-7's no-daemon-HTTP invariant; conduct_ripple/ripple_registered have NO prod driver (automatic on-spt-core-update ripple wiring is REQ-UPD-5 production activation, out of scope here). Keyed verify = a detached `.sig` (lowercase-hex) published beside the release asset, verified against the installed manifest's signing_key (key-continuity: a new .spt must verify against the old key); unsigned ⇒ HTTPS+GitHub acquisition trust. unit = avenue parse + validation (repo required / missing-repo rejected) + version-compare decision (newer→update, same/older→skip) + the optional-verify gate (signed .spt verifies, bad-sig fail-closed, unsigned → HTTPS-trust). doc (CONTEXT.md gh_release avenue + the `.sig` convention + MANIFEST.md/docs-site [update] gh_release) added with the v0.7.4 docs batch. int (real published-release E2E) deferred with REQ-INSTALL-9's real-fetch int. # ───────────────────────────── Terminal wrapper / frontend ────────────────── [[requirements]] id = "REQ-TERM-1" title = "Process-supervisor terminal wrapper hosting broker PTYs" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-TERM-2" title = "session-surface abstraction; send-keys + send-line injection" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-TERM-3" title = "Byte-stream remote terminal streaming for v1" required_stages = ["impl", "unit"] # ── Live activity buffer / PTY digest (ADR-0008, 2026-06-01; activate M3) ── [[requirements]] id = "REQ-TERM-4" title = "Live activity buffer (session digest): projection of normalized session logs, snapshot-pull (spt endpoint digest) + structured-delta-stream contract + api digest-entry push" required_stages = ["impl", "unit", "int"] # ── Digest extractor seam & thread-spanning (ADR-0019, 2026-06-13; activate at the # digest-milestone execution start — registered now per rule 5, stages empty until then) ── [[requirements]] id = "REQ-TERM-5" title = "Adapter-declared digest extractor seam: a `[digest]` manifest section declaring an imperative extractor (native harness log -> the {role,text,tool,ts} contract; defaults to the [history] source files with an own-source escape hatch), `api digest-entry` push fallback, register-time validation of the section, adapter-declared presentation defaults (window depth, arg-truncation, sprint-collapse) that any consumer may override, and a `spt adapter digest-proof` author tool plus runtime skip-diagnostics (no silent drop). Reverses M9's no-manifest-seam stance; no declarative DSL." # End-state target [doc, impl, unit, int]; activated per-wave as evidence lands # (rule 2: traceable-reqs check EXIT=0 every commit — stages match evidence, never # pre-activated). doc landed with the MANIFEST [digest] section; int landed Wave 4 E2E. required_stages = ["doc", "impl", "unit", "int"] [[requirements]] id = "REQ-TERM-6" title = "Thread-spanning digest across session boundaries: a per-endpoint session ledger (`/sessions.log`) appended at first bind and by `api boundary` on `/clear`|`/compact` session rotation, the digest enumerating the last K sessions so its rolling window bridges a boundary, and a distinctive in-timeline boundary marker (DigestEntry::Boundary). The digest follows the live-agent thread, not a single session." # End-state target [impl, unit, int]; impl+unit Wave 2, int Wave 4 E2E. required_stages = ["impl", "unit", "int"] [[requirements]] id = "REQ-TERM-7" title = "Two-origin digest merge: spt-owned context-injection entries (psyche_download | echo_mirror | owl_message) appended by spt to the endpoint `digest.log`, timestamp-interleaved with the adapter's extracted activity records into one ordered timeline, via a distinct context-injection record category. Data model only this milestone; GUI collapse/expand and the echo-reads-digest delta loop are deferred to the surfaces that consume them." # End-state target [impl, unit, int]; impl+unit Wave 3, int Wave 4 E2E. required_stages = ["impl", "unit", "int"] [[requirements]] id = "REQ-FRONT-1" title = "Day-one launcher/manager frontend (list/launch/attach/init)" required_stages = [] # rule 5: GUI milestone — no frontend work in M0–M4 # ── M12 spt-hosted harness bringup + user PTY attach (the spt-claude-code gating # prerequisite; M12-PLAN.md Wave 1). Registered now per rule 5; stages activate # incrementally as evidence lands (doc+impl+unit this wave, int at the W-final E2E). ── [[requirements]] id = "REQ-HOST-RUN-1" title = "spt-hosted harness bringup: `spt endpoint run` spawns an adapter's `[session.self]` command template into a broker-held PTY (the spawn-session seam, brain.rs spawn_session_pid — same broker path shellhost.rs launch_shell_brokered_in uses for shells, now for kind=\"harness\" self-role), registers the perch under the given endpoint id, returns the id. Reverses today's harness-hosted-only launch (external launcher → `api bind`). Non-interactive flag set (--adapter --id --create --resume --attach|--start|--view) covers every terminal action of the W2 interactive picker so shortcuts (cc-) bake fully non-interactive launches; composite adapter:profile resolves via registry::resolve_option leaf-replace overlay." required_stages = ["impl", "unit", "int"] # activated M12-W1: impl = harnesshost::{prepare_harness_spawn, launch_harness_brokered_in} + cli cmd_endpoint_run; unit = harnesshost template-fill/fail-closed/mint; int = spt_hosted_bringup_then_cross_node_attach_drives_the_pty (bringup spawns into the broker PTY, cross-node attach drives it). cwd/project deferred to REQ-HOST-RUN-2 (W1 ships broker-inherited cwd) [[requirements]] id = "REQ-RC-1" title = "`spt rc ` — user CLI attaching a local terminal to a broker-held PTY, reusing the cross-node attach machinery (attach.rs request_attach → send_attach_input pump, spt-net AttachRecord codec); local attach is the degenerate single-node case of the cross-node path (rides REQ-TERM-3 byte-stream streaming). Read-only `--view` (watch, no stdin forwarded). Clean detach that does NOT terminate the broker-held session (KNOWN-HAZARDS: PTY ownership stays with the broker; no termination on detach). Explicit detach keybind that cannot collide with harness passthrough input (legacy capsule used a ctrl-b prefix); documented. ConPTY DSR auto-answer in the attach reader (hazard 5.5)." required_stages = ["impl", "unit", "int"] # activated M12-W1: impl = rc::run_attach pump (single-Brain + stdin-thread + seq-dedup + ctrl-b detach) + cli Cmd::Rc; unit = detach-keybind semantics + prefix-spans-chunks + op-minter; int (CROSS-NODE face) = spt_hosted_bringup_then_cross_node_attach_drives_the_pty + loopback_self_dial_is_refused (pins the local-transport choice). LOCAL face int (loopback-conn B1) lands W1.5 with the "both transports → one pump" assertion (doyle 2026-06-14) [[requirements]] id = "REQ-HOST-RUN-2" title = "Project-scoped working directory for spt-hosted bringup: `spt endpoint run` lands the broker-spawned harness PTY in the user's PROJECT cwd, not the daemon's, via an additive `SpawnReq.cwd` field carried through the broker PTY spawn (portable-pty CommandBuilder cwd). N-1-safe wire change (additive, defaulted). Required because the consumer (Claude Code) is project-scoped: broker-inherited cwd = the daemon's cwd = the wrong `.claude`, wrong session history, wrong digest source; `cc ` at a project root MUST land the harness in that project. W1 ships broker-inherited cwd as a bringup-proof shortcut only; this REQ must land before the M12 gate (doyle, 2026-06-14)." required_stages = ["impl", "unit", "int"] # activated M12-W1.5: impl = additive SpawnReq.cwd + PtySession::spawn_program_in (portable-pty CommandBuilder cwd) + broker dispatch_spawn honors it + harnesshost/cli thread project cwd (std::env::current_dir); unit = spawn_program_in_lands_the_child_in_the_requested_cwd (spt-term); int = broker_spawns_the_pty_child_in_the_requested_cwd (wire field → broker spawn → PTY cwd, end to end) # ── M12 Wave 2: interactive `spt endpoint run` picker (M12-PLAN.md Wave 2 / M12-ENDPOINT-RUN-PICKER.md; # doyle ruling M12-W2-RULING.md). Pure front-end over existing surfaces — no second bringup path # (the gate invariant). doc+impl+unit; NO int — the TUI renders to a testable ratatui Buffer # (buffer + state-model asserts as unit) and the live key loop is a manual-verify leg (REQ-PAIR-6 # precedent); the integrating bringup path is already int-covered by REQ-HOST-RUN-1/REQ-RC-1. ── [[requirements]] id = "REQ-RUN-PICKER" title = "Interactive `spt endpoint run` picker (ratatui TUI): bare `spt endpoint run` (no --adapter/--id) enters an in-process picker (flags-present = the REQ-HOST-RUN-1 non-interactive path, untouched). Layer 1 picks kind (Create new | Pick existing). Create-new: choose a registered kind=\"harness\" adapter with its shipped+local profiles tree-nested (registry::registered / manifest.profiles / local_profile_names) → enter a charset-validated id → start. Pick-existing: category select (left/right) over [ | Local node | Subnet], endpoints grouped + alphabetically sorted per category, a status square per endpoint (online green ■ / offline gray ▢ — the blue \"attached\" tri-state + Kick are DEFERRED to a broker attach-presence slice, M12-W2-RULING Q1), type-to-filter (`/`, nucleo-matcher), a pinned keybind legend, and a right-half two-pane description (harness adapter:profile · best-effort project history newest→oldest from the contextstore p- branches, empty-if-none · `spt endpoint description`). Confirm layer offers status-dependent options — Attach/Start/View (rc pump / cmd_endpoint_run) · Instantiate-locally (remote) · Change-harness-adapter (offline) · Fork (cmd_fork) · Resume-from-history (offline+LOCAL only; enumerate spt_store::sessions::last_k, titles ` @ (…id5)`, feed session_id → cmd_endpoint_run --resume). A single action enum is the source of truth so a future tap-mode (phone PTY) layers on without re-coupling to keybinds. EVERY terminal action routes through cmd_endpoint_run / existing CLI fns — no second bringup path." required_stages = ["doc", "impl", "unit"] # activated M12-W2 (no int: TUI Buffer + state-model unit asserts; live loop manual-verify; bringup path int-covered by REQ-HOST-RUN-1/REQ-RC-1). v0.13.0-W4 UX refinements (todlando 2026-06-19): (1) bare picker OPENS on Pick-existing (Kind = jump-target, `n` → create); (2) Start-now + Resume-from-history ATTACH by default (was detached/no-stdout) with an `h` headless escape (start, no attach); (4) the bringup stderr line is a terse machine token (dropped the Rust `Some(..)` debug + "binds its perch" internals). impl = model new() entry + confirm_terminal attach default + start_headless_outcome + resume_outcome(headless) + mod.rs h keybinds + view legends + cli.rs terse line; unit = kind_routes(PickExisting entry) + confirm_terminal_routes(Start→Attach, h→Start) + resume_outcome_bakes_session(headless). Live keypress path = HITL verify (no int). [[requirements]] id = "REQ-RUN-SHORTCUT" title = "`-` launcher shortcut generation (picker `s` keybind, M12-W2-T2.4): from any pre-start options set the picker writes/updates a `-` launcher at the project root baking the current selection's non-interactive `spt endpoint run` flags (terminal actions only: adapter[:profile] + id + (create|resume) + (start|attach|view); Kick/Instantiate/Change-adapter/Fork are interactive-only, not bakeable). BASENAME IS A PARAMETER (operator rev. 2026-06-14): harness-agnostic spt-core defaults to `spt` (→ `spt-`); an adapter/flow OVERRIDES it (spt-claude-code → `cc`), so spt-core NEVER bakes `cc` (a harness name) into itself. The basename must be a DISTINCT token, never bare `spt` (a `spt.cmd` would shadow the real `spt.exe` only under cmd.exe cwd-first search, silently no-op in PowerShell/Unix, and self-recurse). The script is the CURRENT OS's native form — `.cmd` on Windows (NOT `.ps1`: default PATHEXT excludes `.ps1` so a bare/ext-less name never resolves one; `.cmd` is PATHEXT-resolvable), POSIX `sh` (+chmod +x) on Unix (a single portable form can't be both). The generated header documents the invocation reality (cmd.exe bare `` in the project dir / PowerShell `.\\` / Unix `./`; a truly-bare basename on PATH = a PATH-installed launcher, `/spt:setup`'s job). Overwrite is SENTINEL-guarded: the generator writes + checks a generated-by header marker — it overwrites its own prior output freely, but REFUSES + warns if a same-named file lacks the sentinel (never clobber a user file). Requires the additive `--create` flag on `Run{}` (the default-fresh made explicit; N-1-safe)." required_stages = ["doc", "impl", "unit"] # activated M12-W2 (no int: shortcut content + sentinel create-vs-update as unit; flag→action mapping unit) [[requirements]] id = "REQ-RUN-PICKER-HOME" title = "Home-subnet selection LAYER in the `spt endpoint run` ratatui Create-new picker (v0.14.1; the deferred half of REQ-RUN-MULTISUBNET-HOME's interactive path — ADR-0026 §3 'the interactive picker lists subnets MRU-ordered'). On a MULTI-SUBNET node the Create-new flow gains a `CreateHome` screen (CreateAdapter → CreateId → CreateHome → Confirm) that lists the node's MEMBER subnets MRU-ordered (reusing recent_home::mru_preference + order_by_mru), default cursor = MRU head; the chosen subnet rides Outcome::Run{subnet} into cmd_endpoint_run's --subnet, so decide_run_home resolves Home directly and the post-TUI `Ok to proceed? Y/n` confirm NEVER fires for the picker path. Single-subnet / local-only nodes SKIP the layer (assign_home auto-homes; CreateId → Confirm unchanged). The CLI / flagged `endpoint run` path KEEPS the decide_run_home Y/n confirm + the non-interactive MULTI_SUBNET_HOME refuse (operator: the confirm stays useful for CLI-only bringup, just not in the TUI). Esc backs CreateHome → CreateId; Enter selects → Confirm. Pure front-end invariant preserved: the layer only collects --subnet, routes through the one bringup core." required_stages = ["doc", "impl", "unit"] # activated @F-018/v0.14.1 build: doc = CONTEXT §spt-hosted bringup picker (the CreateHome layer note). impl = Screen::CreateHome + model home_cursor/home_subnets/selected_home/enter_id/move_home + transitions (multi-subnet gate) + Outcome::Run{subnet} + dispatch passes subnet.as_deref() to cmd_endpoint_run + data::home_subnet_options (member subnets via cli::order_by_mru reuse) + view::render_create_home. unit = create_new_multisubnet_inserts_home_layer / single_subnet_skips_home_layer / home_selection_bakes_into_run_subnet / esc_backs_out_of_home_layer (model) + home_subnet_options_is_mru_ordered (data) + create_home_lists_subnets (view). NOTE create-new has no Confirm SCREEN (it terminates by yielding the Run via create_outcome); CreateHome inserts before that terminal yield — the design's "→ Confirm" denotes that terminal step. NO int (TUI live-keypress = HITL manual-verify, same as REQ-RUN-PICKER); the subnet→Home resolution is already int-covered by REQ-RUN-MULTISUBNET-HOME's multi_subnet_bringup_e2e. # ── M12 Wave 4: cross-platform self-elevating re-launch (M12-PLAN.md Wave 4; doyle # ruling M12-W4-RULING.md). The pure decision matrix is the testable seam; each # per-OS launch is an impure manual-verify leg (REQ-PAIR-6 OS-probe precedent). ── [[requirements]] id = "REQ-ELEVATE-1" title = "Cross-platform self-elevating re-launch for privilege-gated commands: a pure decision seam `decide_elevation_path(os, elevation, interactive_tty, has_display, has_pkexec, has_term_emulator) -> ElevatePath{AlreadyElevated, InlineSudo, UacWindow, Pkexec, TerminalEmulator, PrintHint}` selecting how to re-acquire privilege, and the per-OS impure launchers it dispatches — Windows UAC console (ShellExecuteW `runas` on the abs-exe + verbatim argv; the elevated child does the work, prints 'You can close this window', and pauses for a keypress; the original prints 'Elevated terminal launched…' and exits 0; NEVER pipes the child's stdout back across the privilege boundary), Linux desktop pkexec (preferred, native polkit GUI auth) else x-terminal-emulator -e sudo (fallback list x-terminal-emulator→gnome-terminal→konsole→xterm), the existing interactive-TTY inline sudo, and the headless/no-path floor that prints the absolute-path command. Reused by every gated command (not subnet-specific). Generalizes should_auto_elevate." required_stages = ["doc", "impl", "unit"] # activated M12-W4 (no int: the decision matrix is unit-tested across the os×elevation×env grid; each actual launch needs a real UAC/polkit/sudo/TTY → manual-verify leg, REQ-PAIR-6 precedent) # ── M12 Wave 5: whoami → endpoint-list alias (M12-PLAN.md Wave 5; doyle ruling # via owl 2026-06-14, operator-overridden to the FULL alias). Smallest wave. ── [[requirements]] id = "REQ-WHOAMI-1" title = "`spt whoami` is a thin ALIAS for `spt endpoint list` (full output: the SELF pin + the subnet roster) — the standalone bare-id command is dropped (the `id=$(spt whoami)` capture was never a real pattern: env vars don't persist between agent tool calls). The one new render: the `endpoint list` SELF pin carries the Self endpoint's authored `endpoint description` (info::read_info(...).resources) when present, inline after the liveness state. whoami stays a top-level hot-path verb (parse unchanged, REQ-MSG-9)." required_stages = ["doc", "impl", "unit"] # activated M12-W5 (no int: pure SELF-pin render over (id, state, description) unit-tested + whoami routes to cmd_endpoint_list; the roster/info reads are the existing endpoint-list path, already int-covered) # ── M12 Wave 2.5: controller/viewer remote-attach model + loud Kick (CONTEXT.md:317 # locked design; doyle ruling M12-W2.5-RULING.md, operator-scoped to the FULL model). # Real broker wave (hazard zone) — multi-subscriber fan-out + viewer isolation. ── [[requirements]] id = "REQ-RCVIEW-1" title = "Remote-attach controller/viewer model (CONTEXT.md:317): a session's broker OutputLog serves ONE interactive controller (input + EXCLUSIVE PTY resize; its viewport sets the size, sent on attach + every window change via crossterm Event::Resize) plus ANY NUMBER of read-only `--view` attachers (output-only, no input, no resize; client-side letterbox — center+pad when larger, clip+1-line indicator when smaller; only the local ctrl-b d detach chord). Attach intent is three-valued (`Viewer | Control | Take`, wire-default Control): Control to a FREE endpoint becomes controller, Control to a CONTROLLED endpoint is REFUSED with guidance (`--view`/`--take`) — never auto-viewer, never silent-displace. Wire adds (additive, N-1 skip-unknown): `Request.intent`, `Resize{rows,cols}` (controller-only), `Size{rows,cols}` (→viewer), `Displaced{by}` (→displaced controller). The brain-resume cursor (delivered_through, ADR-0018) tracks the CONTROLLER ONLY; viewers replay from their own from_seq and never move it. Dormancy keys on the controller ONLY: controller attach wakes / controller detach goes dormant (even with viewers present); viewer attach/detach is wake-neutral and may watch a dormant endpoint as-is. v1: viewing is gated identically to driving — a viewer runs the same access_check(Unsolicited) as a controller (watching reveals full session contents = a real disclosure); a lighter distinct watch-gate is deferred to cross-subnet/finer-consent (CONTEXT.md:317 'driving ≠ watching' = the future seam)." required_stages = ["doc", "impl", "unit", "int"] # activated M12-W2.5 (int: multi-subscriber is cross-process/cross-node — extend the attach.rs int suite with a 2nd viewer + controller-exclusive resize) [[requirements]] id = "REQ-KICK-1" title = "Explicit, loud controller displacement: `spt rc kick ` / `--take` (Take intent) kicks the incumbent controller and becomes controller; the displaced controller receives a LOUD `Displaced{by}` notice and is FULLY DETACHED (not demoted to a viewer). A default attach to a controlled endpoint is NEVER a silent displace (it is the Control busy-refusal). An old (N-1) rc omits intent → Control, so it can drive a free endpoint but CANNOT `--take` — it can never silently steal, and gets a clean busy-refusal instead. Taking control rides the same access_check(endpoint, origin, Unsolicited) as a normal control attach (if you may drive, you may take — no elevated kick policy). The picker surfaces 'Kick and attach' (Take) only on a controlled (blue ■) endpoint, via the existing attach dispatch (single-bringup-path: intent is a parameter)." required_stages = ["doc", "impl", "unit", "int"] # activated M12-W2.5 (int: cross-daemon displace → displaced controller observes the loud notice + detach, the taker becomes controller) # --- Added 2026-06-17 (v0.10.0 picker-status slice, operator-flagged + doyle dispatch); rule 3 register-before-satisfy --- [[requirements]] id = "REQ-PICKER-1" title = "The picker renders a FOUR-state endpoint status (extending the W2 online/offline duality): the list-item square AND a color-coded STATUS line at the top of the pick-existing right-side details both show — gray OFFLINE; green ONLINE (online + PTY-controllable spt-hosted, not controlled); amber 'ONLINE - HARNESS ONLY' (online but NOT broker-PTY-controllable = harness-hosted, no broker PTY seat — today mis-shows green); blue 'ONLINE + CONTROLLED' (online + driven_by.is_some()). Derived on EndpointRow from {offline | controllable | driven_by} with precedence offline→gray, else driven_by→blue, else !controllable→amber, else green (driven_by outranks harness-only; mutually exclusive in practice — a harness-only endpoint has no broker PTY to control). The controllable discriminator is a NEW InfoJson.controllable: Option (serde-default, N-1-safe), stamped at the establish seam — cmd_listen (harness-hosted relay, no broker PTY) → Some(false); cmd_bind live_agent (spt-hosted broker PTY) → Some(true); absent → not-controllable (amber) default (harness-hosted is the common mis-reported case; one bind self-corrects). Store-projection-only (no live daemon query — doyle ruling). (v0.10.0)" required_stages = ["impl", "unit"] # activated v0.10.0 PICKER-1: impl = InfoJson.controllable + listen/bind stamp + EpStatus 4-state + square_span/render_pick color + data.rs derive; unit = the 4-state derivation + view snapshot render) [[requirements]] id = "REQ-PICKER-2" title = "The picker's project-history loader reads the git-backed context store, not the bare working tree: data.rs project_history_for enumerates an endpoint's projects via the BranchStore branch set (the context store keeps per-project context in git branches — contextstore::project_branch(project_id), checked out to projects/// only on-demand) instead of raw std::fs::read_dir over the empty working tree (which returned empty for ALL rows incl wall-a — the operator bug). Ordered newest→oldest by branch commit recency; degrades to empty (informational pane), never fails. (v0.10.0)" required_stages = ["impl", "unit"] # activated v0.10.0 PICKER-2: impl = data.rs project_history_for enumerates p- branches (BranchStore::branches_by_recency) holding the agent's per-project file, newest→oldest; unit = branches_by_recency orders newest-committed branch first [[requirements]] id = "REQ-PICKER-3" title = "A self-owned subnet row reconciles its status to the LIVE roster: a Subnet-category row whose endpoint_id overlaps a local (is_local) roster id is self-owned (this node hosts it), so its status square is OVERRIDDEN with the live roster status — the WAN registry snapshot (wansend::load_snapshots) is a periodically-advertised, independently-stale projection, while the local roster (p.alive) is ground truth for an endpoint this node hosts. One status square per endpoint (CONTEXT.md:348-350 — nothing licenses opposite squares for one endpoint across its Local vs Subnet listings). A reconcile pass in data.rs after the local_rows + subnet_rows gather; BOTH category listings are preserved (Local + Subnet are legitimately distinct views — you are in your own subnet), only the STATUS is unified. (v0.10.0)" required_stages = ["impl", "unit"] # activated v0.10.0 PICKER-3: impl = reconcile_self_owned id-overlap pass in gather_endpoints (roster status overrides the stale subnet snapshot); unit = a dual-listed self-owned endpoint whose stale snapshot disagrees renders the roster status in BOTH categories [[requirements]] id = "REQ-PICKER-4" title = "The picker's Subnet category renders the canonical node LABEL, not bare key-hex: a subnet row's node renders as 'LABEL (keyprefix…)' (e.g. 'HFENDULEAM (bcead52b…)') per CONTEXT.md:650 + Instance.node_label, NOT the raw node key-hex (SPT_DEV:14efb80cb… — a picker-only regression because resource_projection→ResourceRow drops node_label, so data.rs subnet_rows uses the raw row.node). Thread node_label into the picker subnet path (ResourceRow gains node_label, or subnet_rows looks it up via the registry's node_labels) and REUSE the one canonical render (format!(\"{l} ({}…)\", key_prefix) — cli.rs / wansend.rs), never a re-implementation. (v0.10.0)" required_stages = ["impl", "unit"] # activated v0.10.0 PICKER-4: impl = node_label threaded onto ResourceRow in resource_projection (instance label, node_labels-map fallback) + canonical node_label_display reused by BOTH the picker and endpoint-list; unit = resource_projection_threads_node_label + node_label_display render. v0.13.0-W4 extension (todlando 2026-06-19, the D item): a LOCAL row's `driven_by` controller key now renders the NODE NAME too — data.rs node_label_map() (subnet snapshots' node_labels lease) + driven_by_display() routes the key through the SAME node_label_display, so the "controlled by" pin shows `LABEL (keyprefix…)` not raw hex; degrades to `keyprefix…` when the label is unknown. impl = node_label_map + driven_by_display wired into local_rows; unit = driven_by_display (label present/absent/None). [[requirements]] id = "REQ-PICKER-5" title = "`spt endpoint list` (bare/subnet view) renders an ALIGNED table with canonical node labels: cmd_endpoint_list prints subnet rows with `\\t` TAB separators (cli.rs:~1651-1662) so variable-width endpoint_ids snap fields to different tab-stops → a RAGGED status column (operator screenshot: X/help statuses misaligned vs rt-*/sptc-*/wall-a); and it calls the node renderer with no label → bare key-hex for every row (SAME ResourceRow-drops-node_label root as REQ-PICKER-4). FIX: max-width per-column padding (mirror render_node_rows' pad, pad by char count not byte len — '…' is multibyte) replacing the tabs, and render the node via the shared node_label_display now that ResourceRow carries node_label (REQ-PICKER-4). Extract a pure row-formatter seam so the alignment+label is unit-testable. ALSO: the bare list is the SUBNET view (a just-run LOCAL perch is invisible cross-subnet until the next advertise tick), so emit a `--local` hint line so a freshly-run endpoint isn't perceived as lost. (v0.10.0; operator-flagged + doyle dispatch 2026-06-17)" required_stages = ["impl", "unit"] # activated v0.10.0 PICKER-5: impl = pure format_subnet_rows (per-column char-width pad + node_label_display) wired into cmd_endpoint_list, replacing the \t tabs + None-label, plus the --local hint; unit = a ragged-width row set formats aligned with LABEL (keyprefix…) # --- Added 2026-06-17 (post-v0.10.0 findings backlog; operator greenlight + doyle dispatch); rule 3 register-before-satisfy --- [[requirements]] id = "REQ-SEND-SPT-HOSTED" title = "An inbound `spt send` is DELIVERED to an spt-hosted endpoint (brought up via `spt endpoint run` → `api bind`, broker holds its PTY, NO `api listen` relay). Today cmd_bind→establish_perch (api/startup.rs ~441) writes info.json + ready marker + controllable=Some(true) but registers NO message-listener / NO address, so deliver.rs resolve_address→None→spool (deliver.rs:132-140) and the message NEVER reaches the live PTY — the endpoint reads 'online' (ready marker) yet `spt send` silently SPOOLS ('online but not deliverable' lie). Per CONTEXT:187-188 the daemon owns the PTY and delivers, manifest-configurable per activity-state (direct PTY injection / relay / HTTP). FIX: route an inbound send for an spt-hosted target through the daemon → broker InputReq → session.write_input PTY-inject (broker.rs dispatch_input/write_input ~988-1022), the same path the brain uses; the live-delivery handshake must report Sent (not Queued) and stop the spool-only fallback for a broker-hosted, PTY-resident endpoint. Detection is local: controllable==Some(true) + spt-hosted state + resolve_address==None. = the spt-core HALF of the wall-b finding (perri owns the adapter half: bind-hook fired-zero-perch + the missing endpoint-run int test). (post-v0.10.0)" required_stages = ["impl", "unit", "int"] # activated v0.11.0: impl = KIND_ENDPOINT_INPUT frame + broker dispatch_endpoint_input (endpoint→session scan + write_input, idle-direct, logged) + Brain::inject_endpoint client + cmd_send is_spt_hosted_no_relay gate + render_event_whole inject; unit = endpoint_input_frames_round_trip + spt_hosted_no_relay_detection; int = endpoint_keyed_inject_reaches_hosted_pty (real broker PTY round-trip + negative miss). FOLLOW WAVE: activity-gated routing (idle→inject / active→spool-for-poll-drain) per CONTEXT:188 [[requirements]] id = "REQ-HAZARD-RC-EOF" title = "A severed broker stream during a live rc session surfaces GRACEFULLY, never as a raw io error that crashes the PTY. The rc read-loop (rc.rs:352-362) continues only on WouldBlock/TimedOut; ANY other read_event_until error — including UnexpectedEof 'failed to fill whole buffer' — returns Err → RC_FAIL → the PTY 'crashes' from the user's view. Confirmed trigger: a deliberate `spt daemon stop` (broker bounce) severs an active rc (perri stopped the daemon to release owlery watch handles). Same severed-broker-stream EOF class as the v0.9.1 seed fix (seed_fail_message) and the listener-death case — spt-core must classify a broker-gone EOF and (a) surface a CLEAR actionable message ('daemon stopped/restarted — re-run / reconnect'), never the raw buffer error, and ideally (b) AUTO-REATTACH to the same session on the fresh broker (the broker is the daemon-lifetime anchor; it returns on the next `spt api` call). FOLD two side-observations: (1) `spt daemon stop` SILENTLY drops active rc/live sessions — warn ('N active session(s) will drop') or graceful-detach on stop; (2) the daemon holds owlery WATCH HANDLES on perch dirs so a torn-down perch dir stays 'Device busy' until a full daemon stop releases them (perri's rt-* cleanup) — a torn-down perch's handle should release without a daemon stop. doyle Finding C, root-caused. (post-v0.10.0)" required_stages = ["impl", "unit"] # activated v0.11.0 RC-EOF wave-1: impl = classify_read_err in the rc pump (UnexpectedEof/reset/abort/pipe → BrokerGone) + PumpEnd::BrokerGone graceful clear message; unit = classify_read_err_eof_is_graceful_not_fatal. FOLLOW WAVE (still open): auto-reattach + daemon-stop active-session warning + owlery watch-handle release-on-teardown [[requirements]] id = "REQ-HAZARD-DEFERRED-MANIFEST" title = "A pointer-mode (delegated / GhReleaseManaged) adapter whose binary/manifest is not yet extracted is reported with a CLEAR diagnostic, never silently dropped. Today such an adapter reads its manifest LIVE from source_dir (registry.rs manifest_dir ~146/149); a deferred / un-extracted install makes load_manifest fail → registered() (~410, filter_map(.ok())) SILENTLY DROPS the row → downstream ADAPTER_UNRESOLVED + a cryptic os-error-2 on `spt adapter use`. FIX: surface a clear diagnostic at the resolver + at `adapter use` (name the adapter + the deferred/missing-manifest cause + the fix), not a silent filter-drop and not a bare os-error-2; consider an eager manifest copy at register time so host_binaries survive before the binary download completes. doyle Finding A. (post-v0.10.0)" required_stages = ["impl", "unit"] # activated v0.11.0: impl = RegistryError::DeferredManifest surfaced by load_manifest for a pointer-mode NotFound (clear actionable message, propagates through resolve_option → adapter use), + registered() skip-diagnostic (ADAPTER_SKIP log) replacing the silent filter_map(.ok()) drop; unit = pointer_missing_manifest_is_deferred_not_cryptic (DeferredManifest not Io, actionable message, registered() skips not crashes) [[requirements]] id = "REQ-HAZARD-ENV-SUBST" title = "`spt endpoint run` HONORS manifest [env.] direction=inject values (with {key} substitution) on the spt-hosted spawn. Today only the [session.self] command ARGV is {id}-substituted; the [env] inject value is NEITHER substituted NOR applied — manifest.schema.json promises EnvVar.value = 'Value to inject (with substitution)' but prepare_harness_spawn fills only argv and SpawnReq carries no env, so a [env.SPT_ENDPOINT_ID].value='{id}' arrives EMPTY. A FLAGLESS harness (bare `claude`, no argv slot for {id}) then routes the id via [env] → empty → SessionStart sees empty $SPT_ENDPOINT_ID → seeds-by-PPID instead of binding → ZERO perch → NO_PERCH (the actual wall-b bind blocker; perri hard-repro'd). SILENT failure (empty inject, no error). FIX (doyle ruled a): fill every [env] inject value from the SAME {key} catalog as argv/role (mirror F-009 TEMPLATE fill, whole-string fill_template for an env value), thread it through SpawnReq.env → the broker sets it on the spawned PTY child. Correctness fix — schema already promises it, NO manifest change, NO new binary. PAIRS with REQ-SEND-SPT-HOSTED to make endpoint run fully work. doyle F-013. (post-v0.10.0)" required_stages = ["impl", "unit", "int"] # activated v0.11.0: impl = prepare_harness_spawn fills [env] inject values (fill_template, same catalog) into PreparedSpawn.env → SpawnReq.env → broker dispatch_spawn → PtySession::spawn_program_in_env sets child env; unit = prepare_fills_env_inject_values_with_substitution ({id}→SPT_ENDPOINT_ID, sess-{session_id} filled, read-direction skipped); int = spawn_env_reaches_child (injected SpawnReq.env reaches the spawned PTY child, cross-OS echo). Wall-b composition E2E (endpoint run → SPT_ENDPOINT_ID set → bind → reachable) rides the bundle E2E [[requirements]] id = "REQ-HAZARD-ROSTER-GHOST" title = "A LOCAL subnet roster entry whose backing perch is erased does NOT keep advertising Active (no phantom perch-less endpoint). `api session-end --erase` removes the perch (owlery dir gone) but the subnet roster (identity/registry/.json) keeps the endpoint's instance row ACTIVE with no backing perch; `endpoint stop` says 'address unregistered' yet the line persists; no CLI verb forgets a roster entry, and a hand-edit is re-added by the single-writer daemon advertiser. FIX: daemon-side self-heal — the advertiser DROPS/forgets a LOCAL roster entry whose backing perch no longer exists (stops advertising it Active), and/or a `forget`/evict verb; verify whether the epoch lease eventually evicts it (slow-self-heal) vs a real leak and scope accordingly. doyle secondary finding (perri). (post-v0.10.0)" required_stages = ["impl", "unit"] # activated v0.11.0: impl = advertise_local ghost-heal pass — a LOCAL row whose perch is no longer on disk (erased) is advertised Offline (fresh epoch, lease-ordered) so it stops showing Active locally + peers converge; unit = erased_perch_is_advertised_offline_not_left_active (Active→Offline on erase, pushed to peers, dropped from resource_projection). Cross-node convergence int = follow-up # --- Added 2026-06-17 (v0.12.0 spt-hosted lifecycle & liveness reconciliation milestone; operator-mandated blocking, doyle dispatch; rule 3 register-before-satisfy). UNIFYING ROOT: status=online (info.json) is a ONE-WAY LATCH set at establish (startup.rs:361/468), never cleared vs real liveness (liveness.rs:80-93 returns ONLINE for daemon-hosted). WAVE ORDER: B2 → B1+H3 → B3+Breap → B5 → B4. --- [[requirements]] id = "REQ-HAZARD-HOSTED-LIVENESS-RECONCILE" title = "B2 KEYSTONE: a daemon-hosted (spt-hosted) endpoint's info.json status is RECONCILED to real liveness, not left latched online. The broker exit-waiter (broker.rs:889-910) reaps its in-mem session table + emits ExitEvent but NEVER touches info.json; lifecycle::mark_offline only fires on Psyche teardown — so a dead/exited harness (operator closed the tab) stays status=online forever (is_perch_alive returns ONLINE for daemon-hosted, liveness.rs:80-93). FIX (doyle ruled PULL-PRIMARY — the live-status analog of REQ-HAZARD-ROSTER-GHOST): the livehost reconcile loop (reconcile_once livehost.rs:226-313) queries the broker's live session set (KIND_SESSIONS) each tick and, for any status=online live_agent perch PAST the boot grace whose endpoint has NO live broker session, marks it offline (lifecycle::mark_offline → status=offline → is_perch_alive=false). GATED on spt-hosted (controllable==Some(true)) so a HARNESS-HOSTED relay live agent (api listen, legitimately online with no broker session) is NEVER mis-marked. Crash-robust + self-healing on the next tick (clear-on-event is not crash-robust alone). PUSH (brain ExitEvent→mark_offline) is an OPTIONAL fast-path only if the daemon brain is reliably subscribed to all hosted sessions; correctness rides the pull. Broker stays stateless (ADR-0004 §B — brain owns the info.json write). (v0.12.0)" required_stages = ["impl", "unit", "int"] # activated v0.12.0 B2 keystone: impl = reconcile_hosted_liveness (controllable-gated no-broker-session → mark_offline) + query_live_session_endpoints + boot-grace gate wired into spawn_live_host; unit = pull_liveness_marks_sessionless_spt_hosted_offline_only (offlines only sessionless spt-hosted; relay/legacy/ready exempt); int = pull_reconcile_offlines_perch_when_broker_session_dies (real broker session killed+reaped → next reconcile clears the latch) [[requirements]] id = "REQ-HAZARD-RC-ATTACH-FAILFAST" title = "B1: `spt rc ` to a DEAD or non-streaming session fails fast with a clear message, never an INFINITE blank screen. Today rc.rs run_attach (209-231) + pump spawns PUMP_IPC_READER and blocks: the poll times out each slice but the stream never produces output, so the operator sees a permanent blank (operator: fresh wall-f attached, closed tab, then `spt rc wall-f` HUNG — the broker still resolved a session for it). FIX: (a) once B2 lands, gate attach on is_online/status — an offline endpoint yields a clean 'endpoint offline, start it' not an attach; (b) fail-fast — if the attach-open ack / first output does not arrive within a bound, surface a clear message, never an infinite blank; (c) the broker EOFs the attach stream when the session's child is dead, so rc's existing PumpEnd::BrokerGone graceful path (REQ-HAZARD-RC-EOF) catches it. PIN the exact sub-mechanism with a repro test FIRST (dead-session-lingers-in-broker vs reaped-but-rc-waits vs alive-resting-no-wake — the wall-f Windows tab-close: child alive-silent vs dead-not-reaped). (v0.12.0)" required_stages = ["impl", "unit", "int"] # activated v0.12.0 wave 2: impl = (a) run_attach status-gate (explicit status=offline → clean short-circuit, no broker) + (b) generous first_event_stalled backstop (PumpEnd::Stalled, 30s, only when no event ever — never bites mid-init alive) + (c) dispatch_subscribe dead-child try_wait → Exit frame (primary dead-detector); unit = attach_offline_endpoint_short_circuits_clean + first_event_stall_decision; int = dead_session_subscribe_does_not_hang (subscribe to an exited session surfaces Exit/error promptly, never silence). Live-attach regression = existing broker + attach suites (unchanged for a live child) [[requirements]] id = "REQ-ENDPOINT-STOP-OFFLINE" title = "H3: `spt endpoint stop ` marks the endpoint OFFLINE (alive=false), not merely de-readied. cmd_stop (cli.rs:2994-3010) removes the ready marker + unregisters the address but does NOT set status offline, so a stopped daemon-hosted endpoint still reports alive=true (status=online latch). FIX: add set_status(perch, STATUS_OFFLINE) to cmd_stop — folds with B2 (same setter). Unit: stop → is_perch_alive=false / alive=false. (v0.12.0)" required_stages = ["impl", "unit"] # activated v0.12.0 wave 2: impl = cmd_stop set_status STATUS_OFFLINE (folds the B2 setter); unit = endpoint_stop_marks_offline (stop stamps offline → is_perch_alive=false) [[requirements]] id = "REQ-HAZARD-DAEMON-STOP-BARRIER" title = "B3: `spt daemon stop` then an immediate `spt daemon start` does NOT race — stop fully completes before it returns. Today request_stop (seedmap.rs:240-255) returns on the KIND_STOPPING ack (sent seedmap.rs:174-176) BEFORE the seed socket unbinds, so a following is_running ping (daemon.rs:375) wins the exit window and start reports ALREADY_RUNNING (operator: daemon stop → STOPPED then start → ALREADY_RUNNING). FIX: unbind/stop-gate the seed socket BEFORE acking KIND_STOPPING, OR request_stop waits for a ping-to-fail before returning. Unit: stop then immediate is_running()==false. (v0.12.0)" required_stages = ["impl", "unit"] # activated v0.12.0 wave 3 (with Breap): impl = request_stop ack-then-barrier (polls ping to failure before returning, seedmap.rs); unit = request_stop_barrier_holds_until_no_listener (immediate post-return ping is Err, no poll) [[requirements]] id = "REQ-HAZARD-DAEMON-STOP-REAP" title = "Breap: `spt daemon stop` REAPS the spt-hosted children it spawned — no orphaned psyche/harness processes. Today a stop leaves ~8 orphaned claude-spt-psyche.exe + spt.exe: Psyches are spawned DETACHED (runtime.rs:342-356, the Child is dropped — 'Detached' ~349) and the livehost stop flag Arc is NEVER raised (brainproc.rs:227-230 holds it 'for symmetry'). FIX: on stop, raise the livehost stop flag AND kill the spawned psyche/spt-hosted children — via a Windows job object / Unix process-group so the children die with the daemon (not detached-immortal). Folds with B3 (both the stop path). (v0.12.0)" required_stages = ["impl", "unit"] # activated v0.12.0 wave 3 (with B3): impl = BrainReaper (Windows kill-on-job-close Job / Unix per-brain process-group) enrolled per brain (re)spawn + reaped on the graceful daemon-stop path (supervisor stop flag raised first, no respawn race); unit = reap.rs job/group reaps an enrolled child [[requirements]] id = "REQ-HAZARD-LIVEHOST-BOOT-LIVENESS-GATE" title = "B5: `spt daemon start` does NOT revive phantom Psyches for dead-but-online-latched perches. Today reconcile_once (livehost.rs:285) spawns a Psyche per status=online live_agent perch at boot WITHOUT verifying the harness child / {id}-psyche is actually alive — so a Cold start after an unclean stop revives N psyches for N dead-but-latched perches (3 psyches for 3 dead perches). FIX: gate the boot psyche-spawn on real child-liveness — a perch with NO live broker session (the B2 reconcile signal) is marked OFFLINE at boot instead of hosted, so a dead-harness perch is never revived. Shares the B2 reconcile loop (this is its boot-gate arm); composes with B2's honest latch. Also closes wall-a's psyche_host_error gap (residency-confirm does not run at boot tick-1, livehost.rs:395-441 / 257-263). (v0.12.0)" required_stages = ["impl", "unit", "int"] # activated v0.12.0 wave 4: impl = lift the LIVENESS_RECONCILE_BOOT_GRACE skip so the B2 reconcile_hosted_liveness runs from boot tick 1 (a sessionless controllable perch is offlined BEFORE reconcile_once can revive its Psyche — reuse the B2 fn, no second liveness notion); unit = boot_gate_offlines_sessionless_controllable_then_reconcile_skips_host (+ session-backed contrast hosts); int = cold-start real broker with a stale online controllable perch (no session) offlines it at boot, no phantom psyche [[requirements]] id = "REQ-HAZARD-BRAIN-RESTART-LIFECYCLE-REHYDRATE" title = "B4 (deepest): a bare brain restart (broker survives) REHYDRATES the live-agent lifecycle so post-restart endpoints are hosted + attachable. Today resume_sessions (brainproc.rs:186, brain.rs:797-809) re-subscribes to the broker's PTY sessions but ALL BrainLifecycle instances (lifecycle.rs:58-130; the ephemeral brain.rs:254-275) are LOST on restart → a post-restart live endpoint gets no livehost → its Psyche is never (re)hosted and new spawns die / can't attach until a FULL daemon reset (operator: perri's brain kill+restart wedged everything until a full daemon kill). FIX: on brain startup, rebuild a BrainLifecycle per resumed live-capable session — load the manifest from the adapter registry → instantiate → start the pulse — the rehydrate the resume no-op cannot do. Composes with B2 (the reconcile re-hosts from the honest on-disk status after rehydrate). (v0.12.0)" required_stages = [] # CLOSED v0.12.0 wave 5 — SUBSUMED, not built (doyle+operator ruling). Per-axis evidence: every axis the REQ names is ALREADY rebuilt on a bare brain restart — (1) BrainLifecycle config → host_one with_config_in (from the adapter registry + perch, each host); (2) pulse-driver thread + Psyche host → run_brain→spawn_live_host(brainproc:230)→reconcile_once (every brain start); (3) PTY message-delivery cursors → resume_sessions(brain.rs:797); (4) online/offline honesty → B2 pull-reconcile + B5 boot-gate; (5) shellwake watchers → spawn_wake_host(brainproc:219). The "no livehost / Psyche never re-hosted / can't attach" premise is STALE (predates spawn_live_host-in-run_brain + the B2/B5 reconcile). The real residual it gestured at — brain restart ORPHANS the prior brain's psyches → DUPLICATE per endpoint — is a REAP hazard (different shape than this rehydrate title), tracked honestly as its own id REQ-HAZARD-BRAIN-RESTART-PSYCHE-DUP rather than redefining this one. [[requirements]] id = "REQ-HAZARD-BRAIN-RESTART-PSYCHE-DUP" title = "A bare brain restart leaves EXACTLY ONE `{id}-psyche` process per endpoint — no duplicate. On an abrupt brain death stop_host never runs (the LiveSet + owned child handles die with the brain) and Breap's job/group only reaps at DAEMON stop, so the PRIOR brain's Psyche stays ALIVE; the respawned brain's reconcile re-hosts a SECOND Psyche and overwrites the `{id}-psyche` perch pid, leaving the old one untracked + alive = a duplicate that lingers until daemon-stop (the operator's 'brain kill+restart wedged everything'). FIX: at brain start, BEFORE the first reconcile re-hosts, reap any pre-existing `{id}-psyche` orphan — ID-SPECIFICALLY (recycle-safe on the shared box, where sibling agents share the `claude` basename): scoped-kill the recorded pid ONLY IF it is alive AND its exe basename == the adapter's psyche program (normalize_basename) AND its COMMAND LINE contains the full psyche id `-psyche` (baked via {id}); a sibling never carries THIS id, and any unreadable signal FAILS SAFE (decline to reap — a missed dup is bounded by Breap, a wrong-kill is catastrophic). CAVEAT: the cmdline carries `-psyche` only when the adapter's psyche_init.command uses {id} (the norm); a non-{id} adapter safely MISSES the reap (today's behavior, Breap bounds it) — never a wrong-kill. (v0.12.0)" required_stages = ["impl", "unit", "int"] # activated v0.12.0 wave 5 (the residual surfaced closing B4): impl = reap_orphan_psyches at brain-start in spawn_live_host (triple-gate psyche_orphan_should_reap: alive + normalize_basename + process_cmdline contains -psyche; kill_pid scoped) + spt_store::proc::{kill_pid, process_cmdline}; unit = orphan_reap_is_id_specific_spares_a_same_basename_sibling (real same-basename diff-id sibling SPARED) + process_cmdline_reads_a_live_arg_marker; int = brain_restart_leaves_exactly_one_psyche_per_endpoint (kill brain child → respawn → P1 reaped, one new P2) [[requirements]] id = "REQ-HAZARD-UNHOST-PSYCHE-REAP" title = "On un-host, the detached `{id}-psyche` HARNESS PROCESS is reaped — not just its in-brain pulse-driver thread. Today stop_host (livehost.rs:203) trips the HostedLife stop flag + JOINS the driver thread, but the Psyche is a detached harness process (spawn_psyche → ManifestRuntime detached spawn, runtime.rs:341-356; its pid is untracked in HostedLife though stamped on the `{id}-psyche` perch, where residency-confirm already reads it). So endpoint-stop / mid-life agent-death / a B2/B5 offline-then-unhost leaves the psyche process ORPHANED, alive until the next daemon-stop (where Breap's job/group reaps the whole brain subtree). The Psyche STAYS a harness process by design (CONTEXT.md 97/203/251 — headless harness session, its own perch) — the fix does NOT move it in-brain; it SCOPED-kills the `{id}-psyche` pid on un-host (never machine-wide — shared box). Track the pid in HostedLife at host_one (cleanest) or read the `{id}-psyche` perch pid at stop_host. Composes with H3 (endpoint stop → offline → reconcile un-host → reap) and B2/B5 (the offline arms that trigger un-host). (v0.12.0)" required_stages = ["impl", "unit", "int"] # activated v0.12.0 wave 5 (operator-greenlit fold-in): impl = spawn_session_owned/spawn_psyche_owned RETAIN the Child up to HostedLife.psyche_child; stop_host reaps BY HANDLE (child.kill()+wait(), recycle-proof — NOT a bare pid that could hit a recycled sibling); unit = stop_host_reaps_the_detached_psyche_process (real stand-in terminated); int = endpoint-stop of a live spt-hosted agent leaves no {id}-psyche process [[requirements]] id = "REQ-ENDPOINT-PURGE" title = "`spt endpoint purge ` fully removes an endpoint AND every record keyed on it — the formal teardown devs/CI need for clean test setup/reset. NOT consent-gated (a local dev/test op — no peer consent). OFFLINE-ONLY: refuses while the endpoint is online / daemon-hosted (deleting records out from under a live host risks the daemon re-creating or re-hosting mid-purge); `--force` STOPS it first (endpoint stop → wait for the daemon reconcile to un-host + reap the Psyche) THEN purges. Confirms interactively unless `--yes` (the CI path). Refuses purging the CALLER's OWN running id. All LOCAL — purge reaches only THIS node's records; a remote endpoint's records can't be touched, and its subnet-registry rows decay via the epoch-lease eviction (REQ-HAZARD-REGISTRY-DECAY). Removes: (1) the perch dir TREE recursively — owlery// incl every nested {id}-psyche / {id}-w* / shells child (info.json, ready marker, sessions.log ledger, spool.db, inbox, .idle/.more-done sentinels, auth token); (2) the registry address (registry::unregister_address); (3) the context store — ContextStore::remove_endpoint(id): the a- branch+worktree + the / rows from every p- branch (the same fn `fork --delete-source` already uses); (4) node-local trust rows keyed on the id — access.json + visibility.json. Reuse-heavy: it is `fork --delete-source` generalized (recursive perch remove + unregister + remove_endpoint) + the trust-record cleanup; `endpoint rename` already enumerates the same record set + uses the same offline-only gate. (v0.12.0)" required_stages = ["doc", "impl", "unit", "int"] # doc by doyle (CONTEXT.md `spt endpoint purge`); built v0.12.0 wave 5. impl = cmd_endpoint_purge (offline-gate + --force stop-then-wait-offline; recursive remove_dir_all of the perch tree incl nested; unregister_address; ContextStore::remove_endpoint; access.json + visibility.json row removal) + EndpointCmd::Purge{id, yes, force}; unit = offline-gate refuses an online ep without --force + self-purge guard; int = create endpoint w/ perch + a- context branch → purge → assert EVERY record gone (perch tree, registry row, a- + p-* rows, access/visibility) [[requirements]] id = "REQ-READY-AGENT-RESUME" title = "An offline ReadyAgent shows in `spt endpoint run`'s picker Resume-from-history and resumes correctly — closing the gap that today only LiveAgents do. ROOT: a harness-hosted ready bind (ReadyAgent::start_homed, ready.rs) writes info.json DIRECTLY and never appends the session ledger (unlike the shared establish_perch:250 live path), so a ready agent — though it has a session_id — produces ZERO ledger rows → the picker's offline+local Resume-from-history (which gates on ledger rows) never offers it. FIX (1): ledger the ready bind (ReadyAgent::start_homed → sessions::append Boot, mirroring establish_perch). FIX (2): `spt endpoint run --resume ` honors the adapter MANIFEST's endpoint TYPE — a ReadyAgent manifest (no [session.psyche_init]) resumes as a ready endpoint (poll listener, NO psyche-host); a LiveAgent (with psyche_init) as live. NO new bringup mode + NO picker changes (operator 2026-06-18): `spt endpoint run` is the spt-hosted ENDPOINT bringup for BOTH types, the type IS the adapter-manifest's concern (psyche-host already keys on psyche_init presence) — so (2) likely already holds; VERIFY at code, build only the residual. (v0.12.0)" required_stages = ["doc", "impl", "unit", "int"] # operator add v0.12.0; built. FIX(2)=SUBSUMED (verified-at-code, ZERO code): cmd_endpoint_run is type-agnostic, the no-psyche discriminator is livehost reconcile's start-side STATE gate (info.state != live_agent → skip a ready_agent perch) ahead of the psyche_init gate. doc = CONTEXT.md `spt endpoint run` is the bringup for BOTH types. impl = ReadyAgent::start_homed ledgers a Boot row (sessions::append, mirror establish_perch:250). unit = start_ledgers_a_boot_session_row (ready bind writes one Boot row, survives soft_cleanup/offline). int = ready_bind_ledgers_and_reconcile_hosts_no_psyche (real `spt ready` bind ledgers the carried session + reconcile_once hosts NO psyche even with a live-capable psyche_init adapter resolved — state gate) [[requirements]] id = "REQ-PICKER-ADAPTER-DESCRIPTION" title = "The Create-new adapter-CHOICE screen of `spt endpoint run`'s picker shows a right-hand Description panel (like the Pick-existing endpoint picker's two-pane) surfacing per-adapter detail: install date, last-updated, adapter TYPE / the endpoint types it hosts, and the adapter description — so the user can see WHAT each adapter is before choosing it (today the selector lists bare names). DEFERRED fast-follow to v0.12.0 (operator 2026-06-18). (post-v0.12.0)" required_stages = [] # DEFERRED fast-follow (operator 2026-06-18) — NOT built in v0.12.0; registry-first placeholder [[requirements]] id = "REQ-HAZARD-VIEWER-ISOLATION" title = "A slow / dead / hostile VIEWER must NEVER stall the controller, the PTY child, or the session drain thread. The broker drain fans output to the controller on the authoritative blocking bounded path (advances delivered_through) but to each viewer via a bounded per-viewer channel with a dedicated writer thread; the drain `try_send`s under the log lock and a viewer whose bounded queue OVERFLOWS (can't keep up) is EVICTED (queue dropped, writer thread ends, removed from the viewers map) — the drain thread NEVER touches a viewer socket, so no viewer write can backpressure or block it. A soft viewer cap bounds the thread count. Viewer eviction never perturbs the controller stream, the delivered_through cursor, or the child." required_stages = ["unit", "int"] # activated M12-W2.5 (unit: try_send-overflow eviction decision on the fan-out; int: a wedged viewer is evicted and the controller stream + child run on unaffected) # ───────────────────────────── Install / migration / infra ────────────────── [[requirements]] id = "REQ-INSTALL-1" title = "Two install paths; signed one-line script; OS-service registration" required_stages = ["doc", "impl", "int"] # activated M6-D2: installer/install.sh + install.ps1 — the standalone one-line script half (latest-release lookup → platform asset fetch → sha256 verify → per-OS install root → user PATH → absolute-path print); the harness-bootstrapped half (path a) calls into the same script by design (CONTEXT §Installation). int = oneliner_e2e.rs staged-release rung on both runners. OS-service registration EXCLUDED → docs/DEFERRED.md (daemon auto-start covers dev-stage); "signed" = sha256 at first fetch, full ed25519 is `spt update`'s job (grill decision 3) [[requirements]] id = "REQ-INSTALL-2" title = "Marketplace-repackaging-friendly install" required_stages = ["doc"] # doc activated M6-D2: the relocatable-binary + minimal non-OS-entangled install-logic stance documented (CONTEXT §Installation tag) — the scripts are that stance (single static binary, no OS service, user-scope PATH only); actual marketplace repackaging stays doc-stage per M6-PLAN §Out [[requirements]] id = "REQ-INSTALL-3" title = "Idempotent + interactive-optional first run" required_stages = ["impl", "int"] # activated M6-D2: idempotent re-run is construction — binary replaced atomically, PATH registration added at most once (marker-guarded .profile block / HKCU entry-set check); interactive-optional = the scripts never prompt at all (REQ-INSTALL-5's stronger non-interactive contract subsumes it; first-run identity gen + daemon start were already unattended). int = oneliner_e2e re-run rung (second run green, single PATH block) [[requirements]] id = "REQ-INSTALL-4" title = "Adapter registration lifecycle: spt adapter add (--github, manifest-first, install-is-first-update) + soft-deregister remove + optional manifest uninstall template; node-local registered-adapter set self-update ripples over" required_stages = ["impl", "unit"] # activated M5-D2: spt-runtime::registry (validate-first register; copy-for-file_pull/avenue-less vs pointer-for-delegated under {SPT_HOME}/adapters/; soft-deregister retains record+copy, surfaces the manifest uninstall template; re-add re-activates) + spt adapter add/--github/remove/list CLI (install-is-first-update conducted through plan_adapter_update; bounded clone/conduct) + spt-daemon::adapter_update::ripple_registered (the registered set IS the R-UPD-5 ripple source). int = D3 shell-spawn consumes a registered shell adapter E2E [[requirements]] id = "REQ-MIGRATE-1" title = "Auto-detect and migrate a legacy claude_skill_owl install" required_stages = [] # rule 5: post-v1 — migration tooling needs a v1 to migrate to [[requirements]] id = "REQ-INFRA-1" title = "GitHub issue tracking for v1; tangled.org as migration target" required_stages = [] # rule 5: process req, not code — tracked operationally; activates if/when a docs/infra milestone formalizes it # ───────────────────────────── M6 stage-setting / release ─────────────────── # Added 2026-06-05 (grill `1922fa4`); inactive until M6 execution starts # (rule 5). Activation stages: M6-PLAN.md §Requirement activation. [[requirements]] id = "REQ-INSTALL-5" title = "Non-interactive install path: the canonical one-liner doubles as every adapter's pack-in on-demand install (no second mechanism); sha256-verified fetch; user-PATH registration" required_stages = ["impl", "int"] # activated M6-D2: install.sh/install.ps1 non-interactive by construction (zero prompts; env knobs only — pipeable `curl|sh` / `irm|iex` cannot take args); SPT_INSTALL_ASSET_BASE is the CI/air-gap fetch override; sha256 verified against the release SHA256SUMS, mismatch refuses before placement. int = oneliner_e2e on both runners incl. the tamper-refusal negative rung [[requirements]] id = "REQ-INSTALL-9" title = "Adapter add from a GitHub release archive: `spt adapter add --release [--tag ] [--asset ]` fetches a `.spt` tar asset over HTTPS+GitHub trust, extracts it to the durable adapters/_github home, and registers the root — ships built binaries source-free and versioned (the distribution path for an adapter whose dev repo is a monorepo subdir, where --github root-only clone does not fit)" required_stages = ["doc", "impl", "unit"] # activated 2026-06-15: doc = CONTEXT.md §adapter registration (the --release release-archive acquisition source, distinct from the [update] ripple avenue) + docs-site harness-contract/install-on-demand. impl = cli `adapter add --release` source arm reusing origin_asset_url + http_get_bytes (HTTPS fetch; first-acquisition trusts GitHub like the install one-liner, signing rides the file_pull update avenue) + extract_release_archive (system `tar -xf`, bounded — REQ-HAZARD-SUBPROCESS-TIMEOUT) + register the extracted root (manifest-first, the same registry path --github/local use). unit = release_archive_extracts_to_a_registrable_root (tar round-trip → extract → register) + the --release/--tag parse. int (real published-release fetch E2E) deferred with REQ-INSTALL-4's --github real-repo int (needs a real release target) [[requirements]] id = "REQ-INSTALL-10" title = "Windows at-logon autostart runs the daemon in the background with no persistent window: the scheduled task launches `spt daemon start` (which spawn_detaches a console-less DETACHED_PROCESS daemon and exits) rather than the foreground `spt daemon run` — Task Scheduler's interactive ONLOGON launch of a long-lived console process otherwise leaves a visible console window for the daemon's whole lifetime (v0.7.4)" required_stages = ["impl", "unit"] # activated v0.7.4: impl = install.ps1 at-logon schtasks task action = `daemon start` (background spawn_detached, launcher exits) not `daemon run` (foreground console held for the daemon's lifetime = persistent window). unit = a content-assertion over the real install.ps1 pinning the ONLOGON task action to `daemon start` and forbidding `daemon run` (regression guard; the at-logon registration itself can't be hermetically driven in CI — same constraint as REQ-INSTALL-8's impl-only). Residual ~1-2s launcher console flash at logon while it waits for bind is accepted (a fully windowless GUI/WSH shim was weighed and declined to avoid a WSH-disabled fragility on hardened boxes) [[requirements]] id = "REQ-INSTALL-11" title = "Adapter command templates resolve their program against the adapter's install dir BEFORE PATH: a `.spt`-shipped binary (dropped to adapters/_github// by --release/--github acquisition, or kept in the source_dir under copy-mode where only manifest+strings/ are copied to adapters/) runs without any PATH placement — a bare-name template token (e.g. `claude-spt-digest ...`) is rewritten to /(.exe on Windows) when that file exists, else left bare for the PATH fallback. Makes a `.spt` self-contained (closes the --release bundled-binary gap perri confirmed) (v0.7.4)" required_stages = ["doc", "impl", "unit"] # activate v0.8.0: doc = CONTEXT.md model-level-facts install-dir-resolution bullet + docs/MANIFEST.md [session.] install-dir note. impl = a resolution helper in spt-runtime (beside run_bounded_command) rewriting a command template's first token to the install-dir-absolute path when present (Windows .exe suffix; PATH fallback when absent), applied at the [digest] extractor exec site + the [session.psyche_init] runner exec site (perri's two; install_dir = AdapterRecord.source_dir). unit = helper resolves install-dir-present -> absolute, absent -> bare passthrough, Windows .exe suffix. doc (CONTEXT.md/MANIFEST.md note + the other exec sites as documented later scope) added with the v0.7.4 docs batch. Other templates ([session.self] harness bringup, [history], shell spawn/wake) = documented follow-on scope, not this rev [[requirements]] id = "REQ-INSTALL-12" title = "Durable active-profile pointer for bind-time profile selection (ADR-0021): adapters/active-profiles.toml at the registry ROOT (sibling to the per-adapter / dirs, so adapter add/update/remove — which only rewrite a / subdir — can never clobber it), a flat host_binary → \"adapter[:profile]\" map. Read at bind as the PRIMARY profile selector; unset → the registered_at_ms fallback (REQ-START-5). Written ONLY by `spt adapter use [:profile]` (resolves the adapter's host_binaries → sets each binary→adapter[:profile]); `spt adapter use --clear ` drops. NEVER auto-written by install/update/adapter add (that is precisely what would let an update silently flip the active profile). A stale pointer (uninstalled adapter / deleted profile) self-heals: ignored, fall back, warn once. Pruned on adapter remove. Atomic write (spt_store atomic). (v0.9.0)" required_stages = ["doc", "impl", "unit", "int"] # activated v0.9.0 W3: doc = CONTEXT.md §180 active-profile pointer rule + docs/MANIFEST.md adapter use guidance. impl = the active-profiles.toml pointer store (spt-runtime resolve: load/save at the adapters root, set_active/clear_active/prune_adapter, stale self-heal in resolve_from_basename) + the `spt adapter use [:profile]` / `--clear` CLI verb (cli.rs AdapterCmd::Use) + prune-on-remove (AdapterCmd::Remove). unit = set_clear_prune_rules/stale_pointer_self_heals/pointer_overrides_fallback/pointer_is_sibling (resolve) + adapter_use_sets_and_clears_pointer (cli). int (pointer→fallback resolution) folds into REQ-START-5's final-wave bringup E2E [[requirements]] id = "REQ-INSTALL-13" title = "Adapter add is non-destructive & idempotent-safe (F-018): `spt adapter add --github|--release` REFUSES when the target `_github/` home already backs an ACTIVE registered record — emitting an actionable code (ADAPTER_ADD_ALREADY_REGISTERED) that routes to `spt adapter update ` (refresh in place) or `spt adapter remove ` then re-add (replace) — instead of clobbering the live install (the perri footgun: `add --github` over a `--release` pointer git-cloned a source tree over the extracted built binaries → registered pointer dangled → cryptic `os error 2`). And when it DOES (re)populate the home it STAGES-THEN-SWAPS (clone/extract to a sibling staging dir, swap into place only on success) so a failed fetch/clone never strands the previously-extracted manifest+binaries as a dangling pointer (the os-2 / DeferredManifest class). Mirrors the safe stage-then-swap `adapter update` already uses (REQ-UPD-9, apply_release_crc_swap). (v0.14.1)" required_stages = ["doc", "impl", "unit"] # activated @F-018 build: doc = CONTEXT.md §adapter registration non-destructive-add note. impl = cmd_adapter Add collision-refusal guard (registered_github_home: active record whose source_dir == dest) + stage-then-swap in fetch_release_adapter (reuse apply_release_crc_swap, .add.spt) and the --github clone arm (clone to .staging, swap on success). unit = registered_github_home_detects_active_collision + stage_then_swap_failed_fetch_preserves_install. int (real-repo re-add refusal E2E) DEFERRED with REQ-INSTALL-4/9's own deferred real-repo int — same reason (needs a real release/clone target, env-gated, not run in CI); activate it together when that real-repo E2E lands, so traceable stays green now (matches the INSTALL-4/9 sibling pattern). doyle to rule if int should activate sooner. [[requirements]] id = "REQ-REL-1" title = "spt-releases publish-target repo: README public face, licensing split, Pages docs at the permanent lapse-proof canonical URL (ADR-0014)" required_stages = ["doc", "impl"] # doc activated M6-D1: SaberMage/spt-releases created (private, flips at D7) + README per grill decision 7 + LICENSE-MIT/LICENSE-BINARY split w/ royalty-free adapter clause (spt-releases@2debda8); doc tag on ADR-0014. impl activated M6-D5: the publish pipeline — xtask::site assembles the full Pages payload (HTML + raw .md + llms exports + schema + install scripts + rustdoc), docs-publish.yml force-pushes it to the spt-releases gh-pages branch on release tags / dispatch (needs the RELEASES_TOKEN fine-grained PAT secret) [[requirements]] id = "REQ-REL-2" title = "Release asset set consumable by the self-updater: platform binaries, SHA256SUMS, SignedRelease metadata, manifest schema, mock-adapter zip; tag-triggered cross-repo pipeline" required_stages = ["impl", "int"] # impl activated M6-D6 (release.yml tag-triggered pipeline + xtask release-sign/-publish/-verify); int activated M6-D7: release_verify_e2e.rs — the REAL published v0.1.0's SignedRelease assets fetched from the live release and verified against the EMBEDDED two-key anchor exactly as a deployed node judges a propagated release (env-gated SPT_RELEASE_E2E; run green against the shipped release 2026-06-05, both platforms, key rel-primary-2026). The whole chain proven: tag → CI draft → manual local sign → field binary accepts [[requirements]] id = "REQ-REL-3" title = "Two-key release-signing trust anchor: primary + offline never-used recovery, both pubkeys embedded in the binary's trusted set, manual local signing (ADR-0015)" required_stages = ["impl", "unit"] # activated M6-D6: BUILTIN_RELEASE_KEYS compiled into every binary (release.rs) with release-keys.json as a pure OVERLAY (file adds/shadows keys; file revocations void builtin ids — per-node compromise recovery without a rebuild; empty builtin set stays fail-closed); ceremony tooling = xtask release-keygen (mints via the spt-proto identity primitive, prints to stdout only, seeds never touch disk/repo/CI) + release-sign (manual local signing off SPT_RELEASE_SEED env). unit = merge/overlay/revoke-builtin + recovery-key-signed-release-verifies + shipped-table-well-formed. The ceremony itself (mint 2 keypairs, embed pubkeys, back up seeds) = maintainer action before the v0.1.0 tag # ───────────────────────────── Documentation ──────────────────────────────── [[requirements]] id = "REQ-DOCS-1" title = "Dual-audience docs (human + AI dev-agent), markdown once / two depths" required_stages = ["doc", "impl"] # doc activated M6-D4 (Tier-1 corpus, agent audience first-class on the landing page); impl activated M6-D5: the second depth is machinery — xtask `site` publishes raw .md alongside every HTML page (/x.html ↔ /x.md), generates llms-full.txt from SUMMARY order, places llms.txt/schema/scripts at stable site-root URLs, ships rustdoc; docs-publish.yml is the tag-triggered pipeline to the spt-releases gh-pages branch (rendered snapshot; truth + history stay here, ADR-0014) [[requirements]] id = "REQ-DOCS-2" title = "Sub-10-minute runnable killer quickstart per audience" required_stages = ["doc", "int"] # activated M6-D4: both killer quickstarts authored with REAL captured outputs, zero placeholders (messaging: install→two agents message incl. the QUEUED/backlog beat; adapter: walked via the shipped mock adapter, every command verified against the binary). int = quickstart_e2e.rs runs the published messaging flow step for step in the normal CI sweep — the doc cannot silently rot [[requirements]] id = "REQ-DOCS-3" title = "Diátaxis structure; one canonical way to do X" required_stages = ["doc"] # activated M6-D4: the corpus IS the structure — per-vertical overview/tutorial/how-to/reference separation (SUMMARY.md IA per DOCS-STRATEGY: getting-started → quickstarts → mental model → verticals in build-order), the contract stated on the landing page (doc tag, index.md §How these docs are organized); remaining verticals ship overview+reference stubs per the v0.1 Tier-1 scope [[requirements]] id = "REQ-DOCS-4" title = "Agent-consumable layer (llms.txt, manifest schema, MCP, CLI help)" required_stages = ["doc", "impl", "unit"] # activated M6-D3 (schema leg): manifest_json_schema() generated from the SAME schemars derives that parse adapter manifests (drift impossible by construction), stable $id at the canonical Pages URL, checked-in crates/spt-runtime/manifest.schema.json = the site/release asset. llms.txt + CLI-ref legs land D5; MCP doc server + `--help --json` EXCLUDED → docs/DEFERRED.md (Tier 2, DOCS-STRATEGY §v0.1) [[requirements]] id = "REQ-DOCS-5" title = "Anti-drift: rustdoc/schema/exports/CLI-help generated + CI-checked" required_stages = ["impl", "int"] # activated M6-D3 (schema gate): checked_in_schema_is_current — the checked-in asset must equal what the live derives generate, run in the normal CI sweep every push (regenerate via SPT_BLESS=1). CLI-ref + llms-export drift gates join at D5 through the same pattern # ═════════════════════════════ KNOWN-HAZARDS invariants ══════════════════════ # Each maps to a docs/KNOWN-HAZARDS.md entry. On activation these require # `unit` (and `int` where cross-process/cross-node) — the conformance checklist # becomes a CI gate (PRD success criterion #6). [[requirements]] id = "REQ-HAZARD-GRACE-BEFORE-SIGNOFF" title = "Grace-period wait completes before composing INIT_SIGNOFF (1.1)" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-HAZARD-INFO-JSON-TORN-READ" title = "State-file reads tolerate concurrent writes (1.2)" required_stages = ["impl", "unit"] # activated M0: spt-store info.json atomic write + tolerant read (T12) [[requirements]] id = "REQ-HAZARD-STALE-INDEX-LOCK" title = "Sweep stale lockfiles on daemon boot (1.3)" required_stages = ["impl", "unit"] # activated M5-D3e: branchstore::sweep_stale_index_locks (0-byte + age-gated, seed git-dir + worktree git-dirs) called from Daemon::run boot housekeeping [[requirements]] id = "REQ-HAZARD-DEFERRED-DRAIN" title = "Deferred spool rows excluded from the event-stream drain (1.4)" required_stages = ["impl", "unit"] # activated M0: spt-store spool drain_non_deferred (T9) [[requirements]] id = "REQ-HAZARD-WORKER-PATH" title = "Single source of truth for Worker/Psyche perch location (1.5)" required_stages = ["impl", "unit"] # activated M0: spt-store perch resolver (T10) [[requirements]] id = "REQ-HAZARD-PARENT-PID-PREFER" title = "Prefer stable parent PID / broker handle over ephemeral PID (2.1)" required_stages = [] # rule 5: superseded in spirit by daemon-authoritative liveness (REQ-HAZARD-DAEMON-HOSTED-LIVENESS, int-covered M3b); binds if a per-pid path ever returns (none in the daemon model) [[requirements]] id = "REQ-HAZARD-STDIN-SESSION-ID" title = "Stdin session_id precedence over env (2.2)" required_stages = [] # rule 5: binds at the adapter api surface for harnesses that pass session ids on stdin — no such path exists in spt-core; re-assessed M5-D3e: the whole shell api surface passes identity as argv (--link token, positional shell-id), no stdin id seam grew through D3 [[requirements]] id = "REQ-HAZARD-HANDOFF-ARGV-COMPAT" title = "Broker/brain IPC + handoff argv version-tolerant (2.3)" required_stages = ["impl", "unit"] # The restoration D7-2 CI gate (crates/spt/tests/n1_pairing.rs, ci.yml n1-gate) # exercises this int-grade — a real new-brain × old-broker pairing (current brain # serving the socket verbs against the pinned D1 broker 0c95435). It is left at # [impl,unit] on purpose (D7's one-activation discipline keeps the single int # activation on REQ-HAZARD-BROKER-PROCESS-ISOLATION, doyle D7 vet call 4); # formalizing an int stage here is its own later activation commit. [[requirements]] id = "REQ-HAZARD-GEN-START-NOW" title = "gen_start = now() on cold-start and handoff (2.4)" required_stages = ["impl", "int"] [[requirements]] id = "REQ-HAZARD-EPHEMERAL-CLEANUP" title = "Ephemeral perch cleanup on every ring exit path (3.1)" required_stages = ["impl", "unit"] # activated M1 T4: ring cleans ephemeral perch on success/timeout/queued/error paths [[requirements]] id = "REQ-HAZARD-STALE-SIGNOFF-SENTINEL" title = "Stale signoff sentinel does not kill a fresh start (3.2)" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-HAZARD-ECHO-BEFORE-SIGNOFF" title = "Echo-commune fires before INIT_SIGNOFF on orphan teardown (3.3)" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-HAZARD-ENVELOPE-DECODE-ORDER" title = "Envelope decode order, ampersand decoded last (4.1)" required_stages = ["impl", "unit"] # activated M0: spt-proto envelope codec [[requirements]] id = "REQ-HAZARD-ENVELOPE-CR-LINESAFE" title = "Envelope CR-linesafety (4.1): the line-framed EVENT codec must neutralize raw carriage returns — `event_body_escape` folds CRLF/lone-CR to the codec's representable linebreak (`\\n`→`
`) BEFORE framing, so a body carrying `\\r` (Windows `echo`/CRLF text crossing nodes) cannot survive into the single-line envelope and trigger a receiver terminal CR→col0 overwrite that corrupts the frame. Robustness on unrepresentable input, NOT a wire-format change (decoder untouched, amp-last invariant held). Belt-and-suspenders: `spt send`/`ring` also trim stdin (parity with `notify`)." required_stages = ["impl", "unit"] # activated v0.3.1: spt-proto event_body_escape CR-normalize + spt cmd_send/cmd_ring stdin trim. unit = a body with \r/\r\n escapes with no raw CR and folds to
(round-trips to \n). Field origin: cross-node spt send from Windows rendered a corrupted EVENT (todlando diagnosis 2026-06-08). [[requirements]] id = "REQ-HAZARD-ENVELOPE-PARSER-SAFE" title = "Two-slice envelope parser is panic-free and tolerant (4.2)" required_stages = ["impl", "unit"] # activated M0: spt-proto two-slice parser [[requirements]] id = "REQ-HAZARD-EVENTPART-REASSEMBLY" title = "EVENT-PART split/reassembly is byte-exact; orphan parts dropped silently" required_stages = ["impl", "unit"] # activated M0: spt-proto EVENT-PART chunker (T3) [[requirements]] id = "REQ-HAZARD-ID-CHARSET" title = "Addressable-id charset reserves :/@ delimiters; validated at every creation seam (4.6)" required_stages = ["impl", "unit"] # activated 2026-06-02: spt-proto::id + 4 creation seams (forward-compat for ADR-0006 qualified addressing) [[requirements]] id = "REQ-HAZARD-REGISTRY-STALE-CLEAN" title = "Stale registry entries degrade to fallback, never hard-fail (4.3)" required_stages = ["impl", "unit"] # activated M0: spt-store registry stale-clean (T11) [[requirements]] id = "REQ-HAZARD-REGISTRY-CONCURRENT" title = "Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)" required_stages = ["impl", "unit"] # activated 2026-06-02: busy_timeout-before-WAL ordering (M1 store) [[requirements]] id = "REQ-HAZARD-REGISTRY-DIR-CREATE" title = "SQLite store opens create their parent dir themselves — a fresh-home registry op must not SQLITE_CANTOPEN (4.9)" required_stages = ["doc", "impl", "unit"] # registered+activated 2026-06-03 (CI-bitten twice on hfenduleam): spt_store::registry::open_registry create_dir_all(owlery) before Connection::open; doc = KNOWN-HAZARDS 4.9; unit = register_on_nonexistent_owlery_creates_dir_and_succeeds [[requirements]] id = "REQ-HAZARD-REGISTRY-EPOCH-LEASE" title = "Registry merge ordered by per-node monotonic epoch, never wall-clock — a stale Active can't clobber a newer Offline (4.8, red-team #8)" required_stages = ["impl", "unit"] # activated M4-D3b: spt-store::epoch::EpochSource (persisted strictly-increasing per-node counter, never wall-clock) + spt-net::net::registry::merge_instance version-vector lease keyed on (endpoint,node): strictly-greater-epoch wins, equal/lower dropped Stale. unit = stale_active_cannot_clobber_newer_offline + equal_epoch_replay_noop + epoch monotonic/persist/corrupt-safe. Cross-node replication of the merge = D4; chaos/two-host = D9. [[requirements]] id = "REQ-HAZARD-DEFERRED-SURVIVE-DRAIN" title = "Deferred rows survive poll drain (4.4)" required_stages = ["impl", "unit"] # activated M0: spt-store spool deferred rows (T9) [[requirements]] id = "REQ-HAZARD-INBOX-NO-DOUBLE" title = "No double-delivery via legacy inbox (4.5)" required_stages = ["impl", "unit"] # activated M1 T2: delivery is TCP xor spool, never both [[requirements]] id = "REQ-HAZARD-WINDOWS-PID-RECYCLE" title = "Windows PID-recycling false positives guarded (5.1)" required_stages = ["impl", "unit"] # activated M1 T2: connect-must-succeed guard + dead-pid row clean on delivery [[requirements]] id = "REQ-HAZARD-EBUSY-RENAME" title = "tmp-write + atomic-rename + retry on Windows EBUSY (5.2)" required_stages = ["impl", "unit"] # activated M0: spt-store atomic write (T8) [[requirements]] id = "REQ-HAZARD-SUBPROCESS-TIMEOUT" title = "Every harness/git subprocess has a timeout (5.3)" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-HAZARD-UNC-PATH-STRIP" 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-WIN-PTY-PROGRAM-RESOLVE" title = "Native-PTY spawn must resolve a bare program name with PATHEXT precedence and run a non-PE target through its interpreter: portable-pty's own `which` takes the FIRST PATH match — an extensionless shebang shim (e.g. a node CLI `ccs` shipped beside `ccs.cmd`) — and CreateProcessW then rejects the non-PE file with os error 193 ('not a valid Win32 application'); spt-term resolves the program itself (PATHEXT order prefers .EXE over .CMD; .cmd/.bat → cmd.exe /d /c, .ps1 → powershell -NoProfile -File) so a bare harness/shell [session.self] command actually launches on Windows. Unix is a passthrough (execve honours the shebang)." required_stages = ["doc", "impl", "unit", "int"] # activated 2026-06-16: live failure on `spt endpoint run` claude-spt:ccs (CreateProcessW C:\nvm4w\nodejs\ccs → 193). doc = KNOWN-HAZARDS 5.12; impl = spt-term winprog::resolve_for_pty wired into PtySession::spawn_program_in (the single CommandBuilder chokepoint, covers broker harness + shell spawns); unit = winprog::resolve_in PATHEXT-precedence kernel (.cmd-over-shim, .exe-direct, explicit-ext, passthrough, path-order) [winprog.rs]; int = a .cmd spawns under a real PTY via the cmd.exe wrap (windows-gated, the 193 regression) [tests/winspawn.rs] [[requirements]] id = "REQ-HAZARD-CHILD-CONSOLE-FLASH" title = "Console-subsystem children of the console-less daemon spawn with CREATE_NO_WINDOW, or each spawn flashes a visible blank window on the user's desktop (5.8)" required_stages = ["impl", "unit"] # activated post-M7 field bug 2026-06-06: sync-pump git spawns flashed 2 windows/min on a Windows desktop; window-absence itself is untestable from a consoled test runner (child inherits the console) — the unit stage covers "flag does not break the spawn" (the error-87 regression class) [[requirements]] id = "REQ-HAZARD-INSTANT-UNDERFLOW" title = "Scheduling never subtracts a Duration from Instant::now() (underflow-panics on a host booted more recently than the offset); 'due now / never run' is Option=None gated on forward duration_since only (5.9)" required_stages = ["impl", "unit"] # activated 2026-06-07 CI failure: peer pump primed cadence legs with `Instant::now() - 86_400s`, panicking the pump thread on the sub-24h-uptime Windows runner (run 27082417706) so the subnet never converged. fix = peerloop::due(Option) + None-seeded legs; unit asserts first-tick-due with zero instant subtraction (host-uptime-independent, the env-conditional E2E can't be the guard) [[requirements]] id = "REQ-HAZARD-PUMP-IPC-DEADLINE" title = "The single-threaded peer pump's brain-IPC reads are deadline-bounded (PUMP_PEER_IO_TIMEOUT, total-wait per call); a TimedOut read POISONS the client and escalates to a SUPERVISED RESTART, never a per-peer retry — a black-holed peer must never wedge the whole pump" required_stages = ["doc", "impl", "unit"] # activated 2026-06-11: deployed v0.4.0 pump wedged 2.2h on hfenduleam — net_open_stream's unbounded `loop { read_event }` blocked on a peer conn the broker never replied for; the single-threaded pump froze and supervise_pump cannot rescue a BLOCKED thread (it only catches panic/error/return). Surfaced twice (2026-06-07 + this 2.2h wedge); the stall-warning was the band-aid, not the fix. fix = Brain pump-mode carrier SPLIT at construction (Brain::cold_start_pump → BrainConn::Split: a `pump-ipc-reader` thread does blocking read_frame on the RecvHalf → channel; main thread writes the SendHalf and reads with Receiver::recv_timeout for a per-call total-wait deadline; deadline re-armed on stream progress for the pull legs). The reader-thread+channel mechanism (NOT a non-blocking socket + poll) is mandatory because interprocess 2.4.2 on Windows named pipes has no portable read timeout and its set_nonblocking corrupts mid-stream (mesh E2E proven). A TimedOut bubbles out of run_peer_pump → supervised restart (fresh brain client + conn cache + WorkerLasts re-prime, the V4 stagger, idempotent). The broker-side bound (broker must never make a brain wait unbounded on a QUIC op) is deferred to the next broker-update batch (DEFERRED.md). doc = KNOWN-HAZARDS entry; unit = (a) a never-replying broker over a real socket → Brain::cold_start_pump → net_open_stream returns TimedOut (never blocks) [tests/pumpdeadline.rs], (b) the recovery tier-split (TimedOut → bubble/Err = supervised restart; ordinary Err → per-peer abort only) # --- Added 2026-06-16 (v0.8.3): the pump-IPC-deadline B-half — the broker-side complement of REQ-HAZARD-PUMP-IPC-DEADLINE (DEFERRED.md:42 resolved); rule 3 register-before-satisfy, rule 5 activate per wave --- [[requirements]] id = "REQ-HAZARD-BROKER-QUIC-DEADLINE" title = "The broker bounds every brain-waiting QUIC op (dial / open_stream / send_stream) so a black-holed or dead peer fails PROMPTLY with an ORDINARY error the broker REPLIES, never an unbounded await. The bound (< the brain's 30s PUMP_PEER_IO_TIMEOUT so the BROKER fires first) surfaces to the pump as a normal broker error reply → peer_outcome's non-TimedOut arm → drop conn + redial next tick, the round CONTINUES and the heartbeat keeps advancing — it must NEVER manifest as the brain's own read-deadline (the A-half poison → supervised-restart path REQ-HAZARD-PUMP-IPC-DEADLINE guards). Exactly-once is preserved: a timed-out journaled op fails INSIDE its apply_once closure so no phantom conn_id/stream_id is recorded and a fresh tick re-dials cleanly. The happy path is unchanged (a live peer completes with zero added latency; the bound only bites a non-responsive peer). This is the ROOT-cause cure for the 2.2h hfenduleam pump wedge — a dead roster peer whose QUIC path the broker awaited unbounded — recurring on hfenduleam 2026-06-16." required_stages = ["doc", "impl", "unit", "int"] # registry-first mint (rule 3); activated per wave in v0.8.3. doc = KNOWN-HAZARDS 7.8 (+ the 7.6 B-half-fixed reword + DEFERRED.md:42 RESOLVED). impl = NetHost::bounded_block_on wrapping dial/open_stream/send_stream's QUIC await sites under a quic_op_timeout (default 10s = BROKER_QUIC_OP_TIMEOUT_MS, test-overridable via set_quic_op_timeout); unit = the bound wrapper returns a prompt non-TimedOut error on a never-completing op + a fast op is untouched [nethost.rs]; int = a black-holing peer (membership-mismatch hang) → the broker REPLIES an ordinary error within the bound (not a wedge) + exactly-once-on-timeout (journal not applied, conn table empty) [tests/netbroker.rs], and the pump round survives a dead peer — heartbeat monotonic-advances + run_peer_pump exits Ok (no PEER_PUMP_RESTART) [tests/pump.rs]. doc activates with the KNOWN-HAZARDS 7.8 + 7.6 B-half-fixed reword + DEFERRED.md:42-resolved commit # --- Added 2026-06-17 (v0.9.1): the daemon-state-wire/broker-restart skew (perri PREP-4 FINDING 1); rule 3 register-before-satisfy --- [[requirements]] id = "REQ-HAZARD-BROKER-SEED-WIRE-SKEW" title = "A daemon-state wire-format change (e.g. the v0.9.0 adapter-agnostic Seed) does NOT take effect until a DELIBERATE full broker restart: the broker serves the seed-control channel and is RESIDENT across a brain-only self-update (ADR-0004 no-terminate-during-update forbids auto-killing it), so a NEW-version CLI talking to a still-resident OLD broker fails the seed handshake — the old broker cannot deserialize the new Seed (its formerly-required `adapter` field is gone) and drops the conn without an ack, which surfaces to the CLI as a raw UnexpectedEof 'failed to fill whole buffer'. spt-core must (a) surface an ACTIONABLE diagnostic on that seed-ack EOF (name the stale-broker cause + the `spt daemon stop` fix — the broker restarts on the next api call), never the cryptic io error; and (b) document the operational rule (a deliberate broker restart is required on any daemon-state wire change — NOT automatic) + the FORWARD discipline (daemon-state/Seed schema changes stay additive + serde-default so a resident OLD broker tolerates a NEW CLI across a brain-only update; note this would NOT have rescued 0.9.0 itself, since the old broker's `adapter` was a required field). perri PREP-4 FINDING 1 (v0.9.0 CLI vs stale 0.8.x broker)." required_stages = ["doc", "impl", "unit"] # activated v0.9.1: doc = KNOWN-HAZARDS entry. impl = cmd_seed appends the actionable stale-broker hint when put_seed's seed-ack read returns UnexpectedEof (scoped to that path, not unrelated EOFs). unit = a seed-control server that reads the PUT frame then drops the conn without acking (the old-broker deser-fail close) → cmd_seed surfaces the `spt daemon stop` hint, not a bare buffer error [[requirements]] id = "REQ-HAZARD-SUDO-SECURE-PATH" title = "Elevation guidance on Unix names the binary's ABSOLUTE path under sudo (a user-local install ~/.local/bin · ~/.cargo/bin is not on sudo's secure_path, so bare `sudo spt` dies 'command not found'); gated commands auto-elevate on an interactive TTY, else print the runnable hint (5.10)" required_stages = ["impl", "unit"] # activated 2026-06-07 field report (KITSUBITO): `spt subnet create` refused unelevated, user ran `sudo spt …` → `sudo: spt: command not found` (spt in ~/.local/bin, off secure_path). fix = elevation::rerun_command emits `sudo ` (shell-quoted) + try_auto_elevate re-execs under sudo on interactive Unix; main.rs drops the confusing DEELEVATED line. unit = absolute-path-not-bare-name + shell-quoting + windows-none + should_auto_elevate truth table (the sudo exec itself needs a real TTY → manual/kitsubito verified) [[requirements]] id = "REQ-HAZARD-SELF-ELEVATE" title = "Self-elevation (REQ-ELEVATE-1) re-runs the EXACT original invocation with the binary's ABSOLUTE exe path — never widening privilege scope, never adding/altering args, never via a PATH-resolved bare name, never via a shell-interpolated command string (argv-array only, no `sh -c`); the elevated child drops state back to the user (composes with the 5.7 de-elevation) and NEVER re-elevates (loop-safe: decide_elevation_path returns AlreadyElevated whenever the process is already Elevated, on every OS). The user's UAC/polkit/sudo prompt is the only consent gate — we never bypass it; the print-hint floor prints the absolute-path command too. The unprivileged parent never depends on (pipes/captures) the privileged child's stdout." required_stages = ["unit"] # activated M12-W4 (privilege-escalation feature → mandatory hazard REQ, doyle M12-W4-RULING.md Q6). unit conformance: (1) every launcher's argv uses the absolute exe path + verbatim args (Win runas / pkexec / terminal-emulator / sudo); (2) decide_elevation_path → AlreadyElevated when elevated, every os (loop-safety); (3) the constructed launch argv is an array, never a shell string (no `sh -c`, no interpolation — assert the argv vector). REQ-HAZARD-SUDO-SECURE-PATH covers the Unix abs-path-under-sudo facet; this covers the cross-platform verbatim / no-widen / no-shell-injection / loop-safe facets. The actual elevated launch is manual-verify (real UAC/polkit/sudo). [[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-09 from grill-with-docs (ADR-0018: broker/brain in-process-collapse regression); rule 3: register before satisfying, rule 5: inactive until the broker/brain split restoration milestone (next milestone) --- [[requirements]] id = "REQ-HAZARD-BROKER-PROCESS-ISOLATION" title = "Broker and brain are separate processes: the broker runs as its own long-lived per-machine process that survives every brain restart, so a routine (brain-only) self-update restarts the brain onto the swapped binary while every hosted endpoint (PTY child, live QUIC conn, listening socket) stays untouched at the PROCESS level. The in-process-thread broker (daemon.rs:165-170) is a regression that silently unrealizes REQ-UPD-3 — apply degrades to an in-process Brain::handoff no-op and new code does not run until an unrelated restart (KNOWN-HAZARDS 6.7). Evidence must prove process-level survival (SPIKE-01/03 productionized as int: PTY child + live QUIC survive a brain-PROCESS restart onto a swapped binary), re-pointing the regression-masked in-process int tags currently on REQ-DAEMON-2 / REQ-UPD-3 (ADR-0018)." required_stages = ["doc", "impl", "unit", "int"] # activated restoration D1 (ADR-0018 Q2/Q3): doc = KNOWN-HAZARDS 6.7 + ADR-0018 + design doc; impl = brainproc.rs (brain child entry + broker-side process supervisor) + daemon.rs spawn-brain-supervisor + the hidden `daemon brain` CLI entry; unit = brainproc supervisor respawn/backoff table + the real-binary brain_split.rs process-level smoke (broker survives a brain kill + respawns it). int ACTIVATED restoration D7 = the productionized SPIKE-01/03 process-level survival E2E (crates/spt/tests/brain_survive.rs: a PTY child + a live QUIC conn + held-AND-functional output survive a brain-PROCESS restart onto a swapped binary, exe_hash-proven new bytes — D7-1) + the new-brain x old-broker N-1 verb-surface gate (crates/spt/tests/n1_pairing.rs + ci.yml n1-gate, pinned old broker 0c95435 — D7-2). The D7-1 E2E also re-points the regression-masked in-process int tags off REQ-DAEMON-2 / REQ-UPD-3 (below) [[requirements]] id = "REQ-HAZARD-ROLLBACK-STATE-COMPAT" title = "A brain must not irreversibly migrate durable state before update ready-promotion: the readiness-gated auto-rollback (ADR-0018 Q7) spawns the N-1 binary against durable state the new brain may have written, so every pre-ready write must stay N-1-readable (schema migrations gated behind ready-promotion, or written N-1-tolerant/additive). Else the first in-place schema migration silently bricks rollback (KNOWN-HAZARDS 6.8). Free now — a 2026-06-09 audit confirmed zero state-migration code exists; unmintable retroactively once a migration ships." required_stages = ["doc", "impl", "unit"] # doc activated restoration D1 (KNOWN-HAZARDS 6.8 + ADR-0018 + design doc V1). impl/unit activated restoration D6-3 (the one D6 toml activation): impl = rollback_compat.rs (the pre-ready durable-file registry PRE_READY_DURABLE_FILES — the enumerated guard surface) + the two-phase AppliedRecord (relcache.rs, additive durable state); unit = the rollback_compat tripwire (every current pre-ready file shape is additive / N-1-readable; a non-additive pre-ready change trips the assert). The two-phase record + the generation-stamped brain.ready are additive → N-1-safe by construction (KH 6.8 D6 guard note) # --- Added 2026-06-11 (v0.4.2): the v0.4.1 fleet-roll Linux brain-respawn blocker (ADR-0018 Q3 amendment); rule 3 register-before-satisfy, rule 5 activate per task --- [[requirements]] id = "REQ-HAZARD-BRAIN-RESPAWN-PATH" title = "The broker respawns the brain onto the APPLIED bytes, not the renamed old binary: the candidate-binary default is the canonical exe path captured ONCE at broker start, never a per-spawn std::env::current_exe() — on Linux current_exe (readlink /proc/self/exe) is inode-tracking and follows the `apply` rename (spt -> spt.old-N), so a resident broker would respawn the brain onto OLD bytes while recording `applied` (Windows GetModuleFileName is path-at-start, so Windows was green; ADR-0018 Q3 silently assumed path-string semantics). Backstop: promotion gates on bytes — a trial promotes only if brain.ready exe_hash == the staged artifact hash for this platform, else auto-rollback + loud notif (readiness != new-bytes was the false-success that recorded applied:8 over a v0.4.0 brain on kitsubito, 2026-06-11). KNOWN-HAZARDS 6.11." required_stages = ["doc", "impl", "unit", "int"] # activated v0.4.2 (2026-06-11): minted from the v0.4.1 fleet-roll blocker. doc = KNOWN-HAZARDS 6.11 + ADR-0018 Q3 amendment; int = brain_respawn_rename.rs (real broker process, in-place rename P->P.old-N + new bytes at P, respawn must run B not A — fails Linux pre-fix). Stages: impl = brainproc.rs canonical-exe capture at spawn_brain_supervisor + promotion bytes-gate in supervise_brain Promoted arm (TrialEnv ready_exe_hash/staged_artifact_hash); unit = spawn-path selection (captured-default-for-None vs per-spawn) + promotion-gate truth table (match->promote, mismatch->rollback+notif, either-absent->loud PROMOTE_BYTES_UNVERIFIED degrade-to-readiness); int = the brain_survive rename-under-supervisor sibling (real in-place rename P->P.old-N + new bytes at P through the production None selection, assert respawned exe_hash == new bytes; fails Linux pre-fix) # --- 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) # --- Added 2026-06-06 from the M7 acceptance run's two publish-blocking field bugs (DEFERRED.md "NEXT SESSION" rows; rule 3: register before satisfying) --- [[requirements]] id = "REQ-HAZARD-ELEVATED-DAEMON-SPAWN" title = "The daemon always runs unelevated in the invoking user's universe, regardless of which command spawns it: an elevated spawner de-elevates (Windows: UAC linked token via CreateProcessWithTokenW; Linux: drop to SUDO_UID/SUDO_GID + the invoker's HOME) — an elevated daemon's pipes deny unelevated clients (every later spt reads not-running→spawn→bind Access-denied) and a sudo'd daemon roots the user's state universe (5.7)" required_stages = ["doc", "impl", "unit"] # activated 2026-06-06 (M7 closeout fix 1): spt-daemon::deelevate (the OS-split de-elevation seam: windows linked-token respawn, unix sudo-invoker drop) consumed by daemon::spawn_detached + the Daemon::run entry guard. unit = the pure decision seams (sudo-invoker derivation from euid/env; the windows spawn-plan decision); the live elevated probe is environment-dependent (manual / acceptance rig) [[requirements]] id = "REQ-HAZARD-REGISTRY-GHOST-ROWS" title = "A dead node identity's registry rows must decay: only the per-(endpoint,node) epoch lease supersedes rows, so without eviction a vanished node's rows are immortal and poison bare-id resolution with phantom AcrossNodes ambiguity — evict rows whose author node has not been heard (admitted inbound feed) within the eviction window; own rows never decay; a revived node re-inserts from its durable epoch within one pump cadence (4.10)" required_stages = ["doc", "impl", "unit"] # activated 2026-06-06 (M7 closeout fix 2): SubnetRegistry::evict_nodes (model) + RegistryHost::evict_silent_peers (heard-map TTL, snapshots rewritten) driven from the registry pump tick; registry_evict_after_ms knob (default 300s = 10 default cadences). unit = model eviction + the field-bug replay (ghost row makes bare resolve Ambiguous; eviction restores clean resolution) + self-rows-never-decay + recently-heard survives # --- Added 2026-06-07 at M8 ratification (M8-PLAN.md decisions 1-24; rule 3: # register before satisfying, rule 5: inactive until each deliverable starts. # Planned activations: REQ-CLI-* at D1, REQ-SUBNET-5..8 at D2, REQ-INSTALL-6..8 # at D3, REQ-CONV-*/REQ-PAIR-8/REQ-DAEMON-5 at D4 (D4 also adds REQ-UPD-6 int # per decision 19), REQ-HAZARD-EPOCH-RESET stays inactive by decision 24.) --- [[requirements]] id = "REQ-CLI-1" title = "spt endpoint noun namespace: absorbs fork/suspend/wake/shutdown/rename/stop/digest + access (ported 1:1: allow|revoke|open|list, decision 21) + description (ex-resources blurb; bare=show, set=author); merged endpoint list [--local|--subnet ] grouped by subnet with SELF pinned, --detail adding the ex-resources yellow-pages blurb projection; bare spt endpoint = the list (M8 decisions 1-2, 25)" required_stages = ["impl", "unit"] # activated M8-D1: cli.rs EndpointCmd noun (verbs ride pre-M8 arg shapes verbatim — location-only break), cmd_endpoint_list (SELF pin + subnet grouping over resource_projection's visibility closure, --local = the old roster, --detail = the blurb projection), cmd_description set/show. unit = endpoint_subcommands_parse + access 1:1 + digest defaults + moved-verbs-gone [[requirements]] id = "REQ-CLI-2" title = "spt daemon noun: run|stop|status (hidden daemon verb becomes daemon run; agent-endpoint shutdown keeps its name under endpoint); daemon status renders the pump heartbeat (last-tick recency) so a half-dead daemon is never rendered implied-healthy (M8 decisions 5, 23)" required_stages = ["impl", "unit"] # activated M8-D1: DaemonCmd run|stop|status (bare = the node status view, decision 25b); graceful stop = seedmap KIND_STOP (ack, latch, throwaway-dial wakes the accept loop — serve returns, no kill) + daemon::request_stop; status renders daemon state + pump heartbeat (read_pump_heartbeat; the D4 pump writes it — absent renders 'no heartbeat recorded', never implied-healthy) + subnets + local endpoints; every daemon spawn site passes `daemon run`. unit = daemon_subcommands_parse + stop_op_acks_then_serve_returns [[requirements]] id = "REQ-CLI-3" title = "Agent hot path stays flat across the M8 reorg: send/ring/ready/whoami/how-to unchanged; notify moves to subnet notify while notif stays top-level; breaking renames land clean with no deprecation shims (zero external CLI consumers pre-spt-claude-code) (M8 decisions 3-4, 9)" required_stages = ["impl", "unit"] # activated M8-D1: the hot path parses untouched at top level; SubnetCmd::Notify [BODY] [--target] with home-subnet default (decision 25a — resolve_notify_subnet pure decision table + issuer_home_subnet shell→owner ride); every moved verb's old top-level shape is an unknown subcommand. unit = hot_path_flat_and_moved_verbs_gone + notify_surface_parses + notify_subnet_resolution_home_default [[requirements]] id = "REQ-CLI-4" title = "User-facing CLI output is human-readable: DIRECT-USER commands (e.g. adapter update/list/use) render friendly prose instead of raw CODE:RESULT markers — \"claude-spt is up to date (0.2.0).\" not \"ADAPTER_UPDATE_UPTODATE:claude-spt: installed 0.2.0, latest 0.2.0\". Strictly bounded to the direct-user surface: the adapter-PARSED bringup tokens (SEEDED/BOUND/READY/NO_SEED on seed/listen, which adapters grep) stay machine-parseable — humanization is additive (a human line beside the marker, or a --porcelain/--quiet split), never a silent rename of a dual-contract marker. The user-facing bringup composition belongs to the adapter (perri); this REQ owns only the direct-user CLI surface. (v0.9.0)" required_stages = [] # registry-first (v0.9.0-harness-resolution base commit); activate per-wave: doc+impl+unit at W4 (humanize adapter update/list/use direct-user output; bringup parse-tokens untouched) [[requirements]] id = "REQ-SUBNET-5" title = "Per-subnet serve-state: spt subnet detach [--save] / attach [--save] — daemon keeps running, stops/starts advertising + connecting for that subnet (peer pump + responder selective); --save persists the startup default in daemon config; the all-attached banner gains per-subnet states (M8 decision 6, --save renamed from --auto per decision 25 session)" required_stages = ["impl", "unit", "int"] # activated M8-D2: spt_store::attachment::AttachmentStore (identity/attachment.json live state, all-attached default, corrupt-degrades-attached) + daemon boot reset from the new daemon.json detached_subnets knob (--save writes it; unsaved flips don't survive a restart by design); gating at all three serve points re-read per round — pump per-subnet skip (peerloop), pairing responder filter_serving view, meet-rotation name filter; CLI cmd_subnet_attachment (ungated — no trust mutation) + per-subnet banner states (detached / attached / no connection). unit = store flip/roundtrip/corrupt + boot-reset/filter + config knob + arg shapes. int ADDED post-M8-accept (2026-06-08): the subnet-scoped liveness probe closes the criterion-5 detach-liveness gap — `status --nodes` showed a DETACHED-but-reachable peer as online because the probe was a raw subnet-blind dial (one ALPN serves all subnets). New ServeProbeRecord (spt-net/serveprobe) + daemon serveprobe handler/requester answer "serving subnet X?" from the peer's own AttachmentStore (the same is_detached signal the pump/responder gate on); the dispatcher classifies it by the `serve_probe` field; wansend::probe_node_serving dials (proves reachable) THEN asks. int = dispatcher_serves_a_subnet_serve_probe (loopback E2E: attached→serving, non-member→false, detach flips to false while reachable, re-attach restores) [[requirements]] id = "REQ-SUBNET-6" title = "Trust lifecycle verbs, elevation-gated: spt subnet leave (membership exit) and spt subnet prune (removes a dead identity's trust + registry rows, killing its dead dials; trust mutation = security surface, REQ-PAIR-6 gate machinery) (M8 decisions 6-7)" required_stages = ["impl", "unit"] # activated M8-D2: cmd_subnet_leave (gate-first; drops seed + the subnet's trust rows + serve-state row + registry snapshot; remaining members' seed rotation honestly out of reach from here) + cmd_subnet_prune (gate-first; prune_candidates resolves full hex / unambiguous prefix / label, refuses ambiguous + own identity; drops the identity's trust rows everywhere + registry snapshot rows). unit = trust_mutation_gate truth table + prune_candidates decision table + arg shapes. BIGNET's 09ef831e rows = the acceptance fixture (criterion 5) [[requirements]] id = "REQ-SUBNET-7" title = "Per-machine re-pair trust overwrite: registry rows carry a hashed stable machine identifier (OS machine id /etc/machine-id|MachineGuid, domain-separated SHA-256 before gossip, spt-minted persisted UUID fallback; additive serde-default field — old rows parse clean); a COMPLETED pairing ceremony presenting the same node label AND machine id as an existing trusted row evicts the superseded identity's trust + registry rows on the seed-holder and replicates the eviction; a gossiped claim alone never evicts trust (M8 decisions 13, 22)" required_stages = ["impl", "unit"] # activated M8-D2: spt_daemon::machineid (OS id → domain-separated sha256, minted-fallback chain; raw never leaves the machine) + Instance.machine_id additive field stamped by advertise_local + the Hello NodeIntro wire extension (trailing additive fields; old responder ignores, half-intro refuses) + ResponderOutcome carrying the ceremony facts + pairhost post-confirm eviction via registryhost::{superseded_identities, repair_evict_superseded} (ceremony-subnet-scoped trust drop + disk snapshot rewrite + repair-evict marker) + RegistryHost::consume_repair_evictions on the pump's registry tick (in-memory rows + heard entry go before the snapshot mirror could resurrect them; peer-side replication = the superseded node's silence under the 4.10 eviction + the fresh identity's rows). unit = hash domain-separation/stability + intro wire roundtrip/half-refusal + superseded both-anchors decision + evict trust/snapshot/memory/marker-once + pre-M8 row serde. Acceptance 7 = the rig re-pair proof [[requirements]] id = "REQ-SUBNET-8" title = "Status render honesty: zero-subnet text is daemon-aware ('No subnets registered — this node is standalone.' + daemon-running-dependent blurb, never implying messaging works while the daemon is down); hint footer prints on bare spt subnet only (status drops it); a stalled pump is surfaced in subnet status, never rendered implied-healthy (M8 decisions 11-12, 23)" required_stages = ["impl", "unit"] # activated M8-D2: standalone_text(daemon_running) keyed on the live socket ping; hints flag threaded from the dispatch (bare subnet = true, explicit status = false); pump_stall_warning over read_pump_heartbeat (3-cadence staleness; absent file = pre-D4, no false alarm) appended to both status views. unit = daemon-up/down standalone variants + hints-only-on-bare + bare-vs-status dispatch [[requirements]] id = "REQ-INSTALL-6" title = "Linux elevation install leg: install.sh symlinks the binary into a sudo-reachable path (/usr/local/bin; graceful print-the-one-liner when unelevated) so sudo spt resolves; first sudo spt detects elevation and prompts ONCE for the default user account — thereafter any elevated daemon launch runs daemon + state under that account, never root (KH 5.7 interplay verified) (M8 decision 8)" required_stages = ["impl", "unit"] # activated M8-D3: install.sh symlink leg (writable /usr/local/bin links, else prints the exact sudo one-liner; SPT_INSTALL_NO_SYMLINK gates CI) + deelevate::daemon_target_user election ladder (/etc/spt-core/default-user wins → sudo invoker, electing it on first use: interactive prompt with the invoker default, loud announce otherwise; root never electable — KH 5.7) consumed by BOTH elevated daemon paths (spawn_detached + the Daemon::run entry guard). unit = election_resolves_real_users_never_root (missing/garbage/root/real-account ladder). Live sudo flow = acceptance 1 (kitsubito) [[requirements]] id = "REQ-INSTALL-7" title = "Windows inbound reachability: the elevated install leg registers the inbound-UDP firewall rule (New-NetFirewallRule); the daemon self-detects blocked inbound and renders it as the no-connection state in subnet status + the coming-online banner (covers user-scope installs that skip the elevated leg — never a silent NO_SEED_HOLDER dead-end) (M8 root cause 3)" required_stages = ["impl"] # activated M8-D3: install.ps1 firewall leg (elevated = netsh add rule [delete-first idempotence; netsh not New-NetFirewallRule for PS5.1 hardened boxes]; unelevated = prints the exact elevated command; SPT_INSTALL_NO_FIREWALL gates CI) + the render half: cli inbound_block_hint — a BOUNDED netsh probe (KH 5.3, 3s) for the named rule, rendered in subnet status + the coming-online banner only when the rule is ABSENT (a failed probe says nothing, never alarms). Probe + live join = acceptance 2 (fresh Windows install) [[requirements]] id = "REQ-INSTALL-8" title = "OS-service registration (REQ-INSTALL-1's deferred third leg): Linux systemd USER service + loginctl enable-linger (linger rides the elevated install leg; daemon starts at boot pre-login, user universe per KH 5.7, systemctl --user managed); Windows scheduled task at-logon (interactive session, no stored credentials); a node is reachable after reboot without any manual spt invocation (M8 decision 17)" required_stages = ["impl"] # activated M8-D3: install.sh writes + enables the spt-daemon systemd USER unit (ExecStart = spt daemon run, Restart=on-failure; linger enabled when root, exact loginctl hint otherwise; soft on systemd-less boxes) and install.ps1 registers the at-logon schtasks task (own account, /RL LIMITED — no credentials, interactive session keeps terminal hosting alive; schtasks not Register-ScheduledTask for PS5.1 hardened boxes). SPT_INSTALL_NO_SERVICE gates both in CI (oneliner_e2e stays hermetic); reboot-reachability = acceptance 1 [[requirements]] id = "REQ-CONV-1" title = "Peer address seeding, both cold starts: durable peer-addrs.json (identity dir) maps peer pubkey → last-known dialable address; the pump's resolver consults it FIRST with id-only discovery fallback on miss or dial failure (a stale addr never strands a peer); written by the pairing ceremony (both sides, from the live connection) and by the pump on successful connect; post-join first sync and post-restart resync converge in seconds, not ~1 min (M8 decisions 14, 20)" required_stages = ["impl", "unit"] # activated M8-D4: spt_store::peeraddrs::PeerAddrStore (atomic JSON map, corrupt-degrades-empty) + the pump's seeded dial order in ensure_conn (cached addr → dial → id-only fallback; observed remote addr written back from the dial reply) + ceremony writers in pairhost (joiner + responder, from the live conn's paths). unit = store roundtrip/corrupt + dial-plan order + observed-addr capture. Rig convergence timing = acceptance 6 [[requirements]] id = "REQ-CONV-2" title = "Event-driven advertisement: endpoint online/offline transitions (ready-listener start/stop, rest-state transition, perch death) trigger an immediate advertise_local + peer push as a WAKE of the existing pump loop (no second advertisement path — epoch lease + visibility gates ride unchanged); the cadence stays the steady-state floor (M8 decision 15)" required_stages = ["impl", "unit"] # activated M8-D4: registryhost advertise-now marker (request/take, the resting.rs PULL_MARKER idiom — file-based because transitions happen in other processes) consumed by the pump tick as a forced registry round; dropped at ready-listener start/stop (cli run_listen + endpoint stop) and on every real rest edge (both transition hosts). unit = marker taken-exactly-once + forced-round wiring. Peer-visible flip timing = acceptance 6 [[requirements]] id = "REQ-PAIR-8" title = "NTP TOTP offset: the pairing ceremony queries NTP at ceremony time (both sides) and applies the derived offset to the TOTP calculation in-process only; system-clock fallback when NTP is unreachable (offline LAN pairing unaffected — NTP failure never blocks a pairing that succeeds today); never sets the OS clock; no background sync loop (M8 decision 18; field trigger: enlyzeam clock >1 min off exceeds the ±1 window)" required_stages = ["impl", "unit"] # activated M8-D4: spt_net pairing::ntp — minimal SNTP client (bounded UDP query, transmit-timestamp offset) behind a lazy in-process cache (queried on ceremony use, TTL-refreshed, never a background task, never touches the OS clock); pairhost ceremony clocks (join + respond + meet rotation) read ceremony_now_secs. unit = SNTP packet parse against a loopback mock server + offset math + unreachable→system-clock fallback. Skewed-clock rig pairing = acceptance 9 [[requirements]] id = "REQ-DAEMON-5" title = "Pump liveness: the peer pump writes a last-tick heartbeat consumed by daemon status / subnet status (decision 23 render legs in REQ-CLI-2/REQ-SUBNET-8); the daemon supervises the pump task — a panic is caught, logged loudly, and the pump restarts with capped backoff (≤5 min), so a 5.9-class death self-heals visibly instead of silently halving the daemon (M8 decision 23; field motivation: hfenduleam 2026-06-07 half-death)" required_stages = ["impl", "unit"] # activated M8-D4: run_peer_pump writes pump_heartbeat_path (epoch ms, throttled) each live tick — the D1 reader renders it; spawn_peer_pump becomes the supervisor: catch_unwind around the loop body, PEER_PUMP_PANIC/FAIL logged loud, restart with doubling backoff capped at 5 min, reset on a healthy run. unit = heartbeat write/advance + backoff ladder caps + supervised restart after an injected panic # ── Daemon lifecycle UX: service-aware start/stop + foreground-consistent run (DAEMON-LIFECYCLE-PLAN.md, 2026-06-08 kitsubito restart-loop) ── # Origin: a manual `spt daemon run` fought the systemd spt-daemon user service over the broker socket (auto-restart-fail loop). Windows decision: keep the at-logon scheduled task for boot only (NOT a start/stop-controllable service); start=detached spawn, stop=IPC. Detection source of truth: Linux = systemd user unit FILE presence at the canonical XDG path (cheap, no subprocess; control ops shell to `systemctl --user`). [[requirements]] id = "REQ-DAEMON-6" title = "Service-aware `daemon start`/`stop`: when an OS service manager has a registered spt-daemon for this user, `spt daemon start` and `spt daemon stop` drive THAT service (so stop doesn't IPC-kill a unit that auto-restart-fights for the broker socket — the kitsubito 2026-06-08 loop). `start` graduates from a `run` alias to a first-class background verb (ensure-up, idempotent, non-blocking); stop routes managed→manager, manual→IPC. Linux=systemd user unit (`systemctl --user start|stop|is-active spt-daemon`, detected by unit-file presence); Windows=no controllable manager (the logon task is boot-only), so start=detached spawn / stop=IPC." required_stages = ["impl", "unit"] # activate at daemon-lifecycle execution. impl = service.rs DaemonService trait (SystemdUserService detect-by-unit-file + systemctl control; Windows none-controllable) + pure plan_start/plan_stop routers + start_daemon/stop_daemon outcome APIs wired into cmd_daemon_start/cmd_daemon_stop; DaemonCmd::Start split from Run (alias removed). unit = plan_start/plan_stop truth tables (running×detected, running×detected×active) + `daemon start` parses as its own verb + systemd unit-path derivation. int (systemd rig) deferred to fleet rollout follow-up (rule 5). [[requirements]] id = "REQ-DAEMON-7" title = "`daemon run` is foreground-consistent on every platform: the invoking process IS the daemon, blocks until signalled, never auto-detaches or respawns into an invisible background task. The detached/de-elevated background behavior lives ONLY in `start`. Windows: an ELEVATED `daemon run` refuses with guidance (use `start`, or an unelevated shell) instead of respawning detached/de-elevated and vanishing (KH 5.7 preserved — it still never serves elevated)." required_stages = ["impl", "unit"] # impl = cmd_daemon_run guards: Windows elevated→refuse (exit 2) before Daemon::run() (the hazard-tagged in-process respawn stays as untouched defense-in-depth, now unreached via the CLI); a conflict warning when a managed service is already active before binding inline. Daemon::run() foreground loop itself unchanged. unit = the pure run-mode decision (elevated×windows→refuse vs foreground) + `daemon run`/`daemon start` no longer share an alias. [[requirements]] id = "REQ-DAEMON-8" title = "Internal auto-start prefers the service: `ensure_running` (any spt command's implicit daemon start, REQ-DAEMON-3) routes through the service-aware start path — when a manager has a registered service it starts THAT, never a competing manual `spawn_detached` daemon that would fight the service for the socket." required_stages = ["impl", "unit"] # impl = ensure_running consults service::platform_service(): detected→service.start() else spawn_detached(), then the existing bounded bind-wait. unit = the plan_start router already proves the detected→ServiceStart vs ManualSpawn branch (shared with REQ-DAEMON-6); ensure_running's no-op-when-already-running short-circuit preserved. [[requirements]] id = "REQ-DAEMON-9" title = "Net-bind boot-race resilience: a daemon that comes up net-less (NetHost::start failed — e.g. the systemd unit autostarted before the network/DNS stack was ready, `Failed to create an address lookup service`) must SELF-HEAL — retry the net bring-up in the background with capped backoff and, on success, attach net to the broker + spawn the dispatcher/peer-pump (which today are gated on `net_up` at boot and so never start, leaving the node silently unreachable until a manual restart — kitsubito 2026-06-08). Status surfaces the net-less state honestly (a net-less broker renders as 'no connection', not only a pump-STALLED line with a bogus pre-boot heartbeat age). The installer's autostart unit waits for the network (`Wants=/After=network-online.target`) as belt-and-suspenders." required_stages = ["impl", "unit"] # activate v0.3.1. impl = Broker.net → OnceLock + attach_net(); Daemon::run factors spawn_net_consumers(), fast-path attaches at boot, slow-path spawns a background NetHost::start retry supervisor (capped backoff) that attaches + spawns consumers on success (NET_BIND_RETRY/NET_ATTACHED logs); cmd_daemon_status renders net-less as 'no connection' + treats a heartbeat older than daemon start as 'no tick yet' not a giant age; install.sh unit gains network-online ordering. unit = backoff ladder caps + OnceLock single-attach (consumers spawn exactly once) + the status net-less/heartbeat-age render classifier. # --- Added 2026-06-16 (v0.8.1): perri's v0.8.0 dogfood surfaced a harness-hosted live agent that goes ONLINE but never gets a Psyche on a net-less/unpaired node (boot-race); rule 3 register-before-satisfy, rule 5 activate per wave as evidence lands --- [[requirements]] id = "REQ-HAZARD-LIVEHOST-BOOT-RACE" title = "The brain's daemon-hosted Psyche lifecycle surfaces a host-FAILURE on the live perch (harness-diagnosable) and runs net-INDEPENDENTLY. When reconcile_once→host_one→spawn_psyche fails for a state=live_agent+status=online endpoint (e.g. the adapter's psyche binary absent from its install dir, REQ-INSTALL-11), the failure MUST be written to the perch info.json as a CURRENT-STATE field (reason + ts + attempt count; overwritten each 5s retry, CLEARED on successful host) and surfaced by `spt endpoint list`/status — never left as an eprintln on the brain's invisible stderr where a harness reading only perch state is blind. status=online stays authoritative (agent reachable; only the Psyche is missing — brain-restart rehydrate legitimately has online-without-Psyche windows), so this is a SEPARATE psyche-host-health field, never a status de-stamp. Net-independence is a locked-in invariant: spawn_live_host (brainproc.rs:230) reaches the reconcile and hosts the Psyche on a net-less/unpaired/peer-pump-STALLED node, proven by a REAL detached-daemon E2E (real broker→brain-child, real api seed+listen, real install-dir psyche binary). spt-core SURFACES the failure; the adapter owns fixing its packaging." required_stages = ["impl", "unit", "int"] # activated v0.8.1 2026-06-16 (todlando): impl = info.json psyche_host_error field + set_psyche_host_error helper, host_one write-on-fail/clear-on-success, SELF-pin render annotation; unit = info.rs round-trip+set/clear/increment + render_self_pin annotation; int = livehost_bootrace_e2e (real-daemon positive, psyche_host_error cleared) + livehost_psyche_fail_e2e (real-daemon negative, missing psyche binary → harness-reachable stamp) # --- Added 2026-06-16 (v0.8.2): perri's v0.8.1 dogfood surfaced two follow-on spt-core defects (F-009 command-templating argv-split, F-010 residual host-failure masking); rule 3 register-before-satisfy, rule 5 activate per wave --- [[requirements]] id = "REQ-HAZARD-TEMPLATE-ARGV-FILL" title = "Command-template substitution fills argv ELEMENTS, not a re-tokenized string: spt-core currently `fill_template`s {key} values INTO the command STRING and THEN `tokenize`s the filled string (runtime.rs:94/122), so a multi-word {key} value whitespace-SPLITS into multiple argv tokens unless the adapter hand-quotes the placeholder, and a value containing a `\"` (or `;`) injects/breaks tokenization (shell-injection-adjacent). A filled value MUST become exactly ONE argv element regardless of spaces/quotes in the value. Fix: tokenize the TEMPLATE into argv FIRST, then `fill_template` EACH token, so a `{key}` slot resolves to a single element and the value never participates in tokenization (no whitespace-split, no quote/semicolon injection); preserve the missing-key / empty-command errors and `{{`/`}}` non-interpretation. perri's F-009 (v0.8.1 dogfood, argv-capture-confirmed): a multi-word `{psyche_prompt}` = \"PSYCHE REVIVAL time: epoch-ms:… incoming event: (none)\" arrived as argv[6..12] (7 stray tokens), the harness runner strict-parsed `--prompt` against the 2nd word, exited 2 within ~1s → phantom hosted perch. Applies to EVERY [session.] template (psyche_init, extractor, notif, …); digest survives today only because its fills ({session_id}/{source}) are single-token." required_stages = ["impl", "unit", "int"] # activated v0.8.2 2026-06-16 (todlando-w162): impl = fill_template_tokens (tokenize-template-then-fill-each) + rewired command_for/run_bounded_command_in/harnesshost/shellwake/shellhost callers; unit = multi-word→1-element, quote/semicolon→1-element, embedded-placeholder, missing-key/empty-command, literal-brace parity; int = the residency E2E (livehost_nonresident_e2e) exercises the multi-word fill through the real daemon host path (psyche argv built via the new fill order) [[requirements]] id = "REQ-HAZARD-LIVEHOST-NONRESIDENT" title = "A daemon-hosted Psyche that spawns then EXITS IMMEDIATELY is a host failure, surfaced like a spawn failure (closes the v0.8.1 residual masking): the REQ-HAZARD-LIVEHOST-BOOT-RACE signal stamps `psyche_host_error` only when `spawn_psyche` returns Err, NOT when the detached spawn() returns Ok but the child dies within moments (e.g. a bad-argv child exiting 2 — the F-009 case). That leaves the residual 'online + no Psyche + no cause' gap: the nested `{id}-psyche` info.json is written status=online with a real-but-DEAD pid and the PARENT perch carries NO psyche_host_error (perri's F-010: tasklist showed 0 host procs across the window while info.json read online). The host MUST confirm RESIDENCY — a hosted child not alive (or whose `{id}-psyche` perch never re-registers / has a dead pid) within N seconds of spawn is treated as a host failure: stamp the parent perch `psyche_host_error{reason:\"host not resident within s (psyche perch missing/dead pid)\"}` (and do not leave a phantom online nested perch). Closes the last masking gap the v0.8.1 fix left open. perri's F-010 (v0.8.1 dogfood). Sibling of REQ-HAZARD-LIVEHOST-BOOT-RACE." required_stages = ["impl", "unit", "int"] # activated v0.8.2 2026-06-16 (todlando-w162): impl = classify_residency + confirm_residency_or_unhost (DIRECT pid-probe of {id}-psyche, stamp parent psyche_host_error + clear phantom + un-host + cooldown) wired into reconcile_once; unit = residency_classification (Resident/Pending/Failed by alive+grace); int = livehost_nonresident_e2e (real-daemon: psyche spawns Ok then exits 2 fast → parent psyche_host_error stamped, no phantom online nested perch) [[requirements]] id = "REQ-HAZARD-EPOCH-RESET" title = "Advertisement-epoch reset strands a node: peers' higher last-seen epoch drops the reset node's fresh advertisements as Stale until the counter outruns history. Common case (full reinstall/re-pair) is mitigated by REQ-SUBNET-7's ceremony eviction (peer-side epoch memory dies with the deleted row — acceptance-verified); the residual narrow slice (epoch file lost, identity kept) is documented, guard deferred to a field hit (4.11)" required_stages = [] # rule 5 + M8 decision 24: documented hazard (KNOWN-HAZARDS 4.11 at D5), guard waits for a field hit of the narrow slice; the re-pair eviction path is tested under REQ-SUBNET-7 # ── Subnet full-mesh: membership seed-proof + roster-only relay (ADR-0017, 2026-06-08 grill) ── # Minted inactive (rule 5: no required_stages until the mesh milestone executes). Design: ADR-0017 + SUBNET-MESH-PLAN.md + CONTEXT §Pairing & trust. [[requirements]] id = "REQ-MESH-1" title = "Membership proof (seed-proof): symmetric current-epoch seed-knowledge replaces is_trusted at EVERY inbound gate (registry apply, WAN receive, sync, notif, connection accept). MK = HKDF(seed, domain ‖ subnet_id ‖ seed_epoch); mutual channel-bound challenge-response at connect (transcript binds both handshake-proven node pubkeys, both nonces, subnet_id, seed_epoch, role); verified once per connection, cached on the broker ConnEntry, kept warm via QUIC keep-alive so re-proof is restart/partition/rotation-only. Exact-epoch match (re-seed is the sole N-1 exception). SECURITY INVARIANTS: channel-bound (no cross-connection replay), mutual, accepts a member it never paired (the mesh property)." required_stages = ["impl", "unit", "int"] # Mesh-D1 activated impl+unit (codec: MK derivation + mutual channel-bound transcript/verify + frame codec). Mesh-D2 enriches impl (connect-time mutual proof in nethost dial+accept, ConnEntry proven-subnet set, QUIC keep-alive) and activates int (a member conn establishes only when both prove; a non-prover / no-shared-subnet is dropped). D5 enriches impl (gate swap is_trusted→is_member at the five inbound sites). unit = MK derivation; valid/forged/wrong-subnet/wrong-epoch/cross-connection-replay/mutual-fail; Hello-frame round-trip/reject; keep-alive < idle; D5 adds gate admits-unpaired-member / rejects-non-member. [[requirements]] id = "REQ-MESH-2" title = "Member roster: node-level union-merge grow-set (per member: pubkey, label, machine_id, last-known address, last-seen — NOT the seed), the discovery directory the mesh dials by. Seeded IN FULL at pairing (seed-holder hands joiner the whole current roster, incl. offline members — folds in deferred pairing-time hostname capture + post-join address seeding); each node authors its own entry stamped with its lease_epoch, merged strictly-greater-wins (the node_label lease); exchanged only over seed-proof'd member connections; forgery-inert (a fake entry names a pubkey that still can't seed-proof). Removal needs a TOMBSTONE — a per-pubkey revoked marker that propagates, dominates the entry, gates admission (seed-proof ∧ ¬tombstoned), and prevents reinsert; cleared by a completed re-pair of that pubkey. Persists through silence (offline member keeps its entry)." required_stages = ["impl", "unit", "int"] # Mesh-D3 activated impl+unit: roster store (RosterStore union-merge + strictly-greater lease, tombstone dominate/suppress-reinsert/clear-on-repair, atomic, corrupt-degrades-empty) + pairing-seed full-roster transfer (Frame::Seed gains the roster, additive) + ceremony adopt-on-join wiring (pairhost both legs). Mesh-D4 enriches impl (on-connect roster exchange riding the proof control stream after both prove, REQ-MESH-2's "exchanged only over seed-proof'd member connections" by construction; self-address advertised from endpoint.addr(); peer-addrs gap-fill seam — roster feeds the dial cache only when absent, never clobbering a locally-observed addr) and activates int. unit = merge lease (strictly-greater self-entry) + union convergence (commutative/idempotent) + tombstone truth table + persist-through-silence + serde additive + extended-Seed codec round-trip + loopback ceremony roster transfer + member-conn roster frame round-trip/reject + gap-fill fills-absent-only/skips-self. int = on-connect transitive propagation (B knows offline C; A connects B; A learns C + C's address; B learns A) + no cross-subnet leak (only proven subnets exchanged). [[requirements]] id = "REQ-MESH-3" title = "Mesh row fan-out: registry rows stay OWN-AUTHORED; the only change is the push target widens from directly-paired peers to ALL roster members (a wider DIRECT fan-out, never a third-party relay). Every row/message still arrives from its author over a handshake → KNOWN-HAZARDS 7.5 (origin = handshake node) and 4.10 (eviction lease: any future update comes from that node itself, alive) PRESERVED VERBATIM. Closes the staggered A→B→C repro: C (roster-seeded with A at pairing) initiates to A, seed-proof admits C unpaired, A learns C, both push directly." required_stages = ["impl", "unit", "int"] # Mesh-D6 activated: impl = peerloop push target = roster.members_in via the pure push_targets helper (directly-paired restriction removed; PumpPaths.trust dropped). unit = push_targets returns the full roster minus self+tombstoned (the widen as a unit fact). int = the un-ignored staggered A→B→C harness (B offline at the critical step; A↔C converge) + the all-online star (A reaches C without B relaying), each carrying the no-relay regression (every applied registry row's node == its true owner). [[requirements]] id = "REQ-MESH-4" title = "Revoke + timeboxed seed rotation + re-seed grace: `spt subnet revoke ...` (list, elevation-gated, revoke-only) writes roster tombstones immediately, then schedules ONE seed rotation (re-mint seed, bump seed_epoch, push new seed CONFIDENTIALLY over member-auth'd TLS connections — never in roster/registry gossip — force-drop revokees) at the close of a coalescing window (default 1h); further revokes in the window join the same rotation (one epoch bump). `--force-rotate-seed` rotates immediately (compromised-node path). RE-SEED GRACE: a node proving the immediately-prior epoch (N-1) AND still on the roster gets a re-seed-only restricted connection (auto-heals a benign offliner); revoked/off-roster denied; ≥2 stale → re-pair." required_stages = ["impl", "unit", "int"] # Mesh-D7 activated. impl = revoke CLI (list, elevation-gated, own-refuse) + propagating tombstone + coalescing-window scheduler (rotation.rs: coalesce/due_subnets, --force-rotate-seed inline) + pump fire_due_rotations + 3-valued seed-proof grading (current+prior ProofSet, grade_subnet) + confidential SeedTransfer push/adopt over the member control stream (rotate_seed retains prev one-deep, adopt_rotation). NOTE deviations from the JIT plan: (a) re-seed delivery is INLINE on the proof exchange (a reseed-only conn registers with an empty proven set purely to deliver the seed, then is replaced when the healed peer reconnects full) rather than a separate dispatcher-gated stream — stronger fail-closed (the conn serves nothing) with zero new broker IPC, which D5 deferred; (b) force-drop of a revokee's LINGERING conn is deferred — the propagating tombstone (app gate) + rotation (connect gate) already lock the revokee out, and the int test confirms denial without it. unit = window coalescing (N revokes→1 bump, earliest deadline) + grade truth table (exact∧present⇒full, N-1∧present⇒reseed, tombstoned/off-roster/N-2⇒denied) + seed-never-in-gossip codec assert + ProofSet/SeedTransfer codec round-trips + adopt_rotation idempotence + fire_due_rotations. int = benign offliner re-seeded across a rotation + revoked node denied and never re-seeded (reseed.rs). [[requirements]] id = "REQ-MESH-5" title = "Hard cutover from pairwise trust: delete peers.json + the is_trusted authorization path (no migration — expendable test fleet, re-pairs fresh under the new model, user decision 2026-06-08). Warn-on-change DEMOTED from a gate to an awareness notice anchored on machine_id (not label): 'machine M, last seen as K1, now presents K2' — fires the same event as the REQ-SUBNET-7 re-pair overwrite. The TrustStore/peers.json code and its call sites are removed, not left dead." required_stages = ["impl", "unit"] # Mesh-D6 activated: impl = TrustStore + peers.json + every call site DELETED (spt-store mod, perch::trust_file, pairing wire trust write, peerloop push, CLI status/list/prune/leave, xtask); the serve/inbound gates authorize on roster membership (propagate serve_update → RosterStore::is_member_any; dispatch caller); warn-on-change re-homed onto machine_id as a non-blocking RekeyNotice at the repair_evict_superseded event. unit = roster::is_member_any spans subnets + honors tombstones; repair_evict emits the machine_id-anchored rekey notice without blocking, and absent machine_id raises no false notice; the deletion is compile-enforced (no TrustStore symbol remains). [[requirements]] id = "REQ-MESH-6" title = "Concurrent liveness probes: `spt subnet status --nodes` fans out its offline/serve-probes (REQ-SUBNET-5) CONCURRENTLY — total wall-time bounded by the single-probe ceiling (~3s), never k×ceiling. The mesh makes a node see ALL members (many possibly offline), so a serial probe loop would be offline_count×3s. (Planning verifies the current REQ-SUBNET-5 probe loop's behavior and fixes it if serial.)" required_stages = ["impl", "unit"] # Mesh-D8 activated: impl = `probe_all(items, ceiling, max_inflight, probe)` fans the --nodes serve-probes out across ALL subnets in one bounded batch (thread-per-probe under run_bounded, in-flight cap MAX_INFLIGHT=16, results stitched back in row order), replacing the serial `.map(probe_node)` loop; each probe stays individually ceiling-bounded (KH 5.3) and the over-cap path logs (no silent truncation). Threads + run_bounded, NOT tokio. unit = injected deterministic sleeper proves N probes finish in ≈one ceiling not N×ceiling AND map back in input order; a cap unit proves window-batching (20 at cap 4 ≈ 5 windows); a timeout unit proves one wedged probe settles false in one ceiling without dragging the batch. # --- Added 2026-06-15 at M11-T0 (M11-PLAN.md Shell-substrate extensions; doyle # premise-gate PASS; rule 3: register before satisfying, rule 5: stages=[] until # each wave lands evidence. Planned activations: REQ-SHELL-5 doc/impl at T0 (the # ownership audit — proof-not-change), REQ-CONSENT-3 at W1, REQ-SHELL-3 at W2, # REQ-SHELL-4 at W3, REQ-SHELL-5 unit/int at the W4 Gateway-owner capstone.) --- [[requirements]] id = "REQ-SHELL-3" title = "Drive channel (owner->shell, REST-only, never-spooled, latest-wins): the owner->shell mirror of sensory for continuous real-time control (scroll/crank/stick/avatar) — a [shell.drive] manifest vocab + EVENT_TYPE_DRIVE frame, delivered to the ONLINE binary only via a single live slot (a new frame supersedes an undelivered one — no spool, no queue, no replay on relink), dropped-with-diagnostic if the shell is offline; cross-node rides the ephemeral link (REST class), never the durable shell spool. Commands = discrete+durable; drive = continuous+ephemeral (CONTEXT:260, minted 2026-06-11 Gateway grill)." required_stages = ["impl", "unit", "int"] # activated M11-W2 (machinery + mock exerciser only — doyle C1: no real GameRobot/usbip shell code). impl/unit across T2.1 (manifest [shell.drive] + EVENT_TYPE_DRIVE + compose_drive_frame), T2.2 (broker-held DriveHub slot: latest-wins write, take-and-clear, link-token no-replay stamp; `spt shell drive` owner-write + `api drive-poll` shell-read; close_shell clears), T2.3 (cross-node drive_channel_write shared core + SHELL_LINK_DRIVE; drop-not-wake / no-owner-durability / no-spool-remote divergences). int = the same-node real-socket E2E (tests/drive_e2e.rs: drop-at-write-offline, latest-wins, take-and-clear, NO-SPOOL, clear-on-link-break, no-slot-file) + the rig cross-node drop-not-wake rung (tests/twohost.rs, [twohost]-gated) [[requirements]] id = "REQ-SHELL-4" title = "Shell tunnel (reliable-ordered opaque byte stream): an owner<->shell link may hold a long-lived, reliable-ordered, link-bound QUIC stream pair carrying opaque wire protocol traffic the channel taxonomy must NOT reinterpret (first consumer usbip URB) — manifest opt-in, not enveloped, not MAC-framed, not spooled; the link lifecycle governs it (a link-break closes the tunnel). Reliable-ordered ⇒ congestion surfaces as lag never loss ⇒ acceptable only on-LAN: the on-LAN posture is documented and the tunnel is NOT proven cross-WAN (CONTEXT:262, minted 2026-06-11 Gateway grill; doyle gate C2)." required_stages = ["doc", "impl", "unit", "int"] # M11-W3 (same-node mock E2E only — doyle C2: tunnel is on-LAN by design, not a WAN rung; cross-node-on-LAN real-Iroh wire = W5 [twohost] rung, R2). doc = CONTEXT §shell tunnel; impl = [shell.tunnel] opt-in + validate, broker TunnelHub registry, retentive backpressured NetHost streams (lossless = doyle gate condition), the broker-homed byte bridge (tunnel_ensure/send/recv), open-at-bind hook + close_shell clear, `spt shell tunnel` + `api tunnel` surfaces; unit = shell_tunnel_opt_in + tunnelhub (resolve/R1/relink/clear/per-owner) + nethost retentive-lossless & loopback backpressure + surface_parses; int = tunnel_e2e (multi-write byte-exact opaque round-trip both directions incl