626	# --- Added 2026-06-03 at M4-D5a start (ADR-0009 origin-node gate; rule 3: register before satisfying) ---
627	[[requirements]]
628	id = "REQ-HAZARD-WAN-ORIGIN-AUTH"
629	title = "WAN-inbound origin is transport truth, never payload: the access gate's subject (ADR-0009 origin-node whitelist) is the QUIC handshake-proven remote node id from the broker's conn/stream table — a forged origin/node field inside record bytes is inert (7.5)"
630	required_stages = ["doc", "impl", "unit"]  # activated M4-D5a: WanMessage carries NO origin field by design (a forged one decodes as an ignored unknown field — spt-net wanmsg unit), and wan::receive_wan takes origin_node from NetStreamInfo.remote_id_hex (tests/wanmsg.rs proves the stream-table origin IS the handshake identity and a forged payload origin is inert end-to-end)
631
632	# --- Added 2026-06-04 at M5-D0 (M5-PLAN scope decisions; rule 3: register before satisfying, rule 5: activate per sub-task commit) ---
633	[[requirements]]
634	id = "REQ-CONSENT-1"
635	title = "Consent grant store: capability x subject-agent x target-node rows, enforced at the target node, subnet-settable (replicates as security material near the trust store), revocable; gated-capability ids (remote-exec, instantiate-anywhere) reserved-but-refusing; v1 consumers are the shell spawn gates (CONTEXT Consent & security gates)"
636	required_stages = ["impl", "unit"]  # activated M5-D1a: spt-store::grants GrantStore (trust/grants.json, default-deny polarity, corrupt-degrades-closed, exact-tuple match incl. qualifier) + spt-daemon::grants gate (reserved-refusal-first decide, target-node-local check front door) + spt grant add/revoke/list CLI (reserved ids refuse authoring). int = the D3d spawn-gate consumer E2E. M5-D9b rule-5: int stays unactivated — the escalation's end-to-end leg needs a real harness session answering a real prompt, which is the downstream plugin's acceptance territory (docs/DEFERRED.md)
637	[[requirements]]
638	id = "REQ-CONSENT-2"
639	title = "Interactive consent escalation: an ungated high-risk action routes a consent prompt to the user's most-recently-active session; allow-once / allow-always (writes a grant) / deny; pre-consent flags (can_shutdown, shell_wake_spawn_anywhere) author grants via manifest/settings (CONTEXT Consent & security gates)"
640	required_stages = ["impl", "unit"]  # activated M5-D1b: spt-daemon::grants escalation — EscalationAsk lossless body roundtrip over the consent-kind notif (produce_escalation_notif rides produce_and_first_fire to the most-recently-active session) + apply_escalation_answer (allow-once proceeds unpersisted / allow-always writes the exact tuple / deny persists nothing). D1c: the pre-consent flag authoring paths (author_can_shutdown_grant live-now; author_wake_spawn_anywhere_grant flag-shape-only — qualified shell-wake row the reserved gate still refuses until instantiate-anywhere lands). int = the D3d spawn-gate consumer E2E
641	[[requirements]]
642	id = "REQ-PRES-1"
643	title = "Presence resolution: the presence datum (last_active_node, last_active_endpoint, ts) gossiped subnet-wide via the agent-interaction heartbeat (rides registry distribution, visibility-gated) + one first-class most-recently-active resolution API consumed by notif first-fire, update-consent delivery, consent escalation, and shell wake resolution (M5 scope decision 1: resolution only — the PresenceChannel endpoint stays deferred)"
644	required_stages = ["impl", "unit", "int"]  # activated M5-D6a/b: the presence datum is an ADDITIVE FIELD on the registry Instance (last_active_ms, serde-default skip-if-none — the resources-blurb precedent verbatim), carried by advertise_local from the perch's heartbeat stamp (lifecycle::touch_active — Active seats only, a resting seat never wins recency). No new gossip channel: rides the epoch lease + RegistryUpdate replication, visibility-gated by construction. Wall-clock ms cross-compared ONLY as a routing heuristic — the epoch stays the sole merge-precedence key. unit = lease-ordering (recency never rolls back through a stale merge) + serde forward-compat + advertisement carry (stamped/never-stamped/refresh-epoch-bumped). D6b: spt-daemon::presence MRA API (local live perches ∪ visible routable gossiped rows under one max; PresenceTarget carries the local-perch handle iff the winner is this node); first_fire is the ONE swap point (update-consent + grants escalation both route through it as notif producers — their advisory targets re-resolve at fire time); RemoteTarget = skip-local-surface-unmarked, the row rides existing notif replication and the winner's node surfaces on feed-apply (dispatch::surface_fresh_rows — convergent decisions, no new wire op, seen-marks + suppression window dedup the skew race); resolve_wake's no-local-owner branch forwards the wake via the D5b rest op (WAKE_FORWARDED; cross-node shell link stays D8c). int = loopback two-broker E2E (remote-won notif redirects: produced at A unmarked, surfaced at B via the production dispatcher). Rig [twohost] leg waits for D9a.
645	[[requirements]]
646	id = "REQ-SHELL-1"
647	title = "Shell hosting machinery: shell perch under the owner (type/owner/adapter_name/status/alias), broker-launched binary + api bind local-link handshake, the three channels (command durable, text+file durable + progress-queryable, sensory REST-only never spooled + dropped-unless-owner-live), owner exclusivity (CONTEXT Shell model)"
648	required_stages = ["impl", "unit", "int"]  # activated M5-D3c; int activated M5-D9b: the D3e/D8 loopback E2Es + the rig cross-node shell drive rung (tests/twohost.rs, run 26998058816) carry the int tags
649	[[requirements]]
650	id = "REQ-SHELL-2"
651	title = "Shell sleep/wake: link-break always closes the binary (pre-close instruction + termination timeout), ephemeral teardown vs persistent offline/relink, wake_command wake-watcher (offline-only, exit-opcode supervision, exponential backoff + give-up), state-keyed wake resolution (dormant/suspended/active-elsewhere; no-reachable refuses — spawn-anywhere branch deferred), spt shutdown owner cascade + api owner-shutdown gated by can_shutdown (CONTEXT Shell sleep/wake)"
652	required_stages = ["impl", "unit", "int"]  # activated M5-D4a; impl/unit across D4a-D4d, int at the D4 sleep/wake E2E (shell_sleepwake_e2e.rs)
653

# --- Added 2026-06-03 at M4-D5a start (ADR-0009 origin-node gate; rule 3: register before satisfying) ---
[[requirements]]
id = "REQ-HAZARD-WAN-ORIGIN-AUTH"
title = "WAN-inbound origin is transport truth, never payload: the access gate's subject (ADR-0009 origin-node whitelist) is the QUIC handshake-proven remote node id from the broker's conn/stream table — a forged origin/node field inside record bytes is inert (7.5)"
required_stages = ["doc", "impl", "unit"]  # activated M4-D5a: WanMessage carries NO origin field by design (a forged one decodes as an ignored unknown field — spt-net wanmsg unit), and wan::receive_wan takes origin_node from NetStreamInfo.remote_id_hex (tests/wanmsg.rs proves the stream-table origin IS the handshake identity and a forged payload origin is inert end-to-end)

# --- Added 2026-06-04 at M5-D0 (M5-PLAN scope decisions; rule 3: register before satisfying, rule 5: activate per sub-task commit) ---
[[requirements]]
id = "REQ-CONSENT-1"
title = "Consent grant store: capability x subject-agent x target-node rows, enforced at the target node, subnet-settable (replicates as security material near the trust store), revocable; gated-capability ids (remote-exec, instantiate-anywhere) reserved-but-refusing; v1 consumers are the shell spawn gates (CONTEXT Consent & security gates)"
required_stages = ["impl", "unit"]  # activated M5-D1a: spt-store::grants GrantStore (trust/grants.json, default-deny polarity, corrupt-degrades-closed, exact-tuple match incl. qualifier) + spt-daemon::grants gate (reserved-refusal-first decide, target-node-local check front door) + spt grant add/revoke/list CLI (reserved ids refuse authoring). int = the D3d spawn-gate consumer E2E. M5-D9b rule-5: int stays unactivated — the escalation's end-to-end leg needs a real harness session answering a real prompt, which is the downstream plugin's acceptance territory (docs/DEFERRED.md)
[[requirements]]
id = "REQ-CONSENT-2"
title = "Interactive consent escalation: an ungated high-risk action routes a consent prompt to the user's most-recently-active session; allow-once / allow-always (writes a grant) / deny; pre-consent flags (can_shutdown, shell_wake_spawn_anywhere) author grants via manifest/settings (CONTEXT Consent & security gates)"
required_stages = ["impl", "unit"]  # activated M5-D1b: spt-daemon::grants escalation — EscalationAsk lossless body roundtrip over the consent-kind notif (produce_escalation_notif rides produce_and_first_fire to the most-recently-active session) + apply_escalation_answer (allow-once proceeds unpersisted / allow-always writes the exact tuple / deny persists nothing). D1c: the pre-consent flag authoring paths (author_can_shutdown_grant live-now; author_wake_spawn_anywhere_grant flag-shape-only — qualified shell-wake row the reserved gate still refuses until instantiate-anywhere lands). int = the D3d spawn-gate consumer E2E
[[requirements]]
id = "REQ-PRES-1"
title = "Presence resolution: the presence datum (last_active_node, last_active_endpoint, ts) gossiped subnet-wide via the agent-interaction heartbeat (rides registry distribution, visibility-gated) + one first-class most-recently-active resolution API consumed by notif first-fire, update-consent delivery, consent escalation, and shell wake resolution (M5 scope decision 1: resolution only — the PresenceChannel endpoint stays deferred)"
required_stages = ["impl", "unit", "int"]  # activated M5-D6a/b: the presence datum is an ADDITIVE FIELD on the registry Instance (last_active_ms, serde-default skip-if-none — the resources-blurb precedent verbatim), carried by advertise_local from the perch's heartbeat stamp (lifecycle::touch_active — Active seats only, a resting seat never wins recency). No new gossip channel: rides the epoch lease + RegistryUpdate replication, visibility-gated by construction. Wall-clock ms cross-compared ONLY as a routing heuristic — the epoch stays the sole merge-precedence key. unit = lease-ordering (recency never rolls back through a stale merge) + serde forward-compat + advertisement carry (stamped/never-stamped/refresh-epoch-bumped). D6b: spt-daemon::presence MRA API (local live perches ∪ visible routable gossiped rows under one max; PresenceTarget carries the local-perch handle iff the winner is this node); first_fire is the ONE swap point (update-consent + grants escalation both route through it as notif producers — their advisory targets re-resolve at fire time); RemoteTarget = skip-local-surface-unmarked, the row rides existing notif replication and the winner's node surfaces on feed-apply (dispatch::surface_fresh_rows — convergent decisions, no new wire op, seen-marks + suppression window dedup the skew race); resolve_wake's no-local-owner branch forwards the wake via the D5b rest op (WAKE_FORWARDED; cross-node shell link stays D8c). int = loopback two-broker E2E (remote-won notif redirects: produced at A unmarked, surfaced at B via the production dispatcher). Rig [twohost] leg waits for D9a.
[[requirements]]
id = "REQ-SHELL-1"
title = "Shell hosting machinery: shell perch under the owner (type/owner/adapter_name/status/alias), broker-launched binary + api bind local-link handshake, the three channels (command durable, text+file durable + progress-queryable, sensory REST-only never spooled + dropped-unless-owner-live), owner exclusivity (CONTEXT Shell model)"
required_stages = ["impl", "unit", "int"]  # activated M5-D3c; int activated M5-D9b: the D3e/D8 loopback E2Es + the rig cross-node shell drive rung (tests/twohost.rs, run 26998058816) carry the int tags
[[requirements]]
id = "REQ-SHELL-2"
title = "Shell sleep/wake: link-break always closes the binary (pre-close instruction + termination timeout), ephemeral teardown vs persistent offline/relink, wake_command wake-watcher (offline-only, exit-opcode supervision, exponential backoff + give-up), state-keyed wake resolution (dormant/suspended/active-elsewhere; no-reachable refuses — spawn-anywhere branch deferred), spt shutdown owner cascade + api owner-shutdown gated by can_shutdown (CONTEXT Shell sleep/wake)"
required_stages = ["impl", "unit", "int"]  # activated M5-D4a; impl/unit across D4a-D4d, int at the D4 sleep/wake E2E (shell_sleepwake_e2e.rs)


142	### 4.8 Registry merge ordered by epoch, never wall-clock (red-team #8)
143	<!-- [doc->REQ-HAZARD-REGISTRY-EPOCH-LEASE] -->
144	- **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.
145	- **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).
146	- **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.
147
148	### 4.9 SQLite stores must create their parent dir — SQLite won't
149	<!-- [doc->REQ-HAZARD-REGISTRY-DIR-CREATE] -->
150	- **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.
151	- **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.)
152	- **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.
153
154	---
155
156	## 5. Platform-specific
157
158	### 5.1 Windows PID recycling false positives
159	- **Failure:** recycled PID reads alive for the wrong process → orphan misclassification.
160	- **Invariant:** anchor liveness on the stable parent/harness PID; minimal info.json for supervisor-owned perches; mtime grace window (≥60s) masks transient mismatches.
161	- **spt-core mapping:** broker-held handles supersede PID polling for spt-hosted sessions; keep the grace window for harness-hosted.
162	- **Sister cite:** `src/live/wrapper/orphan.rs:141-161`; `src/common/list_filter.rs:168-175`.
163
164	### 5.2 Windows EBUSY on atomic rename
165	- **Failure:** `fs::rename` fails while a handle is (recently) held → registry/marketplace update fails.
166	- **Invariant:** tmp-write + atomic-rename with retry/backoff; best-effort side-fail; tolerate transient EBUSY.
167	- **spt-core mapping:** all on-disk state writes (registry, trust store, spool checkpoints) use this pattern. Self-update binary swap on Windows especially.
168	- **Sister cite:** CHANGELOG "EBUSY"; `src/common/owlery.rs` atomic_write.
169
170	### 5.3 Git/subprocess timeout stamping
171	- **Failure:** a hung subprocess (git on slow net) blocks the supervisor indefinitely.
172	- **Invariant:** every metadata-producing subprocess has a timeout; timeout yields `None` + rate-limited stderr, never a hang.
173	- **spt-core mapping:** generalize to all manifest-declared harness invocations (delegated commands, adapter updates) — timeouts mandatory.
174	- **Sister cite:** `src/common/git.rs`.
175
176	### 5.4 Windows UNC prefix in serialized paths
177	- **Failure:** canonicalized `\\?\C:\...` serializes to `//?/C:/...` and fails `read_to_string`.
178	- **Invariant:** strip the `\\?\` UNC prefix after backslash→forward-slash conversion; serialized path attrs must be directly consumable.
179	- **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.
180	- **Sister cite:** `src/common/owlery.rs:377-384`.
181
182	### 5.5 ConPTY withholds output until DSR is answered  `[REQ-HAZARD-CONPTY-DSR]`
183	- **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.
184	- **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()`.
185	- **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).
186	- **Source:** Spike #1 (`docs/spikes/SPIKE-01-broker-handoff.md`); reproduced with both a Rust child and `cmd.exe`.
187
188	<!-- [doc->REQ-HAZARD-DETACHED-PIPE-INHERIT] -->
189	### 5.6 Windows detached children inherit a captured caller's pipe  `[REQ-HAZARD-DETACHED-PIPE-INHERIT]`
190	- **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.
191	- **Invariant:** every detach-spawn of a long-lived child runs with **`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 `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).
192	- **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.
193	- **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.
194
195	---
196
197	## 6. Documented regressions (non-obvious invariants)

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

---

## 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 runs with **`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 `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).
- **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.
- **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.

---

## 6. Documented regressions (non-obvious invariants)


Proceed with bug root cause analysis and fixes


Now register reqs + hazards (rule 3).


Edit

[[requirements]]
id = "REQ-SHELL-2"
title = "Shell sleep/wake: link-break always closes the binary (pre-close instruction + termination timeout), ephemeral teardown vs persistent offline/relink, wake_command wake-watcher (offline-only, exit-opcode supervision, exponential backoff + give-up), state-keyed wake resolution (dormant/suspended/active-elsewhere; no-reachable refuses — spawn-anywhere branch deferred), spt shutdown owner cascade + api owner-shutdown gated by can_shutdown (CONTEXT Shell sleep/wake)"
required_stages = ["impl", "unit", "int"]  # activated M5-D4a; impl/unit across D4a-D4d, int at the D4 sleep/wake E2E (shell_sleepwake_e2e.rs)

[[requirements]]
id = "REQ-SHELL-2"
title = "Shell sleep/wake: link-break always closes the binary (pre-close instruction + termination timeout), ephemeral teardown vs persistent offline/relink, wake_command wake-watcher (offline-only, exit-opcode supervision, exponential backoff + give-up), state-keyed wake resolution (dormant/suspended/active-elsewhere; no-reachable refuses — spawn-anywhere branch deferred), spt shutdown owner cascade + api owner-shutdown gated by can_shutdown (CONTEXT Shell sleep/wake)"
required_stages = ["impl", "unit", "int"]  # activated M5-D4a; impl/unit across D4a-D4d, int at the D4 sleep/wake E2E (shell_sleepwake_e2e.rs)

# --- Added 2026-06-06 from the M7 acceptance run's two publish-blocking field bugs (DEFERRED.md "NEXT SESSION" rows; rule 3: register before satisfying) ---
[[requirements]]
id = "REQ-HAZARD-ELEVATED-DAEMON-SPAWN"
title = "The daemon always runs unelevated in the invoking user's universe, regardless of which command spawns it: an elevated spawner de-elevates (Windows: UAC linked token via CreateProcessWithTokenW; Linux: drop to SUDO_UID/SUDO_GID + the invoker's HOME) — an elevated daemon's pipes deny unelevated clients (every later spt reads not-running→spawn→bind Access-denied) and a sudo'd daemon roots the user's state universe (5.7)"
required_stages = ["doc", "impl", "unit"]  # activated 2026-06-06 (M7 closeout fix 1): spt-daemon::deelevate (the OS-split de-elevation seam: windows linked-token respawn, unix sudo-invoker drop) consumed by daemon::spawn_detached + the Daemon::run entry guard. unit = the pure decision seams (sudo-invoker derivation from euid/env; the windows spawn-plan decision); the live elevated probe is environment-dependent (manual / acceptance rig)
[[requirements]]
id = "REQ-HAZARD-REGISTRY-GHOST-ROWS"
title = "A dead node identity's registry rows must decay: only the per-(endpoint,node) epoch lease supersedes rows, so without eviction a vanished node's rows are immortal and poison bare-id resolution with phantom AcrossNodes ambiguity — evict rows whose author node has not been heard (admitted inbound feed) within the eviction window; own rows never decay; a revived node re-inserts from its durable epoch within one pump cadence (4.10)"
required_stages = ["doc", "impl", "unit"]  # activated 2026-06-06 (M7 closeout fix 2): SubnetRegistry::evict_nodes (model) + RegistryHost::evict_silent_peers (heard-map TTL, snapshots rewritten) driven from the registry pump tick; registry_evict_after_ms knob (default 300s = 10 default cadences). unit = model eviction + the field-bug replay (ghost row makes bare resolve Ambiguous; eviction restores clean resolution) + self-rows-never-decay + recently-heard survives


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

[[requirements]]
id = "REQ-SHELL-2"
title = "Shell sleep/wake: link-break always closes the binary (pre-close instruction + termination timeout), ephemeral teardown vs persistent offline/relink, wake_command wake-watcher (offline-only, exit-opcode supervision, exponential backoff + give-up), state-keyed wake resolution (dormant/suspended/active-elsewhere; no-reachable refuses — spawn-anywhere branch deferred), spt shutdown owner cascade + api owner-shutdown gated by can_shutdown (CONTEXT Shell sleep/wake)"
required_stages = ["impl", "unit", "int"]  # activated M5-D4a; impl/unit across D4a-D4d, int at the D4 sleep/wake E2E (shell_sleepwake_e2e.rs)

# --- Added 2026-06-06 from the M7 acceptance run's two publish-blocking field bugs (DEFERRED.md "NEXT SESSION" rows; rule 3: register before satisfying) ---
[[requirements]]
id = "REQ-HAZARD-ELEVATED-DAEMON-SPAWN"
title = "The daemon always runs unelevated in the invoking user's universe, regardless of which command spawns it: an elevated spawner de-elevates (Windows: UAC linked token via CreateProcessWithTokenW; Linux: drop to SUDO_UID/SUDO_GID + the invoker's HOME) — an elevated daemon's pipes deny unelevated clients (every later spt reads not-running→spawn→bind Access-denied) and a sudo'd daemon roots the user's state universe (5.7)"
required_stages = ["doc", "impl", "unit"]  # activated 2026-06-06 (M7 closeout fix 1): spt-daemon::deelevate (the OS-split de-elevation seam: windows linked-token respawn, unix sudo-invoker drop) consumed by daemon::spawn_detached + the Daemon::run entry guard. unit = the pure decision seams (sudo-invoker derivation from euid/env; the windows spawn-plan decision); the live elevated probe is environment-dependent (manual / acceptance rig)
[[requirements]]
id = "REQ-HAZARD-REGISTRY-GHOST-ROWS"
title = "A dead node identity's registry rows must decay: only the per-(endpoint,node) epoch lease supersedes rows, so without eviction a vanished node's rows are immortal and poison bare-id resolution with phantom AcrossNodes ambiguity — evict rows whose author node has not been heard (admitted inbound feed) within the eviction window; own rows never decay; a revived node re-inserts from its durable epoch within one pump cadence (4.10)"
required_stages = ["doc", "impl", "unit"]  # activated 2026-06-06 (M7 closeout fix 2): SubnetRegistry::evict_nodes (model) + RegistryHost::evict_silent_peers (heard-map TTL, snapshots rewritten) driven from the registry pump tick; registry_evict_after_ms knob (default 300s = 10 default cadences). unit = model eviction + the field-bug replay (ghost row makes bare resolve Ambiguous; eviction restores clean resolution) + self-rows-never-decay + recently-heard survives

+# --- Added 2026-06-06 from the M7 acceptance run's two publish-blocking field bugs (DEFERRED.md "NEXT SESSION" rows; rule 3: register before satisfying) ---

+title = "The daemon always runs unelevated in the invoking user's universe, regardless of which command spawns it: an elevated spawner de-elevates (Windows: UAC linked token via CreateProcessWithTokenW; Linux: drop to SUDO_UID/SUDO_GID + the invoker's HOME) — an elevated daemon's pipes deny unelevated clients (every later spt reads not-running→spawn→bind Access-denied) and a sudo'd daemon roots the user's state universe (5.7)"

+required_stages = ["doc", "impl", "unit"]  # activated 2026-06-06 (M7 closeout fix 1): spt-daemon::deelevate (the OS-split de-elevation seam: windows linked-token respawn, unix sudo-invoker drop) consumed by daemon::spawn_detached + the Daemon::run entry guard. unit = the pure decision seams (sudo-invoker derivation from euid/env; the windows spawn-plan decision); the live elevated probe is environment-dependent (manual / acceptance rig)

+title = "A dead node identity's registry rows must decay: only the per-(endpoint,node) epoch lease supersedes rows, so without eviction a vanished node's rows are immortal and poison bare-id resolution with phantom AcrossNodes ambiguity — evict rows whose author node has not been heard (admitted inbound feed) within the eviction window; own rows never decay; a revived node re-inserts from its durable epoch within one pump cadence (4.10)"

+required_stages = ["doc", "impl", "unit"]  # activated 2026-06-06 (M7 closeout fix 2): SubnetRegistry::evict_nodes (model) + RegistryHost::evict_silent_peers (heard-map TTL, snapshots rewritten) driven from the registry pump tick; registry_evict_after_ms knob (default 300s = 10 default cadences). unit = model eviction + the field-bug replay (ghost row makes bare resolve Ambiguous; eviction restores clean resolution) + self-rows-never-decay + recently-heard survives


Now KNOWN-HAZARDS entries.


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

---

## 5. Platform-specific


The file C:\Users\decid\Documents\projects\spt-core\docs\KNOWN-HAZARDS.md has been updated successfully. (file state is current in your context — no need to Read it back)

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

---

## 5. Platform-specific

- **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]`

+- **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.


- **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.

---

## 6. Documented regressions (non-obvious invariants)

- **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".

---

## 6. Documented regressions (non-obvious invariants)


- **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.

---

## 6. Documented regressions (non-obvious invariants)

- **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".

---

## 6. Documented regressions (non-obvious invariants)

- **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.

+### 5.7 Elevated commands spawn the daemon with the wrong token  `[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".


| 4.3 | Stale registry entries → fallback, never hard-fail | subnet registry resolution |

| 4.3 | Stale registry entries → fallback, never hard-fail | subnet registry resolution |
| 4.10 | Silent-node registry rows evicted (heard-map TTL); own rows never decay | registry pump eviction |


| 4.3 | Stale registry entries → fallback, never hard-fail | subnet registry resolution |
| 4.10 | Silent-node registry rows evicted (heard-map TTL); own rows never decay | registry pump eviction |

| 4.1 | Envelope decode order, `&` last | spt-proto (public, wire-versioned) |

| 4.2 | Parser panic-free + tolerant | spt-proto |

+| 4.10 | Silent-node registry rows evicted (heard-map TTL); own rows never decay | registry pump eviction |

| 4.6 | Addressable-id charset reserves `:`/`@` delimiters | `spt_proto::id` at creation seams |

| 5.2 | tmp-write + atomic-rename + retry (EBUSY) | all state writes, binary swap |

| 5.3 | Timeout every harness subprocess | manifest invocations |


| 5.5 | ConPTY reader answers DSR (`ESC[6n`) | spt-term broker PTY reader |

| 5.5 | ConPTY reader answers DSR (`ESC[6n`) | spt-term broker PTY reader |
| 5.6 | Detached long-lived children spawn `bInheritHandles=FALSE` | daemon + shell spawn |
| 5.7 | Daemon always unelevated in the invoker's universe (de-elevated spawn + entry guard) | `deelevate` seam, `spawn_detached`, `Daemon::run` |


| 5.5 | ConPTY reader answers DSR (`ESC[6n`) | spt-term broker PTY reader |
| 5.6 | Detached long-lived children spawn `bInheritHandles=FALSE` | daemon + shell spawn |
| 5.7 | Daemon always unelevated in the invoker's universe (de-elevated spawn + entry guard) | `deelevate` seam, `spawn_detached`, `Daemon::run` |

| 5.4 | Strip UNC prefix on serialized paths | spt-proto path normalization |

+| 5.6 | Detached long-lived children spawn `bInheritHandles=FALSE` | daemon + shell spawn |

+| 5.7 | Daemon always unelevated in the invoker's universe (de-elevated spawn + entry guard) | `deelevate` seam, `spawn_detached`, `Daemon::run` |

| 6.1 | Single path/registry source of truth | storage layout |

| 6.4 | Drop files supervisor-owned single-writer | runtime contract |

| 6.5 | Direct-write precedence marker (+ node id) | cross-node Psyche sync |


Updated task #2 status


/// Merge an instance update under the epoch lease (#8). For the matching

    /// Evict every instance row authored by a node `dead(node)` says is gone
    /// (KNOWN-HAZARDS 4.10): only the per-`(endpoint_id, node)` epoch lease
    /// supersedes rows, so a vanished node's rows are otherwise immortal and
    /// poison bare-id resolution with phantom `AcrossNodes` ambiguity. An
    /// endpoint id whose last row evicts is dropped entirely. Safe under the
    /// lease: v1 has no transitive gossip, so any future update for that node
    /// comes from the node itself, alive, re-inserting from its durable epoch.
    /// Returns the number of rows evicted.
    // [impl->REQ-HAZARD-REGISTRY-GHOST-ROWS]
    pub fn evict_nodes(&mut self, dead: impl Fn(&str) -> bool) -> usize {
        let mut evicted = 0usize;
        self.by_id.retain(|_, rows| {
            let before = rows.len();
            rows.retain(|i| !dead(&i.node));
            evicted += before - rows.len();
            !rows.is_empty()
        });
        evicted
    }

    /// Merge an instance update under the epoch lease (#8). For the matching


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\registry.rs has been updated successfully. (file state is current in your context — no need to Read it back)

    /// Evict every instance row authored by a node `dead(node)` says is gone
    /// (KNOWN-HAZARDS 4.10): only the per-`(endpoint_id, node)` epoch lease
    /// supersedes rows, so a vanished node's rows are otherwise immortal and
    /// poison bare-id resolution with phantom `AcrossNodes` ambiguity. An
    /// endpoint id whose last row evicts is dropped entirely. Safe under the
    /// lease: v1 has no transitive gossip, so any future update for that node
    /// comes from the node itself, alive, re-inserting from its durable epoch.
    /// Returns the number of rows evicted.
    // [impl->REQ-HAZARD-REGISTRY-GHOST-ROWS]
    pub fn evict_nodes(&mut self, dead: impl Fn(&str) -> bool) -> usize {
        let mut evicted = 0usize;
        self.by_id.retain(|_, rows| {
            let before = rows.len();
            rows.retain(|i| !dead(&i.node));
            evicted += before - rows.len();
            !rows.is_empty()
        });
        evicted
    }

    /// Merge an instance update under the epoch lease (#8). For the matching

+    /// Evict every instance row authored by a node `dead(node)` says is gone

+    /// (KNOWN-HAZARDS 4.10): only the per-`(endpoint_id, node)` epoch lease

+    /// supersedes rows, so a vanished node's rows are otherwise immortal and

+    /// poison bare-id resolution with phantom `AcrossNodes` ambiguity. An

+    /// endpoint id whose last row evicts is dropped entirely. Safe under the

+    /// lease: v1 has no transitive gossip, so any future update for that node

+    /// comes from the node itself, alive, re-inserting from its durable epoch.

+    /// Returns the number of rows evicted.

+    // [impl->REQ-HAZARD-REGISTRY-GHOST-ROWS]

+    pub fn evict_nodes(&mut self, dead: impl Fn(&str) -> bool) -> usize {

+            rows.retain(|i| !dead(&i.node));

+            evicted += before - rows.len();

/// `(endpoint_id, node)`: a **strictly greater** epoch supersedes the stored state

/// ([`Updated`](MergeOutcome::Updated)); an equal/lower epoch is dropped


Model test — including the field-bug replay:


// ── resolution policy (D3c) ────────────────────────────────────────────────

    // [unit->REQ-HAZARD-REGISTRY-GHOST-ROWS] THE field bug (M7 acceptance,
    // 2026-06-06): a dead node identity's row makes a bare id refuse with a
    // phantom AcrossNodes ambiguity the user cannot qualify past (the node no
    // longer exists). Eviction removes the dead node's rows — bare resolution
    // is clean again — and an id held ONLY by the dead node drops entirely.
    #[test]
    fn evicting_dead_node_clears_phantom_ambiguity() {
        let mut reg = SubnetRegistry::new();
        // gravity's dead identity (09ef…) and its live one (0385…) both hold
        // `sergey`; the dead one also exclusively holds `old-only`.
        reg.merge_instance("sergey", inst("09ef-dead", Status::Active, 7));
        reg.merge_instance("sergey", inst("0385-live", Status::Active, 3));
        reg.merge_instance("old-only", inst("09ef-dead", Status::Active, 8));

        // The phantom: bare `sergey` refuses across a node that is gone.
        assert!(matches!(
            reg.resolve(&addr("sergey"), "elsewhere"),
            Resolution::Ambiguous(Ambiguity::AcrossNodes(_))
        ));

        let evicted = reg.evict_nodes(|n| n == "09ef-dead");
        assert_eq!(evicted, 2, "both dead-authored rows evicted");

        // Bare resolution is clean again — the live identity wins alone.
        assert!(matches!(
            reg.resolve(&addr("sergey"), "elsewhere"),
            Resolution::Resolved { instance, .. } if instance.node == "0385-live"
        ));
        // The exclusively-dead id dropped entirely (no empty entry lingers).
        assert_eq!(reg.resolve(&addr("old-only"), "elsewhere"), Resolution::NotFound);
        assert!(!reg.endpoint_ids().any(|id| id == "old-only"));

        // Idempotent: a second sweep evicts nothing.
        assert_eq!(reg.evict_nodes(|n| n == "09ef-dead"), 0);
    }

    // ── resolution policy (D3c) ────────────────────────────────────────────────


    // [unit->REQ-HAZARD-REGISTRY-GHOST-ROWS] THE field bug (M7 acceptance,
    // 2026-06-06): a dead node identity's row makes a bare id refuse with a
    // phantom AcrossNodes ambiguity the user cannot qualify past (the node no
    // longer exists). Eviction removes the dead node's rows — bare resolution
    // is clean again — and an id held ONLY by the dead node drops entirely.
    #[test]
    fn evicting_dead_node_clears_phantom_ambiguity() {
        let mut reg = SubnetRegistry::new();
        // gravity's dead identity (09ef…) and its live one (0385…) both hold
        // `sergey`; the dead one also exclusively holds `old-only`.
        reg.merge_instance("sergey", inst("09ef-dead", Status::Active, 7));
        reg.merge_instance("sergey", inst("0385-live", Status::Active, 3));
        reg.merge_instance("old-only", inst("09ef-dead", Status::Active, 8));

        // The phantom: bare `sergey` refuses across a node that is gone.
        assert!(matches!(
            reg.resolve(&addr("sergey"), "elsewhere"),
            Resolution::Ambiguous(Ambiguity::AcrossNodes(_))
        ));

        let evicted = reg.evict_nodes(|n| n == "09ef-dead");
        assert_eq!(evicted, 2, "both dead-authored rows evicted");

        // Bare resolution is clean again — the live identity wins alone.
        assert!(matches!(
            reg.resolve(&addr("sergey"), "elsewhere"),
            Resolution::Resolved { instance, .. } if instance.node == "0385-live"
        ));
        // The exclusively-dead id dropped entirely (no empty entry lingers).
        assert_eq!(reg.resolve(&addr("old-only"), "elsewhere"), Resolution::NotFound);
        assert!(!reg.endpoint_ids().any(|id| id == "old-only"));

        // Idempotent: a second sweep evicts nothing.
        assert_eq!(reg.evict_nodes(|n| n == "09ef-dead"), 0);
    }

    // ── resolution policy (D3c) ────────────────────────────────────────────────

+    // [unit->REQ-HAZARD-REGISTRY-GHOST-ROWS] THE field bug (M7 acceptance,

+    // 2026-06-06): a dead node identity's row makes a bare id refuse with a

+    // phantom AcrossNodes ambiguity the user cannot qualify past (the node no

+    // longer exists). Eviction removes the dead node's rows — bare resolution

+    // is clean again — and an id held ONLY by the dead node drops entirely.

+    fn evicting_dead_node_clears_phantom_ambiguity() {

+        let mut reg = SubnetRegistry::new();

+        // gravity's dead identity (09ef…) and its live one (0385…) both hold

+        // `sergey`; the dead one also exclusively holds `old-only`.

+        reg.merge_instance("sergey", inst("09ef-dead", Status::Active, 7));

+        reg.merge_instance("sergey", inst("0385-live", Status::Active, 3));

+        reg.merge_instance("old-only", inst("09ef-dead", Status::Active, 8));

+        // The phantom: bare `sergey` refuses across a node that is gone.

+            reg.resolve(&addr("sergey"), "elsewhere"),

+            Resolution::Ambiguous(Ambiguity::AcrossNodes(_))

+        let evicted = reg.evict_nodes(|n| n == "09ef-dead");

+        assert_eq!(evicted, 2, "both dead-authored rows evicted");

+        // Bare resolution is clean again — the live identity wins alone.

+            Resolution::Resolved { instance, .. } if instance.node == "0385-live"

+        // The exclusively-dead id dropped entirely (no empty entry lingers).

+        assert_eq!(reg.resolve(&addr("old-only"), "elsewhere"), Resolution::NotFound);

+        assert!(!reg.endpoint_ids().any(|id| id == "old-only"));

+        // Idempotent: a second sweep evicts nothing.

+        assert_eq!(reg.evict_nodes(|n| n == "09ef-dead"), 0);

fn reg_with(instances: &[(&str, &str, Status)]) -> SubnetRegistry {


Now RegistryHost: `evict_silent_peers` with injectable now.


    /// Every instance row for `endpoint_id` in `subnet` (the reconcile
    /// election's input; empty when unknown).

    /// Evict every registry row authored by a **silent** peer node
    /// (KNOWN-HAZARDS 4.10): a node not heard — no admitted inbound feed —
    /// within `ttl` has its rows removed from every subnet registry and the
    /// snapshots rewritten, so a dead node identity can no longer poison
    /// bare-id resolution with phantom ambiguity. Own rows never decay (this
    /// node authors them fresh each pump tick and is never in its own heard
    /// map). A row whose author has NO heard stamp is treated as silent —
    /// rows only enter via admitted feeds (which stamp) or `advertise_local`
    /// (own rows, excluded), so an unstamped foreign row is already an
    /// anomaly and fails toward eviction. A merely-offline peer reconverges
    /// within one pump cadence of its return (re-advertisement from its
    /// durable epoch). Returns the number of rows evicted.
    // [impl->REQ-HAZARD-REGISTRY-GHOST-ROWS]
    pub fn evict_silent_peers(&self, ttl: std::time::Duration) -> usize {
        self.evict_silent_peers_at(ttl, wall_ms())
    }

    /// [`evict_silent_peers`](Self::evict_silent_peers) with an explicit
    /// clock (the testable seam).
    // [impl->REQ-HAZARD-REGISTRY-GHOST-ROWS]
    pub fn evict_silent_peers_at(&self, ttl: std::time::Duration, now_ms: u64) -> usize {
        let ttl_ms = ttl.as_millis() as u64;
        let heard = self.heard.lock().unwrap();
        let silent = |node: &str| {
            node != self.node_hex
                && heard.get(node).is_none_or(|&t| now_ms.saturating_sub(t) > ttl_ms)
        };
        let mut regs = self.regs.lock().unwrap();
        let mut evicted = 0usize;
        for (subnet, reg) in regs.iter_mut() {
            let n = reg.evict_nodes(&silent);
            if n > 0 {
                eprintln!("REGISTRY_EVICT:{subnet}: {n} row(s) from silent node(s)");
            }
            evicted += n;
        }
        drop(regs);
        drop(heard);
        if evicted > 0 {
            self.write_snapshots();
        }
        evicted
    }

    /// Every instance row for `endpoint_id` in `subnet` (the reconcile
    /// election's input; empty when unknown).


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\registryhost.rs has been updated successfully. (file state is current in your context — no need to Read it back)

    /// Evict every registry row authored by a **silent** peer node
    /// (KNOWN-HAZARDS 4.10): a node not heard — no admitted inbound feed —
    /// within `ttl` has its rows removed from every subnet registry and the
    /// snapshots rewritten, so a dead node identity can no longer poison
    /// bare-id resolution with phantom ambiguity. Own rows never decay (this
    /// node authors them fresh each pump tick and is never in its own heard
    /// map). A row whose author has NO heard stamp is treated as silent —
    /// rows only enter via admitted feeds (which stamp) or `advertise_local`
    /// (own rows, excluded), so an unstamped foreign row is already an
    /// anomaly and fails toward eviction. A merely-offline peer reconverges
    /// within one pump cadence of its return (re-advertisement from its
    /// durable epoch). Returns the number of rows evicted.
    // [impl->REQ-HAZARD-REGISTRY-GHOST-ROWS]
    pub fn evict_silent_peers(&self, ttl: std::time::Duration) -> usize {
        self.evict_silent_peers_at(ttl, wall_ms())
    }

    /// [`evict_silent_peers`](Self::evict_silent_peers) with an explicit
    /// clock (the testable seam).
    // [impl->REQ-HAZARD-REGISTRY-GHOST-ROWS]
    pub fn evict_silent_peers_at(&self, ttl: std::time::Duration, now_ms: u64) -> usize {
        let ttl_ms = ttl.as_millis() as u64;
        let heard = self.heard.lock().unwrap();
        let silent = |node: &str| {
            node != self.node_hex
                && heard.get(node).is_none_or(|&t| now_ms.saturating_sub(t) > ttl_ms)
        };
        let mut regs = self.regs.lock().unwrap();
        let mut evicted = 0usize;
        for (subnet, reg) in regs.iter_mut() {
            let n = reg.evict_nodes(&silent);
            if n > 0 {
                eprintln!("REGISTRY_EVICT:{subnet}: {n} row(s) from silent node(s)");
            }
            evicted += n;
        }
        drop(regs);
        drop(heard);
        if evicted > 0 {
            self.write_snapshots();
        }
        evicted
    }

    /// Every instance row for `endpoint_id` in `subnet` (the reconcile
    /// election's input; empty when unknown).

+    /// Evict every registry row authored by a **silent** peer node

+    /// (KNOWN-HAZARDS 4.10): a node not heard — no admitted inbound feed —

+    /// within `ttl` has its rows removed from every subnet registry and the

+    /// snapshots rewritten, so a dead node identity can no longer poison

+    /// bare-id resolution with phantom ambiguity. Own rows never decay (this

+    /// node authors them fresh each pump tick and is never in its own heard

+    /// map). A row whose author has NO heard stamp is treated as silent —

+    /// rows only enter via admitted feeds (which stamp) or `advertise_local`

+    /// (own rows, excluded), so an unstamped foreign row is already an

+    /// anomaly and fails toward eviction. A merely-offline peer reconverges

+    /// within one pump cadence of its return (re-advertisement from its

+    /// durable epoch). Returns the number of rows evicted.

+    // [impl->REQ-HAZARD-REGISTRY-GHOST-ROWS]

+    pub fn evict_silent_peers(&self, ttl: std::time::Duration) -> usize {

+        self.evict_silent_peers_at(ttl, wall_ms())

+    /// [`evict_silent_peers`](Self::evict_silent_peers) with an explicit

+    pub fn evict_silent_peers_at(&self, ttl: std::time::Duration, now_ms: u64) -> usize {

+        let ttl_ms = ttl.as_millis() as u64;

+        let heard = self.heard.lock().unwrap();

+                && heard.get(node).is_none_or(|&t| now_ms.saturating_sub(t) > ttl_ms)

+        let mut regs = self.regs.lock().unwrap();

+        for (subnet, reg) in regs.iter_mut() {

+            let n = reg.evict_nodes(&silent);

+                eprintln!("REGISTRY_EVICT:{subnet}: {n} row(s) from silent node(s)");

pub fn rows(&self, subnet: &str, endpoint_id: &str) -> Vec<Instance> {


crates\spt\src\cli.rs:1794:        .filter(|s| only.is_none_or(|n| n == s.name))
crates\spt\src\cli.rs:3368:                .filter(|r| agent.as_deref().is_none_or(|a| r.agent == a))
crates\spt-store\src\contextstore.rs:220:            if prefix.as_ref().is_none_or(|p| name.starts_with(p.as_str())) {
crates\spt-daemon\src\registryhost.rs:373:                && heard.get(node).is_none_or(|&t| now_ms.saturating_sub(t) > ttl_ms)


`is_none_or` precedented. Config knob next:


pub const DEFAULT_REGISTRY_PUMP_PERIOD: Duration = Duration::from_secs(30);
pub const DEFAULT_NOTIF_PUMP_PERIOD: Duration = Duration::from_secs(15);
pub const DEFAULT_SYNC_PULL_PERIOD: Duration = Duration::from_secs(60);
pub const DEFAULT_UPDATE_CHECK_PERIOD: Duration = Duration::from_secs(3_600);

pub const DEFAULT_REGISTRY_PUMP_PERIOD: Duration = Duration::from_secs(30);
pub const DEFAULT_NOTIF_PUMP_PERIOD: Duration = Duration::from_secs(15);
pub const DEFAULT_SYNC_PULL_PERIOD: Duration = Duration::from_secs(60);
pub const DEFAULT_UPDATE_CHECK_PERIOD: Duration = Duration::from_secs(3_600);

/// Default silent-peer registry eviction window (KNOWN-HAZARDS 4.10): rows
/// authored by a node not heard within this window are evicted on the next
/// registry pump tick. Sized to ~10 default pump cadences — generous against
/// transient drops (a peer re-advertises every cadence, so one admitted feed
/// inside the window keeps its rows), short enough that a dead node identity
/// stops poisoning bare-id resolution within minutes.
pub const DEFAULT_REGISTRY_EVICT_AFTER: Duration = Duration::from_secs(300);


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\config.rs has been updated successfully. (file state is current in your context — no need to Read it back)

pub const DEFAULT_REGISTRY_PUMP_PERIOD: Duration = Duration::from_secs(30);
pub const DEFAULT_NOTIF_PUMP_PERIOD: Duration = Duration::from_secs(15);
pub const DEFAULT_SYNC_PULL_PERIOD: Duration = Duration::from_secs(60);
pub const DEFAULT_UPDATE_CHECK_PERIOD: Duration = Duration::from_secs(3_600);

/// Default silent-peer registry eviction window (KNOWN-HAZARDS 4.10): rows
/// authored by a node not heard within this window are evicted on the next
/// registry pump tick. Sized to ~10 default pump cadences — generous against
/// transient drops (a peer re-advertises every cadence, so one admitted feed
/// inside the window keeps its rows), short enough that a dead node identity
/// stops poisoning bare-id resolution within minutes.
pub const DEFAULT_REGISTRY_EVICT_AFTER: Duration = Duration::from_secs(300);

+/// Default silent-peer registry eviction window (KNOWN-HAZARDS 4.10): rows

+/// authored by a node not heard within this window are evicted on the next

+/// registry pump tick. Sized to ~10 default pump cadences — generous against

+/// transient drops (a peer re-advertises every cadence, so one admitted feed

+/// inside the window keeps its rows), short enough that a dead node identity

+/// stops poisoning bare-id resolution within minutes.

+pub const DEFAULT_REGISTRY_EVICT_AFTER: Duration = Duration::from_secs(300);

/// Which relay stance the broker's net endpoint runs (ADR-0002 / REQ-NET-2) —

/// the `"relay"` knob in `daemon.json`. Maps onto `spt-net`'s `RelayPolicy` in

/// `Daemon::run`; kept as a local enum so the config layer stays transport-free.


    #[serde(default, skip_serializing_if = "Option::is_none")]
    registry_pump_period_ms: Option<u64>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    registry_pump_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    registry_evict_after_ms: Option<u64>,


    #[serde(default, skip_serializing_if = "Option::is_none")]
    registry_pump_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    registry_evict_after_ms: Option<u64>,

+    registry_evict_after_ms: Option<u64>,

+    #[serde(default, skip_serializing_if = "Option::is_none")]


    /// Registry feed push cadence (D9-1).
    pub registry_pump_period: Duration,

    /// Registry feed push cadence (D9-1).
    pub registry_pump_period: Duration,
    /// Silent-peer registry eviction window (KNOWN-HAZARDS 4.10): rows from a
    /// node not heard within this window are evicted on the next registry
    /// pump tick — a dead node identity must not poison bare-id resolution
    /// forever. Default [`DEFAULT_REGISTRY_EVICT_AFTER`].
    pub registry_evict_after: Duration,


    /// Registry feed push cadence (D9-1).
    pub registry_pump_period: Duration,
    /// Silent-peer registry eviction window (KNOWN-HAZARDS 4.10): rows from a
    /// node not heard within this window are evicted on the next registry
    /// pump tick — a dead node identity must not poison bare-id resolution
    /// forever. Default [`DEFAULT_REGISTRY_EVICT_AFTER`].
    pub registry_evict_after: Duration,

+    /// Silent-peer registry eviction window (KNOWN-HAZARDS 4.10): rows from a

+    /// node not heard within this window are evicted on the next registry

+    /// pump tick — a dead node identity must not poison bare-id resolution

+    /// forever. Default [`DEFAULT_REGISTRY_EVICT_AFTER`].

+    pub registry_evict_after: Duration,

/// Context sync pull cadence — the anti-entropy loop (D9-1).


            peer_pump: true,         // the subnet self-drives (D9-1)
            registry_pump_period: DEFAULT_REGISTRY_PUMP_PERIOD,

            peer_pump: true,         // the subnet self-drives (D9-1)
            registry_pump_period: DEFAULT_REGISTRY_PUMP_PERIOD,
            registry_evict_after: DEFAULT_REGISTRY_EVICT_AFTER,


            peer_pump: true,         // the subnet self-drives (D9-1)
            registry_pump_period: DEFAULT_REGISTRY_PUMP_PERIOD,
            registry_evict_after: DEFAULT_REGISTRY_EVICT_AFTER,

relay: RelayChoice::N0,  // zero-config default (ADR-0002)

+            registry_evict_after: DEFAULT_REGISTRY_EVICT_AFTER,


            registry_pump_period: file
                .registry_pump_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.registry_pump_period),

            registry_pump_period: file
                .registry_pump_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.registry_pump_period),
            registry_evict_after: file
                .registry_evict_after_ms
                .map(Duration::from_millis)
                .unwrap_or(d.registry_evict_after),


            registry_pump_period: file
                .registry_pump_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.registry_pump_period),
            registry_evict_after: file
                .registry_evict_after_ms
                .map(Duration::from_millis)
                .unwrap_or(d.registry_evict_after),

+                .registry_evict_after_ms

+                .map(Duration::from_millis)

+                .unwrap_or(d.registry_evict_after),


registry_pump_period_ms: Some(self.registry_pump_period.as_millis() as u64),

            registry_pump_period_ms: Some(self.registry_pump_period.as_millis() as u64),
            registry_evict_after_ms: Some(self.registry_evict_after.as_millis() as u64),


            registry_pump_period_ms: Some(self.registry_pump_period.as_millis() as u64),
            registry_evict_after_ms: Some(self.registry_evict_after.as_millis() as u64),

+            registry_evict_after_ms: Some(self.registry_evict_after.as_millis() as u64),

notif_pump_period_ms: Some(self.notif_pump_period.as_millis() as u64),

sync_pull_period_ms: Some(self.sync_pull_period.as_millis() as u64),

update_check_period_ms: Some(self.update_check_period.as_millis() as u64),


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

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

Fix 2: Ghost registry row eviction

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

Ride-along: AMBIGUOUS render as copy-paste targets

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


            peer_pump: false,
            registry_pump_period: Duration::from_millis(11_000),

            peer_pump: false,
            registry_pump_period: Duration::from_millis(11_000),
            registry_evict_after: Duration::from_millis(16_000),


            peer_pump: false,
            registry_pump_period: Duration::from_millis(11_000),
            registry_evict_after: Duration::from_millis(16_000),

+            registry_evict_after: Duration::from_millis(16_000),


#[derive(Debug, Clone, Copy)]
pub struct PumpCadence {
    pub registry: Duration,
    pub notif: Duration,
    pub sync: Duration,
    pub update: Duration,
}

impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
            update: cfg.update_check_period,
        }
    }
}

#[derive(Debug, Clone, Copy)]
pub struct PumpCadence {
    pub registry: Duration,
    pub notif: Duration,
    pub sync: Duration,
    pub update: Duration,
    /// Silent-peer registry eviction window (KNOWN-HAZARDS 4.10): swept each
    /// registry tick, before re-advertisement.
    pub registry_evict_after: Duration,
}

impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
            update: cfg.update_check_period,
            registry_evict_after: cfg.registry_evict_after,
        }
    }
}


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\peerloop.rs has been updated successfully. (file state is current in your context — no need to Read it back)

#[derive(Debug, Clone, Copy)]
pub struct PumpCadence {
    pub registry: Duration,
    pub notif: Duration,
    pub sync: Duration,
    pub update: Duration,
}

impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
            update: cfg.update_check_period,
        }
    }
}

#[derive(Debug, Clone, Copy)]
pub struct PumpCadence {
    pub registry: Duration,
    pub notif: Duration,
    pub sync: Duration,
    pub update: Duration,
    /// Silent-peer registry eviction window (KNOWN-HAZARDS 4.10): swept each
    /// registry tick, before re-advertisement.
    pub registry_evict_after: Duration,
}

impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
            update: cfg.update_check_period,
            registry_evict_after: cfg.registry_evict_after,
        }
    }
}

+    /// Silent-peer registry eviction window (KNOWN-HAZARDS 4.10): swept each

+    /// registry tick, before re-advertisement.

+            registry_evict_after: cfg.registry_evict_after,


let adverts = if due_reg { registry.advertise_local(&paths.owlery) } else { Vec::new() };

        let adverts = if due_reg {
            // Sweep silent-node ghost rows BEFORE re-advertising (KNOWN-
            // HAZARDS 4.10): a dead node identity's rows decay instead of
            // poisoning bare-id resolution forever; a live peer re-stamps its
            // heard entry with every admitted feed and is never touched.
            // [impl->REQ-HAZARD-REGISTRY-GHOST-ROWS]
            registry.evict_silent_peers(cadence.registry_evict_after);
            registry.advertise_local(&paths.owlery)
        } else {
            Vec::new()
        };


        let adverts = if due_reg {
            // Sweep silent-node ghost rows BEFORE re-advertising (KNOWN-
            // HAZARDS 4.10): a dead node identity's rows decay instead of
            // poisoning bare-id resolution forever; a live peer re-stamps its
            // heard entry with every admitted feed and is never touched.
            // [impl->REQ-HAZARD-REGISTRY-GHOST-ROWS]
            registry.evict_silent_peers(cadence.registry_evict_after);
            registry.advertise_local(&paths.owlery)
        } else {
            Vec::new()
        };

-        let adverts = if due_reg { registry.advertise_local(&paths.owlery) } else { Vec::new() };

+            // Sweep silent-node ghost rows BEFORE re-advertising (KNOWN-

+            // HAZARDS 4.10): a dead node identity's rows decay instead of

+            // poisoning bare-id resolution forever; a live peer re-stamps its

+            // heard entry with every admitted feed and is never touched.

+            // [impl->REQ-HAZARD-REGISTRY-GHOST-ROWS]

+            registry.evict_silent_peers(cadence.registry_evict_after);

+            registry.advertise_local(&paths.owlery)


crates\spt-daemon\tests\peerloop.rs-277-            releases: a_paths.releases.clone(),
crates\spt-daemon\tests\peerloop.rs-278-            release_keys: a_paths.release_keys.clone(),
crates\spt-daemon\tests\peerloop.rs-279-            pull_marker: a_paths.pull_marker.clone(),
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\peerloop.rs-280-        };
crates\spt-daemon\tests\peerloop.rs-281-        thread::spawn(move || {
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\peerloop.rs-282-            let config = PumpConfig {
crates\spt-daemon\tests\peerloop.rs:283:                cadence: PumpCadence {
crates\spt-daemon\tests\peerloop.rs-284-                    registry: Duration::from_millis(100),
crates\spt-daemon\tests\peerloop.rs-285-                    notif: Duration::from_millis(100),
crates\spt-daemon\tests\peerloop.rs-286-                    sync: Duration::from_millis(100),
crates\spt-daemon\tests\peerloop.rs-287-                    update: Duration::from_millis(100),
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\peerloop.rs-288-                },
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\peerloop.rs-289-                // Gated default (REQ-UPD-4) — the consent notif is the assert.
--
crates\spt-daemon\tests\twohost.rs-311-        let flag = Arc::clone(&stop);
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\twohost.rs-312-        let paths = canonical_pump_paths(scratch_root.join("pump"));
crates\spt-daemon\tests\twohost.rs-313-        let resolver: PeerResolver =
crates\spt-daemon\tests\twohost.rs-314-            Box::new(move |hex| if hex == peer_hex { Some(peer_addr.clone()) } else { None });
crates\spt-daemon\tests\twohost.rs-315-        thread::spawn(move || {
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\twohost.rs-316-            let config = PumpConfig {
crates\spt-daemon\tests\twohost.rs:317:                cadence: PumpCadence {
crates\spt-daemon\tests\twohost.rs-318-                    registry: Duration::from_millis(500),
crates\spt-daemon\tests\twohost.rs-319-                    notif: Duration::from_millis(500),
crates\spt-daemon\tests\twohost.rs-320-                    sync: Duration::from_millis(500),
crates\spt-daemon\tests\twohost.rs-321-                    update: Duration::from_millis(500),
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\twohost.rs-322-                },
crates\spt-daemon\tests\twohost.rs-323-                \ Gated default (REQ-UPD-4): self-heal stages + consent-notifies.
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\peerloop.rs-138-        }
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\peerloop.rs-139-    }
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\peerloop.rs-140-}
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\peerloop.rs-141-
crates\spt-daemon\src\peerloop.rs-142-\ Pump cadences (config-driven in production: [`DaemonConfig`]).
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\peerloop.rs-143-#[derive(Debug, Clone, Copy)]
crates\spt-daemon\src\peerloop.rs:144:pub struct PumpCadence {
crates\spt-daemon\src\peerloop.rs-145-    pub registry: Duration,
crates\spt-daemon\src\peerloop.rs-146-    pub notif: Duration,
crates\spt-daemon\src\peerloop.rs-147-    pub sync: Duration,
crates\spt-daemon\src\peerloop.rs-148-    pub update: Duration,
crates\spt-daemon\src\peerloop.rs-149-    \ Silent-peer registry eviction window (KNOWN-HAZARDS 4.10): swept each
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\peerloop.rs-150-    /// registry tick, before re-advertisement.
crates\spt-daemon\src\peerloop.rs-151-    pub registry_evict_after: Duration,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\peerloop.rs-152-}
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\peerloop.rs-153-
crates\spt-daemon\src\peerloop.rs:154:impl PumpCadence {
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\peerloop.rs-155-    /// From the daemon config knobs.
crates\spt-daemon\src\peerloop.rs:156:    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
crates\spt-daemon\src\peerloop.rs:157:        PumpCadence {
crates\spt-daemon\src\peerloop.rs-158-            registry: cfg.registry_pump_period,
crates\spt-daemon\src\peerloop.rs-159-            notif: cfg.notif_pump_period,
crates\spt-daemon\src\peerloop.rs-160-            sync: cfg.sync_pull_period,
crates\spt-daemon\src\peerloop.rs-161-            update: cfg.update_check_period,
crates\spt-daemon\src\peerloop.rs-162-            registry_evict_after: cfg.registry_evict_after,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\peerloop.rs-163-        }


                cadence: PumpCadence {
                    registry: Duration::from_millis(100),
                    notif: Duration::from_millis(100),
                    sync: Duration::from_millis(100),
                    update: Duration::from_millis(100),
                },

                cadence: PumpCadence {
                    registry: Duration::from_millis(100),
                    notif: Duration::from_millis(100),
                    sync: Duration::from_millis(100),
                    update: Duration::from_millis(100),
                    // Inert here: both loopback nodes feed each other every
                    // tick, so nothing is ever silent for 300s.
                    registry_evict_after: Duration::from_secs(300),
                },


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


                cadence: PumpCadence {
                    registry: Duration::from_millis(500),
                    notif: Duration::from_millis(500),
                    sync: Duration::from_millis(500),
                    update: Duration::from_millis(500),
                },

                cadence: PumpCadence {
                    registry: Duration::from_millis(500),
                    notif: Duration::from_millis(500),
                    sync: Duration::from_millis(500),
                    update: Duration::from_millis(500),
                    // Inert here: the rig hosts hear each other every tick.
                    registry_evict_after: Duration::from_secs(300),
                },


275	            vis: a_paths.vis.clone(),
276	            ops: a_paths.ops.clone(),
277	            releases: a_paths.releases.clone(),
278	            release_keys: a_paths.release_keys.clone(),
279	            pull_marker: a_paths.pull_marker.clone(),
280	        };
281	        thread::spawn(move || {
282	            let config = PumpConfig {
283	                cadence: PumpCadence {
284	                    registry: Duration::from_millis(100),
285	                    notif: Duration::from_millis(100),
286	                    sync: Duration::from_millis(100),
287	                    update: Duration::from_millis(100),
288	                },
289	                // Gated default (REQ-UPD-4) — the consent notif is the assert.
290	                full_auto_update: false,
291	            };
292	            let _ = run_peer_pump(
293	                &name,
294	                reg,
295	                &paths,
296	                resolver,
297	                config,
298	                PumpHooks::default(),
299	                &flag,

            vis: a_paths.vis.clone(),
            ops: a_paths.ops.clone(),
            releases: a_paths.releases.clone(),
            release_keys: a_paths.release_keys.clone(),
            pull_marker: a_paths.pull_marker.clone(),
        };
        thread::spawn(move || {
            let config = PumpConfig {
                cadence: PumpCadence {
                    registry: Duration::from_millis(100),
                    notif: Duration::from_millis(100),
                    sync: Duration::from_millis(100),
                    update: Duration::from_millis(100),
                },
                // Gated default (REQ-UPD-4) — the consent notif is the assert.
                full_auto_update: false,
            };
            let _ = run_peer_pump(
                &name,
                reg,
                &paths,
                resolver,
                config,
                PumpHooks::default(),
                &flag,


308	    {
309	        let name = broker_name.to_string();
310	        let reg = Arc::clone(&registry);
311	        let flag = Arc::clone(&stop);
312	        let paths = canonical_pump_paths(scratch_root.join("pump"));
313	        let resolver: PeerResolver =
314	            Box::new(move |hex| if hex == peer_hex { Some(peer_addr.clone()) } else { None });
315	        thread::spawn(move || {
316	            let config = PumpConfig {
317	                cadence: PumpCadence {
318	                    registry: Duration::from_millis(500),
319	                    notif: Duration::from_millis(500),
320	                    sync: Duration::from_millis(500),
321	                    update: Duration::from_millis(500),
322	                },
323	                // Gated default (REQ-UPD-4): self-heal stages + consent-notifies.
324	                full_auto_update: false,
325	            };
326	            let _ = run_peer_pump(&name, reg, &paths, resolver, config, PumpHooks::default(), &flag);
327	        });
328	    }
329	    (registry, stop)
330	}
331
332	/// Seed this host's visibility (both ladder endpoints sync in the rig subnet)

    {
        let name = broker_name.to_string();
        let reg = Arc::clone(&registry);
        let flag = Arc::clone(&stop);
        let paths = canonical_pump_paths(scratch_root.join("pump"));
        let resolver: PeerResolver =
            Box::new(move |hex| if hex == peer_hex { Some(peer_addr.clone()) } else { None });
        thread::spawn(move || {
            let config = PumpConfig {
                cadence: PumpCadence {
                    registry: Duration::from_millis(500),
                    notif: Duration::from_millis(500),
                    sync: Duration::from_millis(500),
                    update: Duration::from_millis(500),
                },
                // Gated default (REQ-UPD-4): self-heal stages + consent-notifies.
                full_auto_update: false,
            };
            let _ = run_peer_pump(&name, reg, &paths, resolver, config, PumpHooks::default(), &flag);
        });
    }
    (registry, stop)
}

/// Seed this host's visibility (both ladder endpoints sync in the rig subnet)


                cadence: PumpCadence {
                    registry: Duration::from_millis(100),
                    notif: Duration::from_millis(100),
                    sync: Duration::from_millis(100),
                    update: Duration::from_millis(100),
                },

                cadence: PumpCadence {
                    registry: Duration::from_millis(100),
                    notif: Duration::from_millis(100),
                    sync: Duration::from_millis(100),
                    update: Duration::from_millis(100),
                    // Inert here: both loopback nodes feed each other every
                    // tick, so nothing is ever silent for 300s.
                    registry_evict_after: Duration::from_secs(300),
                },


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\peerloop.rs has been updated successfully. (file state is current in your context — no need to Read it back)

                cadence: PumpCadence {
                    registry: Duration::from_millis(100),
                    notif: Duration::from_millis(100),
                    sync: Duration::from_millis(100),
                    update: Duration::from_millis(100),
                },

                cadence: PumpCadence {
                    registry: Duration::from_millis(100),
                    notif: Duration::from_millis(100),
                    sync: Duration::from_millis(100),
                    update: Duration::from_millis(100),
                    // Inert here: both loopback nodes feed each other every
                    // tick, so nothing is ever silent for 300s.
                    registry_evict_after: Duration::from_secs(300),
                },

+                    // Inert here: both loopback nodes feed each other every

+                    // tick, so nothing is ever silent for 300s.

+                    registry_evict_after: Duration::from_secs(300),

// Gated default (REQ-UPD-4) — the consent notif is the assert.


                cadence: PumpCadence {
                    registry: Duration::from_millis(500),
                    notif: Duration::from_millis(500),
                    sync: Duration::from_millis(500),
                    update: Duration::from_millis(500),
                },

                cadence: PumpCadence {
                    registry: Duration::from_millis(500),
                    notif: Duration::from_millis(500),
                    sync: Duration::from_millis(500),
                    update: Duration::from_millis(500),
                    // Inert here: the rig hosts hear each other every tick.
                    registry_evict_after: Duration::from_secs(300),
                },


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\twohost.rs has been updated successfully. (file state is current in your context — no need to Read it back)

                cadence: PumpCadence {
                    registry: Duration::from_millis(500),
                    notif: Duration::from_millis(500),
                    sync: Duration::from_millis(500),
                    update: Duration::from_millis(500),
                },

                cadence: PumpCadence {
                    registry: Duration::from_millis(500),
                    notif: Duration::from_millis(500),
                    sync: Duration::from_millis(500),
                    update: Duration::from_millis(500),
                    // Inert here: the rig hosts hear each other every tick.
                    registry_evict_after: Duration::from_secs(300),
                },

+                    // Inert here: the rig hosts hear each other every tick.

// Gated default (REQ-UPD-4): self-heal stages + consent-notifies.


Fix 2: Ghost registry row eviction


Now host-level tests in registryhost.rs: