# Wire/registry/pairing-transcript types cross the network + the broker IPC, so they
# are serde-serializable.
serde = { version = "1", features = ["derive"] }
# The D4d replication wire format is NDJSON (net/replicate.rs); `net`-gated like the
# rest of the transport surface.
serde_json = { version = "1", optional = true }

[package]
name = "spt-net"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core P2P networking (M4): the WAN/LAN transport every cross-machine capability rides — an Iroh endpoint (WAN, n0-relay default + self-host knob), mDNS LAN discovery, TOTP-seeded SPAKE2 pairing (ADR-0005), the eventually-consistent per-subnet registry (ADR-0006), cross-node Psyche sync, and the update peer-propagation transport into the M3c engine seam. The remaining sibling above spt-msg (`spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime}`). Internal broker machinery, NOT public SDK (R-ARCH-2). Whole transport gated behind the default-on `net` feature (ADR-0002) so minimal library consumers can build it out."

[features]
# ADR-0002: WAN transport stack is default-on in the reference binary, optional for
# embedded library consumers. The `net` feature gates the whole iroh/tokio surface
# so a `--no-default-features` lib build stays transport-free.
default = ["net"]
net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
# Wire/registry/pairing-transcript types cross the network + the broker IPC, so they
# are serde-serializable.
serde = { version = "1", features = ["derive"] }
# Iroh is pinned EXACT: the API churns every release (Spike #2 note) and 0.98.2 is the
# version smoke-validated incl. native cross-NAT hole-punch. `address-lookup-mdns`
# (D1c) adds the LAN discovery service. The whole dep is `net`-gated/optional.
iroh = { version = "=0.98.2", optional = true, features = ["address-lookup-mdns"] }
# spt-net is the first async crate in the workspace (the rest is thread-based, sync);
# iroh requires a tokio runtime. The daemon (D4) will host the runtime that owns the
# broker endpoint. Pulled in only with `net`.
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "macros", "time", "sync"] }
# Pairing crypto (ADR-0005 / D2), all `net`-gated/optional. RFC 6238 TOTP is rolled on
# RustCrypto HMAC+SHA-1 (D2a) rather than a TOTP crate, so the raw time-step counter
# stays first-class for transcript binding (#12) and no URL/QR/image deps are pulled.
# `data-encoding` is the dependency-free base32 for the otpauth `secret`.
hmac = { version = "0.12", optional = true }
sha1 = { version = "0.10", optional = true }
data-encoding = { version = "2", optional = true }
# SPAKE2 PAKE (D2a/b). 0.4 resolves curve25519-dalek 4.x (>=4.1.3, RUSTSEC-2024-0344
# fixed) — the SAME major ed25519-dalek v2 already pulls, so no second curve lib. The
# 0.5-pre line bumps to curve25519-dalek 5-pre and is deliberately avoided. The crate
# binds identities+messages into the derived key but does NO key confirmation, so D2b
# layers an explicit HMAC-SHA-256 confirmation MAC (the transcript binding, #12).
spake2 = { version = "0.4", optional = true }
sha2 = { version = "0.10", optional = true }
# Constant-time tag comparison for the confirmation MAC (no timing oracle on verify).
subtle = { version = "2", optional = true }
# Wipe the PAKE shared secret on drop (already in-tree via dalek; direct dep makes the
# secret-hygiene intent explicit). Pairing key material must not linger in freed memory.
zeroize = { version = "1", optional = true }

[dev-dependencies]
tempfile = "3"
# Registry/wire serde roundtrip tests (D3a) exercise the JSON form.
serde_json = "1"

+# The D4d replication wire format is NDJSON (net/replicate.rs); `net`-gated like the

+serde_json = { version = "1", optional = true }


46:#[cfg(feature = "net")]
47:pub mod net;


Gated correctly. Rerun:


PowerShell

cargo test -p spt-daemon --test replicate 2>&1 | Select-Object -Last 10

Run D4d loopback convergence test


   Compiling spt-net v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
   Compiling spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 9.11s
     Running tests\replicate.rs (target\debug\deps\replicate-1191efd533f9cc7a.exe)

running 1 test
test registries_converge_over_the_wire_and_the_lease_holds ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s


cargo check -p spt-net --no-default-features 2>&1 | Select-Object -Last 3; cargo test -p spt-net -p spt-daemon 2>&1 | Select-String -Pattern "test result: FAILED|panicked" | Select-Object -First 5; "TESTS OK"; cargo clippy -p spt-net -p spt-daemon --all-targets -- -D warnings 2>&1 | Select-Object -Last 3

No-default-features build, full tests, clippy


    Checking spt-msg v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
    Checking spt-net v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.26s
TESTS OK
    Checking spt-net v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Checking spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.66s


D4d: replicate.rs wire format + apply seam in spt-net

net/replicate.rs: RegistryUpdate {subnet, endpoint_id, instance} NDJSON wire format, LineDecoder (chunk-boundary reassembly, tolerant of bad lines), apply_into (unknown-subnet fail-closed drop → merge_instance). Unit tests.

Building registry replication wire format

D4d: loopback convergence test + gates + CI

spt-daemon tests/replicate.rs: two daemons converge a registry row over broker-owned QUIC stream (advertise_if_visible → EpochSource-authored update → wire → merge_instance); stale-epoch update dropped over wire; split-chunk reassembly. traceable-reqs check, clippy, commit, CI green.


All gates clean. Update REQ-INST-7 comment to record D4d, then check:


Edit

required_stages = ["impl", "unit"]  # activated M4-D3a: spt-net::net::registry — SubnetRegistry { endpoint_id -> [Instance{node,status,epoch}] }, Status{Active,Dormant,Offline}, serde-roundtrippable, merge_instance keeps distinct-node instances as separate rows. The bare-id *resolution policy* (local -> most-recently-active -> id@node, ambiguity refuses) is D3c (REQ-INST-10); cross-node replication = D4; int two-host = D9. unit = serde roundtrip + distinct-nodes + newer-epoch-supersedes.

required_stages = ["impl", "unit"]  # activated M4-D3a: spt-net::net::registry — SubnetRegistry { endpoint_id -> [Instance{node,status,epoch}] }, Status{Active,Dormant,Offline}, serde-roundtrippable, merge_instance keeps distinct-node instances as separate rows. The bare-id *resolution policy* (local -> most-recently-active -> id@node, ambiguity refuses) is D3c (REQ-INST-10). Cross-node replication delivered M4-D4d: spt-net::net::replicate — RegistryUpdate {subnet,endpoint_id,Instance} as NDJSON over broker-owned QUIC streams (framing survives chunk coalescing/splitting; corrupt lines skipped, 4.3 posture), apply_update routes by subnet into merge_instance and drops non-member subnets fail-closed (REQ-INST-13 posture); advertise gates (D3d/D3e) run locally BEFORE emission. int two-host = D9. unit = serde roundtrip + distinct-nodes + newer-epoch-supersedes + wire convergence/lease-over-the-wire (tests/replicate.rs).


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)

title = "Subnet registry + bare-id resolution policy"

-required_stages = ["impl", "unit"]  # activated M4-D3a: spt-net::net::registry — SubnetRegistry { endpoint_id -> [Instance{node,status,epoch}] }, Status{Active,Dormant,Offline}, serde-roundtrippable, merge_instance keeps distinct-node instances as separate rows. The bare-id *resolution policy* (local -> most-recently-active -> id@node, ambiguity refuses) is D3c (REQ-INST-10); cross-node replication = D4; int two-host = D9. unit = serde roundtrip + distinct-nodes + newer-epoch-supersedes.

+required_stages = ["impl", "unit"]  # activated M4-D3a: spt-net::net::registry — SubnetRegistry { endpoint_id -> [Instance{node,status,epoch}] }, Status{Active,Dormant,Offline}, serde-roundtrippable, merge_instance keeps distinct-node instances as separate rows. The bare-id *resolution policy* (local -> most-recently-active -> id@node, ambiguity refuses) is D3c (REQ-INST-10). Cross-node replication delivered M4-D4d: spt-net::net::replicate — RegistryUpdate {subnet,endpoint_id,Instance} as NDJSON over broker-owned QUIC streams (framing survives chunk coalescing/splitting; corrupt lines skipped, 4.3 posture), apply_update routes by subnet into merge_instance and drops non-member subnets fail-closed (REQ-INST-13 posture); advertise gates (D3d/D3e) run locally BEFORE emission. int two-host = D9. unit = serde roundtrip + distinct-nodes + newer-epoch-supersedes + wire convergence/lease-over-the-wire (tests/replicate.rs).

title = "Remote-control mode distinct from local operation"


PowerShell

traceable-reqs check 2>&1 | Select-String -Pattern "REQ-INST-7|REQ-HAZARD-REGISTRY-EPOCH-LEASE|FAIL"; "EXIT: $LASTEXITCODE"

Traceability check for D4d reqs


       Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)
  [OK] REQ-HAZARD-REGISTRY-EPOCH-LEASE  required: [impl, unit]  stages: +doc +impl +unit -int
       Stale registry entries degrade to fallback, never hard-fail (4.3)
  [OK] REQ-INST-7  required: [impl, unit]  stages: -doc +impl +unit -int
EXIT: 0


Commit + push + CI watch:


git add -A; git commit -m @'
feat(net): M4-D4d registry replication over the wire

Transport -> merge, nothing more (M4-D4-PLAN locked decision):

- spt-net net/replicate.rs: RegistryUpdate {subnet, endpoint_id,
  Instance} as NDJSON over broker-owned QUIC streams. LineDecoder
  reassembles records across arbitrary chunk boundaries (QUIC reads
  coalesce/split writes); an undecodable line is skipped, not fatal
  (4.3 degrade posture — the epoch lease makes replay/loss safe).
- apply_update routes by subnet straight into D3''s merge_instance;
  an update for a non-member subnet is dropped fail-closed (the
  REQ-INST-13 sync posture — never materialize an unadmitted subnet).
- Advertise gates (D3d collision + D3e visibility) run locally BEFORE
  emission: the wire only carries already-admitted state.
- serde_json becomes a net-gated dependency of spt-net (was dev-only);
  --no-default-features lib build stays transport-free.
- tests/replicate.rs (loopback two daemons): advertise gate ->
  EpochSource-authored update -> wire -> merge; record split across
  two separate sends reassembles; a lagging Active replayed after a
  newer Offline merges Stale and the stored row is untouched — the
  epoch lease holds over the real transport.

traceable-reqs: REQ-INST-7 comment records D4d delivery; check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@; git push origin dev-freeform

Commit and push D4d


warning: in the working copy of 'crates/spt-net/Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/replicate.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/replicate.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 237b1bb] feat(net): M4-D4d registry replication over the wire
 5 files changed, 406 insertions(+), 2 deletions(-)
 create mode 100644 crates/spt-daemon/tests/replicate.rs
 create mode 100644 crates/spt-net/src/net/replicate.rs
To https://github.com/SaberMage/spt-core.git
   1b89bc2..237b1bb  dev-freeform -> dev-freeform


Start-Sleep -Seconds 12; $run = gh run list --branch dev-freeform --limit 1 --json databaseId,headSha | ConvertFrom-Json; gh run watch $run.databaseId --exit-status --interval 15 2>&1 | Select-Object -Last 6; "FINAL:"; gh run list --branch dev-freeform --limit 1 --json conclusion,headSha | ConvertFrom-Json | Format-List

Watch CI run for 237b1bb to completion


Command running in background with ID: b9nw0s22b. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\b9nw0s22b.output. You will be notified when it completes. To check interim output, use Read on that file path.


<tool_use_error>Path does not exist: C:\Users\decid\Documents\projects\spt-core\docs\adr\ADR-0002-default-on-net-feature-with-n0-relays.md. Note: your current working directory is C:\Users\decid\Documents\projects\spt-core.</tool_use_error>

Error: Path does not exist: C:\Users\decid\Documents\projects\spt-core\docs\adr\ADR-0002-default-on-net-feature-with-n0-relays.md. Note: your current working directory is C:\Users\decid\Documents\projects\spt-core.


docs\adr\0002-bake-networking-into-core-collapse-spt-node.md:46:- **SERIOUS #6 — one collapsed daemon = one failure domain.** A networking bug can take out terminal hosting; a PTY-broker bug can take out WAN reachability. Accepted (the collapse is locked), but v1 owes a **fault-injection acceptance matrix**: crash/hang Iroh, mDNS, registry sync, PTY broker, and manifest invocation independently; measure blast radius + recovery. Track at M4.
docs\reviews\STAGE-A-codex-redteam.md:43:6. **ADR-0002** — one collapsed daemon = one failure domain. Networking bug kills terminal hosting; PTY broker bug kills WAN reachability. → Fault-injection matrix: crash/hang Iroh, mDNS, registry, PTY broker, manifest invocation; measure blast radius + recovery.
docs\reviews\STAGE-A-codex-redteam.md:64:- **ADR-0002:** document the single-failure-domain tradeoff + fault-injection acceptance (#6).


30	- **LAN-in-core, WAN-in-node (C):** puts the repo boundary *inside* the transport layer, splitting shared discovery/identity/framing code across two repos.
31
32	**v1 relay stance:** default to n0.computer's free public relays for zero-config onboarding; ship a self-hostable-relay config knob and plain-language disclosure as the principled escape hatch. The dependency is operational, metadata-only (traffic is end-to-end encrypted), and escapable.
33
34	## Consequences
35
36	- `spt-node` is retired from the project vocabulary as a separate deliverable. WAN transport is a **first-class v1 capability** of spt-core (baked into the core crates, default-on in the reference binary) — *not* a post-v1 milestone and not a separate repo. The `SptNode` endpoint type survives unchanged — it remains how a machine is addressed.
37	- The "second project atop spt-core" is no longer mandated to be spt-node. The real next milestone is the rebuilt spt plugin reaching parity.
38	- The reference binary carries Iroh's footprint (~8MB) and the `noq` git-patch coupling when built with `net`. The feature flag keeps minimal library builds clean.
39	- v1 has a soft, documented runtime dependency on n0-operated relay infrastructure. Acceptable for v1; self-host path is the mitigation.
40	- The single daemon is a single point of failure for both PTY hosting and networking on a machine; its crash-recovery and binary-handoff story must cover both roles. (Addressed by the broker/brain split in **ADR-0004** — the stable broker holds PTYs + sockets across brain restarts.)
41
42	## Stage A red-team amendments (2026-05-31)
43
44	Codex (`docs/reviews/STAGE-A-codex-redteam.md`) sharpened the collapse's cost:
45
46	- **SERIOUS #6 — one collapsed daemon = one failure domain.** A networking bug can take out terminal hosting; a PTY-broker bug can take out WAN reachability. Accepted (the collapse is locked), but v1 owes a **fault-injection acceptance matrix**: crash/hang Iroh, mDNS, registry sync, PTY broker, and manifest invocation independently; measure blast radius + recovery. Track at M4.
47	- **Interaction with ADR-0004 (FATAL #4):** preserving live WAN streams across a brain restart forces Iroh endpoint / QUIC stream state into the "stable broker," enlarging the kernel ADR-0002 wanted minimal. Resolved in ADR-0004's ownership table — "stable" now means *update cadence*, not *narrowness*.
48

- **LAN-in-core, WAN-in-node (C):** puts the repo boundary *inside* the transport layer, splitting shared discovery/identity/framing code across two repos.

**v1 relay stance:** default to n0.computer's free public relays for zero-config onboarding; ship a self-hostable-relay config knob and plain-language disclosure as the principled escape hatch. The dependency is operational, metadata-only (traffic is end-to-end encrypted), and escapable.

## Consequences

- `spt-node` is retired from the project vocabulary as a separate deliverable. WAN transport is a **first-class v1 capability** of spt-core (baked into the core crates, default-on in the reference binary) — *not* a post-v1 milestone and not a separate repo. The `SptNode` endpoint type survives unchanged — it remains how a machine is addressed.
- The "second project atop spt-core" is no longer mandated to be spt-node. The real next milestone is the rebuilt spt plugin reaching parity.
- The reference binary carries Iroh's footprint (~8MB) and the `noq` git-patch coupling when built with `net`. The feature flag keeps minimal library builds clean.
- v1 has a soft, documented runtime dependency on n0-operated relay infrastructure. Acceptable for v1; self-host path is the mitigation.
- The single daemon is a single point of failure for both PTY hosting and networking on a machine; its crash-recovery and binary-handoff story must cover both roles. (Addressed by the broker/brain split in **ADR-0004** — the stable broker holds PTYs + sockets across brain restarts.)

## Stage A red-team amendments (2026-05-31)

Codex (`docs/reviews/STAGE-A-codex-redteam.md`) sharpened the collapse's cost:

- **SERIOUS #6 — one collapsed daemon = one failure domain.** A networking bug can take out terminal hosting; a PTY-broker bug can take out WAN reachability. Accepted (the collapse is locked), but v1 owes a **fault-injection acceptance matrix**: crash/hang Iroh, mDNS, registry sync, PTY broker, and manifest invocation independently; measure blast radius + recovery. Track at M4.
- **Interaction with ADR-0004 (FATAL #4):** preserving live WAN streams across a brain restart forces Iroh endpoint / QUIC stream state into the "stable broker," enlarging the kernel ADR-0002 wanted minimal. Resolved in ADR-0004's ownership table — "stable" now means *update cadence*, not *narrowness*.


Write

# Fault-injection acceptance matrix

**Why this exists.** ADR-0002 collapsed networking into the one per-machine daemon
(SERIOUS #6, Stage-A red team): a networking bug and a PTY bug now share a failure
domain. The collapse is locked — the owed mitigation is this matrix: inject a fault
into each subsystem **independently**, observe the blast radius (what else breaks)
and the recovery (what brings it back), and grow the proven rows until every
subsystem has injection evidence. Begun at M4-D4 (per `M4-D4-PLAN.md` piece 5);
rows land as their machinery lands.

**How to read it.** *Fault* = what is injected. *Blast radius* = the components that
degrade, by design. *Survives* = what must keep working untouched. *Recovery* = the
path back. *Evidence* = the test (or gap) backing the row. Rows marked **planned**
are accepted debt — tracked here so the matrix is the single place to look.

## Proven rows (injection evidence in-tree)

| # | Fault injected | Blast radius (by design) | Survives untouched | Recovery | Evidence |
|---|---|---|---|---|---|
| 1 | **Brain killed mid-PTY-stream** (logic crash / routine self-update) | Logic halts until restart | Broker, PTY child (pid stable), output log | New brain re-subscribes from cursor; gapless + exactly-once | `spt-daemon/tests/` B2/B9 handoff suite; `tests/idempotent.rs` (crash before-intent / before-effect / after-effect) |
| 2 | **Brain killed mid-QUIC-stream, receive side** | Logic halts | Broker-owned endpoint, conn, stream, read ring | Resubscribe from durable cursor; ring replays the dead window | `tests/netstream.rs::receiver_brain_restart_is_gapless_and_exactly_once` |
| 3 | **Brain killed mid-QUIC-stream, send side** | Logic halts | Broker-owned conn/stream + effect journal | Re-drive whole durable op sequence; journal dedups replays (no dup on the wire) | `tests/netstream.rs::sender_brain_restart_redrive_is_exactly_once` |
| 4 | **Brain dead across peer connect/disconnect** | Presence consumption pauses | PresenceLog ring buffers liveness transitions | Resubscribe from presence cursor; missed transitions replay | `tests/presence.rs` (both tests) |
| 5 | **Peer node vanishes (graceful close)** | That conn's streams end | Local broker, other conns, brain | Closed-watcher removes conn row + emits `disconnected`; dial fresh when needed | `tests/presence.rs`; `nethost.rs` closed-watcher |
| 6 | **Registry feed: stale/replayed update over the wire** | None — update dropped | Stored registry state (newer epoch holds) | None needed (epoch lease absorbs it) | `tests/replicate.rs::registries_converge_over_the_wire_and_the_lease_holds`; `replicate.rs` unit tests |
| 7 | **Registry feed: corrupt record in the stream** | That record only — skipped | The rest of the feed (decoder does not wedge) | Lease makes loss safe; next update supersedes | `replicate.rs::decoder_survives_chunk_splits_and_corrupt_lines` |
| 8 | **Net disabled / endpoint bind fails at boot** | No WAN; net frames answer `enabled:false` / typed error | PTY hosting (daemon degrades to net-less broker) | Fix config, restart daemon | `broker.rs::dispatch_net_status` (None arm); `Daemon::run` degrade path |
| 9 | **Broker process restart (journal survives, conns don't)** | Live conns/streams lost | Effect journal (durable) | Deduped op whose resource is gone → typed "retry with a fresh op_id" error; brain re-dials | `broker.rs::dispatch_net_dial` / `dispatch_net_stream_open` restart arms |

## Planned rows (accepted debt — no injection evidence yet)

| # | Fault to inject | Expected blast radius | Expected survival | Target |
|---|---|---|---|---|
| P1 | **Iroh endpoint task hangs** (not crash — a wedged accept loop) | New inbound conns stall; existing conns + PTY hosting unaffected | PTY side fully isolated (separate threads/runtime) | D9 chaos pass |
| P2 | **mDNS discovery dies** | LAN dial-by-id fails; direct-addr + relay dials unaffected | Endpoint, conns, PTY | D9 (needs real LAN) |
| P3 | **Relay unreachable** (n0 outage / air-gap) | WAN dials needing relay fail; LAN/direct unaffected | Everything local | D9 two-host |
| P4 | **PTY broker drain thread dies** | That session's output stalls | Other sessions, net side | with terminal-wrapper hardening |
| P5 | **Manifest invocation hangs** (adapter subprocess) | That endpoint's lifecycle op | Daemon scheduler (KH 5.3 timeouts + 7.4 non-blocking) | when daemon hosts N agent loops |
| P6 | **Registry feed flood** (malicious/buggy peer spams updates) | Bounded by ring caps + lease; CPU cost unmeasured | — | D9 + REQ-SEC-1 outer gate |
| P7 | **Closed-watcher starvation** (net runtime saturated) | Presence lags behind reality | Conn table eventually consistent | D9 chaos pass |

**Maintenance rule:** when a planned row gains a real injection test, move it up with
its evidence path. When new subsystems land (D5 WAN delivery, D6 Psyche sync), add
their fault rows *in the same change* — a subsystem without a row here is a review
finding.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\docs\FAULT-MATRIX.md (file state is current in your context — no need to Read it back)

# Fault-injection acceptance matrix

**Why this exists.** ADR-0002 collapsed networking into the one per-machine daemon
(SERIOUS #6, Stage-A red team): a networking bug and a PTY bug now share a failure
domain. The collapse is locked — the owed mitigation is this matrix: inject a fault
into each subsystem **independently**, observe the blast radius (what else breaks)
and the recovery (what brings it back), and grow the proven rows until every
subsystem has injection evidence. Begun at M4-D4 (per `M4-D4-PLAN.md` piece 5);
rows land as their machinery lands.

**How to read it.** *Fault* = what is injected. *Blast radius* = the components that
degrade, by design. *Survives* = what must keep working untouched. *Recovery* = the
path back. *Evidence* = the test (or gap) backing the row. Rows marked **planned**
are accepted debt — tracked here so the matrix is the single place to look.

## Proven rows (injection evidence in-tree)

| # | Fault injected | Blast radius (by design) | Survives untouched | Recovery | Evidence |
|---|---|---|---|---|---|
| 1 | **Brain killed mid-PTY-stream** (logic crash / routine self-update) | Logic halts until restart | Broker, PTY child (pid stable), output log | New brain re-subscribes from cursor; gapless + exactly-once | `spt-daemon/tests/` B2/B9 handoff suite; `tests/idempotent.rs` (crash before-intent / before-effect / after-effect) |
| 2 | **Brain killed mid-QUIC-stream, receive side** | Logic halts | Broker-owned endpoint, conn, stream, read ring | Resubscribe from durable cursor; ring replays the dead window | `tests/netstream.rs::receiver_brain_restart_is_gapless_and_exactly_once` |
| 3 | **Brain killed mid-QUIC-stream, send side** | Logic halts | Broker-owned conn/stream + effect journal | Re-drive whole durable op sequence; journal dedups replays (no dup on the wire) | `tests/netstream.rs::sender_brain_restart_redrive_is_exactly_once` |
| 4 | **Brain dead across peer connect/disconnect** | Presence consumption pauses | PresenceLog ring buffers liveness transitions | Resubscribe from presence cursor; missed transitions replay | `tests/presence.rs` (both tests) |
| 5 | **Peer node vanishes (graceful close)** | That conn's streams end | Local broker, other conns, brain | Closed-watcher removes conn row + emits `disconnected`; dial fresh when needed | `tests/presence.rs`; `nethost.rs` closed-watcher |
| 6 | **Registry feed: stale/replayed update over the wire** | None — update dropped | Stored registry state (newer epoch holds) | None needed (epoch lease absorbs it) | `tests/replicate.rs::registries_converge_over_the_wire_and_the_lease_holds`; `replicate.rs` unit tests |
| 7 | **Registry feed: corrupt record in the stream** | That record only — skipped | The rest of the feed (decoder does not wedge) | Lease makes loss safe; next update supersedes | `replicate.rs::decoder_survives_chunk_splits_and_corrupt_lines` |
| 8 | **Net disabled / endpoint bind fails at boot** | No WAN; net frames answer `enabled:false` / typed error | PTY hosting (daemon degrades to net-less broker) | Fix config, restart daemon | `broker.rs::dispatch_net_status` (None arm); `Daemon::run` degrade path |
| 9 | **Broker process restart (journal survives, conns don't)** | Live conns/streams lost | Effect journal (durable) | Deduped op whose resource is gone → typed "retry with a fresh op_id" error; brain re-dials | `broker.rs::dispatch_net_dial` / `dispatch_net_stream_open` restart arms |

## Planned rows (accepted debt — no injection evidence yet)

| # | Fault to inject | Expected blast radius | Expected survival | Target |
|---|---|---|---|---|
| P1 | **Iroh endpoint task hangs** (not crash — a wedged accept loop) | New inbound conns stall; existing conns + PTY hosting unaffected | PTY side fully isolated (separate threads/runtime) | D9 chaos pass |
| P2 | **mDNS discovery dies** | LAN dial-by-id fails; direct-addr + relay dials unaffected | Endpoint, conns, PTY | D9 (needs real LAN) |
| P3 | **Relay unreachable** (n0 outage / air-gap) | WAN dials needing relay fail; LAN/direct unaffected | Everything local | D9 two-host |
| P4 | **PTY broker drain thread dies** | That session's output stalls | Other sessions, net side | with terminal-wrapper hardening |
| P5 | **Manifest invocation hangs** (adapter subprocess) | That endpoint's lifecycle op | Daemon scheduler (KH 5.3 timeouts + 7.4 non-blocking) | when daemon hosts N agent loops |
| P6 | **Registry feed flood** (malicious/buggy peer spams updates) | Bounded by ring caps + lease; CPU cost unmeasured | — | D9 + REQ-SEC-1 outer gate |
| P7 | **Closed-watcher starvation** (net runtime saturated) | Presence lags behind reality | Conn table eventually consistent | D9 chaos pass |

**Maintenance rule:** when a planned row gains a real injection test, move it up with
its evidence path. When new subsystems land (D5 WAN delivery, D6 Psyche sync), add
their fault rows *in the same change* — a subsystem without a row here is a review
finding.


Edit

- **SERIOUS #6 — one collapsed daemon = one failure domain.** A networking bug can take out terminal hosting; a PTY-broker bug can take out WAN reachability. Accepted (the collapse is locked), but v1 owes a **fault-injection acceptance matrix**: crash/hang Iroh, mDNS, registry sync, PTY broker, and manifest invocation independently; measure blast radius + recovery. Track at M4.

- **SERIOUS #6 — one collapsed daemon = one failure domain.** A networking bug can take out terminal hosting; a PTY-broker bug can take out WAN reachability. Accepted (the collapse is locked), but v1 owes a **fault-injection acceptance matrix**: crash/hang Iroh, mDNS, registry sync, PTY broker, and manifest invocation independently; measure blast radius + recovery. Track at M4. → Begun M4-D4: **`docs/FAULT-MATRIX.md`** is the living matrix (proven rows with test evidence; planned rows tracked as debt).


The file C:\Users\decid\Documents\projects\spt-core\docs\adr\0002-bake-networking-into-core-collapse-spt-node.md has been updated successfully. (file state is current in your context — no need to Read it back)

# Bake networking into core; collapse spt-node into the per-machine daemon

## Status

accepted (2026-05-28) — reverses the sister project's separate-daemon decision (claude_skill_owl CONTEXT.md §Networking) for the spt-core context. · amended (2026-05-31): GitHub re-admitted as an opt-in context-sync hub (see Amendment below).

## Amendment (2026-05-31): GitHub re-admitted as an opt-in context-sync hub

This ADR retired GitHub as the *required/default* cross-node sync. It is now re-admitted as an **opt-in alternative transport for the context repo only** (`tracked/`), enabled by `spt context-github-setup`:

- **Default stays P2P** + no-account — the core promise is unchanged.
- **Hub mode (opt-in):** every node pull→merge→pushes against a private GitHub remote (always-online sync hub + live GUI view), authed by a **shared deploy key** distributed as subnet secret material (only the setup node needs a GitHub account). **Prefer-GitHub, P2P backstop.**
- **Scope:** hub mode carries the **context repo only**. Messaging, registry, remote-drive, pairing, and presence always ride P2P/Iroh; gh-on never removes the P2P requirement. So the original decision (networking baked-in, first-class, P2P) stands in full — the hub is a context-sync convenience layered on top, not a return to GitHub-as-infrastructure.

## Context

The sister project (`claude_skill_owl`) decided networking should live in a separate `spt-node` daemon, explicitly to decouple the network stack from SPT's Claude-Code-tied core binary, expose SPT-protocol-as-product, and let networking evolve on its own release cycle. That decision predates spt-core.

spt-core is a ground-up, harness-independent rebuild. The primary reason `spt-node` was separate — decoupling networking from a CC-tied binary — no longer exists, because spt-core is already decoupled from any harness. Meanwhile the product goal for networking sharpened: WAN cross-machine messaging should be **first-class and zero-config — "just works."**

## Decision

Bake the WAN transport stack (Iroh + mDNS LAN discovery + pairing) into spt-core's library crates, behind a compile-time `net` feature flag (default-on in the reference binary, optional for embedded library consumers).

"Just works WAN, reachable even when no session is running" requires a persistent per-machine network presence — the exact job `spt-node` was created to do. spt-core already commits to a single always-on, one-per-machine `spt-daemon` to supervise PTYs (the terminal wrapper). **Collapse the network-host role into that same daemon (option B1).** One process owns both PTYs and the node identity + WAN endpoint.

Rejected alternatives:
- **Seams-only in core, transport in spt-node (A):** inconsistent with first-class/zero-config networking; preserves a separate-deliverable boundary whose original justification is gone.
- **Two daemons — term-daemon + net-daemon (B2):** doubles the always-on process count and the crash/restart/binary-handoff surface for no benefit once both are in core.
- **LAN-in-core, WAN-in-node (C):** puts the repo boundary *inside* the transport layer, splitting shared discovery/identity/framing code across two repos.

**v1 relay stance:** default to n0.computer's free public relays for zero-config onboarding; ship a self-hostable-relay config knob and plain-language disclosure as the principled escape hatch. The dependency is operational, metadata-only (traffic is end-to-end encrypted), and escapable.

## Consequences

- `spt-node` is retired from the project vocabulary as a separate deliverable. WAN transport is a **first-class v1 capability** of spt-core (baked into the core crates, default-on in the reference binary) — *not* a post-v1 milestone and not a separate repo. The `SptNode` endpoint type survives unchanged — it remains how a machine is addressed.
- The "second project atop spt-core" is no longer mandated to be spt-node. The real next milestone is the rebuilt spt plugin reaching parity.
- The reference binary carries Iroh's footprint (~8MB) and the `noq` git-patch coupling when built with `net`. The feature flag keeps minimal library builds clean.
- v1 has a soft, documented runtime dependency on n0-operated relay infrastructure. Acceptable for v1; self-host path is the mitigation.
- The single daemon is a single point of failure for both PTY hosting and networking on a machine; its crash-recovery and binary-handoff story must cover both roles. (Addressed by the broker/brain split in **ADR-0004** — the stable broker holds PTYs + sockets across brain restarts.)

## Stage A red-team amendments (2026-05-31)

Codex (`docs/reviews/STAGE-A-codex-redteam.md`) sharpened the collapse's cost:

- **SERIOUS #6 — one collapsed daemon = one failure domain.** A networking bug can take out terminal hosting; a PTY-broker bug can take out WAN reachability. Accepted (the collapse is locked), but v1 owes a **fault-injection acceptance matrix**: crash/hang Iroh, mDNS, registry sync, PTY broker, and manifest invocation independently; measure blast radius + recovery. Track at M4.
- **Interaction with ADR-0004 (FATAL #4):** preserving live WAN streams across a brain restart forces Iroh endpoint / QUIC stream state into the "stable broker," enlarging the kernel ADR-0002 wanted minimal. Resolved in ADR-0004's ownership table — "stable" now means *update cadence*, not *narrowness*.

-- **SERIOUS #6 — one collapsed daemon = one failure domain.** A networking bug can take out terminal hosting; a PTY-broker bug can take out WAN reachability. Accepted (the collapse is locked), but v1 owes a **fault-injection acceptance matrix**: crash/hang Iroh, mDNS, registry sync, PTY broker, and manifest invocation independently; measure blast radius + recovery. Track at M4.

+- **SERIOUS #6 — one collapsed daemon = one failure domain.** A networking bug can take out terminal hosting; a PTY-broker bug can take out WAN reachability. Accepted (the collapse is locked), but v1 owes a **fault-injection acceptance matrix**: crash/hang Iroh, mDNS, registry sync, PTY broker, and manifest invocation independently; measure blast radius + recovery. Track at M4. → Begun M4-D4: **`docs/FAULT-MATRIX.md`** is the living matrix (proven rows with test evidence; planned rows tracked as debt).


PowerShell

traceable-reqs check > $null 2>&1; "EXIT: $LASTEXITCODE"; Get-Content "C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\b9nw0s22b.output" -Tail 4 -ErrorAction SilentlyContinue

Traceability check + peek CI watcher


EXIT: 0


CI discipline: green before next push. Commit doc locally, hold push:


git add docs/FAULT-MATRIX.md docs/adr/0002-bake-networking-into-core-collapse-spt-node.md; git commit -m @'
docs: begin fault-injection acceptance matrix (ADR-0002 SERIOUS #6, M4-D4 piece 5)

docs/FAULT-MATRIX.md: the living blast-radius/recovery matrix the Stage-A
red team made the price of the single-daemon collapse. 9 proven rows with
in-tree injection evidence (brain kill x PTY/QUIC-recv/QUIC-send/presence,
peer close, stale/corrupt registry feed, net-less degrade, broker restart);
7 planned rows tracked as accepted debt (iroh hang, mDNS death, relay
outage, drain death, manifest hang, feed flood, watcher starvation) with
targets. Maintenance rule: new subsystem => new row in the same change.

ADR-0002 amendment bullet now points at the matrix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@

Commit fault matrix doc locally


warning: in the working copy of 'docs/adr/0002-bake-networking-into-core-collapse-spt-node.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs/FAULT-MATRIX.md', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 924e6b4] docs: begin fault-injection acceptance matrix (ADR-0002 SERIOUS #6, M4-D4 piece 5)
 2 files changed, 46 insertions(+), 1 deletion(-)
 create mode 100644 docs/FAULT-MATRIX.md


Doc committed locally (`924e6b4`), push held until D4d CI (`237b1bb`) reports green. Waiting on watcher `b9nw0s22b`.

State:
- **D4c** ✓ CI-green (`1b89bc2`)
- **D4d** ✓ pushed (`237b1bb`), CI in flight — wire format + lease-over-wire test passed locally, clippy clean, no-default-features build clean, traceable-reqs exit 0
- **D4 piece 5** ✓ committed locally — FAULT-MATRIX.md, 9 proven rows / 7 planned
- After green: push doc, commune D4-complete, then D4 is **done** → next per roadmap is D5 (WAN message delivery) or remaining M4 sequence


<task-notification>
<task-id>b9nw0s22b</task-id>
<tool-use-id>toolu_01TK2TCjBdAmqLkiGxDzgrgA</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\b9nw0s22b.output</output-file>
<status>completed</status>
<summary>Background command "Watch CI run for 237b1bb to completion" completed (exit code 0)</summary>
</task-notification>


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


1	  ✓ Complete job
2
3	ANNOTATIONS
4	! Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: actions/checkout@v4. Actions will be forced to run with Node.js 24 by default starting June 16th, 2026. Node.js 20 will be removed from the runner on September 16th, 2026. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/
5	traceability: .github#2
6
7	FINAL:
8
9	conclusion : failure
10	headSha    : 237b1bb42a59cd7e112c8bf00badb4078ed7133c
11
12

  ✓ Complete job

ANNOTATIONS
! Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: actions/checkout@v4. Actions will be forced to run with Node.js 24 by default starting June 16th, 2026. Node.js 20 will be removed from the runner on September 16th, 2026. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/
traceability: .github#2

FINAL:

conclusion : failure
headSha    : 237b1bb42a59cd7e112c8bf00badb4078ed7133c


CI **failed** on `237b1bb`. Investigate:


$run = gh run list --branch dev-freeform --limit 1 --json databaseId | ConvertFrom-Json; gh run view $run.databaseId --json jobs --jq '.jobs[] | {name, conclusion}'

List job conclusions for failed run


{"conclusion":"success","name":"test (self-hosted, Linux, gravity)"}
{"conclusion":"failure","name":"test (self-hosted, Windows, hfenduleam)"}
{"conclusion":"success","name":"traceability"}


$run = gh run list --branch dev-freeform --limit 1 --json databaseId | ConvertFrom-Json; gh run view $run.databaseId --log-failed 2>&1 | Select-String -Pattern "FAILED|panicked|test result|error\[|failures:" -Context 0,3 | Select-Object -First 30

Get failed test output from Windows leg


> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:27.9734526Z test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.00s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:27.9734786Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:27.9747104Z      Running tests\manifest_valid.rs
(target\debug\deps\manifest_valid-c8d62cbe0af71b46.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:28.0083142Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:28.0098755Z test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.00s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:28.0099077Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:28.0113123Z      Running unittests src\main.rs
(target\debug\deps\spt-bf411dc9a50e797b.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:28.4059771Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:28.7958690Z test result: ok. 54 passed; 0 failed; 0 ignored; 0 measured;
0 filtered out; finished in 0.39s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:28.7958943Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:28.7980473Z      Running tests\contract_e2e.rs
(target\debug\deps\contract_e2e-90234004ae2fc15b.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:28.8307195Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:31.1740460Z test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 2.34s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:31.1740722Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:31.1757989Z      Running unittests src\lib.rs
(target\debug\deps\spt_daemon-316a5ca6e9eeab9a.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:31.8343241Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:31.8910475Z test effect::tests::failed_effect_is_not_recorded_applied ...
ok
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:31.8948812Z test release::tests::artifact_digest_mismatch_is_rejected ...
ok
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:31.8988671Z test
consent::tests::resolves_most_recently_active_live_session ... ok
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:31.8990156Z test lifecycle::tests::crash_signoff_spares_recovered_self
... ok
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:32.3343021Z test result: ok. 80 passed; 0 failed; 0 ignored; 0 measured;
0 filtered out; finished in 0.50s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:32.3343423Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:32.3401408Z      Running tests\brain_swap.rs
(target\debug\deps\brain_swap-79ab6e7cc0272bea.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:32.8354517Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:32.8638129Z test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.03s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:32.8638391Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:32.8652386Z      Running tests\broker.rs
(target\debug\deps\broker-346ee56f06833ab6.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:33.3481995Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:33.3752363Z test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.03s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:33.3752735Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:33.3766207Z      Running tests\daemon_e2e.rs
(target\debug\deps\daemon_e2e-bec2001ff6713a99.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:33.9384630Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:34.1668397Z test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.23s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:34.1668643Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:34.1685913Z      Running tests\digest.rs
(target\debug\deps\digest-4cff845d5b626990.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:34.1988525Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:34.3492427Z test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.15s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:34.3492651Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:34.3514695Z      Running tests\handoff.rs
(target\debug\deps\handoff-46c213468bca94ad.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:34.8417563Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:35.0458987Z test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.20s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:35.0459231Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:35.0475204Z      Running tests\idempotent.rs
(target\debug\deps\idempotent-71d983855908afe4.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:35.0774148Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:35.2030909Z test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.13s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:35.2058670Z      Running tests\netbroker.rs
(target\debug\deps\netbroker-9e692aadc9eba605.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:35.2059103Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:35.2441297Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:35.3405596Z test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.10s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:35.3405972Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:35.3472204Z      Running tests\netstream.rs
(target\debug\deps\netstream-f1b34c1c31a83a1b.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:35.9971590Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:36.5199880Z test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.52s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:36.5200288Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:36.5321816Z      Running tests\presence.rs
(target\debug\deps\presence-48a3e906f3f63d8a.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:37.1957380Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:40.3669982Z test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 3.17s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:40.3670256Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:40.3726582Z      Running tests\replicate.rs
(target\debug\deps\replicate-6314066c73b30a09.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:40.4115470Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:40.6200926Z test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out; finished in 0.21s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:40.6201884Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:40.6300085Z      Running unittests src\lib.rs
(target\debug\deps\spt_live-c3e807cb486b3771.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:40.8751547Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:41.5080974Z test result: ok. 35 passed; 0 failed; 0 ignored; 0 measured;
0 filtered out; finished in 0.63s
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:41.5081230Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:41.5096668Z      Running unittests src\lib.rs
(target\debug\deps\spt_msg-7130358a8294de6b.exe)
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:41.7039449Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:41.7074589Z test listener::tests::blocking_times_out_when_idle ... FAILED
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:41.7075077Z test listener::tests::bind_registers_and_round_trips ...
FAILED
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:41.7075542Z test deliver::tests::send_outcome_mapping ... FAILED
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:41.7076086Z test listener::tests::drop_unregisters_address ... FAILED
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:41.7076888Z test wire::tests::format_empty_from_is_anonymous ... ok
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:41.7077209Z test wire::tests::format_with_from_prepends_reply_to ... ok
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:41.7081453Z test wire::tests::frame_round_trips_over_loopback ... ok
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8255159Z failures:
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8255284Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8255431Z ---- listener::tests::blocking_times_out_when_idle stdout ----
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8255629Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8255869Z thread 'listener::tests::blocking_times_out_when_idle'
(44608) panicked at crates\spt-msg\src\listener.rs:253:85:
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8256491Z called `Result::unwrap()` on an `Err` value: "Failed to
register address: unable to open database file: C:\\Users\\decid\\AppData\\Local\\Temp\\.tmposOin8\\owlery\\.registry"
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8257428Z note: run with `RUST_BACKTRACE=1` environment variable to
display a backtrace
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8257666Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8257795Z ---- listener::tests::bind_registers_and_round_trips stdout
----
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8258243Z thread 'listener::tests::bind_registers_and_round_trips'
(60476) panicked at crates\spt-msg\src\listener.rs:209:48:
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8258866Z called `Result::unwrap()` on an `Err` value: "Failed to
register address: unable to open database file: C:\\Users\\decid\\AppData\\Local\\Temp\\.tmposOin8\\owlery\\.registry"
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8259387Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8259540Z ---- deliver::tests::send_outcome_mapping stdout ----
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8259696Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8259897Z thread 'deliver::tests::send_outcome_mapping' (38936)
panicked at crates\spt-msg\src\deliver.rs:271:49:
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8260475Z called `Result::unwrap()` on an `Err` value: "Failed to
register address: unable to open database file: C:\\Users\\decid\\AppData\\Local\\Temp\\.tmposOin8\\owlery\\.registry"
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8260862Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8260971Z ---- listener::tests::drop_unregisters_address stdout ----
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8261148Z
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8261386Z thread 'listener::tests::drop_unregisters_address' (56652)
panicked at crates\spt-msg\src\listener.rs:241:46:
> test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8262057Z called `Result::unwrap()` on an `Err` value: "Failed to
register address: unable to open database file: C:\\Users\\decid\\AppData\\Local\\Temp\\.tmposOin8\\owlery\\.registry"
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8262412Z
  test (self-hosted, Windows, hfenduleam)	Test	2026-06-04T02:02:43.8262414Z


180	pub(crate) fn write_busy(perch_path: &std::path::Path) {
181	    if let Some(mut info) = info::read_info(perch_path) {
182	        info.pid = PidValue::Busy("BUSY".to_string());
183	        let _ = info::write_info(perch_path, &info);
184	    }
185	}
186
187	#[cfg(test)]
188	mod tests {
189	    use super::*;
190	    use std::net::TcpStream;
191	    use std::time::Instant;
192
193	    /// Unique perch id per invocation (pid + nanos) so concurrent tests sharing
194	    /// the process-global SPT_HOME registry never collide.
195	    fn unique_id(prefix: &str) -> String {
196	        let nanos = std::time::SystemTime::now()
197	            .duration_since(std::time::UNIX_EPOCH)
198	            .map(|d| d.as_nanos())
199	            .unwrap_or(0);
200	        format!("{}-{}-{}", prefix, std::process::id(), nanos)
201	    }
202
203	    // [unit->REQ-MSG-1] a non-blocking listener binds a loopback port, registers
204	    // its address, and a framed envelope round-trips client -> listener.
205	    #[test]
206	    fn bind_registers_and_round_trips() {
207	        crate::test_home::isolate();
208	        let id = unique_id("test-bind");
209	        let listener = PollListener::bind(&id).unwrap();
210	        let addr = listener.local_addr().unwrap();
211
212	        // Registry holds the bound address.
213	        let owlery = perch::owlery_dir();
214	        assert_eq!(registry::lookup_address(&id, &owlery), Some(addr.to_string()));
215
216	        // Deliver one framed message.
217	        let mut client = TcpStream::connect(addr).unwrap();
218	        wire::write_message(&mut client, "sender", "ping").unwrap();
219	        drop(client);
220
221	        // Non-blocking accept may briefly WouldBlock before the connection lands.
222	        let mut got = None;
223	        for _ in 0..200 {
224	            if let Some(m) = listener.check_message() {
225	                got = Some(m);
226	                break;
227	            }
228	            std::thread::sleep(Duration::from_millis(5));
229	        }
230	        assert_eq!(got.as_deref(), Some("__REPLY_TO__:sender\nping\n"));
231	    }
232
233	    // [unit->REQ-MSG-1] dropping a listener removes its registry address (no
234	    // stale entry survives a dead listener).
235	    #[test]
236	    fn drop_unregisters_address() {
237	        crate::test_home::isolate();
238	        let id = unique_id("test-drop");
239	        let owlery = perch::owlery_dir();
240	        {
241	            let _l = PollListener::bind(&id).unwrap();
242	            assert!(registry::lookup_address(&id, &owlery).is_some());
243	        }
244	        assert!(registry::lookup_address(&id, &owlery).is_none());
245	    }
246
247	    // [unit->REQ-MSG-1] a blocking listener returns None within ~timeout when
248	    // no connection arrives (bounded wait, not a hang).
249	    #[test]
250	    fn blocking_times_out_when_idle() {
251	        crate::test_home::isolate();
252	        let id = unique_id("test-block-idle");
253	        let listener = PollListener::bind_blocking(&id, Duration::from_millis(100)).unwrap();
254	        let start = Instant::now();
255	        assert!(listener.check_message_blocking().is_none());
256	        assert!(start.elapsed() < Duration::from_millis(400));
257	    }
258
259	    // [unit->REQ-MSG-1] a blocking listener accepts a message that arrives
260	    // within the poll window.
261	    #[test]
262	    fn blocking_accepts_within_window() {
263	        crate::test_home::isolate();
264	        let id = unique_id("test-block-recv");
265	        let listener = PollListener::bind_blocking(&id, Duration::from_millis(500)).unwrap();
266	        let addr = listener.local_addr().unwrap();
267
268	        let h = std::thread::spawn(move || {
269	            std::thread::sleep(Duration::from_millis(30));

pub(crate) fn write_busy(perch_path: &std::path::Path) {
    if let Some(mut info) = info::read_info(perch_path) {
        info.pid = PidValue::Busy("BUSY".to_string());
        let _ = info::write_info(perch_path, &info);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::net::TcpStream;
    use std::time::Instant;

    /// Unique perch id per invocation (pid + nanos) so concurrent tests sharing
    /// the process-global SPT_HOME registry never collide.
    fn unique_id(prefix: &str) -> String {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        format!("{}-{}-{}", prefix, std::process::id(), nanos)
    }

    // [unit->REQ-MSG-1] a non-blocking listener binds a loopback port, registers
    // its address, and a framed envelope round-trips client -> listener.
    #[test]
    fn bind_registers_and_round_trips() {
        crate::test_home::isolate();
        let id = unique_id("test-bind");
        let listener = PollListener::bind(&id).unwrap();
        let addr = listener.local_addr().unwrap();

        // Registry holds the bound address.
        let owlery = perch::owlery_dir();
        assert_eq!(registry::lookup_address(&id, &owlery), Some(addr.to_string()));

        // Deliver one framed message.
        let mut client = TcpStream::connect(addr).unwrap();
        wire::write_message(&mut client, "sender", "ping").unwrap();
        drop(client);

        // Non-blocking accept may briefly WouldBlock before the connection lands.
        let mut got = None;
        for _ in 0..200 {
            if let Some(m) = listener.check_message() {
                got = Some(m);
                break;
            }
            std::thread::sleep(Duration::from_millis(5));
        }
        assert_eq!(got.as_deref(), Some("__REPLY_TO__:sender\nping\n"));
    }

    // [unit->REQ-MSG-1] dropping a listener removes its registry address (no
    // stale entry survives a dead listener).
    #[test]
    fn drop_unregisters_address() {
        crate::test_home::isolate();
        let id = unique_id("test-drop");
        let owlery = perch::owlery_dir();
        {
            let _l = PollListener::bind(&id).unwrap();
            assert!(registry::lookup_address(&id, &owlery).is_some());
        }
        assert!(registry::lookup_address(&id, &owlery).is_none());
    }

    // [unit->REQ-MSG-1] a blocking listener returns None within ~timeout when
    // no connection arrives (bounded wait, not a hang).
    #[test]
    fn blocking_times_out_when_idle() {
        crate::test_home::isolate();
        let id = unique_id("test-block-idle");
        let listener = PollListener::bind_blocking(&id, Duration::from_millis(100)).unwrap();
        let start = Instant::now();
        assert!(listener.check_message_blocking().is_none());
        assert!(start.elapsed() < Duration::from_millis(400));
    }

    // [unit->REQ-MSG-1] a blocking listener accepts a message that arrives
    // within the poll window.
    #[test]
    fn blocking_accepts_within_window() {
        crate::test_home::isolate();
        let id = unique_id("test-block-recv");
        let listener = PollListener::bind_blocking(&id, Duration::from_millis(500)).unwrap();
        let addr = listener.local_addr().unwrap();

        let h = std::thread::spawn(move || {
            std::thread::sleep(Duration::from_millis(30));


TaskCreate

Fix KH 4.7 flake: spt-msg registry CANTOPEN on hfenduleam (recurrence #2)

4 spt-msg unit tests failed on CI Windows leg with "unable to open database file ...owlery\.registry" — shared test_home fixture. Investigate test_home::isolate + registry open path (dir creation race / AV scan). Fix, then re-push D4d+doc.


Task #6 created successfully: Fix KH 4.7 flake: spt-msg registry CANTOPEN on hfenduleam (recurrence #2)


C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-160-    // [unit->REQ-HAZARD-EPHEMERAL-CLEANUP]
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-161-    #[test]
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-162-    fn ring_gets_reply_and_cleans_up() {
crates\spt-msg\src\ring.rs:163:        crate::test_home::isolate();
crates\spt-msg\src\ring.rs-164-        let owlery = perch::owlery_dir();
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-165-        let target = unique_id("test-ring-target");
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-166-        let from = unique_id("test-ring-from");
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-197-    // [unit->REQ-HAZARD-EPHEMERAL-CLEANUP] timeout path also cleans up.
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-198-    #[test]
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-199-    fn ring_timeout_cleans_up() {
crates\spt-msg\src\ring.rs:200:        crate::test_home::isolate();
crates\spt-msg\src\ring.rs-201-        let owlery = perch::owlery_dir();
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-202-        let target = unique_id("test-ring-silent");
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-203-        let from = unique_id("test-ring-from2");
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-214-    // ephemeral perch cleaned.
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-215-    #[test]
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-216-    fn ring_offline_target_queues_and_cleans_up() {
crates\spt-msg\src\ring.rs:217:        crate::test_home::isolate();
crates\spt-msg\src\ring.rs-218-        let owlery = perch::owlery_dir();
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-219-        let target = unique_id("test-ring-offline");
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-220-        let from = unique_id("test-ring-from3");
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-235-    // [unit->REQ-MSG-1] no target perch => NoPerch, nothing created.
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-236-    #[test]
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-237-    fn ring_no_perch() {
crates\spt-msg\src\ring.rs:238:        crate::test_home::isolate();
crates\spt-msg\src\ring.rs-239-        let owlery = perch::owlery_dir();
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-240-        let target = unique_id("test-ring-absent");
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\ring.rs-241-        let from = unique_id("test-ring-from4");
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\lib.rs-27-/// Without an override the default home is `%LOCALAPPDATA%\spt-core` — distinct
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\lib.rs-28-/// from the modern spt runtime's `spt/` home, but still a real shared dir that
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\lib.rs-29-/// must not collect test debris. Every unit test that touches a perch MUST call
crates\spt-msg\src\lib.rs:30:/// [`test_home::isolate`] first so it operates in a throwaway temp dir, never
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\lib.rs-31-/// the real one.
C:\Users\decid\Documents\projects\spt-core\crates\spt-msg\src\lib.rs-32-#[cfg(test)]
crates\spt-msg\src\lib.rs:33:pub(crate) mod test_home {
crates\spt-msg\src\lib.rs-34-    use std::sync::OnceLock;

[Showing results with pagination = limit: 40]


20	pub mod listener;
21	pub mod ready;
22	pub mod ring;
23	pub mod wire;
24
25	/// Test-only home isolation. The listener/ready/ring surfaces resolve the
26	/// owlery through `spt_store::perch::owlery_dir()`, which honors `$SPT_HOME`.
27	/// Without an override the default home is `%LOCALAPPDATA%\spt-core` — distinct
28	/// from the modern spt runtime's `spt/` home, but still a real shared dir that
29	/// must not collect test debris. Every unit test that touches a perch MUST call
30	/// [`test_home::isolate`] first so it operates in a throwaway temp dir, never
31	/// the real one.
32	#[cfg(test)]
33	pub(crate) mod test_home {
34	    use std::sync::OnceLock;
35	    use tempfile::TempDir;
36
37	    static HOME: OnceLock<TempDir> = OnceLock::new();
38
39	    /// Point `$SPT_HOME` at an isolated temp dir for the whole test process.
40	    /// Idempotent: the first caller wins, every later caller blocks until the
41	    /// env is set, so no test performs a perch op against the default home.
42	    pub(crate) fn isolate() {
43	        HOME.get_or_init(|| {
44	            let dir = TempDir::new().expect("temp SPT_HOME");
45	            std::env::set_var("SPT_HOME", dir.path());
46	            dir
47	        });
48	    }
49	}
50

pub mod listener;
pub mod ready;
pub mod ring;
pub mod wire;

/// Test-only home isolation. The listener/ready/ring surfaces resolve the
/// owlery through `spt_store::perch::owlery_dir()`, which honors `$SPT_HOME`.
/// Without an override the default home is `%LOCALAPPDATA%\spt-core` — distinct
/// from the modern spt runtime's `spt/` home, but still a real shared dir that
/// must not collect test debris. Every unit test that touches a perch MUST call
/// [`test_home::isolate`] first so it operates in a throwaway temp dir, never
/// the real one.
#[cfg(test)]
pub(crate) mod test_home {
    use std::sync::OnceLock;
    use tempfile::TempDir;

    static HOME: OnceLock<TempDir> = OnceLock::new();

    /// Point `$SPT_HOME` at an isolated temp dir for the whole test process.
    /// Idempotent: the first caller wins, every later caller blocks until the
    /// env is set, so no test performs a perch op against the default home.
    pub(crate) fn isolate() {
        HOME.get_or_init(|| {
            let dir = TempDir::new().expect("temp SPT_HOME");
            std::env::set_var("SPT_HOME", dir.path());
            dir
        });
    }
}


crates\spt-store\src\history.rs:24:    std::fs::create_dir_all(perch_path)?;
crates\spt-store\src\registry.rs:1://! SQLite registry for agent address discovery — `<owlery>/.registry`.
crates\spt-store\src\registry.rs:20:/// Open (creating if needed) the registry DB at `<owlery>/.registry`.
crates\spt-store\src\registry.rs:22:    let db_path = owlery.join(".registry");
crates\spt-store\src\registry.rs:217:        std::fs::create_dir_all(&hosted).unwrap();
crates\spt-store\src\nodeid.rs:69:                    std::fs::create_dir_all(parent)
crates\spt-store\src\epoch.rs:61:            std::fs::create_dir_all(parent)?;
crates\spt-store\src\liveness.rs:182:        std::fs::create_dir_all(&off).unwrap();
crates\spt-store\src\liveness.rs:192:        std::fs::create_dir_all(&on).unwrap();
crates\spt-store\src\perch.rs:13://!   `create_dir_all`s on open).
crates\spt-store\src\perch.rs:62:pub fn owlery_dir() -> PathBuf {
crates\spt-store\src\perch.rs:189:    resolve_perch_path_in(&owlery_dir(), child_id, hint)
crates\spt-store\src\perch.rs:193:/// process-global [`owlery_dir`]. Same single-source layout logic — this is the
crates\spt-store\src\perch.rs:421:            std::fs::create_dir_all(&p).unwrap();
crates\spt-store\src\perch.rs:425:        std::fs::create_dir_all(owlery.join("not-a-perch")).unwrap();
crates\spt-store\src\perch.rs:428:        std::fs::create_dir_all(&nested).unwrap();
crates\spt-store\src\rename.rs:12://! The local SQLite address registry (`<owlery>/.registry`) is deliberately NOT
crates\spt-store\src\rename.rs:148:        std::fs::create_dir_all(dir).unwrap();
crates\spt-store\src\subnet.rs:135:            std::fs::create_dir_all(parent).map_err(|e| SubnetError::Io(e.to_string()))?;
crates\spt-store\src\visibility.rs:83:            std::fs::create_dir_all(parent).map_err(|e| SubnetError::Io(e.to_string()))?;
crates\spt-store\src\trust.rs:142:            std::fs::create_dir_all(parent).map_err(|e| TrustError::Io(e.to_string()))?;
crates\spt-store\src\spool.rs:43:        std::fs::create_dir_all(perch_path)


1	//! SQLite registry for agent address discovery — `<owlery>/.registry`.
2	//!
3	//! Schema + surface copied verbatim from sister `registry.rs` (ADR-0001). Maps
4	//! an agent id → its TCP address + owning pid. This is the **local** registry
5	//! (one machine's perches); cross-node subnet resolution (REQ-INST-7 full) is
6	//! M4. M0 delivers local persistence + the stale-entry invariant only.
7	//!
8	//! ## Stale-entry invariant (KNOWN-HAZARDS 4.3, REQ-HAZARD-REGISTRY-STALE-CLEAN)
9	//! A registered pid can die without unregistering (crash, kill). Resolving its
10	//! stale address would deliver to a dead/wrong listener. So [`clean_stale_entries`]
11	//! drops dead-pid rows, and [`resolve_address`] cleans **before** lookup. The
12	//! clean step is best-effort: a cleanup failure never hard-fails resolution
13	//! (the spool is the safe fallback on a TCP miss).
14
15	use rusqlite::{params, Connection};
16	use std::path::Path;
17
18	use crate::liveness;
19
20	/// Open (creating if needed) the registry DB at `<owlery>/.registry`.
21	fn open_registry(owlery: &Path) -> rusqlite::Result<Connection> {
22	    let db_path = owlery.join(".registry");
23	    let conn = Connection::open(&db_path)?;
24	    // [impl->REQ-HAZARD-REGISTRY-CONCURRENT] concurrency-safe pragmas (busy_timeout
25	    // first, then WAL-with-retry) so concurrent ReadyAgent registrations don't
26	    // fail with "database is locked".
27	    crate::db::tune_connection(&conn)?;
28	    conn.execute_batch(
29	        "CREATE TABLE IF NOT EXISTS agents (
30	            id TEXT PRIMARY KEY,
31	            address TEXT NOT NULL,
32	            pid INTEGER NOT NULL,
33	            started TEXT NOT NULL
34	        )",
35	    )?;
36	    Ok(conn)
37	}
38
39	fn now_stamp() -> String {
40	    std::time::SystemTime::now()
41	        .duration_since(std::time::UNIX_EPOCH)
42	        .map(|d| d.as_secs())
43	        .unwrap_or(0)
44	        .to_string()
45	}
46
47	/// Register (or replace) an agent's TCP address under the **current** process's
48	/// pid.
49	// [impl->REQ-HAZARD-REGISTRY-STALE-CLEAN]
50	pub fn register_address(id: &str, addr: &std::net::SocketAddr, owlery: &Path) -> rusqlite::Result<()> {
51	    register_address_with_pid(id, addr, std::process::id(), owlery)
52	}
53
54	/// Register (or replace) an agent's address under an explicit owning `pid`
55	/// (e.g. the daemon registering on behalf of a hosted endpoint process).
56	// [impl->REQ-HAZARD-REGISTRY-STALE-CLEAN]
57	pub fn register_address_with_pid(
58	    id: &str,
59	    addr: &std::net::SocketAddr,
60	    pid: u32,
61	    owlery: &Path,
62	) -> rusqlite::Result<()> {
63	    let conn = open_registry(owlery)?;
64	    conn.execute(
65	        "INSERT OR REPLACE INTO agents (id, address, pid, started) VALUES (?1, ?2, ?3, ?4)",
66	        params![id, addr.to_string(), pid, now_stamp()],
67	    )?;
68	    Ok(())
69	}
70

//! SQLite registry for agent address discovery — `<owlery>/.registry`.
//!
//! Schema + surface copied verbatim from sister `registry.rs` (ADR-0001). Maps
//! an agent id → its TCP address + owning pid. This is the **local** registry
//! (one machine's perches); cross-node subnet resolution (REQ-INST-7 full) is
//! M4. M0 delivers local persistence + the stale-entry invariant only.
//!
//! ## Stale-entry invariant (KNOWN-HAZARDS 4.3, REQ-HAZARD-REGISTRY-STALE-CLEAN)
//! A registered pid can die without unregistering (crash, kill). Resolving its
//! stale address would deliver to a dead/wrong listener. So [`clean_stale_entries`]
//! drops dead-pid rows, and [`resolve_address`] cleans **before** lookup. The
//! clean step is best-effort: a cleanup failure never hard-fails resolution
//! (the spool is the safe fallback on a TCP miss).

use rusqlite::{params, Connection};
use std::path::Path;

use crate::liveness;

/// Open (creating if needed) the registry DB at `<owlery>/.registry`.
fn open_registry(owlery: &Path) -> rusqlite::Result<Connection> {
    let db_path = owlery.join(".registry");
    let conn = Connection::open(&db_path)?;
    // [impl->REQ-HAZARD-REGISTRY-CONCURRENT] concurrency-safe pragmas (busy_timeout
    // first, then WAL-with-retry) so concurrent ReadyAgent registrations don't
    // fail with "database is locked".
    crate::db::tune_connection(&conn)?;
    conn.execute_batch(
        "CREATE TABLE IF NOT EXISTS agents (
            id TEXT PRIMARY KEY,
            address TEXT NOT NULL,
            pid INTEGER NOT NULL,
            started TEXT NOT NULL
        )",
    )?;
    Ok(conn)
}

fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
        .to_string()
}

/// Register (or replace) an agent's TCP address under the **current** process's
/// pid.
// [impl->REQ-HAZARD-REGISTRY-STALE-CLEAN]
pub fn register_address(id: &str, addr: &std::net::SocketAddr, owlery: &Path) -> rusqlite::Result<()> {
    register_address_with_pid(id, addr, std::process::id(), owlery)
}

/// Register (or replace) an agent's address under an explicit owning `pid`
/// (e.g. the daemon registering on behalf of a hosted endpoint process).
// [impl->REQ-HAZARD-REGISTRY-STALE-CLEAN]
pub fn register_address_with_pid(
    id: &str,
    addr: &std::net::SocketAddr,
    pid: u32,
    owlery: &Path,
) -> rusqlite::Result<()> {
    let conn = open_registry(owlery)?;
    conn.execute(
        "INSERT OR REPLACE INTO agents (id, address, pid, started) VALUES (?1, ?2, ?3, ?4)",
        params![id, addr.to_string(), pid, now_stamp()],
    )?;
    Ok(())
}


99:## 4. Wire / transport
101:### 4.1 Envelope HTML-entity codec ordering — `&` decoded LAST
107:### 4.2 Two-slice envelope parser is panic-free and tolerant
113:### 4.3 Registry stale-entry cleanup precedes lookup
119:### 4.4 Deferred rows survive poll drain
124:### 4.5 Inbox legacy compat must not double-deliver
130:### 4.6 Addressable-id charset reserves the address delimiters
136:### 4.7 Concurrent SQLite openers must not fail with "database is locked"
142:### 4.8 Registry merge ordered by epoch, never wall-clock (red-team #8)


136	### 4.7 Concurrent SQLite openers must not fail with "database is locked"
137	<!-- [doc->REQ-HAZARD-REGISTRY-CONCURRENT] -->
138	- **Failure:** two endpoints on one machine open the same SQLite store at once (e.g. two `ReadyAgent::start` calls registering simultaneously) and one fails outright with `SQLITE_BUSY` / "database is locked" → spurious registration/spool failure. Surfaced as a parallel-test flake in `two_agents_exchange_message_tcp_and_spool`, but the bug is real concurrency, not test-only.
139	- **Invariant:** `busy_timeout` is set **before** any lock-taking statement on every connection. Switching `journal_mode=WAL` takes a brief exclusive lock; with the default 0ms timeout it fails immediately under contention, so the pragma order is load-bearing: `Connection::open` → `busy_timeout` → `journal_mode=WAL` → `CREATE TABLE …`. WAL alone is insufficient (concurrent *writers* still serialize; they must *wait*, not error).
140	- **spt-core mapping:** `spt_store::registry::open_registry` + `spt_store::spool::open_spool_at`; both set `busy_timeout=5000` first. Any future SQLite store (history Path B, instance registry) must follow the same ordering.
141
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	---
149
150	## 5. Platform-specific
151

### 4.7 Concurrent SQLite openers must not fail with "database is locked"
<!-- [doc->REQ-HAZARD-REGISTRY-CONCURRENT] -->
- **Failure:** two endpoints on one machine open the same SQLite store at once (e.g. two `ReadyAgent::start` calls registering simultaneously) and one fails outright with `SQLITE_BUSY` / "database is locked" → spurious registration/spool failure. Surfaced as a parallel-test flake in `two_agents_exchange_message_tcp_and_spool`, but the bug is real concurrency, not test-only.
- **Invariant:** `busy_timeout` is set **before** any lock-taking statement on every connection. Switching `journal_mode=WAL` takes a brief exclusive lock; with the default 0ms timeout it fails immediately under contention, so the pragma order is load-bearing: `Connection::open` → `busy_timeout` → `journal_mode=WAL` → `CREATE TABLE …`. WAL alone is insufficient (concurrent *writers* still serialize; they must *wait*, not error).
- **spt-core mapping:** `spt_store::registry::open_registry` + `spt_store::spool::open_spool_at`; both set `busy_timeout=5000` first. Any future SQLite store (history Path B, instance registry) must follow the same ordering.

### 4.8 Registry merge ordered by epoch, never wall-clock (red-team #8)
<!-- [doc->REQ-HAZARD-REGISTRY-EPOCH-LEASE] -->
- **Failure:** the per-subnet registry replicates `endpoint_id → [instances]` eventually-consistently across nodes. Under a partition or clock skew, a lagging node re-announces a stale `Active` for an endpoint that has actually gone `Offline`. If the merge ordered updates by wall-clock (or "last write wins"), the stale `Active` overwrites the newer `Offline` and resolution routes a message to a dead/wrong instance.
- **Invariant:** the merge precedence key is a **per-node monotonic epoch counter** (`spt_store::epoch::EpochSource`, persisted, strictly increasing, NEVER wall-clock), compared version-vector style per `(endpoint_id, node)`: an incoming update wins **iff its epoch is strictly greater** than the stored one for that node; equal or lower is dropped as stale. So a newer `Offline` (higher epoch) can never be clobbered by a lagging `Active` (lower epoch), and an idempotent equal-epoch replay is a no-op. Wall-clock is at most a human tiebreaker hint inside a flagged conflict, never the ordering authority. The same epoch source unifies with the D6 sync-precedence concurrent-write detection (#7).
- **spt-core mapping:** `spt_net::net::registry::SubnetRegistry::merge_instance` (the lease) + `spt_store::epoch::EpochSource` (the counter). Cross-node replication of the merge wires at D4; the merge seam is identical for local and wire-delivered updates. Chaos/two-host verification = D9.

---

## 5. Platform-specific


Fix + new hazard entry. First the code:


Edit

/// Open (creating if needed) the registry DB at `<owlery>/.registry`.
fn open_registry(owlery: &Path) -> rusqlite::Result<Connection> {
    let db_path = owlery.join(".registry");
    let conn = Connection::open(&db_path)?;

/// Open (creating if needed) the registry DB at `<owlery>/.registry`.
fn open_registry(owlery: &Path) -> rusqlite::Result<Connection> {
    // [impl->REQ-HAZARD-REGISTRY-DIR-CREATE] SQLite creates the FILE but never
    // the parent DIR: on a fresh home (first boot, fresh CI workdir) a registry
    // op that races ahead of any perch-creating op fails SQLITE_CANTOPEN
    // ("unable to open database file"). Ensure the owlery exists, best-effort —
    // if creation truly fails, Connection::open surfaces the real error (4.9).
    let _ = std::fs::create_dir_all(owlery);
    let db_path = owlery.join(".registry");
    let conn = Connection::open(&db_path)?;


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

/// Open (creating if needed) the registry DB at `<owlery>/.registry`.
fn open_registry(owlery: &Path) -> rusqlite::Result<Connection> {
    let db_path = owlery.join(".registry");
    let conn = Connection::open(&db_path)?;

/// Open (creating if needed) the registry DB at `<owlery>/.registry`.
fn open_registry(owlery: &Path) -> rusqlite::Result<Connection> {
    // [impl->REQ-HAZARD-REGISTRY-DIR-CREATE] SQLite creates the FILE but never
    // the parent DIR: on a fresh home (first boot, fresh CI workdir) a registry
    // op that races ahead of any perch-creating op fails SQLITE_CANTOPEN
    // ("unable to open database file"). Ensure the owlery exists, best-effort —
    // if creation truly fails, Connection::open surfaces the real error (4.9).
    let _ = std::fs::create_dir_all(owlery);
    let db_path = owlery.join(".registry");
    let conn = Connection::open(&db_path)?;

+    // [impl->REQ-HAZARD-REGISTRY-DIR-CREATE] SQLite creates the FILE but never

+    // the parent DIR: on a fresh home (first boot, fresh CI workdir) a registry

+    // op that races ahead of any perch-creating op fails SQLITE_CANTOPEN

+    // ("unable to open database file"). Ensure the owlery exists, best-effort —

+    // if creation truly fails, Connection::open surfaces the real error (4.9).

+    let _ = std::fs::create_dir_all(owlery);

// [impl->REQ-HAZARD-REGISTRY-CONCURRENT] concurrency-safe pragmas (busy_timeout


190	        assert_eq!(lookup_address("nobody", d.path()), None);
191	    }
192
193	    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] a dead-pid entry is cleaned; a
194	    // live one survives.
195	    #[test]
196	    fn clean_removes_dead_keeps_live() {
197	        let d = tempfile::tempdir().unwrap();
198	        register_address_with_pid("live", &addr(9001), std::process::id(), d.path()).unwrap();
199	        register_address_with_pid("dead", &addr(9002), dead_pid(), d.path()).unwrap();
200
201	        clean_stale_entries(d.path()).unwrap();
202
203	        assert_eq!(lookup_address("live", d.path()), Some("127.0.0.1:9001".to_string()));
204	        assert_eq!(lookup_address("dead", d.path()), None, "dead-pid entry must be cleaned");
205	    }
206
207	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted row is cleaned on
208	    // its `status`, not the (shared, live) daemon pid: an `offline` hosted
209	    // endpoint registered under THIS live process is still swept, while an
210	    // external row under the same live pid survives.
211	    #[test]
212	    fn clean_keys_on_status_for_daemon_hosted_rows() {
213	        use crate::info::{set_status, write_info, InfoJson};
214	        use crate::liveness::STATUS_OFFLINE;
215	        use crate::perch::{resolve_perch_path_in, ParentHint};
216
217	        let d = tempfile::tempdir().unwrap();
218	        let owlery = d.path();
219	        let live = std::process::id();
220
221	        // Daemon-hosted, offline, registered under the live daemon pid.
222	        let hosted = resolve_perch_path_in(owlery, "hosted", ParentHint::Infer);
223	        std::fs::create_dir_all(&hosted).unwrap();
224	        write_info(&hosted, &InfoJson::new("hosted", "t", live, "sid", "psyche")).unwrap();
225	        set_status(&hosted, STATUS_OFFLINE).unwrap();
226	        register_address_with_pid("hosted", &addr(9100), live, owlery).unwrap();
227
228	        // External (no status), same live pid — must survive.
229	        register_address_with_pid("ext", &addr(9101), live, owlery).unwrap();
230
231	        clean_stale_entries(owlery).unwrap();
232
233	        assert_eq!(lookup_address("hosted", owlery), None, "offline hosted row swept despite live daemon pid");
234	        assert_eq!(lookup_address("ext", owlery), Some("127.0.0.1:9101".to_string()), "external live-pid row survives");
235	    }
236
237	    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] resolve_address cleans stale
238	    // BEFORE lookup: a dead entry never resolves; a live one does.
239	    #[test]
240	    fn resolve_cleans_before_lookup() {
241	        let d = tempfile::tempdir().unwrap();
242	        register_address_with_pid("dead", &addr(9003), dead_pid(), d.path()).unwrap();
243	        register_address_with_pid("live", &addr(9004), std::process::id(), d.path()).unwrap();
244
245	        assert_eq!(resolve_address("dead", d.path()), None, "stale resolved → None, not a dead address");
246	        assert_eq!(resolve_address("live", d.path()), Some("127.0.0.1:9004".to_string()));
247	    }
248
249	    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] resolve never hard-fails on a
250	    // missing/empty registry — returns None.
251	    #[test]
252	    fn resolve_on_empty_registry_is_none_not_error() {
253	        let d = tempfile::tempdir().unwrap();
254	        assert_eq!(resolve_address("anyone", d.path()), None);
255	    }
256
257	    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] re-register replaces (id is PK).
258	    #[test]
259	    fn reregister_replaces_address() {

        assert_eq!(lookup_address("nobody", d.path()), None);
    }

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] a dead-pid entry is cleaned; a
    // live one survives.
    #[test]
    fn clean_removes_dead_keeps_live() {
        let d = tempfile::tempdir().unwrap();
        register_address_with_pid("live", &addr(9001), std::process::id(), d.path()).unwrap();
        register_address_with_pid("dead", &addr(9002), dead_pid(), d.path()).unwrap();

        clean_stale_entries(d.path()).unwrap();

        assert_eq!(lookup_address("live", d.path()), Some("127.0.0.1:9001".to_string()));
        assert_eq!(lookup_address("dead", d.path()), None, "dead-pid entry must be cleaned");
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted row is cleaned on
    // its `status`, not the (shared, live) daemon pid: an `offline` hosted
    // endpoint registered under THIS live process is still swept, while an
    // external row under the same live pid survives.
    #[test]
    fn clean_keys_on_status_for_daemon_hosted_rows() {
        use crate::info::{set_status, write_info, InfoJson};
        use crate::liveness::STATUS_OFFLINE;
        use crate::perch::{resolve_perch_path_in, ParentHint};

        let d = tempfile::tempdir().unwrap();
        let owlery = d.path();
        let live = std::process::id();

        // Daemon-hosted, offline, registered under the live daemon pid.
        let hosted = resolve_perch_path_in(owlery, "hosted", ParentHint::Infer);
        std::fs::create_dir_all(&hosted).unwrap();
        write_info(&hosted, &InfoJson::new("hosted", "t", live, "sid", "psyche")).unwrap();
        set_status(&hosted, STATUS_OFFLINE).unwrap();
        register_address_with_pid("hosted", &addr(9100), live, owlery).unwrap();

        // External (no status), same live pid — must survive.
        register_address_with_pid("ext", &addr(9101), live, owlery).unwrap();

        clean_stale_entries(owlery).unwrap();

        assert_eq!(lookup_address("hosted", owlery), None, "offline hosted row swept despite live daemon pid");
        assert_eq!(lookup_address("ext", owlery), Some("127.0.0.1:9101".to_string()), "external live-pid row survives");
    }

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] resolve_address cleans stale
    // BEFORE lookup: a dead entry never resolves; a live one does.
    #[test]
    fn resolve_cleans_before_lookup() {
        let d = tempfile::tempdir().unwrap();
        register_address_with_pid("dead", &addr(9003), dead_pid(), d.path()).unwrap();
        register_address_with_pid("live", &addr(9004), std::process::id(), d.path()).unwrap();

        assert_eq!(resolve_address("dead", d.path()), None, "stale resolved → None, not a dead address");
        assert_eq!(resolve_address("live", d.path()), Some("127.0.0.1:9004".to_string()));
    }

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] resolve never hard-fails on a
    // missing/empty registry — returns None.
    #[test]
    fn resolve_on_empty_registry_is_none_not_error() {
        let d = tempfile::tempdir().unwrap();
        assert_eq!(resolve_address("anyone", d.path()), None);
    }

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] re-register replaces (id is PK).
    #[test]
    fn reregister_replaces_address() {


Add regression test:


    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] resolve never hard-fails on a
    // missing/empty registry — returns None.
    #[test]
    fn resolve_on_empty_registry_is_none_not_error() {
        let d = tempfile::tempdir().unwrap();
        assert_eq!(resolve_address("anyone", d.path()), None);
    }

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] resolve never hard-fails on a
    // missing/empty registry — returns None.
    #[test]
    fn resolve_on_empty_registry_is_none_not_error() {
        let d = tempfile::tempdir().unwrap();
        assert_eq!(resolve_address("anyone", d.path()), None);
    }

    // [unit->REQ-HAZARD-REGISTRY-DIR-CREATE] a registry op on a fresh home —
    // the owlery dir does not exist yet (first boot / fresh CI workdir, no
    // perch-creating op has run) — creates the dir and succeeds, instead of
    // failing SQLITE_CANTOPEN ("unable to open database file", 4.9). Twice
    // CI-bitten on hfenduleam before this row existed.
    #[test]
    fn register_on_nonexistent_owlery_creates_dir_and_succeeds() {
        let d = tempfile::tempdir().unwrap();
        let owlery = d.path().join("home").join("owlery"); // deliberately absent
        assert!(!owlery.exists());

        register_address_with_pid("first", &addr(9200), std::process::id(), &owlery)
            .expect("registry op on a fresh home must not CANTOPEN");

        assert!(owlery.exists(), "owlery dir created by the registry open");
        assert_eq!(lookup_address("first", &owlery), Some("127.0.0.1:9200".to_string()));
    }


    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] resolve never hard-fails on a
    // missing/empty registry — returns None.
    #[test]
    fn resolve_on_empty_registry_is_none_not_error() {
        let d = tempfile::tempdir().unwrap();
        assert_eq!(resolve_address("anyone", d.path()), None);
    }

    // [unit->REQ-HAZARD-REGISTRY-DIR-CREATE] a registry op on a fresh home —
    // the owlery dir does not exist yet (first boot / fresh CI workdir, no
    // perch-creating op has run) — creates the dir and succeeds, instead of
    // failing SQLITE_CANTOPEN ("unable to open database file", 4.9). Twice
    // CI-bitten on hfenduleam before this row existed.
    #[test]
    fn register_on_nonexistent_owlery_creates_dir_and_succeeds() {
        let d = tempfile::tempdir().unwrap();
        let owlery = d.path().join("home").join("owlery"); // deliberately absent
        assert!(!owlery.exists());

        register_address_with_pid("first", &addr(9200), std::process::id(), &owlery)
            .expect("registry op on a fresh home must not CANTOPEN");

        assert!(owlery.exists(), "owlery dir created by the registry open");
        assert_eq!(lookup_address("first", &owlery), Some("127.0.0.1:9200".to_string()));
    }

+    // [unit->REQ-HAZARD-REGISTRY-DIR-CREATE] a registry op on a fresh home —

+    // the owlery dir does not exist yet (first boot / fresh CI workdir, no

+    // perch-creating op has run) — creates the dir and succeeds, instead of

+    // failing SQLITE_CANTOPEN ("unable to open database file", 4.9). Twice

+    // CI-bitten on hfenduleam before this row existed.

+    fn register_on_nonexistent_owlery_creates_dir_and_succeeds() {

+        let d = tempfile::tempdir().unwrap();

+        let owlery = d.path().join("home").join("owlery"); // deliberately absent

+        register_address_with_pid("first", &addr(9200), std::process::id(), &owlery)

+            .expect("registry op on a fresh home must not CANTOPEN");

+        assert!(owlery.exists(), "owlery dir created by the registry open");

+        assert_eq!(lookup_address("first", &owlery), Some("127.0.0.1:9200".to_string()));

// [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] re-register replaces (id is PK).


4 spt-msg unit tests failed on CI Windows leg with "unable to open database file ...owlery\.registry" — shared test_home fixture. Investigate test_home::isolate + registry open path (dir creation race / AV scan). Fix, then re-push D4d+doc.


Updated task #6 status


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

---

## 5. Platform-specific

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