# M4-D5 — cross-node message + remote-drive + access control (JIT plan)

**Status:** D5a complete (`caab433`), D5b complete (`4ea3b37`), D5c complete (this change).
D5d next. D4 complete (broker owns endpoint/conns/streams; gapless exactly-once
stream survival; PresenceChannel seam; registry replication over the wire — CI-green at
`017d103`). D5 puts user-visible function on that substrate: send a message to an agent on
another node, drive a running instance from another node, and gate who may do either
(ADR-0009 node-tier).

## Goal

Drive an agent on machine A from machine B (loopback two-daemon in CI; two-host at D9):
a WAN message reaches a remote endpoint's spool/stream exactly-once; a byte-stream terminal
attach to a remote instance works as a viewport (compute + files stay remote); the driven
agent can detect it is network-driven and from which node; an endpoint with a whitelist
refuses unsolicited inbound from a non-listed origin node. Reqs: REQ-NET-1 (WAN-msg
aspects), REQ-INST-8, REQ-REACH-1, REQ-SEC-1.

## Decisions (locked)

- **WAN messages ride broker QUIC streams as NDJSON** — the D4d `RegistryUpdate`/`LineDecoder`
  pattern (`spt-net/src/net/replicate.rs`) extended with a `WanMessage` record
  `{ target, from, body, op_id }`. Receiver-side the record funnels into the **existing
  local path** `spt_msg::deliver(target, from, body, owlery)` — TCP-first/spool-fallback,
  deferred-row semantics (1.4/4.4) and codec ordering (4.1) all inherited, not rebuilt.
- **`origin_node` is transport truth, never payload.** The whitelist subject is the QUIC
  connection's authenticated remote node id (iroh `EndpointId` == Ed25519 node pubkey,
  REQ-NET-1's identity binding) — a `from`/origin field inside the NDJSON record is
  display metadata only and MUST NOT feed the gate. Register this as a new hazard
  (KNOWN-HAZARDS §7.5 + `REQ-HAZARD-WAN-ORIGIN-AUTH`, rule 3) with a spoof negative test.
- **Exactly-once inherits D4 discipline** (REQ-HAZARD-RESTART-IDEMPOTENT): sends ride the
  journaled `KIND_NET_STREAM_SEND` path (`op_id` dedup at the effect); the receiver dedups
  `op_id` before spool/deliver so redelivery after a restart can't double-deliver (4.5's
  wire-side cousin).
- **Remote attach = OutputLog over the wire.** The broker-side `OutputLog` (seq'd ring,
  cursor resume) is already the gapless surface; D5b pumps its chunks over a broker QUIC
  stream (NDJSON control + base64 chunk records), input flows back over the same stream
  into the existing `KIND_INPUT` path. Cursor-resume on reattach gives brain-restart
  survival on both ends for free. Viewport only — no file or exec surface (REQ-REACH-2
  deferred).
- **Detection is a daemon fact, not an agent guess** (REQ-REACH-1): a net-attached surface
  registers `(session, origin_node)` in the daemon; exposed as an instance-status field +
  queryable over the `api` surface. The "node X is driving" light notification becomes a
  notif-producer when D8 lands — D5 emits the event seam only.
- **File transfer is substrate-level, progress-queryable** (CONTEXT §file-transfer
  progress): chunked NDJSON-framed records with `op_id`, both ends keep
  `{ bytes_done, bytes_total }` queryable mid-flight via a brain frame. v1 surface is
  to/from the driving node (the off-node reach-back case), shaped so the Shell text+file
  channel inherits it.
- **Whitelist = trust-store plumbing, opposite polarity** (ADR-0009): `AccessStore` next to
  `spt-store/src/trust.rs` (security material near the trust store, NOT the context repo);
  per-endpoint `{ nodes: [pubkey], users: [] /* inert until cross-user */ }`; **absent =
  open**, present = restrict. Enforced at the target node on the **unsolicited** inbound
  class only: outbound unrestricted; inbound correlated to the endpoint's own prior
  outbound (`__REPLY_TO__`) allowed from any visible node; same-node always allowed (the
  gate sits on the net-inbound path only, so the local path never sees it). Gate order:
  visibility → whitelist → grants (outer gate before grants).
- **Gate hook lands with D5a, enforcement with D5d:** the net-inbound dispatch calls one
  `access_check(endpoint, origin_node, class)` seam from day one (default-open), so D5b/c
  inbound paths are born gated and D5d only fills in store + reply-correlation + CLI.

## Pieces (build order)

1. **D5a — WAN message delivery** (REQ-NET-1 WAN-msg). `WanMessage` NDJSON over broker
   streams; sender resolves `id[@node]` via `SubnetRegistry` (D3c policy: refuse ambiguous);
   receiver dedups `op_id` → `spt_msg::deliver` → spool/TCP. `access_check` hook stubbed
   default-open. `spt send` grows the off-node leg transparently. Loopback test: message
   from daemon A lands in B's perch spool; replay after receiver brain restart → no dup;
   spoofed payload-origin ignored (hazard test).
2. **D5b — remote terminal attach** (REQ-INST-8). Attach-request record over a broker
   stream → target daemon subscribes its own `OutputLog` from cursor, pumps chunk records
   back; input records → `KIND_INPUT`. `spt attach <id>@<node>` CLI leg. Loopback test:
   drive a real PTY session cross-daemon; kill/restart a brain mid-attach → cursor resume,
   no gap, no dup.
3. **D5c — remote-drive detection + file transfer** (REQ-REACH-1). Net-attached surface
   registers `(session, origin_node)`; instance status + `api` query expose it. Chunked
   file transfer to/from the driving node, `op_id`-idempotent, progress queryable from
   both ends. Handoff seam (`git pull` + fresh-with-preload resume; remote goes dormant)
   documented as the D6-consumed shape — detection + transfer only here.
4. **D5d — endpoint access whitelist** (REQ-SEC-1, ADR-0009). `AccessStore` (+ inert
   `users` field); reply-vs-new classification (track recent outbound per endpoint for
   `__REPLY_TO__` correlation); `access_check` enforces origin-node gate; minimal
   `spt access allow|revoke|list` surface. Tests: default-empty open; whitelisted node
   passes; non-listed unsolicited refused; reply from non-listed node passes (stateful
   firewall); same-node always passes; origin spoof via payload fails.
5. **Fault-matrix rows** (maintenance rule): WAN-msg path, remote attach, file transfer
   each get a row in `docs/FAULT-MATRIX.md` in the same change that lands them.

## Requirement activation (rule 5)

- **REQ-INST-8** → activate `[impl, unit]` at D5b.
- **REQ-REACH-1** → activate `[impl, unit]` at D5c.
- **REQ-SEC-1** → activate `[impl, unit]` at D5d.
- **REQ-NET-1** stays `[impl, unit]` (new evidence tags on the WAN-msg path); `int` remains
  the D9 two-host E2E.
- **New (rule 3): `REQ-HAZARD-WAN-ORIGIN-AUTH`** — origin-node authenticated from transport
  identity, never payload — register at D5a with doc+impl+unit (the spoof negative test).

## THIS slice — D5a (WAN message delivery)

- `WanMessage` record + `LineDecoder` reuse in `spt-net` (or sibling module to
  `replicate.rs`); broker net-inbound dispatch routes message records → dedup → deliver.
- Sender leg: resolve via registry (local-first, refuse-ambiguous, `@node` pin) → dial →
  journaled stream send.
- `access_check` seam (default-open) on the unsolicited-inbound dispatch.
- KNOWN-HAZARDS §7.5 + `REQ-HAZARD-WAN-ORIGIN-AUTH` registered and covered.
- Tests (`crates/spt-daemon/tests/wanmsg.rs`, hermetic two-daemon harness from
  `replicate.rs`): cross-daemon spool landing; receiver-restart no-dup; payload-origin
  spoof ignored; ambiguous bare id refused.

## Handoff seam — the D6-consumed shape (documented at D5c, built at D6)

<!-- [doc->REQ-REACH-1] -->
The smooth remote-control → local handoff (CONTEXT §Remote-control vs local operation)
composes from three pieces, **none of which is new machinery**:

1. **Files:** `git pull` on the local node — file state moves through the repo, never
   through the attach viewport (compute + files stayed remote the whole time; the D5c file
   channel is for individual reach-back payloads, not repo migration).
2. **Mind:** the **fresh-with-preload resume seam** — the local instance starts (or wakes)
   preloaded with the endpoint's latest synced Psyche context. D6a's P2P replication is
   what makes "latest" hold cross-node; the preload call shape is the existing
   resume-with-context path the daemon already drives locally.
3. **Remote goes dormant, no teardown:** the remote instance simply stops being driven —
   its `driven_by` marker clears when the viewport ends (D5c detection), it keeps its
   perch/spool/history, and the cross-instance context-freshness feed (D6) catches it up
   if it is ever driven again. Nothing is destroyed; dormancy is the default rest state
   (CONTEXT §instances).

D5c's deliverables are the two ingredients the seam needs from this slice: **detection**
(the daemon-stamped `driven_by` fact + the `spt api driven-by` query) and **file
transfer** (op-id-addressable, progress-queryable). D6 wires 2 and the freshness feed.

## NOT in D5 (this milestone)

- Psyche-context sync + the full remote→local handoff (fresh-with-preload) — D6.
- Remote command execution (REQ-REACH-2), instantiate-anywhere + consent UX — deferred
  (seams only).
- User-tier whitelist (ADR-0006 cross-user) — schema reserves `users`, inert.
- Notif primitive ("node X is driving" prompt UX) — D8; D5 emits the event seam.
- Two-host integration — D9 (loopback shapes only here).

## Conventions (carried)

- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence. `traceable-reqs
  check` from repo root before done. Local clippy can't see `#[cfg]` arms — Linux CI clippy
  `-D warnings` is the real gate. Push each slice → watch green before next.
  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
