Perfect. Now I have enough information to write a comprehensive mapping. Let me compile the final report:

## Self-Update Pipeline End-to-End: File:Line Reference Map

This traces the concrete chain from discovery through staging and into apply, with exact function and file locations for each major step.

---

### **1. DISCOVERY / SOURCE: How a Node Learns a New Release Exists**

**Mechanism**: Pull-based peer propagation over P2P (REQ-UPD-1, ADR-0004 §D). A daemon-hosted pump loop polls peers on a cadenced schedule.

| Step | Location | Function | Notes |
|------|----------|----------|-------|
| **Pump cycle trigger** | `crates/spt-daemon/src/peerloop.rs` **line 344** | `due_upd = due(last_upd, now, cadence.update)` | Checks if update check is due based on `cadence.update` (config-driven period, default from `DaemonConfig`) |
| **Cadence config** | `crates/spt-daemon/src/peerloop.rs` **line 236** | `update: cfg.update_check_period` | Period loaded from `DaemonConfig` (configurable via daemon config; REQ-UPD-4 controls auto-apply flag) |
| **Pull initiation** | `crates/spt-daemon/src/peerloop.rs` **lines 490–498** | `request_update()` called inside peer roster loop | For each peer, opens stream + sends `UpdRecord::Query` with node's `current_version` and pinned channel |
| **Query sent** | `crates/spt-daemon/src/propagate.rs` **lines 357–364** | `request_update()` :: `UpdRecord::Query` encode | Requester's daemon sends query with running version + channel pin |

---

### **2. VERIFICATION: Binary Verified Before Staging**

**Two-gate design** (REQ-UPD-2): metadata gate before byte fetch, then full re-verification on received bytes.

| Step | Location | Function | Notes |
|------|----------|----------|-------|
| **Pre-fetch metadata gate** | `crates/spt-daemon/src/propagate.rs` **lines 402–435** | `verify_update_set_metadata()` then `verify_metadata()` | Peer's offer is checked for rollback/expiry/channel/signature BEFORE any artifact bytes move (line 395–407) |
| **Metadata verification** | `crates/spt-daemon/src/release.rs` **lines 397–433** | `verify_metadata()` | Checks: revoked key (line 404), unknown key (line 407–410), signature (line 411), channel (line 414–419), expiry (line 420–425), rollback (line 426–431) |
| **Trust anchor** | `crates/spt-daemon/src/release.rs` **lines 225–234** | `BUILTIN_RELEASE_KEYS` | Two-key anchor compiled into every binary: `rel-primary-2026` + `rel-recovery-2026` (minted at v0.1.0 ceremony); overlaid by `release-keys.json` (file keys add, revocations void builtin too) |
| **Artifact fetch** | `crates/spt-daemon/src/propagate.rs` **lines 437–458** | Positional chunk writes from `UpdRecord::Chunk` records | Base64-encoded chunks (64KB each) written with dedup by offset (line 456–457) |
| **Full per-node gate** | `crates/spt-daemon/src/propagate.rs` **lines 470–501** | `plan_verified()` / `plan_verified_update_set()` | Called on real bytes after download completes (line 459–501); returns `UpdatePlan` or `Rejected(reason)` |
| **plan_verified** | `crates/spt-daemon/src/update.rs` **lines 157–166** | `plan_verified()` | Calls `verify_metadata()` (line 163) then `verify_artifact()` (line 164) — no plan produced if either fails (REQ-UPD-2) |
| **Artifact digest match** | `crates/spt-daemon/src/release.rs` **lines 528–538** | `verify_artifact()` | SHA-256 hash of downloaded bytes compared to signed `artifact_sha256` (line 530) |

---

### **3. STAGING: Where Verified Binary Lands on Disk**

**Definition**: Staged = verified release stored on disk where serve/re-serve can read it. Staging is the **commit point** for peer propagation.

| Step | Location | Function | Notes |
|------|----------|----------|-------|
| **Stage call** | `crates/spt-daemon/src/propagate.rs` **line 474** | `cache.stage(&offer, &artifact)` (single) **or** line 489 `cache.stage_update_set()` (set) | Only called after `plan_verified()` succeeds — no unverified bytes ever reach cache |
| **Stage mechanism** | `crates/spt-daemon/src/relcache.rs` **lines 118–125** | `ReleaseCache::stage()` | Atomic write: artifact first (line 121), metadata last (line 124). Metadata presence is commit point. Platform-set variant at line 131–144 |
| **Staged path** | `crates/spt-daemon/src/relcache.rs` **lines 94–96, 100–104** | `artifact_path()` / `artifact_path_for_platform()` | `{cache_dir}/artifact.bin` (single) or `{cache_dir}/artifacts/{platform}.bin` (set). Metadata always at `{cache_dir}/release.json` (line 106–108) |
| **Serve gate** | `crates/spt-daemon/src/propagate.rs` **lines 169–201** | `serve_update()` roster check + `newer_offer()` | Responder checks origin is roster member (line 173, fail-closed); offers only if peer's version < staged version AND channel matches (line 179–181, 304–305) |
| **No poison path** | `crates/spt-daemon/src/relcache.rs` **lines 1–22** | Module docs | "There is no code path from wire bytes to this cache except through the gate" (line 9–11) — every stage call site is guarded by `plan_verified()` |

---

### **4. CONSENT / NOTIFICATION: User-Prompted (Default) vs Auto-Apply**

**Policy** (REQ-UPD-4): Default = staged + consent-notified (user must run `spt update apply`). Opt-in `full_auto_update` skips notif.

| Step | Location | Function | Notes |
|------|----------|----------|-------|
| **Decision logic** | `crates/spt-daemon/src/peerloop.rs` **lines 518–521** | `decide(full_auto_update, most_recently_active(...))` | Routes to consent notif or skip based on config flag (line 519) |
| **Consent notif producer** | `crates/spt-daemon/src/notif.rs` **lines 363–...** | `produce_consent_notif()` | Produces "update-consent" notif row (D8c, ADR-0007) with action: "confirm to apply (`spt update apply`) or dismiss" |
| **Notif surface** | `crates/spt-daemon/src/peerloop.rs` **lines 541–553** | Minted with node's canonical epoch (shared with registry, NOT stream-op counter) | Notif ID is `node:epoch` (must not collide; line 540 cites REQ-HAZARD-REGISTRY-EPOCH-LEASE) |
| **Config knobs** | `crates/spt-daemon/src/peerloop.rs` **line 248** | `full_auto_update: bool` | Loaded from `DaemonConfig`; `true` = skip consent, apply immediately (REQ-UPD-4) |
| **CLI trigger** | `crates/spt/src/cli.rs` **lines 1651–1686** | `cmd_update_apply()` | The explicit ack: user runs `spt update apply` after dismissing notif (line 1664 calls `apply_staged()`) |

---

### **5. APPLY: Binary Swap + Brain Handoff**

**Invariant** (REQ-UPD-3): **Brain-only** updates never cycle endpoints. Broker-touching classes refuse (M5 scope).

| Step | Location | Function | Notes |
|------|----------|----------|-------|
| **Apply entry** | `crates/spt/src/cli.rs` **line 1664** | `spt_daemon::apply_staged(&cache, &keys, &exe, &broker_socket_name())` | User-invoked (`spt update apply`) or auto-triggered |
| **At-rest re-verify** | `crates/spt-daemon/src/applyhost.rs` **lines 88–122** | Re-run `plan_verified()` / `plan_verified_update_set()` on staged bytes | Authenticity/integrity/expiry check; version floor = 0 (monotonic already gated at stage time, line 84–87) |
| **Class check** | `crates/spt-daemon/src/applyhost.rs` **lines 123–125** | Only `UpdateClass::BrainOnly` applies; broker-touching refuse typed | `RefusedClass(UpdateClass)` returned for non-brain-only; artifact stays staged |
| **Binary swap** | `crates/spt-daemon/src/applyhost.rs` **lines 131–138** | Outgoing binary steps aside as `<exe>.old-<version>` (line 132–134), artifact lands atomically (line 135, via `land_artifact()`) | Atomic temp→rename (line 180). Rollback on failure (line 136–137) |
| **Mark applied** | `crates/spt-daemon/src/applyhost.rs` **lines 144–145** | `cache.record_applied(version)` + `cache.record_last_outcome("applied")` | Durable fact for status-only convergence query (M8 decision 19, REQ-UPD-6) |
| **Broker connect** | `crates/spt-daemon/src/applyhost.rs` **lines 129, 194–217** | `connect_retry()` → `Brain::cold_start()` + attach to existing session | Retry 300× @ 10ms intervals (line 196); binary is NOT swapped yet if broker is dead |
| **Brain handoff** | `crates/spt-daemon/src/applyhost.rs` **lines 147–163** | `apply_brain_only(&plan, brain, |state| handoff_retry(...))` | Snapshot outgoing brain (line 259 in `update.rs`), drop it (line 260), re-attach with `Brain::handoff()` (line 225, 300× retry @ 10ms) |
| **Zero endpoint interruption** | `crates/spt-daemon/src/update.rs` **lines 8–11, 226–265** | Module docs + `apply_brain_only()` guard (line 255) | Refuses non-brain-only BEFORE touching brain (line 255–257); endpoint + child process persist across brain crash (proven over M3b substrate) |

---

### **6. PROPAGATION ONWARD: Updated Node Re-Serves to Peers**

**Mechanism** (REQ-UPD-1, REQ-UPD-5 adapter ripple): After a node updates itself, it offers the staged release to its peers on the next pump tick.

| Step | Location | Function | Notes |
|------|----------|----------|-------|
| **Peer roster loop** | `crates/spt-daemon/src/peerloop.rs` **lines 470–490+** | `for (conn_id, peer_hex) in peer_roster { request_update(...) }` | Pump iterates every roster member every `cadence.update` period |
| **Serve gate** | `crates/spt-daemon/src/propagate.rs` **lines 106–201** | `serve_update()` | Responder checks: origin is roster member (fail-closed), staged is newer than requester's version on requester's channel |
| **Re-serve cached bytes** | `crates/spt-daemon/src/propagate.rs` **lines 204–238, 240–280** | `UpdRecord::Fetch` / `UpdRecord::FetchArtifact` → `cache.load_artifact()` / `cache.load_artifact_for_platform()` | Fire-and-forget chunk sends; positional dedup (line 206, 220–228, 261–270) |
| **Adapter ripple** | `crates/spt-daemon/src/adapter_update.rs` **lines 130–145** | `conduct_ripple()` | After spt-core's own update (C0–C2), ripples over registered adapters (C3). Each adapter: file_pull (spt-core verifies) or delegated (adapter self-verifies per attestation). Registration order preserved. |
| **Adapter verification** | `crates/spt-daemon/src/adapter_update.rs` **lines 84–120** | `plan_adapter_update()` | File_pull: verify against adapter's own Ed25519 key (line 102) + digest (line 103). Delegated + attested: delegate to adapter's command. No unattested delegation (line 111–112). |

---

### **Configuration Flags & Knobs (User-Facing)**

| Knob | Location | Effect | Default |
|------|----------|--------|---------|
| **`full_auto_update`** | `DaemonConfig` / `peerloop.rs` line 248 | `true` = skip consent notif, auto-apply staged release (REQ-UPD-4) | `false` (prompt by default) |
| **`update_check_period`** | `DaemonConfig` | Cadence for peer update queries (line 236 in peerloop) | Config-driven (typically hours; see daemon config docs) |
| **`release-keys.json`** | `identity/` per-node file | Overlays builtin keys: add trusted keys, revoke any id (builtin or file) — enables compromise recovery without rebuild (ADR-0015, REQ-REL-3) | Builtin-only if missing (fail-closed) |
| **Channel pinning** | `release-keys.json` :: `"channel"` key | Node only accepts releases on this channel (e.g. `stable`, `beta`, `debug`). Mismatch = `ChannelMismatch` reject (REQ-HAZARD-UPDATE-ROLLBACK) | `"stable"` (from `release.rs` line 209) |

---

### **Key Architectural Invariants**

1. **No unverified bytes reach cache** (`release.rs` lines 1–22, `update.rs` lines 157–166): `stage()` only callable after `plan_verified()` succeeds.

2. **Signature is mandatory** (ADR-0004 §D): Every release signed before handoff; no legacy unsigned path (lines 1–10 in `release.rs`).

3. **Monotonic version floor** (REQ-HAZARD-UPDATE-ROLLBACK, `release.rs` line 426): `version > current_version` strict inequality; rollback = `Rollback { current, candidate }` reject.

4. **Two-key trust anchor** (ADR-0015, REQ-REL-3, `release.rs` lines 225–234): Builtin primary + recovery keys compiled into binary; file overlay enables key rotation + revocation (no rebuild needed for compromise recovery).

5. **Brain-only guarantee** (REQ-UPD-3, `update.rs` lines 246, 255–257): Class guard fires BEFORE outgoing brain is touched; broker-touching classes refuse; no endpoint terminates or suspends in routine (brain-only) case.

6. **Atomic staging** (REQ-UPD-1, `relcache.rs` lines 118–144): Artifact written first, metadata (commit point) written last via atomic temp→rename; torn stage never offers.

7. **Per-node re-verification** (REQ-UPD-2, line 342 in `propagate.rs`): Gate runs twice by design — metadata gate before fetch, full gate on bytes after fetch. No inherited trust from peer.

---

### **Example Call Chain: Peer Receives Update**

```
request_update() [propagate.rs:344]
  ↓
  brain.net_open_stream() → UpdRecord::Query sent
  ↓
  Peer's serve_update() [propagate.rs:106]
    → roster check (origin member?)
    → cache.staged_update() (what's staged?)
    → newer_offer() check (version & channel match?)
    → UpdRecord::Offer sent
  ↓
  verify_update_set_metadata() OR verify_metadata() [propagate.rs:402–435]
    → UpdRecord::FetchArtifact sent
  ↓
  Chunks received → positional writes [propagate.rs:437–458]
  ↓
  plan_verified_update_set() [propagate.rs:481–487]
    → verify_metadata(), verify_artifact() run on real bytes
    → UpdatePlan produced (class: BrainOnly, BrokerCompatible, or BrokerBreaking)
  ↓
  cache.stage_update_set() [propagate.rs:489–495]
    → Staged version now visible to peers & convergence queries
  ↓
  produce_consent_notif() [peerloop.rs:541–553]
    → (unless full_auto_update=true, then skip)
  ↓
  User: spt update apply
    → cmd_update_apply() [cli.rs:1651]
    → apply_staged() [applyhost.rs:73]
      → plan_verified() re-verify [applyhost.rs:99–122]
      → swap_aside() + land_artifact() [applyhost.rs:131–138]
      → apply_brain_only() handoff [applyhost.rs:154]
        → snapshot → drop → Brain::handoff() re-attach
    → APPLIED { version }
```

---

**End of mapping.** This is the full spine: discovery (peer poll) → verify (two gates) → stage (atomic commit) → notify (or skip if full-auto) → apply (binary swap + brain handoff) → re-propagate (serve staged to peers) → ripple (adapters).

Prompt:
Map the spt-core daemon self-update pipeline end-to-end in this repo (Rust, crates/spt-daemon and crates/spt). I need to explain to a user "how does an update come to be STAGED so that `spt update apply` can apply it?"

Trace and report the full chain, with file:line references for each step:

1. **Discovery/source**: How does a node learn a new release exists? Look at `crates/spt-daemon/src/update.rs`, `release.rs`, `propagate.rs`. Is it pull-based peer propagation (P2P), a polling loop, or a manual command? What triggers a check? Search for REQ-UPD-1 (peer-propagated update over P2P), ADR-0016.

2. **Verification**: How is a downloaded/received binary verified before staging? Look at `release.rs` (SignedRelease, BUILTIN_RELEASE_KEYS, SHA256), signature/two-key anchor. Where does verification happen relative to staging?

3. **Staging**: What does "staged" mean concretely — where on disk does the staged binary land, what writes it? Look at `relcache.rs` (staged_version), `applyhost.rs` (apply_staged), `update.rs`. What function stages it?

4. **Consent/notification**: How does the user get prompted? Search for "update-consent" notif, REQ-UPD-4 (update gated on user confirmation by default; opt-in full-auto), and how `spt update apply` (cmd_update_apply in crates/spt/src/cli.rs around line 1648) connects to the staged update. Is there an auto-apply / full-auto path that skips the notif?

5. **Apply**: What `apply_staged` / cmd_update_apply actually does — swap the binary, restart, REQ-UPD-3 (no endpoint process terminates during self-update), broker/brain split role.

6. **Propagation onward**: After a node updates, how does it offer the update to peers (REQ-UPD-1, REQ-UPD-5 adapter ripple)? The pull-based peer propagation mechanism.

Report a concise step-by-step pipeline (discovery → verify → stage → notify → apply → re-propagate) with the key function names and file:line for each. Quote the module-level doc comments where they crisply explain a step. Note any config flags (full-auto, channels, debug rollout) the user can set. Don't dump whole files — just the pipeline spine with citations.


how does the daemon self-update work? i only see `spt update apply`, which appears to only work for an update spt itself has staged. how does that come about?


Let me spot-check the load-bearing claims (the pump → stage → consent decision) before relaying:


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && echo "=== peerloop: update due + request + decide ==="; sed -n '485,555p' crates/spt-daemon/src/peerloop.rs; echo "=== cmd_update_apply ==="; sed -n '1648,1690p' crates/spt/src/cli.rs

verify pump + apply path


<owl_messages>
<owl_message from="doyle-w355" priority="info">[WORKING_PERCH_NOTICE] Subagent working perch created.
Perch: doyle-w355
Agent type: Explore
To message it: $OWL send doyle-w355 doyle</owl_message>
</owl_messages>

[OWL SYSTEM] Informational owl messages above — no acknowledgment needed. Continue your current task.


=== peerloop: update due + request + decide ===
                            current_version: current,
                            ..base_policy
                        };
                        if !policy.trusted_keys.is_empty() {
                            let open_op = ops.next_epoch()?;
                            match request_update(
                                &mut brain,
                                conn_id,
                                open_op,
                                &BrokerAbi::current(),
                                &policy,
                                &cache,
                                &paths.scratch,
                            )? {
                                UpdatePullOutcome::Updated(plan) => {
                                    let version = cache
                                        .staged_version()
                                        .map(|v| v.to_string())
                                        .unwrap_or_else(|| "?".to_string());
                                    eprintln!(
                                        "UPDATE_STAGED:{version}:{:?} (from {})",
                                        plan.class, peer_hex
                                    );
                                    // Convergence fact (M8 decision 19): the
                                    // status query reports this as the last
                                    // typed outcome.
                                    // [impl->REQ-UPD-6]
                                    let _ = cache.record_last_outcome("staged");
                                    // REQ-UPD-4: gated on user confirmation by
                                    // default — the staged release surfaces as
                                    // a consent notif (D8's producer); apply
                                    // rides the existing self-update machinery
                                    // once confirmed. Full-auto writes nothing.
                                    let decision = decide(
                                        full_auto_update,
                                        most_recently_active(&paths.owlery),
                                    );
                                    let surface = NotifSurfacePolicy {
                                        subnets: subnets
                                            .subnets
                                            .iter()
                                            .map(|s| (s.name.clone(), s.hide_new_endpoints))
                                            .collect(),
                                        vis: VisibilityStore::load_from(&paths.vis),
                                    };
                                    let store = NotifStore::open_at(&paths.notif_db)
                                        .map_err(|e| io::Error::other(e.to_string()))?;
                                    // The consent notif's id MUST mint from the
                                    // node's CANONICAL epoch (shared with the
                                    // registry leg + the CLI notify front
                                    // door), NOT `ops` (the stream-open op
                                    // counter, a separate file) — else two
                                    // counters issue the same `node:epoch`
                                    // notif_id and collide (the rig
                                    // presence-rung UNIQUE failure).
                                    // [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]
                                    let produced = registry.with_epoch(|ep| {
                                        produce_consent_notif(
                                            &decision,
                                            &version,
                                            &store,
                                            registry.node_hex(),
                                            ep,
                                            &sub.name,
                                            &surface,
                                            &paths.owlery,
                                            now_ms(),
                                        )
                                    });
                                    if let Err(e) = produced {
                                        eprintln!("UPDATE_NOTIF_FAIL:{e}");
=== cmd_update_apply ===
/// `spt update apply` — the explicit ack named by the update-consent notif.
// [impl->REQ-UPD-4]
// [impl->REQ-UPD-3]
fn cmd_update_apply() -> i32 {
    if let Err(code) = ensure_daemon_announced() {
        return code;
    }
    let exe = match std::env::current_exe() {
        Ok(p) => p,
        Err(e) => {
            eprintln!("CURRENT_EXE_FAIL: {e}");
            return 1;
        }
    };
    let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join("releases"));
    let keys = perch::identity_dir().join("release-keys.json");
    match spt_daemon::apply_staged(&cache, &keys, &exe, &spt_daemon::broker_socket_name()) {
        Ok(spt_daemon::ApplyStagedOutcome::NoUpdate) => {
            println!("NO_UPDATE");
            0
        }
        Ok(spt_daemon::ApplyStagedOutcome::Applied { version }) => {
            println!("APPLIED:{version}");
            0
        }
        Ok(spt_daemon::ApplyStagedOutcome::RefusedClass(class)) => {
            eprintln!("UPDATE_APPLY_REFUSED:{class:?} (full-swap orchestration is not M5 scope)");
            1
        }
        Ok(spt_daemon::ApplyStagedOutcome::Rejected(reason)) => {
            eprintln!("UPDATE_APPLY_REJECTED:{reason}");
            1
        }
        Err(e) => {
            eprintln!("UPDATE_APPLY_FAIL:{e}");
            1
        }
    }
}

/// Epoch milliseconds (the notif surfacing clock).
fn now_ms() -> u64 {
    std::time::SystemTime::now()


Confirmed — the mapping checks out. Here's the answer:

## Staging is the daemon's job, not the CLI's

`spt update apply` is **only the last step** — the consent ack. The staging happens automatically inside the running daemon, over P2P. There's no `spt update check`/`fetch`/`download` command because the daemon does it on its own cadence.

### How a staged update comes about

**1. Pull, on a timer.** The daemon's peer pump loop checks "is an update due?" each cycle (`cadence.update` = `update_check_period` from `DaemonConfig`). When due, for every roster peer it calls `request_update(...)` (`peerloop.rs:490`) → sends `UpdRecord::Query` carrying its own running version + pinned channel (`propagate.rs`). **Pull-based** (REQ-UPD-1): a node asks peers "got anything newer on my channel?" — peers don't push.

**2. Peer offers if it has newer.** The responder's `serve_update()` checks the asker is a roster member (fail-closed), and only offers if staged version > asker's version on the same channel. Then ships the binary in 64KB chunks.

**3. Verify — twice.** Metadata gate *before* fetching bytes (signature against the two-key anchor `BUILTIN_RELEASE_KEYS`, channel, expiry, rollback floor), then `plan_verified()` re-runs the full gate on the *actual downloaded bytes* incl. SHA-256 match (`update.rs:157`, `release.rs:verify_artifact`). No inherited trust from the peer.

**4. Stage = write to disk.** Only after verify passes: `cache.stage(...)` writes the artifact to `{SPT_HOME}/releases/` (artifact first, `release.json` metadata last = atomic commit point, `relcache.rs:118`). **This is the "staged" state** `apply` later reads. Daemon logs `UPDATE_STAGED:<ver>` and records outcome `"staged"`.

**5. Notify (or auto-apply).** `decide(full_auto_update, …)` (`peerloop.rs:518`):
- **default** (`full_auto_update=false`): mints an **update-consent notif** → you run `spt update apply`.
- **opt-in** `full_auto_update=true` in daemon config: skips the notif, applies immediately.

**6. Apply** (`cmd_update_apply`→`apply_staged`, `applyhost.rs`): re-verifies the staged bytes at rest, swaps the binary (`<exe>.old-<ver>` aside, atomic rename), then **brain handoff** — snapshot → drop → `Brain::handoff()` re-attach. Broker + PTY children survive, so **no endpoint dies** during update (REQ-UPD-3). Only `BrainOnly`-class updates apply; broker-breaking ones refuse and stay staged.

**7. Re-propagate.** Once a node has it staged, *it* becomes a source — next pump tick it serves the cached bytes to any peer still behind (REQ-UPD-1), and ripples registered adapters (REQ-UPD-5). The fleet converges by gossip.

### So, concretely
You never "stage" manually. A peer that already updated offers it → your daemon pulls + verifies + stages → you get a consent notif → `spt update apply`. The very first node in a fleet gets its staged release from a maintainer running the release-publish/seed path, then it spreads peer-to-peer.

**Knob:** set `full_auto_update = true` in daemon config to make the whole chain hands-free (stage→apply with no notif).

One gap worth noting: discovery is **peer-only** — there's no "phone home to GitHub" poll. A fleet's first node needs the update seeded to it (maintainer-side) before P2P can carry it onward.


so there's no way to leverage the seamless update flow to install the latest version (from github) using a command within spt? let's plan for it and add that to the DEFERRED doc, then.


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


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


Right — discovery is P2P-only; no GitHub-source bootstrap inside `spt`. That's a real gap (every fleet's first node needs a maintainer seed). Let me find the DEFERRED doc + ground the plan in the existing verify/stage machinery.


cd "C:/Users/decid/Documents/projects/spt-core" && echo "=== deferred doc ==="; git ls-files | grep -iE "defer" ; echo "=== installer fetch URL pattern ==="; grep -nE "github|spt-releases|releases/download|SHA256SUMS|curl|Invoke-WebRequest|https://" installer/install.sh installer/install.ps1 2>/dev/null | head -20

find deferred doc + installer fetch pattern


=== deferred doc ===
docs/DEFERRED.md
=== installer fetch URL pattern ===
installer/install.sh:4:#   curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
installer/install.sh:9:# against the release's SHA256SUMS (full ed25519 verification is `spt update`'s
installer/install.sh:15:#   SPT_INSTALL_REPO       owner/repo             (default: SaberMage/spt-releases)
installer/install.sh:17:#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
installer/install.sh:25:REPO="${SPT_INSTALL_REPO:-SaberMage/spt-releases}"
installer/install.sh:39:command -v curl >/dev/null 2>&1 || die "curl is required"
installer/install.sh:51:# ---- fetch: binary + SHA256SUMS ------------------------------------------
installer/install.sh:57:      http://*|https://*) curl -fsSL "$SPT_INSTALL_ASSET_BASE/$1" -o "$2" ;;
installer/install.sh:61:    curl -fsSL "https://github.com/$REPO/releases/download/$SPT_INSTALL_VERSION/$1" -o "$2"
installer/install.sh:63:    curl -fsSL "https://github.com/$REPO/releases/latest/download/$1" -o "$2"
installer/install.sh:69:fetch "SHA256SUMS" "$WORK/SHA256SUMS"
installer/install.sh:72:WANT="$(awk -v a="$ASSET" '$2 == a { print $1 }' "$WORK/SHA256SUMS")"
installer/install.sh:73:[ -n "$WANT" ] || die "SHA256SUMS has no entry for $ASSET"
installer/install.ps1:3:#   irm https://sabermage.github.io/spt-releases/install.ps1 | iex
installer/install.ps1:8:# against the release's SHA256SUMS (full ed25519 verification is `spt update`'s
installer/install.ps1:17:#   SPT_INSTALL_REPO       owner/repo           (default: SaberMage/spt-releases)
installer/install.ps1:19:#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
installer/install.ps1:38:$repo = if ($env:SPT_INSTALL_REPO) { $env:SPT_INSTALL_REPO } else { 'SaberMage/spt-releases' }
installer/install.ps1:56:    # ---- fetch: binary + SHA256SUMS ----------------------------------------
installer/install.ps1:63:                Invoke-WebRequest -Uri "$base/$name" -OutFile $dest -UseBasicParsing


`docs/DEFERRED.md` exists; installer already fetches from `github.com/SaberMage/spt-releases/releases/latest/download/<asset>` + `SHA256SUMS` + the `.release.json` metadata. Read the deferred doc's format + the verify-gate entry points to ground the reuse:


1	# Deferred Features
2
3	Intended features explicitly cut from v1 scope but committed for later. Not backlog noise — these are decided directions parked for sequencing reasons. Keep this list short and real.
4
5	| Feature | Cut from | Why deferred | Trigger to revisit |
6	|---|---|---|---|
7	| Scrollback on-disk spillover | terminal wrapper v1 | In-memory ring covers the common case; spillover adds a persistence/rotation story | First long-running session that overflows the ring usefully, or any "scroll back further than the buffer" user need |
8	| Sidecar adapter process (long-running, wire-protocol) | harness contract v1 | Manifest + `spt.exe` subcommand surface covers v1 harnesses; sidecar only earns its keep for streaming / in-memory cross-event state | A harness outgrows manifest+hooks (needs streaming or stateful coordination) |
9	| Manifest `include` / composition (option C) | manifest v1 | Single flat manifest per harness is enough until a harness wants to override only part of a section | First sub-project that needs to inherit spt-core defaults but override one section |
10	| Concrete Shell types (GameRobot, in-session-inject) — **OS-notification shell carved out: ships M5** as the dogfood proof (user decision 2026-06-04, M5-PLAN §Scope decisions) | Shell concept v1 | Model is locked; concrete surfaces are large, platform-specific, and not needed for core | First real use case for a driven surface (e.g. the 2D-world experiment) |
11	| PresenceChannel implementation (broker, dispatch/bind/thread) | endpoint types v1 | Depends on presence gossip + Shells + multi-instance routing; only the seams ship v1. **M5 ships presence *resolution* (gossip + most-recently-active API) — its future substrate; the endpoint stays deferred** (user decision 2026-06-04). <!-- [doc->REQ-PRES-1] --> **M5-D6 seam note:** the substrate now exists — the gossiped datum (`Instance.last_active_ms`, riding the registry's epoch lease + replication) and the one MRA API (`spt-daemon::presence`). A future PresenceChannel's dispatch/bind/thread styles consume exactly this datum plus the broker PresenceLog seam (REQ-EP-4); nothing about the endpoint type needs a new gossip channel. | Once Shells exist and the subnet registry is stable |
12	| OS-level input-activity presence signal (7a-A) | presence v1 | Per-platform, privacy-loaded; agent-interaction heartbeat covers the v1 goal. **Note:** distinct from the v1 broker *sensing* user input on a PTY it already holds (spt-hosted) — that is in v1 and is NOT OS-wide input monitoring | Opt-in enhancement after agent-interaction presence proves out |
13	| Instantiate-anywhere + consent gate | Instances V1-mid | Remote launch over network is additive once remote-drive of running instances works. Consent *model* is now designed (CONTEXT §Consent & security gates); **the framework (grant store + escalation + pre-consent flags) ships M5 with this capability id reserved-but-refusing** (user decision 2026-06-04) — the capability lands into it later. Defers with it: the remote-fork arm, cross-node shell spawn, `shell_wake_spawn_anywhere` *behavior* (grant shape ships M5). <!-- [doc->REQ-INST-6] --> **M5-D5b note:** remote `spt suspend/wake <id@node>` shipped (the rest-op wire family, remote-drive trust class) — it *moves existing instances between rest states only*; the remote-**fork** arm (creating an instance on a node that has none) stays here, riding instantiate-anywhere's consent gate. A wake addressed to a node with no local instance refuses with `WAKE_NO_REACHABLE_INSTANCE` naming this deferral. | Revisit-trigger met (remote-drive shipped M4); held back by user decision — capability drops into the M5 consent framework when picked up |
14	| Remote command execution on another node | off-node reach-back v1 | Highest-risk capability; needs the consent/security gate. Workarounds exist (message a local agent; interact with local agent directly). **M5 ships the framework with this capability id reserved-but-refusing** (user decision 2026-06-04) | Designed alongside instantiate-anywhere's consent gate |
15	| Shell-binary (and harness-binary) sandboxing | Shell concept v1 | A capability toolset bounds what the *agent* can ask, not what the *binary* can do with its OS perms. Baseline stance: running any adapter/shell binary = a disclosed, accepted trust risk for all spt-core users | If/when untrusted third-party shell/harness binaries become common enough to warrant OS-level sandboxing |
16	| Concrete Shell adapters (e.g. GameRobot) + the shell binaries themselves | Shell concept v1 | The Shell *model*, manifest schema, perch layout, and `api` surface are now specified; concrete shells are large, platform-specific, and not needed for core | First real driven-surface use case |
17	| REQ-CONSENT int evidence (interactive escalation E2E) | M5 closeout (D9b rule-5) | The escalation prompt''s end-to-end leg needs a real harness session answering a real consent prompt — loopback-faking it would prove nothing; the grant store + gate + CLI are impl/unit-proven | The downstream rebuilt spt plugin (v1 acceptance) — its first gated spawn IS the int evidence |
18	| REQ-INSTALL-4 int evidence (`spt adapter add --github` against a real repo) | M5 closeout (D9b rule-5) | Needs a real clone target; impl/unit cover manifest-first validation + registration. The standalone `SaberMage/spt-shell-notify` repo now makes this trivially possible | First milestone that touches adapter lifecycle again — one E2E cloning the notify repo |
19	| OS-service registration at install (REQ-INSTALL-1's third leg: systemd user service / Windows service or scheduled task for the always-on guarantee) | installer v0.1 (M6-D2, grill decision 3) | Daemon auto-start on any `spt` invocation covers dev-stage use; the minimal non-interactive script stays non-OS-entangled (also serves REQ-INSTALL-2's repackaging stance). Honest gap: a node is unreachable after reboot until something invokes `spt` | First always-on deployment need (headless/server node, or the GUI milestone's background expectations) |
20	| Tier 2 agent-docs: MCP doc/resource server + `spt <cmd> --help --json` structured-help mode (REQ-DOCS-4 legs) | docs v0.1 (M6-D3/D5, DOCS-STRATEGY §v0.1 grill) | Tier 1 covers the integration surface: the schemars-derived manifest schema + llms.txt/llms-full.txt + the generated CLI reference; `--help --json` is low marginal value over that reference, the MCP server is the standout *later* fit | First integrating dev-agent that outgrows llms.txt + schema (or the `spt-claude-code` work surfacing a concrete MCP-resource need) |
21	| Subnet attachment verbs: `spt subnet detach <NAME> [--auto]` / `attach <NAME> [--auto]` (daemon keeps running, stops/starts advertising + connecting for that subnet; `--auto` persists the startup default), `spt subnet leave <NAME>` (elevation-gated), maybe `spt subnet disband <NAME>` (elevation + current TOTP) | M7 grill (user spec 2026-06-06; CONTEXT §subnet attachment) | The attached/detached *term* + the all-attached banner ship M7; the per-subnet serve-state machinery (selective advertise/connect, persisted flags) is its own deliverable | M8 CLI nounification, or first multi-subnet user needing selective participation |
22	| Linux elevation model (user-ratified direction 2026-06-06): (1) install symlinks the binary into a sudo-reachable path (e.g. `/usr/local/bin`) so `sudo spt` resolves; (2) first `sudo spt` detects elevation and prompts once for the DEFAULT USER ACCOUNT — thereafter any `sudo spt` daemon launch always runs the daemon (and state) under that account, never root (kills the `sudo HOME=$HOME` dance and the root-owned-state hazard) | M7 acceptance run (gravity: `sudo: spt: command not found`, then the HOME-universe split) | Needs installer + first-run-state design; Windows elevation has no equivalent problem (same user profile) | M8, with the installer's next touch |
23	| Windows firewall registration for the installed binary: a DETACHED daemon never shows the first-bind firewall prompt, so inbound UDP (mDNS + QUIC meet/pairing) is silently dropped — found as the M7 acceptance NO_SEED_HOLDER (dev/test paths had accumulated allow rules; the install path had none). Options: an elevated install leg adds the rule (`New-NetFirewallRule`), and/or the daemon self-detects blocked inbound and renders it as the "no connection" state in `subnet status` + the coming-online banner | M7 acceptance run (2026-06-06) | User-scope installer is deliberately non-OS-entangled (REQ-INSTALL-2); needs the elevated-install / OS-service design anyway | M8, with the OS-service + Linux-elevation install work |
24	| ~~**Non-admin daemon spawn (both OSes)**~~ **SHIPPED 2026-06-06** (KNOWN-HAZARDS 5.7, REQ-HAZARD-ELEVATED-DAEMON-SPAWN): de-elevated spawn at `spawn_detached` (Windows: UAC linked token via `CreateProcessWithTokenW`; Linux: drop child to SUDO_UID/GID) + `Daemon::run` entry guard + the unix `spt` main-entry sudo drop (whole process re-anchored to the invoker's universe, elevation kept PROVEN for the gated commands). Remaining Linux follow-up = the install-symlink + default-account election row below (M8) | M7 acceptance run (2026-06-06) | The membership-implies-reachability activation made elevated commands daemon-spawners — correct goal, wrong token | DONE (real elevated-spawn verification on both OSes rides the next acceptance pass) |
25	| ~~**Ghost registry row eviction**~~ **SHIPPED 2026-06-06** (KNOWN-HAZARDS 4.10, REQ-HAZARD-REGISTRY-GHOST-ROWS): silent-peer decay — rows from a node unheard for `registry_evict_after_ms` (default 300s ≈ 10 pump cadences) evict on the registry pump tick, snapshots rewritten; own rows never decay. STALE trust rows for a dead identity still need a manual prune verb (below) | M7 acceptance run (2026-06-06) | Needs a decay/eviction policy (e.g. drop rows unrefreshed for N pump cadences; trust-row pruning verb) | DONE (fleet cleanup: 09ef831e ghost rows on both BIGNET nodes decay once the new build deploys) |
26	| Trust-row prune verb (`spt subnet prune <node>` or similar): registry GHOST rows now decay (4.10), but the dead identity's TRUST rows persist by design (trust is a user decision) — they cost dead dials every pump tick and clutter status views. BIGNET carries two such rows (09ef831e, both sides) | Ghost-eviction fix (2026-06-06) | Trust mutation = security surface; wants the elevation gate + the M8 noun shapes | M8 CLI nounification |
27	| Elevated spt-hosted endpoints — a consented elevation satellite: the broker lives inside the always-unelevated daemon (KH 5.7), so spt-hosted children are unelevated by construction; there is NO silent unelevated→elevated path on either OS (that is UAC/sudo's whole point). An agent needing admin powers is harness-hosted-elevated today (works: TCP listeners + talk-down pipes). The spt-hosted form needs a per-grant CONSENTED elevation: UAC prompt / polkit spawns a small elevated PTY-host satellite holding only the elevated children, linked back to the unelevated daemon — rides the REQ-CONSENT-2 escalation framework (allow-once/always) + the reserved-but-refusing capability pattern | Elevated-endpoint design grill (user 2026-06-06) | Real elevation-granting UX + a second privileged process = its own deliverable; the consent framework it rides already ships | First spt-hosted agent that needs admin powers (or the instantiate-anywhere milestone) |
28	| ~~AMBIGUOUS render~~ **SHIPPED 2026-06-06** (rode the ghost-eviction fix): resolution refusals render valid copy-paste targets at the wansend boundary (`render_refusal`) — labels preferred, key-prefix on label collision, `subnet:id` for cross-subnet | M7 acceptance run (user 2026-06-06) | Pure render fix at the wansend/CLI boundary; carries into the M8 nounification sweep | DONE |
29	| Post-join address seeding: the pairing ceremony holds a live, authenticated connection to the peer, but the peer pumps then re-find each other from scratch via id-only discovery (fresh node ids must propagate to n0 DNS first) — observed live as ~1 min to first `status --nodes` convergence after a join. Seed the pump's address cache from the ceremony connection (both sides) for instant first sync | M7 acceptance run (2026-06-06) | Convergence is correct, just slow on first contact; needs an addr-cache seam the pumps consult before the resolver | M8 polish |
30	| Clock source priority for TOTP/rendezvous: query a well-known world clock (NTP) FIRST, fall back to local time — machines with skewed local clocks currently derive wrong steps/rendezvous tokens silently | M7 acceptance grill (user 2026-06-06) | Needs an NTP client dep + offset caching + offline fallback semantics; ±1 window covers small skew today | First field report of clock-skew join failures, or M8 polish |
31	| CLI nounification (M8 candidate; ratified shape from the 2026-06-06 grill): `spt endpoint` namespace absorbs fork/suspend/wake/shutdown/rename/stop/digest + `access` (per-endpoint store — NOT subnet-scoped) + `description` (ex-`resources` blurb; bare = show, `set` = author); `spt endpoint list [--local\|--subnet <name>]` = merged listing, default all-subnets grouped by subnet, SELF endpoint pinned distinctly at top (bare `spt endpoint` = this list); agent HOT PATH stays flat (`send`/`ring`/`ready`/`whoami`/`how-to`); `notify` → `spt subnet notify`; `notif` stays top-level (user-recall, cross-subnet); `spt daemon stop\|status` noun (hidden `daemon` → `daemon run`); agent-endpoint `shutdown` keeps its name | M7 grill (2026-06-06) | Second surface-breaking reorg mid-acceptance; adapter window still open immediately after M7 closes | M8 planning — mint REQs first (rule 3) |
32	| ~~Node label for an endpoint-less node (REQ-SUBNET-3 gap)~~ **SHIPPED 2026-06-08** (option 2 — node-level presence datum): a peer with **zero endpoints** rendered as a bare key-prefix in `status --nodes` because `node_label` only rode endpoint `Instance` rows. FIX: a node-LEVEL carrier — `SubnetRegistry.node_labels` (node→label) + a `NodeLabelUpdate` feed record riding the existing registry replication stream as an untagged `RegistryFeedRecord` variant (instance bytes unchanged → mixed-version fleet safe, old peers skip the new lines). Merged under the same epoch lease, evicted with the node, gated identically to instance feeds. The pump emits one label record per served subnet regardless of endpoint count; `node_status_rows` reads it to NAME an endpoint-less peer without counting it as an endpoint (`[0/0]` holds, no liveness effect). Chose option 2 over option 1 (trust-pin label at pairing) to avoid touching the SPAKE2 ceremony + un-dormanting warn-on-change (which couples with the REQ-SUBNET-7 machine_id re-pair overwrite) — option 2 is also live-updating on hostname change | M8 criterion-5 acceptance run (HFENDULEAM↔kitsubito, 2026-06-07; HF at `[0/0]` showed bare uuid to kitsubito) | Cosmetic; deferred per user decision 2026-06-07, fixed 2026-06-08 (user picked option 2) | DONE (real-hardware verify rides the next acceptance/deploy pass) |
33	| **Instant + never-seen peer hostname via pairing-time capture (REQ-SUBNET-3 follow-up; the deferred "option 1")**: the shipped node-level label (option 2) is GOSSIP-ONLY, so a peer's name is learned only from a pump round while it is alive. Two residual gaps remain after the eviction fix below: (a) ~1 gossip cadence to appear after a join (the joiner trust-pins by pubkey with `label=None`; the hostname arrives next pump round — enlyzeam↔kitsubito converged to `ENLYZEAM` after a cadence, fine but not instant); (b) a peer that stops WITHIN a cadence of pairing is never named (its label never gossiped). FIX: capture the peer hostname **at pairing → the persistent trust-pin label**. Needs (1) BIDIRECTIONAL pairing intro — the joiner→responder `NodeIntro` (label+machine_id) already exists; add responder→joiner so BOTH sides capture each other's hostname; (2) wire `trust.record`'s `label` = peer hostname at both pin sites (`pairing/wire.rs` ~324 responder, ~435 initiator, both currently `None`); (3) handle the warn-on-change coupling with the REQ-SUBNET-7 machine_id re-pair overwrite (a legit machine reinstall = same machine_id, new key → repin, not a silent fail). **NOTE — the OFFLINE-loses-name half was a real bug, FIXED 2026-06-08** (not this deferred row): a *learned* node_label was being silence-decayed by `evict_nodes`, so an offline trusted member lost its name; `evict_nodes` now keeps labels (silence ≠ forget) and only an explicit `spt subnet prune` drops them (`evict_node_labels`). This row is the remaining INSTANT + NEVER-SEEN half | enlyzeam/kitsubito SPT_DEV diagnosis (user 2026-06-08) | The instant/never-seen half of REQ-SUBNET-3; pairs with the "post-join address seeding" row (same ceremony-connection seam, seed label + address both sides) — touches SPAKE2, so its own focused milestone | Next session — mint REQ for the trust-pin-label capture + bidirectional intro |
34	| ~~Subnet-scoped liveness probe (REQ-SUBNET-5 detach gap)~~ **SHIPPED 2026-06-08** (REQ-SUBNET-5 int stage): `subnet status --nodes` showed a DETACHED peer as **online** because liveness fell to `Probe` → a raw transport dial by node-pubkey, which succeeded on the peer's still-bound daemon endpoint (one ALPN `spt-core/net/0` serves all subnets, so the dial was subnet-blind). FIX: a new `ServeProbeRecord` (spt-net `serveprobe`) + daemon `serveprobe` handler/requester ask the peer "serving subnet X?", answered from its own `AttachmentStore::is_detached` ∧ membership (the same signal the pump/responder gate on); the dispatcher classifies it by the `serve_probe` first-line field; `wansend::probe_node_serving` dials (proves reachable) THEN asks, so a detached-but-alive peer reads offline within a cadence. Probe LATENCY had shipped separately 2026-06-07 (`run_bounded` 2.5s + "Checking remote nodes…" notice). int evidence: `dispatcher_serves_a_subnet_serve_probe` (loopback E2E) | M8 criterion-5 acceptance run (HFENDULEAM↔kitsubito, 2026-06-07) | Real liveness-model fix on accepted M7 hybrid-liveness; deferred to keep acceptance moving (user decision 2026-06-07) | DONE (criterion-5 detach-flip now GREEN; real-hardware re-verify rides the next acceptance/deploy pass) |
35	| **Subnet full-mesh visibility (PLANNED MILESTONE — `SUBNET-MESH-PLAN.md`)**: non-directly-paired members never see each other — a subnet is currently the PAIRING GRAPH, not a mesh (A↔B, C↔B; B offline → A and C invisible; even with B online, B doesn't relay). Root: pairwise TOFU trust (`trust.rs`) + own-rows-only gossip / no transitive relay (`peerloop.rs`, KH 4.10/7.5) + no member roster in the pairing seed transfer. FIX = member-AUTHENTICATED subnet data (seed-MAC and/or per-node signatures) unlocking transitive trust (seed-holder = member = trusted) + transitive gossip relay + roster propagation; MUST re-solve eviction/epoch-lease under relay (supersedes KH 4.10's "no transitive gossip" safety assumption). ADR-level; mint REQ-MESH-* first. Folds in the deferred pairing-time hostname capture + post-join address seeding (same roster/ceremony seam) | A→B→C invisibility test (user 2026-06-08) — confirmed two-gap design limitation, not a code defect | **DESIGN RATIFIED 2026-06-08** (grill): symmetric **seed-proof membership** + **roster-only relay** (rows stay own-authored, no third-party relay → KH 4.10/7.5 PRESERVED, not superseded). Full design = **ADR-0017**; reqs = **REQ-MESH-1..6** (inactive); plan = `SUBNET-MESH-PLAN.md`; glossary = CONTEXT §Pairing & trust. Folds in the pairing-time hostname-capture + post-join address-seeding rows (roster fields). Next: plan-phase over REQ-MESH-* | Next milestone — design done 2026-06-08; build pending plan-phase |
36	| `xtask debug-converge` — automated convergence watcher for debug rollout (the REQ-UPD-6 first-slice item ADR-0016 names): watch every expected debug-pinned reachable lab node until all report the target version applied, else a per-node timeout table (Offline / StagedAwaitingConsent / BlockedByBrokerResources / Rejected{reason}). Activates REQ-UPD-6's `int` stage. Full build plan: `docs/DEBUG-CONVERGE-PLAN.md` | REQ-UPD-6 first slice (2026-06-06) | The first slice ships manual "Apply and observe" (`DEBUG-ROLLOUT.md`); the watcher's one genuinely new surface is a node status-query wire leg (no-fetch `{channel, applied_version, last_outcome}` over the existing update handshake) — assembly otherwise | First multi-node debug rollout where hand-walking nodes is the bottleneck, or M8 update polish |
37

# Deferred Features

Intended features explicitly cut from v1 scope but committed for later. Not backlog noise — these are decided directions parked for sequencing reasons. Keep this list short and real.

| Feature | Cut from | Why deferred | Trigger to revisit |
|---|---|---|---|
| Scrollback on-disk spillover | terminal wrapper v1 | In-memory ring covers the common case; spillover adds a persistence/rotation story | First long-running session that overflows the ring usefully, or any "scroll back further than the buffer" user need |
| Sidecar adapter process (long-running, wire-protocol) | harness contract v1 | Manifest + `spt.exe` subcommand surface covers v1 harnesses; sidecar only earns its keep for streaming / in-memory cross-event state | A harness outgrows manifest+hooks (needs streaming or stateful coordination) |
| Manifest `include` / composition (option C) | manifest v1 | Single flat manifest per harness is enough until a harness wants to override only part of a section | First sub-project that needs to inherit spt-core defaults but override one section |
| Concrete Shell types (GameRobot, in-session-inject) — **OS-notification shell carved out: ships M5** as the dogfood proof (user decision 2026-06-04, M5-PLAN §Scope decisions) | Shell concept v1 | Model is locked; concrete surfaces are large, platform-specific, and not needed for core | First real use case for a driven surface (e.g. the 2D-world experiment) |
| PresenceChannel implementation (broker, dispatch/bind/thread) | endpoint types v1 | Depends on presence gossip + Shells + multi-instance routing; only the seams ship v1. **M5 ships presence *resolution* (gossip + most-recently-active API) — its future substrate; the endpoint stays deferred** (user decision 2026-06-04). <!-- [doc->REQ-PRES-1] --> **M5-D6 seam note:** the substrate now exists — the gossiped datum (`Instance.last_active_ms`, riding the registry's epoch lease + replication) and the one MRA API (`spt-daemon::presence`). A future PresenceChannel's dispatch/bind/thread styles consume exactly this datum plus the broker PresenceLog seam (REQ-EP-4); nothing about the endpoint type needs a new gossip channel. | Once Shells exist and the subnet registry is stable |
| OS-level input-activity presence signal (7a-A) | presence v1 | Per-platform, privacy-loaded; agent-interaction heartbeat covers the v1 goal. **Note:** distinct from the v1 broker *sensing* user input on a PTY it already holds (spt-hosted) — that is in v1 and is NOT OS-wide input monitoring | Opt-in enhancement after agent-interaction presence proves out |
| Instantiate-anywhere + consent gate | Instances V1-mid | Remote launch over network is additive once remote-drive of running instances works. Consent *model* is now designed (CONTEXT §Consent & security gates); **the framework (grant store + escalation + pre-consent flags) ships M5 with this capability id reserved-but-refusing** (user decision 2026-06-04) — the capability lands into it later. Defers with it: the remote-fork arm, cross-node shell spawn, `shell_wake_spawn_anywhere` *behavior* (grant shape ships M5). <!-- [doc->REQ-INST-6] --> **M5-D5b note:** remote `spt suspend/wake <id@node>` shipped (the rest-op wire family, remote-drive trust class) — it *moves existing instances between rest states only*; the remote-**fork** arm (creating an instance on a node that has none) stays here, riding instantiate-anywhere's consent gate. A wake addressed to a node with no local instance refuses with `WAKE_NO_REACHABLE_INSTANCE` naming this deferral. | Revisit-trigger met (remote-drive shipped M4); held back by user decision — capability drops into the M5 consent framework when picked up |
| Remote command execution on another node | off-node reach-back v1 | Highest-risk capability; needs the consent/security gate. Workarounds exist (message a local agent; interact with local agent directly). **M5 ships the framework with this capability id reserved-but-refusing** (user decision 2026-06-04) | Designed alongside instantiate-anywhere's consent gate |
| Shell-binary (and harness-binary) sandboxing | Shell concept v1 | A capability toolset bounds what the *agent* can ask, not what the *binary* can do with its OS perms. Baseline stance: running any adapter/shell binary = a disclosed, accepted trust risk for all spt-core users | If/when untrusted third-party shell/harness binaries become common enough to warrant OS-level sandboxing |
| Concrete Shell adapters (e.g. GameRobot) + the shell binaries themselves | Shell concept v1 | The Shell *model*, manifest schema, perch layout, and `api` surface are now specified; concrete shells are large, platform-specific, and not needed for core | First real driven-surface use case |
| REQ-CONSENT int evidence (interactive escalation E2E) | M5 closeout (D9b rule-5) | The escalation prompt''s end-to-end leg needs a real harness session answering a real consent prompt — loopback-faking it would prove nothing; the grant store + gate + CLI are impl/unit-proven | The downstream rebuilt spt plugin (v1 acceptance) — its first gated spawn IS the int evidence |
| REQ-INSTALL-4 int evidence (`spt adapter add --github` against a real repo) | M5 closeout (D9b rule-5) | Needs a real clone target; impl/unit cover manifest-first validation + registration. The standalone `SaberMage/spt-shell-notify` repo now makes this trivially possible | First milestone that touches adapter lifecycle again — one E2E cloning the notify repo |
| OS-service registration at install (REQ-INSTALL-1's third leg: systemd user service / Windows service or scheduled task for the always-on guarantee) | installer v0.1 (M6-D2, grill decision 3) | Daemon auto-start on any `spt` invocation covers dev-stage use; the minimal non-interactive script stays non-OS-entangled (also serves REQ-INSTALL-2's repackaging stance). Honest gap: a node is unreachable after reboot until something invokes `spt` | First always-on deployment need (headless/server node, or the GUI milestone's background expectations) |
| Tier 2 agent-docs: MCP doc/resource server + `spt <cmd> --help --json` structured-help mode (REQ-DOCS-4 legs) | docs v0.1 (M6-D3/D5, DOCS-STRATEGY §v0.1 grill) | Tier 1 covers the integration surface: the schemars-derived manifest schema + llms.txt/llms-full.txt + the generated CLI reference; `--help --json` is low marginal value over that reference, the MCP server is the standout *later* fit | First integrating dev-agent that outgrows llms.txt + schema (or the `spt-claude-code` work surfacing a concrete MCP-resource need) |
| Subnet attachment verbs: `spt subnet detach <NAME> [--auto]` / `attach <NAME> [--auto]` (daemon keeps running, stops/starts advertising + connecting for that subnet; `--auto` persists the startup default), `spt subnet leave <NAME>` (elevation-gated), maybe `spt subnet disband <NAME>` (elevation + current TOTP) | M7 grill (user spec 2026-06-06; CONTEXT §subnet attachment) | The attached/detached *term* + the all-attached banner ship M7; the per-subnet serve-state machinery (selective advertise/connect, persisted flags) is its own deliverable | M8 CLI nounification, or first multi-subnet user needing selective participation |
| Linux elevation model (user-ratified direction 2026-06-06): (1) install symlinks the binary into a sudo-reachable path (e.g. `/usr/local/bin`) so `sudo spt` resolves; (2) first `sudo spt` detects elevation and prompts once for the DEFAULT USER ACCOUNT — thereafter any `sudo spt` daemon launch always runs the daemon (and state) under that account, never root (kills the `sudo HOME=$HOME` dance and the root-owned-state hazard) | M7 acceptance run (gravity: `sudo: spt: command not found`, then the HOME-universe split) | Needs installer + first-run-state design; Windows elevation has no equivalent problem (same user profile) | M8, with the installer's next touch |
| Windows firewall registration for the installed binary: a DETACHED daemon never shows the first-bind firewall prompt, so inbound UDP (mDNS + QUIC meet/pairing) is silently dropped — found as the M7 acceptance NO_SEED_HOLDER (dev/test paths had accumulated allow rules; the install path had none). Options: an elevated install leg adds the rule (`New-NetFirewallRule`), and/or the daemon self-detects blocked inbound and renders it as the "no connection" state in `subnet status` + the coming-online banner | M7 acceptance run (2026-06-06) | User-scope installer is deliberately non-OS-entangled (REQ-INSTALL-2); needs the elevated-install / OS-service design anyway | M8, with the OS-service + Linux-elevation install work |
| ~~**Non-admin daemon spawn (both OSes)**~~ **SHIPPED 2026-06-06** (KNOWN-HAZARDS 5.7, REQ-HAZARD-ELEVATED-DAEMON-SPAWN): de-elevated spawn at `spawn_detached` (Windows: UAC linked token via `CreateProcessWithTokenW`; Linux: drop child to SUDO_UID/GID) + `Daemon::run` entry guard + the unix `spt` main-entry sudo drop (whole process re-anchored to the invoker's universe, elevation kept PROVEN for the gated commands). Remaining Linux follow-up = the install-symlink + default-account election row below (M8) | M7 acceptance run (2026-06-06) | The membership-implies-reachability activation made elevated commands daemon-spawners — correct goal, wrong token | DONE (real elevated-spawn verification on both OSes rides the next acceptance pass) |
| ~~**Ghost registry row eviction**~~ **SHIPPED 2026-06-06** (KNOWN-HAZARDS 4.10, REQ-HAZARD-REGISTRY-GHOST-ROWS): silent-peer decay — rows from a node unheard for `registry_evict_after_ms` (default 300s ≈ 10 pump cadences) evict on the registry pump tick, snapshots rewritten; own rows never decay. STALE trust rows for a dead identity still need a manual prune verb (below) | M7 acceptance run (2026-06-06) | Needs a decay/eviction policy (e.g. drop rows unrefreshed for N pump cadences; trust-row pruning verb) | DONE (fleet cleanup: 09ef831e ghost rows on both BIGNET nodes decay once the new build deploys) |
| Trust-row prune verb (`spt subnet prune <node>` or similar): registry GHOST rows now decay (4.10), but the dead identity's TRUST rows persist by design (trust is a user decision) — they cost dead dials every pump tick and clutter status views. BIGNET carries two such rows (09ef831e, both sides) | Ghost-eviction fix (2026-06-06) | Trust mutation = security surface; wants the elevation gate + the M8 noun shapes | M8 CLI nounification |
| Elevated spt-hosted endpoints — a consented elevation satellite: the broker lives inside the always-unelevated daemon (KH 5.7), so spt-hosted children are unelevated by construction; there is NO silent unelevated→elevated path on either OS (that is UAC/sudo's whole point). An agent needing admin powers is harness-hosted-elevated today (works: TCP listeners + talk-down pipes). The spt-hosted form needs a per-grant CONSENTED elevation: UAC prompt / polkit spawns a small elevated PTY-host satellite holding only the elevated children, linked back to the unelevated daemon — rides the REQ-CONSENT-2 escalation framework (allow-once/always) + the reserved-but-refusing capability pattern | Elevated-endpoint design grill (user 2026-06-06) | Real elevation-granting UX + a second privileged process = its own deliverable; the consent framework it rides already ships | First spt-hosted agent that needs admin powers (or the instantiate-anywhere milestone) |
| ~~AMBIGUOUS render~~ **SHIPPED 2026-06-06** (rode the ghost-eviction fix): resolution refusals render valid copy-paste targets at the wansend boundary (`render_refusal`) — labels preferred, key-prefix on label collision, `subnet:id` for cross-subnet | M7 acceptance run (user 2026-06-06) | Pure render fix at the wansend/CLI boundary; carries into the M8 nounification sweep | DONE |
| Post-join address seeding: the pairing ceremony holds a live, authenticated connection to the peer, but the peer pumps then re-find each other from scratch via id-only discovery (fresh node ids must propagate to n0 DNS first) — observed live as ~1 min to first `status --nodes` convergence after a join. Seed the pump's address cache from the ceremony connection (both sides) for instant first sync | M7 acceptance run (2026-06-06) | Convergence is correct, just slow on first contact; needs an addr-cache seam the pumps consult before the resolver | M8 polish |
| Clock source priority for TOTP/rendezvous: query a well-known world clock (NTP) FIRST, fall back to local time — machines with skewed local clocks currently derive wrong steps/rendezvous tokens silently | M7 acceptance grill (user 2026-06-06) | Needs an NTP client dep + offset caching + offline fallback semantics; ±1 window covers small skew today | First field report of clock-skew join failures, or M8 polish |
| CLI nounification (M8 candidate; ratified shape from the 2026-06-06 grill): `spt endpoint` namespace absorbs fork/suspend/wake/shutdown/rename/stop/digest + `access` (per-endpoint store — NOT subnet-scoped) + `description` (ex-`resources` blurb; bare = show, `set` = author); `spt endpoint list [--local\|--subnet <name>]` = merged listing, default all-subnets grouped by subnet, SELF endpoint pinned distinctly at top (bare `spt endpoint` = this list); agent HOT PATH stays flat (`send`/`ring`/`ready`/`whoami`/`how-to`); `notify` → `spt subnet notify`; `notif` stays top-level (user-recall, cross-subnet); `spt daemon stop\|status` noun (hidden `daemon` → `daemon run`); agent-endpoint `shutdown` keeps its name | M7 grill (2026-06-06) | Second surface-breaking reorg mid-acceptance; adapter window still open immediately after M7 closes | M8 planning — mint REQs first (rule 3) |
| ~~Node label for an endpoint-less node (REQ-SUBNET-3 gap)~~ **SHIPPED 2026-06-08** (option 2 — node-level presence datum): a peer with **zero endpoints** rendered as a bare key-prefix in `status --nodes` because `node_label` only rode endpoint `Instance` rows. FIX: a node-LEVEL carrier — `SubnetRegistry.node_labels` (node→label) + a `NodeLabelUpdate` feed record riding the existing registry replication stream as an untagged `RegistryFeedRecord` variant (instance bytes unchanged → mixed-version fleet safe, old peers skip the new lines). Merged under the same epoch lease, evicted with the node, gated identically to instance feeds. The pump emits one label record per served subnet regardless of endpoint count; `node_status_rows` reads it to NAME an endpoint-less peer without counting it as an endpoint (`[0/0]` holds, no liveness effect). Chose option 2 over option 1 (trust-pin label at pairing) to avoid touching the SPAKE2 ceremony + un-dormanting warn-on-change (which couples with the REQ-SUBNET-7 machine_id re-pair overwrite) — option 2 is also live-updating on hostname change | M8 criterion-5 acceptance run (HFENDULEAM↔kitsubito, 2026-06-07; HF at `[0/0]` showed bare uuid to kitsubito) | Cosmetic; deferred per user decision 2026-06-07, fixed 2026-06-08 (user picked option 2) | DONE (real-hardware verify rides the next acceptance/deploy pass) |
| **Instant + never-seen peer hostname via pairing-time capture (REQ-SUBNET-3 follow-up; the deferred "option 1")**: the shipped node-level label (option 2) is GOSSIP-ONLY, so a peer's name is learned only from a pump round while it is alive. Two residual gaps remain after the eviction fix below: (a) ~1 gossip cadence to appear after a join (the joiner trust-pins by pubkey with `label=None`; the hostname arrives next pump round — enlyzeam↔kitsubito converged to `ENLYZEAM` after a cadence, fine but not instant); (b) a peer that stops WITHIN a cadence of pairing is never named (its label never gossiped). FIX: capture the peer hostname **at pairing → the persistent trust-pin label**. Needs (1) BIDIRECTIONAL pairing intro — the joiner→responder `NodeIntro` (label+machine_id) already exists; add responder→joiner so BOTH sides capture each other's hostname; (2) wire `trust.record`'s `label` = peer hostname at both pin sites (`pairing/wire.rs` ~324 responder, ~435 initiator, both currently `None`); (3) handle the warn-on-change coupling with the REQ-SUBNET-7 machine_id re-pair overwrite (a legit machine reinstall = same machine_id, new key → repin, not a silent fail). **NOTE — the OFFLINE-loses-name half was a real bug, FIXED 2026-06-08** (not this deferred row): a *learned* node_label was being silence-decayed by `evict_nodes`, so an offline trusted member lost its name; `evict_nodes` now keeps labels (silence ≠ forget) and only an explicit `spt subnet prune` drops them (`evict_node_labels`). This row is the remaining INSTANT + NEVER-SEEN half | enlyzeam/kitsubito SPT_DEV diagnosis (user 2026-06-08) | The instant/never-seen half of REQ-SUBNET-3; pairs with the "post-join address seeding" row (same ceremony-connection seam, seed label + address both sides) — touches SPAKE2, so its own focused milestone | Next session — mint REQ for the trust-pin-label capture + bidirectional intro |
| ~~Subnet-scoped liveness probe (REQ-SUBNET-5 detach gap)~~ **SHIPPED 2026-06-08** (REQ-SUBNET-5 int stage): `subnet status --nodes` showed a DETACHED peer as **online** because liveness fell to `Probe` → a raw transport dial by node-pubkey, which succeeded on the peer's still-bound daemon endpoint (one ALPN `spt-core/net/0` serves all subnets, so the dial was subnet-blind). FIX: a new `ServeProbeRecord` (spt-net `serveprobe`) + daemon `serveprobe` handler/requester ask the peer "serving subnet X?", answered from its own `AttachmentStore::is_detached` ∧ membership (the same signal the pump/responder gate on); the dispatcher classifies it by the `serve_probe` first-line field; `wansend::probe_node_serving` dials (proves reachable) THEN asks, so a detached-but-alive peer reads offline within a cadence. Probe LATENCY had shipped separately 2026-06-07 (`run_bounded` 2.5s + "Checking remote nodes…" notice). int evidence: `dispatcher_serves_a_subnet_serve_probe` (loopback E2E) | M8 criterion-5 acceptance run (HFENDULEAM↔kitsubito, 2026-06-07) | Real liveness-model fix on accepted M7 hybrid-liveness; deferred to keep acceptance moving (user decision 2026-06-07) | DONE (criterion-5 detach-flip now GREEN; real-hardware re-verify rides the next acceptance/deploy pass) |
| **Subnet full-mesh visibility (PLANNED MILESTONE — `SUBNET-MESH-PLAN.md`)**: non-directly-paired members never see each other — a subnet is currently the PAIRING GRAPH, not a mesh (A↔B, C↔B; B offline → A and C invisible; even with B online, B doesn't relay). Root: pairwise TOFU trust (`trust.rs`) + own-rows-only gossip / no transitive relay (`peerloop.rs`, KH 4.10/7.5) + no member roster in the pairing seed transfer. FIX = member-AUTHENTICATED subnet data (seed-MAC and/or per-node signatures) unlocking transitive trust (seed-holder = member = trusted) + transitive gossip relay + roster propagation; MUST re-solve eviction/epoch-lease under relay (supersedes KH 4.10's "no transitive gossip" safety assumption). ADR-level; mint REQ-MESH-* first. Folds in the deferred pairing-time hostname capture + post-join address seeding (same roster/ceremony seam) | A→B→C invisibility test (user 2026-06-08) — confirmed two-gap design limitation, not a code defect | **DESIGN RATIFIED 2026-06-08** (grill): symmetric **seed-proof membership** + **roster-only relay** (rows stay own-authored, no third-party relay → KH 4.10/7.5 PRESERVED, not superseded). Full design = **ADR-0017**; reqs = **REQ-MESH-1..6** (inactive); plan = `SUBNET-MESH-PLAN.md`; glossary = CONTEXT §Pairing & trust. Folds in the pairing-time hostname-capture + post-join address-seeding rows (roster fields). Next: plan-phase over REQ-MESH-* | Next milestone — design done 2026-06-08; build pending plan-phase |
| `xtask debug-converge` — automated convergence watcher for debug rollout (the REQ-UPD-6 first-slice item ADR-0016 names): watch every expected debug-pinned reachable lab node until all report the target version applied, else a per-node timeout table (Offline / StagedAwaitingConsent / BlockedByBrokerResources / Rejected{reason}). Activates REQ-UPD-6's `int` stage. Full build plan: `docs/DEBUG-CONVERGE-PLAN.md` | REQ-UPD-6 first slice (2026-06-06) | The first slice ships manual "Apply and observe" (`DEBUG-ROLLOUT.md`); the watcher's one genuinely new surface is a node status-query wire leg (no-fetch `{channel, applied_version, last_outcome}` over the existing update handshake) — assembly otherwise | First multi-node debug rollout where hand-walking nodes is the bottleneck, or M8 update polish |


DEFERRED.md is a rich-cell table. The fix reuses the entire verify→stage→apply gate — only adds an origin (GitHub) fetch as an alternate source to the P2P pull. Quick check: is an HTTPS client already a usable dep (saw `reqwest` compiling)?


cd "C:/Users/decid/Documents/projects/spt-core" && echo "=== reqwest as direct dep? ==="; grep -rn "reqwest" crates/*/Cargo.toml adapters/*/Cargo.toml 2>/dev/null; echo "=== any http client used in spt/spt-daemon src? ==="; git grep -lE "reqwest|ureq|hyper::client|HttpClient" -- 'crates/spt/src' 'crates/spt-daemon/src' 2>/dev/null | head; echo "=== update channel default ==="; grep -nE "channel|stable" crates/spt-daemon/src/release.rs | grep -iE "default|stable" | head -3

check http client availability


=== reqwest as direct dep? ===
=== any http client used in spt/spt-daemon src? ===
=== update channel default ===
15://!   rejected (a `beta` build cannot be slipped onto a `stable` node);
26://! artifact bytes, so the policy is fully testable and the same code runs in the
48:    /// The release channel (e.g. `stable`, `beta`). Must equal the node's pin.


Confirmed: `reqwest` is only transitive (via iroh) — the daemon has **zero HTTP surface today** (all transport is iroh QUIC). So the origin-fetch leg needs a real HTTPS-client decision. That's a key design note. Add the grounded row to DEFERRED.md:


Edit

| `xtask debug-converge` — automated convergence watcher for debug rollout (the REQ-UPD-6 first-slice item ADR-0016 names): watch every expected debug-pinned reachable lab node until all report the target version applied, else a per-node timeout table (Offline / StagedAwaitingConsent / BlockedByBrokerResources / Rejected{reason}). Activates REQ-UPD-6's `int` stage. Full build plan: `docs/DEBUG-CONVERGE-PLAN.md` | REQ-UPD-6 first slice (2026-06-06) | The first slice ships manual "Apply and observe" (`DEBUG-ROLLOUT.md`); the watcher's one genuinely new surface is a node status-query wire leg (no-fetch `{channel, applied_version, last_outcome}` over the existing update handshake) — assembly otherwise | First multi-node debug rollout where hand-walking nodes is the bottleneck, or M8 update polish |

| `xtask debug-converge` — automated convergence watcher for debug rollout (the REQ-UPD-6 first-slice item ADR-0016 names): watch every expected debug-pinned reachable lab node until all report the target version applied, else a per-node timeout table (Offline / StagedAwaitingConsent / BlockedByBrokerResources / Rejected{reason}). Activates REQ-UPD-6's `int` stage. Full build plan: `docs/DEBUG-CONVERGE-PLAN.md` | REQ-UPD-6 first slice (2026-06-06) | The first slice ships manual "Apply and observe" (`DEBUG-ROLLOUT.md`); the watcher's one genuinely new surface is a node status-query wire leg (no-fetch `{channel, applied_version, last_outcome}` over the existing update handshake) — assembly otherwise | First multi-node debug rollout where hand-walking nodes is the bottleneck, or M8 update polish |
| **Origin-source update bootstrap (`spt update fetch` — pull the latest signed release from GitHub into the existing verify→stage→apply pipeline)**: today update DISCOVERY is **peer-only** (REQ-UPD-1: a daemon pulls offers from roster peers via `request_update`; `propagate.rs`/`peerloop.rs`). There is NO origin fetch, so the **first node in a fleet — or any isolated/solo node — cannot get a new release without a peer that already staged it** (the fleet converges by gossip, but only *after* a maintainer seeds the first node via the release-publish path). The installer DOES fetch from GitHub (`github.com/SaberMage/spt-releases/releases/latest/download/<asset>` + `SHA256SUMS`, `installer/install.sh:57-73`) but only at install time, outside the daemon's signed-release gate, and never re-runs. FIX = a new **origin-fetch leg** that downloads the per-platform artifact + its `<asset>.release.json` (the `SignedRelease` metadata) from the release origin and feeds them through the **SAME** `plan_verified` gate (`verify_metadata` two-key signature/channel/expiry/rollback + `verify_artifact` SHA-256, `update.rs:157`/`release.rs`), then `cache.stage()` — after which the EXISTING consent-notif / `spt update apply` flow is unchanged. So it reuses ~all the machinery; it only adds an HTTPS GET from the origin as an alternate SOURCE to the P2P `request_update` pull. Surface: `spt update fetch [--channel <ch>] [--tag vX.Y.Z]` (default: latest on the node's pinned channel); optionally a daemon-config `origin_check` cadence so a lone node self-bootstraps alongside P2P, then re-propagates to peers normally. **Key design note:** the daemon has **zero HTTP surface today** — all transport is iroh QUIC; `reqwest` is only a transitive dep (via iroh), not reachable. This leg needs a deliberate HTTPS-client decision (add `reqwest`/a minimal client as a direct dep, vs shell out to `curl`/`Invoke-WebRequest` like the installer — fragile). Honor the channel pin (`release-keys.json`) + the monotonic rollback floor; configurable repo (mirror `SPT_INSTALL_REPO`). The two-key signed-release anchor keeps the GitHub transport **untrusted-but-verified** (strictly stronger than the installer's HTTPS+SHA256SUMS-only first fetch). Mint **REQ-UPD-7** (origin-source fetch) first. | Update subsystem v1 (ADR-0016, REQ-UPD family) — P2P-pull only by design | v1 update flow is peer-propagation by design (seed one node, the fleet converges by gossip); origin-fetch is additive and the maintainer seed path covered bootstrap so far. No HTTP client is wired into the daemon yet | First isolated / first-in-fleet node that must update with no peer to pull from; or making `spt update` self-sufficient for solo / single-node users (user flagged the gap 2026-06-08) |


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

| `xtask debug-converge` — automated convergence watcher for debug rollout (the REQ-UPD-6 first-slice item ADR-0016 names): watch every expected debug-pinned reachable lab node until all report the target version applied, else a per-node timeout table (Offline / StagedAwaitingConsent / BlockedByBrokerResources / Rejected{reason}). Activates REQ-UPD-6's `int` stage. Full build plan: `docs/DEBUG-CONVERGE-PLAN.md` | REQ-UPD-6 first slice (2026-06-06) | The first slice ships manual "Apply and observe" (`DEBUG-ROLLOUT.md`); the watcher's one genuinely new surface is a node status-query wire leg (no-fetch `{channel, applied_version, last_outcome}` over the existing update handshake) — assembly otherwise | First multi-node debug rollout where hand-walking nodes is the bottleneck, or M8 update polish |
| **Origin-source update bootstrap (`spt update fetch` — pull the latest signed release from GitHub into the existing verify→stage→apply pipeline)**: today update DISCOVERY is **peer-only** (REQ-UPD-1: a daemon pulls offers from roster peers via `request_update`; `propagate.rs`/`peerloop.rs`). There is NO origin fetch, so the **first node in a fleet — or any isolated/solo node — cannot get a new release without a peer that already staged it** (the fleet converges by gossip, but only *after* a maintainer seeds the first node via the release-publish path). The installer DOES fetch from GitHub (`github.com/SaberMage/spt-releases/releases/latest/download/<asset>` + `SHA256SUMS`, `installer/install.sh:57-73`) but only at install time, outside the daemon's signed-release gate, and never re-runs. FIX = a new **origin-fetch leg** that downloads the per-platform artifact + its `<asset>.release.json` (the `SignedRelease` metadata) from the release origin and feeds them through the **SAME** `plan_verified` gate (`verify_metadata` two-key signature/channel/expiry/rollback + `verify_artifact` SHA-256, `update.rs:157`/`release.rs`), then `cache.stage()` — after which the EXISTING consent-notif / `spt update apply` flow is unchanged. So it reuses ~all the machinery; it only adds an HTTPS GET from the origin as an alternate SOURCE to the P2P `request_update` pull. Surface: `spt update fetch [--channel <ch>] [--tag vX.Y.Z]` (default: latest on the node's pinned channel); optionally a daemon-config `origin_check` cadence so a lone node self-bootstraps alongside P2P, then re-propagates to peers normally. **Key design note:** the daemon has **zero HTTP surface today** — all transport is iroh QUIC; `reqwest` is only a transitive dep (via iroh), not reachable. This leg needs a deliberate HTTPS-client decision (add `reqwest`/a minimal client as a direct dep, vs shell out to `curl`/`Invoke-WebRequest` like the installer — fragile). Honor the channel pin (`release-keys.json`) + the monotonic rollback floor; configurable repo (mirror `SPT_INSTALL_REPO`). The two-key signed-release anchor keeps the GitHub transport **untrusted-but-verified** (strictly stronger than the installer's HTTPS+SHA256SUMS-only first fetch). Mint **REQ-UPD-7** (origin-source fetch) first. | Update subsystem v1 (ADR-0016, REQ-UPD family) — P2P-pull only by design | v1 update flow is peer-propagation by design (seed one node, the fleet converges by gossip); origin-fetch is additive and the maintainer seed path covered bootstrap so far. No HTTP client is wired into the daemon yet | First isolated / first-in-fleet node that must update with no peer to pull from; or making `spt update` self-sufficient for solo / single-node users (user flagged the gap 2026-06-08) |

| ~~Subnet-scoped liveness probe (REQ-SUBNET-5 detach gap)~~ **SHIPPED 2026-06-08** (REQ-SUBNET-5 int stage): `subnet status --nodes` showed a DETACHED peer as **online** because liveness fell to `Probe` → a raw transport dial by node-pubkey, which succeeded on the peer's still-bound daemon endpoint (one ALPN `spt-core/net/0` serves all subnets, so the dial was subnet-blind). FIX: a new `ServeProbeRecord` (spt-net `serveprobe`) + daemon `serveprobe` handler/requester ask the peer "serving subnet X?", answered from its own `AttachmentStore::is_detached` ∧ membership (the same signal the pump/responder gate on); the dispatcher classifies it by the `serve_probe` first-line field; `wansend::probe_node_serving` dials (proves reachable) THEN asks, so a detached-but-alive peer reads offline within a cadence. Probe LATENCY had shipped separately 2026-06-07 (`run_bounded` 2.5s + "Checking remote nodes…" notice). int evidence: `dispatcher_serves_a_subnet_serve_probe` (loopback E2E) | M8 criterion-5 acceptance run (HFENDULEAM↔kitsubito, 2026-06-07) | Real liveness-model fix on accepted M7 hybrid-liveness; deferred to keep acceptance moving (user decision 2026-06-07) | DONE (criterion-5 detach-flip now GREEN; real-hardware re-verify rides the next acceptance/deploy pass) |

| **Subnet full-mesh visibility (PLANNED MILESTONE — `SUBNET-MESH-PLAN.md`)**: non-directly-paired members never see each other — a subnet is currently the PAIRING GRAPH, not a mesh (A↔B, C↔B; B offline → A and C invisible; even with B online, B doesn't relay). Root: pairwise TOFU trust (`trust.rs`) + own-rows-only gossip / no transitive relay (`peerloop.rs`, KH 4.10/7.5) + no member roster in the pairing seed transfer. FIX = member-AUTHENTICATED subnet data (seed-MAC and/or per-node signatures) unlocking transitive trust (seed-holder = member = trusted) + transitive gossip relay + roster propagation; MUST re-solve eviction/epoch-lease under relay (supersedes KH 4.10's "no transitive gossip" safety assumption). ADR-level; mint REQ-MESH-* first. Folds in the deferred pairing-time hostname capture + post-join address seeding (same roster/ceremony seam) | A→B→C invisibility test (user 2026-06-08) — confirmed two-gap design limitation, not a code defect | **DESIGN RATIFIED 2026-06-08** (grill): symmetric **seed-proof membership** + **roster-only relay** (rows stay own-authored, no third-party relay → KH 4.10/7.5 PRESERVED, not superseded). Full design = **ADR-0017**; reqs = **REQ-MESH-1..6** (inactive); plan = `SUBNET-MESH-PLAN.md`; glossary = CONTEXT §Pairing & trust. Folds in the pairing-time hostname-capture + post-join address-seeding rows (roster fields). Next: plan-phase over REQ-MESH-* | Next milestone — design done 2026-06-08; build pending plan-phase |

+| **Origin-source update bootstrap (`spt update fetch` — pull the latest signed release from GitHub into the existing verify→stage→apply pipeline)**: today update DISCOVERY is **peer-only** (REQ-UPD-1: a daemon pulls offers from roster peers via `request_update`; `propagate.rs`/`peerloop.rs`). There is NO origin fetch, so the **first node in a fleet — or any isolated/solo node — cannot get a new release without a peer that already staged it** (the fleet converges by gossip, but only *after* a maintainer seeds the first node via the release-publish path). The installer DOES fetch from GitHub (`github.com/SaberMage/spt-releases/releases/latest/download/<asset>` + `SHA256SUMS`, `installer/install.sh:57-73`) but only at install time, outside the daemon's signed-release gate, and never re-runs. FIX = a new **origin-fetch leg** that downloads the per-platform artifact + its `<asset>.release.json` (the `SignedRelease` metadata) from the release origin and feeds them through the **SAME** `plan_verified` gate (`verify_metadata` two-key signature/channel/expiry/rollback + `verify_artifact` SHA-256, `update.rs:157`/`release.rs`), then `cache.stage()` — after which the EXISTING consent-notif / `spt update apply` flow is unchanged. So it reuses ~all the machinery; it only adds an HTTPS GET from the origin as an alternate SOURCE to the P2P `request_update` pull. Surface: `spt update fetch [--channel <ch>] [--tag vX.Y.Z]` (default: latest on the node's pinned channel); optionally a daemon-config `origin_check` cadence so a lone node self-bootstraps alongside P2P, then re-propagates to peers normally. **Key design note:** the daemon has **zero HTTP surface today** — all transport is iroh QUIC; `reqwest` is only a transitive dep (via iroh), not reachable. This leg needs a deliberate HTTPS-client decision (add `reqwest`/a minimal client as a direct dep, vs shell out to `curl`/`Invoke-WebRequest` like the installer — fragile). Honor the channel pin (`release-keys.json`) + the monotonic rollback floor; configurable repo (mirror `SPT_INSTALL_REPO`). The two-key signed-release anchor keeps the GitHub transport **untrusted-but-verified** (strictly stronger than the installer's HTTPS+SHA256SUMS-only first fetch). Mint **REQ-UPD-7** (origin-source fetch) first. | Update subsystem v1 (ADR-0016, REQ-UPD family) — P2P-pull only by design | v1 update flow is peer-propagation by design (seed one node, the fleet converges by gossip); origin-fetch is additive and the maintainer seed path covered bootstrap so far. No HTTP client is wired into the daemon yet | First isolated / first-in-fleet node that must update with no peer to pull from; or making `spt update` self-sufficient for solo / single-node users (user flagged the gap 2026-06-08) |


Added. Commit (docs-only) on dev-freeform, FF main, push both:


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && git checkout dev-freeform 2>&1 | tail -1 && git add docs/DEFERRED.md && git commit -q -F - <<'EOF'
docs(deferred): origin-source update bootstrap (`spt update fetch` from GitHub)

Record the gap the user flagged 2026-06-08: update discovery is peer-only, so a
first-in-fleet / isolated node can't get a release without a peer that already
staged it. Planned fix = an origin-fetch leg that pulls the signed release from
spt-releases and routes it through the EXISTING plan_verified→stage→consent→apply
pipeline (reuses ~all machinery). Key note: the daemon has no HTTP surface today
(iroh QUIC only; reqwest transitive). Mint REQ-UPD-7 when building.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git checkout main 2>&1 | tail -1 && git merge --ff-only dev-freeform 2>&1 | tail -1 && git push origin main 2>&1 | tail -1 && git push origin dev-freeform 2>&1 | tail -1 && git log --oneline -1

commit deferred + FF main + push


Switched to branch 'dev-freeform'
Switched to branch 'main'
 1 file changed, 1 insertion(+)
   a81cf31..9c825cf  main -> main
   a81cf31..9c825cf  dev-freeform -> dev-freeform
9c825cf docs(deferred): origin-source update bootstrap (`spt update fetch` from GitHub)


Recorded + pushed (`9c825cf`, main + dev-freeform).

## Planned: `spt update fetch` — origin-source bootstrap

**Gap:** update discovery is peer-only (`request_update` pulls from roster peers). First-in-fleet or isolated nodes can't update without a peer that already staged it — the fleet converges by gossip, but only *after* a maintainer seeds node zero.

**Fix (reuses ~everything):** a new origin-fetch leg that downloads, from `github.com/SaberMage/spt-releases/releases/latest/download/`:
- the per-platform artifact (`spt-x86_64-{linux,windows.exe}`)
- its `<asset>.release.json` (the `SignedRelease` metadata)

→ feeds both through the **same** `plan_verified` gate (two-key signature + channel + rollback floor + SHA-256) → `cache.stage()`. After that, the existing consent-notif / `spt update apply` flow is **unchanged**. Only adds an HTTPS source alongside the P2P pull.

**Surface:** `spt update fetch [--channel <ch>] [--tag vX.Y.Z]`; optional daemon `origin_check` cadence so a lone node self-bootstraps then re-propagates to peers.

**Key catch (in the doc):** the daemon has **zero HTTP today** — all transport is iroh QUIC, `reqwest` is only transitive. This needs a deliberate HTTPS-client decision (add a direct dep vs shell out to curl). The two-key signed-release anchor keeps the GitHub transport untrusted-but-verified — strictly stronger than the installer's HTTPS+SHA256SUMS-only fetch.

Mint **REQ-UPD-7** when building. Trigger to revisit: first isolated node needing update, or making `spt update` self-sufficient for solo users.


<command-message>diagnose</command-message>
<command-name>/diagnose</command-name>
<command-args>this happened on kitsubito after restarting the machine post-full install. is it misreporting, or an actual issue? [Image #1]</command-args>


Base directory for this skill: C:\Users\decid\.ccs\instances\bigscreen\skills\diagnose

# Diagnose

A discipline for hard bugs. Skip phases only when explicitly justified.

When exploring the codebase, use the project's domain glossary to get a clear mental model of the relevant modules, and check ADRs in the area you're touching.

## Phase 1 — Build a feedback loop

**This is the skill.** Everything else is mechanical. If you have a fast, deterministic, agent-runnable pass/fail signal for the bug, you will find the cause — bisection, hypothesis-testing, and instrumentation all just consume that signal. If you don't have one, no amount of staring at code will save you.

Spend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.**

### Ways to construct one — try them in roughly this order

1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e.
2. **Curl / HTTP script** against a running dev server.
3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot.
4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network.
5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation.
6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call.
7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode.
8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it.
9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs.
10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you.

Build the right feedback loop, and the bug is 90% fixed.

### Iterate on the loop itself

Treat the loop as a product. Once you have _a_ loop, ask:

- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.)
- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".)
- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.)

A 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower.

### Non-deterministic bugs

The goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable.

### When you genuinely cannot build a loop

Stop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop.

Do not proceed to Phase 2 until you have a loop you believe in.

## Phase 2 — Reproduce

Run the loop. Watch the bug appear.

Confirm:

- [ ] The loop produces the failure mode the **user** described — not a different failure that happens to be nearby. Wrong bug = wrong fix.
- [ ] The failure is reproducible across multiple runs (or, for non-deterministic bugs, reproducible at a high enough rate to debug against).
- [ ] You have captured the exact symptom (error message, wrong output, slow timing) so later phases can verify the fix actually addresses it.

Do not proceed until you reproduce the bug.

## Phase 3 — Hypothesise

Generate **3–5 ranked hypotheses** before testing any of them. Single-hypothesis generation anchors on the first plausible idea.

Each hypothesis must be **falsifiable**: state the prediction it makes.

> Format: "If <X> is the cause, then <changing Y> will make the bug disappear / <changing Z> will make it worse."

If you cannot state the prediction, the hypothesis is a vibe — discard or sharpen it.

**Show the ranked list to the user before testing.** They often have domain knowledge that re-ranks instantly ("we just deployed a change to #3"), or know hypotheses they've already ruled out. Cheap checkpoint, big time saver. Don't block on it — proceed with your ranking if the user is AFK.

## Phase 4 — Instrument

Each probe must map to a specific prediction from Phase 3. **Change one variable at a time.**

Tool preference:

1. **Debugger / REPL inspection** if the env supports it. One breakpoint beats ten logs.
2. **Targeted logs** at the boundaries that distinguish hypotheses.
3. Never "log everything and grep".

**Tag every debug log** with a unique prefix, e.g. `[DEBUG-a4f2]`. Cleanup at the end becomes a single grep. Untagged logs survive; tagged logs die.

**Perf branch.** For performance regressions, logs are usually wrong. Instead: establish a baseline measurement (timing harness, `performance.now()`, profiler, query plan), then bisect. Measure first, fix second.

## Phase 5 — Fix + regression test

Write the regression test **before the fix** — but only if there is a **correct seam** for it.

A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site. If the only available seam is too shallow (single-caller test when the bug needs multiple callers, unit test that can't replicate the chain that triggered the bug), a regression test there gives false confidence.

**If no correct seam exists, that itself is the finding.** Note it. The codebase architecture is preventing the bug from being locked down. Flag this for the next phase.

If a correct seam exists:

1. Turn the minimised repro into a failing test at that seam.
2. Watch it fail.
3. Apply the fix.
4. Watch it pass.
5. Re-run the Phase 1 feedback loop against the original (un-minimised) scenario.

## Phase 6 — Cleanup + post-mortem

Required before declaring done:

- [ ] Original repro no longer reproduces (re-run the Phase 1 loop)
- [ ] Regression test passes (or absence of seam is documented)
- [ ] All `[DEBUG-...]` instrumentation removed (`grep` the prefix)
- [ ] Throwaway prototypes deleted (or moved to a clearly-marked debug location)
- [ ] The hypothesis that turned out correct is stated in the commit / PR message — so the next debugger learns

**Then ask: what would have prevented this bug?** If the answer involves architectural change (no good test seam, tangled callers, hidden coupling) hand off to the `/improve-codebase-architecture` skill with the specifics. Make the recommendation **after** the fix is in, not before — you have more information now than when you started.

ARGUMENTS: this happened on kitsubito after restarting the machine post-full install. is it misreporting, or an actual issue? [Image #1]


[Image: source: C:\Users\decid\Documents\ShareX\Screenshots\2026-06\Moonlight_LvQUXDxqr8.png]


/diagnose this happened on kitsubito after restarting the machine post-full install. is it misreporting, or an actual issue? [Image #1]


Caveman off for this — it's a multi-step diagnosis with a live-system safety angle.

Reading the screenshot: daemon running (pid 1359), `managed-by: systemd --user (active)` (the new status works), but `peer pump: STALLED (last tick 352s ago)` and `subnets: (none — standalone)`. The node has **no subnets** after a fresh install. My first instinct: the pump heartbeat may be gated on having subnets/peers to advertise, so a standalone node would always look STALLED — a false alarm. But I need to verify against the actual pump loop, not guess.

Let me build the feedback loop: inspect the pump heartbeat write in code + probe the live box (I have SSH).


cd "C:/Users/decid/Documents/projects/spt-core" && echo "=== heartbeat write sites ==="; git grep -nE "pump_heartbeat|write_pump_heartbeat|heartbeat" -- crates/spt-daemon/src/peerloop.rs | head; echo "=== read + stale calc ==="; git grep -nE "read_pump_heartbeat|pump_heartbeat_path|registry_pump_period" -- crates/spt-daemon/src/peerloop.rs crates/spt-daemon/src/lib.rs crates/spt-daemon/src/daemon.rs | head

find pump heartbeat write/read sites


=== heartbeat write sites ===
crates/spt-daemon/src/peerloop.rs:77:/// warning reads 3-cadence staleness, so a few seconds of heartbeat
crates/spt-daemon/src/peerloop.rs:144:    /// The pump's last-tick heartbeat (M8 decision 23, REQ-DAEMON-5):
crates/spt-daemon/src/peerloop.rs:147:    /// ([`crate::endpoint::pump_heartbeat_path`]).
crates/spt-daemon/src/peerloop.rs:148:    pub heartbeat: PathBuf,
crates/spt-daemon/src/peerloop.rs:173:            heartbeat: crate::endpoint::pump_heartbeat_path(),
crates/spt-daemon/src/peerloop.rs:318:        // Liveness heartbeat (M8 decision 23, REQ-DAEMON-5): written while
crates/spt-daemon/src/peerloop.rs:325:            write_heartbeat(&paths.heartbeat);
crates/spt-daemon/src/peerloop.rs:719:/// Write the pump's last-tick heartbeat (epoch ms) — the liveness fact
crates/spt-daemon/src/peerloop.rs:722:/// pump (and the reader treats an absent/unreadable file as "no heartbeat
crates/spt-daemon/src/peerloop.rs:725:fn write_heartbeat(path: &std::path::Path) {
=== read + stale calc ===
crates/spt-daemon/src/lib.rs:182:    broker_socket_name, daemon_pid_path, digest_socket_name, pump_heartbeat_path,
crates/spt-daemon/src/lib.rs:183:    read_pump_heartbeat, seed_socket_name,
crates/spt-daemon/src/peerloop.rs:147:    /// ([`crate::endpoint::pump_heartbeat_path`]).
crates/spt-daemon/src/peerloop.rs:173:            heartbeat: crate::endpoint::pump_heartbeat_path(),
crates/spt-daemon/src/peerloop.rs:233:            registry: cfg.registry_pump_period,


Read the pump loop structure around the heartbeat write (300–350):