Write

# Dormancy resource budget (D9-3 — ADR-0003 red-team #9)

<!-- [doc->REQ-INST-3] -->

**What this answers.** ADR-0003's Stage-A red-team #9: *"dormant-warm default
has real resource/security cost — measure N dormant sessions on real adapters;
define an adapter-specific warm/cold policy + resource budget before locking
warm as the default."* This document records the measurement and locks the
policy with numbers instead of a guess.

## Methodology

The env-gated harness `crates/spt-daemon/tests/budget.rs` (`SPT_BUDGET=1`,
silent no-op otherwise — CI's normal sweep never runs it):

1. binds a real (net-less) broker — the production PTY-hosting path — in a
   hermetic `SPT_HOME`;
2. spawns N idle seats through `Brain::spawn_session_pid` (one probe brain per
   seat, each labeled `budget-<i>`), exactly the shape of a **dormant** seat:
   PTY master + harness child + output ring, state-preserved and undriven;
3. settles, then samples each child's **RSS**, **handle/fd count**, and
   **cumulative CPU** twice across an idle window — the CPU delta is the idle
   burn of a warm seat;
4. samples the host process's RSS before/after spawning (the broker-side
   per-seat overhead: PTY plumbing + output rings);
5. kills every seat (the **suspended** state — only the on-disk perch record
   would remain in production) and samples the residual.

Sampling: Windows via `Get-Process` (WorkingSet64 / HandleCount /
TotalProcessorTime); Linux via `/proc/<pid>/status` VmRSS, `/proc/<pid>/fd`
count, and `/proc/<pid>/stat` utime+stime. **Limitation:** the sample covers
the direct PTY child only — a harness that forks a worker tree under-reports
by the tree's cost (the claude numbers below are the main node process, which
dominates).

Knobs: `SPT_BUDGET_N` (seats), `SPT_BUDGET_PROGRAM`/`SPT_BUDGET_ARGS` (the
seat command — point at the real adapter's harness), `SPT_BUDGET_SETTLE_MS`,
`SPT_BUDGET_IDLE_MS`. Rig runs ride the CI `[budget]` commit-message gate
(both hosts measure in one tagged push) or a direct local
`SPT_BUDGET=1 cargo test -p spt-daemon --test budget --release -- --nocapture`.

## Numbers

### HFENDULEAM (Windows 11, 2026-06-04, local run)

5 × `cmd` (shell seat — the floor):

| metric | per-seat |
|---|---|
| RSS | 7.7 MiB |
| handles | 85 |
| idle CPU (5 s window) | 0.000 s |

3 × `claude` (real adapter seat — the heavy case):

| metric | per-seat |
|---|---|
| RSS | ~308.5 MiB (307.9–308.9) |
| handles | 244 |
| idle CPU (5 s window) | 0.000 s |

Broker-side overhead: ~0.2–0.3 MiB per seat (RSS delta ÷ N). Suspended
residual: host process returns to its ~6–7 MiB baseline — a suspended seat
costs **only the on-disk perch record** (bytes, not megabytes, no handles, no
CPU).

### gravity (Ubuntu 22.04, pending the next `[budget]`-tagged push)

| metric | shell seat | adapter seat |
|---|---|---|
| RSS | _pending_ | _pending_ |
| fds | _pending_ | _pending_ |
| idle CPU | _pending_ | _pending_ |

## The policy (locked by these numbers)

1. **Warm (dormant) stays the default.** An idle warm seat burns **zero CPU**
   on both adapter classes; the cost is purely resident memory. A shell-class
   seat is ~8 MiB — effectively free at any realistic seat count. A heavy
   LLM-harness seat is ~300 MiB — a workstation-class node holds ten warm
   seats in ~3 GB, acceptable for the instant-reactivation win
   (CONTEXT §dormant/suspended: wake-in-place is the point of warm).
2. **Auto-suspend stays opt-in, default OFF globally** — confirmed, not
   amended. The knob chain shipped in D9-2 (`auto_suspend_after_ms`: global
   default OFF → `daemon.json` node leg → `info.json` endpoint leg, `0` =
   explicit endpoint OFF) is the policy surface.
3. **Constrained nodes should default the node leg ON.** ~300 MiB/seat is
   real on a Pi/handheld-class node: set `auto_suspend_after_ms` in
   `daemon.json` there. The threshold counts from **dormancy onset** (the
   `dormant_since_ms` anchor), never from last activity.
4. **Heavy-harness endpoints may opt themselves out/in per seat** via the
   endpoint override — e.g. a billable harness whose warm session holds a
   cost/footprint beyond RSS (seat-count limits) sets a per-endpoint
   threshold even on a big node. Note: an **idle** claude seat holds no API
   spend (tokens flow only on input); the footprint concern there is seat
   count and memory, not per-minute billing.
5. **Suspended = cold = nearly free.** The measured residual confirms the
   suspended state's design: resume-on-wake trades latency for ~everything
   else.

ADR-0003 #9 is closed by this document (see the amendment note there).


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

# Dormancy resource budget (D9-3 — ADR-0003 red-team #9)

<!-- [doc->REQ-INST-3] -->

**What this answers.** ADR-0003's Stage-A red-team #9: *"dormant-warm default
has real resource/security cost — measure N dormant sessions on real adapters;
define an adapter-specific warm/cold policy + resource budget before locking
warm as the default."* This document records the measurement and locks the
policy with numbers instead of a guess.

## Methodology

The env-gated harness `crates/spt-daemon/tests/budget.rs` (`SPT_BUDGET=1`,
silent no-op otherwise — CI's normal sweep never runs it):

1. binds a real (net-less) broker — the production PTY-hosting path — in a
   hermetic `SPT_HOME`;
2. spawns N idle seats through `Brain::spawn_session_pid` (one probe brain per
   seat, each labeled `budget-<i>`), exactly the shape of a **dormant** seat:
   PTY master + harness child + output ring, state-preserved and undriven;
3. settles, then samples each child's **RSS**, **handle/fd count**, and
   **cumulative CPU** twice across an idle window — the CPU delta is the idle
   burn of a warm seat;
4. samples the host process's RSS before/after spawning (the broker-side
   per-seat overhead: PTY plumbing + output rings);
5. kills every seat (the **suspended** state — only the on-disk perch record
   would remain in production) and samples the residual.

Sampling: Windows via `Get-Process` (WorkingSet64 / HandleCount /
TotalProcessorTime); Linux via `/proc/<pid>/status` VmRSS, `/proc/<pid>/fd`
count, and `/proc/<pid>/stat` utime+stime. **Limitation:** the sample covers
the direct PTY child only — a harness that forks a worker tree under-reports
by the tree's cost (the claude numbers below are the main node process, which
dominates).

Knobs: `SPT_BUDGET_N` (seats), `SPT_BUDGET_PROGRAM`/`SPT_BUDGET_ARGS` (the
seat command — point at the real adapter's harness), `SPT_BUDGET_SETTLE_MS`,
`SPT_BUDGET_IDLE_MS`. Rig runs ride the CI `[budget]` commit-message gate
(both hosts measure in one tagged push) or a direct local
`SPT_BUDGET=1 cargo test -p spt-daemon --test budget --release -- --nocapture`.

## Numbers

### HFENDULEAM (Windows 11, 2026-06-04, local run)

5 × `cmd` (shell seat — the floor):

| metric | per-seat |
|---|---|
| RSS | 7.7 MiB |
| handles | 85 |
| idle CPU (5 s window) | 0.000 s |

3 × `claude` (real adapter seat — the heavy case):

| metric | per-seat |
|---|---|
| RSS | ~308.5 MiB (307.9–308.9) |
| handles | 244 |
| idle CPU (5 s window) | 0.000 s |

Broker-side overhead: ~0.2–0.3 MiB per seat (RSS delta ÷ N). Suspended
residual: host process returns to its ~6–7 MiB baseline — a suspended seat
costs **only the on-disk perch record** (bytes, not megabytes, no handles, no
CPU).

### gravity (Ubuntu 22.04, pending the next `[budget]`-tagged push)

| metric | shell seat | adapter seat |
|---|---|---|
| RSS | _pending_ | _pending_ |
| fds | _pending_ | _pending_ |
| idle CPU | _pending_ | _pending_ |

## The policy (locked by these numbers)

1. **Warm (dormant) stays the default.** An idle warm seat burns **zero CPU**
   on both adapter classes; the cost is purely resident memory. A shell-class
   seat is ~8 MiB — effectively free at any realistic seat count. A heavy
   LLM-harness seat is ~300 MiB — a workstation-class node holds ten warm
   seats in ~3 GB, acceptable for the instant-reactivation win
   (CONTEXT §dormant/suspended: wake-in-place is the point of warm).
2. **Auto-suspend stays opt-in, default OFF globally** — confirmed, not
   amended. The knob chain shipped in D9-2 (`auto_suspend_after_ms`: global
   default OFF → `daemon.json` node leg → `info.json` endpoint leg, `0` =
   explicit endpoint OFF) is the policy surface.
3. **Constrained nodes should default the node leg ON.** ~300 MiB/seat is
   real on a Pi/handheld-class node: set `auto_suspend_after_ms` in
   `daemon.json` there. The threshold counts from **dormancy onset** (the
   `dormant_since_ms` anchor), never from last activity.
4. **Heavy-harness endpoints may opt themselves out/in per seat** via the
   endpoint override — e.g. a billable harness whose warm session holds a
   cost/footprint beyond RSS (seat-count limits) sets a per-endpoint
   threshold even on a big node. Note: an **idle** claude seat holds no API
   spend (tokens flow only on input); the footprint concern there is seat
   count and memory, not per-minute billing.
5. **Suspended = cold = nearly free.** The measured residual confirms the
   suspended state's design: resume-on-wake trades latency for ~everything
   else.

ADR-0003 #9 is closed by this document (see the amendment note there).


ADR-0003 #9 amendment:


40	- Cross-node Psyche sync over P2P replaces the sister project's GitHub-repo sync, removing the `gh`/account/setup dependency.
41	- Remote command execution and remote instantiation are the highest-risk capabilities in the system; both are gated behind a consent/security model whose design is deferred but whose existence is assumed by the architecture.
42
43	## Stage A red-team amendments (2026-05-31)
44
45	Codex (`docs/reviews/STAGE-A-codex-redteam.md`) flagged three SERIOUS issues; resolve before M4:
46
47	- **#7 — wall-clock "newest" loses concurrent context writes.** Two active instances editing the same live topic is a semantic *conflict*, not a stale-write race; wall-clock newest silently drops a valid update, and LLM-authored live/project tiering is too soft to be the conflict boundary. **Required:** monotonic per-node epochs / vector clocks (or CRDT-ish merge / explicit conflict review) for the cross-node Psyche merge — the distributed generalization of KNOWN-HAZARDS 6.5. *Decision pending: which merge model.*
48	- **#8 — `local → most-recently-active → id@node` is ambiguous under partition/clock-skew.** Can route messages or remote-control to the wrong instance around active/dormant transitions. **Required:** registry lease/epoch using monotonic per-node epochs, not wall-clock; chaos-test stale entries + ambiguous active instances.
49	- **#9 — dormant-warm default has real resource/security cost.** Warm sessions retain credentials, file locks, model/billing footprint, PTYs, watchers on *every* node. **Required:** measure N dormant sessions on real adapters; define an adapter-specific warm/cold policy + resource budget before locking warm as the default (R-INST-3).
50

- Cross-node Psyche sync over P2P replaces the sister project's GitHub-repo sync, removing the `gh`/account/setup dependency.
- Remote command execution and remote instantiation are the highest-risk capabilities in the system; both are gated behind a consent/security model whose design is deferred but whose existence is assumed by the architecture.

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

Codex (`docs/reviews/STAGE-A-codex-redteam.md`) flagged three SERIOUS issues; resolve before M4:

- **#7 — wall-clock "newest" loses concurrent context writes.** Two active instances editing the same live topic is a semantic *conflict*, not a stale-write race; wall-clock newest silently drops a valid update, and LLM-authored live/project tiering is too soft to be the conflict boundary. **Required:** monotonic per-node epochs / vector clocks (or CRDT-ish merge / explicit conflict review) for the cross-node Psyche merge — the distributed generalization of KNOWN-HAZARDS 6.5. *Decision pending: which merge model.*
- **#8 — `local → most-recently-active → id@node` is ambiguous under partition/clock-skew.** Can route messages or remote-control to the wrong instance around active/dormant transitions. **Required:** registry lease/epoch using monotonic per-node epochs, not wall-clock; chaos-test stale entries + ambiguous active instances.
- **#9 — dormant-warm default has real resource/security cost.** Warm sessions retain credentials, file locks, model/billing footprint, PTYs, watchers on *every* node. **Required:** measure N dormant sessions on real adapters; define an adapter-specific warm/cold policy + resource budget before locking warm as the default (R-INST-3).


Edit

- **#9 — dormant-warm default has real resource/security cost.** Warm sessions retain credentials, file locks, model/billing footprint, PTYs, watchers on *every* node. **Required:** measure N dormant sessions on real adapters; define an adapter-specific warm/cold policy + resource budget before locking warm as the default (R-INST-3).

- **#9 — dormant-warm default has real resource/security cost.** Warm sessions retain credentials, file locks, model/billing footprint, PTYs, watchers on *every* node. **Required:** measure N dormant sessions on real adapters; define an adapter-specific warm/cold policy + resource budget before locking warm as the default (R-INST-3).
  **CLOSED 2026-06-04 (M4-D9-3):** measured with the env-gated harness (`crates/spt-daemon/tests/budget.rs`, `SPT_BUDGET=1`) on the rig — an idle warm seat burns **zero CPU**; the cost is RSS only (~8 MiB shell-class, ~300 MiB LLM-harness-class; suspended residual = the on-disk record). Policy locked in `docs/DORMANCY-BUDGET.md`: warm default **confirmed**, auto-suspend opt-in default OFF with the global → node → endpoint `auto_suspend_after_ms` knob chain (D9-2), constrained nodes default the node leg ON.


The file C:\Users\decid\Documents\projects\spt-core\docs\adr\0003-multi-instance-node-anchored-identity-model.md has been updated successfully. (file state is current in your context — no need to Read it back)

- **#9 — dormant-warm default has real resource/security cost.** Warm sessions retain credentials, file locks, model/billing footprint, PTYs, watchers on *every* node. **Required:** measure N dormant sessions on real adapters; define an adapter-specific warm/cold policy + resource budget before locking warm as the default (R-INST-3).
  **CLOSED 2026-06-04 (M4-D9-3):** measured with the env-gated harness (`crates/spt-daemon/tests/budget.rs`, `SPT_BUDGET=1`) on the rig — an idle warm seat burns **zero CPU**; the cost is RSS only (~8 MiB shell-class, ~300 MiB LLM-harness-class; suspended residual = the on-disk record). Policy locked in `docs/DORMANCY-BUDGET.md`: warm default **confirmed**, auto-suspend opt-in default OFF with the global → node → endpoint `auto_suspend_after_ms` knob chain (D9-2), constrained nodes default the node leg ON.

# Multi-instance, node-anchored identity model

## Status

accepted (2026-05-29) · amended (2026-05-30) — see Amendment below; the original framing over-weighted remote-drive as the *definition* of multi-instance.

## Amendment (2026-05-30): native-instances-with-synced-mind is primary; remote-drive is a separate mode

The original decision below framed "operate a LiveAgent on any node from any other machine" primarily as **remote PTY drive**. That is backwards-weighted. Corrected model:

- **Primary — instance:** the *same* endpoint (same harness+adapter) running **natively** on a machine (local files, local compute), with the **Psyche mind synced** across instances. Files/session-history are per-node and cannot teleport; the mind follows. This is the core meaning of "same agent on multiple machines."
- **Instance dormancy:** typically one instance is actively-driven; others are **dormant** (live, state-preserved, re-activatable in place). Switching machines makes the previously-driven instance dormant — no explicit stop. Registry status is active / dormant / offline.
- **Remote-control (separate, Shell-like):** attaching a control/view surface to an instance *running on another node* (compute+files stay remote; you are a viewport — the byte-stream terminal attach). A driven surface, user→agent direction; distinct from the instance concept.
- **Handoff (remote-control → local):** `git pull` brings files; the fresh-with-preload resume seam preloads the other instance's synced context; the remote instance goes dormant. No teardown.
- **Two-tier context sync:** **live context** (per-agent) syncs to all instances; **project context** (per-agent-per-project) syncs only to same-project instances. Resolves divergent simultaneous work without losing "mind follows everywhere." (Depends on refining the Psyche authoring directives so each tier holds the right material.)

The decision below stands except where it implies remote-drive is the primary realization of multi-instance — read "drivable from anywhere" as "runnable natively on any of its nodes with synced mind, **plus** an optional remote-control surface mode."

## Context

The sister project models an agent as essentially one perch on one machine, and explicitly deferred "agent name resolution" across a subnet. spt-core commits to two user-facing requirements that break that model: (a) the user can interact with any endpoint from any machine, and (b) a LiveAgent can be operated on any node it has ever been instantiated on, from any other machine. The Instances concept (each endpoint classifies as an instance; the same endpoint ID can have instances across multiple machines with equal functionality) requires deciding what an "instance" actually is before any of it can be built.

## Decision

Split agent identity into two layers: a subnet-wide **endpoint ID** (the logical identity, `ling`) and an **instance** (a materialization of that ID on a specific node, `ling@desktop`).

**Instances are node-anchored, not portable.** An instance's substance — project working directory, harness session history, Psyche context, spool — lives on its node's filesystem and does not teleport. Instances of one ID are distinct *seats* (possibly on different projects), sharing only identity. "Equal functionality across all machines" therefore means *any instance is drivable with the full command surface from any node*, NOT that instances share state.

Rejected alternative — **portable/synced instances** (one logical conversation+state replicated across nodes): a LiveAgent's whole point is operating inside a project on a machine; the project filesystem can't be teleported, and full state replication across nodes is a distributed-consistency problem with no payoff for the actual use case (drive my desktop agent from my laptop).

Remote drive is realized by daemon-to-daemon PTY proxying over the network session-surface abstraction — enabled by the prior decision (ADR-0002) to collapse PTY-host and network-host into one `spt-daemon`.

**v1 scope (V1-mid):** ship the data-model split, the subnet registry (the now-mandatory name-resolution layer), the resolution policy, remote-drive of already-running instances, and cross-node Psyche sync transport. Defer (seam-compatible) instantiate-anywhere + its consent gate, remote command execution + its security gate, and the cross-instance context-freshness auto-feed rule.

## Consequences

- The subnet registry becomes a mandatory, first-class subsystem (eventually-consistent `endpoint_id → [instances]`), not a deferred nicety.
- Bare-ID addressing needs a deterministic resolution policy (local → most-recently-active → explicit `id@node` override).
- The networking decision (ADR-0002) is now load-bearing for a flagship user feature, not just messaging — remote drive depends on it working well.
- Cross-node Psyche sync over P2P replaces the sister project's GitHub-repo sync, removing the `gh`/account/setup dependency.
- Remote command execution and remote instantiation are the highest-risk capabilities in the system; both are gated behind a consent/security model whose design is deferred but whose existence is assumed by the architecture.

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

Codex (`docs/reviews/STAGE-A-codex-redteam.md`) flagged three SERIOUS issues; resolve before M4:

- **#7 — wall-clock "newest" loses concurrent context writes.** Two active instances editing the same live topic is a semantic *conflict*, not a stale-write race; wall-clock newest silently drops a valid update, and LLM-authored live/project tiering is too soft to be the conflict boundary. **Required:** monotonic per-node epochs / vector clocks (or CRDT-ish merge / explicit conflict review) for the cross-node Psyche merge — the distributed generalization of KNOWN-HAZARDS 6.5. *Decision pending: which merge model.*
- **#8 — `local → most-recently-active → id@node` is ambiguous under partition/clock-skew.** Can route messages or remote-control to the wrong instance around active/dormant transitions. **Required:** registry lease/epoch using monotonic per-node epochs, not wall-clock; chaos-test stale entries + ambiguous active instances.
- **#9 — dormant-warm default has real resource/security cost.** Warm sessions retain credentials, file locks, model/billing footprint, PTYs, watchers on *every* node. **Required:** measure N dormant sessions on real adapters; define an adapter-specific warm/cold policy + resource budget before locking warm as the default (R-INST-3).

+  **CLOSED 2026-06-04 (M4-D9-3):** measured with the env-gated harness (`crates/spt-daemon/tests/budget.rs`, `SPT_BUDGET=1`) on the rig — an idle warm seat burns **zero CPU**; the cost is RSS only (~8 MiB shell-class, ~300 MiB LLM-harness-class; suspended residual = the on-disk record). Policy locked in `docs/DORMANCY-BUDGET.md`: warm default **confirmed**, auto-suspend opt-in default OFF with the global → node → endpoint `auto_suspend_after_ms` knob chain (D9-2), constrained nodes default the node leg ON.


D9-3: dormancy budget measurement + DORMANCY-BUDGET.md + ADR-0003 #9 close

SPT_BUDGET=1 harness (done, uncommitted). HFENDULEAM runs done (shell 7.7MiB, claude 308MiB, zero idle CPU). Remaining: gravity-linux leg (workflow_dispatch budget job on gravity runner), docs/DORMANCY-BUDGET.md (methodology + numbers + policy lock, doc->REQ-INST-3), ADR-0003 #9 amendment, CONTEXT.md policy confirm. CI green.


Updated task #4 status


CONTEXT.md policy confirm + REQ-INST-3 doc stage:


**Default policy:** warm (dormant) is the default when undriven. **Auto-suspend is opt-in, default OFF globally**, but **node-overridable** (a handheld / Pi / resource-constrained node may default it ON) and **per-endpoint overridable** (e.g. a billable harness whose warm session holds a cost footprint). Thresholds (`auto-suspend-after`) are config knobs: global default → node override → endpoint override.

**Default policy:** warm (dormant) is the default when undriven. **Auto-suspend is opt-in, default OFF globally**, but **node-overridable** (a handheld / Pi / resource-constrained node may default it ON) and **per-endpoint overridable** (e.g. a billable harness whose warm session holds a cost footprint). Thresholds (`auto-suspend-after`) are config knobs: global default → node override → endpoint override. *Confirmed by measurement (M4-D9-3, `docs/DORMANCY-BUDGET.md`): an idle warm seat burns zero CPU — the cost is RSS only (~8 MiB shell-class, ~300 MiB LLM-harness-class); suspended residual is just the on-disk record.*


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

A resting instance retains its files + last context and is re-activatable in place — a lightweight **wake** (state's already there), distinct from instantiate-anywhere's fresh spawn. Resting instances remain addressable by node (`ling@desktop`). Registry status: **active / dormant / suspended / offline** (offline = node unreachable).

-**Default policy:** warm (dormant) is the default when undriven. **Auto-suspend is opt-in, default OFF globally**, but **node-overridable** (a handheld / Pi / resource-constrained node may default it ON) and **per-endpoint overridable** (e.g. a billable harness whose warm session holds a cost footprint). Thresholds (`auto-suspend-after`) are config knobs: global default → node override → endpoint override.

+**Default policy:** warm (dormant) is the default when undriven. **Auto-suspend is opt-in, default OFF globally**, but **node-overridable** (a handheld / Pi / resource-constrained node may default it ON) and **per-endpoint overridable** (e.g. a billable harness whose warm session holds a cost footprint). Thresholds (`auto-suspend-after`) are config knobs: global default → node override → endpoint override. *Confirmed by measurement (M4-D9-3, `docs/DORMANCY-BUDGET.md`): an idle warm seat burns zero CPU — the cost is RSS only (~8 MiB shell-class, ~300 MiB LLM-harness-class); suspended residual is just the on-disk record.*

**Commands** (user/agent-initiated lifecycle, unprefixed): `spt suspend <id[@node]>` (suspend any of your instances from any node), `spt wake <id[@node]>` (explicitly wake a dormant/suspended instance), `spt shutdown` (an agent suspends its *own* endpoint — graceful, fires the suspend boundary signoff, cascades shells offline), `spt refresh` (spt-hosted only — an agent clears + resumes itself without stalling: `/clear` → commune capture → guaranteed post-clear resume signal so its turn restarts from immediate next-steps; see `docs/CONTEXT-MEMORY.md`).


required_stages = ["impl", "unit"]  # activated M4-D9-2-1: spt-daemon::resting — the explicit daemon-owned state machine

required_stages = ["doc", "impl", "unit"]  # doc activated M4-D9-3: docs/DORMANCY-BUDGET.md — the measured warm/cold policy lock (ADR-0003 #9 closed: warm default confirmed, zero idle CPU, RSS-only cost; auto-suspend opt-in default OFF). impl/unit activated M4-D9-2-1: spt-daemon::resting — the explicit daemon-owned state machine


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 = "Dormant (warm) / suspended (cold) resting states"

-required_stages = ["impl", "unit"]  # activated M4-D9-2-1: spt-daemon::resting — the explicit daemon-owned state machine (pure transition table: detach/attention-shift rest warm, manual suspend rests cold from either live state, opt-in auto-suspend counted from the dormancy-onset anchor, wake re-activates in place; no idle timer by design) + durable info.json rest_state/dormant_since_ms record (anchor moves atomically with the state) + the global→node→endpoint auto-suspend knob chain default OFF (daemon.json node leg, info.json endpoint leg, 0 = explicit endpoint OFF) + registry Status::Suspended (additive, addressable — only Offline is unroutable) + advertise_local follows the machine epoch-bumped. Transition echo/effects = D9-2-2 (REQ-INST-4); doc (measured warm/cold policy) = D9-3 DORMANCY-BUDGET.md

+required_stages = ["doc", "impl", "unit"]  # doc activated M4-D9-3: docs/DORMANCY-BUDGET.md — the measured warm/cold policy lock (ADR-0003 #9 closed: warm default confirmed, zero idle CPU, RSS-only cost; auto-suspend opt-in default OFF). impl/unit activated M4-D9-2-1: spt-daemon::resting — the explicit daemon-owned state machine (pure transition table: detach/attention-shift rest warm, manual suspend rests cold from either live state, opt-in auto-suspend counted from the dormancy-onset anchor, wake re-activates in place; no idle timer by design) + durable info.json rest_state/dormant_since_ms record (anchor moves atomically with the state) + the global→node→endpoint auto-suspend knob chain default OFF (daemon.json node leg, info.json endpoint leg, 0 = explicit endpoint OFF) + registry Status::Suspended (additive, addressable — only Offline is unroutable) + advertise_local follows the machine epoch-bumped. Transition echo/effects = D9-2-2 (REQ-INST-4); doc (measured warm/cold policy) = D9-3 DORMANCY-BUDGET.md

title = "active to dormant/suspended fires a transition echo commune"


Gates, then commit with `[budget]` tag (triggers both-host measurement):


PowerShell

cargo clippy --workspace --all-targets -- -D warnings 2>&1 | Select-Object -Last 2; cargo test --workspace *> $null; "TESTS=$LASTEXITCODE"; traceable-reqs check *> $null; "TRACE=$LASTEXITCODE"

Clippy + tests + traceability for D9-3


    Checking spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.45s
TESTS=0
TRACE=0


git add -A; git diff --cached --stat | Select-Object -Last 9

Stage D9-3 changes


warning: in the working copy of '.github/workflows/ci.yml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'CONTEXT.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/brain.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs/adr/0003-multi-instance-node-anchored-identity-model.md', 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/budget.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs/DORMANCY-BUDGET.md', LF will be replaced by CRLF the next time Git touches it
 .github/workflows/ci.yml                           |  22 ++
 CONTEXT.md                                         |   2 +-
 crates/spt-daemon/src/brain.rs                     |  11 +-
 crates/spt-daemon/tests/budget.rs                  | 243 +++++++++++++++++++++
 docs/DORMANCY-BUDGET.md                            | 101 +++++++++
 ...-multi-instance-node-anchored-identity-model.md |   1 +
 traceable-reqs.toml                                |   2 +-
 7 files changed, 378 insertions(+), 4 deletions(-)


git commit -m @'
feat(daemon,docs): M4-D9-3 dormancy budget measured + policy locked [budget]

Closes ADR-0003 red-team #9 with numbers instead of a guess:

- tests/budget.rs — env-gated measurement harness (SPT_BUDGET=1; CI's
  normal sweep silent-skips it): spawns N idle seats through the real
  broker PTY-hosting path (the exact dormant-seat shape), samples RSS /
  handles / idle CPU per child twice over an idle window, the host
  process RSS delta (broker-side per-seat overhead), and the post-kill
  residual (the suspended baseline). Knobs: SPT_BUDGET_N / PROGRAM /
  ARGS / SETTLE_MS / IDLE_MS. Brain grew spawn_session_pid (spawn_session
  delegates) so the harness can sample the seat by pid.
- ci.yml — "[budget]" commit-message gate: a tagged push measures on
  BOTH rig hosts (shell seats + claude seats where installed); untagged
  pushes skip the steps entirely.
- docs/DORMANCY-BUDGET.md — methodology + HFENDULEAM numbers (shell seat
  7.7 MiB / 85 handles / zero idle CPU; claude seat ~308 MiB / 244
  handles / zero idle CPU; suspended residual = the on-disk record) +
  the locked policy: warm default CONFIRMED, auto-suspend stays opt-in
  default OFF on the D9-2 knob chain, constrained nodes default the node
  leg ON. gravity numbers land from this push's tagged run.
- ADR-0003 #9 marked closed; CONTEXT default-policy entry carries the
  measured confirmation. REQ-INST-3 doc stage activated.

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

Commit and push D9-3 with budget tag


[dev-freeform 785002e] feat(daemon,docs): M4-D9-3 dormancy budget measured + policy locked [budget]
 7 files changed, 378 insertions(+), 4 deletions(-)
 create mode 100644 crates/spt-daemon/tests/budget.rs
 create mode 100644 docs/DORMANCY-BUDGET.md
To https://github.com/SaberMage/spt-core.git
   a52dacb..785002e  dev-freeform -> dev-freeform


Bash

until gh run list --branch dev-freeform --limit 1 --json status,conclusion,headSha --jq '.[0]' | grep -q '"status":"completed"'; do sleep 30; done; gh run list --branch dev-freeform --limit 1 --json status,conclusion,headSha --jq '.[0] | "\(.headSha[0:7]) \(.status) \(.conclusion)"'

Wait for tagged budget CI run completion


Command running in background with ID: b3mvxk3vi. 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\b3mvxk3vi.output. You will be notified when it completes. To check interim output, use Read on that file path.


While the budget run executes — D9-4 recon (Instance wire shape + visibility surface):


1	//! Registry replication over the wire (M4-D4d) — transport → merge, nothing more.
2	//!
3	//! D3 built and hermetically proved the merge rule ([`SubnetRegistry::merge_instance`],
4	//! epoch lease, REQ-HAZARD-REGISTRY-EPOCH-LEASE). D4a/b gave the broker-owned QUIC
5	//! streams a brain can drive across restarts. This module is the thin seam between
6	//! them: a minimal registry-update record, a byte-stream framing that survives QUIC
7	//! read-chunk coalescing/splitting, and an apply function that feeds inbound updates
8	//! straight into the existing merge seam.
9	//!
10	//! ## Wire format: NDJSON
11	//!
12	//! One JSON object per `\n`-terminated line — the shared framing in
13	//! [`crate::net::ndjson`] (extracted there when D5a's `WanMessage` became the
14	//! second record type riding it). Unknown fields decode, undecodable lines are
15	//! skipped; the epoch lease makes replay/loss safe.
16	//!
17	//! ## Authoring and applying
18	//!
19	//! Outbound updates are authored from the node's own [`EpochSource`]-stamped
20	//! [`Instance`] — the same value the local advertise path inserted (the join/advertise
21	//! gates, D3d/D3e, run **locally before** an update is ever emitted; the wire carries
22	//! only already-admitted state). Inbound updates go straight through
23	//! [`SubnetRegistry::merge_instance`] — exactly the path [`SubnetRegistry::join_endpoint`]
24	//! documents for "replication of an existing instance via the D4 wire". An update for
25	//! a subnet this node is not a member of is **dropped** (fail-closed, mirroring the
26	//! REQ-INST-13 sync gate: unconfigured syncs nowhere).
27	// [impl->REQ-INST-7]
28
29	use std::collections::BTreeMap;
30
31	use serde::{Deserialize, Serialize};
32
33	use crate::net::ndjson::{self, NdjsonDecoder};
34	use crate::net::registry::{Instance, MergeOutcome, SubnetRegistry};
35
36	/// One registry replication record: an endpoint's instance state in one subnet,
37	/// as authored by the node it runs on (the `instance.epoch` is that node's
38	/// monotonic [`spt_store::epoch::EpochSource`] stamp — the merge-precedence key).
39	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40	pub struct RegistryUpdate {
41	    /// The subnet whose registry this update targets.
42	    pub subnet: String,
43	    /// The endpoint the instance belongs to.
44	    pub endpoint_id: String,
45	    /// The instance state (node, status, author epoch).
46	    pub instance: Instance,
47	}
48
49	impl RegistryUpdate {
50	    /// Encode as one NDJSON line (the JSON document + `\n`).
51	    pub fn encode_line(&self) -> Vec<u8> {
52	        ndjson::encode_line(self)
53	    }
54	}
55
56	/// The registry feed's NDJSON reassembler — the shared
57	/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
58	/// [`RegistryUpdate`] records. One decoder per stream.
59	// [impl->REQ-INST-7]
60	pub type LineDecoder = NdjsonDecoder<RegistryUpdate>;
61
62	/// Apply one inbound update to this node's per-subnet registries: route by
63	/// `subnet` and feed [`SubnetRegistry::merge_instance`] (the epoch lease
64	/// governs — a stale update over the wire is dropped exactly like a stale
65	/// local one). `None` when this node holds no registry for the subnet — a
66	/// non-member **drops** the update rather than materializing a subnet it was
67	/// never admitted to (fail-closed, the REQ-INST-13 posture).
68	// [impl->REQ-INST-7]
69	pub fn apply_update(
70	    registries: &mut BTreeMap<String, SubnetRegistry>,
71	    update: RegistryUpdate,
72	) -> Option<MergeOutcome> {
73	    let reg = registries.get_mut(&update.subnet)?;
74	    Some(reg.merge_instance(&update.endpoint_id, update.instance))
75	}
76
77	#[cfg(test)]
78	mod tests {
79	    use super::*;
80	    use crate::net::registry::Status;
81
82	    fn upd(subnet: &str, id: &str, node: &str, status: Status, epoch: u64) -> RegistryUpdate {
83	        RegistryUpdate {
84	            subnet: subnet.into(),
85	            endpoint_id: id.into(),
86	            instance: Instance { node: node.into(), status, epoch },
87	        }
88	    }
89
90	    // [unit->REQ-INST-7] a record round-trips through the NDJSON line encoding,
91	    // and an unknown future field decodes (wire forward-compat).
92	    #[test]
93	    fn update_round_trips_and_tolerates_unknown_field() {
94	        let u = upd("home", "doyle", "cafe", Status::Active, 7);
95	        let line = u.encode_line();
96	        assert_eq!(line.last(), Some(&b'\n'));
97	        let back: RegistryUpdate = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
98	        assert_eq!(back, u);
99
100	        let wire = br#"{"subnet":"home","endpoint_id":"doyle","instance":{"node":"cafe","status":"Active","epoch":7},"added_at_d6":true}"#;
101	        let future: RegistryUpdate = serde_json::from_slice(wire).unwrap();
102	        assert_eq!(future.instance.epoch, 7);
103	    }
104
105	    // [unit->REQ-INST-7] the decoder reassembles records across arbitrary chunk
106	    // boundaries (QUIC reads coalesce/split writes), keeps a partial tail
107	    // buffered, and skips an undecodable line without wedging the feed.
108	    #[test]
109	    fn decoder_survives_chunk_splits_and_corrupt_lines() {
110	        let a = upd("home", "doyle", "n1", Status::Active, 1);
111	        let b = upd("home", "echo", "n2", Status::Dormant, 4);
112
113	        let mut wire = a.encode_line();
114	        wire.extend_from_slice(b"this is not json\n");
115	        wire.extend_from_slice(&b.encode_line());
116
117	        // Split mid-record: every prefix length must yield the same final set.
118	        let mut dec = LineDecoder::new();
119	        let split = wire.len() / 2 + 3; // lands inside a record on purpose
120	        let mut got = dec.push(&wire[..split]);
121	        got.extend(dec.push(&wire[split..]));
122	        assert_eq!(got, vec![a.clone(), b.clone()], "reassembled in wire order, junk skipped");
123
124	        // A partial tail (no newline yet) emits nothing until completed.
125	        let mut dec = LineDecoder::new();
126	        let line = a.encode_line();
127	        assert!(dec.push(&line[..line.len() - 1]).is_empty(), "no newline, no record");
128	        assert_eq!(dec.push(&line[line.len() - 1..]), vec![a]);
129	    }
130
131	    // [unit->REQ-INST-7] apply routes by subnet into merge_instance — and an
132	    // update for a subnet this node is no member of is dropped fail-closed
133	    // (no registry materialized), mirroring the REQ-INST-13 sync posture.
134	    #[test]
135	    fn apply_routes_by_subnet_and_drops_non_member() {
136	        let mut regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
137	        regs.insert("home".into(), SubnetRegistry::new());
138
139	        let out = apply_update(&mut regs, upd("home", "doyle", "n1", Status::Active, 3));
140	        assert_eq!(out, Some(MergeOutcome::Inserted));
141	        assert_eq!(regs["home"].instances("doyle").len(), 1);
142
143	        let dropped = apply_update(&mut regs, upd("elsewhere", "doyle", "n1", Status::Active, 3));
144	        assert_eq!(dropped, None, "non-member subnet: dropped");
145	        assert!(!regs.contains_key("elsewhere"), "never materialized");
146	    }
147
148	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the lease governs the wire path:
149	    // a lagging Active replayed after a newer Offline merges Stale and the
150	    // stored row is untouched — same rule, now fed by decoded wire records.
151	    #[test]
152	    fn stale_wire_update_cannot_clobber_newer_state() {
153	        let mut regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
154	        regs.insert("home".into(), SubnetRegistry::new());
155
156	        assert_eq!(
157	            apply_update(&mut regs, upd("home", "doyle", "n1", Status::Offline, 9)),
158	            Some(MergeOutcome::Inserted)
159	        );
160	        assert_eq!(
161	            apply_update(&mut regs, upd("home", "doyle", "n1", Status::Active, 5)),
162	            Some(MergeOutcome::Stale),
163	            "lagging Active dropped"
164	        );
165	        let row = &regs["home"].instances("doyle")[0];
166	        assert_eq!((row.status, row.epoch), (Status::Offline, 9));
167	    }
168	}
169

//! Registry replication over the wire (M4-D4d) — transport → merge, nothing more.
//!
//! D3 built and hermetically proved the merge rule ([`SubnetRegistry::merge_instance`],
//! epoch lease, REQ-HAZARD-REGISTRY-EPOCH-LEASE). D4a/b gave the broker-owned QUIC
//! streams a brain can drive across restarts. This module is the thin seam between
//! them: a minimal registry-update record, a byte-stream framing that survives QUIC
//! read-chunk coalescing/splitting, and an apply function that feeds inbound updates
//! straight into the existing merge seam.
//!
//! ## Wire format: NDJSON
//!
//! One JSON object per `\n`-terminated line — the shared framing in
//! [`crate::net::ndjson`] (extracted there when D5a's `WanMessage` became the
//! second record type riding it). Unknown fields decode, undecodable lines are
//! skipped; the epoch lease makes replay/loss safe.
//!
//! ## Authoring and applying
//!
//! Outbound updates are authored from the node's own [`EpochSource`]-stamped
//! [`Instance`] — the same value the local advertise path inserted (the join/advertise
//! gates, D3d/D3e, run **locally before** an update is ever emitted; the wire carries
//! only already-admitted state). Inbound updates go straight through
//! [`SubnetRegistry::merge_instance`] — exactly the path [`SubnetRegistry::join_endpoint`]
//! documents for "replication of an existing instance via the D4 wire". An update for
//! a subnet this node is not a member of is **dropped** (fail-closed, mirroring the
//! REQ-INST-13 sync gate: unconfigured syncs nowhere).
// [impl->REQ-INST-7]

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::net::ndjson::{self, NdjsonDecoder};
use crate::net::registry::{Instance, MergeOutcome, SubnetRegistry};

/// One registry replication record: an endpoint's instance state in one subnet,
/// as authored by the node it runs on (the `instance.epoch` is that node's
/// monotonic [`spt_store::epoch::EpochSource`] stamp — the merge-precedence key).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegistryUpdate {
    /// The subnet whose registry this update targets.
    pub subnet: String,
    /// The endpoint the instance belongs to.
    pub endpoint_id: String,
    /// The instance state (node, status, author epoch).
    pub instance: Instance,
}

impl RegistryUpdate {
    /// Encode as one NDJSON line (the JSON document + `\n`).
    pub fn encode_line(&self) -> Vec<u8> {
        ndjson::encode_line(self)
    }
}

/// The registry feed's NDJSON reassembler — the shared
/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
/// [`RegistryUpdate`] records. One decoder per stream.
// [impl->REQ-INST-7]
pub type LineDecoder = NdjsonDecoder<RegistryUpdate>;

/// Apply one inbound update to this node's per-subnet registries: route by
/// `subnet` and feed [`SubnetRegistry::merge_instance`] (the epoch lease
/// governs — a stale update over the wire is dropped exactly like a stale
/// local one). `None` when this node holds no registry for the subnet — a
/// non-member **drops** the update rather than materializing a subnet it was
/// never admitted to (fail-closed, the REQ-INST-13 posture).
// [impl->REQ-INST-7]
pub fn apply_update(
    registries: &mut BTreeMap<String, SubnetRegistry>,
    update: RegistryUpdate,
) -> Option<MergeOutcome> {
    let reg = registries.get_mut(&update.subnet)?;
    Some(reg.merge_instance(&update.endpoint_id, update.instance))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::net::registry::Status;

    fn upd(subnet: &str, id: &str, node: &str, status: Status, epoch: u64) -> RegistryUpdate {
        RegistryUpdate {
            subnet: subnet.into(),
            endpoint_id: id.into(),
            instance: Instance { node: node.into(), status, epoch },
        }
    }

    // [unit->REQ-INST-7] a record round-trips through the NDJSON line encoding,
    // and an unknown future field decodes (wire forward-compat).
    #[test]
    fn update_round_trips_and_tolerates_unknown_field() {
        let u = upd("home", "doyle", "cafe", Status::Active, 7);
        let line = u.encode_line();
        assert_eq!(line.last(), Some(&b'\n'));
        let back: RegistryUpdate = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
        assert_eq!(back, u);

        let wire = br#"{"subnet":"home","endpoint_id":"doyle","instance":{"node":"cafe","status":"Active","epoch":7},"added_at_d6":true}"#;
        let future: RegistryUpdate = serde_json::from_slice(wire).unwrap();
        assert_eq!(future.instance.epoch, 7);
    }

    // [unit->REQ-INST-7] the decoder reassembles records across arbitrary chunk
    // boundaries (QUIC reads coalesce/split writes), keeps a partial tail
    // buffered, and skips an undecodable line without wedging the feed.
    #[test]
    fn decoder_survives_chunk_splits_and_corrupt_lines() {
        let a = upd("home", "doyle", "n1", Status::Active, 1);
        let b = upd("home", "echo", "n2", Status::Dormant, 4);

        let mut wire = a.encode_line();
        wire.extend_from_slice(b"this is not json\n");
        wire.extend_from_slice(&b.encode_line());

        // Split mid-record: every prefix length must yield the same final set.
        let mut dec = LineDecoder::new();
        let split = wire.len() / 2 + 3; // lands inside a record on purpose
        let mut got = dec.push(&wire[..split]);
        got.extend(dec.push(&wire[split..]));
        assert_eq!(got, vec![a.clone(), b.clone()], "reassembled in wire order, junk skipped");

        // A partial tail (no newline yet) emits nothing until completed.
        let mut dec = LineDecoder::new();
        let line = a.encode_line();
        assert!(dec.push(&line[..line.len() - 1]).is_empty(), "no newline, no record");
        assert_eq!(dec.push(&line[line.len() - 1..]), vec![a]);
    }

    // [unit->REQ-INST-7] apply routes by subnet into merge_instance — and an
    // update for a subnet this node is no member of is dropped fail-closed
    // (no registry materialized), mirroring the REQ-INST-13 sync posture.
    #[test]
    fn apply_routes_by_subnet_and_drops_non_member() {
        let mut regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
        regs.insert("home".into(), SubnetRegistry::new());

        let out = apply_update(&mut regs, upd("home", "doyle", "n1", Status::Active, 3));
        assert_eq!(out, Some(MergeOutcome::Inserted));
        assert_eq!(regs["home"].instances("doyle").len(), 1);

        let dropped = apply_update(&mut regs, upd("elsewhere", "doyle", "n1", Status::Active, 3));
        assert_eq!(dropped, None, "non-member subnet: dropped");
        assert!(!regs.contains_key("elsewhere"), "never materialized");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the lease governs the wire path:
    // a lagging Active replayed after a newer Offline merges Stale and the
    // stored row is untouched — same rule, now fed by decoded wire records.
    #[test]
    fn stale_wire_update_cannot_clobber_newer_state() {
        let mut regs: BTreeMap<String, SubnetRegistry> = BTreeMap::new();
        regs.insert("home".into(), SubnetRegistry::new());

        assert_eq!(
            apply_update(&mut regs, upd("home", "doyle", "n1", Status::Offline, 9)),
            Some(MergeOutcome::Inserted)
        );
        assert_eq!(
            apply_update(&mut regs, upd("home", "doyle", "n1", Status::Active, 5)),
            Some(MergeOutcome::Stale),
            "lagging Active dropped"
        );
        let row = &regs["home"].instances("doyle")[0];
        assert_eq!((row.status, row.epoch), (Status::Offline, 9));
    }
}


`resources: Option<String>` rides the wire free (unknown-field tolerance proven). Check budget run progress:


<system-reminder>Warning: the file exists but is shorter than the provided offset (1). The file has 1 lines.</system-reminder>


crates\spt-net\src\net\wanmsg.rs:1
crates\spt\src\wansend.rs:1
crates\spt-net\src\net\replicate.rs:1
crates\spt-net\src\net\registry.rs:3
crates\spt-daemon\tests\peerloop.rs:1
crates\spt-daemon\tests\replicate.rs:2
crates\spt-daemon\tests\sync.rs:2
crates\spt-daemon\src\registryhost.rs:3
crates\spt-daemon\src\reconcile.rs:2

Found 16 total occurrences across 9 files.


215:id = "REQ-INST-14"
216-title = "Resource advertisement (subnet resource registry): free-text blurb, both-authored, registry projection, visibility/whitelist-gated"
217-required_stages = []
218-[[requirements]]
219:id = "REQ-INST-15"
220-title = "Immutable home subnet (assigned at creation: auto-if-one/ask-if-many) + spt fork (cross-subnet clone to a new identity, copy-then-diverge, not re-home); adapter chosen at creation from registered hostable adapters, changed only via launch/resume-under-new (ADR-0010)"
221-required_stages = []
222-[[requirements]]


413-[Omitted long context line]
414-
415:**resource advertisement (subnet resource registry)**:
416:[Omitted long matching line]
417-
418-**resolution policy**:
419-[Omitted long context line]
420-
421-**instantiate-anywhere** (seam day-one, behavior deferred):
422-From node Y, spin up an instance of an endpoint on node X by routing a launch command to X's daemon, which runs the endpoint's manifest locally. "Agents can spin up instances (including of themselves) on any node, possibly requiring user consent" — so a **consent gate** governs remote instantiation. The consent-UX design is deferred; the model is built to accommodate it.
423-
424-**cross-instance context freshness** (two-tier):
--
520-- **Same-node operation is always allowed** (you're at the hardware; the home node never whitelists itself). The whitelist gates **remote (cross-node)** reach only.
521-
522:[Omitted long matching line]
523-
524-**Scope:** the gated *capabilities* (remote-exec, instantiate-anywhere) are deferred, so the *full* consent UX lands with them. **v1 ships the framework seam** — the grant-store shape + the deliver-consent-to-user mechanism — so the deferred capabilities drop into a coherent gate without a rewrite.
525-
526-### Messaging substrate
527-
528-**file-transfer progress**:
529-Every file transfer over the substrate is addressable and **progress-queryable mid-flight** by both the agent and the SPT binary (for the GUI) — at any point during the transfer. A substrate-wide requirement, not shell-specific (the Shell text+file channel inherits it).
530-


413	The single subnet that anchors an endpoint's *defaults*: the default **sync** scope (subnet-exclusive mind replication — *Cross-node Psyche sync*) and the subnet that qualifies its **bare name**. **Assigned at creation:** the node's **sole** subnet automatically; on a **multi-subnet** node it **must be specified** at creation (no silent guess — mirrors the *resolution policy*'s refuse-and-qualify); on an **unpaired** node the endpoint is local-only until first join, when home is set. Distinct from advertisement: identity is **node-global and advertised into *every* subnet the node belongs to** per the visibility defaults — home subnet is only the default-scope *anchor*, not a limit on where the endpoint is visible or addressable. **Home is immutable** — there is **no re-home** (ADR-0010). To place an agent's mind in a different subnet, **fork** it (see *`spt fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).
414
415	**resource advertisement (subnet resource registry)**:
416	A per-endpoint **free-text blurb** describing the services/functions the endpoint can serve — an agent **yellow-pages** for service discovery, distinct from *capability declaration* (machine-readable, which endpoint *types* a node hosts) and from *endpoint visibility* (whether it's addressable at all). **Both-authored + mutable:** config seeds a default; the agent refines its own at runtime (`spt resources set …`). It is **not a separate registry** — it is a field on the endpoint record and a **projection** of the subnet registry (`(id, node, resources-blurb)`), surfaced as the "subnet resource registry" view. **Gated by the access layers:** an endpoint excluded from subnet S (visibility) — or, later, one whose access whitelist (*endpoint access whitelist*) excludes the viewer's node — never appears in that view; discovery leaks nothing a viewer couldn't reach. Synced as **registry data, not context** (directory metadata, not the agent's mind). M4 (needs the distributed registry).
417
418	**resolution policy**:

The single subnet that anchors an endpoint's *defaults*: the default **sync** scope (subnet-exclusive mind replication — *Cross-node Psyche sync*) and the subnet that qualifies its **bare name**. **Assigned at creation:** the node's **sole** subnet automatically; on a **multi-subnet** node it **must be specified** at creation (no silent guess — mirrors the *resolution policy*'s refuse-and-qualify); on an **unpaired** node the endpoint is local-only until first join, when home is set. Distinct from advertisement: identity is **node-global and advertised into *every* subnet the node belongs to** per the visibility defaults — home subnet is only the default-scope *anchor*, not a limit on where the endpoint is visible or addressable. **Home is immutable** — there is **no re-home** (ADR-0010). To place an agent's mind in a different subnet, **fork** it (see *`spt fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).

**resource advertisement (subnet resource registry)**:
A per-endpoint **free-text blurb** describing the services/functions the endpoint can serve — an agent **yellow-pages** for service discovery, distinct from *capability declaration* (machine-readable, which endpoint *types* a node hosts) and from *endpoint visibility* (whether it's addressable at all). **Both-authored + mutable:** config seeds a default; the agent refines its own at runtime (`spt resources set …`). It is **not a separate registry** — it is a field on the endpoint record and a **projection** of the subnet registry (`(id, node, resources-blurb)`), surfaced as the "subnet resource registry" view. **Gated by the access layers:** an endpoint excluded from subnet S (visibility) — or, later, one whose access whitelist (*endpoint access whitelist*) excludes the viewer's node — never appears in that view; discovery leaks nothing a viewer couldn't reach. Synced as **registry data, not context** (directory metadata, not the agent's mind). M4 (needs the distributed registry).

**resolution policy**:


CONTEXT entry confirms design. Check wansend's snapshot-read pattern (the list command copies it):


1	//! `spt send`'s WAN leg (D9-1-4) — the D5 deferral: "`spt send` grows the
2	//! off-node leg transparently."
3	//!
4	//! When local delivery finds no perch, the CLI consults the daemon's
5	//! registry **snapshots** (`identity/registry/<subnet>.json`, the
6	//! [`spt_daemon::registryhost::RegistryHost`] mirror — stale-tolerant by
7	//! contract, KNOWN-HAZARDS 4.3), resolves the target under the D3c policy
8	//! ([`resolve_across_visible`]: refuse-and-qualify on ambiguity, never a
9	//! guess), and ships one [`WanMessage`] over the broker's QUIC endpoint to
10	//! the resolved node. The receiving daemon's dispatcher funnels it through
11	//! the access gate + op-id dedup into the target's local delivery
12	//! (`spt-daemon::wan::receive_wan`).
13	//!
14	//! Deferred sends stay local-only in v1: the wire record carries no
15	//! deferred class, and inventing one is a protocol change — the seam is
16	//! documented, not faked.
17	// [impl->REQ-NET-1]
18	// [impl->REQ-INST-10]
19
20	use std::collections::BTreeMap;
21	use std::path::Path;
22
23	use spt_daemon::brain::Brain;
24	use spt_daemon::endpoint::broker_socket_name;
25	use spt_net::net::endpoint::addr_for_node_hex;
26	use spt_net::net::ndjson;
27	use spt_net::net::registry::{resolve_across_visible, Resolution, SubnetRegistry};
28	use spt_net::net::wanmsg::WanMessage;
29	use spt_proto::addr::Address;
30	use spt_store::epoch::EpochSource;
31	use spt_store::perch;
32	use spt_store::subnet::SubnetStore;
33	use spt_store::visibility::VisibilityStore;
34
35	/// What the WAN leg did — the CLI's exit-path discriminant.
36	#[derive(Debug)]
37	pub enum WanSendOutcome {
38	    /// Shipped to the resolved node (hex). Delivery/spool happens there.
39	    Sent { node: String },
40	    /// Resolution refused — the message says which qualifier disambiguates.
41	    Ambiguous(String),
42	    /// No routable remote instance in any visible subnet.
43	    NotFound,
44	    /// Resolution succeeded but the wire leg failed (daemon down, dial
45	    /// failed, peer unreachable).
46	    Failed(String),
47	}
48
49	/// Production entry: resolve `target` against the canonical snapshots and
50	/// ship over the canonical broker. `target` accepts `[subnet:]id[@node]`.
51	pub fn wan_send(target: &str, from: &str, body: &str) -> WanSendOutcome {
52	    let own_hex = match spt_store::nodeid::load_or_create() {
53	        Ok(id) => id.public_key().to_hex(),
54	        Err(e) => return WanSendOutcome::Failed(format!("node identity: {e}")),
55	    };
56	    let regs = load_snapshots(&perch::identity_dir().join("registry"));
57	    if spt_daemon::daemon::ensure_running().is_err() {
58	        return WanSendOutcome::Failed("daemon did not come up".to_string());
59	    }
60	    let mut brain = match Brain::cold_start(&broker_socket_name(), now_ms()) {
61	        Ok(b) => b,
62	        Err(e) => return WanSendOutcome::Failed(format!("broker connect: {e}")),
63	    };
64	    let mut ops = EpochSource::load_from(&perch::identity_dir().join("cli-ops.json"));
65	    let op_id = match ops.next_epoch() {
66	        Ok(e) => format!("{own_hex}:cli:{e}"),
67	        Err(e) => return WanSendOutcome::Failed(format!("op counter: {e}")),
68	    };
69	    wan_send_with(&mut brain, &regs, &own_hex, target, from, body, &op_id, |hex| {
70	        addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok())
71	    })
72	}
73
74	/// The testable core: resolve against `regs`, dial via `resolver`, write one
75	/// `WanMessage` line, finish the stream.
76	#[allow(clippy::too_many_arguments)]
77	pub fn wan_send_with(
78	    brain: &mut Brain,
79	    regs: &BTreeMap<String, SubnetRegistry>,
80	    own_hex: &str,
81	    target: &str,
82	    from: &str,
83	    body: &str,
84	    op_id: &str,
85	    resolver: impl Fn(&str) -> Option<serde_json::Value>,
86	) -> WanSendOutcome {
87	    let address = match Address::parse(target) {
88	        Ok(a) => a,
89	        Err(e) => return WanSendOutcome::Failed(format!("bad address: {e:?}")),
90	    };
91
92	    // The same exclusion rule the registry's advertisement side enforces
93	    // (ADR-0006 §6): hidden = not routable, even via an explicit pin.
94	    let subnets = SubnetStore::load();
95	    let vis = VisibilityStore::load();
96	    let hide_new: BTreeMap<String, bool> =
97	        subnets.subnets.iter().map(|s| (s.name.clone(), s.hide_new_endpoints)).collect();
98	    let excluded =
99	        |subnet: &str, id: &str| vis.hidden(id, subnet, *hide_new.get(subnet).unwrap_or(&false));
100
101	    let instance = match resolve_across_visible(regs, &address, own_hex, excluded) {
102	        Resolution::Resolved { instance, .. } => instance,
103	        Resolution::Ambiguous(a) => return WanSendOutcome::Ambiguous(format!("{a:?}")),
104	        Resolution::NotFound => return WanSendOutcome::NotFound,
105	    };
106	    if instance.node == own_hex {
107	        // A local instance resolves here only when the local perch scan
108	        // missed it (registry staleness) — WAN-dialing ourselves helps
109	        // nobody; let the caller report the local outcome.
110	        return WanSendOutcome::NotFound;
111	    }
112
113	    let Some(addr) = resolver(&instance.node) else {
114	        return WanSendOutcome::Failed(format!("no dialable address for {}", instance.node));
115	    };
116	    let msg = WanMessage {
117	        target: address.id.clone(),
118	        from: from.to_string(),
119	        body: body.to_string(),
120	        op_id: op_id.to_string(),
121	    };
122	    let send = || -> std::io::Result<()> {
123	        let conn = brain.net_dial(addr, None)?;
124	        let opened = brain.net_open_stream(conn.conn_id, None)?;
125	        brain.net_stream_send(opened.stream_id, &ndjson::encode_line(&msg), None, true)?;
126	        Ok(())
127	    };
128	    match send() {
129	        Ok(()) => WanSendOutcome::Sent { node: instance.node },
130	        Err(e) => WanSendOutcome::Failed(e.to_string()),
131	    }
132	}
133
134	/// Load every per-subnet registry snapshot under `dir`. Missing dir or an
135	/// unparseable snapshot degrade to absent (the reader's stale-tolerant
136	/// contract) — resolution then simply finds nothing there.
137	pub fn load_snapshots(dir: &Path) -> BTreeMap<String, SubnetRegistry> {
138	    let mut out = BTreeMap::new();
139	    let Ok(entries) = std::fs::read_dir(dir) else {
140	        return out;
141	    };
142	    for entry in entries.flatten() {
143	        let path = entry.path();
144	        if path.extension().and_then(|e| e.to_str()) != Some("json") {
145	            continue;
146	        }
147	        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue };
148	        let Ok(raw) = std::fs::read_to_string(&path) else { continue };
149	        let Ok(reg) = serde_json::from_str::<SubnetRegistry>(&raw) else { continue };
150	        out.insert(stem.to_string(), reg);
151	    }
152	    out
153	}
154
155	fn now_ms() -> u64 {
156	    std::time::SystemTime::now()
157	        .duration_since(std::time::UNIX_EPOCH)
158	        .map(|d| d.as_millis() as u64)
159	        .unwrap_or(0)
160	}
161
162	#[cfg(test)]
163	mod tests {
164	    use super::*;
165	    use crate::testutil::isolated_home;
166	    use spt_daemon::broker::Broker;
167	    use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
168	    use spt_daemon::nethost::{NetConfig, NetHost};
169	    use spt_daemon::registryhost::RegistryHost;
170	    use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
171	    use spt_net::net::registry::{Instance, Status};
172	    use spt_proto::identity::Identity;
173	    use spt_store::info::{self, InfoJson};
174	    use spt_store::perch::ParentHint;
175	    use spt_store::spool;
176	    use std::sync::atomic::{AtomicBool, Ordering};
177	    use std::sync::Arc;
178	    use std::time::Duration;
179
180	    fn net_broker(name: &str, dir: &Path) -> Arc<Broker> {
181	        let host = NetHost::start(NetConfig {
182	            identity: Identity::generate(),
183	            relay: RelayPolicy::Disabled,
184	            discovery: LocalDiscovery::Off,
185	            scope: BindScope::Loopback,
186	        })
187	        .expect("net host");
188	        let broker = Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host))
189	            .expect("bind broker");
190	        let serve = Arc::clone(&broker);
191	        std::thread::spawn(move || {
192	            let _ = serve.serve();
193	        });
194	        broker
195	    }
196
197	    fn connect_retry(name: &str) -> Brain {
198	        for _ in 0..300 {
199	            if let Ok(b) = Brain::cold_start(name, 1) {
200	                return b;
201	            }
202	            std::thread::sleep(Duration::from_millis(10));
203	        }
204	        panic!("brain could not connect");
205	    }
206
207	    fn reg_with(subnet: &str, id: &str, node: &str) -> BTreeMap<String, SubnetRegistry> {
208	        let mut reg = SubnetRegistry::new();
209	        reg.merge_instance(
210	            id,
211	            Instance { node: node.to_string(), status: Status::Active, epoch: 1 },
212	        );
213	        let mut map = BTreeMap::new();
214	        map.insert(subnet.to_string(), reg);
215	        map
216	    }
217
218	    // [unit->REQ-NET-1] the WAN leg end to end: resolve from a registry map,
219	    // dial, ship one WanMessage; B's production dispatcher funnels it into
220	    // the target perch's spool. Ambiguity refuses (REQ-INST-10); an
221	    // own-node hit degrades to NotFound (the local path's report stands).
222	    #[test]
223	    fn wan_send_ships_to_the_resolved_node() {
224	        let _home = isolated_home();
225	        let dir = tempfile::tempdir().unwrap();
226	        let (a_name, b_name) = (
227	            format!("spt-wansend-a-{}.sock", std::process::id()),
228	            format!("spt-wansend-b-{}.sock", std::process::id()),
229	        );
230	        let _a = net_broker(&a_name, dir.path());
231	        let _b = net_broker(&b_name, dir.path());
232
233	        // B runs the production dispatcher against the canonical home.
234	        let stop = Arc::new(AtomicBool::new(false));
235	        {
236	            let name = b_name.clone();
237	            let flag = Arc::clone(&stop);
238	            let paths = DispatchPaths {
239	                owlery: perch::owlery_dir(),
240	                releases: perch::spt_home().join("releases"),
241	                scratch: dir.path().join("b-scratch"),
242	            };
243	            let registry = Arc::new(RegistryHost::new_at(
244	                "b-wansend",
245	                EpochSource::load_from(&dir.path().join("b-epoch.json")),
246	                dir.path().join("b-registry"),
247	            ));
248	            std::thread::spawn(move || {
249	                let _ = run_dispatch_loop(&name, &paths, registry, &flag, Duration::from_millis(25));
250	            });
251	        }
252	        // An offline perch at B so the funnel spools.
253	        let target = "ling-wan";
254	        let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
255	        std::fs::create_dir_all(&perch_path).unwrap();
256	        let rec = InfoJson::new(target, "now", std::process::id(), "s", "ready_agent");
257	        info::write_info(&perch_path, &rec).unwrap();
258
259	        let mut a = connect_retry(&a_name);
260	        let mut b_probe = connect_retry(&b_name);
261	        let a_hex = a.net_status().expect("status").node_id_hex.expect("net");
262	        let b_hex = b_probe.net_status().expect("status").node_id_hex.expect("net");
263	        let b_addr = b_probe.net_status().expect("status").addr;
264
265	        // Ambiguity refuses before any wire work (two subnets hold the id).
266	        let mut ambiguous = reg_with("s1", target, &b_hex);
267	        ambiguous.extend(reg_with("s2", target, &b_hex));
268	        let out = wan_send_with(
269	            &mut a, &ambiguous, &a_hex, target, "doyle", "x", "op-amb",
270	            |_| None,
271	        );
272	        assert!(matches!(out, WanSendOutcome::Ambiguous(_)), "got {out:?}");
273
274	        // An own-node hit is NotFound (the local report stands; no self-dial).
275	        let own = reg_with("s1", target, &a_hex);
276	        let out =
277	            wan_send_with(&mut a, &own, &a_hex, target, "doyle", "x", "op-own", |_| None);
278	        assert!(matches!(out, WanSendOutcome::NotFound), "got {out:?}");
279
280	        // The real ship: resolve → dial → one record → B spools it.
281	        let regs = reg_with("s1", target, &b_hex);
282	        let resolver = |hex: &str| {
283	            if hex == b_hex {
284	                Some(b_addr.clone())
285	            } else {
286	                None
287	            }
288	        };
289	        let out = wan_send_with(
290	            &mut a, &regs, &a_hex, target, "doyle", "over the wan", "op-ship", resolver,
291	        );
292	        assert!(matches!(out, WanSendOutcome::Sent { .. }), "got {out:?}");
293	        let mut spooled = false;
294	        for _ in 0..400 {
295	            let pending = spool::peek_all_at(&perch_path).expect("peek");
296	            if pending.iter().any(|(_, _, body, _)| body == "over the wan") {
297	                spooled = true;
298	                break;
299	            }
300	            std::thread::sleep(Duration::from_millis(25));
301	        }
302	        assert!(spooled, "B's dispatcher funneled the WAN message into the spool");
303	        stop.store(true, Ordering::Relaxed);
304	    }
305
306	    // [unit->REQ-NET-1] snapshot loading degrades: missing dir, junk files,
307	    // and non-json entries all yield absence, never an error.
308	    #[test]
309	    fn load_snapshots_degrades_on_junk() {
310	        let dir = tempfile::tempdir().unwrap();
311	        assert!(load_snapshots(&dir.path().join("nope")).is_empty());
312	        let d = dir.path().join("registry");
313	        std::fs::create_dir_all(&d).unwrap();
314	        std::fs::write(d.join("good.json"), serde_json::to_string(&SubnetRegistry::new()).unwrap())
315	            .unwrap();
316	        std::fs::write(d.join("bad.json"), "{{{").unwrap();
317	        std::fs::write(d.join("ignored.txt"), "x").unwrap();
318	        let map = load_snapshots(&d);
319	        assert_eq!(map.len(), 1);
320	        assert!(map.contains_key("good"));
321	    }
322	}
323

//! `spt send`'s WAN leg (D9-1-4) — the D5 deferral: "`spt send` grows the
//! off-node leg transparently."
//!
//! When local delivery finds no perch, the CLI consults the daemon's
//! registry **snapshots** (`identity/registry/<subnet>.json`, the
//! [`spt_daemon::registryhost::RegistryHost`] mirror — stale-tolerant by
//! contract, KNOWN-HAZARDS 4.3), resolves the target under the D3c policy
//! ([`resolve_across_visible`]: refuse-and-qualify on ambiguity, never a
//! guess), and ships one [`WanMessage`] over the broker's QUIC endpoint to
//! the resolved node. The receiving daemon's dispatcher funnels it through
//! the access gate + op-id dedup into the target's local delivery
//! (`spt-daemon::wan::receive_wan`).
//!
//! Deferred sends stay local-only in v1: the wire record carries no
//! deferred class, and inventing one is a protocol change — the seam is
//! documented, not faked.
// [impl->REQ-NET-1]
// [impl->REQ-INST-10]

use std::collections::BTreeMap;
use std::path::Path;

use spt_daemon::brain::Brain;
use spt_daemon::endpoint::broker_socket_name;
use spt_net::net::endpoint::addr_for_node_hex;
use spt_net::net::ndjson;
use spt_net::net::registry::{resolve_across_visible, Resolution, SubnetRegistry};
use spt_net::net::wanmsg::WanMessage;
use spt_proto::addr::Address;
use spt_store::epoch::EpochSource;
use spt_store::perch;
use spt_store::subnet::SubnetStore;
use spt_store::visibility::VisibilityStore;

/// What the WAN leg did — the CLI's exit-path discriminant.
#[derive(Debug)]
pub enum WanSendOutcome {
    /// Shipped to the resolved node (hex). Delivery/spool happens there.
    Sent { node: String },
    /// Resolution refused — the message says which qualifier disambiguates.
    Ambiguous(String),
    /// No routable remote instance in any visible subnet.
    NotFound,
    /// Resolution succeeded but the wire leg failed (daemon down, dial
    /// failed, peer unreachable).
    Failed(String),
}

/// Production entry: resolve `target` against the canonical snapshots and
/// ship over the canonical broker. `target` accepts `[subnet:]id[@node]`.
pub fn wan_send(target: &str, from: &str, body: &str) -> WanSendOutcome {
    let own_hex = match spt_store::nodeid::load_or_create() {
        Ok(id) => id.public_key().to_hex(),
        Err(e) => return WanSendOutcome::Failed(format!("node identity: {e}")),
    };
    let regs = load_snapshots(&perch::identity_dir().join("registry"));
    if spt_daemon::daemon::ensure_running().is_err() {
        return WanSendOutcome::Failed("daemon did not come up".to_string());
    }
    let mut brain = match Brain::cold_start(&broker_socket_name(), now_ms()) {
        Ok(b) => b,
        Err(e) => return WanSendOutcome::Failed(format!("broker connect: {e}")),
    };
    let mut ops = EpochSource::load_from(&perch::identity_dir().join("cli-ops.json"));
    let op_id = match ops.next_epoch() {
        Ok(e) => format!("{own_hex}:cli:{e}"),
        Err(e) => return WanSendOutcome::Failed(format!("op counter: {e}")),
    };
    wan_send_with(&mut brain, &regs, &own_hex, target, from, body, &op_id, |hex| {
        addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok())
    })
}

/// The testable core: resolve against `regs`, dial via `resolver`, write one
/// `WanMessage` line, finish the stream.
#[allow(clippy::too_many_arguments)]
pub fn wan_send_with(
    brain: &mut Brain,
    regs: &BTreeMap<String, SubnetRegistry>,
    own_hex: &str,
    target: &str,
    from: &str,
    body: &str,
    op_id: &str,
    resolver: impl Fn(&str) -> Option<serde_json::Value>,
) -> WanSendOutcome {
    let address = match Address::parse(target) {
        Ok(a) => a,
        Err(e) => return WanSendOutcome::Failed(format!("bad address: {e:?}")),
    };

    // The same exclusion rule the registry's advertisement side enforces
    // (ADR-0006 §6): hidden = not routable, even via an explicit pin.
    let subnets = SubnetStore::load();
    let vis = VisibilityStore::load();
    let hide_new: BTreeMap<String, bool> =
        subnets.subnets.iter().map(|s| (s.name.clone(), s.hide_new_endpoints)).collect();
    let excluded =
        |subnet: &str, id: &str| vis.hidden(id, subnet, *hide_new.get(subnet).unwrap_or(&false));

    let instance = match resolve_across_visible(regs, &address, own_hex, excluded) {
        Resolution::Resolved { instance, .. } => instance,
        Resolution::Ambiguous(a) => return WanSendOutcome::Ambiguous(format!("{a:?}")),
        Resolution::NotFound => return WanSendOutcome::NotFound,
    };
    if instance.node == own_hex {
        // A local instance resolves here only when the local perch scan
        // missed it (registry staleness) — WAN-dialing ourselves helps
        // nobody; let the caller report the local outcome.
        return WanSendOutcome::NotFound;
    }

    let Some(addr) = resolver(&instance.node) else {
        return WanSendOutcome::Failed(format!("no dialable address for {}", instance.node));
    };
    let msg = WanMessage {
        target: address.id.clone(),
        from: from.to_string(),
        body: body.to_string(),
        op_id: op_id.to_string(),
    };
    let send = || -> std::io::Result<()> {
        let conn = brain.net_dial(addr, None)?;
        let opened = brain.net_open_stream(conn.conn_id, None)?;
        brain.net_stream_send(opened.stream_id, &ndjson::encode_line(&msg), None, true)?;
        Ok(())
    };
    match send() {
        Ok(()) => WanSendOutcome::Sent { node: instance.node },
        Err(e) => WanSendOutcome::Failed(e.to_string()),
    }
}

/// Load every per-subnet registry snapshot under `dir`. Missing dir or an
/// unparseable snapshot degrade to absent (the reader's stale-tolerant
/// contract) — resolution then simply finds nothing there.
pub fn load_snapshots(dir: &Path) -> BTreeMap<String, SubnetRegistry> {
    let mut out = BTreeMap::new();
    let Ok(entries) = std::fs::read_dir(dir) else {
        return out;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("json") {
            continue;
        }
        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue };
        let Ok(raw) = std::fs::read_to_string(&path) else { continue };
        let Ok(reg) = serde_json::from_str::<SubnetRegistry>(&raw) else { continue };
        out.insert(stem.to_string(), reg);
    }
    out
}

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testutil::isolated_home;
    use spt_daemon::broker::Broker;
    use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
    use spt_daemon::nethost::{NetConfig, NetHost};
    use spt_daemon::registryhost::RegistryHost;
    use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
    use spt_net::net::registry::{Instance, Status};
    use spt_proto::identity::Identity;
    use spt_store::info::{self, InfoJson};
    use spt_store::perch::ParentHint;
    use spt_store::spool;
    use std::sync::atomic::{AtomicBool, Ordering};
    use std::sync::Arc;
    use std::time::Duration;

    fn net_broker(name: &str, dir: &Path) -> Arc<Broker> {
        let host = NetHost::start(NetConfig {
            identity: Identity::generate(),
            relay: RelayPolicy::Disabled,
            discovery: LocalDiscovery::Off,
            scope: BindScope::Loopback,
        })
        .expect("net host");
        let broker = Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host))
            .expect("bind broker");
        let serve = Arc::clone(&broker);
        std::thread::spawn(move || {
            let _ = serve.serve();
        });
        broker
    }

    fn connect_retry(name: &str) -> Brain {
        for _ in 0..300 {
            if let Ok(b) = Brain::cold_start(name, 1) {
                return b;
            }
            std::thread::sleep(Duration::from_millis(10));
        }
        panic!("brain could not connect");
    }

    fn reg_with(subnet: &str, id: &str, node: &str) -> BTreeMap<String, SubnetRegistry> {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance(
            id,
            Instance { node: node.to_string(), status: Status::Active, epoch: 1 },
        );
        let mut map = BTreeMap::new();
        map.insert(subnet.to_string(), reg);
        map
    }

    // [unit->REQ-NET-1] the WAN leg end to end: resolve from a registry map,
    // dial, ship one WanMessage; B's production dispatcher funnels it into
    // the target perch's spool. Ambiguity refuses (REQ-INST-10); an
    // own-node hit degrades to NotFound (the local path's report stands).
    #[test]
    fn wan_send_ships_to_the_resolved_node() {
        let _home = isolated_home();
        let dir = tempfile::tempdir().unwrap();
        let (a_name, b_name) = (
            format!("spt-wansend-a-{}.sock", std::process::id()),
            format!("spt-wansend-b-{}.sock", std::process::id()),
        );
        let _a = net_broker(&a_name, dir.path());
        let _b = net_broker(&b_name, dir.path());

        // B runs the production dispatcher against the canonical home.
        let stop = Arc::new(AtomicBool::new(false));
        {
            let name = b_name.clone();
            let flag = Arc::clone(&stop);
            let paths = DispatchPaths {
                owlery: perch::owlery_dir(),
                releases: perch::spt_home().join("releases"),
                scratch: dir.path().join("b-scratch"),
            };
            let registry = Arc::new(RegistryHost::new_at(
                "b-wansend",
                EpochSource::load_from(&dir.path().join("b-epoch.json")),
                dir.path().join("b-registry"),
            ));
            std::thread::spawn(move || {
                let _ = run_dispatch_loop(&name, &paths, registry, &flag, Duration::from_millis(25));
            });
        }
        // An offline perch at B so the funnel spools.
        let target = "ling-wan";
        let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
        std::fs::create_dir_all(&perch_path).unwrap();
        let rec = InfoJson::new(target, "now", std::process::id(), "s", "ready_agent");
        info::write_info(&perch_path, &rec).unwrap();

        let mut a = connect_retry(&a_name);
        let mut b_probe = connect_retry(&b_name);
        let a_hex = a.net_status().expect("status").node_id_hex.expect("net");
        let b_hex = b_probe.net_status().expect("status").node_id_hex.expect("net");
        let b_addr = b_probe.net_status().expect("status").addr;

        // Ambiguity refuses before any wire work (two subnets hold the id).
        let mut ambiguous = reg_with("s1", target, &b_hex);
        ambiguous.extend(reg_with("s2", target, &b_hex));
        let out = wan_send_with(
            &mut a, &ambiguous, &a_hex, target, "doyle", "x", "op-amb",
            |_| None,
        );
        assert!(matches!(out, WanSendOutcome::Ambiguous(_)), "got {out:?}");

        // An own-node hit is NotFound (the local report stands; no self-dial).
        let own = reg_with("s1", target, &a_hex);
        let out =
            wan_send_with(&mut a, &own, &a_hex, target, "doyle", "x", "op-own", |_| None);
        assert!(matches!(out, WanSendOutcome::NotFound), "got {out:?}");

        // The real ship: resolve → dial → one record → B spools it.
        let regs = reg_with("s1", target, &b_hex);
        let resolver = |hex: &str| {
            if hex == b_hex {
                Some(b_addr.clone())
            } else {
                None
            }
        };
        let out = wan_send_with(
            &mut a, &regs, &a_hex, target, "doyle", "over the wan", "op-ship", resolver,
        );
        assert!(matches!(out, WanSendOutcome::Sent { .. }), "got {out:?}");
        let mut spooled = false;
        for _ in 0..400 {
            let pending = spool::peek_all_at(&perch_path).expect("peek");
            if pending.iter().any(|(_, _, body, _)| body == "over the wan") {
                spooled = true;
                break;
            }
            std::thread::sleep(Duration::from_millis(25));
        }
        assert!(spooled, "B's dispatcher funneled the WAN message into the spool");
        stop.store(true, Ordering::Relaxed);
    }

    // [unit->REQ-NET-1] snapshot loading degrades: missing dir, junk files,
    // and non-json entries all yield absence, never an error.
    #[test]
    fn load_snapshots_degrades_on_junk() {
        let dir = tempfile::tempdir().unwrap();
        assert!(load_snapshots(&dir.path().join("nope")).is_empty());
        let d = dir.path().join("registry");
        std::fs::create_dir_all(&d).unwrap();
        std::fs::write(d.join("good.json"), serde_json::to_string(&SubnetRegistry::new()).unwrap())
            .unwrap();
        std::fs::write(d.join("bad.json"), "{{{").unwrap();
        std::fs::write(d.join("ignored.txt"), "x").unwrap();
        let map = load_snapshots(&d);
        assert_eq!(map.len(), 1);
        assert!(map.contains_key("good"));
    }
}


Implementing D9-4. **1. Instance grows `resources` + the projection (spt-net):**


Edit

/// One endpoint as seen on one node: the node it runs on, its advertised [`Status`], and
/// the author node's monotonic [`epoch`](Self::epoch) (the merge-precedence key).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Instance {
    /// The node this instance runs on (node-qualifier in `id@node` addressing).
    pub node: String,
    /// Advertised liveness.
    pub status: Status,
    /// The author node's per-node monotonic epoch at the time of this update. The sole
    /// precedence key in [`merge_instance`] — strictly-greater wins.
    pub epoch: u64,
}

/// One endpoint as seen on one node: the node it runs on, its advertised [`Status`], and
/// the author node's monotonic [`epoch`](Self::epoch) (the merge-precedence key).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Instance {
    /// The node this instance runs on (node-qualifier in `id@node` addressing).
    pub node: String,
    /// Advertised liveness.
    pub status: Status,
    /// The author node's per-node monotonic epoch at the time of this update. The sole
    /// precedence key in [`merge_instance`] — strictly-greater wins.
    pub epoch: u64,
    /// The resource-advertisement blurb (D9-4, REQ-INST-14 — CONTEXT §resource
    /// advertisement): a free-text "yellow-pages" line describing what this
    /// endpoint can serve. **Not a separate registry** — a field on the
    /// instance row, riding the existing epoch lease + `RegistryUpdate`
    /// replication (a blurb update is ordered like any instance update);
    /// the "subnet resource registry" is a [projection](resource_projection)
    /// of these rows. Additive serde-default: absent = no blurb, and a
    /// pre-D9-4 record parses clean.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resources: Option<String>,
}


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

/// One endpoint as seen on one node: the node it runs on, its advertised [`Status`], and
/// the author node's monotonic [`epoch`](Self::epoch) (the merge-precedence key).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Instance {
    /// The node this instance runs on (node-qualifier in `id@node` addressing).
    pub node: String,
    /// Advertised liveness.
    pub status: Status,
    /// The author node's per-node monotonic epoch at the time of this update. The sole
    /// precedence key in [`merge_instance`] — strictly-greater wins.
    pub epoch: u64,
}

/// One endpoint as seen on one node: the node it runs on, its advertised [`Status`], and
/// the author node's monotonic [`epoch`](Self::epoch) (the merge-precedence key).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Instance {
    /// The node this instance runs on (node-qualifier in `id@node` addressing).
    pub node: String,
    /// Advertised liveness.
    pub status: Status,
    /// The author node's per-node monotonic epoch at the time of this update. The sole
    /// precedence key in [`merge_instance`] — strictly-greater wins.
    pub epoch: u64,
    /// The resource-advertisement blurb (D9-4, REQ-INST-14 — CONTEXT §resource
    /// advertisement): a free-text "yellow-pages" line describing what this
    /// endpoint can serve. **Not a separate registry** — a field on the
    /// instance row, riding the existing epoch lease + `RegistryUpdate`
    /// replication (a blurb update is ordered like any instance update);
    /// the "subnet resource registry" is a [projection](resource_projection)
    /// of these rows. Additive serde-default: absent = no blurb, and a
    /// pre-D9-4 record parses clean.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resources: Option<String>,
}

+    /// The resource-advertisement blurb (D9-4, REQ-INST-14 — CONTEXT §resource

+    /// advertisement): a free-text "yellow-pages" line describing what this

+    /// endpoint can serve. **Not a separate registry** — a field on the

+    /// instance row, riding the existing epoch lease + `RegistryUpdate`

+    /// replication (a blurb update is ordered like any instance update);

+    /// the "subnet resource registry" is a [projection](resource_projection)

+    /// of these rows. Additive serde-default: absent = no blurb, and a

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

/// The per-subnet registry: `endpoint_id → [instance...]`. `BTreeMap`/`Vec` for stable,


TaskCreate

D9-4: REQ-INST-14 resource advertisement (blurb on Instance + spt resources CLI + projection)

resources: Option<String> on Instance (serde-default, rides RegistryUpdate + lease); resource_projection over visible+routable rows; info.json resources field + set_resources; daemon.json resources_blurb node seed (drop Copy on DaemonConfig); advertise_local fills endpoint-else-node blurb; spt resources set/show/list CLI; CONTEXT doc tag; activate REQ-INST-14 [doc, impl, unit]. CI green.


Task #5 created successfully: D9-4: REQ-INST-14 resource advertisement (blurb on Instance + spt resources CLI + projection)


Edit

impl Status {
    /// Whether an instance in this status is a valid routing target. `Offline` is not
    /// (the resolver skips it); `Active` and `Dormant` are both addressable.
    fn routable(self) -> bool {
        !matches!(self, Status::Offline)
    }
}

impl Status {
    /// Whether an instance in this status is a valid routing target. `Offline` is not
    /// (the resolver skips it); `Active`, `Dormant`, and `Suspended` are addressable.
    pub fn routable(self) -> bool {
        !matches!(self, Status::Offline)
    }
}

/// One row of the **subnet resource registry** view (D9-4, REQ-INST-14):
/// `(id, node, status, blurb)` — the agent yellow-pages projection of the
/// per-subnet registry, exactly as CONTEXT defines it (a *projection*, not a
/// separate store).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceRow {
    pub endpoint_id: String,
    pub node: String,
    pub status: Status,
    /// The advertised blurb (`None` = the endpoint advertises no services —
    /// the row still lists; absence renders clean).
    pub resources: Option<String>,
}

/// Project one subnet's registry into its resource-registry view, over
/// **visible** rows only: `excluded(id)` is the same visibility closure the
/// resolver uses ([`SubnetRegistry::resolve_visible`]), so a hidden endpoint
/// never appears — discovery leaks nothing a viewer couldn't reach
/// (ADR-0006 §6, exclusion by construction). Unroutable (`Offline`) rows are
/// skipped too: a dead instance serves nothing, so it is not a resource.
// [impl->REQ-INST-14]
pub fn resource_projection(
    reg: &SubnetRegistry,
    excluded: impl Fn(&str) -> bool,
) -> Vec<ResourceRow> {
    let mut out = Vec::new();
    for id in reg.endpoint_ids() {
        if excluded(id) {
            continue;
        }
        for instance in reg.instances(id) {
            if !instance.status.routable() {
                continue;
            }
            out.push(ResourceRow {
                endpoint_id: id.to_string(),
                node: instance.node.clone(),
                status: instance.status,
                resources: instance.resources.clone(),
            });
        }
    }
    out
}


impl Status {
    /// Whether an instance in this status is a valid routing target. `Offline` is not
    /// (the resolver skips it); `Active` and `Dormant` are both addressable.
    fn routable(self) -> bool {
        !matches!(self, Status::Offline)
    }
}

impl Status {
    /// Whether an instance in this status is a valid routing target. `Offline` is not
    /// (the resolver skips it); `Active`, `Dormant`, and `Suspended` are addressable.
    pub fn routable(self) -> bool {
        !matches!(self, Status::Offline)
    }
}

/// One row of the **subnet resource registry** view (D9-4, REQ-INST-14):
/// `(id, node, status, blurb)` — the agent yellow-pages projection of the
/// per-subnet registry, exactly as CONTEXT defines it (a *projection*, not a
/// separate store).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceRow {
    pub endpoint_id: String,
    pub node: String,
    pub status: Status,
    /// The advertised blurb (`None` = the endpoint advertises no services —
    /// the row still lists; absence renders clean).
    pub resources: Option<String>,
}

/// Project one subnet's registry into its resource-registry view, over
/// **visible** rows only: `excluded(id)` is the same visibility closure the
/// resolver uses ([`SubnetRegistry::resolve_visible`]), so a hidden endpoint
/// never appears — discovery leaks nothing a viewer couldn't reach
/// (ADR-0006 §6, exclusion by construction). Unroutable (`Offline`) rows are
/// skipped too: a dead instance serves nothing, so it is not a resource.
// [impl->REQ-INST-14]
pub fn resource_projection(
    reg: &SubnetRegistry,
    excluded: impl Fn(&str) -> bool,
) -> Vec<ResourceRow> {
    let mut out = Vec::new();
    for id in reg.endpoint_ids() {
        if excluded(id) {
            continue;
        }
        for instance in reg.instances(id) {
            if !instance.status.routable() {
                continue;
            }
            out.push(ResourceRow {
                endpoint_id: id.to_string(),
                node: instance.node.clone(),
                status: instance.status,
                resources: instance.resources.clone(),
            });
        }
    }
    out
}

-    /// (the resolver skips it); `Active` and `Dormant` are both addressable.

+    /// (the resolver skips it); `Active`, `Dormant`, and `Suspended` are addressable.

+/// One row of the **subnet resource registry** view (D9-4, REQ-INST-14):

+/// `(id, node, status, blurb)` — the agent yellow-pages projection of the

+/// per-subnet registry, exactly as CONTEXT defines it (a *projection*, not a

+    /// The advertised blurb (`None` = the endpoint advertises no services —

+    /// the row still lists; absence renders clean).

+/// Project one subnet's registry into its resource-registry view, over

+/// **visible** rows only: `excluded(id)` is the same visibility closure the

+/// resolver uses ([`SubnetRegistry::resolve_visible`]), so a hidden endpoint

+/// never appears — discovery leaks nothing a viewer couldn't reach

+/// (ADR-0006 §6, exclusion by construction). Unroutable (`Offline`) rows are

+/// skipped too: a dead instance serves nothing, so it is not a resource.

+        for instance in reg.instances(id) {

+            if !instance.status.routable() {

+                endpoint_id: id.to_string(),

+                node: instance.node.clone(),

+                status: instance.status,

+                resources: instance.resources.clone(),

/// A join-time bare-id collision (ADR-0006 §1): the joining endpoint's id is already

/// held in the target subnet by a *different* node, so advertising it would create two

/// distinct endpoints sharing one bare name inside a single subnet. The caller surfaces


Fix Instance literal sites — registry.rs test helper + others:


    fn inst(node: &str, status: Status, epoch: u64) -> Instance {
        Instance { node: node.to_string(), status, epoch }
    }

    fn inst(node: &str, status: Status, epoch: u64) -> Instance {
        Instance { node: node.to_string(), status, epoch, resources: None }
    }


    fn inst(node: &str, status: Status, epoch: u64) -> Instance {
        Instance { node: node.to_string(), status, epoch }
    }

    fn inst(node: &str, status: Status, epoch: u64) -> Instance {
        Instance { node: node.to_string(), status, epoch, resources: None }
    }

-        Instance { node: node.to_string(), status, epoch }

+        Instance { node: node.to_string(), status, epoch, resources: None }


crates\spt-net\src\net\wanmsg.rs:102:            instance: Instance { node: node.into(), status: Status::Active, epoch: 1 },
crates\spt-daemon\tests\sync.rs:355:        Instance { node: "node-b".into(), status: Status::Active, epoch: 2 },
crates\spt-daemon\tests\sync.rs:356:        Instance { node: "node-a".into(), status: Status::Dormant, epoch: 1 },
crates\spt-daemon\src\registryhost.rs:227:                    Instance { node: self.node_hex.clone(), status, epoch: ep };
crates\spt-daemon\src\registryhost.rs:324:            instance: Instance { node: node.to_string(), status: Status::Active, epoch },
crates\spt-daemon\src\registryhost.rs:380:                instance: Instance { node: node.to_string(), status, epoch },
crates\spt-daemon\tests\replicate.rs:110:    let active = Instance { node: a_node.clone(), status: Status::Active, epoch: e1 };
crates\spt-daemon\tests\replicate.rs:133:        instance: Instance { node: a_node.clone(), status: Status::Offline, epoch: e2 },
crates\spt-net\src\net\replicate.rs:86:            instance: Instance { node: node.into(), status, epoch },
crates\spt-daemon\src\reconcile.rs:50:        Instance { node: node.to_string(), status, epoch: 1 }
crates\spt-daemon\tests\peerloop.rs:252:                instance: Instance { node: b_hex.clone(), status: Status::Active, epoch: 1 },
crates\spt-net\src\net\registry.rs:530:        Instance { node: node.to_string(), status, epoch, resources: None }
crates\spt\src\wansend.rs:211:            Instance { node: node.to_string(), status: Status::Active, epoch: 1 },


instance: Instance { node: node.into(), status: Status::Active, epoch: 1 },

instance: Instance { node: node.into(), status: Status::Active, epoch: 1, resources: None },


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

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


        Instance { node: "node-b".into(), status: Status::Active, epoch: 2 },
        Instance { node: "node-a".into(), status: Status::Dormant, epoch: 1 },

        Instance { node: "node-b".into(), status: Status::Active, epoch: 2, resources: None },
        Instance { node: "node-a".into(), status: Status::Dormant, epoch: 1, resources: None },


let active = Instance { node: a_node.clone(), status: Status::Active, epoch: e1 };

let active = Instance { node: a_node.clone(), status: Status::Active, epoch: e1, resources: None };


instance: Instance { node: a_node.clone(), status: Status::Offline, epoch: e2 },

instance: Instance { node: a_node.clone(), status: Status::Offline, epoch: e2, resources: None },


instance: Instance { node: node.into(), status, epoch },

instance: Instance { node: node.into(), status, epoch, resources: None },