error: no library targets found in package `spt`


Bash

cargo test -p spt --bin spt 2>&1 | tail -25

Run spt binary unit tests


test cli::tests::unknown_named_subnet_is_usage_error ... ok
test elevation::tests::current_returns_a_determinate_level_on_supported_platforms ... ok
test elevation::tests::fallback_message_names_authenticator_app_and_subnet ... ok
test cli::tests::subnet_arg_shapes_parse ... ok
test elevation::tests::gate_shows_only_when_confirmed_elevated ... ok
test api::worker::tests::cascade_guard_tracks_live_children ... ok
test wansend::tests::load_snapshots_degrades_on_junk ... ok
test api::worker::tests::guard_clears_only_when_all_children_stopped ... ok
test api::worker::tests::worker_poll_drains_spool ... ok
test api::worker::tests::worker_poll_holds_deferred_while_resting ... ok
test api::worker::tests::worker_start_nests_under_parent ... ok
test cli::tests::fork_forks_homes_and_deletes_exactly_the_source ... ok
test cli::tests::owner_shutdown_gated_by_can_shutdown_grant ... ok
test cli::tests::shell_channels_relay_sensory_and_text_file ... ok
test cli::tests::shell_owner_exclusivity_negatives ... ok
test cli::tests::shell_relink_relaunches_offline_instances_only ... ok
test cli::tests::shell_spawn_approval_floor_gates ... ok
test cli::tests::shell_spawn_cap_and_over_cap_gates ... ok
test cli::tests::shell_spawn_gates_on_the_registered_set ... ok
test cli::tests::shutdown_soft_stops_and_suspends ... ok
test wansend::tests::remote_rest_suspends_and_wakes_with_the_deferred_gate ... ok
test wansend::tests::wan_send_ships_to_the_resolved_node ... ok

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


85 unit tests green. Integration tests:


cargo test -p spt 2>&1 | grep -E "^test result|running \d+|FAILED|error" | head -30

Run all spt tests


test cli::tests::create_existing_name_is_usage_error ... ok
test cli::tests::create_new_existing_name_is_usage_error ... ok
test cli::tests::invalid_usage_errors ... ok
test cli::tests::unknown_named_subnet_is_usage_error ... ok
test result: ok. 85 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 28.83s
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.70s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.37s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.13s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.81s


SPT_HOME=$(mktemp -d) ./target/debug/spt.exe subnet; echo "exit=$?"; echo ---; SPT_HOME=$(mktemp -d) ./target/debug/spt.exe how-to; echo ---; ./target/debug/spt.exe how-to ready | head -20

Smoke-test subnet status + how-to output


This node belongs to no subnet — a standalone node. Local messaging works without one; a subnet links your machines into one fabric.

hint: `spt subnet create <name>` mints a new subnet on this machine
hint: `spt subnet show-totp [name]` shows a subnet's joining code (elevated)
exit=0
---
Topics (`spt how-to <topic>`):
  ready  receive messages: listener guidance + the --once fallback loop
  send   deliver messages: send vs ring, replies, SENT vs QUEUED
---
# how-to: ready — receive messages

Pick a short lowercase perch id for yourself (`<id>` below). `spt whoami`
prints it if your session already has one.

Preferred — your harness can host a long-running background task:

    spt ready <id>

- Blocks for the session's life: run it as a background task and read its stdout.
- `READY:<id>` on stderr is the online signal.
- The spooled backlog drains first; then each delivery prints to stdout as it
  arrives.
- Delivery shape on stdout: a `__REPLY_TO__:<sender>` header line, then the body.

Fallback — no long-running tasks available (the one-shot loop):

    spt ready <id> --once

- Drains the backlog, waits for one live delivery, then exits.


Check grouped help render


spt — a harness-independent core for an agent ecosystem: inter-agent messaging, live-agent
lifecycle, terminal hosting, P2P networking, seamless self-update. Docs:
https://sabermage.github.io/spt-releases

Usage: spt.exe <COMMAND>

User commands:
  access     Endpoint access whitelist for unsolicited off-node inbound
  adapter    Adapter registration: what this node can drive/launch
  digest     Show a hosted session's live activity buffer (PTY digest)
  fork       Fork an endpoint into another subnet as a NEW identity
  grant      Consent grant store: gated capabilities held on this node
  help       Print this message or the help of the given subcommand(s)
  list       List this node's perches (id, state, address, ready, alive)
  notif      Inspect and acknowledge notifications
  rename     Rename an endpoint's logical id across its on-disk state
  stop       Soft-stop a perch (spool preserved)
  subnet     Subnet membership: status, create, show-totp
  suspend    Rest an endpoint cold (the suspend edge)
  update     Self-update operations
  wake       Wake a resting endpoint in place

Agent commands:
  api        Harness-contract inbound surface (hook entry points)
  how-to     Task-oriented instructions for agents: `how-to <topic>`
  notify     Issue a subnet-wide user notification
  ready      Become reachable: register the perch and listen (blocks)
  resources  The subnet resource registry — an agent yellow-pages
  ring       Send and block for a reply (body read from stdin)
  send       Send a message (body read from stdin); fire-and-forget
  shell      Shell instances: mint, list, drive, tear down owned surfaces
  shutdown   Gracefully shut down an agent's own endpoint
  whoami     Print this session's own perch id

Options:
  -h, --help     Print help
  -V, --version  Print version


Activate REQ-SUBNET-1 + REQ-DOCS-6 stages in traceable-reqs.toml

Set required_stages = ["impl","unit"] (rule 5 activation at M7 D1 start); int stages added later (D5).

Build spt subnet namespace: status (basic), create (+QR), show-totp; delete pair

cli.rs: Subnet cmd with optional subcommand (bare = status view). create <NAME> elevation-gated mint + code + otpauth + unicode QR + join hint. show-totp [NAME] positional, REQ-PAIR-3/6 semantics. Basic status render (names/node/endpoint counts, hint footer, zero-subnet text — hybrid liveness + --nodes is D2). Remove Cmd::Pair/PairCmd. Add qrcode crate dep.

Remove spt poll; add ready --once

Drop Cmd::Poll, add --once to Ready (run_listen absorbs). Update AGENT_COMMANDS. quickstart_e2e.rs switches poll→ready (full rewrite is D5).

Add spt how-to command with ready/send topics

In-binary agent instructions (REQ-DOCS-6). Bare how-to lists topics; topics: ready (background-task guidance, --once fallback loop, reply mechanics), send (send/ring, reply-to, SENT vs QUEUED). Agent-commands help group.

Update unit tests + regen cli/reference.md + green checks

Update subcommands_parse, pair→subnet tests, how-to parse tests; help_groups_cover_every_command passes. cargo test -p spt, xtask gen, traceable-reqs check, commit.


Updated task #2 status


Updated task #3 status


Updated task #4 status


Updated task #5 status


SPT_HOME=$(mktemp -d) ./target/debug/spt.exe subnet create testnet; echo "exit=$?"

Smoke-test subnet create (elevation gate)


ELEVATION_REQUIRED: creating subnet 'testnet' reveals its joining secret — re-run elevated (run as administrator / root)
exit=3


cargo run -p xtask -- gen 2>&1 | tail -3 && cargo run -p xtask -- check 2>&1 | tail -5

Regenerate CLI reference and run drift gate


    Finished `dev` profile [unoptimized + debuginfo] target(s) in 21.06s
     Running `target\debug\xtask.exe gen`
xtask gen: wrote C:\Users\decid\Documents\projects\spt-core\docs-site/src/cli/reference.md
   Compiling spt-daemon v0.1.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
   Compiling xtask v0.1.0 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 24.59s
     Running `target\debug\xtask.exe check`
xtask check: OK


Run requirement traceability check


       session-surface abstraction; send-keys + send-line injection
  [OK] REQ-TERM-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Byte-stream remote terminal streaming for v1
  [OK] REQ-TERM-4  required: [impl, unit, int]  stages: +doc +impl +unit +int
       Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log
  [OK] REQ-UPD-1  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: [impl, unit]  stages: -doc +impl +unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters


traceable-reqs check 2>&1 | grep -E "FAIL|MISSING|error|REQ-SUBNET|REQ-DOCS-6|REQ-MSG-2" ; echo "exit=$?"; traceable-reqs check >/dev/null 2>&1; echo "overall=$?"

Check traceability for new REQs + overall exit


  [OK] REQ-DOCS-6  required: [impl, unit]  stages: -doc +impl +unit -int
  [OK] REQ-MSG-2  required: [impl, unit]  stages: -doc +impl +unit -int
  [OK] REQ-SUBNET-1  required: [impl, unit]  stages: -doc +impl +unit -int
  [OK] REQ-SUBNET-2  required: []  stages: -doc -impl -unit -int
  [OK] REQ-SUBNET-3  required: []  stages: -doc -impl -unit -int
  [OK] REQ-SUBNET-4  required: []  stages: -doc -impl -unit -int
exit=0
overall=0


cargo check --workspace 2>&1 | tail -3 && git status --short

Workspace check + git status


    Checking xtask v0.1.0 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
    Checking spt v0.1.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 15.03s
 M Cargo.lock
 M crates/spt-daemon/tests/twohost.rs
 M crates/spt/Cargo.toml
 M crates/spt/src/cli.rs
 M crates/spt/tests/quickstart_e2e.rs
 M docs-site/src/cli/reference.md
 M traceable-reqs.toml


git diff --stat && git diff docs-site/src/cli/reference.md | head -60

Review diff stat + reference diff head


warning: in the working copy of 'Cargo.lock', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/twohost.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/cli.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/tests/quickstart_e2e.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs-site/src/cli/reference.md', LF will be replaced by CRLF the next time Git touches it
 Cargo.lock                         |   9 +-
 crates/spt-daemon/tests/twohost.rs |   2 +-
 crates/spt/Cargo.toml              |   6 +-
 crates/spt/src/cli.rs              | 581 +++++++++++++++++++++++++++++++------
 crates/spt/tests/quickstart_e2e.rs |  16 +-
 docs-site/src/cli/reference.md     | 161 ++++++----
 traceable-reqs.toml                |   4 +-
 7 files changed, 626 insertions(+), 153 deletions(-)
warning: in the working copy of 'docs-site/src/cli/reference.md', LF will be replaced by CRLF the next time Git touches it
diff --git a/docs-site/src/cli/reference.md b/docs-site/src/cli/reference.md
index 3d5eb3a..04f75f6 100644
--- a/docs-site/src/cli/reference.md
+++ b/docs-site/src/cli/reference.md
@@ -20,17 +20,17 @@ User commands:
   help       Print this message or the help of the given subcommand(s)
   list       List this node's perches (id, state, address, ready, alive)
   notif      Inspect and acknowledge notifications
-  pair       Subnet pairing: show this node's current pairing code
   rename     Rename an endpoint's logical id across its on-disk state
   stop       Soft-stop a perch (spool preserved)
+  subnet     Subnet membership: status, create, show-totp
   suspend    Rest an endpoint cold (the suspend edge)
   update     Self-update operations
   wake       Wake a resting endpoint in place

 Agent commands:
   api        Harness-contract inbound surface (hook entry points)
+  how-to     Task-oriented instructions for agents: `how-to <topic>`
   notify     Issue a subnet-wide user notification
-  poll       Receive messages: drain backlog, then listen
   ready      Become reachable: register the perch and listen (blocks)
   resources  The subnet resource registry — an agent yellow-pages
   ring       Send and block for a reply (body read from stdin)
@@ -386,20 +386,60 @@ Options:
   -h, --help  Print help
 ```

-## spt pair
+## spt rename
+
+```text
+Rename an endpoint's logical id across its on-disk state.
+
+Rippled everywhere the id appears: the endpoint's perch dir, its nested companion/worker perches,
+and every record naming it. Refuses while the perch is live (stop it first).
+
+Usage: spt rename <OLD_ID> <NEW_ID>
+
+Arguments:
+  <OLD_ID>
+          The endpoint's current (bare) id
+
+  <NEW_ID>
+          The new (bare) id — charset-validated; `:`/`@` are reserved
+
+Options:
+  -h, --help
+          Print help (see a summary with '-h')
+```
+
+## spt stop
+
+```text
+Soft-stop a perch (spool preserved).
+
+Removes the ready marker and unregisters the perch; the spool is preserved.
+
+Usage: spt stop <ID>
+


Check commit trailer convention


reqs(M7): mint REQ-SUBNET-1..4 + REQ-DOCS-6; annotate poll merge + surface moves

New requirements for M7 (M7-PLAN.md), all required_stages = [] per rule 5
(activate when M7 work starts):

- REQ-SUBNET-1 — spt subnet namespace: status views, create (QR/otpauth),
  show-totp; spt pair deleted
- REQ-SUBNET-2 — guided join e2e: CLI initiator + always-on daemon pairing
  responder (the ceremony's product surface; twohost gains a join rung)
- REQ-SUBNET-3 — node labels: hostname-default, gossiped, addressable in
  @node qualifiers with refuse-on-ambiguity
- REQ-SUBNET-4 — membership mutations elevation-gated (create = seed
  reveal, join = trust-boundary enrollment; ADR-0005 amendment with D3)
- REQ-DOCS-6 — spt how-to <topic>: in-binary agent instructions, single
  source = binary, quickstart prompts point agents at it

Amendments: REQ-MSG-2 retitled/annotated for the poll->ready --once merge;
REQ-PAIR-3/4 notes point at the M7 surface moves (semantics unchanged).
traceable-reqs check green (exit 0).

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


git add -A && git commit -m "$(cat <<'EOF'
feat(cli): M7 D1 — spt subnet namespace, poll->ready --once merge, spt how-to

The CLI-surface deliverable (M7-PLAN.md D1; no new machinery):

- `spt subnet` noun namespace (REQ-SUBNET-1, decisions 3/5/11): bare
  `spt subnet` = flagless status view (names + paired-node/endpoint counts
  from registry snapshots; hint footer always; standalone-node text on zero
  subnets; never seeds/epochs/codes). `create <NAME>` un-buries the
  verb-as-flag `show-totp --create-new`: elevation-gated mint printing the
  current code, otpauth:// URI, terminal QR (new dep: qrcode,
  default-features off), and the join hint. `show-totp [NAME]` ports
  REQ-PAIR-3/6 semantics onto a positional name. `spt pair` deleted
  outright (no shim — zero users). D2 adds --nodes + hybrid liveness; D3
  adds join + the join hint line.
- `spt poll` removed; `spt ready <id> --once` absorbs the drain-then-exit
  semantics (REQ-MSG-2 amendment, decision 2). `spt api poll`/`api
  worker-poll` untouched. quickstart_e2e switched to ready/ready --once
  (the full lea/sergey + how-to rewrite is D5).
- `spt how-to [topic]` (REQ-DOCS-6, decision 12): in-binary task-oriented
  agent instructions, Agent-commands help group. v1 topics: ready
  (listener guidance, --once fallback loop, reply mechanics) and send
  (send vs ring, reply-to, SENT vs QUEUED). Bare how-to lists topics;
  single source = the binary (docs-site never duplicates).
- Activated REQ-SUBNET-1 + REQ-DOCS-6 to [impl, unit] (rule 5).
- cli/reference.md regenerated (xtask gen); xtask check green;
  traceable-reqs check green; cargo test -p spt green (85 unit + e2e).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)" && git log -1 --oneline

Commit M7 D1 CLI surface


warning: in the working copy of 'Cargo.lock', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/twohost.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/cli.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/tests/quickstart_e2e.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs-site/src/cli/reference.md', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 0f85f1d] feat(cli): M7 D1 — spt subnet namespace, poll->ready --once merge, spt how-to
 7 files changed, 626 insertions(+), 153 deletions(-)
0f85f1d feat(cli): M7 D1 — spt subnet namespace, poll->ready --once merge, spt how-to


D1 committed (0f85f1d). Commune delta to psyche:


Write

M7 D1 (CLI surface) COMPLETE — committed 0f85f1d on dev-freeform.

Delivered per M7-PLAN.md D1:
- `spt subnet` namespace live: bare status view (counts from registry snapshots, hint footer, standalone-node zero-subnet text), `create <NAME>` (elevation-gated, code + otpauth + terminal QR via new `qrcode` dep, join hint), `show-totp [NAME]` (REQ-PAIR-3/6 semantics, positional). `spt pair` deleted outright.
- `spt poll` removed; `spt ready --once` absorbs drain-then-exit. quickstart_e2e switched to ready (full lea/sergey rewrite still D5).
- `spt how-to` live (Agent-commands group): topics ready + send, bare lists.
- REQ-SUBNET-1 + REQ-DOCS-6 activated to [impl, unit]. All gates green: cargo test -p spt (85 unit + e2e), xtask check (reference regen committed), traceable-reqs check.

Deferred breadcrumbs: status hint footer gains the `join` one-liner at D3 (command doesn't exist yet — hint must not lie); `--nodes` + hybrid liveness = D2; elevation gate tags move to REQ-SUBNET-4 at D3.

Next: D2 (node labels & status views) ∥ D3 (join machinery). Starting D2: label field in registry gossip (additive serde-default), hostname detection at daemon startup, label/key-prefix @node resolution refuse-on-ambiguity, `subnet status --nodes` render, zero-subnet text already in.


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

M7 D1 (CLI surface) COMPLETE — committed 0f85f1d on dev-freeform.

Delivered per M7-PLAN.md D1:
- `spt subnet` namespace live: bare status view (counts from registry snapshots, hint footer, standalone-node zero-subnet text), `create <NAME>` (elevation-gated, code + otpauth + terminal QR via new `qrcode` dep, join hint), `show-totp [NAME]` (REQ-PAIR-3/6 semantics, positional). `spt pair` deleted outright.
- `spt poll` removed; `spt ready --once` absorbs drain-then-exit. quickstart_e2e switched to ready (full lea/sergey rewrite still D5).
- `spt how-to` live (Agent-commands group): topics ready + send, bare lists.
- REQ-SUBNET-1 + REQ-DOCS-6 activated to [impl, unit]. All gates green: cargo test -p spt (85 unit + e2e), xtask check (reference regen committed), traceable-reqs check.

Deferred breadcrumbs: status hint footer gains the `join` one-liner at D3 (command doesn't exist yet — hint must not lie); `--nodes` + hybrid liveness = D2; elevation gate tags move to REQ-SUBNET-4 at D3.

Next: D2 (node labels & status views) ∥ D3 (join machinery). Starting D2: label field in registry gossip (additive serde-default), hostname detection at daemon startup, label/key-prefix @node resolution refuse-on-ambiguity, `subnet status --nodes` render, zero-subnet text already in.


Found 2 files
crates\spt-daemon\src\registryhost.rs
crates\spt-daemon\src\dispatch.rs


Found 1 file
crates\spt-daemon\tests\budget.rs


Grep

fn advertise_if_visible|pub fn advertise|struct RegistryUpdate|enum .*Update|node:.*String


198:    pub fn advertise_local(&self, owlery: &Path) -> Vec<RegistryUpdate> {


1	//! The daemon's live subnet-registry holder (D9-1-2) — REQ-INST-7's
2	//! production wiring.
3	//!
4	//! D3 built the registry **model** (`spt_net::net::registry`: the epoch
5	//! lease, the join-time collision check, visibility enforcement) and D4d the
6	//! wire record (`RegistryUpdate`), both consumed only by tests. This type is
7	//! the daemon-side instance: one in-memory `SubnetRegistry` per member
8	//! subnet, fed from two directions —
9	//!
10	//! - **inbound**: the dispatcher's registry arm applies peer feeds under the
11	//!   same fail-closed gate as every replicated store (member subnet ∧ origin
12	//!   trusted *in that subnet*, subject = handshake-proven stream origin —
13	//!   KNOWN-HAZARDS 7.5);
14	//! - **local**: [`advertise_local`](RegistryHost::advertise_local) scans the
15	//!   owlery and (re-)advertises every visible self perch with a fresh
16	//!   [`EpochSource`] epoch — the per-node monotonic lease key, never
17	//!   wall-clock (KNOWN-HAZARDS 4.8).
18	//!
19	//! The registry is **eventually consistent by re-advertisement**: state is
20	//! in-memory (a daemon restart forgets peers' rows), and each pump tick
21	//! re-pushes the node's own rows — peers reconverge within one cadence, the
22	//! same replay-is-recovery posture as the D8 notif feed. For out-of-process
23	//! consumers (`spt send`'s WAN resolution, D9-1-4) the host mirrors each
24	//! subnet's registry to an atomic JSON snapshot under
25	//! `identity/registry/` — readers tolerate staleness (the 4.3
26	//! stale-clean posture), never block on the daemon.
27	// [impl->REQ-INST-7]
28
29	use std::collections::BTreeMap;
30	use std::io;
31	use std::path::{Path, PathBuf};
32	use std::sync::Mutex;
33
34	use spt_net::net::registry::{advertise_if_visible, Instance, MergeOutcome, Status, SubnetRegistry};
35	use spt_net::net::replicate::RegistryUpdate;
36	use spt_store::epoch::EpochSource;
37	use spt_store::liveness::is_perch_alive;
38	use spt_store::perch::{self, ParentHint};
39	use spt_store::subnet::SubnetStore;
40	use spt_store::trust::TrustStore;
41	use spt_store::visibility::VisibilityStore;
42
43	/// What the host did with one inbound registry record — loud + typed.
44	#[derive(Debug, Clone, PartialEq, Eq)]
45	pub enum RegistryApplyVerdict {
46	    /// Admitted and merged with the stated outcome (the lease may still have
47	    /// dropped it as [`MergeOutcome::Stale`] — that IS a merge decision).
48	    Merged(MergeOutcome),
49	    /// Refused: this node is not a member of the record's subnet.
50	    NotMember,
51	    /// Refused: the origin is not trusted in the record's subnet.
52	    UntrustedOrigin,
53	}
54
55	/// The inbound gate's config — reloaded per feed (config edits take effect
56	/// without a restart; the access-gate stance shared with sync/notif).
57	pub struct RegistryGatePolicy {
58	    /// Names of every subnet this node is a member of.
59	    pub subnets: Vec<String>,
60	    pub trust: TrustStore,
61	}
62
63	impl RegistryGatePolicy {
64	    /// Load from the node's canonical stores.
65	    pub fn load() -> RegistryGatePolicy {
66	        let subnets =
67	            SubnetStore::load().subnets.iter().map(|s| s.name.clone()).collect();
68	        RegistryGatePolicy { subnets, trust: TrustStore::load() }
69	    }
70
71	    /// member subnet ∧ origin trusted in that subnet — fail-closed on both.
72	    pub fn admits(&self, subnet: &str, origin_node: &str) -> bool {
73	        self.subnets.iter().any(|s| s == subnet) && self.trust.is_trusted(subnet, origin_node)
74	    }
75	}
76
77	/// The daemon's per-subnet registry instances + this node's advertisement
78	/// epoch source. Shared (`Arc`) between the dispatcher's registry arm and
79	/// the peer pump.
80	pub struct RegistryHost {
81	    /// This node's stable name (lowercase-hex pubkey).
82	    node_hex: String,
83	    regs: Mutex<BTreeMap<String, SubnetRegistry>>,
84	    /// The node's one durable monotonic counter (the D3b lease source) — the
85	    /// epoch on every locally-authored instance row.
86	    epoch: Mutex<EpochSource>,
87	    /// Where snapshots land (`identity/registry/<subnet>.json`).
88	    snapshot_dir: PathBuf,
89	}
90
91	impl RegistryHost {
92	    /// Stand the host up for this node, with the canonical epoch counter and
93	    /// snapshot dir under the spt home.
94	    pub fn new(node_hex: &str) -> RegistryHost {
95	        RegistryHost::new_at(
96	            node_hex,
97	            EpochSource::load(),
98	            perch::identity_dir().join("registry"),
99	        )
100	    }
101
102	    /// Explicit-paths constructor (tests; the pump E2E gives each in-process
103	    /// "node" its own counter + snapshot dir).
104	    pub fn new_at(node_hex: &str, epoch: EpochSource, snapshot_dir: PathBuf) -> RegistryHost {
105	        RegistryHost {
106	            node_hex: node_hex.to_string(),
107	            regs: Mutex::new(BTreeMap::new()),
108	            epoch: Mutex::new(epoch),
109	            snapshot_dir,
110	        }
111	    }
112
113	    /// This node's stable name.
114	    pub fn node_hex(&self) -> &str {
115	        &self.node_hex
116	    }
117
118	    /// Apply one inbound feed under the gate. `origin_node` MUST be the
119	    /// handshake-proven stream-table origin, never payload bytes.
120	    // [impl->REQ-INST-7]

//! The daemon's live subnet-registry holder (D9-1-2) — REQ-INST-7's
//! production wiring.
//!
//! D3 built the registry **model** (`spt_net::net::registry`: the epoch
//! lease, the join-time collision check, visibility enforcement) and D4d the
//! wire record (`RegistryUpdate`), both consumed only by tests. This type is
//! the daemon-side instance: one in-memory `SubnetRegistry` per member
//! subnet, fed from two directions —
//!
//! - **inbound**: the dispatcher's registry arm applies peer feeds under the
//!   same fail-closed gate as every replicated store (member subnet ∧ origin
//!   trusted *in that subnet*, subject = handshake-proven stream origin —
//!   KNOWN-HAZARDS 7.5);
//! - **local**: [`advertise_local`](RegistryHost::advertise_local) scans the
//!   owlery and (re-)advertises every visible self perch with a fresh
//!   [`EpochSource`] epoch — the per-node monotonic lease key, never
//!   wall-clock (KNOWN-HAZARDS 4.8).
//!
//! The registry is **eventually consistent by re-advertisement**: state is
//! in-memory (a daemon restart forgets peers' rows), and each pump tick
//! re-pushes the node's own rows — peers reconverge within one cadence, the
//! same replay-is-recovery posture as the D8 notif feed. For out-of-process
//! consumers (`spt send`'s WAN resolution, D9-1-4) the host mirrors each
//! subnet's registry to an atomic JSON snapshot under
//! `identity/registry/` — readers tolerate staleness (the 4.3
//! stale-clean posture), never block on the daemon.
// [impl->REQ-INST-7]

use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use spt_net::net::registry::{advertise_if_visible, Instance, MergeOutcome, Status, SubnetRegistry};
use spt_net::net::replicate::RegistryUpdate;
use spt_store::epoch::EpochSource;
use spt_store::liveness::is_perch_alive;
use spt_store::perch::{self, ParentHint};
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;

/// What the host did with one inbound registry record — loud + typed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistryApplyVerdict {
    /// Admitted and merged with the stated outcome (the lease may still have
    /// dropped it as [`MergeOutcome::Stale`] — that IS a merge decision).
    Merged(MergeOutcome),
    /// Refused: this node is not a member of the record's subnet.
    NotMember,
    /// Refused: the origin is not trusted in the record's subnet.
    UntrustedOrigin,
}

/// The inbound gate's config — reloaded per feed (config edits take effect
/// without a restart; the access-gate stance shared with sync/notif).
pub struct RegistryGatePolicy {
    /// Names of every subnet this node is a member of.
    pub subnets: Vec<String>,
    pub trust: TrustStore,
}

impl RegistryGatePolicy {
    /// Load from the node's canonical stores.
    pub fn load() -> RegistryGatePolicy {
        let subnets =
            SubnetStore::load().subnets.iter().map(|s| s.name.clone()).collect();
        RegistryGatePolicy { subnets, trust: TrustStore::load() }
    }

    /// member subnet ∧ origin trusted in that subnet — fail-closed on both.
    pub fn admits(&self, subnet: &str, origin_node: &str) -> bool {
        self.subnets.iter().any(|s| s == subnet) && self.trust.is_trusted(subnet, origin_node)
    }
}

/// The daemon's per-subnet registry instances + this node's advertisement
/// epoch source. Shared (`Arc`) between the dispatcher's registry arm and
/// the peer pump.
pub struct RegistryHost {
    /// This node's stable name (lowercase-hex pubkey).
    node_hex: String,
    regs: Mutex<BTreeMap<String, SubnetRegistry>>,
    /// The node's one durable monotonic counter (the D3b lease source) — the
    /// epoch on every locally-authored instance row.
    epoch: Mutex<EpochSource>,
    /// Where snapshots land (`identity/registry/<subnet>.json`).
    snapshot_dir: PathBuf,
}

impl RegistryHost {
    /// Stand the host up for this node, with the canonical epoch counter and
    /// snapshot dir under the spt home.
    pub fn new(node_hex: &str) -> RegistryHost {
        RegistryHost::new_at(
            node_hex,
            EpochSource::load(),
            perch::identity_dir().join("registry"),
        )
    }

    /// Explicit-paths constructor (tests; the pump E2E gives each in-process
    /// "node" its own counter + snapshot dir).
    pub fn new_at(node_hex: &str, epoch: EpochSource, snapshot_dir: PathBuf) -> RegistryHost {
        RegistryHost {
            node_hex: node_hex.to_string(),
            regs: Mutex::new(BTreeMap::new()),
            epoch: Mutex::new(epoch),
            snapshot_dir,
        }
    }

    /// This node's stable name.
    pub fn node_hex(&self) -> &str {
        &self.node_hex
    }

    /// Apply one inbound feed under the gate. `origin_node` MUST be the
    /// handshake-proven stream-table origin, never payload bytes.
    // [impl->REQ-INST-7]


120	    // [impl->REQ-INST-7]
121	    // [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
122	    pub fn apply_feed(
123	        &self,
124	        origin_node: &str,
125	        updates: &[RegistryUpdate],
126	        policy: &RegistryGatePolicy,
127	    ) -> Vec<RegistryApplyVerdict> {
128	        self.apply_feed_flips(origin_node, updates, policy).0
129	    }
130
131	    /// [`apply_feed`](Self::apply_feed) that ALSO reports **attention
132	    /// flips** (D9-2, REQ-INST-3): the endpoint ids whose admitted update was
133	    /// an observed *transition to `Active` on a peer node* — the wire signal
134	    /// that another instance of the id just became the driven one (its
135	    /// daemon advertises transitions epoch-bumped), which rests a local
136	    /// instance of the same id warm (CONTEXT: driving `ling@laptop` makes
137	    /// `ling@desktop` dormant).
138	    ///
139	    /// Flip = `Updated` merge where the stored row's status was NOT `Active`
140	    /// and the incoming one is. Deliberately **never `Inserted`**: this
141	    /// registry is in-memory, so a daemon restart re-inserts every peer row
142	    /// at its *current* status — counting inserts would knock local
143	    /// instances dormant on every restart; and never the periodic
144	    /// re-advertisement (`Updated` with prev already `Active` is not a
145	    /// flip). The lease has already dropped stale/equal epochs before any
146	    /// of this runs.
147	    // [impl->REQ-INST-3]
148	    pub fn apply_feed_flips(
149	        &self,
150	        origin_node: &str,
151	        updates: &[RegistryUpdate],
152	        policy: &RegistryGatePolicy,
153	    ) -> (Vec<RegistryApplyVerdict>, Vec<String>) {
154	        let mut regs = self.regs.lock().unwrap();
155	        let mut flips: Vec<String> = Vec::new();
156	        let verdicts = updates
157	            .iter()
158	            .map(|u| {
159	                if !policy.subnets.iter().any(|s| s == &u.subnet) {
160	                    return RegistryApplyVerdict::NotMember;
161	                }
162	                if !policy.trust.is_trusted(&u.subnet, origin_node) {
163	                    return RegistryApplyVerdict::UntrustedOrigin;
164	                }
165	                let reg = regs.entry(u.subnet.clone()).or_default();
166	                let prev = reg
167	                    .instances(&u.endpoint_id)
168	                    .iter()
169	                    .find(|i| i.node == u.instance.node)
170	                    .map(|i| i.status);
171	                // The epoch lease (4.8) decides; an own-row echo from a peer
172	                // is dropped Stale by construction (our epoch is newer or
173	                // equal — never authored elsewhere).
174	                let outcome = reg.merge_instance(&u.endpoint_id, u.instance.clone());
175	                if outcome == MergeOutcome::Updated
176	                    && u.instance.status == Status::Active
177	                    && prev != Some(Status::Active)
178	                    && u.instance.node != self.node_hex
179	                    && !flips.contains(&u.endpoint_id)
180	                {
181	                    flips.push(u.endpoint_id.clone());
182	                }
183	                RegistryApplyVerdict::Merged(outcome)
184	            })
185	            .collect();
186	        drop(regs);
187	        self.write_snapshots();
188	        (verdicts, flips)
189	    }
190
191	    /// Scan the owlery and (re-)advertise every self perch into every member
192	    /// subnet it is visible in, each row stamped with a fresh epoch. Returns
193	    /// the updates to push to peers (the node's own rows only — peers' rows
194	    /// reconverge from their own re-advertisement, no transitive gossip in
195	    /// v1: trust is pairwise per subnet, a relayed third-party row would
196	    /// carry an origin the receiver can't gate honestly).
197	    // [impl->REQ-INST-7]
198	    pub fn advertise_local(&self, owlery: &Path) -> Vec<RegistryUpdate> {
199	        let subnets = SubnetStore::load();
200	        let mut vis = VisibilityStore::load();
201	        // First-join home adoption (D9-5, REQ-INST-15): within one
202	        // advertisement cadence of the node's first join (a pair lands the
203	        // subnet record; no pairing-protocol threading needed), unhomed Self
204	        // perches adopt the sole subnet + seed their sync scope. Strictly
205	        // adopt-if-unset; a multi-subnet store adopts nothing.
206	        // [impl->REQ-INST-15]
207	        if !spt_store::home::adopt_for_unset(owlery, &subnets, &mut vis).is_empty() {
208	            let _ = vis.save();
209	        }
210	        let vis = vis;
211	        // The node-level resource-blurb seed (D9-4): an endpoint that has not
212	        // authored its own blurb advertises this default (config seeds, the
213	        // agent refines — CONTEXT §resource advertisement).
214	        let node_blurb = crate::config::DaemonConfig::load().resources_blurb;
215	        // The node's subnet-broadcast shell adapters (M5-D8c, CONTEXT
216	        // §Discovery scope): registered kind="shell" adapters that chose
217	        // broadcast=subnet ride every row this node authors — the other-node
218	        // discovery arm. Computed once per scan (the registration set is
219	        // node-level).
220	        // [impl->REQ-EP-5]
221	        let shell_adapters: Vec<String> =
222	            spt_runtime::registry::registered(&perch::adapters_dir())
223	                .iter()
224	                .filter(|(r, m)| {
225	                    r.kind == spt_runtime::manifest::AdapterKind::Shell
226	                        && m.shell.as_ref().and_then(|s| s.broadcast)
227	                            == Some(spt_runtime::manifest::Broadcast::Subnet)
228	                })
229	                .map(|(r, _)| r.name.clone())
230	                .collect();
231	        let mut out = Vec::new();
232	        let mut regs = self.regs.lock().unwrap();
233	        let mut epoch = self.epoch.lock().unwrap();
234
235	        for id in perch::list_self_perch_ids(owlery) {
236	            let perch_path = perch::resolve_perch_path_in(owlery, &id, ParentHint::Infer);
237	            // Advertisement follows the resting-state machine (D9-2,
238	            // REQ-INST-3): a live perch with a resting record advertises its
239	            // machine state (active / dormant / suspended); a recordless
240	            // (pre-D9-2 / interim) or dead perch keeps the legacy
241	            // liveness-derived mapping. Every advertisement stamps a fresh
242	            // epoch below, so a transition reaches peers as an epoch-bumped
243	            // update — the lease orders it (KH 4.8).
244	            // [impl->REQ-INST-3]
245	            let status = if is_perch_alive(&perch_path) {
246	                match crate::resting::read_rest(&perch_path).map(|r| r.state) {
247	                    Some(crate::resting::RestState::Active) | None => Status::Active,
248	                    Some(crate::resting::RestState::Dormant) => Status::Dormant,
249	                    Some(crate::resting::RestState::Suspended) => Status::Suspended,
250	                }
251	            } else {
252	                Status::Dormant
253	            };
254	            // Endpoint-authored blurb wins; the node seed fills the gap
255	            // (both-authored, REQ-INST-14). Rides the same epoch lease as
256	            // every instance update — a blurb edit reaches peers as an
257	            // ordered, epoch-bumped row.
258	            // [impl->REQ-INST-14]
259	            let info = spt_store::info::read_info(&perch_path);
260	            let resources =
261	                info.as_ref().and_then(|i| i.resources.clone()).or_else(|| node_blurb.clone());
262	            // The presence datum (M5-D6a, REQ-PRES-1): the perch's heartbeat
263	            // stamp (`lifecycle::touch_active` — Active seats only; a resting
264	            // seat never stamps) rides the row like the blurb does — same
265	            // epoch lease, same visibility gate, no new channel. Subnet-wide
266	            // most-recently-active is a projection of these rows (D6b).
267	            // [impl->REQ-PRES-1]
268	            let last_active_ms = info.and_then(|i| i.last_active_ms);
269	            for sub in &subnets.subnets {
270	                let Ok(ep) = epoch.next_epoch() else { continue };
271	                let instance = Instance {
272	                    node: self.node_hex.clone(),
273	                    status,
274	                    epoch: ep,
275	                    resources: resources.clone(),
276	                    last_active_ms,
277	                    shell_adapters: shell_adapters.clone(),
278	                };
279	                let reg = regs.entry(sub.name.clone()).or_default();

    // [impl->REQ-INST-7]
    // [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
    pub fn apply_feed(
        &self,
        origin_node: &str,
        updates: &[RegistryUpdate],
        policy: &RegistryGatePolicy,
    ) -> Vec<RegistryApplyVerdict> {
        self.apply_feed_flips(origin_node, updates, policy).0
    }

    /// [`apply_feed`](Self::apply_feed) that ALSO reports **attention
    /// flips** (D9-2, REQ-INST-3): the endpoint ids whose admitted update was
    /// an observed *transition to `Active` on a peer node* — the wire signal
    /// that another instance of the id just became the driven one (its
    /// daemon advertises transitions epoch-bumped), which rests a local
    /// instance of the same id warm (CONTEXT: driving `ling@laptop` makes
    /// `ling@desktop` dormant).
    ///
    /// Flip = `Updated` merge where the stored row's status was NOT `Active`
    /// and the incoming one is. Deliberately **never `Inserted`**: this
    /// registry is in-memory, so a daemon restart re-inserts every peer row
    /// at its *current* status — counting inserts would knock local
    /// instances dormant on every restart; and never the periodic
    /// re-advertisement (`Updated` with prev already `Active` is not a
    /// flip). The lease has already dropped stale/equal epochs before any
    /// of this runs.
    // [impl->REQ-INST-3]
    pub fn apply_feed_flips(
        &self,
        origin_node: &str,
        updates: &[RegistryUpdate],
        policy: &RegistryGatePolicy,
    ) -> (Vec<RegistryApplyVerdict>, Vec<String>) {
        let mut regs = self.regs.lock().unwrap();
        let mut flips: Vec<String> = Vec::new();
        let verdicts = updates
            .iter()
            .map(|u| {
                if !policy.subnets.iter().any(|s| s == &u.subnet) {
                    return RegistryApplyVerdict::NotMember;
                }
                if !policy.trust.is_trusted(&u.subnet, origin_node) {
                    return RegistryApplyVerdict::UntrustedOrigin;
                }
                let reg = regs.entry(u.subnet.clone()).or_default();
                let prev = reg
                    .instances(&u.endpoint_id)
                    .iter()
                    .find(|i| i.node == u.instance.node)
                    .map(|i| i.status);
                // The epoch lease (4.8) decides; an own-row echo from a peer
                // is dropped Stale by construction (our epoch is newer or
                // equal — never authored elsewhere).
                let outcome = reg.merge_instance(&u.endpoint_id, u.instance.clone());
                if outcome == MergeOutcome::Updated
                    && u.instance.status == Status::Active
                    && prev != Some(Status::Active)
                    && u.instance.node != self.node_hex
                    && !flips.contains(&u.endpoint_id)
                {
                    flips.push(u.endpoint_id.clone());
                }
                RegistryApplyVerdict::Merged(outcome)
            })
            .collect();
        drop(regs);
        self.write_snapshots();
        (verdicts, flips)
    }

    /// Scan the owlery and (re-)advertise every self perch into every member
    /// subnet it is visible in, each row stamped with a fresh epoch. Returns
    /// the updates to push to peers (the node's own rows only — peers' rows
    /// reconverge from their own re-advertisement, no transitive gossip in
    /// v1: trust is pairwise per subnet, a relayed third-party row would
    /// carry an origin the receiver can't gate honestly).
    // [impl->REQ-INST-7]
    pub fn advertise_local(&self, owlery: &Path) -> Vec<RegistryUpdate> {
        let subnets = SubnetStore::load();
        let mut vis = VisibilityStore::load();
        // First-join home adoption (D9-5, REQ-INST-15): within one
        // advertisement cadence of the node's first join (a pair lands the
        // subnet record; no pairing-protocol threading needed), unhomed Self
        // perches adopt the sole subnet + seed their sync scope. Strictly
        // adopt-if-unset; a multi-subnet store adopts nothing.
        // [impl->REQ-INST-15]
        if !spt_store::home::adopt_for_unset(owlery, &subnets, &mut vis).is_empty() {
            let _ = vis.save();
        }
        let vis = vis;
        // The node-level resource-blurb seed (D9-4): an endpoint that has not
        // authored its own blurb advertises this default (config seeds, the
        // agent refines — CONTEXT §resource advertisement).
        let node_blurb = crate::config::DaemonConfig::load().resources_blurb;
        // The node's subnet-broadcast shell adapters (M5-D8c, CONTEXT
        // §Discovery scope): registered kind="shell" adapters that chose
        // broadcast=subnet ride every row this node authors — the other-node
        // discovery arm. Computed once per scan (the registration set is
        // node-level).
        // [impl->REQ-EP-5]
        let shell_adapters: Vec<String> =
            spt_runtime::registry::registered(&perch::adapters_dir())
                .iter()
                .filter(|(r, m)| {
                    r.kind == spt_runtime::manifest::AdapterKind::Shell
                        && m.shell.as_ref().and_then(|s| s.broadcast)
                            == Some(spt_runtime::manifest::Broadcast::Subnet)
                })
                .map(|(r, _)| r.name.clone())
                .collect();
        let mut out = Vec::new();
        let mut regs = self.regs.lock().unwrap();
        let mut epoch = self.epoch.lock().unwrap();

        for id in perch::list_self_perch_ids(owlery) {
            let perch_path = perch::resolve_perch_path_in(owlery, &id, ParentHint::Infer);
            // Advertisement follows the resting-state machine (D9-2,
            // REQ-INST-3): a live perch with a resting record advertises its
            // machine state (active / dormant / suspended); a recordless
            // (pre-D9-2 / interim) or dead perch keeps the legacy
            // liveness-derived mapping. Every advertisement stamps a fresh
            // epoch below, so a transition reaches peers as an epoch-bumped
            // update — the lease orders it (KH 4.8).
            // [impl->REQ-INST-3]
            let status = if is_perch_alive(&perch_path) {
                match crate::resting::read_rest(&perch_path).map(|r| r.state) {
                    Some(crate::resting::RestState::Active) | None => Status::Active,
                    Some(crate::resting::RestState::Dormant) => Status::Dormant,
                    Some(crate::resting::RestState::Suspended) => Status::Suspended,
                }
            } else {
                Status::Dormant
            };
            // Endpoint-authored blurb wins; the node seed fills the gap
            // (both-authored, REQ-INST-14). Rides the same epoch lease as
            // every instance update — a blurb edit reaches peers as an
            // ordered, epoch-bumped row.
            // [impl->REQ-INST-14]
            let info = spt_store::info::read_info(&perch_path);
            let resources =
                info.as_ref().and_then(|i| i.resources.clone()).or_else(|| node_blurb.clone());
            // The presence datum (M5-D6a, REQ-PRES-1): the perch's heartbeat
            // stamp (`lifecycle::touch_active` — Active seats only; a resting
            // seat never stamps) rides the row like the blurb does — same
            // epoch lease, same visibility gate, no new channel. Subnet-wide
            // most-recently-active is a projection of these rows (D6b).
            // [impl->REQ-PRES-1]
            let last_active_ms = info.and_then(|i| i.last_active_ms);
            for sub in &subnets.subnets {
                let Ok(ep) = epoch.next_epoch() else { continue };
                let instance = Instance {
                    node: self.node_hex.clone(),
                    status,
                    epoch: ep,
                    resources: resources.clone(),
                    last_active_ms,
                    shell_adapters: shell_adapters.clone(),
                };
                let reg = regs.entry(sub.name.clone()).or_default();


425	    /// Several live nodes hold the id in one subnet — qualify with `id@node`. (Epochs are
426	    /// per-node monotonic and NOT comparable across nodes, so we never silently pick a
427	    /// "most-recently-active" winner here — that would be a guess.) Lists the nodes.
428	    AcrossNodes(Vec<String>),
429	    /// The id resolves in several visible subnets — qualify with `subnet:id`. Lists the
430	    /// subnets.
431	    AcrossSubnets(Vec<String>),
432	}
433
434	impl SubnetRegistry {
435	    /// Resolve an [`Address`] within THIS single subnet (the `subnet:` qualifier, if any,
436	    /// is ignored here — see [`resolve_across`] for the multi-subnet form). Policy:
437	    /// `@node` exact → an instance on `local_node` (local wins) → the sole remaining live
438	    /// instance → refuse if several live nodes remain (force `@node`). `Offline`
439	    /// instances are never routed to.
440	    // [impl->REQ-INST-10]
441	    pub fn resolve(&self, address: &Address, local_node: &str) -> Resolution {
442	        self.resolve_visible(address, local_node, |_| false)
443	    }
444
445	    /// [`resolve`](Self::resolve) honoring **visibility** (D3e, ADR-0006 §6): when
446	    /// `excluded(id)` says the endpoint is hidden in this subnet, it is **not
447	    /// routable** — [`NotFound`](Resolution::NotFound) even for an explicit `@node`
448	    /// pin (excluded is a real boundary, not a listing cosmetic). Callers build the
449	    /// closure from [`spt_store::visibility::VisibilityStore::hidden`] with this
450	    /// subnet's `hide_new_endpoints` flag.
451	    // [impl->REQ-INST-12]
452	    pub fn resolve_visible(
453	        &self,
454	        address: &Address,
455	        local_node: &str,
456	        excluded: impl Fn(&str) -> bool,
457	    ) -> Resolution {
458	        if excluded(&address.id) {
459	            return Resolution::NotFound;
460	        }
461	        let live: Vec<&Instance> = self
462	            .instances(&address.id)
463	            .iter()
464	            .filter(|i| i.status.routable())
465	            .collect();
466
467	        // Explicit `@node` pins one instance (must be live).
468	        if let Some(node) = &address.node {
469	            return match live.iter().find(|i| &i.node == node) {
470	                Some(i) => Resolution::Resolved { subnet: None, instance: (*i).clone() },
471	                None => Resolution::NotFound,
472	            };
473	        }
474
475	        // Local instance wins outright (no network hop, unambiguous).
476	        if let Some(i) = live.iter().find(|i| i.node == local_node) {
477	            return Resolution::Resolved { subnet: None, instance: (*i).clone() };
478	        }
479
480	        match live.as_slice() {
481	            [] => Resolution::NotFound,
482	            [only] => Resolution::Resolved { subnet: None, instance: (*only).clone() },
483	            many => Resolution::Ambiguous(Ambiguity::AcrossNodes(
484	                many.iter().map(|i| i.node.clone()).collect(),
485	            )),
486	        }
487	    }
488	}
489
490	/// Resolve an [`Address`] across the node's visible per-subnet registries. A `subnet:`
491	/// qualifier routes straight to that subnet. Otherwise every subnet is tried and: zero
492	/// hits → [`NotFound`](Resolution::NotFound); one subnet hits → that resolution; several
493	/// subnets hit → refuse with [`AcrossSubnets`](Ambiguity::AcrossSubnets) (force
494	/// `subnet:`, ADR-0006 §2 — distinct endpoints sharing a name must be qualified).
495	// [impl->REQ-INST-10]
496	pub fn resolve_across(
497	    subnets: &BTreeMap<String, SubnetRegistry>,
498	    address: &Address,
499	    local_node: &str,
500	) -> Resolution {
501	    resolve_across_visible(subnets, address, local_node, |_, _| false)
502	}
503
504	/// [`resolve_across`] honoring **visibility** (D3e, ADR-0006 §6): `excluded(subnet,
505	/// id)` gates each per-subnet resolution. An endpoint hidden in one subnet but visible
506	/// in another resolves cleanly in the visible one — exclusion *removes* a subnet from
507	/// the ambiguity set rather than poisoning the whole resolution. Hidden everywhere ⇒
508	/// [`NotFound`](Resolution::NotFound).
509	// [impl->REQ-INST-12]
510	pub fn resolve_across_visible(
511	    subnets: &BTreeMap<String, SubnetRegistry>,
512	    address: &Address,
513	    local_node: &str,
514	    excluded: impl Fn(&str, &str) -> bool,
515	) -> Resolution {
516	    // Subnet-qualified: resolve only there, tagging the subnet onto a hit.
517	    if let Some(subnet) = &address.subnet {
518	        return match subnets.get(subnet) {
519	            Some(reg) => match reg.resolve_visible(address, local_node, |id| excluded(subnet, id))
520	            {
521	                Resolution::Resolved { instance, .. } => {
522	                    Resolution::Resolved { subnet: Some(subnet.clone()), instance }
523	                }
524	                other => other,
525	            },
526	            None => Resolution::NotFound,
527	        };
528	    }
529
530	    // Unqualified: gather hits across all subnets. A within-subnet ambiguity surfaces
531	    // immediately (it needs `@node`, not `subnet:`).
532	    let mut hits: Vec<(String, Resolution)> = Vec::new();
533	    for (name, reg) in subnets {
534	        match reg.resolve_visible(address, local_node, |id| excluded(name, id)) {
535	            Resolution::Resolved { instance, .. } => {
536	                hits.push((name.clone(), Resolution::Resolved { subnet: Some(name.clone()), instance }));
537	            }
538	            ambiguous @ Resolution::Ambiguous(_) => return ambiguous,
539	            Resolution::NotFound => {}
540	        }
541	    }
542
543	    match hits.len() {
544	        0 => Resolution::NotFound,
545	        1 => hits.pop().expect("len==1").1,
546	        _ => Resolution::Ambiguous(Ambiguity::AcrossSubnets(
547	            hits.into_iter().map(|(name, _)| name).collect(),
548	        )),
549	    }
550	}
551
552	#[cfg(test)]
553	mod tests {
554	    use super::*;

    /// Several live nodes hold the id in one subnet — qualify with `id@node`. (Epochs are
    /// per-node monotonic and NOT comparable across nodes, so we never silently pick a
    /// "most-recently-active" winner here — that would be a guess.) Lists the nodes.
    AcrossNodes(Vec<String>),
    /// The id resolves in several visible subnets — qualify with `subnet:id`. Lists the
    /// subnets.
    AcrossSubnets(Vec<String>),
}

impl SubnetRegistry {
    /// Resolve an [`Address`] within THIS single subnet (the `subnet:` qualifier, if any,
    /// is ignored here — see [`resolve_across`] for the multi-subnet form). Policy:
    /// `@node` exact → an instance on `local_node` (local wins) → the sole remaining live
    /// instance → refuse if several live nodes remain (force `@node`). `Offline`
    /// instances are never routed to.
    // [impl->REQ-INST-10]
    pub fn resolve(&self, address: &Address, local_node: &str) -> Resolution {
        self.resolve_visible(address, local_node, |_| false)
    }

    /// [`resolve`](Self::resolve) honoring **visibility** (D3e, ADR-0006 §6): when
    /// `excluded(id)` says the endpoint is hidden in this subnet, it is **not
    /// routable** — [`NotFound`](Resolution::NotFound) even for an explicit `@node`
    /// pin (excluded is a real boundary, not a listing cosmetic). Callers build the
    /// closure from [`spt_store::visibility::VisibilityStore::hidden`] with this
    /// subnet's `hide_new_endpoints` flag.
    // [impl->REQ-INST-12]
    pub fn resolve_visible(
        &self,
        address: &Address,
        local_node: &str,
        excluded: impl Fn(&str) -> bool,
    ) -> Resolution {
        if excluded(&address.id) {
            return Resolution::NotFound;
        }
        let live: Vec<&Instance> = self
            .instances(&address.id)
            .iter()
            .filter(|i| i.status.routable())
            .collect();

        // Explicit `@node` pins one instance (must be live).
        if let Some(node) = &address.node {
            return match live.iter().find(|i| &i.node == node) {
                Some(i) => Resolution::Resolved { subnet: None, instance: (*i).clone() },
                None => Resolution::NotFound,
            };
        }

        // Local instance wins outright (no network hop, unambiguous).
        if let Some(i) = live.iter().find(|i| i.node == local_node) {
            return Resolution::Resolved { subnet: None, instance: (*i).clone() };
        }

        match live.as_slice() {
            [] => Resolution::NotFound,
            [only] => Resolution::Resolved { subnet: None, instance: (*only).clone() },
            many => Resolution::Ambiguous(Ambiguity::AcrossNodes(
                many.iter().map(|i| i.node.clone()).collect(),
            )),
        }
    }
}

/// Resolve an [`Address`] across the node's visible per-subnet registries. A `subnet:`
/// qualifier routes straight to that subnet. Otherwise every subnet is tried and: zero
/// hits → [`NotFound`](Resolution::NotFound); one subnet hits → that resolution; several
/// subnets hit → refuse with [`AcrossSubnets`](Ambiguity::AcrossSubnets) (force
/// `subnet:`, ADR-0006 §2 — distinct endpoints sharing a name must be qualified).
// [impl->REQ-INST-10]
pub fn resolve_across(
    subnets: &BTreeMap<String, SubnetRegistry>,
    address: &Address,
    local_node: &str,
) -> Resolution {
    resolve_across_visible(subnets, address, local_node, |_, _| false)
}

/// [`resolve_across`] honoring **visibility** (D3e, ADR-0006 §6): `excluded(subnet,
/// id)` gates each per-subnet resolution. An endpoint hidden in one subnet but visible
/// in another resolves cleanly in the visible one — exclusion *removes* a subnet from
/// the ambiguity set rather than poisoning the whole resolution. Hidden everywhere ⇒
/// [`NotFound`](Resolution::NotFound).
// [impl->REQ-INST-12]
pub fn resolve_across_visible(
    subnets: &BTreeMap<String, SubnetRegistry>,
    address: &Address,
    local_node: &str,
    excluded: impl Fn(&str, &str) -> bool,
) -> Resolution {
    // Subnet-qualified: resolve only there, tagging the subnet onto a hit.
    if let Some(subnet) = &address.subnet {
        return match subnets.get(subnet) {
            Some(reg) => match reg.resolve_visible(address, local_node, |id| excluded(subnet, id))
            {
                Resolution::Resolved { instance, .. } => {
                    Resolution::Resolved { subnet: Some(subnet.clone()), instance }
                }
                other => other,
            },
            None => Resolution::NotFound,
        };
    }

    // Unqualified: gather hits across all subnets. A within-subnet ambiguity surfaces
    // immediately (it needs `@node`, not `subnet:`).
    let mut hits: Vec<(String, Resolution)> = Vec::new();
    for (name, reg) in subnets {
        match reg.resolve_visible(address, local_node, |id| excluded(name, id)) {
            Resolution::Resolved { instance, .. } => {
                hits.push((name.clone(), Resolution::Resolved { subnet: Some(name.clone()), instance }));
            }
            ambiguous @ Resolution::Ambiguous(_) => return ambiguous,
            Resolution::NotFound => {}
        }
    }

    match hits.len() {
        0 => Resolution::NotFound,
        1 => hits.pop().expect("len==1").1,
        _ => Resolution::Ambiguous(Ambiguity::AcrossSubnets(
            hits.into_iter().map(|(name, _)| name).collect(),
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::*;


D2 recon done. Sub-plan:


TaskCreate

D2a: node_label additive gossip field + hostname detection at daemon startup

Instance.node_label (serde-default, skip-if-none; old rows parse clean). RegistryHost computes OS hostname at construction (daemon startup re-check), stamps every advertised row. Activate REQ-SUBNET-3 [impl,unit].


Task #6 created successfully: D2a: node_label additive gossip field + hostname detection at daemon startup


D2b: @node qualifier accepts label or key-prefix, refuse-on-ambiguity

resolve_visible @node arm: match full hex, hex prefix, or label (case-insensitive); >1 distinct node matched → Ambiguous with candidate key prefixes, never guess. Update wansend render.


Task #7 created successfully: D2b: @node qualifier accepts label or key-prefix, refuse-on-ambiguity


D2c: subnet status --nodes per-node rows + hybrid liveness + label render

Per-node rows: `LABEL (keyprefix…)`, online/offline, [online endpoints/total]. Liveness = gossip-recency for table, probe only stale rows. Regen reference.


Task #8 created successfully: D2c: subnet status --nodes per-node rows + hybrid liveness + label render


Bash

grep -A8 '^\[dependencies\]' crates/spt-daemon/Cargo.toml | head -20; grep -rn "libc\|windows-sys" crates/spt-daemon/Cargo.toml crates/spt-net/Cargo.toml

Check libc/windows-sys deps in daemon and net crates


[dependencies]
# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
# crate's position in the workspace layering. Tasks B1–B8 consume them.
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-term = { path = "../spt-term" }


Read spt-daemon Cargo.toml


[package]
name = "spt-daemon"
version.workspace = true
edition = "2021"
publish = false
description = "spt-core broker/brain daemon (M3b): the per-machine supervisor process. A stable broker kernel holds the un-transferable resources (PTY masters from spt-term, harness children, sockets) while a restartable brain holds all logic; a versioned local IPC lets a newer brain talk to an older broker so a self-update swaps the brain with zero endpoint interruption (ADR-0004). Internal supervisor (NOT public SDK, R-ARCH-2) — the `spt` binary is a thin CLI over it. B0 lands the crate + the versioned local-IPC transport."

[dependencies]
# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
# crate's position in the workspace layering. Tasks B1–B8 consume them.
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-term = { path = "../spt-term" }
spt-live = { path = "../spt-live" }
# D4 (ADR-0004 §B): the broker owns the live iroh endpoint + QUIC connections so
# they survive a brain restart. spt-net provides the *mechanism* (NetEndpoint);
# the broker hosts the *process* (nethost::NetHost). Default features keep the
# `net` transport on — the daemon is the reference consumer.
spt-net = { path = "../spt-net" }
# The broker is otherwise sync/thread-based; iroh requires tokio, so the broker
# hosts a dedicated runtime on named worker threads (QUIC objects never leave it).
tokio = { version = "1", features = ["rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
# a base64 string in the payload rather than forking the codec for binary.
base64 = "0.22"
# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
# behind one API — the OS-split substrate the DaemonTransport trait wraps,
# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
interprocess = "2"
# Self-update release signing (C1, REQ-UPD-2): pure-Rust Ed25519 — no C/asm
# build deps, clean cross-compile on win+linux (clean-room posture, ADR-0004 §D).
# `rand_core` lets a release-tooling caller mint a keypair; tests use the
# deterministic `from_bytes` ctor (no RNG).
ed25519-dalek = "2"
# SHA-256 binds the signed release metadata to the actual binary bytes
# (artifact digest) so the signature covers the binary, not just the manifest.
sha2 = "0.10"
# Per-frame link auth on the same-node shell channels (CONTEXT local-link
# authentication): HMAC-SHA256 over the token-derived link key (M5-D3b).
hmac = "0.12"

[dev-dependencies]
tempfile = "3"
# Integration tests are separate crates and do not inherit the lib's deps, so the
# B8 digest int-test re-declares the two it touches directly: the manifest seam
# (PtyDigest) and the perch-path/history resolution it asserts persistence against.
spt-runtime = { path = "../spt-runtime" }
spt-store = { path = "../spt-store" }
# The D4a net-broker int-test constructs identities + relay policies directly.
spt-proto = { path = "../spt-proto" }
spt-net = { path = "../spt-net" }
# The D5a wanmsg int-test asserts spool formatting through the spt-msg surface.
spt-msg = { path = "../spt-msg" }


grep -n "libc\|windows-sys\|hostname" crates/spt-store/Cargo.toml crates/spt-msg/Cargo.toml

Check libc usage in spt-store/spt-msg


crates/spt-store/Cargo.toml:16:libc = "0.2"
crates/spt-msg/Cargo.toml:13:libc = "0.2"
crates/spt-msg/Cargo.toml:16:windows-sys = { version = "0.59", features = ["Win32_Networking_WinSock", "Win32_Foundation"] }


ls crates/spt-store/src/ && grep -rn "new_at\|RegistryHost::new" crates --include="*.rs" | grep -v "fn new_at" | head -20

List spt-store modules + RegistryHost constructor callers


access.rs
atomic.rs
branchstore.rs
contextmark.rs
contextstore.rs
db.rs
epoch.rs
gitrun.rs
grants.rs
history.rs
home.rs
info.rs
lib.rs
liveness.rs
nodeid.rs
notif.rs
perch.rs
proc.rs
project.rs
registry.rs
rename.rs
seed.rs
shellinfo.rs
spool.rs
subnet.rs
syncmerge.rs
trust.rs
visibility.rs
xfer.rs
crates/spt/src/wansend.rs:466:            let registry = Arc::new(RegistryHost::new_at(
crates/spt/src/wansend.rs:564:            let registry = Arc::new(RegistryHost::new_at(
crates/spt-daemon/src/daemon.rs:115:                    let registry = Arc::new(crate::registryhost::RegistryHost::new(&hex));
crates/spt-daemon/src/registryhost.rs:95:        RegistryHost::new_at(
crates/spt-daemon/src/registryhost.rs:386:        RegistryHost::new_at(
crates/spt-daemon/tests/dispatch.rs:106:    let registry = Arc::new(spt_daemon::registryhost::RegistryHost::new("b-dispatch"));
crates/spt-daemon/tests/peerloop.rs:162:    let b_registry = Arc::new(RegistryHost::new_at(
crates/spt-daemon/tests/peerloop.rs:236:    let a_registry = Arc::new(RegistryHost::new_at(
crates/spt-daemon/tests/twohost.rs:286:    // `RegistryHost::new` uses): the presence fire path + feed-apply surface
crates/spt-daemon/tests/twohost.rs:289:    let registry = Arc::new(RegistryHost::new_at(


grep -B3 'libc = ' crates/spt-store/Cargo.toml && grep -rn "libc::" crates/spt-store/src | head -5

Check libc cfg in spt-store


spt-proto = { path = "../spt-proto" }

[target.'cfg(unix)'.dependencies]
libc = "0.2"
crates/spt-store/src/proc.rs:28:        if unsafe { libc::kill(pid as i32, 0) != 0 } {
crates/spt-store/src/proc.rs:71:            libc::waitpid(pid as i32, std::ptr::null_mut(), libc::WNOHANG);
crates/spt-store/src/proc.rs:90:        Some(unsafe { libc::getppid() } as u32)


Task 6 start. Add the field, then chase construction sites:


Updated task #6 status


Edit

    /// Discovery only: spawning one rides the deferred instantiate-anywhere
    /// gate. Additive serde-default: a pre-D8 row parses clean.
    // [impl->REQ-EP-5]
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shell_adapters: Vec<String>,
}

    /// Discovery only: spawning one rides the deferred instantiate-anywhere
    /// gate. Additive serde-default: a pre-D8 row parses clean.
    // [impl->REQ-EP-5]
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shell_adapters: Vec<String>,
    /// The authoring node's human-readable label (M7 D2, REQ-SUBNET-3 —
    /// CONTEXT §node label): defaults to the node's OS hostname, re-checked
    /// at daemon startup (a hostname change updates the advertised label).
    /// Display + addressing sugar only — the pubkey stays the identity; views
    /// render `LABEL (keyprefix…)`. **Not a separate channel** — node-level
    /// data riding the instance row like [`resources`](Self::resources) does
    /// (the rows are the distribution; same epoch lease, same visibility
    /// gate). Additive serde-default: a pre-M7 row parses clean.
    // [impl->REQ-SUBNET-3]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub node_label: 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)

    /// Discovery only: spawning one rides the deferred instantiate-anywhere
    /// gate. Additive serde-default: a pre-D8 row parses clean.
    // [impl->REQ-EP-5]
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shell_adapters: Vec<String>,
}

    /// Discovery only: spawning one rides the deferred instantiate-anywhere
    /// gate. Additive serde-default: a pre-D8 row parses clean.
    // [impl->REQ-EP-5]
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shell_adapters: Vec<String>,
    /// The authoring node's human-readable label (M7 D2, REQ-SUBNET-3 —
    /// CONTEXT §node label): defaults to the node's OS hostname, re-checked
    /// at daemon startup (a hostname change updates the advertised label).
    /// Display + addressing sugar only — the pubkey stays the identity; views
    /// render `LABEL (keyprefix…)`. **Not a separate channel** — node-level
    /// data riding the instance row like [`resources`](Self::resources) does
    /// (the rows are the distribution; same epoch lease, same visibility
    /// gate). Additive serde-default: a pre-M7 row parses clean.
    // [impl->REQ-SUBNET-3]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub node_label: Option<String>,
}

+    /// The authoring node's human-readable label (M7 D2, REQ-SUBNET-3 —

+    /// CONTEXT §node label): defaults to the node's OS hostname, re-checked

+    /// at daemon startup (a hostname change updates the advertised label).

+    /// Display + addressing sugar only — the pubkey stays the identity; views

+    /// render `LABEL (keyprefix…)`. **Not a separate channel** — node-level

+    /// data riding the instance row like [`resources`](Self::resources) does

+    /// (the rows are the distribution; same epoch lease, same visibility

+    /// gate). Additive serde-default: a pre-M7 row parses clean.

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

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


crates\spt-daemon\tests\dispatch.rs:557:        spt_net::net::registry::Instance {
crates\spt-daemon\tests\peerloop.rs:252:                instance: Instance { node: b_hex.clone(), status: Status::Active, epoch: 1, resources: None, last_active_ms: None, shell_adapters: Vec::new() },
crates\spt-daemon\tests\replicate.rs:110:    let active = Instance { node: a_node.clone(), status: Status::Active, epoch: e1, resources: None, last_active_ms: None, shell_adapters: Vec::new() };
crates\spt-daemon\tests\replicate.rs:133:        instance: Instance { node: a_node.clone(), status: Status::Offline, epoch: e2, resources: None, last_active_ms: None, shell_adapters: Vec::new() },
crates\spt-daemon\tests\sync.rs:355:        Instance { node: "node-b".into(), status: Status::Active, epoch: 2, resources: None, last_active_ms: None, shell_adapters: Vec::new() },
crates\spt-daemon\tests\sync.rs:356:        Instance { node: "node-a".into(), status: Status::Dormant, epoch: 1, resources: None, last_active_ms: None, shell_adapters: Vec::new() },
crates\spt-net\src\net\registry.rs:72:pub struct Instance {
crates\spt-net\src\net\registry.rs:567:    fn inst(node: &str, status: Status, epoch: u64) -> Instance {
crates\spt-net\src\net\registry.rs:568:        Instance { node: node.to_string(), status, epoch, resources: None, last_active_ms: None, shell_adapters: Vec::new() }
crates\spt-net\src\net\registry.rs:666:        let with = |blurb: Option<&str>, epoch: u64| Instance {
crates\spt-net\src\net\registry.rs:710:        let with = |ts: Option<u64>, epoch: u64| Instance {
crates\spt-net\src\net\registry.rs:747:            Instance {
crates\spt-net\src\net\replicate.rs:86:            instance: Instance { node: node.into(), status, epoch, resources: None, last_active_ms: None, shell_adapters: Vec::new() },
crates\spt-net\src\net\wanmsg.rs:102:            instance: Instance { node: node.into(), status: Status::Active, epoch: 1, resources: None, last_active_ms: None, shell_adapters: Vec::new() },
crates\spt-daemon\src\notif.rs:521:                spt_net::net::registry::Instance {
crates\spt-daemon\src\presence.rs:200:    fn row(node: &str, status: Status, ts: Option<u64>) -> Instance {
crates\spt-daemon\src\presence.rs:201:        Instance {
crates\spt-daemon\src\reconcile.rs:49:    fn inst(node: &str, status: Status) -> Instance {
crates\spt-daemon\src\reconcile.rs:50:        Instance { node: node.to_string(), status, epoch: 1, resources: None, last_active_ms: None, shell_adapters: Vec::new() }
crates\spt-daemon\src\registryhost.rs:271:                let instance = Instance {
crates\spt-daemon\src\registryhost.rs:375:            instance: Instance { node: node.to_string(), status: Status::Active, epoch, resources: None, last_active_ms: None, shell_adapters: Vec::new() },
crates\spt-daemon\src\registryhost.rs:525:                instance: Instance { node: node.to_string(), status, epoch, resources: None, last_active_ms: None, shell_adapters: Vec::new() },
crates\spt\src\wansend.rs:434:            Instance { node: node.to_string(), status: Status::Active, epoch: 1, resources: None, last_active_ms: None, shell_adapters: Vec::new() },
crates\spt\src\cli.rs:4066:            spt_net::net::registry::Instance {
crates\spt\src\cli.rs:4197:                Instance {
crates\spt-daemon\src\shelldisc.rs:310:        let inst = |node: &str, status: Status, shells: &[&str]| Instance {


Bulk-update construction sites:


Bash

grep -rl 'shell_adapters: Vec::new() }' crates --include='*.rs' | xargs sed -i 's/shell_adapters: Vec::new() }/shell_adapters: Vec::new(), node_label: None }/g' && grep -rn 'shell_adapters: Vec::new() }' crates --include='*.rs' | wc -l

Add node_label: None to single-line Instance literals


1	//! Per-subnet eventually-consistent endpoint registry (D3a/b/d/e, ADR-0006).
2	//!
3	//! Maps `endpoint_id → [instance...]` within one subnet, where an [`Instance`] is the
4	//! same logical endpoint as seen running on a particular node, with a liveness
5	//! [`Status`] and the author node's monotonic [`epoch`](Instance::epoch). The same
6	//! `endpoint_id` can hold several instances (the node-global mind advertised from more
7	//! than one machine); picking the right one is the resolution policy's job (D3c) — this
8	//! module owns the **data model + the merge lease**, not resolution.
9	//!
10	//! ## The epoch lease (#8)
11	//!
12	//! Registry updates arrive out of order across a partition or under clock skew. A
13	//! lagging node could re-announce a stale `Active` after the endpoint has actually gone
14	//! `Offline`. [`merge_instance`] defends against this with a **version-vector** rule
15	//! keyed on `(endpoint_id, node)`: an incoming update wins iff its epoch is **strictly
16	//! greater** than the stored one for that node. An equal or lower epoch is dropped as
17	//! stale — so a newer `Offline` (higher epoch) can never be clobbered by a lagging
18	//! `Active` (lower epoch). The epoch is the per-node monotonic counter
19	//! ([`spt_store::epoch`]), NEVER wall-clock. Cross-node *replication* of these updates
20	//! rides the transport (D4); the merge seam is identical for local and wire-delivered
21	//! updates.
22	//!
23	//! ## Join-time bare-id collision check (D3d)
24	//!
25	//! Bare-id uniqueness holds *within* a subnet (ADR-0006 §1): advertising a joining
26	//! endpoint whose id another node already holds in the target subnet refuses via
27	//! [`join_endpoint`](SubnetRegistry::join_endpoint) / [`JoinCollision`] — the clash is
28	//! surfaced and resolved (rename, D3f) before advertisement. Per-subnet: the same bare
29	//! name as *distinct* endpoints in different subnets is legal; the resolver forces
30	//! qualification (D3c).
31	//!
32	//! ## Visibility enforcement (D3e)
33	//!
34	//! Excluded = **not advertised AND not routable** (ADR-0006 §6). The policy lives in
35	//! [`spt_store::visibility::VisibilityStore`]; this module enforces it at both
36	//! registry boundaries: [`advertise_if_visible`] keeps hidden endpoints out of the
37	//! registry entirely, and [`resolve_visible`](SubnetRegistry::resolve_visible) /
38	//! [`resolve_across_visible`] refuse to route to them (even via an explicit `@node`
39	//! pin). Exclusion removes a subnet from the cross-subnet ambiguity set — hidden in
40	//! one subnet, visible in another resolves cleanly in the visible one.
41	// [impl->REQ-INST-7]
42
43	use std::collections::BTreeMap;
44
45	use serde::{Deserialize, Serialize};
46	use spt_proto::addr::Address;
47	use spt_store::visibility::VisibilityStore;
48
49	/// An endpoint instance's liveness within a subnet. Ordering carries no precedence —
50	/// precedence is the epoch's job; status is just the advertised state.
51	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52	pub enum Status {
53	    /// Live and currently reachable.
54	    Active,
55	    /// Known but idle (e.g. a paused live agent) — addressable, not currently serving.
56	    Dormant,
57	    /// Resting **cold** (D9-2, REQ-INST-3): the harness session is closed,
58	    /// resumed-on-wake. Still addressable (`spt wake ling@desktop` must route) —
59	    /// only `Offline` is unroutable. Additive variant on a pre-release fleet;
60	    /// the NDJSON decode posture (unknown-kind skip) is the registry record's
61	    /// forward-compat story.
62	    // [impl->REQ-INST-3]
63	    Suspended,
64	    /// Believed down. Retained (not dropped) so a newer-epoch revival or a stale-Active
65	    /// suppression can be reasoned about; the resolver skips it.
66	    Offline,
67	}
68
69	/// One endpoint as seen on one node: the node it runs on, its advertised [`Status`], and
70	/// the author node's monotonic [`epoch`](Self::epoch) (the merge-precedence key).
71	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72	pub struct Instance {
73	    /// The node this instance runs on (node-qualifier in `id@node` addressing).
74	    pub node: String,
75	    /// Advertised liveness.
76	    pub status: Status,
77	    /// The author node's per-node monotonic epoch at the time of this update. The sole
78	    /// precedence key in [`merge_instance`] — strictly-greater wins.
79	    pub epoch: u64,
80	    /// The resource-advertisement blurb (D9-4, REQ-INST-14 — CONTEXT §resource
81	    /// advertisement): a free-text "yellow-pages" line describing what this
82	    /// endpoint can serve. **Not a separate registry** — a field on the
83	    /// instance row, riding the existing epoch lease + `RegistryUpdate`
84	    /// replication (a blurb update is ordered like any instance update);
85	    /// the "subnet resource registry" is a [projection](resource_projection)
86	    /// of these rows. Additive serde-default: absent = no blurb, and a
87	    /// pre-D9-4 record parses clean.
88	    #[serde(default, skip_serializing_if = "Option::is_none")]
89	    pub resources: Option<String>,
90	    /// The presence datum (M5-D6a, REQ-PRES-1 — CONTEXT §presence): this
91	    /// instance's `last_active_ms` recency stamp as of the authoring node's
92	    /// advertisement scan. **Not a separate gossip channel** — a field on the
93	    /// instance row riding the existing epoch lease + `RegistryUpdate`
94	    /// replication, exactly like [`resources`](Self::resources); subnet-wide
95	    /// `(last_active_node, last_active_endpoint, ts)` is a projection of
96	    /// these rows. Wall-clock ms, cross-compared ONLY as a routing heuristic
97	    /// (most-recently-active resolution) — the epoch stays the sole merge-
98	    /// precedence key; clock skew can cost a suboptimal surface target, never
99	    /// a wrong merge. Additive serde-default: a pre-D6 row parses clean.
100	    // [impl->REQ-PRES-1]
101	    #[serde(default, skip_serializing_if = "Option::is_none")]
102	    pub last_active_ms: Option<u64>,
103	    /// The authoring node's subnet-broadcast shell adapters (M5-D8c — CONTEXT
104	    /// §Shell model "Discovery scope": shell adapters on *other* subnet nodes
105	    /// with `broadcast = subnet` surface to agents). **Not a shell-instance
106	    /// registry** (shell perches stay private to the owner link, CONTEXT
107	    /// "Not in the subnet registry") — the names of `kind="shell"` adapters
108	    /// REGISTERED on the authoring node that chose subnet-wide broadcast,
109	    /// riding the instance row like [`resources`](Self::resources) does
110	    /// (same epoch lease, same visibility gate, no new channel; node-level
111	    /// data carried per-row because the rows are the distribution).
112	    /// Discovery only: spawning one rides the deferred instantiate-anywhere
113	    /// gate. Additive serde-default: a pre-D8 row parses clean.
114	    // [impl->REQ-EP-5]
115	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116	    pub shell_adapters: Vec<String>,
117	    /// The authoring node's human-readable label (M7 D2, REQ-SUBNET-3 —
118	    /// CONTEXT §node label): defaults to the node's OS hostname, re-checked
119	    /// at daemon startup (a hostname change updates the advertised label).
120	    /// Display + addressing sugar only — the pubkey stays the identity; views
121	    /// render `LABEL (keyprefix…)`. **Not a separate channel** — node-level
122	    /// data riding the instance row like [`resources`](Self::resources) does
123	    /// (the rows are the distribution; same epoch lease, same visibility
124	    /// gate). Additive serde-default: a pre-M7 row parses clean.
125	    // [impl->REQ-SUBNET-3]
126	    #[serde(default, skip_serializing_if = "Option::is_none")]
127	    pub node_label: Option<String>,
128	}
129
130	/// The per-subnet registry: `endpoint_id → [instance...]`. `BTreeMap`/`Vec` for stable,
131	/// deterministic serde ordering (no hash-seed nondeterminism across nodes).
132	#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133	pub struct SubnetRegistry {
134	    #[serde(default)]

... [946 lines truncated] ...


cargo check --workspace --all-targets 2>&1 | grep -E "error|missing" | head -30

Find remaining Instance literal sites via compile errors


error[E0063]: missing field `node_label` in initializer of `net::registry::Instance`
    |                                                      ^^^^^^^^ missing `node_label`
error[E0063]: missing field `node_label` in initializer of `net::registry::Instance`
    |                                                  ^^^^^^^^ missing `node_label`
error[E0063]: missing field `node_label` in initializer of `net::registry::Instance`
    |             ^^^^^^^^ missing `node_label`
For more information about this error, try `rustc --explain E0063`.
error: could not compile `spt-net` (lib test) due to 3 previous errors
error[E0063]: missing field `node_label` in initializer of `Instance`
    |                                ^^^^^^^^ missing `node_label`
error[E0063]: missing field `node_label` in initializer of `spt_net::net::registry::Instance`
    |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `node_label`
error[E0063]: missing field `node_label` in initializer of `spt_net::net::registry::Instance`
    |         ^^^^^^^^ missing `node_label`
error[E0063]: missing field `node_label` in initializer of `spt_net::net::registry::Instance`
    |                                ^^^^^^^^ missing `node_label`
error[E0063]: missing field `node_label` in initializer of `spt_net::net::registry::Instance`
    |                                                                  ^^^^^^^^ missing `node_label`
error: could not compile `spt-daemon` (lib) due to 1 previous error
error: could not compile `spt-daemon` (lib test) due to 4 previous errors


cargo check --workspace --all-targets 2>&1 | grep -A1 "E0063" | grep "\-\->" | sort -u

List exact locations of missing-field errors


   --> crates\spt-daemon\src\notif.rs:521:17
   --> crates\spt-daemon\src\presence.rs:201:9
   --> crates\spt-daemon\src\registryhost.rs:271:32
   --> crates\spt-daemon\src\shelldisc.rs:310:66
   --> crates\spt-net\src\net\registry.rs:666:54
   --> crates\spt-net\src\net\registry.rs:710:50
   --> crates\spt-net\src\net\registry.rs:747:13


660	    // a newer-epoch row carries the updated blurb; a lagging blurb edit is
661	    // dropped stale; and the wire shape roundtrips with the blurb present
662	    // and parses a pre-D9-4 row (no field) clean.
663	    #[test]
664	    fn resource_blurb_rides_the_lease_and_serde() {
665	        let mut reg = SubnetRegistry::new();
666	        let with = |blurb: Option<&str>, epoch: u64| Instance {
667	            node: "n1".to_string(),
668	            status: Status::Active,
669	            epoch,
670	            resources: blurb.map(str::to_string),
671	            last_active_ms: None,
672	            shell_adapters: Vec::new(),
673	        };
674	        reg.merge_instance("ling", with(Some("rust builds"), 1));
675	        assert_eq!(
676	            reg.merge_instance("ling", with(Some("rust builds; code review"), 2)),
677	            MergeOutcome::Updated,
678	            "a blurb edit is an ordinary epoch-bumped update"
679	        );
680	        assert_eq!(
681	            reg.instances("ling")[0].resources.as_deref(),
682	            Some("rust builds; code review")
683	        );
684	        // A lagging edit drops under the lease — the newer blurb survives.
685	        assert_eq!(reg.merge_instance("ling", with(Some("stale"), 1)), MergeOutcome::Stale);
686	        assert_eq!(
687	            reg.instances("ling")[0].resources.as_deref(),
688	            Some("rust builds; code review")
689	        );
690
691	        // Serde: roundtrips with the blurb; a pre-D9-4 row parses clean.
692	        let json = serde_json::to_string(&reg).unwrap();
693	        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
694	        assert_eq!(reg, back);
695	        let old: Instance = serde_json::from_str(
696	            r#"{"node":"n1","status":"Active","epoch":1}"#,
697	        )
698	        .unwrap();
699	        assert_eq!(old.resources, None, "absent field parses as no blurb");
700	    }
701
702	    // [unit->REQ-PRES-1] the presence datum is a FIELD under the lease, not a
703	    // feed: a newer-epoch row carries the fresher stamp; a lagging row drops
704	    // stale (a partition can never roll recency backward through the merge);
705	    // the wire shape roundtrips with the datum and a pre-D6 row (no field)
706	    // parses clean — the additive-field discipline the resources blurb proved.
707	    #[test]
708	    fn presence_datum_rides_the_lease_and_serde() {
709	        let mut reg = SubnetRegistry::new();
710	        let with = |ts: Option<u64>, epoch: u64| Instance {
711	            node: "n1".to_string(),
712	            status: Status::Active,
713	            epoch,
714	            resources: None,
715	            last_active_ms: ts,
716	            shell_adapters: Vec::new(),
717	        };
718	        reg.merge_instance("ling", with(Some(1_000), 1));
719	        assert_eq!(
720	            reg.merge_instance("ling", with(Some(2_000), 2)),
721	            MergeOutcome::Updated,
722	            "a heartbeat refresh is an ordinary epoch-bumped update"
723	        );
724	        assert_eq!(reg.instances("ling")[0].last_active_ms, Some(2_000));
725	        // A lagging row drops under the lease — recency never rolls back.
726	        assert_eq!(reg.merge_instance("ling", with(Some(500), 1)), MergeOutcome::Stale);
727	        assert_eq!(reg.instances("ling")[0].last_active_ms, Some(2_000));
728
729	        // Serde: roundtrips with the datum; a pre-D6 row parses clean.
730	        let json = serde_json::to_string(&reg).unwrap();
731	        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
732	        assert_eq!(reg, back);
733	        let old: Instance =
734	            serde_json::from_str(r#"{"node":"n1","status":"Active","epoch":1}"#).unwrap();
735	        assert_eq!(old.last_active_ms, None, "absent field parses as no datum");
736	    }
737
738	    // [unit->REQ-INST-14] the projection is the yellow-pages view over
739	    // VISIBLE rows only: a hidden endpoint never appears (discovery leaks
740	    // nothing resolution would refuse), an Offline row is not a resource,
741	    // and an absent blurb renders clean (None, not an error).
742	    #[test]
743	    fn resource_projection_filters_hidden_and_offline() {
744	        let mut reg = SubnetRegistry::new();
745	        reg.merge_instance(
746	            "ling",
747	            Instance {
748	                node: "n1".to_string(),
749	                status: Status::Active,
750	                epoch: 1,
751	                resources: Some("rust builds".to_string()),
752	                last_active_ms: None,
753	                shell_adapters: Vec::new(),
754	            },
755	        );
756	        reg.merge_instance("oak", inst("n1", Status::Dormant, 1)); // no blurb
757	        reg.merge_instance("hidden-ag", inst("n1", Status::Active, 1));
758	        reg.merge_instance("dead", inst("n2", Status::Offline, 1));
759
760	        let rows = resource_projection(&reg, |id| id == "hidden-ag");
761	        let ids: Vec<&str> = rows.iter().map(|r| r.endpoint_id.as_str()).collect();
762	        assert_eq!(ids, vec!["ling", "oak"], "hidden excluded, offline skipped: {rows:?}");
763	        assert_eq!(rows[0].resources.as_deref(), Some("rust builds"));
764	        assert_eq!(rows[1].resources, None, "absent blurb renders clean");
765	        assert_eq!(rows[1].status, Status::Dormant, "resting rows still list (addressable)");
766	    }
767
768	    // [unit->REQ-INST-3] a Suspended instance is resting, not gone: it stays
769	    // addressable (resolves bare and via @node pin — a wake must route to it)

    // a newer-epoch row carries the updated blurb; a lagging blurb edit is
    // dropped stale; and the wire shape roundtrips with the blurb present
    // and parses a pre-D9-4 row (no field) clean.
    #[test]
    fn resource_blurb_rides_the_lease_and_serde() {
        let mut reg = SubnetRegistry::new();
        let with = |blurb: Option<&str>, epoch: u64| Instance {
            node: "n1".to_string(),
            status: Status::Active,
            epoch,
            resources: blurb.map(str::to_string),
            last_active_ms: None,
            shell_adapters: Vec::new(),
        };
        reg.merge_instance("ling", with(Some("rust builds"), 1));
        assert_eq!(
            reg.merge_instance("ling", with(Some("rust builds; code review"), 2)),
            MergeOutcome::Updated,
            "a blurb edit is an ordinary epoch-bumped update"
        );
        assert_eq!(
            reg.instances("ling")[0].resources.as_deref(),
            Some("rust builds; code review")
        );
        // A lagging edit drops under the lease — the newer blurb survives.
        assert_eq!(reg.merge_instance("ling", with(Some("stale"), 1)), MergeOutcome::Stale);
        assert_eq!(
            reg.instances("ling")[0].resources.as_deref(),
            Some("rust builds; code review")
        );

        // Serde: roundtrips with the blurb; a pre-D9-4 row parses clean.
        let json = serde_json::to_string(&reg).unwrap();
        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
        assert_eq!(reg, back);
        let old: Instance = serde_json::from_str(
            r#"{"node":"n1","status":"Active","epoch":1}"#,
        )
        .unwrap();
        assert_eq!(old.resources, None, "absent field parses as no blurb");
    }

    // [unit->REQ-PRES-1] the presence datum is a FIELD under the lease, not a
    // feed: a newer-epoch row carries the fresher stamp; a lagging row drops
    // stale (a partition can never roll recency backward through the merge);
    // the wire shape roundtrips with the datum and a pre-D6 row (no field)
    // parses clean — the additive-field discipline the resources blurb proved.
    #[test]
    fn presence_datum_rides_the_lease_and_serde() {
        let mut reg = SubnetRegistry::new();
        let with = |ts: Option<u64>, epoch: u64| Instance {
            node: "n1".to_string(),
            status: Status::Active,
            epoch,
            resources: None,
            last_active_ms: ts,
            shell_adapters: Vec::new(),
        };
        reg.merge_instance("ling", with(Some(1_000), 1));
        assert_eq!(
            reg.merge_instance("ling", with(Some(2_000), 2)),
            MergeOutcome::Updated,
            "a heartbeat refresh is an ordinary epoch-bumped update"
        );
        assert_eq!(reg.instances("ling")[0].last_active_ms, Some(2_000));
        // A lagging row drops under the lease — recency never rolls back.
        assert_eq!(reg.merge_instance("ling", with(Some(500), 1)), MergeOutcome::Stale);
        assert_eq!(reg.instances("ling")[0].last_active_ms, Some(2_000));

        // Serde: roundtrips with the datum; a pre-D6 row parses clean.
        let json = serde_json::to_string(&reg).unwrap();
        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
        assert_eq!(reg, back);
        let old: Instance =
            serde_json::from_str(r#"{"node":"n1","status":"Active","epoch":1}"#).unwrap();
        assert_eq!(old.last_active_ms, None, "absent field parses as no datum");
    }

    // [unit->REQ-INST-14] the projection is the yellow-pages view over
    // VISIBLE rows only: a hidden endpoint never appears (discovery leaks
    // nothing resolution would refuse), an Offline row is not a resource,
    // and an absent blurb renders clean (None, not an error).
    #[test]
    fn resource_projection_filters_hidden_and_offline() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance(
            "ling",
            Instance {
                node: "n1".to_string(),
                status: Status::Active,
                epoch: 1,
                resources: Some("rust builds".to_string()),
                last_active_ms: None,
                shell_adapters: Vec::new(),
            },
        );
        reg.merge_instance("oak", inst("n1", Status::Dormant, 1)); // no blurb
        reg.merge_instance("hidden-ag", inst("n1", Status::Active, 1));
        reg.merge_instance("dead", inst("n2", Status::Offline, 1));

        let rows = resource_projection(&reg, |id| id == "hidden-ag");
        let ids: Vec<&str> = rows.iter().map(|r| r.endpoint_id.as_str()).collect();
        assert_eq!(ids, vec!["ling", "oak"], "hidden excluded, offline skipped: {rows:?}");
        assert_eq!(rows[0].resources.as_deref(), Some("rust builds"));
        assert_eq!(rows[1].resources, None, "absent blurb renders clean");
        assert_eq!(rows[1].status, Status::Dormant, "resting rows still list (addressable)");
    }

    // [unit->REQ-INST-3] a Suspended instance is resting, not gone: it stays
    // addressable (resolves bare and via @node pin — a wake must route to it)


cd crates/spt-net && sed -i 's/            shell_adapters: Vec::new(),\n        };/X/' src/net/registry.rs; perl -0pi -e 's/(last_active_ms: None,\n            shell_adapters: Vec::new(),\n        \};)/last_active_ms: None,\n            shell_adapters: Vec::new(),\n            node_label: None,\n        };/g; s/(last_active_ms: ts,\n            shell_adapters: Vec::new(),\n        \};)/last_active_ms: ts,\n            shell_adapters: Vec::new(),\n            node_label: None,\n        };/g' src/net/registry.rs && grep -n "node_label: None" src/net/registry.rs

Add node_label to multi-line literals in registry.rs tests


568:        Instance { node: node.to_string(), status, epoch, resources: None, last_active_ms: None, shell_adapters: Vec::new(), node_label: None }


Perl missed. Manual edits:


Edit

            resources: blurb.map(str::to_string),
            last_active_ms: None,
            shell_adapters: Vec::new(),
        };

            resources: blurb.map(str::to_string),
            last_active_ms: None,
            shell_adapters: Vec::new(),
            node_label: None,
        };


<tool_use_error>File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.</tool_use_error>

Error: File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.


            resources: None,
            last_active_ms: ts,
            shell_adapters: Vec::new(),
        };

            resources: None,
            last_active_ms: ts,
            shell_adapters: Vec::new(),
            node_label: None,
        };


                resources: Some("rust builds".to_string()),
                last_active_ms: None,
                shell_adapters: Vec::new(),
            },

                resources: Some("rust builds".to_string()),
                last_active_ms: None,
                shell_adapters: Vec::new(),
                node_label: None,
            },


Instance.node_label (serde-default, skip-if-none; old rows parse clean). RegistryHost computes OS hostname at construction (daemon startup re-check), stamps every advertised row. Activate REQ-SUBNET-3 [impl,unit].

resolve_visible @node arm: match full hex, hex prefix, or label (case-insensitive); >1 distinct node matched → Ambiguous with candidate key prefixes, never guess. Update wansend render.

Per-node rows: `LABEL (keyprefix…)`, online/offline, [online endpoints/total]. Liveness = gossip-recency for table, probe only stale rows. Regen reference.