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

//! Per-node monotonic epoch counter (D3b) — the ordering authority behind the
//! registry lease (#8) and, later, sync precedence (#7).
//!
//! A single strictly-increasing `u64` persisted at [`epoch_file`](crate::perch::epoch_file).
//! Every state change this node advertises (an instance going Active/Dormant/Offline, a
//! context snapshot) stamps the value [`next`](EpochSource::next) returns. Comparing two
//! updates by this counter — NOT wall-clock — is what stops a lagging "Active" from
//! overwriting a newer "Offline" after a partition or clock skew. Wall-clock is at most a
//! human tiebreaker hint elsewhere; it is never the ordering authority (M4-PLAN D3b/#8).
//!
//! **Single-writer assumption (v1):** the value is read-modify-written, so concurrent
//! writers on one node could collide. v1 is fine because the daemon (D4) owns the one
//! writer; until then a single process bumps it. Cross-process serialization is a D4
//! concern, not modeled here.
// [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]

use std::path::{Path, PathBuf};

use crate::atomic::atomic_write_string;
use crate::perch::epoch_file;

/// A node's persisted monotonic epoch counter.
pub struct EpochSource {
    path: PathBuf,
    current: u64,
}

impl EpochSource {
    /// Load (or initialize) the counter from the canonical [`epoch_file`].
    pub fn load() -> EpochSource {
        Self::load_from(&epoch_file())
    }

    /// Load from an explicit path (the testable core). An absent file starts at `0`
    /// (first [`next`] yields `1`); a corrupt/unparsable file ALSO degrades to `0`
    /// rather than panicking — a damaged counter must not brick the node. Restarting
    /// from 0 is safe-by-construction: see [`next`]'s monotonicity note.
    pub fn load_from(path: &Path) -> EpochSource {
        let current = std::fs::read_to_string(path)
            .ok()
            .and_then(|s| s.trim().parse::<u64>().ok())
            .unwrap_or(0);
        EpochSource { path: path.to_path_buf(), current }
    }

    /// The last issued epoch without advancing (peek). `0` means none issued yet.
    pub fn current(&self) -> u64 {
        self.current
    }

    /// Advance and return the next epoch, persisting it **before** updating the
    /// in-memory value so a failed write leaves the counter unmoved (the error is
    /// returned, no epoch is consumed). Strictly greater than every prior return on
    /// this source — the monotonic guarantee callers depend on.
    pub fn next_epoch(&mut self) -> std::io::Result<u64> {
        let n = self
            .current
            .checked_add(1)
            .expect("epoch counter overflowed u64 — unreachable in any real lifetime");
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        atomic_write_string(&self.path, &n.to_string())?;
        self.current = n;
        Ok(n)
    }
}

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

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] next() is strictly increasing and starts
    // at 1 (a small counter, NOT a wall-clock-derived value — proof the source is a
    // monotonic counter, not a timestamp).
    #[test]
    fn next_is_strictly_increasing_from_one() {
        let dir = tempdir().unwrap();
        let mut src = EpochSource::load_from(&dir.path().join("epoch"));
        let a = src.next_epoch().unwrap();
        let b = src.next_epoch().unwrap();
        let c = src.next_epoch().unwrap();
        assert_eq!((a, b, c), (1, 2, 3));
        assert!(a < 1_000_000, "epoch is a small counter, not a unix timestamp");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the counter is persisted: a reload picks
    // up where the prior source left off, never reissuing a consumed epoch.
    #[test]
    fn counter_persists_across_reload() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("epoch");
        {
            let mut src = EpochSource::load_from(&path);
            assert_eq!(src.next_epoch().unwrap(), 1);
            assert_eq!(src.next_epoch().unwrap(), 2);
        }
        let mut reloaded = EpochSource::load_from(&path);
        assert_eq!(reloaded.current(), 2, "peek sees the persisted value");
        assert_eq!(reloaded.next_epoch().unwrap(), 3, "continues, never reissues 1/2");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] a corrupt/absent counter file degrades to
    // a safe restart (0) rather than panicking.
    #[test]
    fn corrupt_or_absent_file_degrades_safely() {
        let dir = tempdir().unwrap();
        let absent = dir.path().join("nope");
        assert_eq!(EpochSource::load_from(&absent).current(), 0);

        let corrupt = dir.path().join("epoch");
        std::fs::write(&corrupt, "not-a-number").unwrap();
        let mut src = EpochSource::load_from(&corrupt);
        assert_eq!(src.current(), 0);
        assert_eq!(src.next_epoch().unwrap(), 1, "recovers without panic");
    }
}

-//! context snapshot) stamps the value [`next`](EpochSource::next) returns. Comparing two

+//! context snapshot) stamps the value [`next_epoch`](EpochSource::next_epoch) returns. Comparing two


Edit

    /// Load from an explicit path (the testable core). An absent file starts at `0`
    /// (first [`next`] yields `1`); a corrupt/unparsable file ALSO degrades to `0`
    /// rather than panicking — a damaged counter must not brick the node. Restarting
    /// from 0 is safe-by-construction: see [`next`]'s monotonicity note.

    /// Load from an explicit path (the testable core). An absent file starts at `0`
    /// (first [`next_epoch`] yields `1`); a corrupt/unparsable file ALSO degrades to `0`
    /// rather than panicking — a damaged counter must not brick the node. Restarting
    /// from 0 is safe-by-construction: see [`next_epoch`]'s monotonicity note.


    /// Load from an explicit path (the testable core). An absent file starts at `0`
    /// (first [`next_epoch`] yields `1`); a corrupt/unparsable file ALSO degrades to `0`
    /// rather than panicking — a damaged counter must not brick the node. Restarting
    /// from 0 is safe-by-construction: see [`next_epoch`]'s monotonicity note.

//! Per-node monotonic epoch counter (D3b) — the ordering authority behind the
//! registry lease (#8) and, later, sync precedence (#7).
//!
//! A single strictly-increasing `u64` persisted at [`epoch_file`](crate::perch::epoch_file).
//! Every state change this node advertises (an instance going Active/Dormant/Offline, a
//! context snapshot) stamps the value [`next_epoch`](EpochSource::next_epoch) returns. Comparing two
//! updates by this counter — NOT wall-clock — is what stops a lagging "Active" from
//! overwriting a newer "Offline" after a partition or clock skew. Wall-clock is at most a
//! human tiebreaker hint elsewhere; it is never the ordering authority (M4-PLAN D3b/#8).
//!
//! **Single-writer assumption (v1):** the value is read-modify-written, so concurrent
//! writers on one node could collide. v1 is fine because the daemon (D4) owns the one
//! writer; until then a single process bumps it. Cross-process serialization is a D4
//! concern, not modeled here.
// [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]

use std::path::{Path, PathBuf};

use crate::atomic::atomic_write_string;
use crate::perch::epoch_file;

/// A node's persisted monotonic epoch counter.
pub struct EpochSource {
    path: PathBuf,
    current: u64,
}

impl EpochSource {
    /// Load (or initialize) the counter from the canonical [`epoch_file`].
    pub fn load() -> EpochSource {
        Self::load_from(&epoch_file())
    }

    /// Load from an explicit path (the testable core). An absent file starts at `0`
    /// (first [`next`] yields `1`); a corrupt/unparsable file ALSO degrades to `0`
    /// rather than panicking — a damaged counter must not brick the node. Restarting
    /// from 0 is safe-by-construction: see [`next`]'s monotonicity note.
    pub fn load_from(path: &Path) -> EpochSource {
        let current = std::fs::read_to_string(path)
            .ok()
            .and_then(|s| s.trim().parse::<u64>().ok())
            .unwrap_or(0);
        EpochSource { path: path.to_path_buf(), current }
    }

    /// The last issued epoch without advancing (peek). `0` means none issued yet.
    pub fn current(&self) -> u64 {
        self.current
    }

    /// Advance and return the next epoch, persisting it **before** updating the
    /// in-memory value so a failed write leaves the counter unmoved (the error is
    /// returned, no epoch is consumed). Strictly greater than every prior return on
    /// this source — the monotonic guarantee callers depend on.
    pub fn next_epoch(&mut self) -> std::io::Result<u64> {
        let n = self
            .current
            .checked_add(1)
            .expect("epoch counter overflowed u64 — unreachable in any real lifetime");
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        atomic_write_string(&self.path, &n.to_string())?;
        self.current = n;
        Ok(n)
    }
}

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

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] next() is strictly increasing and starts
    // at 1 (a small counter, NOT a wall-clock-derived value — proof the source is a
    // monotonic counter, not a timestamp).
    #[test]
    fn next_is_strictly_increasing_from_one() {
        let dir = tempdir().unwrap();
        let mut src = EpochSource::load_from(&dir.path().join("epoch"));
        let a = src.next_epoch().unwrap();
        let b = src.next_epoch().unwrap();
        let c = src.next_epoch().unwrap();
        assert_eq!((a, b, c), (1, 2, 3));
        assert!(a < 1_000_000, "epoch is a small counter, not a unix timestamp");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the counter is persisted: a reload picks
    // up where the prior source left off, never reissuing a consumed epoch.
    #[test]
    fn counter_persists_across_reload() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("epoch");
        {
            let mut src = EpochSource::load_from(&path);
            assert_eq!(src.next_epoch().unwrap(), 1);
            assert_eq!(src.next_epoch().unwrap(), 2);
        }
        let mut reloaded = EpochSource::load_from(&path);
        assert_eq!(reloaded.current(), 2, "peek sees the persisted value");
        assert_eq!(reloaded.next_epoch().unwrap(), 3, "continues, never reissues 1/2");
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] a corrupt/absent counter file degrades to
    // a safe restart (0) rather than panicking.
    #[test]
    fn corrupt_or_absent_file_degrades_safely() {
        let dir = tempdir().unwrap();
        let absent = dir.path().join("nope");
        assert_eq!(EpochSource::load_from(&absent).current(), 0);

        let corrupt = dir.path().join("epoch");
        std::fs::write(&corrupt, "not-a-number").unwrap();
        let mut src = EpochSource::load_from(&corrupt);
        assert_eq!(src.current(), 0);
        assert_eq!(src.next_epoch().unwrap(), 1, "recovers without panic");
    }
}

-    /// (first [`next`] yields `1`); a corrupt/unparsable file ALSO degrades to `0`

+    /// (first [`next_epoch`] yields `1`); a corrupt/unparsable file ALSO degrades to `0`

-    /// from 0 is safe-by-construction: see [`next`]'s monotonicity note.

+    /// from 0 is safe-by-construction: see [`next_epoch`]'s monotonicity note.


Re-run clippy + tests on spt-store.


Bash

cargo clippy -p spt-store --all-targets 2>&1 | grep -E "warning|error" | head; echo "--- test ---"; cargo test -p spt-store 2>&1 | grep -E "test result|error" | head -4

Clippy + test spt-store after rename


--- test ---
test registry::tests::resolve_on_empty_registry_is_none_not_error ... ok
test result: ok. 64 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.12s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


Unexpected: a `resolve_on_empty_registry` test I didn't write, and spt-store count jumped 49→64. Investigate.


grep -rn "resolve_on_empty_registry_is_none_not_error\|fn resolve\b\|resolve(" crates/spt-store/src crates/spt-net/src 2>/dev/null | head

Find the unexpected resolve test


(Bash completed with no output)


Grep finds nothing — likely the grep matched "error" inside "not_error" from a stale/other line. Run clean per-crate counts and confirm my new tests exist.


echo "=== spt-store epoch tests ===" && cargo test -p spt-store epoch:: 2>&1 | grep -E "running|test result|epoch::"
echo "=== spt-net registry tests ===" && cargo test -p spt-net registry:: 2>&1 | grep -E "running|test result|registry::"

Targeted test run for new modules


=== spt-store epoch tests ===
running 3 tests
test epoch::tests::corrupt_or_absent_file_degrades_safely ... ok
test epoch::tests::counter_persists_across_reload ... ok
test epoch::tests::next_is_strictly_increasing_from_one ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 61 filtered out; finished in 0.00s
=== spt-net registry tests ===
running 5 tests
test net::registry::tests::newer_epoch_supersedes_same_node ... ok
test net::registry::tests::stale_active_cannot_clobber_newer_offline ... ok
test net::registry::tests::distinct_nodes_are_separate_instances ... ok
test net::registry::tests::equal_epoch_replay_is_stale_noop ... ok
test net::registry::tests::registry_serde_roundtrips ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 45 filtered out; finished in 0.00s


All new tests pass (3 epoch + 5 registry). The phantom line was a grep artifact (`not_error` matched `error`); grep confirms no such test exists. D3 code is platform-independent (no `cfg` split), so Windows clippy is representative. Run workspace clippy + traceable.


echo "=== workspace clippy (CI-equiv) ===" && cargo clippy --workspace --all-targets 2>&1 | grep -E "^warning|^error|generated .* warning" | head; echo "clippy-exit=${PIPESTATUS[0]}"
echo "=== traceable ===" && traceable-reqs check >/dev/null 2>&1; echo "trace-exit=$?"

Workspace clippy + traceable check


=== workspace clippy (CI-equiv) ===
clippy-exit=0
=== traceable ===
trace-exit=2


traceable now exit 2 (was 0). Something regressed. Inspect.


traceable-reqs check 2>&1 | grep -iE "error|fail|MISSING|invalid|panic|EPOCH-LEASE|INST-7" | head -20

Show traceable failure detail


manifest_error: cannot read traceable-reqs.toml (The system cannot find the file specified. (os error 2))


cwd drifted — the earlier `cd crates/spt-store` persisted. traceable-reqs looks for the toml in cwd. Run from repo root.


cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check >/dev/null 2>&1; echo "trace-exit=$?"; traceable-reqs check 2>&1 | grep -E "REQ-INST-7|EPOCH-LEASE"

traceable check from repo root


trace-exit=0
  [OK] REQ-HAZARD-REGISTRY-EPOCH-LEASE  required: [impl, unit]  stages: +doc +impl +unit -int
  [OK] REQ-INST-7  required: [impl, unit]  stages: -doc +impl +unit -int


traceable green (exit 0). Both reqs covered. Commit the D3 plan + D3a/D3b slice.


cd C:/Users/decid/Documents/projects/spt-core && git add -A && git status --short && echo "=== commit ===" && git commit -F - <<'MSG'
feat(net): M4-D3a/b subnet registry model + epoch lease (REQ-INST-7, hazard #8)

The hermetic substrate for D3: the per-subnet registry data model and the
monotonic-epoch merge lease that red-team #8 demands. Cross-node replication
rides D4; this is the local model + merge, unit-tested now (as D2 built the
ceremony before the wire).

- spt-store::epoch (REQ-HAZARD-REGISTRY-EPOCH-LEASE): EpochSource — a persisted
  per-node monotonic counter (next_epoch(), strictly increasing, NEVER wall-
  clock), absent/corrupt => safe restart at 0. One source serves the registry
  lease (#8) and, later, D6 sync precedence (#7). identity/epoch file.
- spt-net::net::registry (REQ-INST-7): SubnetRegistry { endpoint_id ->
  [Instance{node,status,epoch}] }, Status{Active,Dormant,Offline}, serde-
  roundtrippable. merge_instance applies the version-vector lease keyed on
  (endpoint,node): strictly-greater epoch wins, equal/lower dropped Stale,
  distinct nodes coexist. So a lagging Active can't clobber a newer Offline.
- New hazard REQ-HAZARD-REGISTRY-EPOCH-LEASE: toml + KNOWN-HAZARDS §4.8.
- M4-D3-PLAN.md: full D3a-f build order; this commit ships the D3a/b core.

8 new unit tests (3 epoch + 5 registry incl. the #8 lease invariant). clippy
clean, traceable-reqs green. Next: D3c resolution policy (REQ-INST-10).

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

Commit D3a/b slice + plan


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-net/Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs/KNOWN-HAZARDS.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'M4-D3-PLAN.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-net/src/net/registry.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/epoch.rs', LF will be replaced by CRLF the next time Git touches it
M  Cargo.lock
A  M4-D3-PLAN.md
M  crates/spt-net/Cargo.toml
M  crates/spt-net/src/net.rs
A  crates/spt-net/src/net/registry.rs
A  crates/spt-store/src/epoch.rs
M  crates/spt-store/src/lib.rs
M  crates/spt-store/src/perch.rs
M  docs/KNOWN-HAZARDS.md
M  traceable-reqs.toml
=== commit ===
[dev-freeform 8b47fa0] feat(net): M4-D3a/b subnet registry model + epoch lease (REQ-INST-7, hazard #8)
 10 files changed, 421 insertions(+), 1 deletion(-)
 create mode 100644 M4-D3-PLAN.md
 create mode 100644 crates/spt-net/src/net/registry.rs
 create mode 100644 crates/spt-store/src/epoch.rs
8b47fa0 feat(net): M4-D3a/b subnet registry model + epoch lease (REQ-INST-7, hazard #8)


Clean commit 8b47fa0. Push + watch CI.


cd C:/Users/decid/Documents/projects/spt-core && git push 2>&1 | tail -2 && sleep 10 && rid=$(gh run list --limit 1 --json databaseId --jq '.[0].databaseId'); echo "run=$rid"; gh run watch "$rid" --exit-status 2>&1 | grep -vE "Node.js 20|github.blog|deprecat" | tail -22

Push + watch CI


To https://github.com/SaberMage/spt-core.git
   a82446f..8b47fa0  dev-freeform -> dev-freeform
run=26916984787
  ✓ Install traceable-reqs
  ✓ Coverage gate (hard — fails PR on uncovered activated req)
  ✓ Quality audit (advisory)
  ✓ Post Run actions/checkout@v4
  ✓ Complete job
✓ test (ubuntu-latest) in 1m39s (ID 79408870582)
  ✓ Set up job
  ✓ Run actions/checkout@v4
  ✓ Install Rust (stable + clippy)
  ✓ Cache cargo
  ✓ Build
  ✓ Test
  ✓ Clippy (deny warnings)
  ✓ Post Cache cargo
  ✓ Post Run actions/checkout@v4
  ✓ Complete job

ANNOTATIONS
traceability: .github#2

test (ubuntu-latest): .github#2


CI green on **8b47fa0** — test (incl Clippy) + traceability both pass. **D3a+D3b shipped.** REQ-INST-7 + REQ-HAZARD-REGISTRY-EPOCH-LEASE `[impl,unit]`.

Commune progress, then continue to D3c.


Write

## D3 started — D3a+D3b shipped (CI-green 8b47fa0)

**Done:** registry substrate. REQ-INST-7 + REQ-HAZARD-REGISTRY-EPOCH-LEASE `[impl,unit]`.
- `spt-store::epoch::EpochSource` — persisted per-node monotonic counter (`next_epoch()`,
  strictly increasing, NEVER wall-clock; absent/corrupt → safe restart). One source serves
  registry lease (#8) + D6 sync precedence (#7). `identity/epoch` file.
- `spt-net::net::registry` — `SubnetRegistry{ id → [Instance{node,status,epoch}] }`,
  `Status{Active,Dormant,Offline}`, serde. `merge_instance` = version-vector lease on
  (endpoint,node): strictly-greater epoch wins, equal/lower dropped Stale. **#8 invariant
  tested:** lagging Active can't clobber newer Offline.
- New hazard REQ-HAZARD-REGISTRY-EPOCH-LEASE: toml + KNOWN-HAZARDS §4.8.
- Plan: `M4-D3-PLAN.md` (full D3a–f build order).
- Gotcha hit: clippy `should_implement_trait` on `next()` → renamed `next_epoch()`. And
  Bash-tool cwd drift (`cd` persists) made traceable-reqs read wrong dir — run from root.

**Next: D3c** — resolution policy (REQ-INST-10). `Address::parse("[subnet:]id[@node]")` +
`resolve(registry, addr, local_node)`: local → most-recently-active (highest epoch among
Active) → explicit `id@node`; **ambiguity refuses + forces qualification** (no guessing).
Reuse REQ-HAZARD-ID-CHARSET (`:`/`@` reserved) from spt-proto id validation.
Then D3d membership / D3e visibility / D3f rename.


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)

## D3 started — D3a+D3b shipped (CI-green 8b47fa0)

**Done:** registry substrate. REQ-INST-7 + REQ-HAZARD-REGISTRY-EPOCH-LEASE `[impl,unit]`.
- `spt-store::epoch::EpochSource` — persisted per-node monotonic counter (`next_epoch()`,
  strictly increasing, NEVER wall-clock; absent/corrupt → safe restart). One source serves
  registry lease (#8) + D6 sync precedence (#7). `identity/epoch` file.
- `spt-net::net::registry` — `SubnetRegistry{ id → [Instance{node,status,epoch}] }`,
  `Status{Active,Dormant,Offline}`, serde. `merge_instance` = version-vector lease on
  (endpoint,node): strictly-greater epoch wins, equal/lower dropped Stale. **#8 invariant
  tested:** lagging Active can't clobber newer Offline.
- New hazard REQ-HAZARD-REGISTRY-EPOCH-LEASE: toml + KNOWN-HAZARDS §4.8.
- Plan: `M4-D3-PLAN.md` (full D3a–f build order).
- Gotcha hit: clippy `should_implement_trait` on `next()` → renamed `next_epoch()`. And
  Bash-tool cwd drift (`cd` persists) made traceable-reqs read wrong dir — run from root.

**Next: D3c** — resolution policy (REQ-INST-10). `Address::parse("[subnet:]id[@node]")` +
`resolve(registry, addr, local_node)`: local → most-recently-active (highest epoch among
Active) → explicit `id@node`; **ambiguity refuses + forces qualification** (no guessing).
Reuse REQ-HAZARD-ID-CHARSET (`:`/`@` reserved) from spt-proto id validation.
Then D3d membership / D3e visibility / D3f rename.


Now D3c — resolution policy. Gather the id-validation/charset surface + how `id@node` is currently refused in cmd_digest.


Bash

cd C:/Users/decid/Documents/projects/spt-core
echo "=== spt-proto id module ===" && grep -n "pub fn\|reserved\|':'\|'@'\|validate" crates/spt-proto/src/id.rs 2>/dev/null | head -25
echo "=== existing qualified-addr handling ==="
grep -rn "@node\|subnet:\|qualified\|contains('@')\|contains(':')\|id@" crates/spt-net/src crates/spt/src 2>/dev/null | head

id validation + qualified-addr handling


=== spt-proto id module ===
5://!   - `:` and `@` stay **reserved** as the future address delimiters of the
16://! only `-` + alphanumerics, so composite ids validate cleanly.
44:                 `:` and `@` are reserved address delimiters)"
53:pub fn is_allowed_id_char(c: char) -> bool {
66:pub fn validate_endpoint_id(id: &str) -> Result<(), IdError> {
85:/// Convenience boolean form of [`validate_endpoint_id`].
86:pub fn is_valid_endpoint_id(id: &str) -> bool {
87:    validate_endpoint_id(id).is_ok()
145:        assert_eq!(validate_endpoint_id(""), Err(IdError::Empty));
148:            validate_endpoint_id(&long),
158:            validate_endpoint_id("ab:cd"),
159:            Err(IdError::BadChar { ch: ':', pos: 2 })
=== existing qualified-addr handling ===
crates/spt-net/src/net/pairing/ratelimit.rs:123:    pub fn begin(&mut self, subnet: &str, now: u64) -> Result<(), RateLimited> {
crates/spt-net/src/net/pairing/ratelimit.rs:146:    pub fn record_success(&mut self, subnet: &str) {
crates/spt-net/src/net/pairing/ratelimit.rs:157:    pub fn record_failure(&mut self, subnet: &str, now: u64) {
crates/spt-net/src/net/pairing/spake.rs:210:        subnet: &str,
crates/spt-net/src/net/pairing/wire.rs:46:use spt_store::subnet::{SubnetError, SubnetStore, TOTP_SEED_LEN};
crates/spt-net/src/net/pairing/wire.rs:640:    use spt_store::subnet::{SubnetRecord, SubnetStore};
crates/spt-net/src/net/registry.rs:45:    /// The node this instance runs on (node-qualifier in `id@node` addressing).
crates/spt/src/cli.rs:23:use spt_store::subnet::{SubnetError, SubnetStore};
crates/spt/src/cli.rs:102:    /// `[subnet:]id@node` cross-node form is M4).
crates/spt/src/cli.rs:132:        subnet: Option<String>,


30	    BadChar { ch: char, pos: usize },
31	}
32
33	impl std::fmt::Display for IdError {
34	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35	        match self {
36	            IdError::Empty => write!(f, "endpoint id must not be empty"),
37	            IdError::TooLong { len, max } => {
38	                write!(f, "endpoint id too long ({len} chars; max {max})")
39	            }
40	            IdError::BadChar { ch, pos } => write!(
41	                f,
42	                "endpoint id has a disallowed character {ch:?} at position {pos} \
43	                 (allowed: A-Z a-z 0-9 - _ and Hiragana/Katakana/Kanji; \
44	                 `:` and `@` are reserved address delimiters)"
45	            ),
46	        }
47	    }
48	}
49
50	impl std::error::Error for IdError {}
51
52	/// True if `c` is permitted in an endpoint id.
53	pub fn is_allowed_id_char(c: char) -> bool {
54	    c.is_ascii_alphanumeric()
55	        || c == '-'
56	        || c == '_'
57	        || matches!(c,
58	            '\u{3040}'..='\u{309F}'   // Hiragana
59	            | '\u{30A0}'..='\u{30FF}' // Katakana
60	            | '\u{4E00}'..='\u{9FFF}' // CJK Unified Ideographs (Kanji)
61	        )
62	}
63
64	/// Validate an addressable endpoint id/name against the charset contract.
65	/// Returns `Ok(())` if every scalar is allowed and the length is in `1..=MAX`.
66	pub fn validate_endpoint_id(id: &str) -> Result<(), IdError> {
67	    if id.is_empty() {
68	        return Err(IdError::Empty);
69	    }
70	    let len = id.chars().count();
71	    if len > MAX_ENDPOINT_ID_LEN {
72	        return Err(IdError::TooLong {
73	            len,
74	            max: MAX_ENDPOINT_ID_LEN,
75	        });
76	    }
77	    for (pos, ch) in id.chars().enumerate() {
78	        if !is_allowed_id_char(ch) {
79	            return Err(IdError::BadChar { ch, pos });
80	        }
81	    }
82	    Ok(())
83	}
84
85	/// Convenience boolean form of [`validate_endpoint_id`].
86	pub fn is_valid_endpoint_id(id: &str) -> bool {
87	    validate_endpoint_id(id).is_ok()
88	}
89
90	#[cfg(test)]
91	mod tests {
92	    use super::*;
93
94	    // [unit->REQ-HAZARD-ID-CHARSET]
95	    #[test]
96	    fn accepts_plain_and_composite_ids() {
97	        for id in [
98	            "alice",
99	            "doyle",
100	            "snake_case",
101	            "kebab-case",
102	            "doyle-psyche",   // Psyche suffix scheme
103	            "doyle-w331",     // Worker suffix scheme
104	            "Node1",
105	            "a",
106	        ] {
107	            assert!(is_valid_endpoint_id(id), "{id} should be valid");
108	        }
109	    }
110
111	    // [unit->REQ-HAZARD-ID-CHARSET]
112	    #[test]
113	    fn accepts_japanese_scripts() {
114	        for id in [
115	            "こんにちは",       // Hiragana
116	            "ミックス",         // Katakana
117	            "日本語",           // Kanji
118	            "agent-日本-01",    // mixed with ASCII + hyphen
119	        ] {

    BadChar { ch: char, pos: usize },
}

impl std::fmt::Display for IdError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            IdError::Empty => write!(f, "endpoint id must not be empty"),
            IdError::TooLong { len, max } => {
                write!(f, "endpoint id too long ({len} chars; max {max})")
            }
            IdError::BadChar { ch, pos } => write!(
                f,
                "endpoint id has a disallowed character {ch:?} at position {pos} \
                 (allowed: A-Z a-z 0-9 - _ and Hiragana/Katakana/Kanji; \
                 `:` and `@` are reserved address delimiters)"
            ),
        }
    }
}

impl std::error::Error for IdError {}

/// True if `c` is permitted in an endpoint id.
pub fn is_allowed_id_char(c: char) -> bool {
    c.is_ascii_alphanumeric()
        || c == '-'
        || c == '_'
        || matches!(c,
            '\u{3040}'..='\u{309F}'   // Hiragana
            | '\u{30A0}'..='\u{30FF}' // Katakana
            | '\u{4E00}'..='\u{9FFF}' // CJK Unified Ideographs (Kanji)
        )
}

/// Validate an addressable endpoint id/name against the charset contract.
/// Returns `Ok(())` if every scalar is allowed and the length is in `1..=MAX`.
pub fn validate_endpoint_id(id: &str) -> Result<(), IdError> {
    if id.is_empty() {
        return Err(IdError::Empty);
    }
    let len = id.chars().count();
    if len > MAX_ENDPOINT_ID_LEN {
        return Err(IdError::TooLong {
            len,
            max: MAX_ENDPOINT_ID_LEN,
        });
    }
    for (pos, ch) in id.chars().enumerate() {
        if !is_allowed_id_char(ch) {
            return Err(IdError::BadChar { ch, pos });
        }
    }
    Ok(())
}

/// Convenience boolean form of [`validate_endpoint_id`].
pub fn is_valid_endpoint_id(id: &str) -> bool {
    validate_endpoint_id(id).is_ok()
}

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

    // [unit->REQ-HAZARD-ID-CHARSET]
    #[test]
    fn accepts_plain_and_composite_ids() {
        for id in [
            "alice",
            "doyle",
            "snake_case",
            "kebab-case",
            "doyle-psyche",   // Psyche suffix scheme
            "doyle-w331",     // Worker suffix scheme
            "Node1",
            "a",
        ] {
            assert!(is_valid_endpoint_id(id), "{id} should be valid");
        }
    }

    // [unit->REQ-HAZARD-ID-CHARSET]
    #[test]
    fn accepts_japanese_scripts() {
        for id in [
            "こんにちは",       // Hiragana
            "ミックス",         // Katakana
            "日本語",           // Kanji
            "agent-日本-01",    // mixed with ASCII + hyphen
        ] {


Clean layering: address grammar → spt-proto (with id validation); resolution → spt-net (needs registry). Read id.rs head to extend it with `Address`.


1	//! Endpoint-id charset contract — the addressable-name validator.
2	//!
3	//! Every addressable endpoint id/name is restricted to a deliberately narrow
4	//! charset so that:
5	//!   - `:` and `@` stay **reserved** as the future address delimiters of the
6	//!     canonical qualified form `[subnet:]id[@node]` (ADR-0006 / REQ-INST-10) —
7	//!     no bare id can ever contain them, so qualified-address parsing is
8	//!     unambiguous and no permissive id-data accumulates that would need a
9	//!     later migration;
10	//!   - path separators, whitespace, and control characters cannot smuggle into
11	//!     a perch directory name or a wire target string.
12	//!
13	//! Allowed: ASCII alphanumeric, `-`, `_`, and the Japanese script blocks
14	//! (Hiragana, Katakana, CJK Unified Ideographs). Nothing else. The existing
15	//! Psyche (`<parent>-psyche`) and Worker (`<parent>-w<N>`) suffix scheme uses
16	//! only `-` + alphanumerics, so composite ids validate cleanly.
17	// [impl->REQ-HAZARD-ID-CHARSET]
18
19	/// Maximum endpoint-id length, in Unicode scalar values.
20	pub const MAX_ENDPOINT_ID_LEN: usize = 64;
21
22	/// Why an endpoint id was rejected.
23	#[derive(Debug, Clone, PartialEq, Eq)]
24	pub enum IdError {
25	    /// The id was the empty string.
26	    Empty,
27	    /// The id exceeded [`MAX_ENDPOINT_ID_LEN`] scalar values.
28	    TooLong { len: usize, max: usize },
29	    /// A disallowed character was found (with its scalar position).
30	    BadChar { ch: char, pos: usize },

//! Endpoint-id charset contract — the addressable-name validator.
//!
//! Every addressable endpoint id/name is restricted to a deliberately narrow
//! charset so that:
//!   - `:` and `@` stay **reserved** as the future address delimiters of the
//!     canonical qualified form `[subnet:]id[@node]` (ADR-0006 / REQ-INST-10) —
//!     no bare id can ever contain them, so qualified-address parsing is
//!     unambiguous and no permissive id-data accumulates that would need a
//!     later migration;
//!   - path separators, whitespace, and control characters cannot smuggle into
//!     a perch directory name or a wire target string.
//!
//! Allowed: ASCII alphanumeric, `-`, `_`, and the Japanese script blocks
//! (Hiragana, Katakana, CJK Unified Ideographs). Nothing else. The existing
//! Psyche (`<parent>-psyche`) and Worker (`<parent>-w<N>`) suffix scheme uses
//! only `-` + alphanumerics, so composite ids validate cleanly.
// [impl->REQ-HAZARD-ID-CHARSET]

/// Maximum endpoint-id length, in Unicode scalar values.
pub const MAX_ENDPOINT_ID_LEN: usize = 64;

/// Why an endpoint id was rejected.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdError {
    /// The id was the empty string.
    Empty,
    /// The id exceeded [`MAX_ENDPOINT_ID_LEN`] scalar values.
    TooLong { len: usize, max: usize },
    /// A disallowed character was found (with its scalar position).
    BadChar { ch: char, pos: usize },


cd C:/Users/decid/Documents/projects/spt-core && grep -n "pub mod\|pub use" crates/spt-proto/src/lib.rs

spt-proto lib exports


14:pub mod chunk;
15:pub mod endpoint;
16:pub mod envelope;
17:pub mod event;
18:pub mod id;
19:pub mod identity;
20:pub mod payload;
21:pub mod version;


Add `addr.rs` to spt-proto — the `[subnet:]id[@node]` grammar, reusing `validate_endpoint_id`.


Write

//! Qualified-address grammar `[subnet:]id[@node]` (D3c, ADR-0006 §2 / REQ-INST-10).
//!
//! The canonical way to name an endpoint across the multi-subnet, multi-node fabric.
//! Two optional qualifiers combine around the bare endpoint id:
//!   - a **subnet** qualifier `home:ling` selects which subnet's registry to resolve in;
//!   - a **node** qualifier `ling@hfenduleam` pins a specific running instance.
//!
//! Both delimiters (`:`, `@`) are permanently reserved from the id charset
//! ([`crate::id`], REQ-HAZARD-ID-CHARSET), so parsing is unambiguous: there is at most
//! one `:` (before the id) and at most one `@` (after the id), and every component is a
//! valid addressable name in its own right. This module is **grammar only** — picking an
//! instance from a registry is the resolution policy (`spt-net::net::registry`).
// [impl->REQ-INST-10]

use crate::id::{validate_endpoint_id, IdError};

/// Which component of a qualified address failed validation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Part {
    /// The `subnet:` qualifier.
    Subnet,
    /// The bare endpoint id.
    Id,
    /// The `@node` qualifier.
    Node,
}

/// Why a qualified address failed to parse: a named component violated the id charset
/// contract (empty, too long, or a disallowed character).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddrError {
    /// The offending component.
    pub part: Part,
    /// The underlying charset/length violation.
    pub source: IdError,
}

impl std::fmt::Display for AddrError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let part = match self.part {
            Part::Subnet => "subnet qualifier",
            Part::Id => "endpoint id",
            Part::Node => "node qualifier",
        };
        write!(f, "invalid {part}: {}", self.source)
    }
}

impl std::error::Error for AddrError {}

/// A parsed qualified address. `subnet`/`node` are present only when their qualifier was
/// given; `id` is always present and valid.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Address {
    /// The optional subnet qualifier (before `:`).
    pub subnet: Option<String>,
    /// The bare endpoint id.
    pub id: String,
    /// The optional node qualifier (after `@`).
    pub node: Option<String>,
}

impl Address {
    /// Parse `[subnet:]id[@node]`. Every present component is validated against the id
    /// charset; the reserved delimiters guarantee an unambiguous split. A second `:` or
    /// `@` lands inside a component and is rejected as a disallowed character there.
    // [impl->REQ-INST-10]
    pub fn parse(s: &str) -> Result<Address, AddrError> {
        // Split the node qualifier off the end (`@` is after the id). `rsplit` so a
        // stray earlier `@` stays in `head` and is caught when `head`/id is validated.
        let (head, node) = match s.rfind('@') {
            Some(i) => (&s[..i], Some(&s[i + 1..])),
            None => (s, None),
        };
        // Split the subnet qualifier off the front (`:` is before the id). First `:` —
        // a second one lands in the id and is rejected by the charset validator.
        let (subnet, id) = match head.find(':') {
            Some(i) => (Some(&head[..i]), &head[i + 1..]),
            None => (None, head),
        };

        validate_endpoint_id(id).map_err(|source| AddrError { part: Part::Id, source })?;
        if let Some(sub) = subnet {
            validate_endpoint_id(sub).map_err(|source| AddrError { part: Part::Subnet, source })?;
        }
        if let Some(n) = node {
            validate_endpoint_id(n).map_err(|source| AddrError { part: Part::Node, source })?;
        }

        Ok(Address {
            subnet: subnet.map(str::to_string),
            id: id.to_string(),
            node: node.map(str::to_string),
        })
    }

    /// A bare, unqualified address (just an id) — convenience for the common case.
    pub fn bare(id: &str) -> Result<Address, AddrError> {
        Address::parse(id)
    }
}

/// Render back to the canonical `[subnet:]id[@node]` string.
impl std::fmt::Display for Address {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if let Some(sub) = &self.subnet {
            write!(f, "{sub}:")?;
        }
        write!(f, "{}", self.id)?;
        if let Some(node) = &self.node {
            write!(f, "@{node}")?;
        }
        Ok(())
    }
}

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

    // [unit->REQ-INST-10] all four shapes parse into the right components.
    #[test]
    fn parses_every_qualifier_combination() {
        assert_eq!(
            Address::parse("ling").unwrap(),
            Address { subnet: None, id: "ling".into(), node: None }
        );
        assert_eq!(
            Address::parse("home:ling").unwrap(),
            Address { subnet: Some("home".into()), id: "ling".into(), node: None }
        );
        assert_eq!(
            Address::parse("ling@hfenduleam").unwrap(),
            Address { subnet: None, id: "ling".into(), node: Some("hfenduleam".into()) }
        );
        assert_eq!(
            Address::parse("home:ling@hfenduleam").unwrap(),
            Address { subnet: Some("home".into()), id: "ling".into(), node: Some("hfenduleam".into()) }
        );
    }

    // [unit->REQ-INST-10] Display round-trips the canonical form.
    #[test]
    fn display_round_trips() {
        for s in ["ling", "home:ling", "ling@node", "home:ling@node"] {
            assert_eq!(Address::parse(s).unwrap().to_string(), s);
        }
    }

    // [unit->REQ-INST-10] a second delimiter lands inside a component and is rejected
    // (no silent mis-split) — the reserved-charset guarantee in action.
    #[test]
    fn rejects_doubled_delimiters() {
        assert_eq!(Address::parse("a:b:c").unwrap_err().part, Part::Id);
        assert_eq!(Address::parse("a@b@c").unwrap_err().part, Part::Id);
    }

    // [unit->REQ-INST-10] each component is charset-validated; empties are rejected.
    #[test]
    fn rejects_empty_or_bad_components() {
        assert_eq!(Address::parse("home:").unwrap_err().part, Part::Id);
        assert_eq!(Address::parse(":ling").unwrap_err().part, Part::Subnet);
        assert_eq!(Address::parse("ling@").unwrap_err().part, Part::Node);
        assert_eq!(Address::parse("").unwrap_err().part, Part::Id);
    }
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-proto\src\addr.rs (file state is current in your context — no need to Read it back)

//! Qualified-address grammar `[subnet:]id[@node]` (D3c, ADR-0006 §2 / REQ-INST-10).
//!
//! The canonical way to name an endpoint across the multi-subnet, multi-node fabric.
//! Two optional qualifiers combine around the bare endpoint id:
//!   - a **subnet** qualifier `home:ling` selects which subnet's registry to resolve in;
//!   - a **node** qualifier `ling@hfenduleam` pins a specific running instance.
//!
//! Both delimiters (`:`, `@`) are permanently reserved from the id charset
//! ([`crate::id`], REQ-HAZARD-ID-CHARSET), so parsing is unambiguous: there is at most
//! one `:` (before the id) and at most one `@` (after the id), and every component is a
//! valid addressable name in its own right. This module is **grammar only** — picking an
//! instance from a registry is the resolution policy (`spt-net::net::registry`).
// [impl->REQ-INST-10]

use crate::id::{validate_endpoint_id, IdError};

/// Which component of a qualified address failed validation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Part {
    /// The `subnet:` qualifier.
    Subnet,
    /// The bare endpoint id.
    Id,
    /// The `@node` qualifier.
    Node,
}

/// Why a qualified address failed to parse: a named component violated the id charset
/// contract (empty, too long, or a disallowed character).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddrError {
    /// The offending component.
    pub part: Part,
    /// The underlying charset/length violation.
    pub source: IdError,
}

impl std::fmt::Display for AddrError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let part = match self.part {
            Part::Subnet => "subnet qualifier",
            Part::Id => "endpoint id",
            Part::Node => "node qualifier",
        };
        write!(f, "invalid {part}: {}", self.source)
    }
}

impl std::error::Error for AddrError {}

/// A parsed qualified address. `subnet`/`node` are present only when their qualifier was
/// given; `id` is always present and valid.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Address {
    /// The optional subnet qualifier (before `:`).
    pub subnet: Option<String>,
    /// The bare endpoint id.
    pub id: String,
    /// The optional node qualifier (after `@`).
    pub node: Option<String>,
}

impl Address {
    /// Parse `[subnet:]id[@node]`. Every present component is validated against the id
    /// charset; the reserved delimiters guarantee an unambiguous split. A second `:` or
    /// `@` lands inside a component and is rejected as a disallowed character there.
    // [impl->REQ-INST-10]
    pub fn parse(s: &str) -> Result<Address, AddrError> {
        // Split the node qualifier off the end (`@` is after the id). `rsplit` so a
        // stray earlier `@` stays in `head` and is caught when `head`/id is validated.
        let (head, node) = match s.rfind('@') {
            Some(i) => (&s[..i], Some(&s[i + 1..])),
            None => (s, None),
        };
        // Split the subnet qualifier off the front (`:` is before the id). First `:` —
        // a second one lands in the id and is rejected by the charset validator.
        let (subnet, id) = match head.find(':') {
            Some(i) => (Some(&head[..i]), &head[i + 1..]),
            None => (None, head),
        };

        validate_endpoint_id(id).map_err(|source| AddrError { part: Part::Id, source })?;
        if let Some(sub) = subnet {
            validate_endpoint_id(sub).map_err(|source| AddrError { part: Part::Subnet, source })?;
        }
        if let Some(n) = node {
            validate_endpoint_id(n).map_err(|source| AddrError { part: Part::Node, source })?;
        }

        Ok(Address {
            subnet: subnet.map(str::to_string),
            id: id.to_string(),
            node: node.map(str::to_string),
        })
    }

    /// A bare, unqualified address (just an id) — convenience for the common case.
    pub fn bare(id: &str) -> Result<Address, AddrError> {
        Address::parse(id)
    }
}

/// Render back to the canonical `[subnet:]id[@node]` string.
impl std::fmt::Display for Address {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if let Some(sub) = &self.subnet {
            write!(f, "{sub}:")?;
        }
        write!(f, "{}", self.id)?;
        if let Some(node) = &self.node {
            write!(f, "@{node}")?;
        }
        Ok(())
    }
}

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

    // [unit->REQ-INST-10] all four shapes parse into the right components.
    #[test]
    fn parses_every_qualifier_combination() {
        assert_eq!(
            Address::parse("ling").unwrap(),
            Address { subnet: None, id: "ling".into(), node: None }
        );
        assert_eq!(
            Address::parse("home:ling").unwrap(),
            Address { subnet: Some("home".into()), id: "ling".into(), node: None }
        );
        assert_eq!(
            Address::parse("ling@hfenduleam").unwrap(),
            Address { subnet: None, id: "ling".into(), node: Some("hfenduleam".into()) }
        );
        assert_eq!(
            Address::parse("home:ling@hfenduleam").unwrap(),
            Address { subnet: Some("home".into()), id: "ling".into(), node: Some("hfenduleam".into()) }
        );
    }

    // [unit->REQ-INST-10] Display round-trips the canonical form.
    #[test]
    fn display_round_trips() {
        for s in ["ling", "home:ling", "ling@node", "home:ling@node"] {
            assert_eq!(Address::parse(s).unwrap().to_string(), s);
        }
    }

    // [unit->REQ-INST-10] a second delimiter lands inside a component and is rejected
    // (no silent mis-split) — the reserved-charset guarantee in action.
    #[test]
    fn rejects_doubled_delimiters() {
        assert_eq!(Address::parse("a:b:c").unwrap_err().part, Part::Id);
        assert_eq!(Address::parse("a@b@c").unwrap_err().part, Part::Id);
    }

    // [unit->REQ-INST-10] each component is charset-validated; empties are rejected.
    #[test]
    fn rejects_empty_or_bad_components() {
        assert_eq!(Address::parse("home:").unwrap_err().part, Part::Id);
        assert_eq!(Address::parse(":ling").unwrap_err().part, Part::Subnet);
        assert_eq!(Address::parse("ling@").unwrap_err().part, Part::Node);
        assert_eq!(Address::parse("").unwrap_err().part, Part::Id);
    }
}


Edit

pub mod addr;
pub mod chunk;
pub mod endpoint;


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

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


13
14	pub mod chunk;
15	pub mod endpoint;

pub mod chunk;
pub mod endpoint;


pub mod addr;
pub mod chunk;
pub mod endpoint;


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

pub mod addr;
pub mod chunk;
pub mod endpoint;

//! `spt-proto` — the wire-protocol commodity layer for spt-core.
//!
//! Per ADR-0001, the stable wire formats are **copied verbatim** from the
//! sister project (`claude_skill_owl`); only architecture/lifecycle is
//! clean-room. This crate owns the envelope grammar and is part of the public,
//! semver-committed SDK (R-ARCH-2), carrying its own wire-protocol version
//! independent of crate semver (R-ARCH-3).
//!
// spt-proto is the bottom layer of the acyclically-layered crate stack
// (R-ARCH-1): it depends on no other workspace crate, so nothing it exposes can
// form a cycle. Every higher crate (spt-store, …) layers above it one-way.
// [impl->REQ-ARCH-1]

pub mod chunk;
pub mod endpoint;
pub mod envelope;
pub mod event;
pub mod id;
pub mod identity;
pub mod payload;
pub mod version;


Now resolution in spt-net registry.rs. Key correctness point: epochs are **per-node monotonic** — NOT comparable across nodes, so "most-recently-active" can't use raw epoch across nodes. Safe no-guessing policy: local → single-live → **refuse on multiple-live** (force `@node`); cross-subnet hit → refuse (force `subnet:`). Append to registry.rs.


use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use spt_proto::addr::Address;


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)

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use spt_proto::addr::Address;

//! Per-subnet eventually-consistent endpoint registry (D3a/b, ADR-0006).
//!
//! Maps `endpoint_id → [instance...]` within one subnet, where an [`Instance`] is the
//! same logical endpoint as seen running on a particular node, with a liveness
//! [`Status`] and the author node's monotonic [`epoch`](Instance::epoch). The same
//! `endpoint_id` can hold several instances (the node-global mind advertised from more
//! than one machine); picking the right one is the resolution policy's job (D3c) — this
//! module owns the **data model + the merge lease**, not resolution.
//!
//! ## The epoch lease (#8)
//!
//! Registry updates arrive out of order across a partition or under clock skew. A
//! lagging node could re-announce a stale `Active` after the endpoint has actually gone
//! `Offline`. [`merge_instance`] defends against this with a **version-vector** rule
//! keyed on `(endpoint_id, node)`: an incoming update wins iff its epoch is **strictly
//! greater** than the stored one for that node. An equal or lower epoch is dropped as
//! stale — so a newer `Offline` (higher epoch) can never be clobbered by a lagging
//! `Active` (lower epoch). The epoch is the per-node monotonic counter
//! ([`spt_store::epoch`]), NEVER wall-clock. Cross-node *replication* of these updates
//! rides the transport (D4); the merge seam is identical for local and wire-delivered
//! updates.
// [impl->REQ-INST-7]

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// An endpoint instance's liveness within a subnet. Ordering carries no precedence —
/// precedence is the epoch's job; status is just the advertised state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Status {
    /// Live and currently reachable.
    Active,
    /// Known but idle (e.g. a paused live agent) — addressable, not currently serving.
    Dormant,
    /// Believed down. Retained (not dropped) so a newer-epoch revival or a stale-Active
    /// suppression can be reasoned about; the resolver skips it.
    Offline,
}

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

/// The per-subnet registry: `endpoint_id → [instance...]`. `BTreeMap`/`Vec` for stable,
/// deterministic serde ordering (no hash-seed nondeterminism across nodes).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubnetRegistry {
    #[serde(default)]
    by_id: BTreeMap<String, Vec<Instance>>,
}

/// What [`merge_instance`] did with an update — useful for replication accounting and
/// for asserting the lease in tests.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeOutcome {
    /// First instance for this `(endpoint_id, node)` — added.
    Inserted,
    /// A strictly-newer epoch superseded the stored instance.
    Updated,
    /// Equal or lower epoch — dropped as stale (the lease at work).
    Stale,
}

impl SubnetRegistry {
    /// An empty registry.
    pub fn new() -> SubnetRegistry {
        SubnetRegistry::default()
    }

    /// The instances known for an endpoint id (empty slice if none).
    pub fn instances(&self, endpoint_id: &str) -> &[Instance] {
        self.by_id.get(endpoint_id).map(Vec::as_slice).unwrap_or(&[])
    }

    /// Every endpoint id in the registry (sorted, by `BTreeMap`).
    pub fn endpoint_ids(&self) -> impl Iterator<Item = &str> {
        self.by_id.keys().map(String::as_str)
    }

    /// Merge an instance update under the epoch lease (#8). For the matching
    /// `(endpoint_id, node)`: a **strictly greater** epoch supersedes the stored state
    /// ([`Updated`](MergeOutcome::Updated)); an equal/lower epoch is dropped
    /// ([`Stale`](MergeOutcome::Stale)) so a lagging `Active` cannot overwrite a newer
    /// `Offline`; a new node is added ([`Inserted`](MergeOutcome::Inserted)). Distinct
    /// nodes coexist as separate rows — cross-instance ambiguity is the resolver's call.
    // [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]
    pub fn merge_instance(&mut self, endpoint_id: &str, incoming: Instance) -> MergeOutcome {
        let row = self.by_id.entry(endpoint_id.to_string()).or_default();
        match row.iter_mut().find(|e| e.node == incoming.node) {
            Some(existing) => {
                if incoming.epoch > existing.epoch {
                    *existing = incoming;
                    MergeOutcome::Updated
                } else {
                    MergeOutcome::Stale
                }
            }
            None => {
                row.push(incoming);
                MergeOutcome::Inserted
            }
        }
    }
}

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

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

    // [unit->REQ-INST-7] the model serde-roundtrips (the on-wire/persisted form).
    #[test]
    fn registry_serde_roundtrips() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
        reg.merge_instance("ling", inst("nodeB", Status::Dormant, 1));
        let json = serde_json::to_string(&reg).unwrap();
        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
        assert_eq!(reg, back);
    }

    // [unit->REQ-INST-7] distinct nodes for one id coexist as separate instances.
    #[test]
    fn distinct_nodes_are_separate_instances() {
        let mut reg = SubnetRegistry::new();
        assert_eq!(reg.merge_instance("ling", inst("nodeA", Status::Active, 1)), MergeOutcome::Inserted);
        assert_eq!(reg.merge_instance("ling", inst("nodeB", Status::Active, 1)), MergeOutcome::Inserted);
        assert_eq!(reg.instances("ling").len(), 2);
    }

    // [unit->REQ-INST-7] a strictly-newer epoch supersedes; the row stays single per node.
    #[test]
    fn newer_epoch_supersedes_same_node() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
        assert_eq!(
            reg.merge_instance("ling", inst("nodeA", Status::Dormant, 2)),
            MergeOutcome::Updated
        );
        assert_eq!(reg.instances("ling").len(), 1, "same node stays one row");
        assert_eq!(reg.instances("ling")[0].status, Status::Dormant);
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] THE lease invariant (#8): a lagging
    // lower-epoch Active must NOT overwrite a newer higher-epoch Offline — the resolved
    // status stays Offline, so a partition/skew never routes to a dead instance.
    #[test]
    fn stale_active_cannot_clobber_newer_offline() {
        let mut reg = SubnetRegistry::new();
        // Endpoint went Offline at the newer epoch 5.
        reg.merge_instance("ling", inst("nodeA", Status::Offline, 5));
        // A lagging node re-announces Active, but at the STALE epoch 3.
        let outcome = reg.merge_instance("ling", inst("nodeA", Status::Active, 3));
        assert_eq!(outcome, MergeOutcome::Stale, "lower epoch is dropped");
        assert_eq!(
            reg.instances("ling")[0].status,
            Status::Offline,
            "newer Offline survives the stale Active"
        );
        assert_eq!(reg.instances("ling")[0].epoch, 5);
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] an equal-epoch replay is a no-op (idempotent
    // delivery must not flip state).
    #[test]
    fn equal_epoch_replay_is_stale_noop() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 7));
        assert_eq!(
            reg.merge_instance("ling", inst("nodeA", Status::Offline, 7)),
            MergeOutcome::Stale
        );
        assert_eq!(reg.instances("ling")[0].status, Status::Active);
    }
}


            None => {
                row.push(incoming);
                MergeOutcome::Inserted
            }
        }
    }
}

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

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

            None => {
                row.push(incoming);
                MergeOutcome::Inserted
            }
        }
    }
}

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

/// The outcome of resolving a bare/qualified [`Address`] against the registry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution {
    /// A single live instance was selected. `subnet` is set when resolution crossed a
    /// per-subnet registry (multi-subnet form), `None` for single-subnet resolution.
    Resolved { subnet: Option<String>, instance: Instance },
    /// Ambiguous — resolution refuses and forces the user to qualify (ADR-0006 §2: no
    /// guessing). The payload says which qualifier disambiguates.
    Ambiguous(Ambiguity),
    /// No live (routable) instance matched.
    NotFound,
}

/// Why resolution refused, and how to qualify past it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ambiguity {
    /// 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 {
        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 {
    // 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(address, local_node) {
                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(address, local_node) {
            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::*;

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

    fn addr(s: &str) -> Address {
        Address::parse(s).unwrap()
    }


            None => {
                row.push(incoming);
                MergeOutcome::Inserted
            }
        }
    }
}

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

/// The outcome of resolving a bare/qualified [`Address`] against the registry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution {
    /// A single live instance was selected. `subnet` is set when resolution crossed a
    /// per-subnet registry (multi-subnet form), `None` for single-subnet resolution.
    Resolved { subnet: Option<String>, instance: Instance },
    /// Ambiguous — resolution refuses and forces the user to qualify (ADR-0006 §2: no
    /// guessing). The payload says which qualifier disambiguates.
    Ambiguous(Ambiguity),
    /// No live (routable) instance matched.
    NotFound,
}

/// Why resolution refused, and how to qualify past it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ambiguity {
    /// 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 {
        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 {
    // 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(address, local_node) {
                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(address, local_node) {
            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::*;

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

    fn addr(s: &str) -> Address {
        Address::parse(s).unwrap()
    }

//! Per-subnet eventually-consistent endpoint registry (D3a/b, ADR-0006).
//!
//! Maps `endpoint_id → [instance...]` within one subnet, where an [`Instance`] is the
//! same logical endpoint as seen running on a particular node, with a liveness
//! [`Status`] and the author node's monotonic [`epoch`](Instance::epoch). The same
//! `endpoint_id` can hold several instances (the node-global mind advertised from more
//! than one machine); picking the right one is the resolution policy's job (D3c) — this
//! module owns the **data model + the merge lease**, not resolution.
//!
//! ## The epoch lease (#8)
//!
//! Registry updates arrive out of order across a partition or under clock skew. A
//! lagging node could re-announce a stale `Active` after the endpoint has actually gone
//! `Offline`. [`merge_instance`] defends against this with a **version-vector** rule
//! keyed on `(endpoint_id, node)`: an incoming update wins iff its epoch is **strictly
//! greater** than the stored one for that node. An equal or lower epoch is dropped as
//! stale — so a newer `Offline` (higher epoch) can never be clobbered by a lagging
//! `Active` (lower epoch). The epoch is the per-node monotonic counter
//! ([`spt_store::epoch`]), NEVER wall-clock. Cross-node *replication* of these updates
//! rides the transport (D4); the merge seam is identical for local and wire-delivered
//! updates.
// [impl->REQ-INST-7]

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use spt_proto::addr::Address;

/// An endpoint instance's liveness within a subnet. Ordering carries no precedence —
/// precedence is the epoch's job; status is just the advertised state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Status {
    /// Live and currently reachable.
    Active,
    /// Known but idle (e.g. a paused live agent) — addressable, not currently serving.
    Dormant,
    /// Believed down. Retained (not dropped) so a newer-epoch revival or a stale-Active
    /// suppression can be reasoned about; the resolver skips it.
    Offline,
}

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

/// The per-subnet registry: `endpoint_id → [instance...]`. `BTreeMap`/`Vec` for stable,
/// deterministic serde ordering (no hash-seed nondeterminism across nodes).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubnetRegistry {
    #[serde(default)]
    by_id: BTreeMap<String, Vec<Instance>>,
}

/// What [`merge_instance`] did with an update — useful for replication accounting and
/// for asserting the lease in tests.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeOutcome {
    /// First instance for this `(endpoint_id, node)` — added.
    Inserted,
    /// A strictly-newer epoch superseded the stored instance.
    Updated,
    /// Equal or lower epoch — dropped as stale (the lease at work).
    Stale,
}

impl SubnetRegistry {
    /// An empty registry.
    pub fn new() -> SubnetRegistry {
        SubnetRegistry::default()
    }

    /// The instances known for an endpoint id (empty slice if none).
    pub fn instances(&self, endpoint_id: &str) -> &[Instance] {
        self.by_id.get(endpoint_id).map(Vec::as_slice).unwrap_or(&[])
    }

    /// Every endpoint id in the registry (sorted, by `BTreeMap`).
    pub fn endpoint_ids(&self) -> impl Iterator<Item = &str> {
        self.by_id.keys().map(String::as_str)
    }

    /// Merge an instance update under the epoch lease (#8). For the matching
    /// `(endpoint_id, node)`: a **strictly greater** epoch supersedes the stored state
    /// ([`Updated`](MergeOutcome::Updated)); an equal/lower epoch is dropped
    /// ([`Stale`](MergeOutcome::Stale)) so a lagging `Active` cannot overwrite a newer
    /// `Offline`; a new node is added ([`Inserted`](MergeOutcome::Inserted)). Distinct
    /// nodes coexist as separate rows — cross-instance ambiguity is the resolver's call.
    // [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]
    pub fn merge_instance(&mut self, endpoint_id: &str, incoming: Instance) -> MergeOutcome {
        let row = self.by_id.entry(endpoint_id.to_string()).or_default();
        match row.iter_mut().find(|e| e.node == incoming.node) {
            Some(existing) => {
                if incoming.epoch > existing.epoch {
                    *existing = incoming;
                    MergeOutcome::Updated
                } else {
                    MergeOutcome::Stale
                }
            }
            None => {
                row.push(incoming);
                MergeOutcome::Inserted
            }
        }
    }
}

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

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

    // [unit->REQ-INST-7] the model serde-roundtrips (the on-wire/persisted form).
    #[test]
    fn registry_serde_roundtrips() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
        reg.merge_instance("ling", inst("nodeB", Status::Dormant, 1));
        let json = serde_json::to_string(&reg).unwrap();
        let back: SubnetRegistry = serde_json::from_str(&json).unwrap();
        assert_eq!(reg, back);
    }

    // [unit->REQ-INST-7] distinct nodes for one id coexist as separate instances.
    #[test]
    fn distinct_nodes_are_separate_instances() {
        let mut reg = SubnetRegistry::new();
        assert_eq!(reg.merge_instance("ling", inst("nodeA", Status::Active, 1)), MergeOutcome::Inserted);
        assert_eq!(reg.merge_instance("ling", inst("nodeB", Status::Active, 1)), MergeOutcome::Inserted);
        assert_eq!(reg.instances("ling").len(), 2);
    }

    // [unit->REQ-INST-7] a strictly-newer epoch supersedes; the row stays single per node.
    #[test]
    fn newer_epoch_supersedes_same_node() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 1));
        assert_eq!(
            reg.merge_instance("ling", inst("nodeA", Status::Dormant, 2)),
            MergeOutcome::Updated
        );
        assert_eq!(reg.instances("ling").len(), 1, "same node stays one row");
        assert_eq!(reg.instances("ling")[0].status, Status::Dormant);
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] THE lease invariant (#8): a lagging
    // lower-epoch Active must NOT overwrite a newer higher-epoch Offline — the resolved
    // status stays Offline, so a partition/skew never routes to a dead instance.
    #[test]
    fn stale_active_cannot_clobber_newer_offline() {
        let mut reg = SubnetRegistry::new();
        // Endpoint went Offline at the newer epoch 5.
        reg.merge_instance("ling", inst("nodeA", Status::Offline, 5));
        // A lagging node re-announces Active, but at the STALE epoch 3.
        let outcome = reg.merge_instance("ling", inst("nodeA", Status::Active, 3));
        assert_eq!(outcome, MergeOutcome::Stale, "lower epoch is dropped");
        assert_eq!(
            reg.instances("ling")[0].status,
            Status::Offline,
            "newer Offline survives the stale Active"
        );
        assert_eq!(reg.instances("ling")[0].epoch, 5);
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] an equal-epoch replay is a no-op (idempotent
    // delivery must not flip state).
    #[test]
    fn equal_epoch_replay_is_stale_noop() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", inst("nodeA", Status::Active, 7));
        assert_eq!(
            reg.merge_instance("ling", inst("nodeA", Status::Offline, 7)),
            MergeOutcome::Stale
        );
        assert_eq!(reg.instances("ling")[0].status, Status::Active);
    }
}

+    /// Whether an instance in this status is a valid routing target. `Offline` is not

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

+        !matches!(self, Status::Offline)

+/// The outcome of resolving a bare/qualified [`Address`] against the registry.

+    /// A single live instance was selected. `subnet` is set when resolution crossed a

+    /// per-subnet registry (multi-subnet form), `None` for single-subnet resolution.

+    Resolved { subnet: Option<String>, instance: Instance },

+    /// Ambiguous — resolution refuses and forces the user to qualify (ADR-0006 §2: no

+    /// guessing). The payload says which qualifier disambiguates.

+    /// No live (routable) instance matched.

+/// Why resolution refused, and how to qualify past it.

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

+    /// The id resolves in several visible subnets — qualify with `subnet:id`. Lists the

+    /// 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`

+    pub fn resolve(&self, address: &Address, local_node: &str) -> Resolution {

+        let live: Vec<&Instance> = self

+            .filter(|i| i.status.routable())

+        // 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() };

+            [] => 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).

+    subnets: &BTreeMap<String, SubnetRegistry>,

+    // 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(address, local_node) {

+                Resolution::Resolved { instance, .. } => {

+                    Resolution::Resolved { subnet: Some(subnet.clone()), instance }

+            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();

+        match reg.resolve(address, local_node) {

+                hits.push((name.clone(), Resolution::Resolved { subnet: Some(name.clone()), instance }));

+            ambiguous @ Resolution::Ambiguous(_) => return ambiguous,

+        _ => Resolution::Ambiguous(Ambiguity::AcrossSubnets(

+            hits.into_iter().map(|(name, _)| name).collect(),


Append resolution tests.


        assert_eq!(
            reg.merge_instance("ling", inst("nodeA", Status::Offline, 7)),
            MergeOutcome::Stale
        );
        assert_eq!(reg.instances("ling")[0].status, Status::Active);
    }
}

        assert_eq!(
            reg.merge_instance("ling", inst("nodeA", Status::Offline, 7)),
            MergeOutcome::Stale
        );
        assert_eq!(reg.instances("ling")[0].status, Status::Active);
    }

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

    fn reg_with(instances: &[(&str, &str, Status)]) -> SubnetRegistry {
        let mut r = SubnetRegistry::new();
        for (id, node, status) in instances {
            r.merge_instance(id, inst(node, *status, 1));
        }
        r
    }

    // [unit->REQ-INST-10] a local instance wins outright (no hop, unambiguous) even when
    // a remote instance also exists.
    #[test]
    fn resolve_prefers_local_instance() {
        let reg = reg_with(&[("ling", "here", Status::Active), ("ling", "there", Status::Active)]);
        let r = reg.resolve(&addr("ling"), "here");
        assert!(matches!(r, Resolution::Resolved { instance, .. } if instance.node == "here"));
    }

    // [unit->REQ-INST-10] a sole remote live instance resolves; an Offline one is skipped.
    #[test]
    fn resolve_sole_live_remote_and_skips_offline() {
        let reg = reg_with(&[("ling", "there", Status::Active), ("ling", "dead", Status::Offline)]);
        let r = reg.resolve(&addr("ling"), "here");
        assert!(matches!(r, Resolution::Resolved { instance, .. } if instance.node == "there"));
    }

    // [unit->REQ-INST-10] several live nodes + no local + no @node ⇒ refuse, force @node
    // (per-node epochs are NOT cross-comparable, so we never guess a winner).
    #[test]
    fn resolve_multiple_live_nodes_refuses() {
        let reg = reg_with(&[("ling", "a", Status::Active), ("ling", "b", Status::Dormant)]);
        match reg.resolve(&addr("ling"), "elsewhere") {
            Resolution::Ambiguous(Ambiguity::AcrossNodes(nodes)) => {
                assert_eq!(nodes, vec!["a".to_string(), "b".to_string()]);
            }
            other => panic!("expected AcrossNodes refusal, got {other:?}"),
        }
    }

    // [unit->REQ-INST-10] an explicit @node pins that instance past the ambiguity; an
    // @node that is Offline / absent is NotFound (never routed to a dead instance).
    #[test]
    fn resolve_node_qualifier_pins_and_offline_is_not_found() {
        let reg = reg_with(&[("ling", "a", Status::Active), ("ling", "b", Status::Active)]);
        assert!(matches!(
            reg.resolve(&addr("ling@b"), "elsewhere"),
            Resolution::Resolved { instance, .. } if instance.node == "b"
        ));
        let dead = reg_with(&[("ling", "a", Status::Offline)]);
        assert_eq!(dead.resolve(&addr("ling@a"), "elsewhere"), Resolution::NotFound);
    }

    // [unit->REQ-INST-10] across subnets: a `subnet:` qualifier routes straight there and
    // tags the subnet on the hit.
    #[test]
    fn resolve_across_subnet_qualified_routes_there() {
        let mut subnets = BTreeMap::new();
        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
        subnets.insert("work".to_string(), reg_with(&[("ling", "n2", Status::Active)]));
        match resolve_across(&subnets, &addr("work:ling"), "elsewhere") {
            Resolution::Resolved { subnet, instance } => {
                assert_eq!(subnet.as_deref(), Some("work"));
                assert_eq!(instance.node, "n2");
            }
            other => panic!("expected resolved in work, got {other:?}"),
        }
    }

    // [unit->REQ-INST-10] across subnets: a bare id present in several subnets refuses,
    // forcing a `subnet:` qualifier (no guessing across distinct subnets).
    #[test]
    fn resolve_across_bare_id_in_two_subnets_refuses() {
        let mut subnets = BTreeMap::new();
        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
        subnets.insert("work".to_string(), reg_with(&[("ling", "n2", Status::Active)]));
        match resolve_across(&subnets, &addr("ling"), "elsewhere") {
            Resolution::Ambiguous(Ambiguity::AcrossSubnets(subs)) => {
                assert_eq!(subs, vec!["home".to_string(), "work".to_string()]);
            }
            other => panic!("expected AcrossSubnets refusal, got {other:?}"),
        }
    }

    // [unit->REQ-INST-10] across subnets: a bare id in exactly one subnet resolves cleanly.
    #[test]
    fn resolve_across_unique_id_resolves() {
        let mut subnets = BTreeMap::new();
        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
        subnets.insert("work".to_string(), reg_with(&[("other", "n2", Status::Active)]));
        match resolve_across(&subnets, &addr("ling"), "elsewhere") {
            Resolution::Resolved { subnet, instance } => {
                assert_eq!(subnet.as_deref(), Some("home"));
                assert_eq!(instance.node, "n1");
            }
            other => panic!("expected unique resolution, got {other:?}"),
        }
    }
}


        assert_eq!(
            reg.merge_instance("ling", inst("nodeA", Status::Offline, 7)),
            MergeOutcome::Stale
        );
        assert_eq!(reg.instances("ling")[0].status, Status::Active);
    }
}

        assert_eq!(
            reg.merge_instance("ling", inst("nodeA", Status::Offline, 7)),
            MergeOutcome::Stale
        );
        assert_eq!(reg.instances("ling")[0].status, Status::Active);
    }

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

    fn reg_with(instances: &[(&str, &str, Status)]) -> SubnetRegistry {
        let mut r = SubnetRegistry::new();
        for (id, node, status) in instances {
            r.merge_instance(id, inst(node, *status, 1));
        }
        r
    }

    // [unit->REQ-INST-10] a local instance wins outright (no hop, unambiguous) even when
    // a remote instance also exists.
    #[test]
    fn resolve_prefers_local_instance() {
        let reg = reg_with(&[("ling", "here", Status::Active), ("ling", "there", Status::Active)]);
        let r = reg.resolve(&addr("ling"), "here");
        assert!(matches!(r, Resolution::Resolved { instance, .. } if instance.node == "here"));
    }

    // [unit->REQ-INST-10] a sole remote live instance resolves; an Offline one is skipped.
    #[test]
    fn resolve_sole_live_remote_and_skips_offline() {
        let reg = reg_with(&[("ling", "there", Status::Active), ("ling", "dead", Status::Offline)]);
        let r = reg.resolve(&addr("ling"), "here");
        assert!(matches!(r, Resolution::Resolved { instance, .. } if instance.node == "there"));
    }

    // [unit->REQ-INST-10] several live nodes + no local + no @node ⇒ refuse, force @node
    // (per-node epochs are NOT cross-comparable, so we never guess a winner).
    #[test]
    fn resolve_multiple_live_nodes_refuses() {
        let reg = reg_with(&[("ling", "a", Status::Active), ("ling", "b", Status::Dormant)]);
        match reg.resolve(&addr("ling"), "elsewhere") {
            Resolution::Ambiguous(Ambiguity::AcrossNodes(nodes)) => {
                assert_eq!(nodes, vec!["a".to_string(), "b".to_string()]);
            }
            other => panic!("expected AcrossNodes refusal, got {other:?}"),
        }
    }

    // [unit->REQ-INST-10] an explicit @node pins that instance past the ambiguity; an
    // @node that is Offline / absent is NotFound (never routed to a dead instance).
    #[test]
    fn resolve_node_qualifier_pins_and_offline_is_not_found() {
        let reg = reg_with(&[("ling", "a", Status::Active), ("ling", "b", Status::Active)]);
        assert!(matches!(
            reg.resolve(&addr("ling@b"), "elsewhere"),
            Resolution::Resolved { instance, .. } if instance.node == "b"
        ));
        let dead = reg_with(&[("ling", "a", Status::Offline)]);
        assert_eq!(dead.resolve(&addr("ling@a"), "elsewhere"), Resolution::NotFound);
    }

    // [unit->REQ-INST-10] across subnets: a `subnet:` qualifier routes straight there and
    // tags the subnet on the hit.
    #[test]
    fn resolve_across_subnet_qualified_routes_there() {
        let mut subnets = BTreeMap::new();
        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
        subnets.insert("work".to_string(), reg_with(&[("ling", "n2", Status::Active)]));
        match resolve_across(&subnets, &addr("work:ling"), "elsewhere") {
            Resolution::Resolved { subnet, instance } => {
                assert_eq!(subnet.as_deref(), Some("work"));
                assert_eq!(instance.node, "n2");
            }
            other => panic!("expected resolved in work, got {other:?}"),
        }
    }

    // [unit->REQ-INST-10] across subnets: a bare id present in several subnets refuses,
    // forcing a `subnet:` qualifier (no guessing across distinct subnets).
    #[test]
    fn resolve_across_bare_id_in_two_subnets_refuses() {
        let mut subnets = BTreeMap::new();
        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
        subnets.insert("work".to_string(), reg_with(&[("ling", "n2", Status::Active)]));
        match resolve_across(&subnets, &addr("ling"), "elsewhere") {
            Resolution::Ambiguous(Ambiguity::AcrossSubnets(subs)) => {
                assert_eq!(subs, vec!["home".to_string(), "work".to_string()]);
            }
            other => panic!("expected AcrossSubnets refusal, got {other:?}"),
        }
    }

    // [unit->REQ-INST-10] across subnets: a bare id in exactly one subnet resolves cleanly.
    #[test]
    fn resolve_across_unique_id_resolves() {
        let mut subnets = BTreeMap::new();
        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));
        subnets.insert("work".to_string(), reg_with(&[("other", "n2", Status::Active)]));
        match resolve_across(&subnets, &addr("ling"), "elsewhere") {
            Resolution::Resolved { subnet, instance } => {
                assert_eq!(subnet.as_deref(), Some("home"));
                assert_eq!(instance.node, "n1");
            }
            other => panic!("expected unique resolution, got {other:?}"),
        }
    }
}

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

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

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

+        for (id, node, status) in instances {

+            r.merge_instance(id, inst(node, *status, 1));

+    // [unit->REQ-INST-10] a local instance wins outright (no hop, unambiguous) even when

+    fn resolve_prefers_local_instance() {

+        let reg = reg_with(&[("ling", "here", Status::Active), ("ling", "there", Status::Active)]);

+        let r = reg.resolve(&addr("ling"), "here");

+        assert!(matches!(r, Resolution::Resolved { instance, .. } if instance.node == "here"));

+    // [unit->REQ-INST-10] a sole remote live instance resolves; an Offline one is skipped.

+    fn resolve_sole_live_remote_and_skips_offline() {

+        let reg = reg_with(&[("ling", "there", Status::Active), ("ling", "dead", Status::Offline)]);

+        assert!(matches!(r, Resolution::Resolved { instance, .. } if instance.node == "there"));

+    // [unit->REQ-INST-10] several live nodes + no local + no @node ⇒ refuse, force @node

+    // (per-node epochs are NOT cross-comparable, so we never guess a winner).

+    fn resolve_multiple_live_nodes_refuses() {

+        let reg = reg_with(&[("ling", "a", Status::Active), ("ling", "b", Status::Dormant)]);

+        match reg.resolve(&addr("ling"), "elsewhere") {

+            Resolution::Ambiguous(Ambiguity::AcrossNodes(nodes)) => {

+                assert_eq!(nodes, vec!["a".to_string(), "b".to_string()]);

+            other => panic!("expected AcrossNodes refusal, got {other:?}"),

+    // [unit->REQ-INST-10] an explicit @node pins that instance past the ambiguity; an

+    // @node that is Offline / absent is NotFound (never routed to a dead instance).

+    fn resolve_node_qualifier_pins_and_offline_is_not_found() {

+        let reg = reg_with(&[("ling", "a", Status::Active), ("ling", "b", Status::Active)]);

+            reg.resolve(&addr("ling@b"), "elsewhere"),

+            Resolution::Resolved { instance, .. } if instance.node == "b"

+        let dead = reg_with(&[("ling", "a", Status::Offline)]);

+        assert_eq!(dead.resolve(&addr("ling@a"), "elsewhere"), Resolution::NotFound);

+    // [unit->REQ-INST-10] across subnets: a `subnet:` qualifier routes straight there and

+    fn resolve_across_subnet_qualified_routes_there() {

+        let mut subnets = BTreeMap::new();

+        subnets.insert("home".to_string(), reg_with(&[("ling", "n1", Status::Active)]));

+        subnets.insert("work".to_string(), reg_with(&[("ling", "n2", Status::Active)]));

+        match resolve_across(&subnets, &addr("work:ling"), "elsewhere") {

+            Resolution::Resolved { subnet, instance } => {

+                assert_eq!(subnet.as_deref(), Some("work"));

+                assert_eq!(instance.node, "n2");

+            other => panic!("expected resolved in work, got {other:?}"),

+    // [unit->REQ-INST-10] across subnets: a bare id present in several subnets refuses,

+    // forcing a `subnet:` qualifier (no guessing across distinct subnets).

+    fn resolve_across_bare_id_in_two_subnets_refuses() {

+        match resolve_across(&subnets, &addr("ling"), "elsewhere") {

+            Resolution::Ambiguous(Ambiguity::AcrossSubnets(subs)) => {

+                assert_eq!(subs, vec!["home".to_string(), "work".to_string()]);

+            other => panic!("expected AcrossSubnets refusal, got {other:?}"),

+    // [unit->REQ-INST-10] across subnets: a bare id in exactly one subnet resolves cleanly.

+    fn resolve_across_unique_id_resolves() {

+        let mut subnets = BTreeMap::new();

+        subnets.insert("work".to_string(), reg_with(&[("other", "n2", Status::Active)]));

+                assert_eq!(subnet.as_deref(), Some("home"));

+                assert_eq!(instance.node, "n1");

+            other => panic!("expected unique resolution, got {other:?}"),


Activate REQ-INST-10, then build + test.


id = "REQ-INST-10"
title = "Qualified addressing [subnet:]id[@node] + ambiguity forces qualification"
required_stages = []

id = "REQ-INST-10"
title = "Qualified addressing [subnet:]id[@node] + ambiguity forces qualification"
required_stages = ["impl", "unit"]  # activated M4-D3c: spt_proto::addr::Address::parse (grammar, reserved :/@ → unambiguous split, each component charset-validated) + spt_net::net::registry::resolve/resolve_across (policy: @node exact → local wins → sole live → REFUSE on multiple live nodes [AcrossNodes, force @node] / multiple subnets [AcrossSubnets, force subnet:]; Offline never routed). Per-node epochs are NOT cross-node comparable, so no silent most-recently-active guess. unit = addr 4-shape parse/roundtrip/doubled-delim reject + resolve local/sole/refuse/@node-pin/cross-subnet. Cross-node replication feeding the registry = D4; two-host = D9.


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

id = "REQ-INST-10"
title = "Qualified addressing [subnet:]id[@node] + ambiguity forces qualification"
required_stages = []

id = "REQ-INST-10"
title = "Qualified addressing [subnet:]id[@node] + ambiguity forces qualification"
required_stages = ["impl", "unit"]  # activated M4-D3c: spt_proto::addr::Address::parse (grammar, reserved :/@ → unambiguous split, each component charset-validated) + spt_net::net::registry::resolve/resolve_across (policy: @node exact → local wins → sole live → REFUSE on multiple live nodes [AcrossNodes, force @node] / multiple subnets [AcrossSubnets, force subnet:]; Offline never routed). Per-node epochs are NOT cross-node comparable, so no silent most-recently-active guess. unit = addr 4-shape parse/roundtrip/doubled-delim reject + resolve local/sole/refuse/@node-pin/cross-subnet. Cross-node replication feeding the registry = D4; two-host = D9.

+required_stages = ["impl", "unit"]  # activated M4-D3c: spt_proto::addr::Address::parse (grammar, reserved :/@ → unambiguous split, each component charset-validated) + spt_net::net::registry::resolve/resolve_across (policy: @node exact → local wins → sole live → REFUSE on multiple live nodes [AcrossNodes, force @node] / multiple subnets [AcrossSubnets, force subnet:]; Offline never routed). Per-node epochs are NOT cross-node comparable, so no silent most-recently-active guess. unit = addr 4-shape parse/roundtrip/doubled-delim reject + resolve local/sole/refuse/@node-pin/cross-subnet. Cross-node replication feeding the registry = D4; two-host = D9.

title = "spt rename <id> rippled to all instances (collision-checked, 6.5-reconciled)"


Bash

cd C:/Users/decid/Documents/projects/spt-core && cargo test -p spt-proto -p spt-net 2>&1 | grep -E "test result|error\[|error:|warning:" | head -20

Test spt-proto + spt-net


test result: ok. 56 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.22s
test result: ok. 53 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


All green. Confirm new resolution tests ran + clippy.


cd C:/Users/decid/Documents/projects/spt-core && echo "=== resolve tests ===" && cargo test -p spt-net resolve 2>&1 | grep -E "resolve_|test result"
echo "=== addr tests ===" && cargo test -p spt-proto addr:: 2>&1 | grep -E "test result"
echo "=== clippy proto+net ===" && cargo clippy -p spt-proto -p spt-net --all-targets 2>&1 | grep -E "warning|error" | head; echo "clippy done"

Confirm resolution/addr tests + clippy


=== resolve tests ===
test net::registry::tests::resolve_node_qualifier_pins_and_offline_is_not_found ... ok
test net::registry::tests::resolve_across_subnet_qualified_routes_there ... ok
test net::registry::tests::resolve_multiple_live_nodes_refuses ... ok
test net::registry::tests::resolve_across_unique_id_resolves ... ok
test net::registry::tests::resolve_across_bare_id_in_two_subnets_refuses ... ok
test net::registry::tests::resolve_prefers_local_instance ... ok
test net::registry::tests::resolve_sole_live_remote_and_skips_offline ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 50 filtered out; finished in 0.00s
=== addr tests ===
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 49 filtered out; finished in 0.00s
=== clippy proto+net ===
clippy done


7 resolution + 4 addr tests pass, clippy clean. traceable check + commit D3c.


cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check >/dev/null 2>&1; echo "trace-exit=$?"; traceable-reqs check 2>&1 | grep "REQ-INST-10"

traceable check for INST-10


trace-exit=0
  [OK] REQ-INST-10  required: [impl, unit]  stages: -doc +impl +unit -int