# Known Hazards

Hard-won edge cases harvested from the sister project (`claude_skill_owl`, ~80 commits / 12+ phases / multiple production incidents). Per ADR-0001, this is a **test checklist for the spt-core rebuild** — the clean-room rebuild must re-satisfy each invariant rather than re-discover the bug.

**Architecture-translation note.** The sister project runs poll listeners and Psyche wrappers as *separate processes*. spt-core consolidates both into the one `spt-daemon` (brain), with a stable broker beneath it (ADR-0004). Many hazards below were inter-process races in the sister project; in spt-core some become intra-daemon concerns (potentially easier) while others move to the daemon↔broker IPC boundary or the network boundary (potentially new failure surface). Each entry notes the mapping where it differs. Citations point at sister-project paths for reference, not at spt-core.

---

## 1. Race conditions & ordering

### 1.1 Phantom INIT_SIGNOFF after grace period
- **Failure:** orphan teardown enqueues INIT_SIGNOFF before the grace-period recheck; a transient Self recovery (binary handoff, brief stale poll) makes the recheck pass-as-alive, but the signoff was already spooled and drains on the next iteration → teardown despite a live Self.
- **Invariant:** grace-period wait MUST complete *before* composing/delivering INIT_SIGNOFF; the recheck must bind `still_gone` before any envelope write.
- **spt-core mapping:** in-daemon now (no separate wrapper), but the ordering invariant is identical — orphan/teardown logic must re-evaluate liveness after the grace wait, not before enqueue.
- **Sister cite:** `src/live/wrapper/orphan.rs:201-259` (sleep@209 precedes compose@231-251); tests T-grace-recovery:576, T-still-gone-recheck:618.

### 1.2 Poll-rewrite race & info.json mid-write reads
- **Failure:** `info.json` written by the wrapper mid-iteration while a list/classify command reads it → torn read, misclassification.
- **Invariant:** consult liveness via the supervisor (`is_wrapper_alive`-equivalent) before any grace gate; reads of state files must tolerate concurrent writes (atomic write + rename, or read-retry).
- **spt-core mapping:** the daemon owns both writer and reader → use in-process locking/snapshotting instead of racing on disk. Cross-node registry reads remain eventually-consistent and must tolerate staleness.
- **Sister cite:** `src/common/list_filter.rs:100-150`; `src/owl/poll.rs:141`.

### 1.3 Stale `index.lock` wedge from prior git crash
- **Failure:** crashed git leaves a 0-byte `index.lock` in a psyche tracked worktree; every later commit blocks forever.
- **Invariant:** on daemon boot, sweep seed + all agent/project worktrees for stale locks (0 bytes, mtime > 60s) and remove; leave live locks alone.
- **spt-core mapping:** cross-node Psyche sync (ADR-0002/0003) replaces git-repo sync, so the *git* lock may disappear — but any equivalent lockfile in the new sync mechanism needs the same stale-sweep on boot.
- **Sister cite:** CHANGELOG v1.11.20 "Stale `index.lock`"; `src/common/git.rs`.

### 1.4 Deferred spool rows must not leak to the event stream
- **Failure:** a hook spools a deferred (spool-only, no TCP wake) notice; startup `drain_all` flushes ALL rows including deferred → event emitted at wrong time/priority.
- **Invariant:** startup drain (and idle/timeout TCP-wake sites) use `drain_non_deferred` only; deferred rows are picked up by their intended consumer via `peek`. All drain sites must agree on which rows they flush.
- **spt-core mapping:** carries directly — the daemon's spool-drain has the same deferred-vs-immediate distinction.
- **Sister cite:** `src/owl/poll.rs:276-316`; `spool::drain_non_deferred_with_metadata`.

### 1.5 Worker (working-perch) lifecycle path consistency
- **Failure:** subagent-start creates the perch at one path layout; later hooks read it at another → not found; stop-hook scan misses nested perches.
- **Invariant:** all Worker/Psyche child-perch path composition routes through one central resolver; no divergent path construction across hooks.
- **spt-core mapping:** `Worker` is a day-one endpoint type; the daemon owns the registry, so perch location is a registry lookup, not ad-hoc path math. Single source of truth for instance→location.
- **Sister cite:** `src/owl/hook_subagent_start.rs:122-168`; `hook_subagent_stop.rs:15-55`.

---

## 2. Identity & session-binding

### 2.1 Parent PID over ephemeral poll PID
- **Failure:** orphan check polls an ephemeral listener PID; it dies and is recycled (esp. Windows); a foreign process with the recycled PID reads as alive → false-positive teardown (or false-negative).
- **Invariant:** prefer the stable harness-session PID (`parent_pid`) over any ephemeral process PID for liveness; minimal `info.json` for supervisor-owned perches to avoid stale leaks.
- **spt-core mapping:** session binding (parent-process-tree anchor) still applies for harness-hosted topology. For spt-hosted sessions the broker holds the child directly → liveness is the broker's held-handle state, more reliable than PID polling.
- **Sister cite:** `src/live/wrapper/orphan.rs:141-161`; CHANGELOG v1.11.20.

### 2.2 Stdin session_id precedence over env
- **Failure:** subagent inherits a stale `OWL_SESSION_ID` env across `/clear`; hook gets two session_ids (fresh stdin, stale env) → wrong-agent binding.
- **Invariant:** stdin-provided session_id wins; env is fallback only.
- **spt-core mapping:** the harness-contract subcommand surface must define the same precedence for whatever identity fields hooks pass in.
- **Sister cite:** CHANGELOG v1.35.1 "IN-05"; `hook_subagent_start.rs:40-51`.

### 2.3 Binary-handoff argv schema must stay backward-compatible
- **Failure:** old binary spawns new binary with old argv arity; clap rejects before state rehydration → wrapper dies unlogged.
- **Invariant:** every newly-added handoff positional has a default; state-file rehydration happens *after* argv parse; defaults survive intermediate versions.
- **spt-core mapping:** CRITICAL — self-update (ADR-0004) makes handoff routine. The broker↔brain IPC and any brain-relaunch argv must be versioned and forward/backward tolerant (a newer brain talks to an older broker). This is the single most update-frequency-sensitive invariant.
- **Sister cite:** `src/live/wrapper/lifecycle.rs:17-106`; `src/cli.rs` defaults; CHANGELOG v1.11.10.

### 2.4 Generation `gen_start` always = now() on cold-start AND handoff
- **Failure:** stale gen_start from a rehydrated state file fires time-based discriminators on the new process.
- **Invariant:** wall-clock `gen_start` is set to `now()` on both cold-start and handoff; generation counter increments on every start/revive; session UUID captured fresh and carried so the resumed mind distinguishes "same gen continuing" vs "new gen born".
- **spt-core mapping:** carries to the daemon's per-instance generation tracking.
- **Restoration D3/D4 (ADR-0018):** the generation *counter* custody moved to the broker (D3-2 — it observes every brain spawn, planned or crash, and hands `{generation, start-reason}` at spawn; `gen_start` stays `now()`-fresh, never rehydrated). The brain→brain **`BrainState` *message*** (`{session_id, generation, next_seq, gen_start_ms}`) that previously carried continuity across a handoff is **retired from the production path in D4-2**: a brain the supervisor respawns cold-starts and reconstructs all session continuity by **querying the broker** (`Brain::resume_sessions` over the broker's cursor-of-record), never a frame. `BrainState` / `Brain::handoff` / `Brain::snapshot` remain `pub` and compiled **only for the integration tests** (handoff/idempotent/da…
- **Sister cite:** `src/live/wrapper/lifecycle.rs:70`; `src/common/wrapper_state.rs`.

### 2.5 Daemon-hosted endpoints have no dedicated liveness PID
- **Failure:** the sister evaluates Psyche/perch liveness via a dedicated process PID — the wrapper's own pid in `info.json`, checked with `is_process_alive`. Under ADR-0004 the Psyche (and any spt-hosted Self) is a **loop inside the daemon**, not a separate process: it holds no dedicated pid, and its `claude`/summarizer subprocess is ephemeral (spawned per pulse/commune, then exits). If a daemon-hosted perch's `info.json` carries the **daemon's** pid, then *every* hosted endpoint shares one pid, and `is_process_alive(pid)` reads "alive" for a torn-down endpoint as long as the daemon runs — while `clean_stale_entries` (dead-pid deletion) can no longer distinguish a dead endpoint from a live one. The 2.1/5.1 liveness models do **not** cover this third categor…
- **Invariant:** for **daemon-hosted** perches (Psyche; spt-hosted Self), liveness is the **daemon's authoritative in-memory endpoint table + a `status` field** on `info.json` (`online|offline|…`), **never** `is_process_alive(info.pid)`. `info.pid` for a daemon-hosted perch is at most a *hosted-by-daemon* marker (the daemon pid), not a liveness signal; registry stale-clean for these rows keys on the daemon's endpoint table, not per-row pid. This reuses the pattern already specified for **Shells** (`info.json` carries daemon-managed `status`, capability resolved by `adapter_name` — CONTEXT "Shell… Not in the subnet registry") and extends it to daemon-hosted *agent* perches.
- **spt-core mapping:** the **M1/M2a interim** model keeps the Psyche/listener a real per-process owner (the `api listen` process), so its per-pid liveness (`deliver::is_online` → `info.read_pid` → `proc::is_process_alive`; `registry::clean_stale_entries`) is correct *interim*. **M3 daemon consolidation replaces it** with daemon-authoritative liveness for hosted perches. Keep the liveness check behind one resolver (mirrors `resolve_address` stale-clean) so the M3 swap is localized — do **not** let the per-pid assumption leak into new call sites.
- **Sister cite:** `src/live/wrapper/orphan.rs` (wrapper-pid liveness); `src/common/list_filter.rs:168-175` (pid-classify); spt-core `crates/spt-store/src/{proc.rs,registry.rs}` + `crates/spt-msg/src/deliver.rs::is_online`.

### 2.6 A shell instance's `online` is a recorded field with no death edge
- **Failure:** a shell perch's `status` has exactly two writers — the bind handshake (→`online`) and `close_shell` (→`offline`) — and `close_shell` runs on the **link-break** path alone. A binary that dies **abruptly** (force-kill, crash, OOM) breaks no link, so nothing ever writes the flip: the record says `online` forever while the pid it names is gone. Every consumer of the recorded field then inherits the lie — `shell list` reports online indefinitely; `relink` (the one command that recovers the instance) refuses `SHELL_ALREADY_ONLINE`, gated on the very state that is wrong; and `shell cmd`'s wake-if-offline arm reads "not offline", skips the wake, and spools the command to a corpse — accepted happily, drained by nobody, **indistinguishable from a busy s…
- **Invariant:** ONLINE-ness is **derived**, never the recorded field alone: `status == online` **AND** the recorded `shell.pid` is not *provably* dead. Provably dead is narrow — pid parked, non-zero, `!is_process_alive`; pid absent, unparseable, or `0` (a broker-hosted spawn whose backend exposed no pid) reads **alive**, the same fail-toward-alive parity 2.5 holds, so a backend the resolver cannot see is never declared dead. **The recycled-pid caveat is narrowed since BAROMETER W2 and is now conditional on the stamp:** a launch parks a **birth stamp** beside the pid (`shell.launch.json`), and the probe runs the PAIR test — same pid, *different* start time ⇒ **gone**, so a pid the OS handed to an unrelated process no longer masks a dead instance. That case m…
- **No spontaneous relaunch of a SAME-BOOT death (operator-ratified, flynn 2026-07-25; re-based BAROMETER W2):** the reconciler does NOT adopt an instance whose binary died during this boot. Every reason an operator stops a shell is a reason not to want it back a tick later: mid-deploy (a shared install dir makes "kill the process" routine on Windows, since a running exe cannot be overwritten) an auto-relaunch would run the **old** binary out of the file being replaced — worse than a failed install — and it turns quarantining a misbehaving shell into a restart loop. For those, recovery stays demand-driven: `relink`, or a `shell cmd` that wakes. `relink` probes **locally** rather than trusting a daemon sweep, so recovery holds with the daemon down.
- **What holds that open is no longer the stale record (BAROMETER W2):** until W2 the guarantee rested on an accident: a force-killed instance was held out of the eligible set because its record still said `online`, and the eligibility read took that field raw. 2.7 heals that record to the truth, which removes the accidental protection — so the guard had to move to a rule that *states* it. Eligibility is now recorded-`offline` **AND** (no corpse, **or** the corpse's recorded launch **predates the boot instant**). A steady-state force-kill leaves a corpse launched *after* boot, which the predicate can never accept, so the ruling is preserved **by construction** rather than by a second rule. A restart casualty is the one case the predicate does accept, and it …
- **spt-core mapping:** `spt_store::shellinfo::{shell_pid_provably_dead, effective_status, is_shell_online}` (the resolver — the shell-side twin of 2.5's `liveness.rs`, which gave *agent* perches exactly this and which shells never got); consumers in `linkhost::{relink_shell, prepare_drive, run_action}`, `shelldisc::discover`, `activity::observe_links`; the class-(c) eligibility read is `spt_daemon::shellwake::watcher_eligible` (+ `launch_predates_boot`, `BOOT_RESTORE_SLACK_MS`), and the birth stamp it rests on is `shellinfo::{ShellLaunch, record_shell_launch, read_shell_launch}`.
- **Source:** spt-core, flynn's spt-alchemy field report (2026-07-25) — a deterministic recipe, not a race. Class-(c) re-based BAROMETER W2 (releases#78, doyle's ruling on comment 5156810267).

### 2.7 A node restart permanently strands every `persistent` shell — and the existing cascade test cannot see that it does  `[REQ-HAZARD-RESTART-STRANDS-PERSISTENT-SHELLS]`
- **Failure:** the contract's own sentence says a `persistent` shell is **online whenever its owner endpoint is online**, and a node restart is the one case where that promise was never kept. A machine death breaks no link, so `close_shell` never runs and 2.6's stale record survives the reboot; when the owner comes back online, the wake cascade reads the **recorded** field, sees `online`, and skips the relaunch of a binary that has not existed since the previous boot. Nothing else corrects it: the instance is down forever, with no error anywhere, until a human happens to run `relink`. Every `persistent` instance on the node is stranded by one restart, together.
- **The owner-facing surfaces HIDE the fault**, which is why it survived unreported for days: every display path (`shell list`, its `--json` twin, the shell-context render) routes through the ONE discovery seam, which **derives** status (2.6) and so correctly reports the binary as offline — while the only consumer whose decision matters, the wake cascade, reads the RECORDED field that no owner-facing surface shows. Absence of a visible symptom is therefore **not** evidence of absence of this fault, and a clean-looking context render must never be taken as proof the class did not occur. The correct falsifier is the **on-disk record**.
- **The existing cascade test cannot fail on it — its SETUP SUPPRESSES THE FAILING ARM.** `rest_edges_cascade_shells_with_divergence` establishes its fixture by driving the suspend path, which itself writes the `offline` the code under test is supposed to encounter — so the restart shape (a record still saying `online` over a corpse, reached with **no** suspend edge) is unreachable from that fixture BY CONSTRUCTION. The test is not weak; it gives a true answer to the wrong question, so a reader asking "is the cascade covered?" is told yes. This is the third suppressed-arm instance found in one week, and the pattern is the same each time: **a fixture that establishes the precondition the code under test is supposed to establish.**
- **Invariant:** a node restart must not strand a `persistent` instance. Two legs, and each is worthless alone. **(a) The record stops lying:** the daemon-side reconciler heals a recorded `online` that 2.6's derivation contradicts, writing `offline` — every cycle, **guarded on an actual change** (a heal that rewrote each tick would be a stream of identical writes and would destroy the record's mtime as a signal). **(b) The restart shape reaches a restoration path:** a once-per-daemon-generation boot sweep — and, for an owner that comes online after that sweep has run, an **owner offline→online edge** in the reconcile loop running the identical body (releases#228) — relaunches an instance when **all four** hold — the adapter section declares `persistent`; the…
- **Regression shape (the row the suppressed fixture cannot express):** construct the restart shape **without a suspend edge** — an instance whose record says `online` over a corpse that predates boot, with an online owner — and assert it is restored. A fixture that suspends first re-creates the suppressed arm and proves nothing about this class.
- **spt-core mapping:** `spt_daemon::shellwake::{heal_stale_online_records, restore_persistent_shells_at_boot, restore_persistent_shells_on_owner_online, OwnerOnlineEdge, launch_predates_boot, BOOT_RESTORE_SLACK_MS}`; `spt_store::proc::boot_instant_ms`; `spt_store::shellinfo::{ShellLaunch, record_shell_launch, read_shell_launch}` (the birth stamp, parked at the two production pid-write sites the launch-site census identified).
- **Source:** spt-core, BAROMETER W2 (releases#78) — field specimen `liam/alchemy-0`, a genuinely stranded record: `online` over a dead pid, no launch stamp, days old.
<!-- [doc->REQ-HAZARD-RESTART-STRANDS-PERSISTENT-SHELLS] -->

---

## 3. Lifecycle

### 3.1 Ephemeral perch cleanup on every `ring` exit path
- **Failure:** `ring` creates an ephemeral perch; early-exit paths (no-perch, empty-msg, timeout) skip cleanup → stale dirs accumulate.
- **Invariant:** every code path that creates an ephemeral perch cleans it before exit; exception: if the caller already had an active perch, do not treat as ephemeral and do not clean up.
- **spt-core mapping:** `ring` semantics carry; the daemon owns ephemeral-perch lifecycle, so a single guaranteed-cleanup (drop guard / RAII) is achievable in-process.
- **Sister cite:** `src/owl/ring.rs:58-294`.

### 3.2 Stale signoff sentinel must not kill a fresh start
- **Failure:** a leftover `.claude/<id>-signoff.md` from a prior session is read by a fresh listener as a live signoff → immediate teardown.
- **Invariant:** on every listener/daemon spawn, sweep stale signoff sentinels; signoff files are write-once per generation.
- **spt-core mapping:** same sweep on daemon (re)start per hosted instance.
- **Sister cite:** CHANGELOG v1.11.20; `src/owl/cleanup.rs:97`.

### 3.3 Orphan teardown fires echo-commune BEFORE INIT_SIGNOFF
- **Failure:** teardown delivers INIT_SIGNOFF without first saving the final context delta → Psyche signoff lacks the context-save summary.
- **Invariant:** on orphan path, synchronously run the echo-commune (final delta) before composing INIT_SIGNOFF; skip only if the session_id is missing.
- **spt-core mapping:** the daemon runs psyche/pulse loops in-process; ordering invariant identical.
- **Sister cite:** `src/live/wrapper/orphan.rs:175-199`; tests A-H:333-565.

### 3.4 A `ring` never adopts — so never deletes — a perch it did not create
- **Failure:** 3.1 guards the LEAK direction (cleanup on every exit path). It says nothing about *whose* perch is being cleaned, and the two compose into a data-loss bug: `ring` decides "the caller has no perch" **ready-marker-first**, so an existing perch whose marker is momentarily down — busy turn, soft session-end, stale re-bind — reads *perchless*, `setup_ephemeral` clobbers the caller's real `info.json`, and 3.1's guaranteed cleanup then runs on the way out and **deletes** the ready marker, `info.json`, `spool.db` and the directory. Silent CLI-side fs ops, no daemon log, no console trace (field 2026-07-27 — emphasys lost a live endpoint and its spool to one timed-out ring).
- **The mail half is a CONFIDENTIALITY failure, not only a durability one:** deletion is what happens on the way out, but while the ring *holds* the adopted perch its reply-wait `drain_one_at` consumes whatever is in the victim's spool and **renders it to the ringer as the reply**. Ringer == victim (the field case) is the degenerate, mild shape — anything drained reaches its intended recipient. Ringer != victim is the severe one: a third party rings a live agent and the **victim's inbox is drained into the ringer's output**, leaving no trace on either side once the dir is removed. Across a multi-machine subnet that is one agent reading another's mail. Bounded honestly for the record: in the field instance theft did **not** fire — emphasys's 66-second window …
- **Self-camouflaging**, which is why it went unattributed for hours: the damage — perch gone, roster row ghosted — *mimics the stale-liveness condition people reach for `ring` to diagnose*. The field caller was probing a ghost-roster symptom and the probe manufactured a fresh one, so debuggers of stale liveness are disproportionately its victims and read the wreckage as more of the symptom they were chasing.
- **Marker-down is not an edge case:** on hosted OMP endpoints the extension owns the listener and publishes busy/idle through `api state`, so marker-down is the **normal steady state during work** — marker-first misjudges healthy endpoints routinely.
- **Invariant:** probe the perch **directory**, not the marker. A dir carrying a **record or a spool** is OCCUPIED — refuse to adopt it. Ambiguity resolves toward refusal, always: an **unreadable** record (corrupt/truncated `info.json`) or an unreadable dir counts as occupied, never as residue, because deletion is irreversible while refusal is recoverable. A pre-existing **empty** dir is refused too — it is not provably ring's own residue (a bringup mid-create owns an empty perch dir for a window, and deleting it is the same race in a different hat). Refusal never blocks and is never silent: the message is delivered, the call declines to block-wait (the reply lands on the caller's own listener), and the caller gets a **distinct** loud report — `RING_PERCH_EX…
- **spt-core mapping:** `spt_msg::ring::{probe_perch, setup_ephemeral, ring}`; `RingOutcome::{PerchExists, StaleDir}` rendered by `cmd_ring`.
- **Source:** spt-core field incident 2026-07-27 (emphasys endpoint + spool loss); ruled by doyle, with emphasys's mid-create-race amendment revoking the empty-dir self-heal.
- **Amendment — the perch GC inherits this reasoning EXTENDED, never carved out (doyle, 2026-08-04, releases#109).** A second consumer now deletes perch directories: `spt endpoint gc` (3.5, `REQ-PERCH-GC-RESIDUE-PREDICATE`). Its authority is something `ring` never had — a **positive** statement that the directory carries **no endpoint record at all**, from the store that answers endpoint existence on a node (the owlery tree itself; the perch dir plus its parseable `info.json` IS the endpoint record). That authority does **not** outrank this section's occupancy rules, and the ruling says why: undeliverable-by-construction settles *deliverability*, not *value*, so a recordless dir carrying a **spool** is still refused — that spool is the only surviving copy of…
<!-- [doc->REQ-HAZARD-RING-PERCH-ADOPTION] -->

### 3.5 `is_perch_alive` is INVERTED on perch residue — a GC must never key on it
<!-- [doc->REQ-HAZARD-PERCH-GC-LIVENESS-INVERSION] -->
<!-- [doc->REQ-PERCH-GC-RESIDUE-PREDICATE] -->
- **Failure:** the obvious way to write a perch garbage collector is "reap what is not alive" — `!spt_store::liveness::is_perch_alive(dir)`. On the population a GC actually walks, that predicate is **backwards**. `is_perch_alive` returns **true** for a directory with no `info.json` (`RawRead::Absent ⇒ true`, interim parity — an absent record means a wrapper-owned listener between polls), and **false** for a daemon-hosted endpoint whose `status` is `offline`. Measured on HFENDULEAM 2026-08-04 across 38 top-level perch dirs: **all 24 recordless residue directories read ALIVE, and all 6 offline REAL endpoints read DEAD.** A sweep keyed on `!is_perch_alive` would have spared every stray probe directory and deleted six resting agents' records — it does not under-…
- **Why it is a hazard and not a bug in the resolver:** `is_perch_alive` is correct for its own job. Fail-toward-alive is the safe direction for DELIVERY (2.5) — the cost of a false "alive" is one message taking the spool path. It is the wrong direction for DELETION, where a false "not alive" is irreversible. The inversion is what happens when a predicate is reused across a polarity boundary it was never sized for.
- **Invariant:** perch garbage collection classifies on record **presence** only (3.5's predicate: no `info.json` present on any read attempt ⇒ residue; NotFound kept distinct from every other I/O error, because absent is the only answer that authorizes deletion) and **never calls a liveness resolver** — not `is_perch_alive`, not `is_registry_entry_alive`, not a pid probe. The registry is likewise never asked: `clean_stale_entries` deletes dead-pid rows and `unregister_address` fires on an ordinary stop, so registry-absence is the normal steady state of every offline endpoint (4.3). The unit pinning this asserts **both** arms with the shipped resolver as the witness: residue reads alive and is reaped anyway; an offline endpoint reads dead and is kept anyway.…
- **spt-core mapping:** `spt_store::perchgc::{sweep, PerchClass}`; `spt endpoint gc` renders it. The refusal/report contract is `REQ-PERCH-GC-REFUSAL-REPORT`; the occupancy rules it inherits are 3.4.
- **Source:** measured by todlando on HFENDULEAM 2026-08-04 while answering doyle's step-1 predicate question on releases#109; ruled into the lane by doyle the same day.

---

## 4. Wire / transport

### 4.1 Envelope HTML-entity codec ordering — `&amp;` decoded LAST
- **Failure:** decoding `&amp;`-entity before the others double-decodes nested entities (`&amp;amp;lt;` → wrong result).
- **Invariant:** ENCODE order amp→first … `<br>`→last; DECODE order `<br>`→first … amp→**last** (`&lt;`,`&gt;`,`&quot;`, then `&amp;`). One sole decode site (at the LLM/stdin boundary); the parser never decodes.
- **spt-core mapping:** `spt-proto` owns the envelope grammar (public SDK, semver + wire-version). This codec contract is a copy-verbatim commodity item (ADR-0001) and a public-API conformance test.
- **Sister cite:** `src/owl/poll.rs:1-73`; `src/common/envelope.rs`.
- **CR-linesafety `[REQ-HAZARD-ENVELOPE-CR-LINESAFE]`:** the EVENT is LINE-FRAMED, so the codec must neutralize raw `\r` too — `event_body_escape` folds CRLF/lone-CR to `\n` (→`<br>`) **before** framing. **Failure (field, 2026-06-08):** a cross-node `spt send` from Windows (`echo` → CRLF) carried a raw `\r` into the single-line envelope; the receiver terminal did a CR→column-0 overwrite (`</EVENT>` clobbered `<EVENT t`). `\r` was never line-representable here, so normalizing it is robustness, not an ADR-0001 wire divergence (decoder + amp-last untouched). Belt-and-suspenders: `spt send`/`ring` trim stdin like `notify`.

### 4.2 Two-slice envelope parser is panic-free and tolerant
- **Failure:** malformed envelope (unclosed/misordered/nested tags) panics or drops output.
- **Invariant:** tags case-sensitive, all optional; no tags → whole body to live slot; unclosed → None for that tag; out-of-order → both still extracted; nested unknown tags preserved verbatim; zero `unwrap` on parsed text.
- **spt-core mapping:** `spt-proto` parser; property-test the robustness rules.
- **Sister cite:** `src/common/envelope.rs:64-92`; tests 99-207.

### 4.3 Registry stale-entry cleanup precedes lookup
- **Failure:** sender resolves a dead process's stale TCP port → delivery to wrong/dead listener.
- **Invariant:** clean stale entries (dead PID) before/at lookup; spool fallback is the safe path on TCP miss.
- **spt-core mapping:** now spans the **subnet registry** (ADR-0003) — eventually-consistent across nodes. Cross-node staleness is expected; resolution policy (local → most-recent → `id@node`) must degrade to spool/relay fallback on stale hits, and never hard-fail on a stale remote entry.
- **Sister cite:** `src/common/registry.rs:62-78`; `src/owl/send.rs`.

### 4.4 Deferred rows survive poll drain
- **Failure:** poll `drain_all` flushes a deferred (spool-only) message meant for a hook consumer → message lost.
- **Invariant:** deferred rows are never flushed by the event-stream drain; only `drain_non_deferred_*` / `peek_all` touch them.
- **Sister cite:** CHANGELOG v1.11.20; `src/common/spool.rs`. (See also 1.4.)

### 4.5 Inbox legacy compat must not double-deliver
- **Failure:** message surfaced via both spool (durable) and legacy inbox files → duplicate or racing delivery.
- **Invariant:** spool is the sole read path at poll time; inbox is write-for-compat only and never read.
- **spt-core mapping:** clean-room — likely drop the legacy inbox entirely. If kept for any compat, preserve "never read at drain time."
- **Sister cite:** `src/common/inbox.rs`.

### 4.6 Addressable-id charset reserves the address delimiters
<!-- [doc->REQ-HAZARD-ID-CHARSET] -->
- **Failure:** a bare endpoint id that contains `:` or `@` (or a path separator / whitespace / control char) makes the canonical qualified address `[subnet:]id[@node]` (ADR-0006 / REQ-INST-10) ambiguous to parse, and lets a name smuggle into a perch directory path. Once permissive ids exist in the wild, tightening later needs a migration.
- **Invariant:** every addressable id/name is validated to `[A-Za-z0-9_-]` + Hiragana/Katakana/CJK only, length `1..=64`, **at every creation seam** (`ready` start, `api bind`, `api listen`, `api worker-start`). `:` and `@` are permanently reserved as address delimiters; reads of existing perches are never re-validated. Enforce now (pre-M3/M4) so no permissive id-data accumulates.
- **spt-core mapping:** `spt_proto::id::validate_endpoint_id`; called at the four creation seams. The existing Psyche (`<parent>-psyche`) / Worker (`<parent>-w<N>`) suffix scheme uses only `-` + alphanumerics, so composite ids validate.

### 4.7 Concurrent SQLite openers must not fail with "database is locked"
<!-- [doc->REQ-HAZARD-REGISTRY-CONCURRENT] -->
- **Failure:** two endpoints on one machine open the same SQLite store at once (e.g. two `ReadyAgent::start` calls registering simultaneously) and one fails outright with `SQLITE_BUSY` / "database is locked" → spurious registration/spool failure. Surfaced as a parallel-test flake in `two_agents_exchange_message_tcp_and_spool`, but the bug is real concurrency, not test-only.
- **Invariant:** `busy_timeout` is set **before** any lock-taking statement on every connection. Switching `journal_mode=WAL` takes a brief exclusive lock; with the default 0ms timeout it fails immediately under contention, so the pragma order is load-bearing: `Connection::open` → `busy_timeout` → `journal_mode=WAL` → `CREATE TABLE …`. WAL alone is insufficient (concurrent *writers* still serialize; they must *wait*, not error).
- **spt-core mapping:** `spt_store::registry::open_registry` + `spt_store::spool::open_spool_at`; both set `busy_timeout=5000` first. Any future SQLite store (history Path B, instance registry) must follow the same ordering.

### 4.8 Registry merge ordered by epoch, never wall-clock (red-team #8)
<!-- [doc->REQ-HAZARD-REGISTRY-EPOCH-LEASE] -->
- **Failure:** the per-subnet registry replicates `endpoint_id → [instances]` eventually-consistently across nodes. Under a partition or clock skew, a lagging node re-announces a stale `Active` for an endpoint that has actually gone `Offline`. If the merge ordered updates by wall-clock (or "last write wins"), the stale `Active` overwrites the newer `Offline` and resolution routes a message to a dead/wrong instance.
- **Invariant:** the merge precedence key is a **per-node monotonic epoch counter** (`spt_store::epoch::EpochSource`, persisted, strictly increasing, NEVER wall-clock), compared version-vector style per `(endpoint_id, node)`: an incoming update wins **iff its epoch is strictly greater** than the stored one for that node; equal or lower is dropped as stale. So a newer `Offline` (higher epoch) can never be clobbered by a lagging `Active` (lower epoch), and an idempotent equal-epoch replay is a no-op. Wall-clock is at most a human tiebreaker hint inside a flagged conflict, never the ordering authority. The same epoch source unifies with the D6 sync-precedence concurrent-write detection (#7).
- **spt-core mapping:** `spt_net::net::registry::SubnetRegistry::merge_instance` (the lease) + `spt_store::epoch::EpochSource` (the counter). Cross-node replication of the merge wires at D4; the merge seam is identical for local and wire-delivered updates. Chaos/two-host verification = D9.

### 4.9 SQLite stores must create their parent dir — SQLite won't
<!-- [doc->REQ-HAZARD-REGISTRY-DIR-CREATE] -->
- **Failure:** `Connection::open` creates the database FILE but never its parent DIRECTORY. On a fresh home (first boot, fresh CI `_work` dir) a registry op that runs before any perch-creating op (`create_dir_all` side effects) fails `SQLITE_CANTOPEN` — "unable to open database file …owlery\.registry". Timing-dependent: whichever code path touches the home first decides the outcome, so it surfaces as a parallel-test flake (bind-first tests losing the dir-creation race to perch-first tests). Bit the hfenduleam CI leg twice (2026-06-03/04, four spt-msg unit tests at once on the second strike) before being run to ground; a slow runner filesystem (AV scanning fresh dirs) widens the window but is not the cause.
- **Invariant:** every SQLite store's open path `create_dir_all`s its parent dir itself, best-effort, before `Connection::open` — never relying on another subsystem having materialized the home first. (Mirrors the spool, which always did this; the registry didn't.)
- **spt-core mapping:** `spt_store::registry::open_registry` (`create_dir_all(owlery)` before open). `spt_store::spool::open_spool_at` already creates its perch dir. Any future SQLite store must do the same — pair this with the 4.7 pragma ordering on every new store.

### 4.10 Dead node identities leave immortal registry rows  `[REQ-HAZARD-REGISTRY-GHOST-ROWS]`
<!-- [doc->REQ-HAZARD-REGISTRY-GHOST-ROWS] -->
- **Failure:** the registry's only superseding mechanism is the per-`(endpoint_id, node)` epoch lease (4.8) — a row is replaced only by a newer row *from the same node*. When a node identity dies permanently (machine retired, or `node.key` regenerated so the "node" never speaks again), its rows are never superseded and never expire: they sit in the in-memory registries and the `identity/registry/<subnet>.json` snapshots forever. A bare-id send then resolves the same endpoint id on both the live and the dead identity and refuses with a **phantom `AcrossNodes` ambiguity** — unfixable by the user, because no qualifier reaches a node that no longer exists. Hit live in the M7 acceptance run (2026-06-06): gravity paired under two identities (09ef…, then 03854a… af…
- **Invariant:** registry rows authored by a **silent** peer node decay: a node not *heard* (admitted inbound feed — the M7 D2 heard-map, REQ-SUBNET-1) within the eviction window (`registry_evict_after_ms`, default 300s ≈ 10 default pump cadences) has its rows **evicted** from every subnet registry, snapshots rewritten. Own rows never decay (the node always hears itself implicitly — it authors them each pump tick). Eviction is safe under the lease: v1 has **no transitive gossip**, so any future update for a node comes from that node itself, alive, re-inserting from its durable `EpochSource` within one cadence — there is no lagging third-party replay to mis-order against. A merely-offline node loses its rows after the window and reconverges on return; meanwhi…
- **spt-core mapping:** `spt_net::net::registry::SubnetRegistry::evict_nodes` (model) + `spt_daemon::registryhost::RegistryHost::evict_silent_peers` (heard-map TTL) driven from the registry pump tick (`peerloop`). Trust rows are NOT auto-evicted (trust is a user decision; a stale trust row only costs dead dials) — pruning those is a separate verb.
- **Source:** M7 acceptance run 2026-06-06 (DEFERRED.md "Ghost registry row eviction"); the AMBIGUOUS render fix rode along.
- **Mesh note (ADR-0017, 2026-06-08):** the subnet mesh **preserves** this invariant rather than superseding it. "No transitive gossip" sharpens to **no transitive *row* gossip** — the mesh relays only the member *roster* (discovery), while registry **rows stay own-authored and are fetched directly** from each member over a handshake. So "any future update for a node comes from that node itself, alive" still holds and the eviction lease is untouched. (The plan's rejected alternative — signed transitive *row* relay — would have broken this; roster-only relay was chosen precisely to keep it.)

### 4.11 Advertisement-epoch reset strands a node  `[REQ-HAZARD-EPOCH-RESET]`
<!-- [doc->REQ-HAZARD-EPOCH-RESET] -->
- **Failure:** a node whose advertisement-epoch counter resets (the durable `EpochSource` file lost/recreated) re-advertises with LOW epochs; peers hold a higher last-seen epoch for that `(endpoint, node)` lease and drop every fresh row as **stale** — the node advertises into a void until its counter outruns its own history. Nothing renders the cause: the node looks healthy locally, peers simply never update.
- **Invariant (mitigation by construction, common case):** the common trigger — a full reinstall / identity regeneration — is covered by the **re-pair trust overwrite** (M8 decision 13, REQ-SUBNET-7): a completed ceremony presenting the same label + machine id evicts the superseded identity's trust AND registry rows on the seed-holder, and the peer-side epoch memory **dies with the deleted row** — the re-paired node's fresh epochs land on a clean lease. M8 acceptance 7 verifies this explicitly (the epoch sub-check).
- **Residual (documented, guard deferred):** the narrow slice — epoch file lost while the node *identity* is kept (manual state surgery, partial restore from backup) — has no guard; it waits for a field hit before one is designed (M8 decision 24). `REQ-HAZARD-EPOCH-RESET` is minted inactive (TRACEABILITY rule 5) as the tracking hook. If hit: symptoms are one node's endpoints frozen-stale on every peer while its own views are fresh; recovery today is re-pairing the node (rides the common-case eviction above).
- **spt-core mapping:** epoch mint = `spt_store::epoch::EpochSource` (`identity/epoch.json`); the lease = the per-`(endpoint, node)` epoch compare in `spt_net::net::registry`; the eviction that clears peer-side epoch memory = `registryhost::repair_evict_superseded` + `RegistryHost::consume_repair_evictions`.
- **Source:** minted at M8 ratification (decision 24), recognized as a class during the 2026-06-07 pump diagnosis / re-pair overwrite design — not yet field-hit in its residual form.

---

## 5. Platform-specific

### 5.1 Windows PID recycling false positives
- **Failure:** recycled PID reads alive for the wrong process → orphan misclassification.
- **Invariant:** anchor liveness on the stable parent/harness PID; minimal info.json for supervisor-owned perches; mtime grace window (≥60s) masks transient mismatches.
- **spt-core mapping:** broker-held handles supersede PID polling for spt-hosted sessions; keep the grace window for harness-hosted.
- **Sister cite:** `src/live/wrapper/orphan.rs:141-161`; `src/common/list_filter.rs:168-175`.

### 5.2 Windows EBUSY on atomic rename
- **Failure:** `fs::rename` fails while a handle is (recently) held → registry/marketplace update fails.
- **Invariant:** tmp-write + atomic-rename with retry/backoff; best-effort side-fail; tolerate transient EBUSY.
- **spt-core mapping:** all on-disk state writes (registry, trust store, spool checkpoints) use this pattern. Self-update binary swap on Windows especially.
- **Sister cite:** CHANGELOG "EBUSY"; `src/common/owlery.rs` atomic_write.

### 5.3 Git/subprocess timeout stamping
- **Failure:** a hung subprocess (git on slow net) blocks the supervisor indefinitely.
- **Invariant:** every metadata-producing subprocess has a timeout; timeout yields `None` + rate-limited stderr, never a hang.
- **spt-core mapping:** generalize to all manifest-declared harness invocations (delegated commands, adapter updates) — timeouts mandatory.
- **Sister cite:** `src/common/git.rs`.

### 5.4 Windows UNC prefix in serialized paths
- **Failure:** canonicalized `\\?\C:\...` serializes to `//?/C:/...` and fails `read_to_string`.
- **Invariant:** strip the `\\?\` UNC prefix after backslash→forward-slash conversion; serialized path attrs must be directly consumable.
- **spt-core mapping:** any path crossing the wire (file-drop EVENTs, off-node file transfer per ADR-0003) needs canonical normalization at the `spt-proto` boundary.
- **Sister cite:** `src/common/owlery.rs:377-384`.

### 5.5 ConPTY withholds output until DSR is answered  `[REQ-HAZARD-CONPTY-DSR]`
- **Failure:** a broker reading a ConPTY master sees only the 4-byte startup query `ESC [ 6 n` and then nothing — the child looks hung/silent but is producing output normally. ConPTY blocks all child stdout until the terminal answers the cursor-position query.
- **Invariant:** every ConPTY reader auto-answers DSR (`ESC [ 6 n` → write `ESC [ 1;1 R`, or a real cursor position) on the PTY writer. Secondary: a ConPTY master does not EOF while the writer is held, so read loops drain on a thread and never gate exit on a blocking `read()`.
- **spt-core mapping:** `spt-term` broker PTY reader (ADR-0004). Brand-new to spt-core — not in the sister project (it never hosted ConPTY directly).
- **Source:** Spike #1 (`docs/spikes/SPIKE-01-broker-handoff.md`); reproduced with both a Rust child and `cmd.exe`.

<!-- [doc->REQ-HAZARD-DETACHED-PIPE-INHERIT] -->
### 5.6 Windows detached children inherit a captured caller's pipe  `[REQ-HAZARD-DETACHED-PIPE-INHERIT]`
- **Failure:** a caller captures an `spt` invocation's output through a pipe (`Command::output()`, a harness hook reading the command). That `spt` process detach-spawns a **long-lived** child (the daemon via `ensure_running`; a shell binary via `spt shell spawn`). On Windows `CreateProcess` runs with `bInheritHandles = TRUE`, and the spt process's std handles — the caller's pipe write-ends — are inheritable by construction, so the immortal child inherits them even when its *own* stdio is `Stdio::null()`. The caller's pipe read never sees EOF: the capturing caller **hangs forever** (unix is immune — pipe fds are `CLOEXEC`). Paid twice: daemon spawn (guarded at D4a-era `spawn_detached`), then again at M5-D3e when the mock-shell E2E hung `spt shell spawn` for h…
- **Invariant:** every detach-spawn of a long-lived child inherits only handles it **enumerates**. The default and overwhelmingly common form of that is `bInheritHandles = FALSE` (`spt-daemon::daemon::detached_no_inherit`) — zero handles flow, whatever the pipe's depth in the ancestry. Stripping `HANDLE_FLAG_INHERIT` from the spawner's *std* handles is NOT sufficient: a grandparent capture's pipe sits in the handle table as a stray inheritable handle and still flows through every **bare** `bInheritHandles = TRUE` hop (the first guard shipped that way and was wedged by exactly this — a daemon spawned three layers deep held the pwsh-level pipe of the CI/test harness).
- **The one permitted TRUE (amended 2026-07-26, RESIDENT-SERVICE W1):** a spawn that must capture the child's output may pass `bInheritHandles = TRUE` **only** paired with an explicit `STARTUPINFOEX` + `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` allowlist — the Win32 contract honors the list only under TRUE, so the earlier phrasing ("flipping TRUE is refused") forbade its own mechanism and was unimplementable. The hazard was always *unenumerated* inheritance, and the list **is** the enumeration: only the named handles flow, so a grandparent's pipe cannot reach the child at any depth — strictly stronger than what FALSE achieves incidentally. A **bare** TRUE, with no attribute list, remains REFUSED. Today the only such path is a supervised `[service]` spawn with start…
- **spt-core mapping:** `spt-daemon::daemon::spawn_detached` (the daemon) and `spt-daemon::shellhost::launch_shell` (the relay-receipt shell binary). Any future long-lived detached spawn (manifest-template children included) must use the same no-inherit spawn, or the enumerated form above where it genuinely must capture output.
- **Source:** spt-core, M5-D3e (`shell_e2e.rs` hang, 2026-06-04, twice — once per guard generation); Rust `Command` restricts *its own* created stdio handles but a parent's inheritable handle table still flows.

### 5.7 Elevated commands spawn the daemon with the wrong token  `[REQ-HAZARD-ELEVATED-DAEMON-SPAWN]`
<!-- [doc->REQ-HAZARD-ELEVATED-DAEMON-SPAWN] -->
- **Failure:** membership-implies-reachability made *every* `spt` invocation a potential daemon spawner (`ensure_running`), including the elevation-gated ones (`subnet create`/`join`, REQ-SUBNET-4). The spawned daemon inherits the spawner's token. **Windows:** an elevated `subnet create` auto-starts an ELEVATED daemon whose named pipes deny unelevated clients — every subsequent unelevated `spt` reads "not running", tries to spawn its own daemon, and dies on bind Access-denied; the user had to taskkill (hit live, M7 acceptance 2026-06-06). **Linux:** a sudo'd command spawns a root daemon and/or root-owned state — and because sudo flips `$HOME`, the daemon can mint a *different node identity* in root's universe (the very key-flip that produced the 4.10 ghost r…
- **Invariant:** the daemon **always runs unelevated in the invoking user's universe**, regardless of which command spawns it. Two enforcement points sharing one seam: (a) `spawn_detached` de-elevates the child — Windows: the UAC **linked token** (`TokenLinkedToken` → `DuplicateTokenEx` → `CreateProcessWithTokenW`; inherits no handles, so 5.6 holds by construction); Linux: drop to `SUDO_UID`/`SUDO_GID` with `$HOME`/`$USER`/`$LOGNAME` reset to the invoking user's (passwd lookup); (b) a `Daemon::run` entry guard catches a *directly* elevated `spt daemon` — Linux drops privileges in-process before touching any state; Windows respawns de-elevated and exits. When no unelevated identity exists to drop to (UAC disabled, genuine root login, SYSTEM), the daemon runs …
- **spt-core mapping:** `spt-daemon::deelevate` (the OS-split seam) consumed by `daemon::spawn_detached` + the `Daemon::run` entry guard. The fuller Linux elevation model (install symlink + default-account election) is deferred (DEFERRED.md, M8).
- **Source:** M7 acceptance run 2026-06-06 (DEFERRED.md "Non-admin daemon spawn"); interim field rule was "bring the daemon up unelevated FIRST".

<!-- [doc->REQ-HAZARD-CHILD-CONSOLE-FLASH] -->
### 5.8 Console children of the console-less daemon flash visible windows  `[REQ-HAZARD-CHILD-CONSOLE-FLASH]`
- **Failure:** the daemon runs DETACHED (no console, 5.6/`detached_no_inherit`). Any console-subsystem child it spawns (`git`, `taskkill`, manifest hook commands) gets a **fresh conhost with a visible window** — piped/null stdio does NOT prevent it. Field shape: the 60s sync pump's two git calls (`for-each-ref` + `rev-parse`) flashed two blank windows per minute on the user's desktop (2026-06-06).
- **Invariant:** every short-lived console child spawned from daemon-reachable code sets `creation_flags(0x0800_0000)` (`CREATE_NO_WINDOW`). Long-lived detached children use `detached_no_inherit` (already `DETACHED_PROCESS | CREATE_NO_WINDOW`); de-elevated spawns use `CREATE_NEW_CONSOLE + SW_HIDE` (5.7 — `CreateProcessWithTokenW` rejects `CREATE_NO_WINDOW`, error 87).
- **Test seam caveat:** window-absence is unobservable from a consoled test runner — the child inherits the runner's console and never creates a window, flag or no flag. Unit coverage asserts the flagged spawn still works (the error-87 "flag combo breaks spawn" regression class); window-absence was verified live by process-watch capture.
- **spt-core mapping:** `spt-store::gitrun::run_git` (every BranchStore/ContextStore git call), `spt-daemon::shellhost::kill_shell_pid` (taskkill), `spt-runtime::run_bounded_command` (manifest hook commands), `spt-runtime::ManifestRuntime::command_for` (the one shared builder behind `spawn_session` + `run_bounded_stdin` — the notif pump's `spawn_notif_command` and the live agent's psyche/echo/turn spawns), `spt-daemon::shellwake` (already guarded). The flag lives in each shared builder, not per call site, so the invariant holds for every ManifestRuntime spawn by construction.
- **Source:** spt-core field bug, 2026-06-06 — two blank windows flashing every 60 seconds on a desktop workstation, caught by process-spawn watcher (git.exe parent=spt daemon, conhost.exe child each).

### 5.9 `Instant - Duration` underflow-panics on a freshly-booted host  `[REQ-HAZARD-INSTANT-UNDERFLOW]`
- **Failure:** `Instant::now() - Duration::from_secs(N)` panics `overflow when subtracting duration from instant` when the process's monotonic clock is younger than `N` — i.e. the host booted less than `N` ago. The peer pump primed its cadence legs with `Instant::now() - 86_400s` to mean "everything due now"; on a Windows runner with sub-24h uptime the pump thread panicked at startup, so the subnet never converged (CI `pump_and_dispatch_self_drive_the_subnet` failed, run 27082417706). It is *environment-conditional* — green on any host up longer than the offset, red below it — so it slips local dev and only bites a fresh CI box or a just-rebooted machine.
- **Invariant:** NEVER compute an instant in the past by subtracting from `Instant::now()`. Represent "never run / due now" as `Option<Instant> = None` and gate on forward `now.duration_since(past)` only (`peerloop::due`). No backward instant arithmetic anywhere in scheduling.
- **Test seam caveat:** the convergence E2E only reproduces on a sub-offset-uptime host (it passed everywhere with >24h uptime). The deterministic guard is the `due(None, ..)`/`due(Some(now), ..)` unit on the extracted gate — it asserts first-tick-due with zero instant subtraction, independent of host uptime.
- **spt-core mapping:** `spt-daemon::peerloop::due` (the sole cadence gate behind `due_reg`/`due_notif`/`due_sync`/`due_upd`); cadence legs are `Option<Instant>` seeded `None`.
- **Source:** spt-core CI failure, 2026-06-07 — Windows runner `hfenduleam` (just booted) panicked the peer pump at the v0.1.1 release gate.

### 5.10 `sudo spt` dead-ends on a user-local install (secure_path)  `[REQ-HAZARD-SUDO-SECURE-PATH]`
- **Failure:** the elevation-gated commands (`subnet create` / `subnet join` / `show-code`) refuse when unelevated and tell the user to "run as administrator / root". The user types the obvious `sudo spt subnet create FOO` → `sudo: spt: command not found`. `spt` is a user-local install (`~/.local/bin`, `~/.cargo/bin`), and sudo's `secure_path` (a `/etc/sudoers` default) does NOT include those dirs, so a bare command name doesn't resolve under sudo. The guidance is a trap: it names an action that cannot work for the common install shape. Field-hit on KITSUBITO at the v0.1.1 ship.
- **Invariant:** elevation guidance on Unix emits the binary's **absolute path** under sudo — `sudo /home/u/.local/bin/spt subnet create FOO` — reconstructed from `current_exe()` + the real argv and shell-quoted. An absolute program path is executed directly; `secure_path` only governs bare-name PATH lookup, so the absolute form always resolves. On an interactive Unix TTY the command auto-elevates (re-execs itself under sudo, the elevated child does the work and `main` de-elevates back); non-interactive or sudo-absent falls back to printing the runnable hint. Never emit a bare-name elevation instruction.
- **Companion UX:** the post-de-elevation `DEELEVATED: running as uid N` line is internal state-safety noise — omit it from the user-facing CLI path (it confused the same field user). The detached daemon's own de-elevation log line is fine (it lands in the daemon log, not the terminal).
- **Test seam caveat:** the sudo re-exec needs a real `sudo` + TTY (not hermetic). The deterministic guard is the pure `elevation::sudo_argv` / `print_hint_command` (assert an absolute exe path, never a bare name, + shell-quoting on the printed line) and the `decide_elevation_path` matrix (which picks inline-sudo only on an interactive Unix TTY); the exec leg is manual/kitsubito-verified.
- **spt-core mapping:** `spt::elevation::{sudo_argv, print_hint_command, decide_elevation_path}` (pure — generalized from the M12-W4 self-elevation seam, 5.11), `spt::cli::{try_auto_elevate, with_elevation_hint}` wired into `cmd_subnet_create` / `cmd_subnet_join` / `cmd_subnet_show_code`; `spt::main` de-elevation drop silenced.
- **Source:** spt-core field report, 2026-06-07 — `reavus@KITSUBITO`, `spt` in `~/.local/bin`; the absolute-path `sudo` invocation was confirmed working before the fix landed.

### 5.11 Self-elevating re-launch must re-run verbatim, never widen / inject / loop  `[REQ-HAZARD-SELF-ELEVATE]`
- **Failure class:** a privilege-gated command (`subnet create` / `join` / `show-code`) self-elevates by re-launching itself with privilege (Windows UAC `runas`, Linux `pkexec` / a terminal-emulator `sudo`, or inline `sudo`). A careless re-launch is a security hole: widening the privilege scope (adding args), resolving the binary by a bare name (a PATH/`secure_path` hijack runs an attacker's `spt`), interpolating a crafted arg into a shell string (`sh -c "… $id …"` injects a second command), or re-elevating the already-elevated child (an infinite UAC/polkit loop). The user's UAC/polkit/sudo prompt is the ONLY consent gate — the mechanism must never bypass or widen it.
- **Invariant:** self-elevation re-runs the **EXACT** original invocation with the binary's **ABSOLUTE** exe path — never adding/altering args, never a PATH-resolved bare name, never a shell-interpolated string. Every launcher passes an **argv array** (`Command::new(prog).args([...])`, never `sh -c`); the Windows `ShellExecuteW` params string (which is inherently one string) MSVC-quotes each verbatim arg so `CommandLineToArgvW` round-trips it as a single token. The elevated child drops state back to the user (composes with the 5.7 de-elevation) and **never re-elevates**: `decide_elevation_path` returns `AlreadyElevated` whenever the process is `Elevated`, on every OS (loop-safety). The unprivileged parent never pipes/captures the elevated child's stdout acro…
- **Test seam caveat:** the real launch needs a UAC/polkit/sudo prompt (not hermetic) — manual-verify. The deterministic guards are the pure `decide_elevation_path` matrix (loop-safety: `AlreadyElevated` on every os; the os×env path order) and the argv builders (`sudo_argv` / `pkexec_argv` / `terminal_argv` assert absolute-exe + verbatim args + array; `windows_runas_params` asserts MSVC-quoting with no `cmd /c` interpolation; the crafted-arg test asserts a shell-metachar arg stays one element / one quoted token).
- **spt-core mapping:** `spt::elevation::{decide_elevation_path, sudo_argv, pkexec_argv, terminal_argv, windows_runas_params, print_hint_command, ElevatePath}` (pure), `spt::cli::{try_auto_elevate, launch_uac_window, pause_elevated_console_if_fresh, program_on_path, first_terminal_emulator}` (impure launchers) wired into `cmd_subnet_create` / `cmd_subnet_join` / `cmd_subnet_show_code`. Companions: 5.10 (the Unix abs-path-under-sudo facet) and 5.7 (the elevated child's de-elevation drop, which this composes with).
- **Source:** M12-W4 design (subnet QR + self-elevating window), doyle ruling `M12-W4-RULING.md` Q6 — a privilege-escalation feature carries a mandatory hazard REQ.

<!-- [doc->REQ-HAZARD-WIN-PTY-PROGRAM-RESOLVE] -->
### 5.12 Native-PTY spawn of a bare program runs the wrong (non-PE) file on Windows  `[REQ-HAZARD-WIN-PTY-PROGRAM-RESOLVE]`
- **Failure:** `portable-pty`'s ConPTY spawn resolves a bare program name with a `which` that takes the FIRST `PATH` match. A node/npm CLI installs as BOTH an extensionless shebang shim (`ccs`, for Git Bash) and a Windows launcher (`ccs.cmd`) in the same dir; portable-pty picks the extensionless `ccs`, and `CreateProcessW` then tries to execute that non-PE file and fails with **os error 193** ("%1 is not a valid Win32 application"). Live failure: `spt endpoint run claude-spt:ccs` → `CreateProcessW C:\nvm4w\nodejs\ccs` 193 (operator, 2026-06-16). The same bites any harness/shell whose `[session.self]`/`[shell].spawn` names a `.cmd`/`.bat`/`.ps1`-backed command — `CreateProcessW` cannot execute a batch or PowerShell script directly.
- **Invariant:** spt-term resolves the program ITSELF before handing it to `CommandBuilder`, bypassing portable-pty's `which`. A bare name is searched over `PATH` × `PATHEXT` (whose default order already prefers `.EXE`/`.COM` over `.BAT`/`.CMD`), then an extensionless fallback. A non-PE target is wrapped in its interpreter: `.cmd`/`.bat` → `cmd.exe /d /c <path>`, `.ps1` → `powershell -NoProfile -File <path>` (the wrap args precede the caller's args); a real executable spawns directly; an unresolvable name passes through unchanged (never makes a working case worse). Unix is a passthrough — `execve` honours a shebang on an extensionless script. Applied at the ONE `CommandBuilder` chokepoint (`PtySession::spawn_program_in`), so every broker harness + shell spaw…
- **spt-core mapping:** `spt_term::winprog::{resolve_for_pty, resolve_in}` (the pure PATHEXT-precedence kernel + the Windows env wiring), wired into `spt_term::pty::PtySession::spawn_program_in`. Unit: `resolve_in` precedence (`.cmd`-over-shim, `.exe`-direct, explicit-extension, path-order, passthrough) [`winprog.rs`].
- **Source:** field diagnosis 2026-06-16 (operator dogfood, `claude-spt:ccs` bringup) — doyle.

<!-- [doc->REQ-HAZARD-PERCH-RECORD-POWER-LOSS] -->
### 5.13 Atomic write leaves data un-synced before the rename → NUL zero-fill on power loss  `[REQ-HAZARD-PERCH-RECORD-POWER-LOSS]`
- **Failure:** `atomic_write_bytes` was `fs::write(tmp)` + `rename(tmp, path)` with no `fsync`. The rename's directory **metadata** is journaled durable, but the tmp file's **data blocks** are still in the page cache. A hard reset (power loss, forced reboot) between the two flushes lands the rename but loses the data → the file reappears at its **full length filled with NUL**. Field incident: after a machine restart `owlery/hall-a/info.json` was 360 bytes of all-NUL (the nested psyche record 125 bytes all-NUL); `Get-Content` renders blank, `Format-Hex` shows the zero-fill. A wiped-but-present record then read as a live ONLINE endpoint for days (compounded by 5.14).
- **Invariant:** for records whose loss is **unrecoverable**, flush data to stable storage **before** the rename publishes the name — `File::create(tmp)` → `write_all` → `sync_all()` → `rename_with_retry`. Then a crash yields the complete old file OR the complete new file, never a NUL husk. (A file of correct length that is ALL-NUL is the diagnostic tell of this class — a non-fsync'd write caught by a hard reset.) **Durability is SCOPED, not blanket.** `fsync`ing *every* atomic write serializes a durable flush behind each of the ~26 `atomic_write` callers — and for `info.json` the flush lands under the per-perch `.info.lock` (the **W1b lock-across-fsync wedge shape**, §6-era) — which stalls daemon bringup 4–6× so the endpoint misses its ONLINE window. Scope …
- **spt-core mapping:** `spt_store::atomic::atomic_write_bytes_durable` / `atomic_write_string_durable` are the opt-in durable siblings; the default `atomic_write_bytes` / `atomic_write_string` stay non-durable. Durable callers are exactly `spt_store::info::write_info` (the perch record — the hall-a surface), `spt_store::nodeid` (the node seed — corrupt is node-bricking, never regenerated), and `spt_daemon::machineid` (minted-once machine id — a NUL husk would silently re-mint a different id). Reader-side handling of an already-corrupt record is the sibling 5.14. **Canary:** `attach_wedge_e2e` is the regression guard — a blanket fsync fails it (bringup misses the 20s ONLINE budget); keep durability scoped so it stays green.
…

<!-- [doc->REQ-HAZARD-CORRUPT-PERCH-COHERENCE] -->
…

…
<!-- [doc->REQ-HAZARD-EMPOWER-SESSION-RESURRECT] -->