- **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 **owner endpoint is online** (an offline owner is owed nothing — that is the line between healing a record and resurrecting a shell nobody is there to drive); the instance is down **in fact** (the derived read, made trustworthy across a pid reuse by 2.6's birth stamp); and its recorded launch **predates the boot instant**. No stamp ⇒ **not restored** (the sweep cannot prove it predates boot); no boot oracle on the platform ⇒ nothing restored at all. The boot instant is derived (Linux `btime`; Windows now − `GetTickCount64`), so a named slack constant absorbs derivation jitter and keeps a process launched moments after boot from reading as a corpse from the previous one. **The once-ness of the sweep is scheduling, not safety** (ruled releases#228): the predates-boot discriminant is what forbids relaunching a force-killed shell, and it is monotone within a machine boot — every launch restamps, a kill never does — so a second trigger evaluating the same conjuncts can only refuse more. The second trigger is an **edge**, not the level “owner is online”, which is what bounds a failing launch to one attempt per owner-online event instead of one per reconcile tick.
- **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 returned `TIMEOUT` with zero `Replied{…}` — it was available and did not happen.
- **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_EXISTS` (record/spool) or `RING_STALE_DIR` (empty; names the path and the manual remedy). Loudness — not a self-heal — is what keeps a permanent refusal from being a silent one. Structural, not advisory: the leaf dir is created with `create_dir` (fails `AlreadyExists`), so a perch appearing between probe and create still cannot be adopted, and the refusal path never calls cleanup.
- **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 whatever was queued, and deleting mail is not a GC's call at any confidence level. A pre-existing **empty** dir is still refused for the reason emphasys's amendment gave: the mid-create window exists for the GC identically, and no still-positive discriminator for "not mid-create" exists for an empty dir (there is no create-intent marker, and mtime is out by the GC's own predicate). What the GC *may* delete is therefore strictly narrower than what it may *call* residue, and both refusals are kept honest exactly as this section requires — **loudly**, per path, under distinct tokens (`RESIDUE-WITH-SPOOL` / `RESIDUE-EMPTY`) with the manual remedy named. A refusal also **shields the whole subtree under it** — a nested child that would qualify on its own is reported `SHIELDED-BY-REFUSED-PARENT` and left alone, because refusing a directory has to mean not modifying its CONTENTS either (carving a child out destroys part of what the refusal report asked the operator to look at, and can move the parent's own verdict on the next pass). The one discriminator the GC has and `ring` does not is the **held `.info.lock`**: a NON-empty recordless dir whose per-perch write sentinel is held right now is a bringup mid-create, provably, and is refused (`OCCUPIED-WRITE-IN-FLIGHT`). An empty dir gets no such signal, which is why its refusal stays unconditional.
<!-- [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-perform, it authorizes **exactly the inverse set**.
- **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… after its key universe flipped during the sudo experiments); the dead identity's `sergey` row made bare `spt send sergey` refuse on HFENDULEAM.
- **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; meanwhile resolution honestly reports it absent instead of poisoning bare-id sends.
- **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 hours.
- **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 startup capture (one-entry list, one file handle), and it is pinned behaviorally, not by flag-reading: `a_captured_spawn_inherits_only_the_handle_in_its_allowlist` plants a deliberately inheritable handle that is NOT in the list and fails if the child receives it.
- **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 rows).
- **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 as-is with a loud warning — a consistent universe, never a torn one. Elevated one-shot *clients* talking to an unelevated daemon are fine (downward connects work); the daemon side is the invariant.
- **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 across the privilege boundary — the child is self-contained (on Windows it self-pauses a fresh console via `GetConsoleProcessList` so its output stays legible). The print-hint floor prints the absolute-path command too.
- **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 spawn is covered. *Caveat:* the `cmd.exe /d /c` wrap inherits cmd's argument-quoting rules for paths/args containing spaces or cmd metacharacters — adequate for the common install-path case; a fully robust cmd-quoting pass is a follow-on if it bites.
- **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 the fsync to the record, not the writer: only authoritative per-perch records and one-shot identity material are durable; reconstructible state (registry snapshots, epoch counters, peer caches, the release cache) stays non-durable — a post-crash NUL husk parse-fails to *absent* and is regenerated / re-gossiped, and the 5.14 read-side already makes a corrupt record harmless at every seam.
- **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.
- **Source:** field diagnosis 2026-07-01 (operator, post-v0.19.0 restart) — counter-39 bug #2; blanket-fsync perf regression pinned + scoped by the gate rig (doyle), same date.

<!-- [doc->REQ-HAZARD-CORRUPT-PERCH-COHERENCE] -->
### 5.14 Corrupt info.json read as ABSENT → fail-open readers gossip a wiped perch ONLINE  `[REQ-HAZARD-CORRUPT-PERCH-COHERENCE]`
- **Failure:** three readers each collapsed a **corrupt** (present-but-unparseable) `info.json` into their fail-open ABSENT default, so a NUL-wiped perch (5.13) read as permanently live: `is_perch_alive` returned `true` (unreadable ⇒ interim-alive), `advertised_status` then saw alive + no resting record ⇒ `Active`, and the daemon self-gossiped that Active row every round (epoch 173k+). Result: `hall-a`, dead since a machine restart, showed ONLINE in `spt whoami` and on every remote picker.
- **Invariant:** CORRUPT ≠ ABSENT. A record that EXISTS but stays unparseable across the retry budget is a **destroyed** record — never a live endpoint. `is_perch_alive` reads corrupt ⇒ **not alive** (ABSENT keeps interim-alive parity — the ONE case that stays true); `advertised_status` then lands corrupt in the cold arm ⇒ **Suspended**, never Active/Dormant. Readers that decide liveness/status must branch on the tri-state (present / absent / corrupt), not an `Option` that fuses the last two.
- **spt-core mapping:** `spt_store::liveness::{read_raw_state, is_perch_alive}` (the tri-state kernel) and `spt_daemon::registryhost::advertised_status` (cascades to Suspended off the alive fix). `list_self_perch_ids` deliberately still lists a corrupt dir by existence — that is the *visibility* the local-roster fix (counter-39 #3) relies on, not a liveness claim. Candidate sibling seam `is_registry_entry_alive` (corrupt hosted row falls to a daemon-pid probe) is parked — it does not produce the ONLINE gossip.
- **Source:** field diagnosis 2026-07-01 (operator, post-v0.19.0 restart) — counter-39 bug #2; sequel to the v0.17.0 W4 presence-truth fix (cold ⇒ Suspended) which a corrupt perch bypassed via false-alive.

<!-- [doc->REQ-HAZARD-ATOMIC-TMP-COLLISION] -->
### 5.15 Fixed atomic-write tmp name → concurrent writers collide (loser renames a consumed file)  `[REQ-HAZARD-ATOMIC-TMP-COLLISION]`
- **Failure:** `atomic_write_bytes` staged every write under a **fixed** sibling `{name}.tmp`. Two processes writing the SAME target concurrently — traced at bind ~700µs apart: the daemon's `mutate_info` RMW and `spt api bind`'s `establish_perch` — both create the same `info.json.tmp`; whichever renames first CONSUMES it, and the loser's `fs::rename` hits `NotFound` (os error 2). `NotFound` is non-transient in `rename_with_retry`, so it surfaces as a hard write error: `establish_perch` returns `BindError::Io` *after* having written the record, mock-session's bind-is-fatal check `exit(1)`s, the harness dies, and the endpoint never reaches ONLINE. A microsecond window that existed forever (bare `fs::write` + rename back-to-back) — the 5.13 `sync_all` widened create→rename ~50× (to ~4ms) and made the collision near-certain *exactly at bind*, which is why the durable-perch-record change unmasked it (deterministic wedge, not a perf regression).
- **Invariant:** concurrent atomic writers to the same target must never share a tmp name. Stage a **unique** tmp per write — `{name}.tmp.{pid}-{seq}`, `seq` from a process-local static `AtomicU64` (no clock, no rand → resume/replay-safe) — on BOTH the durable and non-durable paths (the collision is generic to `atomic_write`, not the fsync). Keep `rename_with_retry` + best-effort tmp cleanup on rename error. The new names still lack a `.json` extension, so exact-name / `*.json` loaders stay blind to them.
- **spt-core mapping:** `spt_store::atomic::write_then_rename` (the shared core behind `atomic_write_bytes`/`_string` and the `_durable` siblings). Regression guard: a ≥4-thread `Barrier`-aligned hammer on one target (`concurrent_writers_never_collide_on_tmp`) — reds on the fixed-tmp code, greens on unique-tmp; a hammer that can't red is decorative. Cross-ref 5.13: the fsync widened the window, the fixed tmp name was the defect.
- **Source:** RCA 2026-07-01 (doyle gate rig) — write-trace side-channel + `cmd_bind` step probes pinned `PROBE cmd_bind establish ERR: Io(os error 2)`; explains the counter-39 gate-1 blanket failure, the dummy_harness ONLINE-race DIAGs, and the nondeterminism. Lesson: a widened timing window doesn't create a race, it reveals one.

<!-- [doc->REQ-HAZARD-INFO-RMW-LOST-UPDATE] -->
### 5.16 Unlocked whole-record info.json write races a locked RMW → silent lost update  `[REQ-HAZARD-INFO-RMW-LOST-UPDATE]`
- **Failure:** `mutate_info` serializes its read→mutate→write under the per-perch `.info.lock` sentinel, but `establish_perch` (`spt::api::startup`) did read→conflict-check→`write_info` with **no lock**. At bind the two writers race (~700µs apart): the daemon RMW reads the PRE-BIND record, bind's `write_info` renames the full record in (`state=live_agent`, `controllable=Some(true)`, `session_id`), then the RMW writes its STALE pre-bind snapshot back plus a `status=online` stamp. The surviving `info.json` is a **pre-bind shape carrying `status=online`** — bringup passes (it sees ONLINE), the broker serves the session, then on child death `reconcile_hosted_liveness` reads `state != "live_agent"` (or `controllable != Some(true)`) and *silently `continue`s* — the dead endpoint is latched ONLINE forever. This resurrects counter-39 #2's dead-but-ONLINE field surface through a brand-new path. Once-loud, now-silent: 5.15's unique tmp turned the previously-`os-error-2` collision into a clean last-writer-wins, and the 5.13 durable fsync (inside the RMW read→write window) made the interleave near-certain on a normal-speed disk. The race predates all of this (v0.19.0-era) — the old fixed tmp made it fail LOUD and non-durable kept the window µs-narrow.
- **Invariant:** every `info.json` writer serializes under the one per-perch `.info.lock`. A whole-record write (`write_info`) takes the lock exactly as the RMW (`mutate_info`) does — a unique tmp only makes concurrent writes *last-writer-wins*, which is safe ONLY if the writers are serialized (cross-ref 5.15). A multi-step read→check→write (bind's establish) must hold ONE lock acquisition across all three (a true compare-and-set) — check-then-write with the lock dropped in between still interleaves a stamp. Readers stay lock-free (the atomic rename already gives a complete old-or-new record). Holding `.info.lock` across the durable fsync is ms-scale on these infrequent record writes (the same thing every `mutate_info` already does), NOT the per-keystroke W1b lock-across-fsync shape.
- **spt-core mapping:** `spt_store::info` — `write_info` now acquires the sentinel then calls a private `write_info_unlocked` (and `mutate_info` — the public RMW primitive — calls the unlocked writer while holding its own lock, so no double-lock deadlock); `info::establish_locked(perch, build)` runs the bind's read→check→build→write as one locked CAS, called by `spt::api::startup::establish_perch`. The other read-modify-write callers were audited and the two that mutate a load-bearing field were converted to the `mutate_info` CAS: `spt::api::reporting::cmd_boundary` (rc-rebind session-id rotation) and `spt_store::home::adopt_for_unset` (home-subnet adoption; the `is_none()` re-check runs inside the closure). **Deliberately parked (known unlocked-RMW residue, honest coverage):** `spt_msg::listener::write_busy` (BUSY-pid stamp — self-healing: re-marked next delivery, recency next tick) and `spt_store::rename::rewrite_id` (rare operator rename ripple) still do a raw read→write; a lost update there self-heals or is operationally rare, so they are left as follow-ups. `spt_msg::ready::start_homed` (harness-hosted `ready`/`listen` bind) is the same read→build→write *shape* as `establish_perch` but is a single-writer context (the daemon does not host/stamp a harness-hosted ready agent concurrently at its own bind), so it is left as-is — revisit if a daemon-hosted path ever stamps it. Regression guard: `write_info_racing_mutate_info_never_lost_update` — a `Barrier`-aligned RMW-stamp vs full-bind-record write hammer on one perch asserting the full record's fields never vanish under a racing stamp (reds on the unlocked-write code, greens after the lock).
- **Source:** RCA 2026-07-01 (doyle gate rig) — differential (baseline PASS / 77beeac+baseline-atomic PASS / fe385f5 2/3 FAIL) isolated the atomic rework as the delta; `reconcile_hosted_liveness` emitting ZERO `LIVENESS_RECONCILE_OFFLINE` lines for the dead victim over 20s pinned the silent-skip. The unique-tmp fix worked (bringup + serve fine) but exposed this deeper pairing.

### 5.17 `powershell` never executes under our detached spawn — a silent no-op that costs every spawn-rig builder the same three runs
- **Failure:** a test rig that spawns `powershell -Command …` through `daemon::detached_no_inherit*` (`DETACHED_PROCESS`, no console, no inherited handles) gets a process that **starts and exits having run nothing** — not even a bare `Set-Content`. No error, no output, exit status unremarkable: the rig reads as "my code did not do the thing" when in fact the *fixture* never ran. Measured 2026-07-26 while building the `[service]` supervision gates: three consecutive runs burned on a fixture that could not have worked, each one re-diagnosed as a supervisor bug. `cmd /c` and `sh -c` run fine under the identical spawn, so the no-op is specific to the shell, not to the detachment.
- **Invariant (rig discipline, not product code):** spawn-based fixtures use `cmd` on Windows and `sh` on unix — never `powershell`. And find a spawned process's descendants **by parentage** via `spt_store::proc::process_table()` (which returns `(pid, ppid)`), never by asking the child to report them: a grandchild that must cooperate to be found is a rig that cannot observe an *uncooperative* one, which is the case the gate exists for.
- **spt-core mapping:** every spawn rig under `crates/spt-daemon` — `servicehost.rs` (`long_running`/`instant_exit`/`noisy_instant_exit` fixtures, the tree-teardown gate) and any future rig over `daemon::detached_no_inherit_env`. No `REQ-HAZARD-*` id and no regression test **deliberately**: the invariant constrains how tests are written, and the only assertion available would be "the OS still refuses to run powershell this way" — an instrument test, which is never requirement evidence. It is numbered here because the cost is paid by whoever writes the *next* rig, and a fact that reads as a bug in your own code is exactly what this document exists to intercept.
- **Source:** RESIDENT-SERVICE W1 leg D (todlando, 2026-07-26); ruled into this document by doyle the same day.
- **Amendment 2026-08-02 (hertz, releases#110 measurement):** the cause is `DETACHED_PROCESS` **alone** — not the no-inherit half, as the entry's parenthetical implied. Measured on HFENDULEAM with raw `creationflags` and fully piped stdio: `powershell -Command "Set-Content …"` under `DETACHED_PROCESS` exits **rc=0 having written no file** (it does not execute); under `CREATE_NO_WINDOW` the identical spawn writes the file and returns its stdout. `cmd /c` behaves DIFFERENTLY from powershell and the entry's "runs fine" is half-right: `cmd /c echo ran> file` under `DETACHED_PROCESS` **does execute** (file written, rc=0), but a `cmd /c ping` whose stdout is a pipe returns **zero bytes** — cmd runs and its piped stdout is lost. So the rig rule stands, with the sharper statement of it: under `DETACHED_PROCESS`, powershell does not run at all and cmd's **captured output** cannot be trusted. A rig that parses a detached shell's stdout is reading a value that no longer exists — see 5.18 for the concrete false-green this shape would have manufactured.

<!-- [doc->REQ-HAZARD-TREE-CWD-PIN] -->
### 5.18 A surviving test descendant cwd-pins the tree it ran in — the `.worktrees` skeleton that will not delete  `[REQ-HAZARD-TREE-CWD-PIN]`
- **Failure:** a directory that is some live process's **current directory** cannot be renamed or deleted on Windows, while files *inside* it stay writable. That asymmetry is why the symptom never reads as a file lock: `git worktree remove` fails naming the **directory**, `openfiles` shows nothing, and the tree looks idle. cargo runs each test binary with cwd = the **package root**, so every child a test spawns inherits `<worktree>/crates/<pkg>` as its own cwd — and any child (or **grandchild**) that outlives the test pins that path forever. Result: a silent `.worktrees` skeleton accumulator, each one costing the next agent an afternoon.
- **Mechanism — the common case is a surviving descendant, and it is not the `CREATE_NO_WINDOW` conhost.** releases#110 filed the holder as an *orphaned `conhost.exe` holding the crate root*; measurement on HFENDULEAM 2026-08-02 **refutes** that for the population the rigs mint. `CREATE_NO_WINDOW` does produce exactly **one conhost per console child** (counted by `ParentProcessId`), but that conhost's cwd is **not** inherited: it is launched with a system cwd. Census: **43 readable, all of them `C:\WINDOWS\` or `C:\WINDOWS\system32\`, zero under any worktree** (4 unopenable, stated). Held directly: spawning a child with cwd = the package root and then force-killing it leaves the orphan conhost still reading `C:\WINDOWS\`. Positive control on a scratch dir — write-inside **OK**, delete **BLOCKED**, kill the **conhost** → still BLOCKED, kill the actual holder → **deletes first try**. The holder in that control turned out to be an orphaned **grandchild** (`PING.exe`, cwd inherited through `cmd`) that survived its parent's kill. **That conhost is a byproduct, never the pin; killing it is not the cure and "no conhosts" is not the all-clear.**
- **Amendment 2026-08-02 — the exception, field-refuted before it shipped: a `--headless` ConPTY conhost DOES inherit its creator's cwd, and pins it alone.** The entry previously generalized the census above into "a conhost's cwd is never inherited". That is true of the `CREATE_NO_WINDOW` population and false of the ConPTY one, which no rig mints but every terminal-hosting path does.
  - **Field specimen (BAROMETER W4 teardown):** `pid 46556 conhost.exe --headless --width 80 --height 24 --signal 0x214 --server 0x20c`, born during the build, parent dead, PEB cwd `…\.worktrees\barometer-w4\crates\spt\` — the **sole** holder. Killing it deleted the tree that had refused to delete.
  - **Reproduced deliberately, both launch paths.** Minted through `CreatePseudoConsole` from a process whose cwd was a scratch dir: the new `--headless` conhost's PEB reads **that dir**, and with it alive — write-inside **OK**, delete **BLOCKED**, then **DELETED** after `ClosePseudoConsole`, nothing else changed. Launched as `conhost.exe --headless` directly with a working directory: same inheritance. The first covers the path the wild specimen took; the second shows the binary takes its launcher's cwd in that mode regardless.
  - **The pty-teardown alternative is excluded causally, not by absence.** "Zero children" clears nothing here — a ConPTY client is a child of whoever called `CreatePseudoConsole`, never of the conhost — so killing the conhost could in principle have taken a living client with it. Kill-**order** rig, client deliberately `cd`'d elsewhere so it cannot be the holder: both alive → **BLOCKED**; kill the **client** only → still **BLOCKED**; kill the **conhost** → **deleted**.
  - **A census of live `--headless` conhosts cannot settle this in either direction.** All 8 alive that day read `C:\WINDOWS\system32\` — and their single creator's cwd *is* `C:\WINDOWS\system32\`, so the value inheritance predicts equals the value OS-assignment predicts, on rows uniform in the asserted property. It reads as confirmation of the old claim and carries no information; the deliberate launch is what discriminates.
  - **Elevation is not the discriminator, and neither reader needs it.** Both readers open with `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` only and, **unelevated**, read ~46–49 of the ~50 conhosts alive. A first pass that reported *no* conhost readable was a parsing fault in the caller, not an access denial — worth stating because "not readable" and "read as `C:\WINDOWS\`" are the same blank row to a careless reader.
  - **Scoped conclusion:** a `CREATE_NO_WINDOW` conhost is never the pin and killing it is never the cure; a `--headless` ConPTY conhost **can be the sole pin**, and there killing it **is** the cure. Neither sentence licenses the other.
- **Triage step — the finder answers WHO pins, never whether it is SAFE TO KILL.** These are different questions and the blocked-delete error is **identical** for both, which is exactly what makes the step worth writing down. Read the holder's identity *before* acting: an **orphaned descendant** (parent dead, no window, no client) → kill it; a **LIVE process — above all an agent's own shell** → `cd` out, never kill. **When the holder is a `conhost.exe`, its COMMAND LINE is the discriminator** — a bare `conhost.exe 0x…` is the `CREATE_NO_WINDOW` byproduct and is never the pin (look behind it for the descendant), while `conhost.exe --headless --width … --server …` is a ConPTY host that can be the pin itself and dies to a kill. Do not read its **child count** as evidence either way: a ConPTY's client is a child of the pseudoconsole's creator, not of the conhost, so "zero children" says nothing about what is alive behind it. Live confirmation 2026-08-02: after one worktree cleared, an identical error on a second traced to a working agent's **own pwsh tool shell**, and behind it that agent's **Bash tool** cwd in the same tree (the two hold *separate* cwds, so clearing one leaves the other). Pattern-matching the kill recipe there would have cost a working agent their session. The pin also follows whatever crate the test ran under, not a fixed package.
- **spt-core mapping:** `.github/ci/find-cwd-holders.ps1` — read-only (`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`, `NtQueryInformationProcess` → PEB → `RTL_USER_PROCESS_PARAMETERS.CurrentDirectory`), no process opened for write or killed; `-Match <substring>` filters by cwd. It reports its **denominator** (readable vs not-openable) because "0 rows" from a filter and "0 rows" because nothing could be opened are the same output and very different facts — an empty result means "no pin I could see", never "no pin". `handle.exe` is not on the box by default and does not distinguish a cwd pin from an open handle; `openfiles` cannot see this at all without the reboot-time maintain-objects-list flag. The **prevention** side is teardown completeness — a rig that reaps its own descendants leaves nothing to pin — which is the `REQ-TEST-RIG-DAEMON-TEARDOWN-PROVEN` (#99) class extended to grandchildren, measured by surviving-process count on both arms rather than asserted.
- **Do NOT "fix" this with the spawn flag.** Flipping the rigs' `CommandNoWindowExt::no_window()` (`crates/spt/tests/common/mod.rs`) from `CREATE_NO_WINDOW` to `DETACHED_PROCESS` removes the conhost (measured: 1 → 0) but does **not** remove the pin (that conhost is never the holder — the `--headless` ConPTY exception above is a host no rig mints through this helper), and it breaks rigs outright per the 5.17 amendment: `job_escape_e2e::conhost_children_of` shells out to **powershell** through that very helper and falls back to `unwrap_or(0)`, so a detached spawn would make `wmi_daemon_run_has_no_conhost_window` — the guard for `REQ-HAZARD-WMI-DAEMON-WINDOW` — assert "no conhost" **vacuously green**, a gate whose failure signature is identical to the fault it guards. Nor can the helper simply hand children a neutral cwd: `bind_cwd_project_e2e` and `resume_template_e2e` deliberately assert on the **inherited** cwd, which is the behavior under test.
- **Source:** hertz, releases#110 — filed diagnosis post-v0.51.0 (2026-08-01); mechanism re-measured and the conhost attribution **refuted** 2026-08-02 (per-parent conhost counts, box-wide conhost cwd census, orphan-grandchild positive control). doyle's live correction the same day supplied the live-shell half of the triage step. **The `--headless` exception is field evidence from todlando** (BAROMETER W4 teardown specimen, the direct-launch inheritance test, and the kill-order rig that excluded pty teardown), reproduced through the `CreatePseudoConsole` path with a delete-blocked/deleted control by hertz, and ruled an in-milestone amend by doyle the same day — the entry was refuted **before** it shipped in a golden run.

<!-- [doc->REQ-TEST-ENV-ONE-LOCK] -->
### 5.19 Two mutexes over one process-global env var guard nothing between them  `[REQ-TEST-ENV-ONE-LOCK]`
- **Failure:** two test-helper crates each declare their own `static ENV_LOCK` over the SAME process-global variable (`SPT_HOME`). Each serializes its own callers **flawlessly** — which is exactly what makes the arrangement read as correct — and neither serializes helper A against helper B. Whichever writes last wins. Because `spt_store::perch::spt_home()` is **not memoized** (it re-reads the env on every call), a test writes its fixture into home A and reads it back from home B: the store is absent, an empty access baseline is minted over it, and the assertion fails on a policy that was never loaded. Field shape (releases#111): `cargo test -p spt-daemon --lib dispatch::` lost **1–3 of 26 nondeterministically**, `an_unadmitted_asker_is_refused_at_this_nodes_own_seam` reading `Some(offline)` where the rules should have answered `None`.
- **Invariant (rig discipline, like 5.17):** one variable, one lock, **per binary**. A crate may keep its own ergonomic wrapper (`with_home`, `isolated_home`, a `TestHome` guard) but must **never** keep its own mutex over a variable another helper in the same binary also writes. `spt_test_support::env_lock()` is THE lease; crate-local helpers return that guard. It is **not reentrant** — a `TestHome` holds it for its whole lifetime, so one is never constructed inside a body already holding the lease.
- **Why it hides, and where it does not:** invisible under **nextest** (per-test processes) and invisible when a loser is run **alone** (no concurrent holder), so CI's nextest unit lane is structurally blind to it — the cost falls entirely on whoever hand-runs the module during a gate. This is the same "invisible-by-construction" property as 5.17, and the reason it is numbered here rather than left to the requirement.
- **The diagnostic that shortcuts it:** the tell is a **path shape the suspected helper could not have produced**. #111's panic printed `<tmp>\home\identity\trust\access.json` — a `home` SUBDIR, which `with_home` never makes (it points `SPT_HOME` at the tempdir *root*). That single mismatch named the real aggressor (`spt_test_support::TestHome`) faster than reading either helper. Corollary for the filed-mechanism trap: `with_home` holds its mutex across its **entire body**, so "a sibling `with_home` test retargets the home mid-test" — the filed story — was impossible; read a helper's lock SCOPE before accepting a race that names it.
- **spt-core mapping:** `spt_test_support::env_lock` (the one lease) consumed by `spt_daemon::test_home::{env_lock, with_home}` and `spt::testutil::isolated_home`. Guard: `test_home::tests::with_home_and_testhome_serialize_on_the_same_lease` — a `with_home` body holds the lease while a second thread attempts a `TestHome`, asserting the thread cannot enter and `SPT_HOME` cannot move, **then** that it does proceed once released (so a shared lock is proven to be a lock, not a deadlock). Reds on the two-lock code, greens on one. **Sweep discipline recorded because one instance of a class is not the class:** the population is every crate declaring an `SPT_HOME` helper INTERSECTED with the binaries that also reach `spt_test_support`; running it found a second live, unreported instance (`crates/spt`'s `testutil::LOCK` beside `cli.rs`'s direct `TestHome`). `spt-live` and `spt-store` depend on `spt-test-support` not at all, and `spt-msg` declares no helper of its own.
- **Source:** releases#111 (filed by doyle from hertz's gate observation, 2026-08-02); filed mechanism refuted and the real root measured the same day — hertz. Cousin class, same date: two builds writing one binary path with nothing guarding between them, which is why the gate recipe now identity-checks the exe hash (doyle, W2 gate).

<!-- [doc->REQ-POOL-FOREIGN-TREE-REFUSED] -->
### 5.20 Two source trees sharing one cargo target directory fabricate reds that belong to neither  `[REQ-POOL-FOREIGN-TREE-REFUSED]`
- **Failure:** a gate lane and a builder lane both built through `.worktrees/gate-target`. The gate compiled against the builder's mid-seam, half-written artifacts and reported an `E0308` in `psyche_event_turn_e2e` that **vanished on re-run** — a red belonging to neither tree, in a lane whose entire job is to be believed. The reverse is worse and silent: the builder's green was equally untrustworthy and nothing said so. Reproduced small (releases#103): two packages of the same name and version, built from two DIFFERENT paths into one pool, resolve to **one** build-script output dir and overwrite each other.
- **Why it hides:** the sharing is invisible at the point of use. A junction shows as an ordinary `target/` and nothing in cargo's output names the real directory; `CARGO_TARGET_DIR` is quieter still — it does not even leave a directory entry to notice afterwards. Both reach the same hazard, which is why the rule is about the **relationship** (this tree writing into a target owned by another) and never about a path: the incident that motivated the filing pointed `CARGO_TARGET_DIR` at the *primary checkout*, which no rule about `.worktrees/` would have covered.
- **The harm is INTERLEAVING, not simultaneity.** Cargo holds an exclusive `.cargo-lock` for a build's duration, so two builds are never literally concurrent — the second blocks. What bit was one lane's artifacts being invalidated by another **between that lane's own steps**. A lease keyed on a `cargo` process therefore guards nothing: it is stale seconds after every claim, so every crossing reads as a takeover and the refusal can never fire. That shape was designed, measured, and refused.
- **Invariant:** a pool belongs to one source tree. `crates/spt-store/build.rs` enforces it and refuses with `SPT_POOL_FOREIGN`, naming the pool, the owning tree, the lane, the holder pid and the way out. Sequential sharing is correct and cheap — a provably-gone lane is a **loud takeover**, never a refusal, because refusing it would demand a pool per lane (30+ worktrees at a measured 7-15 GB each). Staleness is proven from the **process table plus the birth stamp**; UNKNOWN is not stale and proceeds with a warning. An ownerless pool imprints its first builder, so a developer's own fresh `target/` is never refused. `SPT_POOL_UNCHECKED=1` overrides and says so on every build it allows.
<!-- [doc->REQ-POOL-LANE-IDENTITY] -->
- **A DEAD HOLDER IS NOT A FINISHED LANE `[REQ-POOL-LANE-IDENTITY]`.** The first shape of this guard answered "has that lane finished?" from the holder process alone. Measured on this box afterwards: **3 of 3 live pool claims had a dead holder, and one of those three lanes was live.** A holder is an agent session or a rig shell — cleared, re-forked, re-parented as a matter of routine — so its death says nothing about the lane, and reading it as "finished" hands the pool to a second tree **mid-lane**, i.e. reaches this very hazard *through* the guard. So the claim records the lane's **git identity** (its branch and the base sha that branch carried at claim time) and the finished question is answered by **ancestry**: in flight while the lane's tip is not contained in the integration head; settled once it is merged, once the branch is gone, or once the branch no longer carries the claimed base — a branch name re-pointed onto other work is the branch-level twin of a recycled pid. **The base sha is also the repo-identity witness:** ancestry is read in the *arriving* tree, so a branch absent from a *different* repository would otherwise read as a finished lane; an unanchored claim answers UNKNOWN, never "settled". (Found by its own test, which read `Settled: branch no longer exists` out of a directory that was not a repository at all.) **Precedence is asymmetric:** a live holder may only ever ADD a refusal, ancestry alone may declare a lane settled, and a stamp written before the identity fields existed keeps its old behaviour and says so in the line it prints. **What this arm covers, stated exactly:** the enforcer is the build script of the tree that ARRIVES, never the claim it reads, so a lane is protected when the arriving tree carries this change — coverage grows as trees turn over and is never retroactive (measured: a rig whose arriving worktree sat at the pre-identity sha took the pool over, which is the old behaviour working correctly in an old tree). A version line in the stamp cannot fix that and is refused: the tree that must refuse is the one that does not read the new field, so the reader is the problem and not the record. **Every refusal's remedy must be a command that RUNS** — `xtask` depends on `spt-store`, so any command building the tool goes through the refused pool; measured from the refusal state, a remedy naming a bare `cargo run … pool-release` failed with the identical refusal that printed it. The rule is enforced over the whole population of refusal arms, because it was learned once on one arm and re-broken by the next arm added.
- **The guard's own hazard:** the build script must emit **no** `rerun-if` directive. It is re-run because cargo's fingerprint for its unit carries `CARGO_MANIFEST_DIR`, i.e. on exactly the tree ALTERNATION the guard exists to catch. Measured on byte-identical trees, sequence `A B A A B`: no directives → 4 firings; one `rerun-if-changed` → **1**, and the foreign tree never fired it again. `crates/spt-store/tests/pool_guard_canary.rs` reads the shipped build script and reds if a directive appears — a comment would be a prohibition, not a boundary.
- **Source:** BAROMETER W1/H1 window (doyle + todlando, 2026-08-02); design measured and built by hertz, releases#103.

---

## 6. Documented regressions (non-obvious invariants)

### 6.1 No flat/nested perch siblings; resolver-routed paths
- **Failure:** mixed flat + nested perch layouts confuse which perch is live; cascade-wipe risk.
- **Invariant:** one path resolver; never create divergent siblings.
- **spt-core mapping:** clean greenfield layout from day one (no migration window) — pick one structure, route everything through the registry. Storage layout deferred to design phase but this single-source-of-truth rule is binding.
- **Sister cite:** `src/common/perch_path.rs`; CHANGELOG Phase 25.4.

### 6.2 Soft-cleanup preserves state, removes `ready`
- **Failure:** hard-deleting a perch on cleanup loses spool (incl. stored signoff) needed for offline recovery.
- **Invariant:** soft-stop removes only the `ready`/online marker; preserves info + spool + dir. Hard-delete only on explicit operator action.
- **spt-core mapping:** instance offline-state recovery depends on this; carries to the daemon's stop path.
- **Sister cite:** `src/owl/stop.rs`.

### 6.3 Cascade-wipe guard: never delete a parent hosting non-empty children
- **Failure:** `doctor --fix` deletes a top-level perch that still hosts in-flight nested Worker/Psyche perches.
- **Invariant:** before hard-delete, check for non-empty nested children; if present, soft-clean only and surface the path.
- **spt-core mapping:** any destructive maintenance command must check for live child instances first.
- **Sister cite:** CHANGELOG v1.11.20 Phase 35.1.